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:

FieldNotes
projectIdThe project the claim belongs to
knowledgeIdThe entry being attested
runIdYour CI run identifier
checkNameMust match the check map entry
assertionOnly check_passed is supported today
keyIdWhich key you signed with (current or previous)
issuedAtISO timestamp, within 300 seconds of server time
nonceUnique 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

StatusMeaning
200{ validationId, counted }. A replay-safe repeat returns the original validation.
400Malformed body, unsupported assertion, or issuedAt outside the 300-second window
401Signature did not verify under the named key
404No such knowledge entry in this project
409Nonce 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.

ModeWhat happensUse 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-runRoutine rotation
Revoke (incident)The previous slot is cleared in the same write that mints the replacement. Not a shorter grace - noneThe 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.