Skip to content

feat(evidence): add a public certificate check and map where each rule lives - #2606

Merged
jeremi merged 40 commits into
mainfrom
feat/evidence-public-check
Nov 8, 2026
Merged

jeremi merged 40 commits into
mainfrom
feat/evidence-public-check

Conversation

@jeremi

@jeremi jeremi commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Adds an experimental public certificate check to Evidence, and publishes an explanation of where each kind of rule lives across the products.

Public certificate check

A holder, or anyone the holder shows a paper certificate to, scans a QR code and sees whether that certificate is currently valid, suspended, revoked or expired, with the fields printed on it. No account is needed.

Authoring and activation

  • An assertion definition may declare publicCheck (checkCodeFact, statusOutput, fieldsOutput).
  • A target authority activates it with publicCheck.definition. Without an activation the route does not exist and answers like any unknown path.
  • evidencectl check and package loading refuse:
    • a status Output that is not a controlled category within the four statuses;
    • a fields Output that is not a reviewed structured value;
    • the same Output used for both;
    • a definition with more than one subject role, selector profile or field, or without exactly one acquiring Source;
    • a selector field of type integer or boolean, since the route presents the number as text (startup refuses this too);
    • any existenceDisclosure other than collapse-unresolved;
    • a check code fact the Source's facts schema does not declare as a string, or that is also a Source input;
    • an entity-reference Output, since a public check has no relying party to scope it to.
  • Startup repeats the two checks that matter most at runtime: it refuses a status codelist with values outside valid, suspended, revoked and expired, and a check code fact that is not a string fact of the acquiring Source or that a Source input binds.

Route

  • POST /v1/public-check takes {documentNumber, checkCode}. The code is 24 characters of base32; the server uppercases it before comparing.
  • It reads no Authorization header and no request header except Content-Type. The trace id is minted by the server.
  • A hit returns the status, the printed fields and the evaluation time under registry.evidence.public-check/v1alpha1. The answer is unsigned, and its schema enumerates the four statuses; the answer refuses any other status with 503 service.unavailable.
  • A wrong number, a wrong code, a record with no stored code and every other unresolved case return the same 404 resource.not-found.
  • Malformed input returns 400 before any audit entry or registry read. That includes a number the selector profile accepts but the Source's inputs schema refuses, which answers exactly as a profile refusal.
  • The code is compared only after the Source returns a record. Any Source failure before that point (transport, timeout, call budget, an error status from the Source, a protocol failure such as a facts-schema refusal or an extract-script failure) returns 503 source.unavailable.
  • CORS is *. The route carries no cookie or token, so the wildcard grants nothing a plain HTTP client lacks.

Audit

  • Each well-formed request writes an access-attempt entry and an outcome entry under registry.evidence.audit.public-check/v1. Neither holds the document number or the check code.
  • evidencectl audit show and the local audit view list these entries.

Operator duties (documented, not enforced)

  • Evidence has no rate limiter on the route and never reads a client address.
  • The operator exposes only POST and OPTIONS on /v1/public-check through a path-restricted proxy, limits each client and the whole path with a 429 answer, keeps no client address in the access or error log for that location, and terminates TLS on the printed check origin.
  • A separate check-only instance is recommended.

Writing a pack for a public check

  • A number with no record cannot fail the way a record can. A pack that refuses one record's content before the code comparison (a facts schema constraining values beyond their type, an extract script that throws on one record's data) would let the 503 reveal that a record exists for that number, though neither its status nor its fields. A register that answers an error status for one record but not for an unknown number has the same effect.
  • Pack authors therefore keep the facts schema to types, declare a returned record whose number does not exactly match as no-match, and check record content in the derivation, which runs after the comparison. The example pack follows this, and the security invariant matrix (V1-I59) states the residual.
  • The example README also tells pack authors to give the inputs schema a pattern for the number, so a number the route cannot place in a path segment answers 400 (not in the expected format) rather than 503 (unavailable), and to bind a number that can contain / to a query parameter.

