The task #
The developer types one sentence: “Add Stripe as a payment method to the checkout service.” The agent captures it, and from this point on, everything depends on what surrounds that sentence.
A payment method touches an external provider (ADR-042 applies), needs a schema change (ADR-051 applies), lands in Go code (ADR-060 applies), and sits one directory away from frozen legacy files (ADR-070 applies). Four rules, all relevant, none of them in the sentence.
Act 1: two hundred lines of hope #
Without a gateway, the first layer of governance is front-loaded. Somewhere in the project there is a CLAUDE.md
that says, among two hundred other things:
...
- All external calls MUST go through security-egress-proxy (see ADR-042)
- Never modify internal/old_payment.go or internal/auth/ (frozen, ADR-070)
- DB migrations before code (ADR-051); always add tests (ADR-060)
- Use table-driven tests; prefer errors.As; run goimports; ...
...
These files act once, at minute zero. The proxy rule is real, but it is page 3 of a wall of text, competing for attention with formatting conventions and tribal lore.
The team did it right: a skill #
To be fair to Act 1: this team follows the state of the art. The platform team packaged the payment workflow as a skill, and the developer invokes it: /add-payment-method Stripe
. Its SKILL.md
injects the right rules at the right moment, not buried on page 3:
---
name: add-payment-method
description: Adds a payment provider to checkout, following platform ADRs.
---
1. Route every provider call through security-egress-proxy (ADR-042).
2. Generate the schema migration BEFORE the code that uses it (ADR-051).
3. Add an integration test step (ADR-060).
4. Never touch internal/old_payment.go or internal/auth/ (ADR-070).
This is genuinely better than the wall: focused, versioned, reviewed. But look at what it is: text, injected into the context. A semantic directive the agent is trusted to follow, with no mechanism that verifies it followed it. The skill states the rules; nothing in the loop checks the result against them.
The agent gets to work #
The agent is competent, and it starts well: the skill’s instructions are fresh, the migration comes first, the proxy is used. Then the session gets long. A refactor here, a follow-up question there; forty minutes and a few context compactions later, the skill’s four rules are just more tokens far behind.
Because agents finish what they find: “while I’m here, internal/old_payment.go has a helper that almost fits; I’ll adapt it”. A small tweak in
internal/auth/
to expose the customer id. And the second Stripe endpoint gets called directly, api.stripe.com
, no proxy. Each step violates an instruction that is still in the context; nothing is there to notice.
Everything looks fine #
The code compiles. Local tests are green. The diff is tidy and well-commented. The agent’s Observe step checks everything it can see, and everything it can see is fine.
That is the trap: nothing in the loop can observe “this file is frozen” or “this call bypasses the egress proxy”. Those are organizational facts, and the loop has no channel for them.
Days later: the late gate #
The pull request meets CI and a human reviewer. The verdict is correct, and it arrives at the worst possible moment:
internal/old_payment.go
andinternal/auth/
are frozen (ADR-070); please revert.- The Stripe client calls the API directly; route it through
security-egress-proxy
(ADR-042).- The migration was added in the same commit as the code that needs it; split and reorder (ADR-051).
Three architectural decisions, all documented, all violated, all detected after the work was done.
The bill #
The agent’s session is long gone; its context has evaporated. The rework cannot be absorbed as one more iteration of the loop: it re-enters at the top, as a new intent, carried by a frustrated human who now has to explain what “frozen” means to a fresh session.
Note what failed. Not the rules (they were correct), not their delivery (CLAUDE.md and a well-written skill put them in the context), not the agent (it read them). What failed is that every rule was text: advisory by construction. As the previous article put it: feedback that arrives inside the loop costs one iteration; feedback that arrives after the loop costs the whole loop. Act 1 just paid the whole loop.
Act 2: same task, governed loop #
Replay. The repository is reset, the intent is identical, the agent is the same stock Claude Code. One thing has changed: a Platform Planning Gateway now sits between the agent and the work, and the rules changed form. The responsibilities split cleanly in three:
- the platform team operates the gateway and exposes its gates (over HTTP, and asMCPtools the agent sees natively); - each stream team writes its rules twice (a semantic directive plus an executable policy) and pairs its skills with a policy; - the agent executes the skill, and every decision it makes passes through the gateway’s endpoints.
The next two scenes show what “written twice” and “paired with a policy” mean, concretely.
The rule, written twice #
Take ADR-060 (“a Go change ships with tests”). In Act 1 it was one line of prose. In Act 2 it is a dual-representation artifact: the Markdown invariant stays (the agent will reason over it at planning time), and next to it the team wrote ADR-060.rego
, an executable policy in Rego:
package ppg.linter
import rego.v1
violation contains v if {
input.repository_context.tech_stack[_] == "Go"
not plan_has_go_test
v := {
"policy_id": "go_tests_present",
"message": "SDLC invariant violated: the plan has no test step. Add a step whose tool is \"go-test\", or whose action runs 'go test'.",
"nature": "amplifier",
}
}
plan_has_go_test if {
input.steps[_].tool == "go-test"
}
plan_has_go_test if {
some step in input.steps
contains(lower(step.action), "go test")
}
Read it as a sentence: if the stack is Go and no step satisfies plan_has_go_test, emit this violation. The two helper rules are an OR: the canonical
go-test
tool, or any step whose action runs go test
(agents describe steps with their own tool names; the policy meets them where they are). Note that the message contains the exact criterion: a rejected agent should never have to guess. The input
is the agent’s plan; no LLM anywhere: the gateway loads every ADR-paired .rego
into an embedded OPAengine, and evaluation is deterministic. Keep this policy in mind: you will see it fire three scenes from now.
The skill comes back, with its policy #
Remember the skill from Act 1? It is not thrown away: it is promoted. The team ships version 2, where the body is no longer a list of rules to remember but a workflow that puts the gateway inside the loop, and pairs it with a companion policy:
---
name: add-payment-method
version: 2.0.0
---
1. Call get_platform_guidelines_for_intent with the intent and repo context.
2. Draft the plan honoring the invariants; submit it through lock_in_plan.
3. Use Edit to implement, staying within the ticket scope.
Publication goes through the platform’s validation gate, POST /validate_skill
. Because the skill instructs file modifications, the gate requires the companion SKILL.rego
; with it, the gate answers:
{ "status": "SKILL_VALID", "tier": 1 }
That is the division of labor: the team ships the capability and its policy; the platform ships the gate. (In the PoC the companion policy is enforced at this publish gate; evaluating it again when a plan declares which skill built it is the documented next step.)
enrich(): the architect chat, automated #
The developer types the same command as in Act 1: /add-payment-method Stripe
. The skill executes, and its first instruction sends the intent to the gateway: “here is what I am about to do; which of our decisions apply?” The word “payment” in the intent matches the scope selectors of two ADRs, and the gateway answers with invariants (never recipes):
{
"status": "CONTEXT_ENRICHED",
"amplifier_context": {
"architectural_invariants": [
{ "adr_id": "ADR-042",
"invariant": "Every outbound call to a third-party service (payment, KYC,
notification) MUST go through the corporate security egress proxy..." },
{ "adr_id": "ADR-070",
"invariant": "The following paths are frozen and MUST NOT be modified:
internal/old_payment.go, internal/auth/..." }
]
}
}
It is the fifteen-minute chat with the staff architect before starting a piece of work: automated, exhaustive, and scoped to this task. The two rules that Act 1 buried on page 3 are now the freshest thing in the agent’s planning context.
The gate publishes its contract #
How does the agent know what a valid plan looks like? Nobody explains it in prose. The gateway’s Go type for a plan has a language-neutral twin, a JSON Schema, and the MCP server serves it to the agent as the lock_in_plan
tool schema at session start:
{
"title": "AgentPlan",
"required": ["session_id", "intent", "repository_context", "steps"],
"properties": {
"steps": {
"type": "array", "minItems": 1,
"items": { "required": ["id", "action", "tool", "targets"] }
}
}
}
Three layers, and none of them overlap: the skill says when to call the gate; the tool schema says how to format the plan; the enrich invariants say what the plan must contain. The platform publishes contracts; the agent fills in the content.
First plan: rejected #
The agent submits its plan as a structured JSON contract. The gateway’s linter evaluates every ADR-paired Rego policy against it, and one fires: ADR-060.rego
, the exact policy you read three scenes ago. No test step for a Go stack.
{
"status": "PLAN_REJECTED",
"violations": [
{ "policy_id": "go_tests_present",
"message": "SDLC invariant violated: the plan has no test step. Add a
step whose tool is \"go-test\", or whose action runs 'go test'.",
"nature": "amplifier" }
],
"guidance": "Fix the violations above and resubmit the plan."
}
Note the register: not “no”, but “here is what is missing”, down to the machine-checkable criterion. A semantic violation reads like a compiler error, and agents are very good at compiler errors; deprive them of the criterion and they guess, give it to them and they fix it in one round-trip.
Self-correction in one iteration #
The agent reads the violation, adds the missing step, resubmits:
"steps": [
{ "id": "s1", "action": "create the payment_methods migration for Stripe",
"tool": "db-migration-generator", "targets": ["migrations/001_stripe.sql"] },
{ "id": "s2", "action": "add Stripe client and route it in the payment router",
"tool": "patch_code", "targets": ["internal/payment/router.go"] },
{ "id": "s3", "action": "go test ./...",
"tool": "go-test", "targets": ["tests/integration_payment_test.go"] }
]
No human touched anything. The correction cost one round-trip, measured in seconds; in Act 1 the equivalent feedback cost a review cycle, measured in days.
PLAN_LOCKED: the capability ticket #
The plan passes. The gateway locks it and issues a signed ticket (an ephemeral JWT) that encodes exactly what was agreed, and nothing more:
{
"plan_hash": "283bcbcfce9405ac805d29aa539a8b2eef98...",
"scope": {
"allow_modify": [
"migrations/001_stripe.sql",
"internal/payment/router.go",
"tests/integration_payment_test.go"
],
"allow_tool": ["db-migration-generator", "patch_code", "go-test"]
}
}
Three files, three tools, fifteen minutes of validity. Least privilege, derived mechanically from the plan the agent itself proposed.
Execution inside the rails #
Every Edit
and Write
now passes through a PreToolUse
hook (ppg-guard
) that checks the target against the ticket. In scope: silent pass, zero friction; the agent does not even notice the guard exists.
And the Stripe call goes through security-egress-proxy
. Not because a gate forced it: because ADR-042 was in the planning context when the plan was written. The soft move did the steering; the hard moves are only there for the day it fails.
The drift, blocked in real time #
Mid-session, the “while I’m here” reflex strikes again: the agent tries to touch internal/auth/login.go
. The hook blocks the call before it executes (exit code 2) and the message goes straight back to the model:
OUT_OF_PLAN_SCOPE: "internal/auth/login.go" is not part of the locked plan
(allowed: migrations/001_stripe.sql, internal/payment/router.go,
tests/integration_payment_test.go). Nothing was modified. If this change is
genuinely needed, re-plan through lock_in_plan.
This is the same violation that cost Act 1 a review cycle. Here it costs nothing: nothing was modified, and the refusal contains its own remediation path (re-plan, or stay in scope). The agent course-corrects and moves on.
When it fails legitimately: a deterministic mentor #
Not every failure is a governance failure. The agent submits a patch with a syntax error; the platform tool catches it in a sandbox and answers with structure, not with exit 1
:
{
"error_category": "GO_SYNTAX_ERROR",
"message": "The patched file does not parse as valid Go.",
"remediation_guidance": {
"allowed_actions": [
"Fix the syntax error reported below and resubmit the patch.",
"internal/payment/router.go:2:22: expected ')', found '{'"
]
}
}
The failure becomes one guided iteration instead of a guessing loop. The tool behaves like a mentor with perfect knowledge of the environment: the exact file, the exact line, the exact next action.
The platform watches itself #
One question remains: is all this governance a durable asset, or scaffolding that compensates for today’s model limitations? The gateway answers about itself:
{
"transition_debt_ratio": 0.4,
"pending_sunsets": [
{ "artifact_id": "explicit_frozen_files_enumeration",
"sunset_condition": "Model honors '@deprecated' annotations semantically
on >95% of an internal benchmark." }
],
"health": "DEBT_ALERT"
}
ADR-070’s frozen-file list is tagged compensatory: the day models infer “deprecated” from annotations, the list is deleted and the ratio drops. The platform ships with its own demolition plan for every crutch it contains.
Two endings, one metric #
Same task, same agent, same mistakes attempted: the missing test, the frozen file, the direct external call. The difference between the two endings is a single variable: the form the rules take. In Act 1 they were text (a context file, a skill), read and then outrun. In Act 2 they were data, checked at three points of the loop.
Act 1: feedback arrives in days, outside the loop, and costs a full rework carried by a human. Act 2: feedback arrives in seconds, inside the loop, and is absorbed as ordinary iterations. The agent did not get smarter between the acts; the governance changed form and place.