CI attestation
A green build is evidence. CI attestation is how a project lets that evidence count toward promoting a knowledge entry, without letting an agent promote its own work.
It is a plain HTTP endpoint, deliberately not an MCP tool. An agent holding a connect code cannot reach it; signing requires a secret only an owner ever sees.
Enabling it does not mean automatic promotion. The entire CI system counts as one issuer, and promotion needs K distinct issuers (default 2). A hundred green runs are one voice. See How knowledge earns trust.
Leaving it off is a valid policy, not a missing setup: with no secret, only a human owner promotes.
1. Mint a secret
Project -> Knowledge -> Settings, as an owner. The plaintext secret is shown once, in the mint response, and there is no way to read it back - the status view returns the key id and grace state, never the secret. Put it in your CI's secret store immediately.
2. Map a check to a claim
An attestation says "check X passed, which supports claim Y". The check map records which CI check may attest which knowledge entry.
An attestation whose check has no mapping is still recorded, but comes back
counted: false and does not count toward promotion. That is the intended
shape: the evidence is kept, but nobody decided in advance that this check speaks
for this claim, so it does not get a vote. The map only offers claims from the same
project, and the database enforces that boundary independently.
3. Sign and post
POST /api/knowledge/attest
X-RR-Attest-Signature: <hex HMAC-SHA256>
Content-Type: application/json
Body, all eight fields required:
| Field | Notes |
|---|---|
projectId | The project the claim belongs to |
knowledgeId | The entry being attested |
runId | Your CI run identifier |
checkName | Must match the check map entry |
assertion | Only check_passed is supported today |
keyId | Which key you signed with (current or previous) |
issuedAt | ISO timestamp, within 300 seconds of server time |
nonce | Unique per project; replaying one is rejected |
The signature covers a canonical string, not the JSON you send. The eight fields appear in the fixed order above, with no whitespace between tokens and each value JSON-escaped:
{"projectId":"...","knowledgeId":"...","runId":"...","checkName":"...","assertion":"check_passed","keyId":"...","issuedAt":"...","nonce":"..."}
Build it by listing the fields explicitly rather than by JSON.stringify on your
object - object key order would then decide whether your signature verifies, and an
extra key would silently change the bytes:
import { createHmac } from "node:crypto";
const FIELDS = ["projectId", "knowledgeId", "runId", "checkName",
"assertion", "keyId", "issuedAt", "nonce"];
const canonical = "{" + FIELDS
.map((f) => `${JSON.stringify(f)}:${JSON.stringify(claim[f])}`)
.join(",") + "}";
const signature = createHmac("sha256", secret).update(canonical).digest("hex");A field the server does not sign cannot influence the signature, so sending a ninth key is harmless - it just is not covered.
Responses
| Status | Meaning |
|---|---|
200 | { validationId, counted }. A replay-safe repeat returns the original validation. |
400 | Malformed body, unsupported assertion, or issuedAt outside the 300-second window |
401 | Signature did not verify under the named key |
404 | No such knowledge entry in this project |
409 | Nonce already spent - this attestation was replayed |
Checks run in that order on purpose: signature and tenancy are verified before the nonce is spent, so a forged request cannot burn a nonce it never earned. An entry belonging to another project answers exactly like one that does not exist, because on a path a project's CI secret can reach, confirming that an id exists is itself a disclosure.
Rotating the secret
Two slots, current and previous, and two modes.
| Mode | What happens | Use when |
|---|---|---|
| Rotate (hygiene) | The replaced secret moves to the previous slot and keeps verifying for 24 hours, so pipelines still carrying it do not break mid-run | Routine rotation |
| Revoke (incident) | The previous slot is cleared in the same write that mints the replacement. Not a shorter grace - none | The secret leaked |
The audit row records which mode was used and the grace deadline, so months later a routine rotation is still distinguishable from an incident response.
Revoking stops future misuse only. It does not undo what already happened. Entries a leaked secret already promoted stay promoted. Reversing those is forensics: find them and refute them through the normal contradiction path. "I revoked the key, so the damage is cleaned up" is exactly the wrong reading.
The 24-hour grace is a chosen value, not one derived from any specification.
What CI can and cannot do
- Can: add one issuer's support to a claim, and keep adding evidence for it.
- Cannot: promote a claim by itself, because all of CI is one issuer.
- Cannot: attest a claim in another project, even with a valid signature.
- Cannot: clear a contradiction. An entry with an unexpired contradiction does not promote no matter how much support arrives.