Changelog¶
All notable changes to this project. Format: [Keep a Changelog]. Versioning:
semver, with schema_version of ActionProposal / EvidenceRecord tracked
independently (SPEC §25.6).
[Unreleased]¶
Fixed — a timing gate that was measuring the runner, not the kernel¶
test_p95_on_postgres failed on main with a p95 of 62.24 ms against the
50 ms budget. The same commit measured 3.77 ms and 8.71 ms on the runs either
side of it: a 16x spread on identical code, one of them red.
It was a stall, not a slowdown, and the distribution proves which. On the red
run the median got faster — 4.91 ms against the green run's 6.44 ms — while
p95 went up sevenfold and p99 reached 174 ms, out of 300 samples of which
nearly all fell between 4.8 and 5.2 ms. The chain append stalled in the same
run on an unrelated code path (p99 0.91 ms → 14.60 ms), which is a second
witness pointing at the host's disk rather than at check_proposal. A slower
machine raises the median and the tail together.
So the gate was reporting on GitHub's storage, and it will keep doing so: this is the third time a perf assertion in this repository has gone red without a regression behind it, and the decision floor has its own instance (71.7/s measured against an 80/s floor, with the four-replica aggregate printing 87, 92 and 165 decisions/s across three runs of one commit).
tests/perf/windowed.py measures up to three independent windows and passes if
any one of them meets the budget. Applied to both latency gates and both
throughput floors. No budget or floor changed: 50 ms is SPEC §18's number
and the kernel meets it with an order of magnitude to spare, so raising it
would have weakened a published claim in order to work around a claim nobody
made.
What this gives up is stated in the module and in docs/performance.md
rather than left for a reader to find: an intermittent regression — one slow
run in three — now passes. That is the cheaper of the two losses, because a
gate that cries wolf teaches people to re-run a red build without reading it,
and the habit does not stay confined to the test that taught it. Every window
is printed either way, so a tolerated stall stays visible in the log.
Measuring once in the green case was a requirement, not an optimisation: this repository's Actions minutes are metered, and tripling the perf suite's time to defend against something that happens on one run in three would have been a bill rather than a fix. A second window is only measured after the first has missed.
docs/performance.md carried the claim this falsified — that a gate firing at
50 ms against a real 4.5 ms "only fires on a genuine regression" — and now
carries the measurement instead.
Fixed — a CI job that died on one registry's quota, twice¶
The postgres job pulled its service container from
public.ecr.aws/docker/library/postgres:17, and on 2026-10-17 ECR Public
answered toomanyrequests: Data limit exceeded. The job died before its first
step, with a log that reads like a broken test suite.
That mirror was chosen because Docker Hub does this. One mirror is not a
fallback; it is a different single point of failure. BUILDER_MIRRORS solved
exactly this for the builder image on 2026-10-15, with the reason written down
— the quota is per source address and runners share addresses, so the answer
is another registry rather than another attempt — and the fix was applied to
the builder and not here. Second recurrence of finding #58: a fix landing on
one of the two places that needed it.
A services: block takes exactly one image: and GitHub pulls it before any
step runs, so it cannot have a list. The job now pulls in a step, over
POSTGRES_MIRRORS (ECR → mirror.gcr.io → Docker Hub), starts the container
itself and waits on pg_isready with the budget the health check had.
Verified by running the extracted step locally: the happy path, the fallback
with a bogus first registry, and tests/evidence/test_cross_backend.py (23
passed) against the container it started.
test_ci_pulls_postgres_from_more_than_one_registry_too is the guard, and it
took three attempts, which is the part worth recording. The first sliced the
job out of the YAML by looking for the next two-space-indented line — which is
every key inside steps: — and read a fragment. The second parsed the YAML
and then hunted the shell script with a regex, where it matched the words
no registry served postgres:17 inside an error message and concluded
Docker Hub was a fallback: it passed with one registry configured. That is
finding #48 again, prose answering a question asked of configuration. The list
is a variable now and the test reads the variable, plus asserts the script
actually uses it — #60's lesson, that a plumbed value nothing reads is
invisible.
Added — qed --version, which v1.0.0 shipped without¶
There was no way to ask this binary what it was. Not --version, not a
version command, and not doctor — the command whose entire job is posture.
Twenty-two commands and twelve weeks.
It matters more here than in most tools, because VERSION is
ENGINE_VERSION, the value stamped into every verdict record, and the record
is immutable. This project already has the scar: ENGINE_VERSION sat at
0.1.0 through seven releases, so an auditor asking "which engine decided
this?" got an answer wrong by six versions that nothing could correct
afterwards. "Which engine am I holding?" is the same question one step earlier.
The flag is eager: it answers before any state directory is resolved,
because whoever types it has most likely just downloaded a binary and has no
qed.yaml, no keys and no chain. It reports whether it is the single-file
binary or a wheel, since the binary freezes the interpreter it was built on and
a bug report that omits which is one that cannot be reproduced. It is a flag
rather than a command, so SPEC §14's inventory of 22 stays 22.
doctor now reports the same number as its first line — and adding it there
reproduced a known trap on the spot. doctor builds a dict and then prints it
by hand, which its own source warns about on the line above ("this has to be
added in both places, which is how the first version of this silently printed
nothing"). The JSON had version and the human output did not, for as long as
it took to run the command once. There is now one test per rendering, and the
human one was confirmed to fail while the JSON one passed.
Changed — a caveat that stopped being true without becoming right¶
Four pages told a reader pip install valqore-qed was "not on PyPI yet", and
the guard written for finding #48 enforced that exact phrase. Reserving the
name made all five wrong at once, and in the direction that costs a reader
something: they would run the command, see a clean exit, and conclude the page
had merely been pessimistic.
So docs/index.md, docs/deployment.md, docs/onboarding-shadow-to-enforce.md
and docs/ci.md now say what the command does — it installs a 0.0.0
package holding one README — and INSTALL_COMMANDS in tests/docs/test_claims.py
matches on that instead of on the state of an index. Same guard, same strength,
a marker that does not expire the moment a name is taken.
Removed while there: an or "not on pypi yet" in text fallback in that guard,
which let a page's PyPI warning satisfy the requirement for a Docker command on
the same page.
README.md's quick start now opens with qed --version, so the flag is one of
the commands tests/docs/test_documented_commands.py executes — which is how
finding #27 was caught.
Changed — the names are reserved, and that made the documentation wrong¶
PyPI and npm both hold valqore-qed, qed-verify and valqore, each as a
0.0.0 placeholder whose README says in its first line that it installs
nothing. brew tap valqore/tap resolves to valqore/homebrew-tap, which now
exists, is private, and holds the real formula rendered from
deploy/homebrew/qed.rb.template. Three of SPEC §24.1's reservations are done;
a public ghcr.io/valqore/qed and the EUIPO/USPTO filings are not.
npm served a surprise worth recording. The account's 2FA is a security key with
no authenticator app, so there was no six-digit code to pass as --otp, and a
plain publish returned 403 Two-factor authentication or granular access token
with bypass 2fa enabled is required. What resolved it was npm's staged
publishing: the registry accepted the publish into a staging queue, and for a
name that does not exist yet it publishes a placeholder of its own at
0.0.0-stage while keeping the submitted contents private until a maintainer
approves them. Approving needs the 2FA that staging did not, and npm's website
takes a security key for it where the CLI could not. All three were approved
the same day, so latest is now 0.0.0 and npm install valqore-qed serves
the stub rather than npm's holding version; 0.0.0-stage stays in each
package's version list as the artefact of how it got there.
That sequence falsified this entry's own first draft within the hour: it had
said npm serves the holding version, which stopped being true the moment the
queue was approved. Fifth time in two days, and the pattern held again —
the one claim that survived every one of those five is landing/index.html's,
which says what the placeholder does ("installs nothing") rather than which
version string is in front of it.
The interesting part is what reserving a name broke. Three documents said the
names were unreserved and that the install commands therefore failed —
README.md, landing/index.html and landing/README.md — and a test
docstring explained the pre-launch notice on exactly that basis. All four
were true in the morning and false by the afternoon, and in the direction
that matters: pip install valqore-qed used to fail, which is self-explaining,
and now exits 0 having installed a package containing one README. A reader who
tried it would have had every reason to believe QED was installed. The notice
is doing more work than it was, not less, and all four now say so.
This is the third time a state claim has died at a launch step (see the
v1.0.0 entry and docs/validation.md #49). The claims that survived were,
again, the ones about mechanism rather than about today.
Added — a renderer for the Homebrew formula, because the one-liner was wrong¶
deploy/homebrew/render.sh <version> [sums-dir] fills the template's version
and three checksums from a release's own .sha256 files and prints the formula
the tap carries.
It exists because the obvious sed 's/VERSION/1.0.0/g' was used once, by
hand, to produce the tap's first formula, and the template's header prose
contains the word VERSION: the formula below came out correct and the
documentation above it came out as "1.0.0, and the three SHA256 values, come
from the release". Right answer, wrong file, nobody looking. So substitution
now skips comment lines, the result is refused if a placeholder survives in a
line Homebrew will read, and the rendered header names the command that made
it so the claim is checkable by running it.
Six tests, and one of them earns its keep: no comment in the real template
contains a placeholder, so rendering the real template passes whether comments
are substituted or not. test_the_prose_survives_substitution renders a
modified template with a planted comment instead, and it was confirmed to
fail against the blanket-substitution version before being kept — which is the
discipline docs/validation.md #60b was written about.
A seventh bug found while writing them: the script's EXIT trap ended in
[ -n "$tmp" ] && rm -rf "$tmp", false whenever no temporary directory was
made, and a trap's final status becomes the script's exit status. It printed a
perfect formula and exited 1.
Added — gcs-retention, a second anchor whose refusal has been measured¶
The GCP twin of s3-object-lock, and the fifth checkpoint publisher. A GCS
bucket with a locked retention policy refuses to delete or overwrite an
object until its retention expires, and the policy can never afterwards be
shortened or removed — which is the same claim object-lock COMPLIANCE makes,
reached by a different mechanism.
evidence:
checkpoint:
publishers:
- { type: gcs-retention, bucket: acme-qed-anchors, require_locked: true }
Needs valqore-qed[gcs]. Three design calls are worth stating, because each
is a difference from the S3 publisher rather than a port of it.
Retention belongs to the bucket, not the upload, so this publisher asks
for nothing and instead reads the policy back and reports which of three
states it found. That middle state is the one that misleads: an unlocked
policy really does refuse deletes, so it reads like protection, and an
administrator can remove it and then delete — the exact analogue of S3's
GOVERNANCE mode. require_locked defaults to true so anything less becomes
a caveat in the receipt and therefore in the chain, rather than a success an
operator reads as one.
It never sets or locks a policy, deliberately. The protection is worth something precisely because the bucket is outside this instance's control; a publisher that could lock a bucket could unlock one it had just created. The bucket is platform configuration that the kernel asserts and never manages.
Writes are create-only (if_generation_match=0), which on a bucket with
no policy is the only thing that turns a silent overwrite into a visible
failure. Re-publishing the same checkpoint stays idempotent: a precondition
failure is compared against the stored bytes before it is called a collision.
Why this shipped a day late. It was designed, and then deliberately left
unbuilt, because the only local option — fsouza/fake-gcs-server — turned out
not to implement retention at all: it accepts retention_period, reads it
back as None, accepts the delete, and the object is gone. Shipping against
that would have meant an anchor whose central claim had never been observed to
hold anywhere. Measured against real GCS instead, on 2026-10-16: a delete and
an overwrite both refused for a project owner with
403 … is subject to bucket's retention policy; retention_expiration_time
returned populated, which is the server confirming the policy binds that
object; and removing an unlocked policy and then deleting the anchor
succeeding, which is why the default is what it is.
Two measurements changed the code. retentionPolicy.isLocked is absent
from the JSON for an unlocked policy, so the client surfaces None and not
False — an is True test would have read every unlocked policy as a state
the code had never heard of. And reading bucket metadata needs
storage.buckets.get, which a least-privilege writer holding only
roles/storage.objectCreator does not have; that now degrades to publishing
the object and claiming no protection, instead of failing the publication.
What is not measured, on the record: that a locked policy cannot be
removed. Locking is irreversible, so no bucket was locked to check it.
test_a_locked_policy_cannot_be_removed exists and skips until
QED_TEST_GCS_BUCKET names a locked bucket. These tests are not in CI, for
the fake-gcs-server reason above; docs/checkpoints.md and THREAT_MODEL.md
both say which claims are continuously verified and which are not.
Fixed — SPEC §10.4 listed an email checkpoint publisher that has never existed¶
Alongside the four real ones. No such publisher is in build_publishers, in
qed/infra/publishers/, or anywhere else in the tree. An operator who
configured it would have got the "unknown checkpoint publisher" error, which is
the right behaviour for a typo and the wrong one for a type the normative spec
told them to use. The same class as the command inventory in §14 being wrong in
both directions: a list in prose that nothing executes.
Fixed — the 24-hour history counters are collected, so two rules can fire¶
AG-BUD-007 ("High call rate in the last day", calls_24h >= 500) and
AG-IDN-010 ("Repeated blocks from the same principal", blocks_24h >= 5)
shipped in week 1 and neither had ever been able to fire. HistoryDerived was
threaded through check_proposal into the engine's context and no caller
passed it, so every decision saw zeros. SPEC §5 said the API layer collected
the snapshot; nothing did. Both rules were counted in the pack's rule total
and in its compliance mappings the whole time.
Both numbers now come from the evidence chain, which is the only source an
auditor also has: calls_24h is proposal records for this principal in the
window, blocks_24h is verdict records whose decision was BLOCK.
The window is aligned rather than rolling, and that one choice is the whole
design. It covers [end - 24h, end) with end floored to
history.refresh_seconds (new, default 60). Two things follow, and both are
the point. The query runs once per principal per bucket instead of once
per decision, which is what keeps a store round-trip off the decision path —
at one decision per second that is one query in sixty. And the window is a
pure function of the decision's own timestamp, so qed replay recomputes the
identical numbers from the chain.
A cadence built as a cache with a TTL would have been easier to write and would have made every replay of a history-dependent rule inconclusive forever, because nothing would record which stale value a decision happened to see. That is the trade this avoids, and it is why the setting is an interval rather than a TTL.
The cost is explicit: a burst inside one bucket is counted in the next one.
Both shipped thresholds are daily volumes, so a minute of staleness cannot
change an answer a human would call correct. refresh_seconds: 0 turns
alignment off — exact counters, one query per decision, still replayable.
Three ways it costs nothing: a bundle whose active rules do not read
derived.history.* is never queried (PolicyBundle.reads_history); an action
with no human principal is never queried, because principal_hash is NULL and
grouping every unattended agent under one NULL identity would make "this
principal has been blocked repeatedly" mean "some agent somewhere has been";
and a query that fails is logged and treated as zeros, because both rules that
read these counters are shadow rules about a usage pattern and refusing an
action over a failed COUNT would be the worst trade available.
Migration 0006 adds decision to evidence and an index on
(principal_hash, ts). The column is denormalised out of the verdict payload
because counting blocks by reading payload JSON would need a different
expression per backend and would stop working once retention strips the
payload. It is derived in the store from the record rather than passed in:
a parameter would have to be supplied by the direct path, the outbox enqueue,
the leader's drain and append_direct, and the first caller to forget it
would under-count blocks with nothing to notice — the column would simply be
NULL and a shadow rule would quietly not fire. The column is storage only; it
is not a field of EvidenceRecord and never reaches an export, so evidence
format 1.0 is untouched.
blocks_24h counts from this migration, since older rows have no decision.
Said in docs/replay.md, because an operator comparing a counter to the chain
by hand would otherwise find it short.
Migration 0006 also drops history_daily, a table migration 0001 created
and nothing ever read or wrote. It described per-principal, per-target,
per-day aggregates where the rules ask for a rolling window over the chain.
Leaving it would now be worse than before: it would read as a second source
for numbers that come from evidence, and the first person to find two
sources trusts the wrong one.
Changed — qed replay recovers the history counters instead of assuming them¶
The aligned window is what makes this possible, and replay now recomputes
both numbers from the records it loaded. They fall back to assumed, and are
reported as such, in two cases: a verdict inside the window whose payload
retention has stripped, which makes blocks_24h a lower bound; and
history.refresh_seconds having changed since the decision, which moves the
boundary — --refresh-seconds says what it was.
One implementation in SQL and one over chain records is a place for two
readings of one definition to drift, invisibly: a replay would report a
difference in a history-dependent rule and an operator would go looking for
tampering. tests/core/test_history.py runs both over the same chain and
asserts they agree.
Doing it also turned up that an export carries no principal. principal_hash
is a storage column, not a field of EvidenceRecord, so the record walk takes
the principal out of each proposal payload and reaches a verdict through its
proposal_id — which is a thing worth knowing about the format independently
of this change.
Fixed — a replay difference could be excused by the wrong assumption¶
_attribution accepted a difference as explained if any rule mentioned by
either verdict read an assumed input. A verdict whose hits differed by both a
budget rule and a history rule was therefore reported as "explained by the
assumed budget", and a real difference hid behind an acknowledged one.
Two corrections. The rules considered are now the ones that actually differ — present on one side only, or present on both with a different mode — because a rule both verdicts agree on cannot explain a difference it had no part in. And a difference is attributed only when every differing rule is accounted for; anything left over makes it a finding, and all contributing inputs are named rather than the first one found.
Fixed — a guard whose assertion held on both sides of the change it watched¶
The previous commit added
tests/core/test_replay.py::TestTheHistoryCountersAreNotAnAssumption to fail
the day something started supplying real counters. It asserted that
AG-BUD-007 and AG-IDN-010 did not fire for a single proposal — which is
true whether the counters are real or always zero. It passed, unchanged, when
the counters were wired up.
Replaced by tests that drive a threshold: five blocks into the chain, the
sixth decision fires AG-IDN-010, and that decision replays exactly with
nothing assumed. With its counterweight — the same decision replayed with zero
counters does not reproduce — which is what stops the pair passing against a
build whose counters do nothing.
Added — qed replay <proposal_id>¶
The last thing SPEC §14 listed as specified and not built. §14's inventory is now the whole of §14.
qed verify answers is this record authentic. Every check it makes is
self-referential — hashes over the record's own fields, signatures over those
hashes — so a chain an operator rewrote and re-signed passes it completely.
That is the top entry in THREAT_MODEL.md's own list of what it cannot catch.
replay answers the other question: does this verdict follow from the policy
it says it was decided under. tests/cli/test_replay.py rewrites a verdict,
re-signs the whole chain with the real signing key, asserts qed verify
returns 0, and then asserts qed replay returns 2.
Three answers get three exit codes: 0 reproduced, 2 differs with nothing to account for it, 1 the question could not be answered. Folding the third into the second would make a missing file indistinguishable from a forged record, which is the state a pipeline is most often in.
What makes it honest is the accounting for its inputs.
The clock is exact: check_proposal stamps the proposal record, the
verdict record and the engine's now from one value, so the record's own
timestamp is what the engine was told the time was.
The policy is not in the chain. policy_loaded carries a bundle's hash,
not its rules, so a hash mismatch makes replay refuse rather than answer.
Re-deciding under today's rules is a reasonable thing to want and is not a
replay; presenting its answer as one would be the most misleading thing this
command could do. --policy points at the archived bundle.
The budget ledger and the mandate are assumed and named — the ledger has
moved since, and mandate_issued keeps an id and a window rather than the
signed document. Every difference is then classified against the rules that
actually read derived.budget.* and derived.mandate.*: a difference an
assumption explains is reported as inconclusive, and a difference in a rule
reading only the proposal is reported as a finding. blast_radius is never
excused, because it is computed from the proposal, which is recorded in full.
Approvals are handled as an overlay rather than a decision. check_proposal
runs the engine and then consults the approval store, so a recorded ALLOW
may be a REQUIRE_APPROVAL that met an open window. Comparing decisions
directly would report a mismatch for every correctly approved action in every
chain that has ever been used. The engine's own decision is recovered first and
the overlay checked as its own claim — an ALLOW that the policy says needed an
approval, carrying none, is a finding.
New docs page, docs/replay.md, and the chain-rewrite section of
THREAT_MODEL.md now says what replay does and does not add: forging one
verdict comes to require forging a consistent policy to go with it, but an
operator who can rewrite the chain can rewrite policy_loaded too, and a
rewrite that deletes records leaves nothing to replay. An anchor is still the
only thing that fixes history in place.
Fixed — SPEC §5 claimed something collected the history counters¶
Building replay meant enumerating which of the engine's inputs can be
recovered from the chain, which turned up one that is never supplied at all.
§5 read: "compute_derived.history reads a snapshot passed in ctx
(collected by the API layer before calling the engine)". The API layer does
not collect it. Nothing does — not the API, not the MCP or HTTP proxies, not
the Claude Code hook, not qed check. HistoryDerived is threaded through
check_proposal into EvalContext and every caller leaves it at its default,
so every decision this build has made saw calls_24h = 0 and
blocks_24h = 0.
Two shipped rules read those counters and therefore cannot fire: AG-BUD-007
(calls_24h >= 500, "High call rate in the last day") and AG-IDN-010
(blocks_24h >= 5, "Repeated blocks from the same principal"). Both are
mode: shadow, so nothing was wrongly allowed or blocked — but both are
counted in the pack's rule total and in its compliance mappings, and an
operator reading either one would reasonably expect a runaway loop or a
probing agent to be surfaced.
Corrected in the spec rather than papered over, and not implemented here:
collecting the counters means a store query on the decision path, which is a
trade against §21's latency budget and a decision worth making deliberately
instead of as a side effect of a documentation fix.
tests/core/test_replay.py pins the current behaviour, so the day the
counters become real the test fails and says to revisit what replay should
do about an input that has just become unrecoverable.
Added — qed adapters add|list|rotate¶
SPEC §12.6 has specified this command since week 1, SPEC §17 lists "adapter
add" among the operations qed should govern about itself, and qed check
--server tells an operator to "register it on the instance first with qed
adapters add" — pointing at a command that did not exist. Registration was
hand-editing a list in qed.yaml and inventing a shared secret, which is the
first thing anybody does with this product after qed demo and the one step
with no feedback of any kind until an adapter is refused as AG-IDN-005.
add writes the entry and generates the secret. The secret is printed once
and stored nowhere: qed.yaml carries secret_env, the name of the
environment variable, so the config file stays safe to commit, and nothing
puts the value in the evidence chain. There is no command that will show it
again. A generated 43-character secret also removes the failure where
MIN_SECRET_LENGTH rejects, at server startup, the password somebody typed
into the config an hour earlier.
list answers the question that previously required starting the server and
reading /v1/status: for each registered adapter, whether its variable is
actually set and long enough. Never the value.
rotate has an overlap, and the overlap is the point. 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 an unknown adapter,
and under fail: closed a refused request is a blocked action. So a rotation
moves the secret to a new variable and records the old one as
previous_secret_env; both verify until qed adapters rotate <id> --finish
removes it. The same shape as key rotation (ADR-0010) — the old credential
stays usable for exactly as long as something may still present it. A stale
overlap variable does not fail /readyz, because taking an instance out of
service over a half-finished rotation is the outage the overlap exists to
prevent arriving by another route; qed adapters list reports the overlap's
state instead.
Registering or rotating an adapter changes who may submit proposals to the
kernel, so both are self-governed admin operations (SPEC §17) and both append
an admin_action record naming the adapter and the variable. The default
admin.operations list is unchanged: widening it would gate operations an
existing instance was not gating yesterday, on upgrade, with no edit to its
config.
Added — qed.yaml can be edited by the tool without being rewritten¶
qed adapters add is the first thing in the product that modifies an
operator's configuration file, so qed/infra/config_edit.py is its own module
with its own tests. It does not load and re-dump the YAML: comments are not in
the parse tree, so a round-trip silently deletes every explanation an operator
wrote down, in the file that configures a policy kernel. The edit is textual —
find the sequence, insert one item, leave every other byte alone.
What makes that safe is the check afterwards rather than the care beforehand.
The new file is parsed and validated before it is kept, and the resulting
configuration must equal the old one plus exactly the one adapter that was
asked for; anything else and the original bytes go back. A flow-style
adapters: [], which cannot be appended to by anything working in lines, is
refused rather than guessed at. A nested adapters: under a section the
config reader does not read is not mistaken for the real one — appending
there would register nothing while printing success.
Fixed — one registry's hourly quota could fail the release gate¶
The container tests built from a single base image: whichever registry
QED_BUILDER_IMAGE named, read as the only permitted source. On 2026-10-15
ECR Public answered 429 Too Many Requests -- Data limit exceeded to the
anonymous pull, four tests errored, and deploy — a required status check —
went red on a commit that touched nothing to do with containers.
That variable was already a workaround: the build had been moved to AWS's
mirror because Docker Hub's anonymous limit was doing the same thing. Both
quotas are counted per source address, and CI runners share addresses with
everyone else on the pool, so retrying the same host is the weakest answer
available. The fixtures now resolve the base image across three independent
registries (ECR Public, mirror.gcr.io, Docker Hub), two rounds with a
backoff, and QED_BUILDER_IMAGE becomes a preference tried first rather
than the only option.
The same list now covers the arm64 build, which had no mirror handling at all
and fell through to the Dockerfile's Docker Hub default — the registry with
the smallest anonymous allowance of the three. It resolves the foreign
manifest, not the host's: the first attempt resolved against the host on the
reasoning that a quota belongs to the registry rather than to the manifest
asked for, and CI disproved it within the hour. ECR Public's limit is on
bytes, the amd64 pull spent what was left, and the arm64 build failed 429
beside an amd64 build that had just succeeded. Pulling the foreign manifest
is what lets it fall through to the next registry at all. The base image is also pulled
in its own step before the build, because docker build reports a throttled
registry and a broken Dockerfile identically: as a failed build, against this
commit.
It is still a failure and not a skip when every registry refuses. A skip there
is a green deploy with the shipped artifact never built, which is the
failure mode this directory's probe already exists to prevent. Seven static
tests hold the list to the properties that make it safe: independent hosts,
one interpreter version across all of them (a fallback quietly building on
3.13 would reintroduce the ABI mismatch these tests were written for), and a
pin that neither replaces the list nor duplicates an entry in it.
Fixed — the only control against a rewritten chain was tested against a fake¶
THREAT_MODEL.md has one control for "the whole chain is rewritten by the
operator": a checkpoint root published where the instance cannot afterwards
revise it. Every other check the verifier makes is self-referential — a chain
rewritten wholesale and re-signed passes all of them — so that row carries more
weight than any other in the document.
Its only test was a hand-written FakeS3 that recorded the keyword arguments
it was given. That shows the publisher asks for an object-lock. It cannot
show that any server accepts the request as written, and it cannot show the one
thing the control claims: that the object then cannot be deleted. A fake
that answers {"ETag": ...} to anything agrees just as readily with a publisher
sending nonsense.
tests/evidence/test_s3_object_lock_live.py now runs the publisher over HTTP
against a server that enforces retention and asserts the refusals: the locked
version cannot be deleted, cannot be deleted with BypassGovernanceRetention
set from the store's root credentials, survives a plain delete (which only
writes a delete marker and hides it) and survives an overwrite (which adds a
version beside it). It also pins the thing no API reference states plainly —
that ChecksumAlgorithm, ObjectLockMode and ObjectLockRetainUntilDate are
accepted together on one put_object, which an object-lock bucket requires —
and demonstrates that governance mode really is bypassable where COMPLIANCE
is not, which is the whole argument for the default. A new anchors CI job
runs it against LocalStack and fails if the tests skipped.
One assertion is marked AWS-only (QED_TEST_S3_AWS=1) because measuring it
found a divergence worth writing down: real S3 refuses ObjectLockMode on a
bucket with no object-lock configuration, and LocalStack accepts it and stores
no lock. Those are opposite failures with the same moral — the publisher must
ask the bucket rather than infer a lock from a success — and the module's
docstring now says which of its claims this environment can and cannot prove.
Added — endpoint_url for the S3 checkpoint publisher¶
The publisher called boto3.client("s3") with no endpoint, so the only anchor
that actually anchors could only talk to AWS. For a kernel whose premise is
self-hosting, that is the wrong default shape: the anchor has to be outside
this instance's control, not outside the building. endpoint_url and
region now point it at MinIO, Ceph or an S3 gateway, and qed doctor names
the endpoint in its publisher line.
Setting an endpoint forces path-style addressing, which is not a
preference: boto3's default builds a virtual-host URL (bucket.host) that
resolves for s3.amazonaws.com and for nothing else without a wildcard DNS
record, and the failure reads like the bucket being down. SPEC §16's reference
config and docs/checkpoints.md carry both keys.
Added — qed demo, one command from nothing to a verified chain¶
Before this, the shortest honest path from "cloned the repository" to "saw a
verdict" was: write a qed.yaml, run qed keys init, pick a pack, hand-craft
an ActionProposal, find qed export, then find qed-verify and the three
arguments it needs. Every one of those is in the documentation and none of them
is a first impression. The remaining exit criteria in SPEC §22 are all people —
two design partners in enforce, auditor acceptance, 300 stars — and all three
start with a stranger deciding in a minute whether this is real.
qed demo generates a key, writes a qed.yaml, decides five proposals and
verifies the chain it wrote. The arc is deliberate: a read in dev that nothing
objects to, a DROP TABLE in production routed to a human with the blast
radius already computed, an open security group refused outright, an export to
an unallow-listed host that a shadow rule records and lets through, and an
unattended agent with no mandate stopped before any rule runs. That is the
whole product in five lines, and the last two are the ones nothing else in the
repository showed without a configuration file.
Three properties it keeps, because each is a way a demo becomes a liability:
- Every record is marked
dev, in the record rather than in the prose around it. An export of a demo chain says what it is wherever it ends up, and the directory it writes (./qed-demo) carries a.gitignoreof its own so a generated signing key cannot be committed by accident. - It exits 0 although a BLOCK is in it.
qed checkreturns the verdict as its exit code (SPEC §12.5); a demo returns whether the demo ran. - What it claims the pack decides is asserted in the test suite. Each scene
carries its expected verdict and
tests/cli/test_demo.pychecks all five against the shipped pack, so a rule change fails a test instead of surprising somebody's first run. The same file asserts thedevflag on every exported record, that the chain verifies through the realqed-verify, that the files theNextsection points at can be read by the command it suggests, and — withsocket.socketmonkeypatched to raise — that the demo needs no network.
Nothing new was built underneath. The proposals come from
qed.adapters.common.build_proposal, the function the MCP and HTTP adapters
use, and they are decided through check_proposal, the path a real adapter
takes. A demo of code nobody runs would be worth less than no demo.
Fixed — SPEC §14's command list, and the rule that nothing was enforcing¶
qed/cli.py has opened with "SPEC 14 forbids adding a top-level command
without a spec update" since week 1. Nothing checked it, and three commands —
approvals, status, checkpoint — were built and never listed, while
replay and adapters were listed and never built. The count in the heading
said 18; the CLI had 19 names before demo and 20 after.
The inventory is now correct, the two unbuilt commands are named as unbuilt
rather than quietly listed as features, and tests/cli/test_command_set.py
holds the list to the CLI in both directions, group subcommands included. A
command the CLI gains and the spec does not is now a failing test, which is
what the sentence in the docstring has been promising for twelve weeks.
Changed — a rule that cannot be evaluated is skipped, and the skip is recorded¶
PredicateError was caught nowhere in qed/. The guard that bounds a
catastrophic regex (REGEX_TIMEOUT, widened to 1 s in the previous entry)
raises at evaluation time, and that exception propagated straight out of
evaluate — so a timing-out pattern in one rule produced no decision at all,
and the adapter's own fail-open or fail-closed behaviour decided the action
instead. Nothing in the evidence chain said a rule had not run.
A when that raises now skips its rule, and the verdict carries a synthetic
AG-PRD-001 hit naming every skipped rule and the reason. The hit is
mode: shadow with verdict ALLOW, appended after the decision is computed, so
anything recomputing the decision from the enforce-mode hits still arrives at
the decision the kernel gave; default_applied is untouched, which means a
verdict can carry both AG-DEF-001 and AG-PRD-001 — no rule matched, and one
rule never got to say. qed_decisions_rule_skipped_total counts the decision
and qed policy explain lists the rules under rules_skipped.
Both alternatives are worse and ADR-0016 says why. Counting the failure as a
match is fail-closed and tempting, but the failure that actually happened was
machine load: it would turn a busy runner into a BLOCK of work the policy
allows, and a BLOCK rule and an ALLOW rule would move in opposite
directions for the same cause. Refusing the proposal — what the code did by
accident — hands one bad pattern in one rule a denial of service against every
action the kernel governs.
SPEC §4.4, §4.5 and §4.6 record the behaviour; §18 gains the counter. The unevaluable rule costs its own coverage and nothing else, and the cost is visible in the chain rather than inferred later from an absence.
[1.0.0] — 2026-10-12¶
The evidence format is frozen. That is what the major version means here,
and it is the only promise in this release that cannot be walked back: a record
written under schema_version: "1.0" verifies under every later verifier,
forever, and the repository now contains the records that prove it rather than a
sentence saying so.
Weeks 11 and 12 of the twelve-week plan (SPEC §22). Week 11's security fix is below, under its own heading, because it is the more serious entry.
The sections below run newest first, so the pre-publication pass that rewrote this repository's history sits above the format freeze it shipped with. Both are this release. The freeze is what the major version means; the pass is what made the repository publishable, and it is recorded here because the commits that would have shown the work no longer exist.
Fixed — a test that mutated an input without checking that it had¶
tests/compliance/test_pack_bundles.py::test_the_bundle_hash_covers_the_signature
altered a bundle's signature and asserted the bundle hash changed with it. It
altered it by replacing the first character with "x". The signing key is
generated per test, so the signature is random base64url, and one run in
sixty-four produced a signature that already began with "x": the mutation
copied the string, the two bundles were byte-identical, and the test failed
reporting that the hash does not cover the signature -- the opposite of what
had happened, with the same 64 characters on both sides of a !=.
It failed in CI on 2026-10-12 after passing for eleven weeks. Measured over 4,000 fresh keys the rate is 1.8%, which matches one base64url character in sixty-four.
The replacement derives the altered signature from the original, so it cannot coincide with it, and asserts the change before asserting the property that depends on it. A test that mutates an input has to establish that it mutated it; otherwise its failure message describes a defect in the product when the defect is in the test.
Fixed — a ReDoS guard that spent its budget on machine load¶
Three of five Dependabot branches had a red cell. Two were a wall-clock race in
the rate-limit tests that 6add28a had already removed — those branches simply
predated it. The third was not a test problem:
That pattern has no backtracking in it. Measured against a value of the shape
it is written for, it matches in 219 microseconds — 457 times inside the
100 ms budget it was refused for. What it spent was not 100 ms of regex engine
but 100 ms of wall clock on a loaded runner, which descheduled the thread
mid-match. regex's timeout is wall clock; it cannot tell the two apart.
The consequence is worse than a red build. PredicateError is not caught
anywhere on the decision path, so a rule that trips this does not allow and
does not block — it fails to evaluate, and the machine's load decided that.
REGEX_TIMEOUT is now 1 second: still a bound on catastrophic
backtracking, now four thousand times a real match rather than four hundred.
The budget was never a latency target — SPEC §18's 50 ms p95 is enforced
elsewhere, and a pattern that needs a second is a lint problem, not a runtime
one.
And the guard had no test. Writing one turned up something worth keeping:
of six textbook catastrophic patterns, the regex module optimises five away —
(x+x+)+y, ^(a+)+$ and (.*a){20} all return in under a millisecond against
inputs that defeat re. (a|a)*$ does not, and is what the new test uses, so
there is now a case proving the bound still bites as well as one proving an
ordinary pattern is not mistaken for an attack. A third asserts the budget
itself, deliberately without measuring elapsed time: a test with a clock in it
is what broke here.
Fixed — the Intel macOS binary was assembled from two different OpenSSLs¶
The runner fix got the Intel job a machine in three seconds, and the binary it built could not start:
ImportError: dlopen(.../cryptography/hazmat/bindings/_rust.abi3.so):
Symbol not found: _SSL_get0_group_name
Expected in: .../libssl.3.dylib
Measured on a real macos-15-intel runner rather than guessed at.
cryptography no longer publishes an x86_64 macOS wheel, so pip built it
from source against Homebrew's OpenSSL 3.6.3. PyInstaller then collected a
mismatched pair:
libssl.3.dylib |
/Library/Frameworks/Python.framework/Versions/3.12 |
libcrypto.3.dylib |
/usr/local/Cellar/openssl@3/3.6.3 |
Two different OpenSSL installations, one library taken from each.
_SSL_get0_group_name is an OpenSSL 3.2 symbol: the Cellar copy has it, the
framework copy does not, and the extension was linked against the Cellar one.
The binary job now reinstalls cryptography from a wheel on macOS before
building. A wheel has OpenSSL linked statically -- the extension links only
libiconv and libSystem -- so there is no pair to get wrong. The step then
checks that, with otool -L, and fails there with a sentence rather than
letting the frozen binary die on dlopen five steps later.
One consequence stated rather than hidden: on Intel this pins back to the
newest cryptography that still publishes a universal2 wheel, so that one
binary carries an older version than the wheels do. The step prints the version
into the build log. The honest reading is that Intel macOS is a platform the
Python ecosystem is leaving, and a frozen binary for it now depends on a
hand-checked OpenSSL linkage every release.
Fixed — the release job downloaded artifacts it had no use for, and died on them¶
With the binary job red, release ran for the first time -- if: always()
working as intended -- and failed in download-artifact:
Found 7 artifact(s) … downloading all artifacts
ok dist-qed · dist-qed-verify · chart · binary-linux-x86_64 · binary-macos-arm64
fail valqore~qed~TL3OI6.dockerbuild
fail valqore~qed~QPN6PZ.dockerbuild
##[error]Unable to download artifact(s): failed after 5 retries
The five real artifacts arrived and verified their digests. The two that failed
are build records docker/build-push-action uploads, which were never going to
be release assets -- the step simply had no pattern, so it took everything
the run produced. The release died on files it did not want.
It now asks for {dist-*,chart,binary-*}, which was checked against the real
artifact names with the same matcher @actions/artifact uses rather than read
and assumed: all six real names match, both build records do not. The pattern
also means a new artifact added elsewhere in the pipeline cannot quietly become
a release asset.
Fixed — the release waited on a runner that was retired ten months ago¶
With the pipe race fixed, every job in the release went green except one, and
the release still did not appear. The Intel macOS binary was built on
macos-13, a runner image GitHub retired on 2025-12-04. A job that asks for a
retired label is not rejected when it is created: it is queued, and it
stays queued until GitHub cancels it at the 24-hour limit. Both of v1.0.0's
first two tags sat for hours with verify, wheels, chart, both image
jobs and the other two binaries all successful, waiting on a machine that no
longer exists.
The release job anticipated a broken binary and says so in a comment: the
binaries "are part of the release but not a condition of it", guarded with
if: always() && wheels == 'success' && chart == 'success'. That is the right
intent and it does not help here, because always() releases a job whose
dependency failed, and this dependency never finished. The distinction
between "failed" and "has not started" is invisible when you write the guard
and total when it runs.
Two consequences, and the second is the general one:
- The label is now
macos-15-intel, GitHub's replacement, and that was proven on a throwaway branch before the tag -- one job printinguname -m, which answeredx86_64on macOS 15.7.9 in a second. The two dead tags are what it costs to learn this from a tag instead. - A runner label is a dependency on something outside the repository, like a
base image or an action version, and nothing in the pipeline pins or checks
it.
macos-13was added in week 11 and the Intel binary had never once been built before the 1.0.0 tag, because thebinaryjob did not exist when v0.7.0 was released -- so this was not a regression, it was a step that had never run. A release step that has never run is not a release step.
Fixed — the release pipeline failed a check that had succeeded¶
Tagging v1.0.0 ran the release workflow, and the binary job failed on
linux-x86_64 and macos-arm64 in the step that proves the frozen
single-file binary can decide. The binary was fine. The step read
under set -o pipefail. grep -q exits as soon as it matches, which closes
the pipe; the producer is then killed writing to it; and pipefail reports the
pipeline as failed because the match was found early. Three such lines per
job, three jobs per release, and the failure is a race -- measured locally at
two runs in twenty for the first line alone, which is why five earlier releases
got through.
The image job thirty lines above had hit this in week 10 and carries the
comment that explains it: "Command substitution reads to the end, so there is
no pipe to break." The binary job was never changed to match. So this is not
a new fix but the existing one applied where it was missed, and the fixed step
was rehearsed twenty times before re-tagging.
The step also pins NO_COLOR and a wide COLUMNS now. rich styles its
output and folds it at the terminal width, and a fold inside one of the phrases
these checks look for would fail a check that is satisfied -- the same defect
as the one above, arriving from the other direction.
Added — vqed, the same command under a second name¶
The wheel installs vqed alongside qed, pointing at the same entry point.
qed is also a Unix text editor -- the ancestor of ed -- which nothing
current ships but which a reader may know, and a second name costs one line
where a collision on somebody's PATH costs them an afternoon.
It is an alias and deliberately not a rename. qed stays the documented
command, the import package and the repository, and three published constants
carry the product name and cannot follow a rename now that evidence format
1.0 is frozen: qed-pack-bundle/1 in qed/remote/packs.py, the RFC 9457 type
base in qed/api/problems.py, and the OSCAL property namespace in
qed/core/evidence/export/oscal.py. ADR-0006 kept the product name out of the
hash domain separators, so a rename would not invalidate a chain -- it would
leave a differently named product writing records that still say qed.
Typer takes the program name from argv[0], so vqed --help describes itself
as vqed with no code change. tests/test_packaging.py asserts that both
names resolve to one entry point, that there are no others, and -- by running
each installed script -- that the help text calls the command by the name that
was typed. An alias whose help tells the reader to run something else would be
worse than no alias.
That test then failed in all four check cells and in postgres, twice over,
on two assumptions a Windows developer machine cannot see:
- The help text is styled.
richcounts a GitHub Actions runner as a terminal, so the usage line arrives asUsage:in bold yellow followed byqed [OPTIONS]...in bold, and the literal"Usage: qed "the test looked for occurs nowhere in it. The real bytes are now a fixture in the test file, with an assertion that they still fail the plain check -- a sample that stopped reproducing the problem would quietly stop guarding anything. - Console scripts are not beside the interpreter. The test resolved them
next to
sys.executable, which is true of a virtualenv on Windows and false of the CI runner, where Python is.../x64/python.exeand its scripts are in.../x64/Scripts/. It now askssysconfig.get_path("scripts"), which is the question it meant to ask, and falls back toPATHonly last.
Both are the same mistake in different clothes: a test that reads its environment through one machine's layout. The alias itself was correct throughout -- what the red build caught was the proof, not the thing proved.
Changed — three documents that one click would have made false¶
README.md, SECURITY.md and CONTRIBUTING.md each opened with a note saying
the remote stays private until SPEC §24.1's name reservations are done.
Publication is one click in a settings page, and the click does not edit any of
them, so the first thing a reader met in all three would have been a sentence
about a state that had just ended.
The worst of the three was SECURITY.md's, because it did not merely go stale.
It told a reporter that the process below it was not in effect yet and to
"report directly to the maintainer" — on a page whose whole purpose is to be
believed by somebody holding a working exploit. It now says the thing that is
true in every state: the email path always works, GitHub's private
vulnerability reporting is a per-repository setting (.github/rulesets/apply.sh
turns it on), and a missing "Report a vulnerability" button is a missing setting
rather than a missing channel.
What is still gated is publishing, which is a different fact with its own
switch, and that is what README.md now says: nothing is on PyPI, npm, GHCR or
the Homebrew tap; release.yml holds both publish steps behind PUBLISH_PYPI
and PUBLISH_GHCR; the reservations are what turn them on. landing/index.html
had already been written this way — its pre-launch notice is tied to the
workflow variable and not to the repository's visibility, with a test holding
the pair together — so this is the README catching up with the landing page.
Two smaller ones, both of which gave repository visibility as half of a reason
that outlives it: mkdocs.yml's explanation of why versioning is deferred (the
site is published nowhere; that, not privacy, is why mike cannot be tested),
and .github/rulesets/README.md's "nothing protects main today", which is
true until apply.sh --apply runs rather than until publication, and which now
names the one command that answers it without a claim.
Fixed — a quote was enough to hide an install command from its own guard¶
README.md's Postgres section said pip install 'valqore-qed[postgres]'. The
package does not exist, and tests/docs/test_claims.py exists precisely to
catch a page that tells a reader to install it without saying so on that page
— it found five such pages and passed, because it matched
pip install valqore-qed at the start of a line and this line has a quote
after pip install.
The guard now strips quotes before matching, and the command is the one that
works from a clone (pip install -e '.[postgres]') with the released form in a
comment beside it. Five spellings and four non-instructions are parametrised
tests, and the pair was verified by reintroducing the exact line: the guard
fails on it now. A guard tested only against the form its author had in mind is
tested against nothing, which is the third time in two days that sentence has
been the finding.
Fixed — two green tests asserting the two halves of a contradiction¶
.github/rulesets/main.json set require_code_owner_review: true while
.github/CODEOWNERS gives one person every path, required_approving_review_count
is 0 and bypass_actors is empty. GitHub does not let anybody approve their
own pull request, so every pull request would have needed an approval that
could not be given, with no override — main would have been a branch nobody
could merge into.
tests/docs/test_repo_protection.py had a test pinning the count at zero whose
docstring spells out that exact danger, and, eleven lines above it, a second
test requiring the flag that makes the zero irrelevant. Both passed. Neither
knew the other existed. A control that reads as present while something else
makes it inert is the failure this product exists to remove, and here it was in
the file written to prevent it.
It survived only because it had never run: rulesets are refused on a private
repository, so apply.sh could not install the trap. It would have sprung in
the minute after the repository went public.
require_code_owner_review is now false, for the same reason the approval
count is zero, and .github/rulesets/README.md says so where it already
explained the count. Nothing is lost: GitHub still requests review from the
owners in CODEOWNERS; only the blocking requirement goes, and it becomes safe
to turn on in the same change that gives the repository a second maintainer.
The replacement test is written against the property rather than the flag:
while bypass_actors is empty and the approval count is zero, no parameter may
require a human approving review by any route. Verified by putting the old
value back — it fails and names the parameter.
Changed — the history was rewritten once, and this is the only account of it¶
Two planning documents and one spec section came out of the tree before publication, and removing a file in a new commit leaves it readable in every earlier commit, in every clone and in every fork. The remote was private with zero forks and zero clones, so the whole history was rewritten instead: seventy-eight commits, every hash new, the old history kept locally as a bundle. This entry is the record, because after this the commits that would have shown the work do not exist.
What came out. ARCHITECTURE_PLAN.md from the seventy-one commits that
carried it, REPO-AND-PUBLICATION-PLAN.md from seventy, and spec §28 from
seventy-one. Appendix B's one row that inventoried the predecessor's feature
set got the wording the working tree already had.
What came out that was not a file. The commit that removed those documents
described their contents in its own message, and the changelog entry announcing
the removal summarised the same material. Three further commit messages, and
nine files in their earlier versions, explained a control in this product by
pointing at a finding about the commercial engine; several named the private
inventory document the finding came from, and one of them -- the test that
forbids an HMAC signing path -- stated a paying product's default signing
algorithm and cited the removed plan's section number for the rest. Each keeps the technical point -- it
is why check_separation_of_duties refuses three times instead of warning once
-- and loses the attribution.
That is the shape worth keeping from this: a purge that explains in public exactly what it purged has purged nothing. The files were checked against the decision and the decision was checked against the files; nothing checked the prose that announced it. It is the same failure as a control reported in an export that nothing enforces, found this time in our own repository.
What went in. Every commit now carries Signed-off-by, which SPEC §25.1
has required since week 1 and which exactly one commit of the seventy-eight
had; the cutoff documented in CONTRIBUTING.md, ci.yml and the 1.0.0 entry
is therefore gone rather than explained. Five commits were authored by a
placeholder identity -- Developer-Backend <agent@valqore.local> and
<noreply@anthropic.com>, one of them the first commit in the repository and
one of them the commit tagged v0.1.0 -- and now carry the maintainer who owns
the copyright. Their Co-Authored-By lines are unchanged, so what a model
wrote is still on the record.
Every author and committer date is byte-identical, the seventy-three commits
that already named the maintainer are untouched in their authorship, the four
tags still point at the same logical commits, and the tree at main is
unchanged except for this file.
Fixed — the build spoke in the future tense about work that had shipped¶
Twenty-one statements across the source and the documentation promised things that already existed, or blamed a week of the build order for a gap that is simply open. Each was true when it was written in week 1 or 2 and nobody revisited it. A reader cannot tell a stale promise from a real limitation, so each one costs exactly what a real gap costs — while hiding the real gaps in the noise.
Three were not comments.
qed mcp told an agent that approvals do not exist. Every
REQUIRE_APPROVAL through the MCP proxy — the adapter the README demonstrates
first — ended with No approval route is configured in this build (approvals
land in week 4). The approval service shipped in week 4. check_proposal
files a request and writes approval_requested with its id, approvers and
expiry before it answers, whether or not a Slack channel is configured, and
POST /v1/proposals has been returning that id in its approval field all
along. The HTTP proxy and the Claude Code hook both already put the id in their
message. Only the oldest adapter said there was nothing to wait for, moments
after a request had been filed for it.
It now reads: Approval apr_… is open; qed approvals lists it. Retry once
agreed. The expiry that SPEC §12.1 also asks for is still missing and is now
described as missing: it lives on the stored request and on the record, and
Appended.record is None while the HA outbox holds one, so putting it in
that string means a store lookup on the decision path or a new field on
Verdict — a decision, not an oversight.
The test for that message asserted the stale sentence — assert "week 4" in
text, "be honest that there is no approval route yet" — and was green for ten
weeks. A test pinning a stale claim is worse than no test: it makes the
staleness look deliberate and fails on anybody who fixes it. It now asserts
that the id the agent is told is the id in the chain.
docs/api.md said rate limits do not exist. "No rate limits. SPEC
§13's 100 proposals/s per adapter is week 10." They are enforced, on by
default, per adapter and per principal — per_adapter_rps: 100,
per_principal_rps: 20 — answering 429 with Retry-After, and 429 was
missing from the page's list of status codes. An integrator would have read
"no limits", built against it, and met the lower limit of the two in
production. The page now documents both, the default values, and why the
per-principal limit is the smaller one: a runaway agent loop arrives under a
legitimately registered adapter.
Two CLI strings named week 3 as the arrival of verified identity:
--token-jti's help ((week 3: real)) and the --human warning. Both now
describe what is true — --human is the adapter's assertion, --mandate is
signed, and identity is proven on the API path.
The OSCAL exporter still stated the circularity corrected in docs/oscal.md
yesterday: "the document an auditor receives contains everything needed to
verify the chain it describes". The keys in back-matter are the keys the
exporter says it used; checking an export against them is circular, because
whoever produced the file chose both halves. The docstring now says so and
points at conformance requirement 11. Its next paragraph had also been left
half-edited mid-sentence when the catalogues landed, which is its own small
lesson about editing prose in a docstring nobody renders.
qed/compliance/loader.py described another product's internals — a file
path, a rule count and a mapping-line count belonging to the commercial engine
— in a note addressed to whoever would do week 8. Reworded to the part that
matters and is this project's own: an imported mapping is keyed to a rule this
project does not share, so every mapping would dangle; titles transfer, links
do not, and every link here was written by hand.
The rest were docstrings and comments in qed/api/, qed/config.py,
qed/adapters/mcp/, qed/core/evidence/ and four documentation pages, each
now describing the build as it is. Two were real gaps wearing a week number,
and both are now stated as gaps: export streaming is not implemented on either
store, and qed adapters add with per-adapter rotation (SPEC §12.6) does not
exist — registration is a list in qed.yaml and rotation is editing it.
tests/test_no_stale_promises.py keeps it from coming back: no file under
qed/ or packages/ may promise a week. There is no future week left, so the
future tense is a bug by construction, and the guard carries tests in both
directions so it neither rots into silence nor starts flagging the honest
history ("the week-1 bug", "moved here in week 6") that the next reader wants.
Fixed — read as a buyer: three commands that cannot work, and a page that does not exist¶
A pass through the repository as somebody evaluating it rather than building it, which is a different reader: they run the first command on the page and believe the capability table.
Install commands that fail. docs/index.md's "Thirty seconds" — the first
thing a visitor to the documentation site runs — opened with
pip install valqore-qed. The name is not registered; SPEC §24.1's
reservations are the publication gate and release.yml holds both publishes
behind PUBLISH_PYPI and PUBLISH_GHCR. So the command answers "No matching
distribution found", and the same command appears in
docs/onboarding-shadow-to-enforce.md, docs/ci.md and docs/deployment.md,
which also offers docker run ghcr.io/valqore/qed:1.0.0 against an image that
has never been pushed. docs/deployment.md did explain all of this —
140 lines below the commands, under "Releasing". That is the exit-code bug
again: a document accurate as a whole and wrong at the only place anybody
reads. Each command now carries the caveat and the alternative where it is
written, and tests/docs/test_claims.py fails if an unavailable install
command appears on a page that does not say so. A second test fails when
release.yml stops gating a publish, because by then the caveats are the
false statement and have to go.
A capability nobody outside this organisation can have. The table said
"Web approval page with OIDC human login" and cited qed/api/ui.py. That
module contains no UI by design (SPEC §20.4 keeps the AGPL pages out of the
Apache wheel) and the qed-ui repository it linked to is private, so the row
advertised a page a reader cannot obtain through a link that answers 404. What
exists today is better than what the row claimed and was missing from the
table entirely: Slack Block Kit approvals where the click is resolved through
users.info to an identified person rather than a workspace member, qed
approve on the CLI, and POST /v1/approvals with a verified OIDC bearer. The
row now names those, and a second row says plainly that the web pages are in a
package that is not published.
Three places still named a defect in the commercial engine.
qed/core/approval/model.py, docs/approvals.md and ADR-0014 each explained
this control by pointing at a finding about the shipping commercial engine. The
lesson is the right one and it is why the function refuses three times instead
of warning; naming a paying product as the example in an Apache-2.0 repository
is not, and restating the finding in order to announce its removal is the same
disclosure in a different file, which is what the first draft of this entry
did. Reworded in all three, lesson kept, attribution gone — the same
correction made last commit for human_auth.py and ADR-0008, which this pass
found had missed three. A fourth place, tests/approval/test_model.py,
mirrored the docstring and was missed again, because both passes went looking
for the file rather than for the sentence.
Added — the rules that protect main are files, not a settings page¶
GOVERNANCE.md has told a reader since week 11 that maintainers' changes go
through the same CI as anybody else's, with "no bypass and no admin merge".
Nothing enforced it. Every commit in this repository went to main directly,
and the reason is not negligence: protected branches and rulesets are refused
on a private repository on this plan — GET /repos/valqore/qed/rulesets
answers 403 Upgrade to GitHub Pro or make this repository public — so there
has been no setting to switch on. A promise in a document with nothing behind
it is the exact failure this product exists to remove, which made it worth
fixing before publication rather than after.
.github/rulesets/main.json and tags.json now hold the rules as the JSON the
REST API takes: a pull request is required, all ten checks ci.yml runs on one
are required, a stale branch must rebase before merging, main cannot be
force-pushed or deleted, a v* tag cannot be moved or deleted, merges are
squash-only with linear history enforced, and bypass_actors is empty — which
is the single line that makes "no admin merge" true. .github/rulesets/apply.sh
installs them and the repository settings that cannot be expressed as a ruleset
(squash-only, branch deletion on merge, Discussions on, private vulnerability
reporting, Dependabot alerts, secret scanning with push protection). It prints a
plan and changes nothing without --apply, reports each refusal by name instead
of abandoning the rest, and is idempotent, because two rulesets named main
both apply and neither is the one you edited.
required_approving_review_count is 0, and the reason is in
.github/rulesets/README.md: with one maintainer and no bypass actor,
requiring an approval nobody can give is not strictness, it is a repository
nobody can merge into — and the usual repair, adding the maintainer as a bypass
actor, is precisely the admin merge this project says does not happen here.
tests/docs/test_repo_protection.py derives the required-check list from
ci.yml, including the four check matrix cells, and fails in both directions:
a job that is not required is a check that can fail with the merge button
green, and a required context no job reports is a pull request that waits
forever. It also ties the approval count to the sentence in GOVERNANCE.md
saying nobody has reviewed this repository, so the day a second maintainer
arrives the ruleset has to change with it. GOVERNANCE.md now states both
halves plainly: nothing protects main today, and here is the file that will.
Also: .hypothesis/ is ignored explicitly. Hypothesis writes its own
.hypothesis/.gitignore containing *, so 814 cache files have been staying
out of the repository by a library's courtesy rather than by this repository's
decision. The merged week-2 branch is deleted.
Fixed — the verifier reported a pass over a check it had skipped¶
qed-verify printed result : OK -- chain is contiguous, unmodified and
signed whether or not --instance-id was supplied, and --json carried no
field recording the difference. Without the flag the genesis binding does not
run, and the genesis binding is the only check in the verifier that reaches
outside the file it was given: linkage, recomputed hashes, canonicalisation,
signatures and Merkle roots are all satisfied exactly as well by records
fabricated this morning and signed with a key generated for the purpose. So the
tool whose entire job is not overstating what it knows was overstating what it
knew — and the quick start in README.md omitted the flag, which made the weak
invocation the documented one.
The report now carries genesis_bound_to and unchecked, plus a complete
property that is true only when every check ran and passed. summary() prints
a bound to line naming the instance or NOTHING, and an unbound pass reads
OK SO FAR -- ... but 1 check(s) did not run followed by what did not run. The
CLI repeats it on stderr, and --require-complete turns the omission into exit
1, which is what a pipeline needs: a pipeline reads the exit code and nobody
reads the summary.
qed verify <file> had the same hole — it passed instance_id=None for an
explicit file — and now takes --instance-id. It deliberately does not default
to the local instance id when verifying a file, because binding a foreign export
to the local identity would turn a real check into a coincidence.
The second half of this is that the instance id and the public keys must reach
an auditor out of band. Both are in the export — the id in the genesis
payload, the key id on every record — so reading either out of the file and
checking the file against it is a tautology, and a forged chain labelled with
any instance id and signed with a laptop key passes it completely. Nothing said
so. docs/oscal.md said the opposite, presenting the keys embedded in the
OSCAL back-matter as "what is needed to check it". That paragraph is rewritten,
THREAT_MODEL.md gains limitation 11 and splits its substitution row into the
operator's case (covered) and the wholesale-forgery case (auditor-side, partial),
and docs/evidence-format.md makes it conformance requirement 11 plus a MUST
NOT against taking either value from the material under review.
Requirement 11 is a new obligation on verifiers added after the format froze, so the in-freeze/out-of-freeze table now says explicitly which of the two the freeze is about: record bytes, not what a verifier has to tell its user. No record changed and no previously valid chain became invalid.
Fixed — README.md had the exit codes backwards¶
It listed 2 REQUIRE_APPROVAL and 3 BLOCK; qed/runtime.py has
EXIT_BLOCK = 2 and EXIT_APPROVAL = 3, and README.md itself got it right
140 lines further down. Neither place mentioned that shadow mode always exits
0 — which a newcomer meets immediately, because shadow is the default and the
quick start's first qed check prints REQUIRE_APPROVAL and exits 0 two lines
above the table that says 3.
Added — the repository files GitHub reads rather than a human¶
CODE_OF_CONDUCT.md as a file (it existed only as a section inside
CONTRIBUTING.md, which satisfies a reader and not GitHub's community profile),
.github/CODEOWNERS, .github/dependabot.yml, .github/PULL_REQUEST_TEMPLATE.md
and three issue templates with blank issues disabled, so no reporter can bypass
the one that says a vulnerability does not belong in a public issue.
One of the templates is for reporting that a claim this project makes is not true. Every overclaim found so far was found by a person reading the product rather than by the suite, including two this week, so there is now a front door for it.
tests/docs/test_community_health.py checks all of it, because each of these
fails silently: a template with a YAML error is ignored without a warning, and a
CODEOWNERS line naming a team that does not exist is dropped — which is why
the owner is spelled as a user. A protection that reads as present and is absent
is the same shape as a compliance control nobody enforces, which is the failure
this product exists to remove.
Removed — commercial planning material, and its history¶
A pre-publication read of this repository as an outsider rather than as its author found four documents that would have become public on the first push, and one of them was a liability rather than an embarrassment.
ARCHITECTURE_PLAN.md, the planning-phase draft the spec superseded, carried a
section assessing the shipping commercial engine against this design, row by
row, and several of those rows are unfixed. Under a public Apache-2.0 licence,
in a repository carrying the same brand, that section is an unpatched-defect
list for a product customers pay for — so this entry does not summarise it.
Naming the rows in a public changelog publishes them exactly as surely as
publishing the file would, which is what the first draft of this entry did.
The rows are kept privately, against the product that has to fix them.
REPO-AND-PUBLICATION-PLAN.md held internal publication sequencing. Spec
§28 held the funding application: targeting, revenue model, moat sequencing
and prepared answers about named competitors, none of which is a specification
of this product.
All three are now kept privately and are purged from every commit in this
repository's history, not deleted in a new one. Deleting a file in a later
commit leaves it readable in all 71 earlier ones and in every clone, which is
the mistake .gitleaks.toml was written to prevent and the reason this
happened before the remote went public rather than after. The repository had
zero forks and zero clones at the time of the rewrite. The same pass rewrote
the removal commit's own message and this entry, because a purge that explains
in public precisely what it purged has purged nothing.
Two source comments and one ADR attributed a compliance finding to the planning-phase review of the predecessor; each keeps the technical point, which is why the human-authentication work in this product exists, and drops the attribution. Spec Appendix B stays — every row names a module path or a capability already published on the product's own site, and four places in the code and the ADRs cite it. The spec gains a scope line saying that commercial planning is deliberately not in it.
DESIGN_DECISIONS.md also stays. It is the design rationale for this product,
written in Turkish during the planning phase, superseded by the spec, and cited
by the public ADR index as the record of what was decided before any code
existed. Nothing in it concerns the other product.
Added - evidence format 1.0, frozen against a committed fixture (ADR-0015)¶
SPEC §25.6 has promised "cross-version fixtures in CI" since week 1. There were
none. The suite had something that looked like one -- test_chain.py writes a
chain with the real signer and store and verifies it with the real verifier --
and it cannot see the failure that matters, because a producer and a verifier
that change together pass it while the records already in a customer's database
do not change with them.
tests/evidence/fixtures/evidence-format-1.0/ is now a real export produced on
10 October 2026 and never regenerated: fourteen records over five simulated
days, with genesis, policy_loaded, three real proposals from
examples/proposals/ deciding ALLOW / REQUIRE_APPROVAL / BLOCK, an approval
decided by a different human through the real separation-of-duties check, a
signing-key rotation, two Merkle checkpoints, a retention purge that recorded
itself, and one proposal whose payload is gone. Both public keys are committed;
no private key is, so the chain cannot be extended.
tests/evidence/test_frozen_format.py checks four things against those bytes:
| Layer | What only it can catch |
|---|---|
qed-verify verifies the export |
the auditor's whole position, in one line |
record_hash -- the producer's function -- recomputes every frozen hash |
that the producer still writes 1.0, not just that the verifier reads it |
qed.core.jcs and qed_verify.jcs agree on every frozen payload |
a divergence between two deliberately separate canonicalisers |
| twelve mutations each produce the documented failure code | that the conformance table in the fixture's README is tested, not asserted |
The fixture's digest is pinned in the test file, not in the fixture: a checksum a regeneration rewrites alongside the thing it describes verifies nothing. Refreshing it is therefore a deliberate edit in two places, with a failure message explaining why it is almost certainly wrong.
And the freeze is a rule rather than a habit:
test_every_schema_version_the_code_can_write_has_a_fixture reads the
schema_version: Literal[...] declarations out of qed/core/schemas.py and
fails until each has a fixture directory. A bump to "1.1" is red until 1.1 has
records of its own, and 1.0 goes on being verified beside it.
Changed - docs/evidence-format.md is a specification, not a draft¶
It carries a status line, two tables drawing the line between what freezing
covers and what it does not, and a conformance section: ten MUSTs, three
MUST NOTs, and an acceptance test an independent implementer runs against the
committed fixture. The MUST most often got wrong is called out -- an unsupplied
key_id is a failure, not a skip, or a verifier reports a forged chain as
clean.
One claim in it was backwards, and freezing it would have been expensive. The draft said the masking pattern set "is part of the format: changing it changes record hashes for future records". Both halves of that sentence are true and the conclusion was wrong. Masking runs before hashing, so a new pattern changes what future records contain; it cannot change a record already written, and a verifier never re-derives a mask -- it hashes the payload as it finds it. Published as written, 1.0 would have frozen the secret-detection patterns for the life of the product. The pattern set is producer-side policy and is now explicitly outside the freeze.
Added - a landing page (landing/index.html)¶
One file, no build step, no external request -- no CDN font, no analytics script. That is a constraint, not minimalism: the product's first claim is that zero bytes leave by default, and a page that contradicts it above the fold is not a landing page for this product.
The terminal transcript on it is real output from a run on 10 October 2026, and
tests/docs/test_landing.py keeps it honest in three ways: every command shown
is one the CLI registers, every number is re-derived from its source (46 rules,
16 of them shadow, p95 4.5 ms, 35 of 76 controls), and a deny-list of thirteen
phrases -- "trusted by", "SOC 2 compliant", "tamper-proof", "% compliant" --
must not appear. tamper-proof is on that list because the chain detects
modification and does not prevent it, and qed doctor says so.
The page carries a visible pre-launch notice, because pip install valqore-qed
does not work: the names are unreserved. landing/README.md has the launch
checklist and says to remove the notice in the same commit that turns publishing
on. A test asserts publishing is still gated, so the two cannot drift apart
quietly.
Changed - version 1.0.0 everywhere¶
qed/version.py is still the one literal; pyproject.toml (both of them), the
Helm chart, the Dockerfile label and docs/deployment.md follow it, and
tests/test_version.py checks all five. ENGINE_VERSION -- which goes into
every verdict record -- now reads 1.0.0.
The tag is not the format. schema_version stays "1.0" and is
independent of the package version by design (SPEC §25.6): the next release
that changes nothing about records will be 1.0.1 and the records will still
say 1.0.
Fixed - two ways a byte-pinned fixture almost shipped broken¶
Neither reached a release; both are in docs/validation.md because the class
is what matters, and a byte-pinned artefact turns out to have two hidden
dependencies that are invisible from the machine that produced it.
.gitignore's *.pem matched the fixture's public keys. The rule sits
under "never commit keys or chains" and is right for a private key. The first
commit would have published a chain with nothing to verify it against, and
locally every test passed because the files were on disk. In CI it would have
surfaced as unknown_key on a frozen fixture, which reads like a format
regression. There is now an explicit negation for
evidence-format-*/public/*.pem, and a test that asks git what it tracks
rather than asking the filesystem what exists -- the same correction as the
packaging guard that used fnmatch while setuptools expands with glob.
The fixture was written with CRLF. Path.write_text translates on Windows,
and the digest is over bytes while .gitattributes checks the repository out as
LF -- so the pin matched on one machine and no other. The generator writes bytes
now, a test asserts it on the bytes, and .gitattributes says what depends on
its rule.
Security - a mandate's signature was never verified (week 11)¶
A forged mandate authorised unattended action. Found while writing the onboarding guide, by trying the thing the documentation describes.
A mandate is what lets an action with no human behind it proceed at all:
AG-IDN-007 BLOCKs an unattended proposal that carries no mandate. Two correct
decisions composed into a hole, the same shape as ADR-0012's remote kill
switch:
qed.coreholds no keys, somandate_validitychecks the window and revocation only and says as much -- "signature verification is the token layer's job".- Every consumer --
qed check, the hook, the MCP and HTTP proxies,POST /v1/proposals-- parsed a mandate withMandate.model_validate_json, which validates the schema and not the signature, and passed it straight to the engine.
Nobody was the token layer. Measured on the same proposal:
| Mandate | Decision | Derived |
|---|---|---|
| none | BLOCK (AG-IDN-007) | present: false |
| genuine | REQUIRE_APPROVAL | present: true, within: true |
signature.sig replaced with 86 As |
REQUIRE_APPROVAL | present: true, within: true |
So writing a JSON file turned a BLOCK into a normal decision. Anyone who could write a file could manufacture the authority for an unattended agent.
verify_mandate() had existed since week 3 and was unit-tested from that day:
tamper with a mandate, assert it raises. Those tests passed for eight weeks
because they called the function. Nothing else did. SECURITY.md listed
this as a week-1 limitation to be fixed in week 3, and the function was written
while the caller never was.
Fixed at the one choke point, check_proposal, rather than at each of the
seven places a mandate is parsed -- seven sites is seven chances to forget, and
the eighth caller would be the one that got it wrong. Verification happens
before the proposal record is appended, so a refusal leaves the id unused and
the agent can retry with a real mandate.
MandateError joined OPERATOR_ERRORS, so the refusal is one line and exit 1
rather than a traceback:
qed: mandate md_01JB00000000000000000001 does not match its signature
(the document was changed after it was signed)
That parenthetical exists because InvalidSignature carries no message, so
interpolating it produced "does not match its signature: " with nothing after
the colon -- on the one refusal where an operator most needs to be told what
happened.
qed policy explain still does not verify, and now says so. It takes no
--dir, so it has no public keys; its JSON reports "mandate": {"present":
true} either way, and an operator using it to find out whether a mandate works
would read that as a mandate that will work. It prints a yellow line saying the
signature was not checked and that qed check refuses a forged one.
tests/delegation/test_mandate_is_verified_at_runtime.py holds it, including a
structural test that no module outside qed/runtime.py builds an
EvalContext with a mandate in it. That test found the two remaining bypasses
on its first run -- policy explain and the policy-case fixtures -- which is
why both are now named exemptions with reasons rather than silent ones.
Added - SECURITY.md disclosure process, CONTRIBUTING.md, GOVERNANCE.md (week 11)¶
SECURITY.md had a disclosure section reading "there is no public disclosure
process to follow; the full process lands with the public launch (week 11)".
This is week 11. It now has the channels, a response timetable, safe harbour,
and explicit scope -- including an out-of-scope list, because "the
documentation already says this is absent" is the commonest report a project
like this gets.
Its "Known limits in week 1" section had been stale for ten weeks, listing
"no authentication anywhere", "no approval service" and "no credential broker",
all of which shipped in weeks 3-5. Replaced with a pointer to
THREAT_MODEL.md and the four limitations people most often assume away.
CONTRIBUTING.md is new: the DCO, the definition of done, and a table of
changes that will be refused with the reason for each -- an LLM in the decision
path, an HMAC signing path, a development fallback key. Better to know in
advance than in review.
The DCO is now actually checked. SPEC §25.1 has required a Signed-off-by
line since week 1 and nothing enforced it; at this release no commit carried
one. The new dco job checks the commits a pull request adds, which is what a
DCO is for: contributions from people who are not the maintainer. The cutoff
this entry originally documented is gone -- the pre-publication history rewrite
signed off every commit instead. See the entry above.
GOVERNANCE.md gained "who decides", and the first draft of that section said
"two maintainers, named in the repository's metadata". Both halves were false:
there is one maintainer and pyproject.toml declared no authors at all. A
governance document inventing a second reviewer is the same failure as a
coverage percentage with no catalogue behind it (ADR-0011), so it now says one
maintainer, that nothing here has been reviewed by a second person, and
that the structural tests are what substitutes for one. authors is declared
in pyproject.toml so the document points somewhere real.
Fixed - a root document cannot link relatively (week 11)¶
strict: true caught this the moment the governance docs gained
cross-references. [THREAT_MODEL.md](THREAT_MODEL.md) resolves on GitHub,
where the reader is at the repository root, and not on the site, where the page
is /security/ and the target is ../threat-model/. There is no spelling that
satisfies both, so root documents use backticked filenames, which are correct
in both contexts. Guarded twice: statically, and by the build.
README.md promised tests/policy/test_determinism.py for the invariant "same
inputs produce byte-identical verdicts". No such file exists; the test is
tests/policy/test_engine.py::TestDeterminism. The invariant was enforced and
the sentence claiming so pointed at nothing, which is worse than no table at
all. tests/docs/test_claims.py now checks that every path any document cites
resolves, and refuses to pass if its own pattern matches suspiciously few.
Added - the docs site, and the guide that says how to reach enforce (week 11)¶
SPEC §26 names mkdocs-material; mkdocs.yml is it, and
docs/onboarding-shadow-to-enforce.md is the page SPEC §24 has had in the file
listing since week 1 with nothing behind it.
The guide exists because three separate dials all have to point at "enforce"
before a BLOCK stops anything -- the instance mode, each rule's mode,
and whether the credential broker holds the credential at all -- and turning
the wrong one is how an operator ends up with a chain full of enforcing
verdicts in front of an agent that was never gated. Every command on the page
was run to write it, and the output is quoted rather than invented. Three
passages were wrong on the first draft and are now what the tool actually
prints: qed policy test takes the case file positionally, the broker's
upstreams.yaml uses credential: {source, ref, inject}, and qed doctor
ends with a table and a count rather than a sentence.
That last one is worth keeping. An upstream whose credential cannot be resolved does not fail -- it degrades to advisory and says so:
UPSTREAM MODE INJECT CREDENTIAL
postgres-prod ADVISORY env unavailable: $PROD_DATABASE_URL is not set, ...
0 enforcing, 1 advisory
0 enforcing is the number to read. The presence of a broker: block is not.
Normative documents are included, not copied. THREAT_MODEL.md,
GOVERNANCE.md, SECURITY.md, CHANGELOG.md and QED_SPEC.md stay at the
repository root, where GitHub and a fresh clone look for them; the site reaches
them through pymdownx.snippets, which ships with the theme. One copy of each,
and tests/docs/test_site.py fails if one is ever duplicated into docs/.
tests/docs/test_site.py asserts the nav and docs/ are the same set, in
both directions. mkdocs build will happily publish a page that no nav entry
points at: reachable by URL, invisible to a reader, green build, wrong
artifact. That is the same shape as the wheel that shipped without
compliance/packs/*.yaml, and docs/validation.md keeps the list.
It found a bug in itself on the first run, which is the direction worth
recording: test_no_root_document_was_copied_into_docs used Path.exists(),
and Windows is case-insensitive, so docs/governance.md -- the stub that is
supposed to be there -- answered True to (docs / "GOVERNANCE.md").exists().
Written that way the test failed on Windows and would have passed in CI on
Linux, which is the worse of the two directions. It now compares against the
real directory listing.
Added - examples/README.md and a gap report that runs (week 11)¶
examples/onboarding/gap_report.py answers "how far is this instance from
mode: enforce?". Every verdict carries decision and would_decision; the
records where they differ are exactly the work remaining, and the script counts
them per shadow rule:
2 verdict(s); 1 would have decided differently under enforce
COUNT RULE WOULD MOVE
1 AG-SKL-003 ALLOW -> REQUIRE_APPROVAL
Stdlib only, and it reads an export rather than the instance -- the same file an auditor is handed, with no access to the database that produced it. An empty report explicitly says it may mean "not enough traffic" rather than "ready".
examples/README.md indexes what runs, and says what is deliberately absent:
there is no Okta or AWS AgentCore example, because one written against a
service it had never touched would be a configuration file presented as a
tested integration.
examples/ is now in mypy's scope. An example is code a reader copies, so
one that does not type-check teaches the habit. Adding it caught
gap_report.py having shipped with an object parameter and a type: ignore,
because nothing had been checking. 119 source files became 122.
Fixed - two lint errors that said what was refused and not what to do¶
AG-BUD-002's own comment said "Flip to enforce deliberately." Both obvious
ways to do that are hard errors, and nothing anywhere documented the one that
works.
ADR-002.4 settles the direction -- an override may only soften or narrow, and
"for hardening, a new rule is written, not an override" -- and that is right: an
overlay file that could silently tighten central policy can break production
from a directory nobody reviews. The defect was that neither refusal named the
alternative, while the verdict -> ALLOW case next to them already did:
override_hardening: an override may only soften or narrow:
mode shadow -> enforce (write your own rule with your own rule_id)
duplicate_rule_id: already defined in skills.yaml;
write your own rule with your own rule_id, or use `override:` to soften this one
Three baseline rules carried comments implying a promotion path that errors
(budget.yaml, data.yaml, skills-extended.yaml); they now name the real
mechanism, and data.yaml additionally says not to edit the shipped file,
since it lives in the wheel and an upgrade replaces it.
Added - qed governs its own admin operations (week 10)¶
Every rule in the baseline pack constrains an agent. The operator has been the one actor no rule touches, and the operator is who rotates the signing key, replaces the control catalogues, and edits the policy an agent is judged by. "Who governs the thing doing the governing" is the first question an auditor asks about a governance product, and until now the answer was "nobody".
qed keys rotate and qed packs import can now require an approval, with the
same separation of duties an agent's action gets -- the same
ApprovalRequest, the same qed approve, the same
check_separation_of_duties. Not a second approval system: two would be two
places for the SoD check to live, and the one that mattered would be the one
nobody tested.
admin:
self_governed: true
approvers: ["group:platform-oncall"]
operations: [keys_rotate, packs_import]
Off by default, because it needs a second person and the common case is one
operator on a laptop -- on by default would make qed keys rotate fail on
first use, which teaches people the feature is an obstacle. What keeps that
honest is that qed doctor reports it either way, in yellow, with the sentence
an auditor would write. And the record carries self_governed: false rather
than omitting the field, so a rotation that was unapproved by configuration
is distinguishable from one that evaded a gate.
Break-glass exists and costs a permanent record. The reason to rotate a key
is that it may have leaked, which is exactly when the approver is asleep. A gate
with no break-glass does not stop the rotation -- it moves it outside the tool,
where the operator sets self_governed: false, rotates, sets it back, and
nothing records any of it. So --break-glass "<reason>" proceeds, the reason is
required, the command prints red, and the admin_action says break_glass:
true for good.
An approval is bound to one operation and spent once. The operation lives
inside the synthetic proposal id the approver decided on, so an approval granted
for a catalogue import cannot rotate a key -- a smaller ask spent on a larger
one. Single use is read out of the chain rather than from a column: the
admin_action carrying an approval id is the receipt, and a column could
disagree with the chain after a restore.
What it does not prove. On the CLI --by is self-asserted, as
qed approve --by always has been. Self-governance there buys that two separate
invocations naming two separate subjects are required: a real obstacle to a
careless action, none at all to a determined operator who lies about who they
are. ADR-0014 and docs/approvals.md say so rather than leaving it to be
discovered.
Added - the single-file binary, and a Homebrew formula that is not published (week 10)¶
SPEC §24.1's Homebrew path: one file, no Python on the machine. deploy/qed.spec
builds it with PyInstaller; the release workflow builds three (macOS arm64,
macOS x86_64, Linux x86_64) with a .sha256 beside each, because the formula
downloads by checksum.
Per OS, because PyInstaller freezes the interpreter it runs on and there is no
cross-compilation. macOS arm64 and x86_64 are separate builds rather than a
universal2 binary: a universal build needs universal wheels for cryptography
and pydantic-core, and those are not what pip resolves.
Building proves almost nothing, which the first build demonstrated. It
succeeded, and then qed verify inside the binary answered "qed-verify is not
installed: pip install ./packages/qed-verify" — advice a brew user with no
Python cannot take. qed_verify is an editable install in
packages/qed-verify/, so collect_submodules resolved its names through the
editable finder while the bundler still needed the directory on pathex. One
line, and only a test that runs the binary would ever have found it.
So the tests run it: policy lint (which resolves every rule's control
references against the compliance catalogues, so a clean lint proves both sets
of bundled YAML arrived — more than a --help that only proves the bootloader
works), packs list --json for all ten catalogues, then keys init, check
and verify against a real chain. The static half of the file checks that the
spec's data patterns agree with the wheel's package-data, since two lists of
the same files are two chances to be wrong.
The Homebrew formula is a template and deliberately not publishable. The tap
is valqore/tap, which does not exist: SPEC §24 lists reserving it among the
names needed before the first public push, and publishing is the irreversible
step. The template lives here so the binary and the formula that installs it
change together, with a test asserting it names every platform the release
matrix builds — a label added there and not here is a platform nobody can
brew install.
Its test do block runs policy lint and verifies a chain rather than checking
--version, because a --version passes on a binary whose data files are
missing. The wheel shipped exactly that bug once and the container shipped it
again.
pyinstaller joins the dev extras, and SPEC §20.3 is amended to list it per
§23's "no new dependency without a spec update" — §24.1 already named it as the
mechanism. It has to be installed in CI rather than skipped politely, because
the deploy job fails on any skip.
Added - THREAT_MODEL.md, and a test that keeps it true (week 10)¶
SPEC §17 has named this file since week 1 and nothing was behind it. It is the document the first auditor session opens with.
It is written for somebody deciding whether the evidence is worth anything, so it leads with the limits: the "What qed does not protect against" section is eleven numbered items and is deliberately longer than the STRIDE table. The first one is the one that matters most -- on most paths qed does not stop anything. An adapter enforces only where it holds the credential the action needs; everywhere else the agent holds its own and qed decides and records. What the evidence supports is "this is what the agent proposed and what policy concluded", not "this is what the agent was able to do".
The other ten, briefly: CLI identity is self-asserted; an operator with the signing key can rewrite the chain and only a published anchor catches it; break-glass bypasses self-governance by design; rate limits are per process; the clock is not verified against anything; a signed bundle is only as trustworthy as its publisher; shadow mode is the shipped default; the kernel judges what the adapter tells it; coverage is a property of the rules, not of qed; and four things are not built.
There is also a table of SPEC §17's nine MUSTs with the state of each, which is the quickest way for an auditor to find the two that are qualified: admin self-governance is off by default, and supply-chain signing is gated off until the name reservations are done.
tests/test_threat_model.py checks the claims that are checkable -- every
rule id it cites exists, every number matches the constant, every file and ADR
it points at is there, every config key resolves on the real model, and the
limitations section is longer than the controls. Not a lint pass on prose: the
same discipline as tests/test_packaging.py, which exists because a list
nobody verified was wrong.
It earned itself immediately. It caught the advisory-adapter concession attributed to ADR-0001, which is the blast-radius table; a MUST table with eight of nine rows; two cited things that were not in the document at all; and the limitations section being shorter than the STRIDE table, which the document's own opening claims it is not. Four errors in a document I had just finished writing, found by a test rather than by rereading it.
Added - fuzzing the parsers, and the two bugs it found (week 10)¶
SPEC §17's "fuzz on parsers", with hypothesis -- already in §20.3's dev/test
set, so the dependency was reserved for exactly this.
Every test asserts the same property, and it is a security one rather than a
correctness one: a malformed input produces a refusal, never a crash. A
parser that raises something its caller does not expect turns a 400 into a 500,
and a 500 on the decision path is an adapter in fail: closed blocking an
agent's work because somebody else sent bad JSON. So each test names the
exception the caller handles and asserts nothing else escapes -- "it did not
crash" is too weak, because a KeyError instead of a ValidationError is still
a crash to the route above it.
Five targets: the JCS canonicaliser (which sits under every signature in the product), the proposal schema, the policy loader, the pack-bundle envelope, and the config loader. A test at the bottom asserts each is named, so a parser added later and not fuzzed is a visible omission.
It found two.
load_bundle raised whatever yaml raised -- ScannerError for an
unterminated quote, ReaderError for a stray control character -- so
qed policy lint, the command whose entire job is to say what is wrong with a
rule file, answered with a traceback. Unreadable files are findings now:
ERROR [invalid_yaml] broken.yaml: this file is not valid YAML: while scanning
a quoted scalar ... found unexpected end of stream
load_bundle_file decoded a pack bundle as UTF-8 outside its own try, so a
single raised UnicodeDecodeError instead of BundleRejected. That one
matters more than it looks: a bundle is the one input in this product that
comes from a third party by design.
And three of the failures were my tests being wrong, which is worth
recording. json.loads(canonicalize(x)) == x is too strong: JCS (RFC 8785)
serialises an integral double without a decimal point, so 6.0e16 comes back
as an int. And two strategies generated integers at 2**53, which jcs.py
refuses on purpose -- a number two JSON parsers read differently is a payload
hash two verifiers disagree about. The code was right in all three cases; the
properties now say what they actually depend on.
Added - rate limits on the API (week 10)¶
SPEC §13: "per adapter 100 proposals/s default; per principal 20/s. 429 with
Retry-After." Token buckets in qed/core/ratelimit.py, spent in the
proposals route.
A rate limit on a gate is a kill switch under load, and that shaped every
decision here. Every other service answers a 429 by making somebody wait;
an adapter in fail: closed that cannot reach a decision treats it as a
refusal, so throttling the kernel stops the agent's work. The limit exists for
the case it is actually for -- one misbehaving adapter, or one principal in a
loop, consuming the capacity every other adapter needs.
Which is why the bucket holds three seconds' worth, not one. A textbook bucket has capacity equal to the rate, and the spec's per-principal limit is 20/s -- so an agent that makes thirty tool calls while working out what to do next would be refused. That is not an abusive agent; it is an ordinary one. The sustained rate stays exactly what the spec says, and only the first burst is free. The suite found this rather than reasoning finding it: a test that sent thirty proposals against the shipped defaults went red.
Three more calls worth naming:
- A refusal writes nothing to the chain. The attack this stops is a caller
flooding the API, and recording every refusal would let them flood the
evidence chain instead -- the one thing that has to stay readable. Refusals
are counted for
/metricsand/v1/status. Retry-Afteris computed, not guessed, and never zero when the bucket is empty. A client told to retry in one second when the bucket needs three is a client that gets refused again, and three of them doing it in lockstep is a thundering herd of our own making.Problemcarries response headers now, because a client should be able to back off without parsing a problem document.- It is per process, and says so in
/v1/status: four replicas withper_adapter_rps: 100admit 400/s between them. A shared limiter would need Redis, or the database on every request -- which costs more than the work it protects. An operator sizing a limit divides by their replica count.
The config lives under api.rate_limit, not the spec's adapters.rate_limit:
in this build adapters is a list of credentials, so a mapping with
rate_limit in it would not parse at all. 0 means off rather than "refuse
everything", because zero reads as "no limit" to everyone who has ever written
a config file; negative is refused outright.
Authentication still comes first, and must: a 429 for an unauthenticated caller would tell them an adapter id exists, and would let an anonymous flood spend a real adapter's allowance.
Added - pip-audit in CI, and the clock warning (week 10)¶
The last two of SPEC §17's nine MUSTs with nothing behind them.
pip-audit runs as its own job, and twice. Its own job because it fails for
reasons that have nothing to do with the code -- an advisory published overnight
turns a green commit red -- and when that happens the useful information is
"which dependency, which advisory", not "the matrix is red". It also runs on a
daily schedule, because a vulnerability in a pinned dependency does not wait for
somebody to push.
Twice because the two questions are different and neither answer covers both.
The environment audit sees the versions actually installed, transitive ones
included, which is where a vulnerability usually turns up. It cannot be
--strict: valqore-qed and qed-verify are editable installs, and --strict
counts anything it could not audit as a failure. The declared-set audit is the
strict one -- it resolves exactly the dependencies this project chose, so
nothing can be quietly left out. That mattered: the first attempt used
--strict on the environment and would have gone red on our own packages the
moment it ran.
The clock check reports when the newest record's timestamp is more than five
seconds in the future -- the signature of a clock that has gone backwards, after
which an export sorted by ts does not read in the order things happened. It
warns rather than refusing, because seq and prev_hash fix the order and
halting on a clock would trade a cosmetic problem for an outage.
It does not query an NTP server, and says so in its own output
("measured": "newest record ts vs system clock; no NTP query"). The kernel is
offline by design, and a governance component that reached for the network to
decide whether to trust itself would be a new dependency in the decision path.
The spec says "NTP drift"; this is a weaker, local signal, and naming it
drift with the measurement beside it is the honest version.
qed status carries it and qed doctor prints the sentence. Both, because
doctor builds its own report rather than reusing chain_status -- which is
how the first version computed the warning correctly and printed nothing.
Fixed - an invalid qed.yaml printed a pydantic traceback¶
Found while testing the above, and nothing to do with it: mode: nonsense
produced forty lines of frames and a URL, with the answer -- mode must be
shadow or enforce -- somewhere in the middle. Every command did this, for a
one-character typo in a config file.
ConfigError carries the file path and the field path now, and the CLI's group
handler prints it as a sentence like any other statement about the operator's
own setup:
It was found because a validation test asserted on output that did not exist -- the assertion was right and the output was empty, because the exception had never been caught.
Added - signed control catalogues, and the air-gap import (week 9)¶
qed packs import and qed packs verify, with qed/remote/packs.py behind
them. A bundle is one JSON envelope carrying catalogues in the same shape as
the YAML files that ship with qed, signed with a pack key -- not the
evidence key, because the evidence key says what this instance did and a pack
key says what a publisher published. One key doing both would let an instance
sign its own catalogue and the signature would prove nothing about where the
control titles came from.
The signature covers everything except itself, key_id and issued_at
included: a signature over the catalogues alone would let anyone restate which
key signed a bundle, or move the date to defeat the rollback check. There is no
default key and the bundle's own key_id is not trusted as the answer --
--public-key has to arrive by some route other than the bundle, or the
signature is checking the bundle against itself.
verify is separate from import: whether to trust a catalogue is a decision
somebody should make with the contents in front of them, and a command that
showed you the contents by installing them would not be giving you the choice.
Every import appends an admin_action -- bundle hash, issuer, issued_at, key
id, frameworks, whether it was signed. A control title an auditor reads has to
be traceable to a moment and a key.
Unsigned import is possible, takes a flag, and is loud: the record says
unsigned: true and the command prints UNSIGNED. Refusing outright would
push people to edit the installed files by hand, where nothing would record it.
Changed - DORA deepened to article level; ISO 42001 deliberately not¶
SPEC §22 week 9 asks for both. One could be done honestly and one could not, and the difference is worth more than the half that shipped.
DORA is now depth: article. The catalogue covers Chapter II, "ICT risk
management" -- all twelve articles, 5 to 16 -- with the Regulation's own
headings, checked against the consolidated text on EUR-Lex rather than written
from memory. That caught one: Article 8 is titled "Identification", and the
label this catalogue carried before was a paraphrase of what the article is
about.
Fifteen baseline rules now map to Article 9 ("Protection and prevention", whose 9(4)(c) and 9(4)(d) ask for policies limiting access to what is required and for change-management procedures) and Article 10 ("Detection", whose 10(1) asks for mechanisms to promptly detect anomalous activities). Coverage is 3 of 12 -- 25% -- and the nine unmapped articles each say why, as a claim about the kernel rather than a reading of the Regulation. Article 11 and Article 12 are named as the near misses rather than quietly left out.
ISO 42001 stays at depth: pack, with the reason written into the
catalogue. The deepening it needs is its Annex A controls, and ISO/IEC
42001 is not freely published: the text is sold and the Annex A titles are not
available from an authoritative public source. ADR-0011 says control titles are
written against the standard's own published material and never from memory,
and this is exactly the case it was written for. A plausible Annex A list
recalled from training would be the worst possible outcome -- not a title
slightly wrong, but a whole catalogue that looks verified and is not, attached
to real rules, in a document an auditor reads as authoritative.
So the eight management-system clauses (4-10, whose titles come from the published ISO harmonised structure) each carry a note saying why a kernel cannot serve them, and the file states the two things that would unblock the real work: a licensed copy of the standard, or a design partner who holds one. Week 9's signed import is the second route -- a partner can publish an ISO 42001 pack without it passing through this repository at all.
Also fixed, in my own tooling. The first script that added these mappings
used a lazy .*? between the rule id and its compliance: block, so for a rule
it could not match it slid forward and inserted into a later rule. Five rules
briefly claimed articles nobody had chosen for them -- a compliance claim
attributed to the wrong rule, which is precisely the thing that reaches an
auditor looking real. Caught by diffing the result against the intended set
before committing anything. The rewrite splits each file into rule blocks first
and asserts afterwards that the rules carrying a DORA mapping are exactly the
ones asked for.
Added - the transparency-log anchor client (week 9)¶
qed/remote/anchor.py, a fourth checkpoint publisher: {type: qed-anchor, url,
log_id}. The first one that sends anything to a third party, and for that
reason the only one that does not copy the whole checkpoint record.
It POSTs six values -- log_id, seq, hash, merkle_root, key_id, sig
-- and nothing else. The record was not an option: SPEC §10.4 requires a
checkpoint payload to carry the previous checkpoint's receipts, and a
receipt's target is an s3://bucket/key or a git remote. Posting the record
would hand an anchor service a running inventory of the operator's bucket names,
arriving as a side effect of a feature whose whole premise (invariant §1.7) is
that it carries hashes and signatures only. A test renders the body and fails if
a bucket name or git remote appears in it.
Every value sent is one the auditor already holds, so the log learns nothing new and the auditor can check the log's entry against their own export. The honest consequence: the log cannot verify that the signature covers a meaningful payload, because it never sees the payload. It does not need to -- a transparency log witnesses that a value existed at a time.
log_id is required, with no default. instance_id names a company and an
environment (and is not on the record at all), and deriving one from the signing
key would move an instance's entries to a new scope at the next rotation,
splitting its own history in the log. The name your entries carry in somebody
else's log is your decision.
https is enforced rather than warned about; a token: in qed.yaml is
refused with a pointer to token_env; the token goes in a header and never in
the body; the environment is read at publish time so a secret arriving after
startup still works. The receipt keeps the status plus the attestation fields a
log actually returns and drops the rest, because a receipt lands in the
immutable chain and a log answering with a kilobyte would put a kilobyte there
for good.
Plain http is allowed to loopback only, matched on the literal host so no
DNS name can resolve its way in. The guard was doing real harm without that: a
local relay or sidecar holding the log credential and talking https onward is a
legitimate deployment, and an operator blocked from http://127.0.0.1 turns the
publisher off instead, which is strictly worse than anchoring through a relay.
Tested against a real HTTP server in a thread rather than a mocked httpx: the
thing worth asserting is what crosses the wire, and a mock tells you what the
code meant to send. Then driven end to end -- two checkpoints through the CLI
against a stub log -- which is how both of the fixes below were found. ADR-0013
has the decisions. The log itself stays out of this repository (SPEC §24.2).
Fixed - evidence.checkpoint.every_records was parsed and never read¶
ChainWriter.checkpoint compared against the module constants directly, so
every_records and every_seconds in qed.yaml were validated, reported by
qed doctor, and ignored. The defaults match SPEC §10.4, so an operator who
left them alone saw correct behaviour and an operator who tuned them saw none.
Found by running it, which is the only way this kind is ever found: an
end-to-end script set every_records: 1, wrote nine records, and
qed checkpoint answered "nothing to checkpoint yet". Twice, before I stopped
assuming the script was wrong.
The thresholds are instance attributes now, filled by the session from the
config; the constants remain the defaults. Five tests, two of which fail if the
fix is reverted -- including one that asserts a large every_records keeps a
long chain undue, so reading the attribute and then ignoring it would not pass.
This is the same shape as observability sitting in UNIMPLEMENTED_SECTIONS
while the code read it: a config key and the code that should read it, drifting
apart quietly, with the defaults hiding it.
Fixed - an import could stop the kernel deciding¶
Found by running it, before any of this had tests. An imported catalogue replaces the shipped one for its framework, so a bundle that does not list a control an installed rule cites leaves that rule citing nothing -- which week 8 made a lint error, and the engine refuses to decide against a bundle with errors.
A three-article DORA bundle replaced the shipped catalogue's one article. The
import succeeded. The next qed check answered "the policy bundle has errors;
refusing to decide against a broken bundle", and the instance governed
nothing.
So the import now checks the bundle against the loaded rules before anything
is written, names the rules it would orphan, and refuses. The alternative --
downgrading an unknown control to a warning when the catalogue was imported --
would put the untraceable OSCAL line back, which is the thing the error exists
to prevent. --allow-orphaned-rules for somebody who means it.
This is the supply-chain shape of the feature: a publisher who drops a control should not be able to switch your kernel off.
Fixed - the same leadership race, in the file I did not check¶
0.7.0 fixed test_a_dead_connection_releases_it, which asserted the
immediate retry after a leader's connection closed. The same assumption lived
in three more places in tests/evidence/test_ha_outbox.py, and one of them
failed on the next full run.
PostgreSQL releases a session's advisory locks when it reaps the backend, not
when close() returns. There is one take_over() helper now, in
tests/evidence/helpers.py, used by both files -- fixing one site and leaving
the duplicate is how this came back a day later.
The three sites in tests/api/test_leader_loop.py are not affected and were
left alone: they follow resign_leadership() or loop.stop(), where the same
session issues the unlock and the release is synchronous. The race only exists
when the connection dies.
Fixed - a failure message that promised output nobody could see¶
The arm64 guard added in 0.7.0 failed on the release tag exactly as designed --
and said "the probe printed which" with no probe output anywhere in the log.
The probe ran at import, which is collection time, and pytest discards
module-level stdout under -q.
emulation_available() returns the reason now instead of printing it, and the
assertion interpolates it. A new test asserts the pairing holds in both
directions, and does not skip when emulation works -- because the deploy job
fails on any skip, so a test that skipped on a healthy runner would turn it
red.
A guard that reports its symptom and not its cause is half a guard, and the half it keeps is the one nobody can act on.
[0.7.0] — 2026-10-10¶
Week 8: control catalogues, so a control reference in a rule means something to the auditor who reads the export.
What this release does not contain is the other half of SPEC §22's week 8:
the auditor session, and the deepening of the EU AI Act and NIST AI RMF
mappings that the session is supposed to steer. The mechanism is complete and
the catalogues are honest about where the mapping is not: EU AI Act is 4 of 9
articles with a stated reason for each of the other five, and the NIST AI RMF
catalogue lists 5 subcategories of roughly seventy and is marked partial for
exactly that reason. Moving those numbers by writing rules to cover more
controls is the overclaim this entire release refuses, so they move after
somebody who audits for a living has looked at it.
Added - control catalogues, so a control reference means something¶
A rule could always say compliance: [{framework: eu_ai_act, control: "Art.
14"}], and until now nothing said what Art. 14 is. The OSCAL export handed
an auditor a list of identifiers with no titles, and no way to tell a thorough
mapping from a decorative one.
qed/compliance/packs/<framework>.yaml now carries 65 controls across 10
frameworks -- the EU AI Act high-risk articles, NIST AI RMF, the OWASP Agentic,
LLM and MCP Top 10s, SOC 2, NIST CSF, ISO 27001, ISO 42001 and DORA -- each with
a label, the framework's source URL, and the edition where the identifiers
depend on it. The labels and sources travel into every OSCAL finding, whose
target title now reads eu_ai_act Art. 14 -- Human oversight, with
control-label, catalogue-source and mapping-depth in its props.
qed packs list is the coverage report, and most of the work went into what it
refuses to say:
- A percentage needs a complete catalogue. SOC 2 is 9 of 9 and prints
partial, because those nine are the criteria this rule set maps to and not all of SOC 2. A percentage over a curated list reads as complete and is not. Only acatalogue: completefile gets a number: EU AI Act 44.4%, OWASP Agentic 70%, OWASP LLM 30%, OWASP MCP 10%. - And a percentage never prints without its scope.
eu_ai_act 44.4%reads as a share of the Regulation; it is 4 of the 9 articles this catalogue covers, out of 113. Every pack states its scope, the coverage report prints it beside the table, and acompletecatalogue is complete for that scope and not for the whole standard. - An unmapped framework still gets a catalogue. ISO 42001 is 0 of 8, with all eight clauses named. Omitting the file would have made it look like a framework nobody had considered; a visible zero is a gap somebody can close.
- The unmapped controls are listed by name, because the first question an auditor asks is which controls you do not claim.
- A deliberate gap says why. The five unmapped EU AI Act articles each
carry a
notethat prints under the table. A note is a claim about this kernel -- "nothing here records a risk management process" -- and never a reading of the standard, because "this does not apply to us" is a legal opinion and has no business in a YAML file. A stated reason is something an auditor can disagree with; a bare absence they can only guess at, and now means exactly "not mapped yet". depth(article/category/pack) says how deep a mapping goes, not how good it is, and rides on every finding.
docs/compliance.md is the page for all of it. NIST CSF records version: 1.1
on purpose: PR.AC-4 is a 1.1 identifier and does not exist in 2.0, where the
family became PR.AA, and a mapping to a retired identifier is one nobody can
follow.
Added - qed policy normalize-frameworks¶
EU AI Act, eu-ai-act and AI Act all normalise to eu_ai_act on read, so a
rule decides correctly whichever it writes. What normalising on read does not do
is group: two spellings are two rows in the coverage report and two control
sets in an OSCAL export, with nothing telling the reader they are one framework.
This rewrites them in place, or reports and exits 1 with --check.
It is a text substitution on the framework: value and nothing else. A YAML
round-trip would reformat every comment and quote style in a rule file somebody
wrote by hand, and the diff would be unreviewable.
Changed - an uncatalogued control is now an error¶
qed policy lint used to warn when a rule cited a control nothing defined.
That is now an error, and qed decide refuses to run against a bundle with
errors, because a control id that is nearly right reaches the OSCAL export
looking exactly like a real mapping -- and the first person to notice is the
auditor who goes looking for the control and finds nothing.
Fixed - the ten new catalogues shipped in no wheel¶
tests/test_packaging.py exists because qed/compliance/frameworks.yaml was
once missing from package-data, so the container started and died on its first
request. It matched with fnmatch, whose * crosses a path separator -- so
compliance/*.yaml looked as though it covered
compliance/packs/eu_ai_act.yaml. setuptools expands these patterns with the
glob module, where it does not.
The result was a container that built cleanly, loaded its rules, found no catalogue for any control they cite, and refused to decide against a bundle it reported as broken. Only the container test saw it; 2,346 tests against the checkout were green, because the checkout has the files.
The matcher is PurePosixPath.match now, which segments the way setuptools
does, and removing the pattern fails ten tests by name instead of none.
Fixed - an unreachable PostgreSQL hung instead of erroring¶
libpq has no default connect_timeout, and this never set one. A database that
does not answer -- a wrong port, a security group that drops rather than
refuses, a server that went away -- waited on the operating system's connect
timeout instead: measured at over a minute on Windows with nothing
listening.
qed serve and qed doctor both open the store, so the operator-visible
symptom was a command that printed nothing for a minute, which reads like a
wedged process rather than a database that is down. Now PostgresUnavailable
in ten seconds, naming the cause. A connect_timeout= already in the DSN wins:
an operator who wrote one meant it.
Found by accident, which is the only honest way to describe it: a test run stalled after the local Postgres container stopped, and chasing the stall found a real hang underneath it.
Fixed - the arm64 tests could vanish from a green CI run¶
The emulation probe ran docker run --platform <foreign> busybox true and read
its exit code. That returns the same False for "this machine has no emulator"
and "the registry did not answer just then", and the second of those happened:
the deploy job skipped both arm64 tests one step after installing the
emulator. The job's own "something skipped" guard caught it, which is why that
guard exists -- but it named the symptom and not the test.
The pull is now its own step, so a registry failure prints as one. And because
that job installs the emulator itself, it sets QED_REQUIRE_EMULATION=1: the
tests then run and fail by name rather than skipping, since a skip there is a
green run with the test that caught the last arm64 bug not having run.
Every probe in that file also has a timeout now. They run at import, which is
collection time for the whole suite, and an unbounded subprocess.run there is
a suite that hangs with no test name to blame -- and it is docker being
asked, so a busy daemon is the ordinary case.
While there: the job ran pytest tests/deploy twice, building the container
twice for one result. Once now.
Fixed - a leadership test raced the Postgres backend reaper¶
test_a_dead_connection_releases_it closed the leader's connection and
asserted the immediate retry took the lock. close() sends a terminate and
returns; PostgreSQL releases that session's advisory locks when it reaps the
backend, so the next pg_try_advisory_lock can arrive first. It passed on
every local run and failed on CI.
The race was in the test, but it names a real property of the takeover: a
crashed leader's lock frees up shortly after the crash, not instantly, which
is why qed/api/leader.py asks every tick rather than once. The test now waits
up to ten seconds and says what a longer stall would mean.
Fixed - qed packs list printed a section sign¶
A unicode escape for the section sign sat in a CLI string, and a Windows console at the default code page cannot encode it. The ASCII assertion on command output is a week-1 lesson that this file had not yet learned.
[0.6.1] — 2026-10-10¶
Four fixes from validating v0.6.0 end to end -- installing the built wheels
into a clean venv, driving the installed scripts, and starting the HA stack.
Three of the four were invisible to a green 2,200-test suite, and all of them
are in docs/validation.md.
Fixed - the arm64 image had never worked¶
/usr/lib/x86_64-linux-gnu was hardcoded in the Dockerfile, so on arm64 every
shared library landed where that architecture's loader does not look and the
image died before it could say anything useful: python3.12: error while
loading shared libraries: libpython3.12.so.1.0. Nothing caught it because the
container test only ever built for the host while the release workflow builds
both.
The triplet now comes from sysconfig.get_config_var("MULTIARCH") -- the
interpreter being copied is the right thing to ask -- and the libraries stage
as a filesystem tree so each lands at its real path. Three guards: the script
asserts libpython3.12.so.1.0 by name, a test refuses a hardcoded triplet, and
tests/deploy/test_container.py now builds and starts the other
architecture under emulation.
Fixed - a replica could die on startup in the HA path¶
record_bundle was a SELECT then an INSERT. Two replicas starting together
both saw no row, both inserted, and the loser died on UniqueViolation:
policy_bundles_pkey before its API came up -- so a simultaneous start or a
rolling restart could leave a replica refusing to serve, in the one deployment
shape the PostgreSQL backend exists for.
One statement with ON CONFLICT DO NOTHING now, with rowcount deciding who
was first. The loser returns False rather than raising: another replica getting
there first is the normal case for every replica after the first.
Fixed - AG-BLR-007 missed terraform.destroy¶
The rule is CRITICAL, BLOCK, no approvers -- teardown goes through a change
window with a named owner. It matched terraform destroy only inside
action.params_redacted.command, which is what the shell adapter sends. An IaC
adapter sends action.name: terraform.destroy, and that returned ALLOW,
blast radius 40, no rule matched.
Widened to the tool names (terraform, tf, pulumi, cdk, cdktf + destroy, and
helm uninstall/delete), scoped to the tool rather than a bare destroy which
would have taken auth.destroy_session. Six golden cases, three that must fire
and three that must not. The policy hash changes, which policy_loaded
records.
Added - examples/policy-cases.yaml¶
Seven runnable cases for the shipped pack, so qed policy test
examples/policy-cases.yaml works out of the box. The format was documented and
no example shipped, which meant somebody who installed the wheel got 46 rules
and no way to watch them decide anything. Writing it is what found the
AG-BLR-007 gap.
Fixed - a smoke test that failed on a good build¶
docker run ... --help | head -3 under set -o pipefail: head closes the
pipe, the producer dies of SIGPIPE, and the step fails on an image that is
fine.
[0.6.0] — 2026-10-09¶
Weeks 2–7 of the SPEC §22 build order. Everything below accumulated under
[Unreleased] across six weeks and had never been cut: v0.1.0 was the last
tag, so the versions the exit criteria name (v0.2.0 … v0.6.0) do not exist
as releases. This is all of them, under the version week 7 asked for, rather
than five tags invented after the fact for commits that were never shipped.
Added - key rotation (SPEC 10.5, week 7)¶
qed keys rotate. The rule it implements has been in qed-verify since week 1
-- a key_rotated record must be signed by the retiring key -- and nothing
produced the record, so the only way to change keys was qed keys init
--overwrite, which breaks the chain. ADR-0010 records three decisions:
- The old private key is destroyed. Rotation exists because a key might
have leaked, and one left on disk reduces nothing. The threat is not editing
an existing record --
hashcoverspayload_hashand the nextprev_hashcovershash-- it is fabricating a whole alternative history that verifies under a key an auditor still trusts for the period before the rotation. - The record is appended before the key is activated. Activating first would leave the chain on the old key with the next record signed by the new one and nothing in between saying so: a discontinuity indistinguishable from a forged chain, produced by the correct command.
- The writer enforces continuity too, not only the verifier.
ChainWriter.__init__raisesKeyDiscontinuityunless the active key signed the last record or akey_rotatedat the tip introduced it. The verifier is the authoritative place and the wrong moment: by then the records exist, are immutable, and are unverifiable.
qed keys export-public now prints every key with an active/retired
marker. The old behaviour made "send the auditor your public key" produce a
file that verifies half the chain and reports unknown_key for the rest.
Added - retention and purge (SPEC 10.6, week 7)¶
Week 7's exit criterion is "purge-then-verify passes", and it does: hash
recomputes from payload_hash, so a record whose payload is gone still
verifies its linkage and its signature.
The interesting part is what retention refuses to touch. Five record types
are structural and can never be purged, whatever the config says: a
checkpoint's payload holds its Merkle root, a key_rotated's holds the
incoming public key, genesis and policy_loaded hold the chain's identity
and the rules in force, and admin_action is the record of the purges
themselves. Erasing the record of what was erased is the one thing retention
must not do.
That exemption is why the plan is an explicit set of sequence numbers rather
than seq <= N: retention is per type, so the expired set is not contiguous,
and a range would take an exempt checkpoint in the middle of it.
apply_retentionappends anadmin_action{action: "retention_purge"}. A NULL payload with nothing saying why is indistinguishable from data loss.- The policy reports ages as the config wrote them.
24hreported back as1dis a small lie in a document an auditor reads. 0dis refused rather than obeyed: it reads like "no retention", means "purge everything now", and is almost certainly a typo forforever.
Added - checkpoint anchors (SPEC 10.4, week 7)¶
A checkpoint on its own proves the instance was internally consistent when somebody read it; an operator who rewrote the chain could recompute every root. Publishing puts the root somewhere the instance does not control.
- file -- one canonical record per file, named so the set sorts, refusing to overwrite an anchor. Says plainly that a directory on the same disk as the chain anchors nothing.
- s3-object-lock -- the one that actually anchors: compliance mode means
nobody can delete the object until the retain-until date, not even the
account root. It asks whether the bucket has object-lock, because one
without it accepts the upload and provides no lock, and reports
okwith a caveat rather than implying a protection that is not there.governancemode logs a warning: a principal withs3:BypassGovernanceRetentionis the same set of people who could rewrite the chain. - git-tag -- an annotated tag carrying the record, pushed to a remote
somebody else can read.
push: falseis a local tag as rewritable as the chain beside it, and the receipt says so.
Receipts go in the next checkpoint, never this one. A receipt describes a
record that has already been hashed and signed, so writing it back would change
the hash the receipt is about. previous_receipts was a [] placeholder from
week 1 and now carries them.
A failed publication never stops the chain and never disappears. It lands
in the checkpoints table, in the next checkpoint's payload, and in
qed doctor.
Added - the maintenance pass (SPEC 10.4, 10.6)¶
qed/infra/maintenance.py: checkpoint, publish, purge, in that order -- the
order matters, because purging before the root is anchored would leave a window
in which the payloads were gone and nothing outside the instance had attested
to the hashes they produced.
Run from the background loop every 30 seconds and from qed checkpoint.
Nothing was doing any of it: writer.checkpoint() was reachable only from the
CLI, so an instance nobody ran that command against had a chain with no
anchors, and SPEC 10.4's "every 1,000 records or 3,600 s" was a threshold with
no clock behind it.
Added - Prometheus /metrics (SPEC 18)¶
The nine metrics SPEC 18 names, hand-rolled rather than via
prometheus_client -- the set is fixed and small, and a client library
registers process and GC collectors in a global registry at import, so
/metrics on a policy kernel would start reporting the interpreter's
internals. The tests parse the output, because a nearly-valid metrics
endpoint is worse than an absent one: Prometheus drops the whole scrape on a
parse error and reports it in its own logs rather than yours.
Open, like the probes: a scraper is not an adapter and has no signing secret, and a bearer token in every Prometheus config is the arrangement that ends with monitoring switched off. Labels are bounded by construction -- a metric labelled with a proposal's own strings is a cardinality explosion an agent can trigger on purpose.
Added - OCSF events for a SIEM (SPEC 18)¶
A verdict and an approval_decided map to Authorization (3003);
everything else to API Activity (6003). Severity comes from what the
decision did, not from the rule's severity: a CRITICAL rule that fired in
shadow mode stopped nothing, and paging on it would train people to ignore the
channel.
Shipped by a bounded queue and one background thread, because a SIEM must never be able to slow down or fail a decision. At 10,000 events the oldest are dropped and counted -- dropping the newest would mean a burst hid itself -- and failures are counted rather than retried forever, because a webhook down for an hour has lost an hour of events and the operator needs to know.
observability has been in UNIMPLEMENTED_SECTIONS since week 1, which means
a config naming it was silently dropped. It is out of that list now.
Added - the perf suite (SPEC 18, 22 week 7)¶
p95 is 4.5 ms on SQLite and 10.6 ms on PostgreSQL, against a 50 ms target.
SPEC 22 also asks for "1k rps proposals", and a single Python replica does not
reach it: ~250 decisions/s on SQLite, ~105/s on PostgreSQL. That is written
down in docs/performance.md rather than quietly reinterpreted -- 1,000/s is
four to ten replicas, which is what the PostgreSQL path exists for, and four
replicas on one machine reach ~290/s so the scaling is real.
ADR-0009's claim now has a number: one append on PostgreSQL is 4.4 ms direct and 1.6 ms deferred, so the outbox is 2.8x cheaper and "decision latency is independent of chain latency" is a measurement rather than an argument.
Fixed - strict masking was eating two values the chain exists to carry¶
Found by reasoning about two features built in the same week. The strict
profile masks any hex run of 32+ characters and any base64 run of 40+, which
ate a key_rotated's new_public_pem and a checkpoint receipt's sha256.
Masking runs before payload_hash, so neither is a verification failure:
the record still verifies against its own hash, qed-verify reports OK, and
the chain is internally consistent while having quietly lost the thing it was
written to say. Same shape as the bug where masking rewrote sha256 digests and
broke a checkpoint's Merkle root.
The fix is by record type: qed.core.schemas.STRUCTURAL_TYPES is the five
types whose payloads the kernel writes, and build_record does not mask them
at all -- no agent wrote them, so there is nothing in them to redact. It is
deliberately the same set retention may not purge, and NEVER_PURGE is now an
alias for it.
Fixed - the background loop could not touch SQLite¶
Broadening the loop to run maintenance exposed it. asyncio.to_thread uses a
pool and a sqlite3 connection may only be used by the thread that created it
-- the API's was created by the event loop at startup -- so every maintenance
tick on the default backend died with ProgrammingError: SQLite objects created
in a thread can only be used in that same thread, in a log nobody reads. The
drain never hit it because the drain only ever ran on PostgreSQL.
The loop now runs on one dedicated thread with its own session, and
start() requires the factory that opens it: sharing the caller's session
works on PostgreSQL and fails on SQLite after the first read, so refusing up
front is the difference between a mistake that is impossible and one that is
merely unlikely.
Added - evidence.signing_key¶
The config field existed since week 1 and was never read. It now names the private key wherever it is, which is what a container needs: the chain is on a writable volume or in a database, the key arrives from a read-only secret mount, and nesting one inside the other to satisfy a fixed filename works until a kubelet orders the mounts differently.
Changed - qed checkpoint runs a whole maintenance pass¶
It checkpoints, publishes the root and applies retention, reporting each. The three are together because they have to happen in that order.
Changed - qed doctor reports the chain's housekeeping¶
Retention (and what it would purge on the next pass), the configured anchors and the last publication, an interrupted rotation, and the SIEM target reduced to scheme, host and port -- that output ends up in support tickets, and a webhook URL often carries a token.
QedGroup.invoke turns InstanceMismatch and KeyDiscontinuity into one red
sentence for every command, on the group rather than in main(), because
main is not the only way in: the test runner and anything embedding
qed.cli.app invoke the group directly.
Added - the outbox and one chain leader (SPEC 10.3, 15, 104; week 6)¶
PostgreSQL made it possible to share a chain between replicas. This makes it safe.
The chain is the obstacle. Every record carries seq and prev_hash, so
appending means reading the tip, computing the next link and writing -- and two
replicas doing that concurrently read the same tip. Either the primary key on
hash refuses the loser, and a decision that was already delivered has no
evidence, or both commit and the chain forks. A forked chain still verifies
record by record. It just stops being a chain, and nothing says so until
somebody walks it.
Holding the lock across each decision would fix that and lose the reason for having replicas: decision latency would become chain latency plus contention, against SPEC 18's p95 < 50 ms. So the two guarantees are separated. "No decision without a record" is satisfied by durability, not by chaining.
ChainWritergained awrite_mode. Indeferredmodeappend()masks the payload, writes it toevidence_outboxand returns anAppendedcarrying a reference instead of a record. Masking happens at enqueue time because the outbox is durable storage like any other.- The backend chooses the mode, not a flag. Postgres exists in this design for more than one replica, and more than one direct writer forks the chain; a flag would be a way to configure the fork back into existence.
qed/infra/chain_drain.pyassignsseq,prev_hash,hashand the signature in batches of at most 100, one transaction per batch, reading the tip inside that transaction. A row it cannot parse rolls the batch back and is reported; it is never deleted. Dropping it would turn a parsing bug into a missing record, which is the one outcome the chain must never produce quietly.qed/api/leader.pyis the loop around that drain, started from the app's lifespan on the deferred path only. It re-askspg_try_advisory_lockevery two seconds rather than remembering leadership: the lock can be lost without this process being told -- a connection reset, a failover, an administrator runningpg_advisory_unlock_all-- and a replica appending on a stale belief is precisely the fork the lock exists to prevent. It never blocks on the lock either; a follower that blocked would hold a connection waiting for a leader to die, then wake up holding it with no idea how long it had waited.- Genesis and checkpoints stay direct, even in deferred mode. Record 0 has nothing to be ordered against, and a chain whose first record was queued behind others would be a chain with no beginning.
runtime.settle()drains on the way out of a one-shot command.qed checkenqueues and exits, so without this a CLI-only Postgres deployment would have a chain that never advanced.
Verified against a real PostgreSQL 17 and then against two real containers.
tests/evidence/test_ha_outbox.py runs two connections and asserts the
property: no gaps, no duplicate sequence numbers, nothing lost, a leader killed
mid-stream and taken over by the survivor, and an export of the result
verifying through qed-verify -- which has never heard of replicas, outboxes
or leaders. ADR-0009 records the design and the three alternatives rejected.
Changed - evidence_ref is a reference, not always a sequence number¶
GET /v1/evidence/by-ref/{ref} now resolves both shapes, and answers
200 {"seq": null, "pending": true} for a record that is queued. Not 404: the
record exists and is durable, a 404 would tell a caller their evidence was
lost, and an integration retrying on 404 would give up on a record that was
about to arrive.
chain_status -- so qed status and GET /v1/status both -- now reports
store, write_mode and chain_lag_records, which is SPEC 18's
qed_chain_lag_records until there is a /metrics endpoint to carry it. A
depth that keeps growing means the leader is gone, or is losing the lock faster
than it can drain.
Changed - qed check exit codes now match SPEC 12.5¶
2 is BLOCK and 3 is REQUIRE_APPROVAL. They were the other way round
since week 1, which was a guess made before the spec section was read closely,
and it also disagreed with the Claude Code hook's own EXIT_BLOCK = 2. Nothing
is released, so this is corrected rather than documented as a deviation.
1 remains "QED could not decide" -- a missing key, an unreadable proposal, an
unreachable instance. That distinction is the one a pipeline cannot do without:
a refusal is not a malfunction, and conflating them lets a misconfigured runner
look like a policy that allows everything.
Added - qed check in CI (SPEC 12.5)¶
--server URLdecides on an instance instead of locally, over a HMAC-signed request (qed/adapters/remote.py). This is the mode that makes sense in CI: no evidence signing key in the runner, the evidence outlives the build, and the rules that gate CI are the rules that gate everything else. An instance that cannot be reached exits1and never0-- "probably allowed" is the failure this project exists to prevent.--evidence-out PATHwrites the chain as JSONL after a local decision, so an ephemeral runner leaves somethingqed-verifycan check. The whole chain, not just the verdict: verification walks linkage from genesis.docs/ci.mdhas the exit-code table, both modes and a GitHub Actions workflow.
Added - Helm chart (deploy/helm/qed, SPEC 104, 24)¶
Four things it refuses to install, because each is otherwise discovered at runtime where the symptom is a CrashLoop or a forked chain:
- no evidence signing key -- and the chart will not generate one, because a key created by an install hook lives wherever the hook wrote it and one created per pod starts a new chain on every restart;
replicaCount > 1on SQLite;evidence.backend: postgreswith no secret holding the DSN;- SQLite with persistence disabled.
Defaults chosen to be safe rather than convenient: mode: shadow, the image
tag pinned to the chart's appVersion rather than latest, a read-only root
filesystem with one writable mount, helm.sh/resource-policy: keep on the
evidence volume, a ConfigMap checksum that rolls the pods when the policy
bundle changes, and no service account token. tests/deploy/test_helm_chart.py
renders the chart with helm template and parses it.
deploy/docker-compose.yml is the same topology on a laptop: two replicas, one
PostgreSQL, one chain. Kill the leader and watch the other take over.
Added - evidence.signing_key¶
The config field existed and was never read. It now names the private key wherever it is, which is what a container needs: the chain is on a writable volume or in a database, the key arrives from a read-only secret mount, and nesting one inside the other to satisfy a fixed filename is the sort of arrangement that works until a kubelet orders the mounts differently.
Fixed - the container had never actually run¶
Both of these passed every check in the repository and failed the moment anybody started the image.
- The runtime base was
distroless/python3-debian12, which ships Python 3.11, while the builder installed wheels for 3.12. The 3.12 extension modules landed beside a 3.11 interpreter that could not load them, and the first symptom wasImportError: cannot import name 'ArgsKwargs' from 'pydantic_core._pydantic_core' (unknown location). The runtime is nowdistroless/base-debian12with the interpreter copied from the builder and its shared-library closure computed byldd(deploy/stage-libs.sh) rather than listed -- a hand-written list of Debian libraries goes stale on the next base bump and fails at import time in production. qed/compliance/frameworks.yamlwas missing frompackage-data, so it was absent from the wheel, so the image started, bound its port and died on the first request with aFileNotFoundErrorpointing inside site-packages.tests/test_packaging.pynow checks thepackage-datapatterns against what is on disk, so a new data file that is not listed fails here instead of in somebody's cluster.
The image also now includes the postgres extra: which backend an operator
uses is a property of the deployment, not of the artifact they were handed.
tests/deploy/test_container.py builds the image and starts it -- CLI, every
runtime import, the packaged data files, and a decision whose chain is verified
on the host. Marked slow and skipped without a Docker daemon; CI runs it in
its own deploy job.
Fixed - charge_budget's daily rollup was refused by PostgreSQL¶
ON CONFLICT ... DO UPDATE SET steps_used = steps_used + excluded.steps_used.
SQLite accepts the bare column; PostgreSQL calls it ambiguous, because
excluded has a column of the same name -- which is exactly what the
expression is adding. The existing column is now qualified with the table name.
It is the only upsert in the store that accumulates rather than replaces, and
the cross-backend test that should have caught it called charge_budget
without a principal_hash, so the branch never ran. The argument is now the
point of that test, and a second test drives a whole decision -- the same
check_proposal the API calls -- against PostgreSQL, so the next
dialect divergence in any method on that path fails in CI rather than in a
container.
Added - a 202 now carries the approval it opened¶
POST /v1/proposals filled "approval": null unconditionally, a week-1
placeholder that week 4 never came back to. SPEC 13's optional approval? is
now populated with the id, status, approvers, expiry and poll path. Without it
an adapter holding a 202 would have to call GET /v1/approvals/{id} before it
could say anything useful -- and it does not know the id yet, which is the
point.
Added - the PostgreSQL backend (SPEC 15, week 6)¶
SQLite stays the default and runs the whole product on one file. Postgres is for the one thing SQLite cannot do: more than one replica sharing a chain.
One store implementation, not two. The forty repository methods are written
once and run against either backend. Two classes would be two implementations
of the chain's append path, and the day they drift is the day the chain behaves
differently depending on which database somebody deployed. What made that
practical is that the two drivers already agree about more than expected --
both expose execute(sql, params) -> cursor and both return rows addressable
by column name -- so qed/infra/storage/dialect.py has four fields in it and a
test asserts it stays that small.
- The migrations are shared, and that was checked rather than assumed. The week-1 scripts carry a comment claiming they sit in the SQL subset both engines accept. All four were replayed against a real PostgreSQL 17 before the seam was written, and they do -- so there is one set of files and no per-dialect directory.
- The SQL is written with
?and converted for psycopg.tests/evidence/test_store_dialects.pyasserts no SQL string contains a quoted?, which is the one thing that would make the conversion change a statement's meaning. - Timestamps stay TEXT on both, in the format the record already carries.
tsis hashed, so a native timestamp column would let the backend's own precision change the bytes. pg_try_advisory_lock(1734440037)for the chain leader, non-blocking, withtry_become_leader/holds_leadership/resign_leadership. The lock is session-scoped so a crashed replica releases it, which is the liveness signal a leader election needs; a lease in a table would need a heartbeat, a clock, and a decision about what to do when the clock is wrong.- Migrations take a different advisory key, transaction-scoped. Two replicas
starting together would otherwise both run
0001and the loser's ledger insert would fail, crashing a replica during an ordinary rollout. Holding one key must not imply holding the other: a replica applying a migration is not a replica that may append. psycopgis an optional extra ([postgres]) and asking for Postgres without it says what to install. The matrix jobs deliberately run with it absent, which is how almost everyone will run the product; a separatepostgresjob installs it and the service container.
Fixed - genesis was the one record whose clock nothing could pin¶
ChainWriter.ensure_genesis() took utcnow() with no way to override it,
while every other write path accepts a ts precisely so a caller can be
deterministic. Since every later prev_hash descends from genesis, that made a
whole chain irreproducible from its inputs.
tests/evidence/test_cross_backend.py is what noticed: the two backends
produced identical records and different chains, because genesis had taken its
own clock eight milliseconds apart on the two runs. ensure_genesis(ts=...)
now exists and append passes its own ts through, so pinning the first
record pins the genesis before it.
Changed - SqliteStore moved to qed/infra/storage/store.py¶
It gained a sibling, so it lives beside SqlStore and PostgresStore.
qed/infra/storage/sqlite.py re-exports it: fifteen modules import it from
that path and renaming them all would have been churn with no reader benefit.
ChainWriter and Session are typed on SqlStore now rather than the SQLite
one.
37 new tests. 1,805 with Postgres running, 1,788 with it skipped.
Added - the Python SDK (SPEC 12.4, week 6)¶
from qed.sdk import Qed, guard
qed = Qed.from_env()
@guard(qed, target=lambda account_id, **_: f"crm:accounts/{account_id}", deletes=True)
def delete_account(account_id: str) -> None: ...
The fourth adapter, for the action that has no front: a function in your own
application. The decorator proposes before the call, raises QedBlocked or
QedApprovalPending if the answer was no, runs the function, and reports what
happened. A refused action never runs, and a test asserts the body was not
entered.
QedUnavailableis a separate exception from a refusal. "The policy says no" and "I could not find out what the policy says" are different facts, and an application that treats them the same will eventually treat an outage as permission.- It fails closed by default, unlike the coding-agent hook. An application
has no human watching, so a QED it cannot reach must stop the action.
fail_mode="open"is a deliberate decision, not a default. - Advisory by default (ADR-001 class A): whoever can edit the code can
delete the decorator.
enforcement="enforcing"is for when the credential the function needs comes from the broker, so going around QED leaves the function unable to authenticate. target,summary,intentandparamsmay each be callables taking the wrapped function's own arguments, because an approver needs to know which account is being deleted rather than which function was called.writes=Trueby default. A hint that under-reports the damage routes a destructive call past the blast-radius score; one that over-reports asks a person about a harmless call. Only one of those is recoverable.- The wrapped function's own exception is reported and re-raised untouched. The application's failure is the application's to handle.
Verdictis a thin view over the kernel's JSON rather than a second model, so there is nothing here to drift out of step withqed.core.schemas.report()is the one place in the SDK that swallows an error: the action has already happened, and raising would replace the application's own result with a reporting failure.examples/sdk/has governed LangGraph and CrewAI tools. Both run whether or not those frameworks are installed, because a framework tool is an ordinary Python function and that is what@guardwraps -- which is also why there is noqed-langgraphpackage to keep in step with somebody else's API.
Changed - the request signing moved out of qed/api¶
canonical_string and sign now live in qed/adapters/signing.py, with
qed/api/auth.py re-exporting them. The SDK has to sign, and importing
qed.api.auth to do it pulled FastAPI, Starlette, Click and Pydantic into
whatever application embedded the SDK. An SDK that drags a web framework into
a LangGraph worker is an SDK people route around, and routing around it means
their actions stop being recorded.
It cannot live in qed/core or qed/infra, which are scanned for any mention
of HMAC so that the evidence chain stays Ed25519-only. Adapter authentication
is a different problem with a different right answer, and keeping it out of
those trees is what lets that invariant stay absolute.
tests/sdk/test_sdk_is_light.py holds both halves: import qed.sdk loads no
server, and the signer and the verifier still agree byte for byte.
Fixed - two fields that were on some rows and not others¶
execution_resultreported throughPOST /v1/proposals/{id}/resultcould not carryduration_ms, while both proxies record it when they write to the chain directly. An auditor would have found the field on some rows and not others for no reason a reader could explain. Still an allowlist -- an adapter does not get to put arbitrary keys in the chain.approval_decidedwritten byqed approvehad noidentity, which the API path has carried since the week-4 work. It now recordscli-asserted:--by, beside the API'soidc:<issuer>andadapter-asserted:<id>. Three paths, three strengths, one field. Found by reading the chain after a live SDK run.
82 new tests, 1,764 total.
Added - the Claude Code hook and qed install (SPEC 12.2, week 6)¶
Every Bash, file write, MCP call and skill then goes through the same
check_proposal the two proxies and qed check use, and lands in the same
chain. docs/hooks.md; the three decisions that depart from SPEC 12.2 are in
ADR-0008, which exists because the spec was written against the older
exit-code-only hook contract.
- An ALLOW verdict prints nothing at all. The contract has a
permissionDecision: "allow"and it bypasses the permission rules the operator configured. A gate answeringallowto everything it did not block would be granting permissions nobody asked for -- somebody who had deniedBash(rm *)in their own settings would find QED waving it through. Exit 0 with no output means "no decision, carry on with the normal flow", which is the honest answer from something asked whether to stop a call. A test asserts the string never reaches stdout. - REQUIRE_APPROVAL answers
ask, not a refusal. SPEC 12.2 degraded it to exit 2 "(hook cannot wait)". It still cannot -- butaskhands the call to the person at the terminal, which is what the rule asked for. The approval is still opened and recorded, so it can also be decided out of band and the window applies to the retry. - Nothing ever exits 1. Any non-zero exit that is not 2 is non-blocking:
the call proceeds and the transcript shows a notice. So a traceback would not
fail closed, it would fail silently open -- the hook documentation warns that
a mistyped path "silently disables the gate". Every path out of the adapter
is a deliberate exit, and a fail-open gap writes an
ungated_window. mcp__postgres__execute_sqlbecomespostgres.execute_sql, the sameaction.namethe MCP proxy writes, so one rule matches the same action through either adapter.- File paths are recorded relative to the session directory, so the chain does
not carry somebody's home directory and a rule against
src/*matches on two machines. A shell command targetshost:<hostname>, because the command is the parameters. agent_chainis real, not asserted: Claude Code sendsagent_idandagent_typeonly inside a subagent, so["claude-code@host", "sub:Explore"]means one genuinely is acting.- Always
enforcement: advisory(ADR-001), andHookOptionsnow refuses an identityless construction like the other two adapters do. qed installmerges rather than replacing, leaves other hooks and the rest of the file alone, defaults to the gitignoredsettings.local.json, resolves the executable to an absolute path, and writes the exec form so a Windows path needs no quoting. It is idempotent on the invocation rather than the path: recreating a virtualenv replaces the entry whose executable moved instead of leaving a dead one beside a live one, and a dead entry is non-blocking.- It prints the hardening route (
allowManagedHooksOnlythrough managed settings) and is clear that hardening is not enforcement. SPEC 12.2's note namesallowManagedPermissionRulesOnly, which is the permissions equivalent and does not lock hooks.
Codex is not built: SPEC 12.2 says "implement when API confirmed" and it is
not. A stub that recorded nothing would be worse than its absence, because
qed doctor would list an adapter that does nothing.
70 new tests, 1,719 total.
Added - HTTP forward proxy (qed proxy) (SPEC 12.3, week 5)¶
Point an agent's HTTPS_PROXY at QED and every outbound call gets a
verdict from the same check_proposal the MCP proxy and qed check use.
docs/http-proxy.md has the whole picture; the parts worth stating here:
- Every CONNECT tunnel is recorded as
advisory, whatever the command line asks for. QED decides whether the connection may be opened, which is real and the refusal is real, but once open it carries ciphertext: no method, no path, no body, and no way to replace the credential inside. An adapter recordingenforcingthere would put a control in the evidence that the transport does not allow. The reason travels with the record, and theexecution_resultsaysvisibility: host onlyin the row rather than only in the documentation. - A tunnel's evidence is byte counts and a duration. Thin, labelled as thin, and still the difference between "an agent moved 41 MB to pastebin.example at 02:14" and no record at all.
- Plain HTTP starts honest too. Enforcing needs a
kind: httpupstream whose credential resolves; with none, the session is advisory and says so at startup. The MCP proxy keeps the operator's flag in that case because deployments existed before the broker did. This adapter has no such history, so it does not inherit the dishonesty.--enforcementis a ceiling: it can ask for less than the broker holds, never for more. action.targetis a URL. Not cosmetic:match_upstreamfinds a host glob by splitting the target on://, and the shippedAG-DAT-003egress rule matchesaction.targetagainst*://*. A target in another shape would leave both matching nothing, and an egress rule that never fires is invisible.- The query string stays out of the target and goes into the parameters. It carries tokens and its order varies between calls that mean the same thing; either would make a policy glob leaky or useless.
- The body is not read in
host_pathmode, soparams_hashcovers the request line and the headers that describe the body. Stated rather than implied; a rule that needs the body needsmitmmode, which is post-v1. X-QED-Intentis the HTTP counterpart of MCP's_meta.intent, and is stripped before forwarding: it is for QED, not the upstream.- Refusals are a
403whose body carries the rule ids, because an agent that only logs the status still logs the body with it.502upstream,503kernel-failed-and-closed,501for what this build will not forward. - Strict request framing. Both
Content-LengthandTransfer-Encoding, two disagreeingContent-Lengths, an obs-fold line, a non-token header name, aCONNECTwithout a port, userinfo in a proxied URL: all refused with a status rather than resolved. A proxy that resolves an ambiguity can be made to disagree with its upstream about where a request ends, and the smuggled half would be the half QED never decided. One request per connection, for the same reason. - Binding anything but loopback needs
--allow-non-loopback. A forward proxy on a reachable interface is an open relay, and the chain would attribute every request through it to this session's--human. inject: {type: env}on anhttpupstream is refused at startup rather than silently dropped per request.- New rule AG-DAT-013: a request to a cloud instance metadata service is BLOCKed with no approval path. One unauthenticated GET to a link-local address returns the instance role's keys, the workload's own SDK reads it in-process rather than through a governed egress path, and this adapter is the first one that can see the attempt at all. 46 rules.
Fixed - a result claimed an enforcement its own proposal denied¶
The first live run of qed proxy put this in one chain, two records apart,
about the same call: the proposal recorded advisory, correctly, because no
upstream was configured -- and the execution_result beside it recorded
enforcing, with assurance: adapter, because the recording path read the
operator's command-line flag instead of the answer that had just been derived.
Both proxies had it. The MCP adapter's execution_result was written the
same way, so the fix that arrived with enforcing MCP was half a fix: the
proposal told the truth and the record of what happened did not.
The derived enforcement now travels from the proposal down to the recording
site in both adapters, and falls back to the option only on the fail-open
path, where nothing was decided and so nothing was derived. assurance
follows it, so an advisory call is recorded as self -- the agent's own
testimony, per ADR-007.1 -- rather than as the adapter's.
Added - the linter catches a glob that is secretly a character class¶
fnmatch reads [ as the start of a character class, so
*://[fd00:ec2::254]* does not look for that IPv6 literal: it matches any
target whose host begins with one of f d 0 2 4 5 c e :. A rule written that
way reads correct and fires on traffic nobody meant it to, in the direction
that blocks real work.
AG-DAT-013 shipped with exactly that mistake, and the first live qed proxy
run was refused a CONNECT to example.com by the instance-metadata rule --
e was in the accidental set. The golden case written beside the rule used
api.github.com and passed the whole time, because a was not.
suspicious_glob is a warning, not an error: a character class is a legal
glob feature, rarely the intended one but not something to forbid. The message
names both the escape ([[]) and the predicate the author probably wanted
(contains). The golden table now also carries one host per character the
accidental class contained.
Fixed - masking was rewriting the chain's own identifiers¶
About one sha256 digest in four hundred contains a 13-to-19 digit run that
starts with 2-6 and passes Luhn, so the card-number redactor replaced it with
[masked:card]. Masking runs before payload_hash, so the record still
verified against its own hash and nothing reported a problem -- the
merkle_root was simply wrong, and the checkpoint became impossible for
anyone to verify, for ever. params_hash and policy_hash were exposed at
the same rate.
It surfaced as tests/evidence/test_chain.py going red on roughly one push in
a hundred, on whichever runner happened to generate an unlucky signing key.
Two fixes, each sound on its own:
- A digit run flanked by letters is not a card number. The pattern now requires a non-alphanumeric boundary, which is what a real card number in a payload always has -- whitespace, a quote, a comma, a colon.
_STRUCTURAL_KEYS(merkle_root,params_hash,policy_hash,prev_hash,key_id,human_hash, …) are exempt from masking, because a redactor has no business rewriting a value the kernel generated. The exemption is conditional: the value must look like an identifier and contain a letter, so a card number, a phone number or a national id -- all pure digits -- can never be exempted by sitting under one of those keys.
Old chains are unaffected. qed-verify recomputes payload_hash from the
payload as stored and never re-runs masking, so a record written under the
earlier pattern set still verifies.
Fixed - a host glob stopped matching when the target had a port¶
host_globs is documented as matching the host and not the string, but
_host_of left the port on. The HTTP proxy found it: it writes
http://127.0.0.1:8431/v1/orders whenever the port is not the default, a glob
of 127.0.0.1 then matched nothing, the upstream went unmatched, and the
request ran advisory while the config said enforcing. target_globs still
sees the whole string for an operator who does want to distinguish ports.
Fixed - qed/adapters/http/ shadowed the standard library¶
The proxy arrived as qed/adapters/http/ with tests in
tests/adapters/http/, and every test that runs
python tests/adapters/upstream_server.py as a subprocess started failing
with ModuleNotFoundError: No module named 'http.client' -- three imports
deep inside Starlette, nowhere near the directory that caused it. Running a
file puts its own directory at the front of sys.path. Both packages are now
http_proxy, and tests/test_no_stdlib_shadowing.py catches the next
plausible name (json, types, select, email) when the directory is
created rather than a week later.
Added - enforcing MCP (SPEC 7.2, week 5)¶
context.enforcementis derived per action, not asserted by a flag. QED is enforcing for a call if and only if it is holding the credential for it. Before this an adapter was told it was enforcing by a command-line option, that claim reached the evidence, and a missing credential made it silently false.- Credentials are injected into a stdio upstream at spawn, into the child's environment. A child receives its environment once, so "resolved per request" is true for HTTP and not for stdio, where the TTL cache governs re-spawns. Saying so beats implying a freshness the transport cannot give.
strip_agent_credentialsremoves nine credential-shaped headers, case-insensitively, before the broker's own is attached. An adapter that added its header while leaving the agent's would be one where the agent still chooses which credential is used.sigv4reportsneeds_signingrather than faking a header: the signature covers the request body, so it cannot be planned ahead, and forwarding unsigned would mean the agent's credentials get used after all.ProxyStats.advisoryis printed separately, because a session that was entirely advisory looks identical to an enforcing one in every other count.- With no upstreams configured the adapter keeps whatever the flag says. Going silently advisory on an existing install would be the worse surprise.
- The upstream test server gained
echo_env, which is the only way to show the injected credential reached the child process rather than stopping at the proxy.
Fixed - one Enforcement, not two¶
The broker defined its own enum with the same two members. It type-checked everywhere and compared unequal in exactly the one place it mattered, which is where mypy found it. The schema's enum is now re-exported.
Added — credential broker: matching, resolution, posture (SPEC §7, week 5)¶
qed/core/broker/(pure) matches an action to an upstream;qed/infra/secrets/resolvesenv,fileandvaultreferences behind a TTL cache;qed/infra/upstreams.pyloadsupstreams.yaml.Secretmakes a leak something you have to write on purpose.str,repr, f-strings,%-formatting andpprintall render<redacted>;json.dumpsrefuses outright rather than serialising;reveal()is the only way to the bytes and is deliberately awkward to type. Sixteen tests, each one a different accident -- a log line, a traceback that formats its locals, a value nested in a dict being pretty printed.len()andbool()still work, because "set but empty" is something a health check needs to say without printing anything.- An upstream with no resolvable credential is advisory, whatever the config claims. Config that promises more than it can deliver is worse than config that admits the gap, because the claim ends up in the evidence.
qed doctorreports the uncomfortable question per upstream: does the qed hold the credential, or is the agent carrying its own? With nothing configured it says every adapter is advisory, in those words.- First match wins, in file order. Not "most specific": specificity between a host glob and an environment list is not rankable, and an order nobody can predict is worse than one they can read top to bottom.
host_globsmatches the host, not the string, so*.amazonaws.comdoes not matchec2.amazonaws.com.evil.test.- A missing or malformed
upstreams.yamlis an error, not an empty set. Carrying on would quietly turn every enforcing adapter advisory, and the symptom is one nobody notices until it matters. - An env var that is set but empty is an error: a deployment slip essentially every time, and failing loudly beats injecting nothing.
brokerleavesUNIMPLEMENTED_SECTIONS.docs/credential-broker.md.
Still to come in week 5: enforcing MCP and the HTTP forward proxy, the parts
that strip an agent-supplied Authorization and attach the broker's own.
Added — approval service, core and CLI (SPEC §9, week 4)¶
- A
REQUIRE_APPROVALverdict now resolves against what a human has already said about that exact action: an open window makes it ALLOW, a recent denial makes it BLOCK, a pending request links to it, and otherwise a request is opened. All four happen before the verdict is written, so the chain records the decision that was acted on. - Separation of duties is enforced, not reported. The approver may not be
the principal (
AG-SOD-001), may not be a service identity, and must be in the group the rule named (AG-SOD-002). A refused attempt is written to the chain: somebody trying to approve their own production delete is exactly what an auditor wants to find later, and a refusal that leaves no trace is indistinguishable from an attempt that never happened. - Dedupe on
sha256(principal | name | target | params_hash). Parameters are in, because an approval forDROP TABLE orders_archivemust not coverDROP TABLE orders. Environment and time are out, because two identical actions a minute apart are one question and an approver should see one request, not forty. --windowgrants every identical action for a period, keyed on the dedupe key so it covers that action rather than that person or that rule. Windows are not extendable. The length is the smallest of the request, the rule ceiling and the service ceiling; an approver cannot widen a rule's own bound by asking.- The rule's timeout, approvers and window ceiling are copied onto the request at creation, not looked up at decision time. The bundle can be reloaded in between, and an approver should be bounded by the rule in force when the action was proposed. Where rules disagree the tighter one wins.
qed approveandqed approvals [--expire];approval_requested,approval_decidedandapproval_expiredreach the chain.0004_approvals.sqladdsprincipal_hashandwindow_max_seconds. Without the principal on the row, "the approver is not the principal" cannot be checked at decision time -- only claimed.docs/approvals.md.
Not yet: Slack Block Kit, /events SSE, the web approval page. The decision
path they would call is finished and is the one qed approve uses.
Added — baseline pack, 15 rules to 45 (SPEC §22 week 3)¶
Thirty new rules across the five existing families: blast radius 10, budget 7,
data 12, identity 8, skills 8. 29 enforce, 16 shadow; 18 BLOCK, 27
REQUIRE_APPROVAL. The golden table goes from 74 cases to 107 and every rule
has one, which test_every_shipped_rule_is_exercised enforces.
The theme of the second set is that it mostly does not read the declared
hints. The first fifteen trust what the adapter said; these match the verb,
the target, or the absence of something, because an adapter that forgot to set
deletes on a teardown call is exactly the case the hints cannot catch.
Four of them are about the actions that make later actions invisible or unrecoverable -- audit logging off, encryption off, backups weakened, data made public -- which are worth blocking on their own terms whatever the agent was trying to achieve.
Fixed — three rules I got wrong, caught by the existing suites¶
does not bound a snake_case verb.terminatenever matchesterminate_instances, because_is a word character, and tool names are full of snake_case. AG-BLR-007 and AG-BLR-010 silently matched nothing until their own cases failed.- AG-SKL-007 flagged
resources/read, which is an MCP protocol method and entirely ordinary. The regex only allowed theserver.toolshape. Caught by the MCP golden table, which builds proposals through the real adapter rather than hand-writing them -- the exact gap its docstring says it exists to close. - AG-BLR-007 blocked single-instance teardown, escalating a documented REQUIRE_APPROVAL to BLOCK with no approval path. Narrowed to the verbs that take a whole system away; AG-BLR-003 already handles one instance, with a route for a person to say yes.
Two more were over-broad rather than wrong and were narrowed before they shipped: AG-BUD-005 fired on reads (ambient noise is how a shadow rule gets ignored) and AG-IDN-004 was harsher than the AG-BLR-004 it backs up.
Added — history in policy cases¶
derived.history is a documented field path and rules read it, but a case
could not set it, so any rule keyed on call or block counts was untestable.
An untestable rule is one nobody can change safely.
Added — qed policy test (SPEC §14)¶
- Runs cases you write against the rules you have loaded. Touches nothing: no chain, no store, no network. Exit 0 all passed, 1 a case failed.
expect.rulesis the exact set of rule ids and is required, not optional. A case that checks only the decision passes when the right answer arrives from the wrong rule, which is worse than no case because it reads like coverage. Pinning the exact set means a case fails both when a rule stops firing and when an unrelated one starts -- the second is what catches a rule widened by accident.- Every case is evaluated twice, the second time against a reshuffled bundle.
ADR-002.2 says a decision must not depend on rule load order and this is the
only thing that shows it; rule authors get the check on their own rules for
free.
--no-order-checkopts out while iterating. --coveragelists loaded rules no case mentions.- A case whose proposal does not validate is a failing case, not an exception, so one malformed entry does not hide every case after it.
docs/policy-testing.md.
Changed — the golden suite and the CLI share one comparison¶
tests/policy/test_golden.py now compares through qed.core.policy.cases,
the same code qed policy test uses. Two comparison routines would drift and
the one that drifted would be the user's; ours is the one with a CI job behind
it. The 74 golden cases still each run twice -- the shuffle moved inside
run_case rather than being a separate parametrized test, which is why the
total test count falls while coverage does not.
Added — mandate lifecycle (SPEC §6.5)¶
qed mandate sign | list | revoke, completing §6. Mandates were already consumed byqed check --mandate, the MCP proxy and the API envelope; there was no way to issue, store or withdraw one.qed/core/mandates.py(pure): the signing payload, signature verification and the active/inactive question. Ed25519 only, with no algorithm field read off the document -- the same rule as the token path.- What is signed is the document minus
signatureand minusrevoked_at. A signature cannot cover itself; the second exclusion is the one that matters. Revocation is an act of QED, recorded in the chain, not an edit the signer authorised, so a revoked mandate still verifies against the signature its issuer produced. Includingrevoked_atwould mean revoking a mandate destroyed the proof of who granted the authority -- exactly the record an investigation needs. - The document is stored whole, as the canonical JSON that was signed. Rebuilding it from columns risks bytes that differ from what the signer produced, and a signature that no longer verifies is indistinguishable from a forged one.
mandate_issuedandmandate_revokedreach the evidence chain. Revoking twice is refused rather than recorded twice.- An unknown signing key is a failure, not a skip: treating "this instance does not hold that key" as "probably fine" is how an unsigned document gets accepted.
Added — delegation (SPEC §6), week 3¶
POST /v1/token(RFC 8693): an IdP token in, a narrower budgeted qed token out. Scope is intersected with what the subject's groups hold, budget is capped per axis, lifetime is the smallest of the request, an hour, the parent's remaining life and the mandate's.refused_scopecomes back so a misconfigured agent fails at the exchange rather than at its first action.qed login(RFC 8628 device flow) andqed token. Both were already in SPEC §14's list of 18, so no spec update was needed. The refresh token goes to the OS keychain, or a 0600 file with a warning where there is none.GET /.well-known/jwks.json, outside/v1on purpose: every/v1route refuses an unsigned request and that test has no exemption list.qed/core/delegation/(pure) andqed/infra/{tokens,jwks}.py(I/O).deploy/keycloak/: a real Keycloak with the realm imported, anddemo.sh, which runs the week-3 exit criterion end to end.docs/delegation.md.
Fixed — three things only a real identity provider showed¶
Found by running the demo against Keycloak 26 instead of a fixture. All three were invisible to the synthetic tests, because those built claims the way the qed itself writes them.
scopeis a space-delimited string (RFC 6749 §3.3), not a list. Both forms are accepted now.- A token could declare its own kind.
TokenClaims.token_typedefaulted to"qed", so an IdP token — which carries no such claim — arrived looking qed-issued and was handed to the narrowing check as its own parent, failing every exchange. Whether a token is qed-issued is decided by which key verified it; the field is gone and a test asserts nothing reads it again. This is the same rule as never readingalgfrom the token. - A narrowing violation during mint escaped as a 500. It is a caller asking for more than the subject token holds, so it is a 400.
Fixed — --config on the commands that open a session¶
export, status, checkpoint and verify opened a session without a config
path, so they read the default instance_id and refused to touch a chain
written by any deployment that had set one. The demo hit it on its first
complete run.
Changed — Keycloak realm requests the basic client scope¶
Keycloak 26 omits sub from the access token without it. A token with no
subject cannot name a principal, so QED refuses it — correctly, but the
shipped realm should not produce one.
Added — budget ledger (SPEC §8), week 3¶
- Budgets are persisted and charged.
check_proposalpreviously passed an emptyBudgetStateinto the engine, soderived.budget.*was always zero and the three AG-BUD-* rules could never fire against stored state. The caps now live inbudgetsnext to the usage, a decision reads them, and an action that runs is charged. qed.core.budgetholds the arithmetic and stays pure:charge_for(cost from the adapter's estimate),apply_charge,newly_exhausted. The atomic read-modify-write isSqliteStore.charge_budget, under the sameBEGIN IMMEDIATEwrite lockappend_evidenceuses and for the same reason — two adapters charging one token must not both read the samesteps_used.- A charge follows what happened, not what was decided. In enforce mode a
BLOCK stops the action and nothing is spent. In shadow mode — the shipped
default — the adapter proceeds whatever the verdict says, the tokens are
really spent upstream, and the ledger says so.
effective_decisionalready encoded that difference. - Usage may exceed its cap and is not clamped. Clamping would make the ledger disagree with what was spent, and the overshoot is what tells an operator the cap was set too low.
first_seenis set by the first charge, not at issuance: asecondsbudget that started ticking when a token was minted would punish an agent for the gap before it was used.- Per-principal daily aggregate (
budget_daily), kept as its own table so a retention purge of payloads cannot erase counts a policy reads. - No evidence record per charge. SPEC §8 writes one only on exhaustion, and
§6.2 writes
budget_updateonly when a token is issued. A record per allowed action would double the chain, restate what the verdict record already implies, and put a signature and an insert on the hot path the week-7 p95 target has to defend. The first draft of this change did write one; the record counts in eleven existing tests were what caught it. - 41 tests: arithmetic, ledger, migration, and the runtime rules above.
End-to-end on the CLI: a 2-step token allows two actions, the third is
blocked by the AG-BUD-001 short-circuit, exactly one
budget_exhaustedsits after the verdict that caused it, andqed-verifystill passes on the resulting chain.
Changed — migrations are applied once¶
SqliteStore.migratereplayed every script on every open, which worked only while every statement wasCREATE TABLE IF NOT EXISTS. SQLite has noADD COLUMN IF NOT EXISTS, so 0002 could not be written that way. Aschema_migrationsledger now records what has run. An existing database has no ledger, so 0001 is replayed there once — safe precisely because it is idempotent, which is why no backfill was needed.
Added — licence applied, name settled (SPEC §24.1, §24.2)¶
- Apache-2.0 is applied.
LICENSE(verbatim, 202 lines) andNOTICEat the root, and the same two files copied intopackages/qed-verify/— copied, not referenced, because PEP 639license-filesglobs cannot escape a project directory. CI asserts the two licence texts stay byte-identical, since a verifier shipped under a stale licence text is the kind of detail an auditor is paid to notice. license = "Apache-2.0"in bothpyproject.tomlfiles as an SPDX expression, not the{text = ...}table: setuptools 77 deprecated the table and now treats aLicense ::classifier next to the expression as an error, so there is no such classifier.- The
Private :: Do Not Uploadclassifier is gone from both projects. A PyPI upload is no longer refused by its own metadata. PATENT-AND-LICENCE-QED.mdis deleted. SPEC §24.2 closed on 2026-10-08: Valqore holds no patent filings and has nothing pending, so the irrevocable Apache-2.0 §3 grant transfers nothing. It was answered before distribution, which was the entire purpose of withholding the licence.- The CI job No LICENSE file until SPEC 24.2 closes now asserts the opposite: both licence files present, both projects declaring Apache-2.0, the guard-rail classifier not back, and the qed-verify copy identical.
- SPEC §24.1 closed: the product is "Valqore QED".
qedstays the command, the import package and the repository name. Zero refactor — the evidence format already carries no product name (ADR-0006), so nothing in a chain written today depends on this choice.
Still not public¶
The remote stays private until the PyPI, npm, GHCR, Homebrew-tap, domain and EUIPO/USPTO reservations are in place (SPEC §24.1). Applying a licence is reversible for as long as nobody has received the code; publishing is not.
Changed — evidence format¶
- Breaking, pre-release. The hash domain separators no longer carry the
product name (ADR-0006).
"gate-genesis:"becomes"evidence-chain/1:genesis:","gate-principal:"becomes"evidence-chain/1:principal:", andchain_format/chain-formatbecomesevidence-chain/1. Genesis hashes and principal hashes therefore change. Nothing outside the test suite has written a chain, so there is nothing to migrate; doing this after a design partner runs the kernel would have meant either invalidating their evidence or shipping a retired name in the format forever. SPEC §24.1's "the name is a placeholder, a rename is a single refactor" is true after this change and was not true before it. qed/core/evidence/format.pyis the single definition.qed-verifykeeps its own copy, as SPEC §10.7 requires, andtests/evidence/test_format_constants.pychecks the two agree, pins the exact bytes against digests recorded outside the package, and fails if a candidate product name appears in a hash input.
[0.1.0] — 2026-10-07¶
Weeks 1 and 2 of the SPEC §22 build order.
Tagged locally, not published. SPEC §22 week 2 also makes the repository public as
v0.1.0. At this tag that step was waiting on the three pre-repo decisions — patent scope, product name, licence timing. All three closed on 2026-10-08; see [0.6.0]. The public push now waits only on the name reservations in SPEC §24.1.
Added — week 2 (SPEC §22)¶
qed/adapters/mcp/: the MCP proxy. MCP server toward the agent, MCP client toward the upstream, over stdio or Streamable HTTP, on the officialmcpSDK.initializekeeps the upstream'sserverInfoand adds a note toinstructions; capabilities are mirrored rather than claimed, so the proxy never advertisespromptsto a client whose upstream has none.resources/readandprompts/getare forwarded, recorded and never refused (SPEC §12.1).qed/adapters/mcp/intent.py: tool-call classification into the SPEC §3.1 hints. Reads the arguments as well as the tool name. See "Changed from the spec" below — this is the week 1 review's carry-forward item.qed/adapters/mcp/targets.py:target_templatewith{server},{tool},{env}and{args.<dotted.path>}. Validated at startup; substituted values are sanitised, because a tool argument is attacker-influenced input.qed/adapters/common.py: proposal building shared by every adapter —params_hashover the whole parameters, a boundedparams_redactedthat honours the SPEC §17 16 KB limit, ULID proposal ids.qed/api/: FastAPI app.POST /v1/proposals,GET /v1/proposals/{id},POST /v1/proposals/{id}/result,GET /v1/evidence,GET /v1/evidence/by-ref/{ref},GET /healthz,GET /readyz. RFC 9457problem+jsonthroughout, including for 404s and validation failures.readyzis false without a signing key, with a broken policy bundle, or without usable adapter authentication (SPEC §17).qed/api/auth.py: adapter authentication.X-QED-Adapter-Idplus an HMAC-SHA256 over method, path, timestamp, nonce and body digest. Secrets come from environment variables named inqed.yaml, never from the file itself.qed/core/evidence/export/oscal.py: OSCAL 1.1.2assessment-results. One observation per evidence record, one finding per (rule hit, control), public keys and checkpoint roots inback-matter. All identifiers are derived version-5 UUIDs, so the same range exports byte-identically every time. Validated against the vendored NIST schema in CI (SPEC §10.8, MUST pass).- CLI:
qed serveandqed mcp;qed export --format oscal. deploy/Dockerfile: two-stage, distroless, non-root, no shell in the runtime image, noVOLUMEline (an anonymous volume is how an evidence chain disappears ondocker run).qed/core/evidence/hashing.py::principal_hash: one definition of how a human principal is hashed, so the proxy today and week 3's token verification attribute to the same principal.docs/api.md,docs/mcp-proxy.md,docs/oscal.md;docs/assurance-classes.mdupdated for the three classes that now exist.- Tests: the week 2 exit criterion end to end against a real MCP server process
(
tests/adapters/), API and auth suites (tests/api/), OSCAL schema validation with negative controls (tests/evidence/test_oscal_schema.py), and 14 golden cases built through the real adapter path (tests/policy/test_golden_mcp.py). 922 tests, up from 595.
Fixed — week 2¶
qed/core/evidence/masking.py: the card-number masker corrupted identifiers qed itself generates. Luhn is a checksum, not an identifier — a run of eighteen zeros passes it exactly, because the sum is 0 and 0 is divisible by 10 — so the ULID-shapedtoken_jti01JB000000000000000000JTI0was rewritten to01JB[masked:card]JTI0inside the chain, in every proposal record. The recorded token then no longer matched the token the API and the week 3 budget ledger know about, and a corrupted identifier is indistinguishable from a redacted one. A candidate now also has to have more than one distinct digit and an issued-card prefix (2–6). Every real card range is still masked.
This changes payload_hash for records whose payload contains such a run.
Nothing has been released and no fixtures existed, so no chain in the world
is affected; the hash algorithm itself is unchanged and existing records still
verify, since each was hashed over whatever it stored.
Changed — week 2¶
qed mcprequires--humanor--mandate. Without identity,evaluateshort-circuits to a syntheticAG-IDN-007BLOCK before any rule in the bundle runs, so the chain would fill with identical identity blocks in which no policy rule had ever been evaluated — and in shadow mode nothing would be stopped and nothing would look wrong.--humanis recorded as adapter-asserted inclient_fingerprint, which SPEC §3.1 defines as advisory and never enforced on. Verified identity is week 3.qed/config.py:apiandadapterssections are now read, and removed fromUNIMPLEMENTED_SECTIONS.SqliteStore: addedget_proposal,evidence_at, and atypesfilter oniter_evidence.- SPEC §20.3:
jsonschemaadded to the dev/test dependencies, for the OSCAL schema check. SPEC §10.8 permits JSON Schema in place ofoscal-cli, which would put a JVM in CI. mcpis pinned>=2.3,<3. The 2.0 SDK replaced the decorator-based low-level server with theon_*callbacks the proxy uses, so a 1.x resolution would not import.
Changed from the spec — week 2¶
mcp/gateway.pyis not ported as SPEC Appendix B says. Its blast-radius function reads:
try:
from valqore.agents.action_intent import normalize
...
except Exception:
nb, nct = "low", "unknown"
action_intent imports valqore.codegen.validate at module level, which
pulls in the IaC parser and the rule engine. The Phase-0 inventory measured
import isolation with sys.modules, which only sees top-level imports of
already-loaded modules, so that chain did not show up. Ported as written into
a tree with no valqore package, the import raises, the bare except
swallows it, and the function returns low for everything. Nothing errors and
nothing logs; blast radius silently degrades to matching the tool name, and
execute_sql carrying DROP TABLE orders stops routing to approval. In
shadow mode nobody would notice, and enforce starts in week 10.
The useful half — the verb tables, earned on tools agents really call — is
written out in qed/adapters/mcp/intent.py with no import that can fail.
Valqore's "sensitive → high" half is deliberately left out: the kernel's
blast_radius.py already scores that surface, already reads params_redacted,
and is inside the policy hash where an auditor can replay it.
tests/adapters/test_import_boundary.py holds both halves of the line — an
import allowlist for intent.py, and a check that no broad except on the
decision path neither logs, records nor refuses.
Added — week 1 (SPEC §22)¶
- Repo skeleton,
ruff+mypy --strict+pytestin CI, no-network and no-LLM import guards onqed.core. qed/core/schemas.py: full SPEC §3 domain model on Pydantic v2, frozen andextra="forbid". Timestamps are RFC 3339 UTC at millisecond precision with a deterministic serializer.qed/core/jcs.py: RFC 8785 canonical JSON, used for every hash.qed/core/evidence/: record construction, SPEC §10.2 hash, Ed25519 signatures, Merkle checkpoints, secret/PII masking at write.qed/infra/storage/sqlite.py+ forward-only migrations: the evidence ledger and the SPEC §15 tables week 1 needs.qed/infra/keys/file.py: file-backed Ed25519 keys,qed keys init.qed/core/policy/: loader (rules, overrides, disables), linter (all SPEC §4.3 error classes), predicate evaluator (SPEC §4.4), engine (SPEC §5), deterministic blast-radius table.qed/core/policy/packs/agent-baseline/: 15 baseline rules.packages/qed-verify/: standalone verifier,cryptographyonly.- CLI:
keys init,check,policy lint|explain,export,verify. - 62 golden policy cases; chain, Merkle, masking, JCS, lint and CLI suites.
Deliberately not added¶
- No
LICENSEfile and nolicensemetadata, because SPEC §24.2 (patent scope) had to close first — the Apache-2.0 patent grant is irrevocable once distributed. Lifted in [0.6.0] on 2026-10-08, when §24.2 came back clean.
Changed from the spec¶
qed/core/policy/blast_radius.pyis written fresh, not ported from Valqoregovernance/blast_radius.py. Seedocs/adr/0001-blast-radius-table.md.