diff --git a/public/blog/broker-pattern.webp b/public/blog/broker-pattern.webp
new file mode 100644
index 0000000..111d894
Binary files /dev/null and b/public/blog/broker-pattern.webp differ
diff --git a/public/diagrams/broker-pattern.svg b/public/diagrams/broker-pattern.svg
new file mode 100644
index 0000000..6507f7a
--- /dev/null
+++ b/public/diagrams/broker-pattern.svg
@@ -0,0 +1,60 @@
+
diff --git a/src/components/DossierThumb.astro b/src/components/DossierThumb.astro
index 8f01210..f4722d5 100644
--- a/src/components/DossierThumb.astro
+++ b/src/components/DossierThumb.astro
@@ -37,6 +37,58 @@ const { kind = "generic" } = Astro.props;
>
+ ) : kind === "gpu" ? (
+ <>
+ {/* GPU die with passthrough lanes feeding two edge nodes */}
+
+
+
+
+
+
+ GPU
+
+
+
+
+
+
+ >
+ ) : kind === "globe" ? (
+ <>
+ {/* globe of regions — a modernised multi-region estate */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : kind === "network-fleet" ? (
+ <>
+ {/* fleet of switches, single-pane managed */}
+
+ NCM
+
+
+
+
+
+
+
+
+
+
+
+
+ >
) : (
<>
{/* pipeline — commits flowing through gates to a fleet */}
diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro
index 39357ae..aab58b1 100644
--- a/src/components/ProjectCard.astro
+++ b/src/components/ProjectCard.astro
@@ -79,12 +79,26 @@ const dossierNo = String(index).padStart(3, "0");
}
.dossier__body {
+ position: relative;
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
+ /* a faint on-theme grid fills the idle space above the chips/meta (the
+ margin-top:auto gap on shorter "argument" text) instead of a flat void */
+ .dossier__body::before {
+ content: "";
+ position: absolute;
+ inset: 68px 24px 64px;
+ background-image:
+ linear-gradient(var(--grid-line) 1px, transparent 1px),
+ linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
+ background-size: 22px 22px;
+ mask-image: linear-gradient(180deg, black, transparent 85%);
+ pointer-events: none;
+ }
.dossier__title {
margin: 0;
font-family: var(--font-display);
diff --git a/src/content/blog/broker-pattern.md b/src/content/blog/broker-pattern.md
new file mode 100644
index 0000000..85cbaa9
--- /dev/null
+++ b/src/content/blog/broker-pattern.md
@@ -0,0 +1,74 @@
+---
+title: "An agent should never hold the key it's using"
+date: 2026-07-03
+summary: "You want an AI agent that can actually do things — call APIs, touch real data. You also don't fully trust it. The resolution isn't a better sandbox; it's making sure the agent never possesses a credential at all. A broker holds the keys, mints short-lived capabilities, and gates every write behind a human. Here's the pattern."
+tags: ["security", "ai-agents", "architecture", "zero-trust", "homelab"]
+draft: false
+hero: "/blog/broker-pattern.webp"
+heroAlt: "An untrusted agent reaches through a sealed gate to ask; on the far side, a guardian holds a ring of keys the agent can never touch."
+---
+
+
+The previous post put untrusted code in a hardware-isolated VM, and ended on a caveat: isolation contains an
+*escape*, but it does nothing about an agent **misusing a tool it was legitimately given**. If you hand an AI
+agent a database credential so it can be useful, a single bad decision — a prompt injection, a confused chain
+of reasoning — spends that credential. The sandbox did its job perfectly and you still got robbed, through the
+front door you built.
+
+So the real question isn't "how do I isolate the agent?" It's "how does the agent get work done *without ever
+holding a key*?"
+
+## The agent holds a capability, not a credential
+
+The pattern is to put every credential, every tool, and every model endpoint **behind a broker**, and give the
+agent only a *capability to ask*. The agent never sees a token. It calls the broker; the broker holds the real
+credential, decides whether the request is allowed, and — if it is — performs the action itself and returns the
+result. The key never leaves the broker.
+
+That one inversion changes the threat model completely. A fully compromised agent can now do exactly one thing:
+**ask**. And asking is answered by something it can't reach, can't impersonate, and can't bypass.
+
+
+
+
+## Two clusters, two brokers
+
+The trust boundary is physical, not just logical. The **untrusted executor** (the Kata sandboxes) lives on one
+cluster; the **trusted tier** (the brokers, the model gateway, the real credentials) lives on a *separate*
+cluster. A total compromise of the executor still can't reach the brokers' secrets except across a policed
+network link — there's no shared kernel, no shared API server, nothing to pivot through.
+
+On the trusted side there are two brokers, deliberately split:
+
+- A **credential broker** validates the sandbox's identity (a short-lived, signed token unique to the task) and
+ mints a **capability** — a cryptographically signed, scoped, single-use, expiring grant. Not a credential. A
+ *permission to ask for one specific thing*.
+- A **tool broker** takes that capability, verifies the signature, the scope, and the one-time nonce, and only
+ then runs the requested tool — using a credential *it* holds. The result comes back; the credential doesn't.
+
+## A human gates every write
+
+Reads are one thing. For anything that *changes the world* — creating, deleting, sending — the broker doesn't
+just decide on policy. It **stages** the action and pings a human: an Approve/Deny prompt on my phone, carrying
+a one-time token bound to the exact task, method, and arguments. Tap approve and it executes; tap deny, or
+ignore it, and it doesn't. The gate is **fail-closed**: a timeout is a denial, a replayed token is rejected, an
+unknown method is rejected. The default, always, is *no*.
+
+The load-bearing principle underneath all of it: **enforcement that has to survive a compromised agent lives at
+the broker, never at the agent or the orchestrator.** Approval, scope, budgets, write-authority — none of it
+lives anywhere the agent's reasoning can touch. The agent can be wrong, jailbroken, or outright hostile, and the
+worst-case is still just *a request that gets refused*.
+
+## Useful and safe at the same time
+
+It's tempting to think you have to choose: give the agent real power and accept real risk, or lock it down so
+hard it can't do anything. The broker pattern is how you get both. The agent is *useful* — it can call real
+tools against real systems. It's *safe* — it never holds a key, every write waits on a human, and the moment
+something goes wrong, the blast radius is a denied request, not a spent credential.
+
+Run agents like you'd run any other untrusted input: assume it's compromised, and make sure that assumption is
+*boring*.
+
+*Live as the trusted tier of a two-cluster AI-agent platform: a credential broker and a tool broker holding the
+keys, a phone-based human-approval gate on every write, and an agent that — by construction — never sees a
+secret.*
diff --git a/src/content/blog/init-gating-gpu-readiness.md b/src/content/blog/init-gating-gpu-readiness.md
index 5641819..700887c 100644
--- a/src/content/blog/init-gating-gpu-readiness.md
+++ b/src/content/blog/init-gating-gpu-readiness.md
@@ -8,18 +8,20 @@ heroAlt: "A GPU glows behind a sealed checkpoint gate while a waiting pod-orb is
---
-The most common way a GPU workload fails at the edge isn't the model, the driver, or the
-network. It's timing. Kubernetes is eager — it will happily schedule your inference pod the
-moment a node is `Ready`, which is often *before* the NVIDIA device plugin has advertised
-`nvidia.com/gpu`. The pod starts, can't see a GPU, crash-loops, and now your rollout is
-poisoned across the fleet.
+The most common way a GPU workload fails at the edge isn't the model, the driver, or the network.
+It's timing. Kubernetes is eager — it will happily schedule your inference pod the moment a node
+reports `Ready`, which is often *before* the NVIDIA device plugin has advertised `nvidia.com/gpu`.
+The pod starts, can't see a GPU, crash-loops — and now your rollout is poisoned across the fleet,
+on boxes nobody is standing next to.
-The fix is to make readiness explicit. Don't trust node-`Ready`; gate on the GPU.
+Node-`Ready` answers the wrong question. It says the kubelet is up. It says nothing about whether
+the one piece of hardware your workload exists to use is actually there yet. So stop trusting it:
+make GPU readiness explicit, and gate on that.
-## Gate the schedule, not just the start
+## Gate the schedule, then gate the start
-A resource request is the first line — a pod that *requests* a GPU won't schedule until the
-plugin advertises capacity:
+The first gate is free — a resource request. A pod that *requests* a GPU won't schedule until the
+device plugin advertises capacity:
```yaml
resources:
@@ -27,9 +29,10 @@ resources:
nvidia.com/gpu: 1
```
-But on a single-GPU edge node that's recovering from a reboot, you still want a hard check
-before the workload does anything expensive. An init container that blocks until the device
-is real keeps the main container honest:
+That handles the common case. But on a single-GPU edge node recovering from a power cut, there's a
+window where the plugin has advertised the device and the driver is still finding its feet — and
+you don't want an expensive model load to be the thing that discovers it. So the second gate is an
+init container that blocks until the device is demonstrably real, and fails loudly if it never is:
```bash
#!/usr/bin/env bash
@@ -45,11 +48,21 @@ echo "GPU never became ready" >&2
exit 1
```
+Two gates, two failure modes closed: the scheduler can't place the pod before capacity exists, and
+the workload can't start before the hardware answers. Note the bound — an init gate that waits
+forever isn't a gate, it's a hang. Two and a half minutes, then fail loud and let the platform
+retry. Fail-closed, never fail-quiet.
+
## Why this is the win
-Once readiness is gated, the whole class of "pod started before the GPU" failures disappears
-— and it disappears *the same way on every node*. That consistency is the real prize at the
-edge, where no one is standing next to the box to nurse a bad rollout.
+Once readiness is gated, the entire class of "pod started before the GPU" failures disappears —
+and it disappears *the same way on every node*. That consistency is the real prize at the edge.
+A fix that requires a human to notice, shell in, and nurse a bad rollout doesn't scale past the
+first dozen sites; a gate that makes every node converge identically after every reboot does.
-The principle generalises: at the edge, **design the dependency, don't hope for it**. The GPU
-is just the first dependency worth gating; egress paths and model artifacts are next.
+## The principle
+
+At the edge, **design the dependency — don't hope for it**. Anything your workload cannot run
+without deserves an explicit, bounded, fail-loud gate between it and the scheduler's optimism.
+The GPU is just the first dependency worth naming; egress paths and model artifacts are next,
+and they want the same treatment.
diff --git a/src/content/blog/shipping-this-site.md b/src/content/blog/shipping-this-site.md
index 5cea651..ef9c750 100644
--- a/src/content/blog/shipping-this-site.md
+++ b/src/content/blog/shipping-this-site.md
@@ -8,25 +8,28 @@ heroAlt: "A glowing data container travels a luminous rail from a small server r
---
-This site is a static Astro build, but how it gets to you is the interesting part. It's
-served from my homelab Kubernetes cluster over a Cloudflare Tunnel, deployed the same way I'd
-ship anything else: as an immutable image, pinned by digest, reconciled by GitOps.
+This site is a static Astro build, and that's the least interesting thing about it. What matters
+is how it reaches you: served from my homelab Kubernetes cluster over a Cloudflare Tunnel, shipped
+the way I'd ship anything I actually cared about — an immutable image, pinned by digest, reconciled
+by GitOps. No special case for "it's just a website."
## The pipeline
-1. The site is built and baked into a hardened `nginx-unprivileged` image.
-2. The image is pushed to a **self-hosted public Gitea registry** — deliberately separate
- from the private instance that holds my infrastructure code.
-3. The image digest is pinned in a private `home-ops` repo.
-4. **ArgoCD** reconciles that repo onto the cluster.
-5. A **Cloudflare Tunnel** exposes exactly one service — this site — outbound-only.
+The build is baked into a hardened `nginx-unprivileged` image and pushed to a **self-hosted public
+Gitea registry** — deliberately a separate instance from the private one holding my infrastructure
+code, so the public artifact and the private estate never share a trust boundary. The image digest
+is then pinned in a private `home-ops` repo, **ArgoCD** reconciles that repo onto the cluster, and
+a **Cloudflare Tunnel** exposes exactly one service — this site — outbound-only.
-No open ports. No server runtime. No registry credential on the cluster, because the public
-package is anonymous-pull and the image holds nothing secret.
+Follow the chain and notice what's missing. No open ports: the tunnel dials out. No server runtime:
+the output is static files behind nginx. No registry credential on the cluster: the public package
+is anonymous-pull, and the image holds nothing secret to protect. Every link in the pipeline is
+either immutable, declarative, or absent.
## Security as acceptance criteria
-The interesting constraint was treating security as a checklist to *pass*, not a vibe:
+The discipline that made it work was treating security as a checklist to *pass*, not a vibe to
+gesture at. The site didn't ship until every box was ticked:
```text
[x] Static output — no server runtime to attack
@@ -36,8 +39,14 @@ The interesting constraint was treating security as a checklist to *pass*, not a
[x] Outbound-only tunnel, single hostname, no catch-all
```
-## Why bother
+A checklist sounds bureaucratic until you notice what it changes: each item is a claim you can
+verify, and a failing item blocks the ship. "Pretty secure" isn't a state you can test for.
+`grep` finding zero secrets in the bundle is.
-Because the site *is* the argument. A platform engineer's portfolio should demonstrate the
-discipline it's advertising — and "it's a static page" is no excuse to skip the rigour. The
-deployment story is part of the work.
+## The principle
+
+The site *is* the argument. A platform engineer's portfolio should demonstrate the discipline it
+advertises, and "it's a static page" is no excuse to skip the rigour — it's the cheapest possible
+place to practise it. If the pipeline behind a brochure site is immutable, verified, and
+zero-trust, that's not overkill. That's the standard, rehearsed where the stakes are low so it
+holds where they aren't.
diff --git a/src/content/projects/global-infra-modernisation.md b/src/content/projects/global-infra-modernisation.md
index afc5c1f..c14e837 100644
--- a/src/content/projects/global-infra-modernisation.md
+++ b/src/content/projects/global-infra-modernisation.md
@@ -7,6 +7,7 @@ period: "2019 – 2025"
stack: ["VMware / vSphere", "Azure (Blob, AVS)", "Microsoft 365", "SD-WAN", "Aruba ClearPass", "Palo Alto / FortiGate", "Veeam"]
featured: false
order: 40
+diagram: "globe"
---
## Problem
diff --git a/src/content/projects/gpu-as-code.md b/src/content/projects/gpu-as-code.md
index 0bfa446..52b9fd3 100644
--- a/src/content/projects/gpu-as-code.md
+++ b/src/content/projects/gpu-as-code.md
@@ -7,6 +7,7 @@ period: "2025 – Present"
stack: ["GPU passthrough", "ESXi", "DCGM Exporter", "Prometheus", "Bash", "Watchdogs"]
featured: false
order: 30
+diagram: "gpu"
---
## Problem
diff --git a/src/content/projects/network-automation-fleet.md b/src/content/projects/network-automation-fleet.md
index 3b3c8bc..e61cc1f 100644
--- a/src/content/projects/network-automation-fleet.md
+++ b/src/content/projects/network-automation-fleet.md
@@ -7,6 +7,7 @@ period: "2019 – 2022"
stack: ["Unimus (NCM)", "NetBox (IPAM / SoT)", "Config backup & DR", "Bulk config push", "Credential vaulting + rotation", "Multi-vendor switching"]
featured: false
order: 45
+diagram: "network-fleet"
links:
- label: "Unimus"
href: "https://unimus.net"
diff --git a/src/data/series.ts b/src/data/series.ts
index 5e76abb..3c5982b 100644
--- a/src/data/series.ts
+++ b/src/data/series.ts
@@ -2,6 +2,6 @@
// The security series threads the zero-trust arc across posts as they publish.
export const series: Record = {
"secret-zero": { name: "SECURITY SERIES", number: 1 },
+ "broker-pattern": { name: "SECURITY SERIES", number: 2 },
"workload-least-privilege": { name: "SECURITY SERIES", number: 3 },
- // broker-pattern → 02 when it publishes (currently a draft in the workshop)
};