Validating a release¶
The test suite is 2,727 fast tests and green. This page is about the things it cannot see, because every one of them has shipped at least once.
The pattern is consistent enough to name: a test that imports the source tree
is not a test of the thing you ship. The suite runs against a repo checkout,
on one architecture, in one process, with qed importable from .. A release
is a wheel installed somewhere else, a container on two architectures, and
several replicas starting at the same moment.
What a green suite has missed¶
| What shipped | Why the suite could not see it |
|---|---|
| A container that could not import pydantic | the Dockerfile assertions read text; nothing started the image |
A wheel missing compliance/frameworks.yaml |
the checkout has the file |
| An arm64 image with every library in the wrong directory | the container test only built for the host |
| A replica dying on startup in HA | one process in the suite, two in the stack |
terraform.destroy in prod returning ALLOW |
no case asserted the tool-name spelling |
Masking eating a public key under strict |
the record still verified against its own hash |
| Ten control catalogues missing from the wheel | the test for exactly this matched them with fnmatch, whose * crosses a / |
| Both arm64 tests skipping one step after the emulator was installed | a probe that could not tell "no emulator" from "the registry did not answer" |
| An imported catalogue that stopped the kernel deciding | nothing had tested an import against the installed rules |
policy_loaded saying unsigned: false after an unsigned import |
the policy hash does not change, so the record is never re-emitted |
evidence.checkpoint.every_records being ignored |
the default matches the spec, so only a tuned value shows it |
A traceback from qed policy lint on a malformed rule file |
every test fixture is valid YAML |
UnicodeDecodeError from a pack bundle with one stray byte |
every test bundle was written by the test |
A frozen binary telling a Python-less user to run pip install |
the build succeeded; nothing ran the binary |
| A mandate's signature was never verified | verify_mandate was unit-tested from week 3 and nothing called it |
README.md citing tests/policy/test_determinism.py |
no test asserts that a cited path exists |
| A format spec that would have frozen the secret-masking patterns | the sentence was true; the conclusion drawn from it was not, and no test reads a conclusion |
| Eleven weeks of "cross-version fixtures in CI" with no fixtures | the test that looked like one regenerates its own input |
A frozen fixture's public keys, matched by .gitignore's *.pem |
the files were on disk, so everything passed locally |
| A byte-pinned fixture written with CRLF on Windows | the pin matched on the machine that made it |
qed-verify printing OK over a check it had skipped |
every test supplied --instance-id, so no test ever saw the output an auditor following the README would get |
| A repository about to publish a defect list for the commercial product | no test reads a document the way an outsider would |
v1.0.0 shipped with no way to ask it its version |
nothing in the suite asks the binary what it is, and the Homebrew formula's test block avoids --version on purpose -- correct reasoning about the test, mistaken for a reason the flag need not exist |
Each is now guarded, and the guards are the point of the list: it is not that these bugs existed, it is that the class keeps recurring.
The mandate signature, which is the worst of them¶
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. So forging
one converts a refusal into a decision, and until week 11 forging one meant
editing a JSON file.
verify_mandate() was written in week 3 and unit-tested the same day: tamper
with a mandate, assert it raises. Those tests passed for eight weeks because
they called the function. Nothing else did. Every consumer parsed a mandate
with Mandate.model_validate_json -- schema, not signature -- and handed it to
the engine. qed.core had deliberately declined the job ("signature
verification is the token layer's job") and no token layer picked it up.
Three things make this the most instructive entry on the page:
- Coverage was not the problem. The function was covered. The missing assertion was that the path an operator actually uses calls it, which is a different kind of test and the kind this project keeps having to add.
SECURITY.mddocumented it as fixed-by-week-3. A known limitation with a due date is worse than an unknown one, because everybody stops looking.- It was found by executing the documentation. Writing
docs/onboarding-shadow-to-enforce.mdmeant running every command in it, including one with a mandate, and the only reason to try a forged one was that the guide claimed the signature mattered.
That last point is now policy: a command in a document must have been run. Three passages of the onboarding guide were wrong on the first draft -- a flag that is positional, a config block with the wrong keys, and output invented from the help text -- and all three would have shipped had the page been written from the source rather than from a terminal.
The two week-12 entries, which are about tests rather than code¶
Neither of these was a bug in qed. Both were a test or a document that could
not have failed, which is worse value for the same green tick.
"Cross-version fixtures in CI" existed as a sentence for eleven weeks.
SPEC §25.6 promised that a schema_version bump keeps qed-verify able to
verify every prior version, with fixtures in CI to prove it. There were no
fixtures. What there was is tests/evidence/test_chain.py, which writes a chain
with the real signer and the real store and verifies it with the real verifier,
end to end -- a good test that cannot see this failure at all, 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.
The shape to look for: a test that regenerates its own input is not a test of
compatibility. It is a test that today's code agrees with itself. The fix was
to commit an export produced once, pin its digest in the test file rather than
inside the fixture, and make a schema_version bump fail until it has a fixture
of its own (ADR-0015).
A document froze the wrong thing. The draft docs/evidence-format.md said
the masking pattern set "is part of the format: changing it changes record
hashes for future records". Every clause there is true. The conclusion is wrong:
masking runs before hashing, so a new pattern changes what future records
contain and cannot change one already written, and a verifier never re-derives
a mask. Freezing 1.0 as written would have frozen the secret-detection patterns
for the life of the product.
No test could have caught it, and that is the entry. A guard can check that a
document's cited paths exist (tests/docs/test_claims.py) and that its commands
run; it cannot check that an inference drawn in prose is sound. The only control
for that is writing down what a commitment covers in two explicit tables --
inside the freeze, outside the freeze -- before committing to it, which is what
the page now does. A promise whose boundary is implied is a promise nobody can
check.
Two things the fixture itself nearly shipped broken¶
Both were caught between writing the fixture and committing it, by asking what a different machine would see. Both are in the table above because they are the recurring class, not because they reached a release.
.gitignore matched the fixture's public keys. Line 15 is *.pem, under
the comment "never commit keys or chains" -- correct for a private key, and it
matched the two public ones the frozen chain cannot be verified without.
The first commit would have published a chain with no keys. Locally nothing
would have shown it: the files are on disk and every test passed. In CI it
would have surfaced as unknown_key on a frozen fixture, which reads like a
format regression rather than a missing file.
.gitignore now carries an explicit negation for
evidence-format-*/public/*.pem, and
test_every_fixture_file_is_tracked_by_git asks git what it is tracking rather
than asking the filesystem what exists -- the same correction as the packaging
guard that used fnmatch while setuptools expands with glob: ask the thing
that actually decides.
The fixture was written with CRLF. The generator used
Path.write_text, which on Windows translates every to . The test
pins a sha256 over the fixture's bytes, and .gitattributes checks the
repository out as LF everywhere -- so the pin matched on the machine that made
it and would have failed on every other one, again reading as a format
regression.
The generator now writes bytes, test_no_fixture_file_carries_a_carriage_return
asserts it on the bytes rather than trusting .gitattributes, and
.gitattributes says what depends on its rule. This is the week-11
Windows/Linux asymmetry in the worse direction: that one failed locally and
passed in CI, which is the direction you find out about.
The general shape, for the next fixture: a byte-pinned artefact has two hidden dependencies, the version-control system's filters and the platform's line endings, and neither is visible from the machine that produced it.
A guard written in a different matcher than the thing it guards¶
The fnmatch row is worth dwelling on, because the guard was already there and
still let it through. tests/test_packaging.py was written after the
frameworks.yaml bug, precisely to check package-data against what is on
disk -- and it used fnmatch, which has no notion of path segments, while
setuptools expands those patterns with glob, which does. So
compliance/*.yaml appeared to cover compliance/packs/eu_ai_act.yaml and did
not. A guard written in a different matcher than the thing it guards is not a
guard, and the only reason this surfaced is that one test starts the real
image and drives it.
Read the product as somebody who has not seen it¶
Two of the rows above came from one exercise that is not a test and cannot be: installing the package into an empty virtualenv and following the documentation literally, as a first-time user, a security reviewer and an auditor in turn.
The suite could not have found either. qed-verify's tests all passed
--instance-id, because the person writing them knew it mattered; the
documented quick start omitted it, so the output an auditor would actually see
was output no test had ever rendered. A test asserts what its author already
knows. It cannot notice that the one command in the README produces a weaker
result than the README implies, and it cannot notice that a planning document
left in the repository would, on publication, hand the world a list of named
defects in the product that funds this one.
The practice that follows: before anything irreversible — a release, a publication — read the repository as an outsider, run the commands exactly as written, and treat the gap between what a command prints and what a reader concludes from it as a defect in the product rather than in the reader.
Reading it a second time, as a buyer¶
The same exercise with a different reader — somebody deciding whether to run this, who will type the first command on the page and believe the capability table — found five more, and none of them are subtle once named.
| What a buyer hit | Why no test could see it |
|---|---|
The first line of docs/index.md's "Thirty seconds" was pip install valqore-qed, which fails: the name is not registered and release.yml holds the publish behind PUBLISH_PYPI. Four more pages said the same, and docs/deployment.md also offered docker run ghcr.io/valqore/qed:1.0.0 against an image never pushed |
The suite installs from the working tree, which is the one way of installing that always works. docs/deployment.md explained the gate correctly — 140 lines below the commands, under "Releasing" |
The capability table advertised a "Web approval page with OIDC human login" and linked to a private repository. qed/api/ui.py contains no UI by design, so the capability is unobtainable for everyone outside this organisation, through a link that answers 404. Meanwhile Slack approvals, which are built and better, were not in the table at all |
No test reads a table as a promise. tests/docs/test_claims.py checked that every cited path resolves, and qed/api/ui.py resolves |
qed mcp ended every REQUIRE_APPROVAL with "No approval route is configured in this build (approvals land in week 4)", ten weeks after the approval service shipped, while the kernel had just filed a request and the two other adapters were already reporting its id |
A test asserted the stale sentence — assert "week 4" in text — and was green. That is the worst version of this failure: the suite was not silent, it was arguing for the bug |
docs/api.md said "No rate limits." Rate limits are on by default, per adapter and per principal, answering 429 — which was missing from the same page's list of status codes |
The limiter has tests. Nothing compares the limiter's existence to the sentence denying it |
| Twenty-one statements in source and docs promised work that had shipped, and three of them were strings a user sees | Every one was true when written. Staleness has no failing assertion unless something is written to look for it, which tests/test_no_stale_promises.py now does |
The common shape is worth stating once, because it is the same shape as the
product's own subject: a claim and the thing it claims about were checked
separately, and nothing checked them against each other. A control in a
report nobody enforces, a cited test path that resolves to a module containing
nothing, a message asserting a limitation the code had removed. The fix in
each case was not more tests of the code. It was a test that reads the
sentence and the code together — which is what test_claims.py,
test_repo_protection.py and test_no_stale_promises.py each do, and what
test_threat_model.py has done since week 11.
The checks¶
Install the wheel and drive it from somewhere else¶
python -m build --outdir dist .
python -m build --outdir dist packages/qed-verify
python -m venv /tmp/clean && /tmp/clean/bin/pip install dist/*.whl
cd "$(mktemp -d)" # no source tree on the path
/tmp/clean/bin/qed keys init --dir ./state
/tmp/clean/bin/qed check proposal.json --dir ./state
/tmp/clean/bin/qed export --dir ./state > chain.jsonl
/tmp/clean/bin/qed-verify chain.jsonl --keys ./state/public
The cd matters. Run it in the repo and a missing data file is masked by the
checkout; the wheels job in release.yml does the same thing for the same
reason.
Tamper with something that is actually different¶
A chain decided in shadow mode is all ALLOW, so setting a verdict to ALLOW tampers with nothing — and a verifier that correctly accepts an unmodified chain looks like one that accepts a forged one. Flip the value to something else and assert the bytes changed before concluding anything:
current = record["payload"]["verdict"]["decision"]
record["payload"]["verdict"]["decision"] = "BLOCK" if current != "BLOCK" else "ALLOW"
assert new_line != old_line, "the tamper changed nothing"
Use a fresh proposal id each time¶
Ids are single-use. Reusing one is a 409 that writes nothing, which silently empties any assertion about the records that were supposed to follow — a post-rotation check that reused an id left the chain with no post-rotation records at all, and the "verifies across the rotation" assertion then passed without a rotation in it.
Pick a key by key id, not alphabetically¶
sorted() over ed25519_<hex>.pem has nothing to do with which key is
current. Picking the wrong one made "a chain must not verify without the
retired key" pass for the wrong reason.
Start both architectures¶
pytest tests/deploy -m slow # builds and starts the foreign arch under emulation
QED_REQUIRE_EMULATION=1 pytest tests/deploy -m slow # ...and refuses to skip
Skipped without emulation rather than passing on the easy half -- which leaves
one more way to pass without testing anything, so CI sets
QED_REQUIRE_EMULATION=1: it installs the emulator a step earlier, and there a
skip is a green run with the test that caught the last arm64 bug not having
run.
The probe pulls its image in a separate step from starting it, because one
probe that did both returned the same False for "this machine has no
emulator" and "the registry did not answer just then" -- and the second of
those made the arm64 tests vanish from a run that had installed the emulator.
Import a catalogue, then decide something¶
qed packs verify bundle.qedpack.json --public-key pack.pem
qed packs import bundle.qedpack.json --public-key pack.pem --dir .qed
qed check examples/proposals/github-read.json --dir .qed # must still decide
qed export --dir .qed | qed-verify - --keys .qed/public
The check is the point, and it is the step that was missing. An imported
catalogue replaces the shipped one, an uncatalogued control is a lint error,
and the engine refuses to decide against a bundle with errors -- so a bundle
that drops a control an installed rule cites switches the kernel off. The
import refuses that now, before writing anything, but the sequence above is
what proves it: verifying the bundle and reading the coverage report both
looked perfectly healthy while qed check answered "refusing to decide".
Then read the chain. An unsigned import must show unsigned: true in the
next policy_loaded, not only in its own admin_action:
Anchor two checkpoints to a log you can watch¶
# a stub log on loopback; plain http is allowed there and nowhere else
qed checkpoint --dir .qed --config qed.yaml # publishes checkpoint 1
qed checkpoint --dir .qed --config qed.yaml # publishes checkpoint 2
qed export --dir .qed --config qed.yaml | grep -o '"previous_receipts":[^]]*]'
Two checkpoints, not one, because the receipt for the first appears in the
second one's payload -- a receipt cannot live inside the record it describes.
Watch what the log receives: it must be six values (log_id, seq, hash,
merkle_root, key_id, sig) and nothing from the payload, because a
checkpoint payload carries the previous checkpoint's receipts and those name
your buckets.
Two bugs came out of running exactly this. every_records: 1 was ignored, so
nothing was ever due; and the first version of the script looked for
published_to in the payload when the field is previous_receipts -- that
one was my mistake, not the code's, and it is in this list because a
validation script that cries wolf is worse than none. Check the field name
before reporting the bug.
Start more than one replica¶
qed keys init --dir deploy/state
docker compose -f deploy/docker-compose.yml up --build
curl -s localhost:8710/readyz | jq .ready
curl -s localhost:8711/readyz | jq .ready
Both, not one. The startup race only appeared because both were checked — the survivor looked perfectly healthy.
The full pass¶
tests/perf and tests/deploy -m slow are excluded from the matrix and run in
their own CI jobs. A release pass is:
pytest -q # everything, including slow and perf
QED_TEST_POSTGRES_DSN=postgresql://... pytest -q
pytest tests/deploy -q -m slow # both architectures
pytest tests/perf -q -m perf -s # p95, with the distributions printed
docker compose -f deploy/docker-compose.yml up --build -d && ...
helm template qed deploy/helm/qed --set signingKey.existingSecret=k
Plus the one thing no automation covers: install the wheel on a machine that
has never had the source, and follow README.md from the top. Every gap in the
table above would have been found in the first five minutes of doing that.