Example and deployment reference

  • products/evidence/examples/public-check/: project, check-only target, authority, derive function, printed-fields schema, a synthetic Adapter pack, fixtures for each status plus a record issued before check codes existed and a record with an unknown status, a dependency-free static page, and a code generation recipe.
  • The page removes the number and code from the address bar and the history entry once it has read them, shows only the answer to the latest request, and has its own message for 429.
  • products/evidence/reference/request-adapter/deployment-projects/public-check-evidence/ is the packaged form of the example. A test fails if it drifts from the example.
  • CI runs the real-binary test and the page tests in the evidence-tutorials job, which the example path now triggers.

Contract correction: selector values a Source cannot carry

The problem contract, the security invariant matrix (V1-I56) and the source contract describe two ways the runtime refuses a selector value that passes its profile but that a Source cannot carry. Neither reads a credential or sends a source request.

  • A value the Source's inputs schema refuses is refused before any audit entry: 400 request.selector-invalid on the public check route, and 503 service.unavailable (category source-inputs) on the relying routes.
  • A value the inputs schema admits, but that the Source binds to a request path segment it cannot occupy (a separator, a dot segment, a percent escape or a control character), is refused after the durable access-attempt audit entry as 503 source.unavailable, audited as a dependency failure. On the relying routes it does not count against the caller's selector-failure budget.

Adapter pack routes compute path values in route scripts, which Evidence cannot see during selector resolution, so the second refusal happens inside source execution. The text previously described a selector-time refusal.

Where each rule lives

docs/site: a new explanation page maps each kind of rule (field shape, Action, eligibility, evidence question, workflow, rendering, scheduling, and so on) to the product and file that owns it, with examples. BReg docs now name the Action and hook ABIs the code accepts.

Render

The Render docs and the App Kit integration guidance now point printed QR checks at the Evidence route instead of an app-owned route.

Checks

Run on macOS against this branch, rebased on current main:

  • cargo fmt --check for the touched crates: clean.
  • cargo clippy --locked -p registry-evidence -p registry-evidencectl -p registry-evidence-authoring --all-targets -- -D warnings: clean.
  • cargo test --locked: registry-evidence 1221 passed, 8 ignored; registry-evidencectl 1000 passed, 15 ignored; registry-evidence-authoring 226 passed.
  • The real-binary tests in public_check with EVIDENCE_BIN and --include-ignored: 2 passed.
  • check-contracts.sh (including security_contract_traceability and generated-contract reproduction), check-authoring-schema.sh, check-authoring-no-io.sh, check-config-key-paths.sh, check-source-neutrality.sh: all pass.
  • check-config-conventions.py --base origin/main: 0 errors, 0 unrecorded findings.
  • products/identifiers/scripts/check.sh: the catalog is complete and reproducible.
  • node --test on the page: 20 of 20 pass.
  • docs/site: npm test 949 of 949 pass; npm run check passes (llms check, SEO and the built link check).
  • Static page in a real browser with the endpoint stubbed: a fragment hit (code grouping stripped, the fragment removed from the address bar, revoked shown as revoked, markup in a field rendered as text), a slow earlier answer that does not replace a later miss, the 429 message, a form miss, malformed input, and a missing endpoint meta (unavailable message, no request sent).

What the end-to-end tests exercise

  • Production fixture command: every fixture case in the packaged project runs through evidence evaluate after evidence authority activate, including the unknown-status case, which fails closed.
  • Real binaries:
    • The test packages the example with evidencectl, activates the authority and starts evidence serve. A loopback thread stands in for the register and the issuer key endpoint.
    • Misses (wrong code, unknown number, a record without a code, and a wrong code against each of several other records) return byte-identical 404 bodies apart from traceId.
    • A lowercase code returns a 200 answer with no signature.
    • After shutdown, the local audit view shows access-attempt then release for the hit. Neither the audit file, the view nor the service log contains a document number, code or holder name.
  • Not exercised: evidencectl audit show end to end. It reads only a stopped dev start session, which needs an issuer container and registered clients that a check-only target does not have. Its rendering of these entries is unit-tested.

