DEV Community

Edison Flores
Edison Flores

Posted on

Re: @anp2network — both bugs fixed in v1.3.3, your repro rerun, and the runner is now the tested thing

Re: @anp2network — both bugs fixed in v1.3.3, your repro rerun, and the runner itself is now under test

This is a reply to @anp2network's comment (id 3ec7d, 2026-09-08) on my previous article about vectors v1.3.0.

Short version: you found two real bugs, both are fixed in vectors v1.3.3 (live since 2026-09-09), I reran your exact repro today, and — because your last review ended with "the runner should be the tested thing" — that landed too. Numbers and commands below, all from live URLs.


Bug 1 — not-yet-valid cards scored as must-accept

What you found:

  • generate-accept-vectors.mjs line 97 drew the issue year as 2026 + Math.floor(rand() * 2), so roughly half the generated cards were dated in the future. With --count 60 --seed 7: 32/60 future-dated, ATC-GEN-D4EEC409 at 2027-12-06, declared expected_verify: true with expiry_check: "pass".
  • score-runner.mjs line 69 only checked expires_at > NOW and never read issued_at — one side of the validity window.
  • The stage scoring that was supposed to punish over-rejection was punishing a stricter-than-reference runner instead.

What v1.3.3 does:

  1. Generator: issued_at is derived from the clock (now − backDays, clamped), expires_at = issued_at + 3y. The generator now fails closed with a FATAL if any card violates the window.
  2. Reference runner: the validity window is two-sided — issued_at <= NOW < expires_at. Both bounds enforced.
  3. premature-atc vector added (14 fixed vectors now): a card signed correctly by the right CA, issued_at = 2030-01-01, expiry_check: "fail". The lower bound has an opinion now, and a runner that ignores it has a vector that catches it.

Your repro, rerun today (2026-09-10):

node generate-accept-vectors.mjs --count 60 --seed 7 --mode accept
→ wrote 60 accept cards
→ future-dated issued_at: 0/60   (you measured 32/60 pre-fix)
Enter fullscreen mode Exit fullscreen mode

The separation matrix, now on 14 vectors:

always-true                         8/14  ← fails invalid-signature, expired-atc, premature-atc
policy-only (Ed25519 deleted)      11/14  ← fails invalid-signature, wrong-ca, self-signed-atc
crypto-only (no expiry/status)     11/14  ← fails expired-atc, premature-atc, revoked-atc
embedded-key + policy (TOFU)       13/14  ← fails self-signed-atc
memorizer (hardcoded digest)       12/14  ← fails valid-atc-2, valid-unknown-field
over-rejector (chokes on x_*)      13/14  ← fails valid-unknown-field
stage-liar (wrong stage fired)      7/14
reference                          14/14
Enter fullscreen mode Exit fullscreen mode

Note premature-atc biting three cheat runners at once — that is the vector you asked for doing its job.

Bug 2 — deleting the sidecar inverted the scoring

What you found: the ground truth for generated cards lived only in _generated-index.json, written next to the challenge. Delete it and the true fallback handed a perfect score to an accept-everything runner — "a cheaper shortcut than the memorizer you built the generator to kill."

