HTTP API¶
qed serve runs the API from SPEC §13. Base path /v1. Everything is JSON;
errors are RFC 9457 application/problem+json.
Routes that do not exist are absent rather than stubbed, for the reason SPEC §14 gives about the CLI: an endpoint that answers "not implemented" ends up in somebody's script. SPEC §19's live stream and budget pages are not built, so they are not routable.
| Method & path | Purpose |
|---|---|
POST /v1/proposals |
Submit an ActionProposal, get a verdict |
GET /v1/proposals/{id} |
The proposal and its verdict, as recorded |
POST /v1/proposals/{id}/result |
Report what happened when the action ran |
GET /v1/evidence |
Export the chain as jsonl, csv or oscal |
GET /v1/evidence/by-ref/{ref} |
Resolve an evidence_ref to its record |
GET /healthz |
Liveness. 200 while the process can serve |
GET /readyz |
Readiness. 503 with reasons when it cannot |
Arriving later: /v1/token (week 3), /v1/approvals (week 4), /v1/events
and /v1/metrics (weeks 4 and 7), /v1/policies and /v1/admin (week 10).
One decision path¶
The router does not decide anything. It validates the body and calls
qed.runtime.check_proposal, which is the same function qed check calls.
SPEC §22's week 2 exit criterion is that both return the same verdict for the
same input, and tests/api/test_proposals.py asserts it by running both and
comparing the verdict field by field — not by trusting that they still share a
code path.
Readiness¶
/readyz is false, with reasons, when any of these is true:
- No evidence signing key. SPEC §17. An unsigned decision is not evidence, so serving verdicts here would produce records nobody can verify.
- The policy bundle has errors. Deciding against a bundle with a broken regex would apply rules nobody wrote.
- No usable adapter authentication. SPEC §17 requires unknown adapters to be rejected, and an instance that cannot tell adapters apart cannot do that.
/healthz stays 200 through all three. They answer different questions, and an
orchestrator restarts a container whose liveness probe fails — restarting will
not conjure up a signing key, so a missing key must not fail there.
Authentication¶
Adapters authenticate with a shared-secret MAC. Humans and admins arrive on an OIDC bearer in week 3; this is adapter identity, and an adapter is a process in a CI job or on a laptop with no browser to redirect.
Registering an adapter¶
writes the entry and prints the secret once:
# qed.yaml
adapters:
- adapter_id: adp_laptop_ali
secret_env: QED_ADAPTER_SECRET_ADP_LAPTOP_ALI # the name, never the value
kind: mcp
The secret lives in the environment variable named here, so qed.yaml stays
safe to commit and the secret stays out of the process table, out of backups
of the repository, and out of the evidence chain. It must be at least 32
characters: a shorter shared secret is a password somebody typed, which is why
add generates one rather than asking for it.
There is no command that shows the secret again. qed adapters list
reports whether each registered adapter's variable is set and long enough --
the question that otherwise needs the server running and /v1/status read --
and never the value.
Rotating a secret¶
A shared secret cannot be replaced on the instance and on every adapter at the
same instant. In between, requests signed with the old one are refused as
AG-IDN-005, an unknown adapter, and under fail: closed a refused request
is a blocked action. So rotation has an overlap and it takes two commands:
qed adapters rotate adp_laptop_ali # new secret; both are accepted
# ... set it wherever the adapter reads its environment, and restart ...
qed adapters rotate adp_laptop_ali --finish # the old one stops working
Between them the entry names both variables:
adapters:
- adapter_id: adp_laptop_ali
secret_env: QED_ADAPTER_SECRET_ADP_LAPTOP_ALI_V2
kind: mcp
previous_secret_env: QED_ADAPTER_SECRET_ADP_LAPTOP_ALI
Either signature verifies until --finish removes the second line. The
instance stays ready if the previous variable is already gone -- a
half-finished rotation must not be an outage -- so qed adapters list is
where the state of the overlap is reported.
Registering or rotating an adapter grants or changes an identity's right to
submit proposals, so both are self-governed admin operations (SPEC §17) and
both leave an admin_action record naming the adapter and the variable. Not
the secret: evidence is the one place it must never reach.
Signing a request¶
X-QED-Adapter-Id : adp_laptop_ali
X-QED-Timestamp : 2026-10-07T12:00:00.000Z RFC 3339, UTC, milliseconds
X-QED-Nonce : 01JB... optional
X-QED-Signature : base64url(HMAC-SHA256(secret, canonical)) unpadded
The canonical string is exactly five fields joined by \n, with nothing else:
METHODupper-cased.pathwithout the query string.nonceis the empty string when not sent, and is still part of the MAC.- The body digest is over the exact bytes sent. For a GET, that is
sha256("").
Covering the method, the path and the body means a captured signature cannot be
replayed against another endpoint or with different content. Covering the
timestamp bounds how long one stays usable: requests more than
api.max_clock_skew_seconds (default 300) from the server's clock are refused.
A worked example lives in tests/api/conftest.py, which signs through the same
qed.api.auth functions the server verifies with.
Two limits in week 2¶
No nonce replay cache. Inside the skew window the same signed request can be
sent twice. It does not get two decisions — proposal.id is single-use, so the
second attempt is a 409 (SPEC §17, replay). The nonce is already in the MAC so
that adding the store later needs no protocol change.
Rate limits are on by default, which this page said for eleven weeks that
they were not. SPEC §13's limit is enforced per adapter and per principal, and
over either one the answer is 429 with no record written -- a refused request
never became a proposal, so there is nothing to put in the chain.
api:
rate_limit:
per_adapter_rps: 100 # the default
per_principal_rps: 20 # the default, and the one an integrator meets first
0 turns a limit off; a negative value is refused at load. The per-principal
limit is the lower of the two on purpose: one runaway agent loop is the common
case, and it arrives under a legitimately registered adapter.
--dev¶
qed serve --dev skips signature verification entirely, marks every record
dev: true, and records the reporting adapter as dev-unauthenticated so a dev
chain is honest about never having checked who was calling. It prints a warning
to stderr on start. Bind it to loopback and do not point real agents at it.
POST /v1/proposals¶
Body: an ActionProposal (SPEC §3.1), or an envelope carrying context the
caller already holds:
{
"proposal": { "...": "ActionProposal" },
"budget": { "...": "BudgetState, optional" },
"mandate": { "...": "Mandate, optional" }
}
The envelope is the same shape qed check --budget --mandate takes. Budgets
become server-side state in week 3; until then whoever holds the token is the
only one who knows how much of it is left.
Response:
{
"verdict": { "decision": "REQUIRE_APPROVAL", "...": "" },
"enforced": false,
"effective_decision": "ALLOW",
"evidence_ref": "3",
"evidence_seq": [2, 3],
"approval": null
}
enforcedis false in shadow mode, andeffective_decisionis then ALLOW whateververdict.decisionsays (ADR-003). Adapters must act oneffective_decision, not onverdict.decision.200normally;202when REQUIRE_APPROVAL is actually being enforced;409on a replayedproposal.id;413over 64 KB;422on a schema failure;429over a rate limit.approvalisnullonly when the verdict needed none. On REQUIRE_APPROVAL it carries the id of the request the kernel filed before answering, which is whatqed approveresolves and what the agent is told to wait for.
Error responses never echo the rejected input. A refused body can carry the very
secret AG-DAT-001 exists to stop, and an error response is not masked on the
way out the way an evidence payload is; only field paths and messages come back.
POST /v1/proposals/{id}/result¶
status is one of ok, error, skipped, timeout, deferred.
The record's assurance follows the proposal's enforcement (SPEC §13,
ADR-007.1): enforcing gives adapter, advisory gives self. An advisory
adapter is one the agent could have gone around, so its report is the agent's
testimony and the chain says so. See docs/assurance-classes.md.
GET /v1/evidence¶
| Parameter | Meaning |
|---|---|
from, to |
Sequence range, inclusive |
format |
jsonl (default), csv, oscal |
type |
Repeatable record-type filter |
Two response headers matter:
X-QED-Verifiable: true|falseX-QED-Not-Verifiable-Reasonwhen false
A filtered export is not verifiable. qed-verify checks that sequence
numbers are contiguous and that each record links to the previous hash, which a
?type=verdict export cannot satisfy. Rather than hand back something that will
fail verification later with no explanation, the response says so up front.
format=oscal refuses a type filter outright: an assessment-results document
states what was observed over a period, and one assembled from a subset would
omit observations without saying so. That document goes to an auditor. See
docs/oscal.md.
Up to 50,000 records per request; page with from and to. Streaming arrives
with the Postgres path in week 6.
evidence_ref¶
SPEC §10.3 defines evidence_ref as an outbox id for the Postgres path, where a
decision is answered before the chain row exists. The SQLite path appends inside
the decision, so there is no outbox and the ref is the sequence number.
GET /v1/evidence/by-ref/{ref} exists now so that adapters are written against
it and need no change in week 6.
One connection, one thread¶
Every handler is async def, so it runs on the event loop thread. The SQLite
connection belongs to the thread that opened it, and check_proposal contains no
await, so two concurrent requests cannot interleave inside it and seq stays
contiguous. A def handler would be run in a threadpool by Starlette and would
touch that connection from the wrong thread.
This constraint is load-bearing until the Postgres pool and chain leader arrive
in week 6. tests/api/test_proposals.py submits concurrently and asserts the
sequence has no gaps, which is what would catch its removal.
Rate limits¶
A spent limit is a 429 with a Retry-After header and a problem document
naming which limit it was:
{
"type": "https://valqore.io/qed/problems/rate-limited",
"status": 429,
"detail": "the per-adapter limit of 100/s is spent for adp_mcp. ...",
"scope": "adapter",
"retry_after_seconds": 0.3
}
Four things worth knowing before you tune it.
A rate limit on a gate is a kill switch under load. An adapter in
fail: closed that cannot reach a decision treats a 429 as a refusal, so
throttling qed stops the agent's work. The defaults are high enough that
nothing legitimate reaches them; the limit is for one misbehaving adapter or
one principal in a loop.
The bucket holds three seconds' worth, not one. An agent that makes thirty tool calls while deciding what to do next is ordinary, and at 20/s a one-second bucket would refuse it. The sustained rate is still the number you set — only the first burst is free.
It is per process. Four replicas with per_adapter_rps: 100 admit 400/s
between them, so divide by your replica count. GET /v1/status reports the
limits, the refusal counts and that caveat in rate_limit.scope. A shared
limiter would need Redis or a database write per proposal, which costs more
than the work it protects.
A 429 writes nothing to the chain, and does not burn the proposal id. The attack this exists to stop is a flood, and recording every refusal would let that flood fill the evidence instead of the queue. Back off and resubmit the same id.
0 turns a limit off. Authentication happens first, so an unauthenticated
request is a 401 rather than a 429 — otherwise an anonymous flood could spend a
real adapter's allowance.