Known limits

  • The existence signal described under "Writing a pack for a public check" is a pack-authoring rule, not a runtime guarantee. Collapsing those failures into the 404 would make a genuine certificate read as unknown during a register fault, so the runtime keeps them as 503.
  • On the relying routes an inputs-schema refusal still answers 503 service.unavailable (source-inputs); only the public route treats it as a malformed request.
  • evidencectl test resolves each fixture through an access profile and has no fallback for accessProfiles: {}. The example authority therefore carries one relying-service profile. A deployed check-only instance that runs no fixtures can use accessProfiles: {}.
  • The packaged reference uses the local assurance profile with loopback hosts, because it is the example's own package. A deployment packages its own target with a deployable profile and HTTPS hosts.
  • The route, the answer schema and the new keys are experimental (v1alpha1).

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-08T04:48:27.067262Z 9240721 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a7aecc005

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/registry-evidence/src/runtime.rs Resolved
@jeremi
jeremi enabled auto-merge November 8, 2026 03:08
jeremi added 27 commits November 8, 2026 10:10
…ing model

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…l on activation

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…plan cannot read stored records

Also rewraps the BReg action-plan constraint to the file's width.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…oute

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…is never styled valid

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…e authority

A printed certificate carries a document number and a check code. The
runtime now answers one such pair from the active target authority: it
selects the record through the activated lookup, compares the stored code
in constant time, and returns only the activated status and printed fields.
Every miss, whatever its cause, collapses to one not-found outcome.

Each check writes an access attempt before the source read and a
disclosure release before the answer, under the
registry.evidence.audit.public-check/v1 entry. The entry records no
presented value, no caller, and no requester pseudonym.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
POST /v1/public-check is the first unauthenticated route that reads
registry data, so it is registered only when the active target authority
activates a check. Without one the path answers exactly like any unknown
route.

The route reads one request header, Content-Type, and a bounded body. It
mints its own trace instead of continuing an inbound traceparent, shares
the evaluation slots and request timeout of the relying routes, and leaves
rate limiting to the fronting proxy. Any origin may read its responses,
the preflight allows only POST with Content-Type, and no other route
carries a CORS header.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The request and answer schemas are hand-written YAML contracts, served
unchanged and generated into JSON. The OpenAPI document describes the
check and its preflight as experimental and unauthenticated, with every
miss as one 404, no-store on every response, and a trace the service
mints rather than reads.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The route tests run a compiled certificate authority against a mock
Source. They prove an unactivated check answers like an unknown route,
every miss is one identical 404, a refused request reaches neither the
Source nor the audit, the audit brackets the Source read and the answer,
an audit or Source failure is a 503 and never a miss, only this route is
readable cross-origin, and no request header but Content-Type reaches the
logs or the audit.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…dit record

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…te claims

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…inition

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ath cannot carry

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
jeremi added 12 commits November 8, 2026 10:11
…ppears in the audit

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…eta is missing

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ommand and a real binary

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…angelog entries

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…a keys

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…e route cannot serve

A value the Source inputs schema refuses is a malformed request on the public route. Startup refuses a status codelist outside the four statuses and a check code fact that is not a string fact of the acquiring Source or that a Source input binds, and the answer refuses any other status.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…author rule

Describe the two selector refusals, the before-comparison existence residual and the startup checks in the contracts, the concept, the operator contract and the site.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
… a record exists

The facts schema declares types only, the lookup treats a non-matching record as no match, and the derivation checks record content after the code comparison. Repackage the reference and test more misses.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The page removes the number and code from the address bar and history, ignores answers to superseded requests, and has its own message for a rate limit.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
The evidence-tutorials job runs the page tests, and a change under the example path triggers it.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
…ilures before the code comparison

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi force-pushed the feat/evidence-public-check branch from 2cbfa2e to 9c1267e Compare November 8, 2026 03:26
…present as text

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi
jeremi added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit d399e34 Nov 8, 2026
74 checks passed
@jeremi
jeremi deleted the feat/evidence-public-check branch November 8, 2026 05:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant