Attestation
The first six lessons covered what to declare. This one covers what happens when you do. Attestation is the surface where ArchRails turns a CALM architecture into a verdict on a proposed change — allowed, refused, or review required. Every refusal is reproducible from the inputs (the change, the architecture, the controls in force) and points at a specific node or relationship so the reviewer (or the agent) can act on it.
What attestation is — and isn't
At code-gen-time and at PR-time, ArchRails decides whether a proposed change
respects the architecture you declared in your .calm.json and the
controls attached to it. That decision is an attestation: a
structured, signed record of what was checked, what the inputs were, and
what the verdict was.
What that buys you, concretely:
- Audit-grade evidence. Every attestation carries the rule that fired, the node / relationship it fired on, and the literal evidence (the diff line, the imported module, the changed config). You can answer “why did this PR get blocked?” a year later.
- Reproducibility. The same change against the same architecture always produces the same verdict. If a reviewer or agent disagrees, the answer is in the inputs, not the model's mood.
- Single source of authority. A change refused at code-gen-time will also be refused at PR-time. There is no second authority a customer can “upgrade to” for a different answer.
The three verdicts
Every attestation resolves to one of three outcomes. The vocabulary is deliberately small so reviewers / agents / dashboards all render the same thing.
allowed The change does not violate any in-force control, pattern, or flow constraint. The agent proceeds. The PR check passes. refused The change violates one or more declared controls. The agent sees the rule that fired and the evidence that triggered it. The PR check fails; a reviewer sees the same evidence as a comment. review_required The change touches a part of the architecture ArchRails doesn't yet have a rule for, OR introduces a network call to an unknown destination. The agent pauses and asks the human; the PR comment surfaces the gap.
review_required is the one most teams underestimate. The first
time it fires on a real PR, the right move is usually to author a control or
add a mapping rule that names the new surface — not to dismiss it.
The engine errs on the side of "I need a human" when the architecture isn't
explicit, not on the side of "looks fine."
Reading a finding
Every finding inside a refused or review_required
verdict has the same shape. Reading it correctly the first time saves
twenty minutes of debugging.
{
"rule_id": "AR-CTRL-SENSITIVE-LOG-001",
"severity": "error",
"node_id": "orders-service",
"path": "services/orders-service/src/persist.py",
"summary": "PII field 'tax_id' written to a logger call.",
"evidence_excerpt": "logger.info(f\"tax_id={trade.tax_id}\")",
"fix_guidance": "Logs are not the audit trail. Use the orders-audit edge instead."
}
-
rule_id— the rule that fired. Either anAR-CTRL-*built-in or a control ID you authored. Always quote this in support tickets. -
severity—errorblocks the merge / refuses the agent's change;warningsurfaces but doesn't block. -
node_id— the CALM node the rule fired against. One PR can have findings on multiple nodes; group by this when debugging. -
path— the file the engine saw the violation in. Repo-relative. -
evidence_excerpt— the literal text the engine based its verdict on. If this looks wrong, the engine is wrong (file a bug); if it looks right, your change is the bug. -
fix_guidance— what to do instead. Author-supplied when you write your own controls; AR-CTRL rules ship with sensible defaults.
The two surfaces — same engine
ArchRails runs the attestation engine in two places. The vocabulary is identical; only the trigger differs.
Code-gen-time (MCP) Trigger: An AI coding agent (Claude Code, Cursor, Windsurf, autonomous agent) is about to write code. Input: The proposed change + the architecture in force. Verdict: The agent receives `allowed` / `refused` / `review_required` before writing. A refusal means the wrong code never gets written to disk. PR-time (CI) Trigger: A pull request is opened / updated. Input: The unified diff + the architecture in force. Verdict: The PR check turns green or red; a structured review comment lists every finding with rule_id + evidence.
A change refused at code-gen-time will be refused at PR-time too. The inverse is also true: a change the agent generated cleanly will pass PR-time unless the architecture itself changed in the interim. This is the property that makes "the same engine runs in both places" valuable — you can trust the early verdict without rerunning it later.
Authoring for engagement
A control only fires if its declared constraints engage with the change. "Engages" means the keys the constraint names appear in the change's evidence: a referenced module, a literal value, a property in your config, an identifier in the diff.
Pragmatically: when you author a control, name keys that will appear in the kind of change you want to catch.
// A control that catches PII written to logs. // Engages when a logger call appears in the diff AND the // logged expression references a PII-classified field. "no-pii-in-logs": { "description": "PII fields must not be written to logs.", "requirements": [ { "control-requirement-url": "https://controls.myorg.io/no-pii-in-logs.schema.json", "control-config-url": "https://controls.myorg.io/orders-service/no-pii-in-logs.config.json" } ] } // The config the schema validates against: { "forbidden_in_logs": ["tax_id", "ssn", "card_number", "date_of_birth"], "log_call_patterns": ["logger.info", "logger.warning", "console.log"] }
The control engages when the diff adds a line matching one of
log_call_patterns and the line references one of
forbidden_in_logs. A change to an unrelated file in the same
service doesn't engage this rule, so it doesn't fire (and doesn't get
counted in attestation noise).
The engagement model is what keeps attestation precise as your architecture grows. A node with twenty controls doesn't trigger twenty findings on every PR — it triggers findings for the controls whose keys actually appear in the change.
Worked example · PII in a log line
Here's the full loop: agent proposes a change, attestation refuses, agent revises. Same control as above.
1. Agent proposal (Claude Code, inline edit on persist.py) + logger.info(f"tax_id={trade.tax_id}") 2. MCP attestation runs against the proposed change rule_id: AR-CTRL-SENSITIVE-LOG-001 node_id: orders-service evidence_excerpt: logger.info(f"tax_id={trade.tax_id}") summary: PII field 'tax_id' written to a logger call. fix_guidance: Logs are not the audit trail. Use the orders-audit edge instead. decision: refused 3. Agent revises in-context - logger.info(f"tax_id={trade.tax_id}") + audit.record({"trade_id": trade.id, "actor": user.id}) 4. MCP attestation runs again on the revised change findings: [] decision: allowed 5. Agent writes the file
The wrong code never reached disk. The right code that did reach disk carries an audit record — the orders-audit edge satisfies the same compliance requirement the log line was trying to satisfy, but through the architecturally-correct path.
Deterministic by design
Attestation verdicts are deterministic. That word is doing real work — here's what it means in practice:
- Same inputs ⇒ same verdict, always. The same diff against the same architecture against the same controls always produces the same outcome. If a verdict surprises you, the dashboard shows the inputs the engine saw — not the model's reasoning, the literal evidence.
- AI is on the explanation path, not the decision path. Bedrock (or whichever model your tenant uses) generates the prose summary so a reviewer can read the finding in English. It cannot escalate severity, cannot mark a violation as passed, cannot invent new findings. The model is constrained to explain only the findings the deterministic engine already emitted.
- Reproducibility ⇒ auditability. Every attestation is written to your audit log with the inputs, the engine version, and the verdict. SOX ITGC change-management controls and SOC 2 CC8.1 / CC6.1 / CC7.2 are satisfied because the audit trail is reconstructable from the stored evidence.
Common mistakes
I authored a control but it never fires.
The constraint's keys aren't appearing in the changes you expect to catch.
Open the dashboard, find a PR you expected to trigger the control, and
check the evidence the engine extracted. If the keys you named
in control-config-url aren't in the evidence, your rule won't
engage. Either rename the keys to match what your code actually does, or
broaden the constraint's matching pattern.
Every PR gets review_required — that's a lot of human review.
Usually one of two things. Either the changed paths aren't covered by
mapping rules in .archrails/config.yml (so they resolve to
"unknown node, ask for review"), or the architecture lacks declared
relationships for the network calls in the diff. Add the mapping entries
and the missing connects edges; the noise drops fast.
An agent sees "refused" but writes the code anyway.
The verdict is advisory to the agent's planner — the file system isn't hooked. If an agent ignores refusals consistently, the failsafe is the PR-time gate: the same engine will refuse the merge. The agent doesn't have the keys to bypass that. If you want stricter behavior at code-gen- time, revoke the agent's API key — without a key, the MCP server returns 401 and the agent's MCP client errors out before any write.
The model "explained" a verdict in a way that contradicts the rule.
That's a presentation bug, not a verdict bug. The rule that fired is the
authority — the prose is downstream. File a bug with the
request_id from the dashboard's record; the engine's verdict in
the audit log will show what actually happened, separate from how it was
narrated.
Can I write a control that fires only on Friday afternoons?
No. Attestation is a function of the diff and the architecture, not of wall-clock time. If you want time-windowed gating, layer it on top: have your CI step check the day-of-week before invoking ArchRails. ArchRails itself stays purely deterministic on (diff, architecture, controls).