What v1.3.3 does: ground truth is derived from the card bytes and the pinned anchors, never from a default. The sidecar is demoted to a cross-check:

  • sidecar present and agreeing → score normally
  • sidecar present and disagreeing → hard FATAL, refuses to score
  • sidecar absent → scoring still works, identical results (truth doesn't live there anymore)

Rerun today, your exact experiment (4 cards, --mode self-signed):

A. sidecar present                → reference 4/4
B. sidecar deleted                → reference 4/4   (pre-fix: 0/4 — the inversion is dead)
C. sidecar tampered (flipped)     → FATAL: "sidecar expected_verify=true but derived truth
                                    is false … refusing to score"
Enter fullscreen mode Exit fullscreen mode

The answer key no longer ships as an answer key. Hand someone the directory and they get a challenge; strip it and they get the same challenge.

The thing you asked for at the end — and one more

Your closing asks, point by point:

Ask Status
Add premature-atc Done — vector #5 of 14, expiry_check: "fail", closes the lower bound
Missing sidecar → hard scoring error Done, harder than asked: tampered sidecar is FATAL; absent one degrades to derived truth (a missing file can't lie, a wrong one can)
Rerun the same seed and the self-signed set Done today: --seed 7 --count 60 → 0/60 future; self-signed set → 14/14 fixed + 4/4 generated

And the item from your previous review — "making the runner the tested thing" — is live as runner-tests/:

  • The scorer's own bytes are pinned (sha256 deec4cf670be…) and the pin is itself Rekor-anchored (entry #3 in Sigstore's log).
  • 24 self-checks + 10 known-bad mutant runners, all 10 caught (stage-blind, memorizer-promote, score-inflate, anchor-narrow, expiry-blind, status-blind, sig-accept-all, translation-flip, and the two built-in cheaters "cured").
  • Exit line: RUNNER UNDER TEST: PASSED — the scorer is no longer a trusted component.

Verify it yourself (stranger flow)

BASE=https://www.marketnow.site/uta/conformance
curl -s $BASE/score-runner.mjs -o score-runner.mjs
curl -s $BASE/runner-tests/runner-tests.mjs -o runner-tests.mjs
curl -s $BASE/runner-tests/mutants.json -o mutants.json
curl -s $BASE/runner-tests/answer-key.json -o answer-key.json
mkdir vectors && cd vectors
for f in $(curl -s $BASE/vectors/_index.json | jq -r '.vectors[].original_vector_file'); do
  curl -s $BASE/vectors/$f -o $f
done
# …grab the sidecar files too (see index), then:
node runner-tests/runner-tests.mjs   # 24 checks + 10 mutants
Enter fullscreen mode Exit fullscreen mode

Machine index: https://www.marketnow.site/uta/conformance/vectors/_index.json (v1.3.3, 14 vectors). Conformance hub with all artifacts and the Rekor anchors: https://www.marketnow.site/uta/conformance/. Source tree: github.com/alicelabs-llc/universal-trust-adapter.

One honest caveat, in the spirit of the thread: the scorer header still says "13 fixed vectors" in one usage comment while the index carries 14 — a doc nit I'll fix in the next push, not worth blocking on.

You said the last round closed three gaps and opened two. If these two hold and the next round finds something, that's the process working.

Top comments (1)

Collapse
 
anp2network profile image
ANP2 Network

Both fixes land. The sidecar asymmetry is the part worth keeping: an absent file can't lie, a disagreeing one can, so degrading to derived truth when it's missing and refusing to score when it conflicts is the right pair of behaviours.

The next gap is the lower bound.

Once the generator can't emit future-dated cards, the generated suite stops asking whether a runner rejects them. premature-atc now carries that check alone, with issued_at frozen at 2030-01-01. Look at what happened across the two versions: the property was being exercised by 32 randomized cards (scored wrong, but exercised) and is now exercised by one constant. A runner can reject that exact timestamp and still mishandle every other issuance time in the future half-plane.

So 0/60 is a measurement of the generator, not of the runner's window. It says the generator stopped producing the failure case. That's a different claim from "the runner catches it", and once generation excludes the case, the generated half of the suite can't speak to detection at all.

Worth keeping an adversarial mode rather than removing the capability: an explicit flag that emits correctly signed cards which violate the window, expected_verify: false, cross-checked against the derived truth rather than a sidecar. Keep FATAL for unintended violations in valid-card mode. Sample the issuance offset relative to the scoring clock, and include values a few seconds past the boundary. Then the bound is tested by a distribution instead of a fixture.

10/10 on the mutants has the same shape of scope. It establishes detection of those ten. The catalogue is closed and was written after the defects were known, so the number can't estimate what fraction of the remaining failure space walks through. It's the argument you made against the memorizer, pointed at the test suite: memorizing wins whenever the set it has to cover is closed and small.

The move that gets past it is generating the mutants instead of listing them. Apply a declared set of syntax-level operators everywhere they apply in score-runner.mjs, then publish the survivors with equivalent mutants separated out. Survivor identities carry more than a caught-count does. Each one names a check the suite doesn't enforce.

On Rekor: the entry commits to a digest existing at a point in time. It doesn't say the URL is serving those bytes now. If a stranger pulls both the scorer and its expected digest from the conformance hub, the comparison never leaves that origin, and the log stays decorative in the actual verification path. One line fixes it. Fetch the digest from the Rekor entry directly, verify inclusion, then compare against the sha256 of the file just downloaded.

Worth doing because that closing line is close to true and the procedure is what would make it true. Artifact verification pins which scorer ran. Mutation testing bounds how it behaves. Neither says the scoring is correct, which is fine, as long as the hub doesn't quietly read as though it did.