What this build does not protect against, written down before anyone asks.
Each entry: what it is, what it specifically is not, what limits the damage today, and what would close it. Ordered by what a security reviewer should read first, not by severity of the underlying mechanism.
Current as of 16 September 2026. Nothing here is a defect — every item is a known boundary, and most are deliberate scope decisions for this version.
1. The verifier credential model is a development stand-in
This is the one launch blocker on this list. Everything else is a maturity question.
What it is. A verifier authenticates with a verifier id and a password, seeded at deployment, hashed with scrypt, exchanged for a signed 12-hour session cookie. That session is the sole source of the verifier account bound into every receipt, which is never read from a request. There is no second factor, no device binding, and no way to tell one human from another beyond knowledge of a shared secret.
What it is not. It is not a weakness in the receipt: signatures, hashing and the duty-of-care checks are unaffected, and a receipt still proves some holder of that credential approved that payload. What it cannot prove is which person.
What limits the damage today. Login attempts are throttled with a persistent lockout
that survives restart, and unknown verifier ids throttle identically so the endpoint is
not an existence oracle. The session lifetime is enforced server-side inside the signed
token, not by the cookie's Max-Age.
Four things changed since this entry was written, and they narrow it rather than close it:
- Sessions can now actually be ended. Sign-out revokes the session server-side rather than asking the browser to drop a cookie, and one operator command ends every session a person holds, including on devices nobody enumerated. Before this, "there is now a sign-out control" was true and meant almost nothing: the token stayed valid for its full twelve hours.
- The receipt now says how the account signed in. It carries
assurance: SHARED_SECRET— the weakness stated in the evidence itself rather than disclosed in a document nobody attaches to it. The certificate a firm hands to a bank reads "a username and password (no second factor; identifies a login, not a person)". - A duty-of-care check that could never fail now can.
verifierAuthenticatedwasverifierId.length > 0on an id already resolved from a validated session, so every receipt ever issued asserted it. It now fails when an ELEVATED item is decided more than four hours after the last sign-in, which is the "logged in this morning, approved a wire at five" case — and that approval is refused until the reviewer signs in again, so the check reads false only on a refusal. As first built it was only recorded, and the approval went through (entry 13). - Production refuses to boot on shared passwords unless the operator records, in configuration, that they accept this specific risk. The launch blocker is now enforced by the server rather than by this paragraph.
None of that addresses a shared or stolen password, which is the thing itself.
Why it matters more here than elsewhere. One of the four adversaries this design addresses is the insider verifier — someone who approves a payment to their own account and later denies it. A credential that cannot distinguish two people is exactly the credential that threat needs.
What would close it. SSO with enforced MFA for the session, and a per-decision
WebAuthn assertion on ELEVATED items — at different layers rather than as alternatives.
SSO gives a firm the lifecycle control a compliance function actually asks for; it does
not answer threat #3, because amr: ["mfa"] can describe a login seven hours before the
approval. The assertion does, because a key that cannot be copied signed that payment.
Do not deploy against real payment rails until at least the first exists. The acknowledgement variable makes a scoped pilot possible — shadow mode, a test account, a low ceiling — and is not a substitute for either.
2. What the reviewer sees is not bound to what is signed
This is the second launch blocker, and it sits under the product's central claim.
What it is. The receipt binds payloadHash — a hash the server computed at ingest.
Nothing binds that hash to what the console actually put on the screen. Three links are
missing, and each is independently true:
- The review screen never reads
payload. It rendersdiffs, a second copy of the five payment fields, computed once at ingest and stored in its own column. The signature coverspayload_json; the human readsdiffs_json. The two are bound by nothing but one insert having written both. - The console never reads
payloadHashat all — it does not re-canonicalise and does not compare. Verifying the receipt signature proves the server's claim about a hash, never that the hash describes what was displayed. - No test welds the chain. Nothing anywhere builds a payload, hashes it, renders it, and compares the rendered text against the hashed bytes. Both ends are strong and independently well tested, which is exactly why the missing middle went unnoticed.
Separately, characters that hash faithfully and render deceptively — zero-width spaces, bidi overrides, homoglyphs — reach both the hash and the screen unfiltered.
What it is not. It is not a weakness in the receipt, and not a reason to doubt one. A receipt still proves that some holder of a verifier credential approved that payload, and the payload hash is reproducible by anyone: the offline verifier, given a payment instruction alongside a receipt, settles precisely whether the record is about that payment. What is unproven is the step before — that the payload the hash names is the one rendered to the person who approved it. The binding runs server-side only.
What limits the damage today. Most of this entry's original "what would close it" has since been built, and the entry is kept because what remains is the part that matters:
- The diff is derived at read time from the signed payload, by one shared implementation that both the server's serialisation and the review screen use. The second stored copy is no longer what anybody reads.
- A test welds the chain rather than testing each end: it builds a payload, hashes it, renders the component, and asserts the rendered text against the hashed bytes — with a one-character perturbation proving the assertion can fail.
- The server refuses rather than renders when a stored payload no longer hashes to its stored hash, on the review route and again before a certificate PDF is produced.
- The certificate marks what the signature does not cover. The risk tier and its reasons, and the simulated CoP result, are printed under "Not covered by the signature" rather than among the signed values. The receipt still does not carry them.
- A permitted character class on
vendorNameandinvoiceNumber, applied to the payload and to the ground truth, refuses characters that render invisibly or reorder surrounding text — and the diff table now enforces its positional-comparison precondition instead of asserting it. - Risk scoring was always server-side over
payload, so none of this was ever a silent bypass of the risk tiers; andsortCodeandaccountNumberare pattern-constrained, so the two fields that actually move the money never accepted such characters.
What would close it. The browser still does not recompute payloadHash. Everything
above makes the binding a property the server enforces; none of it makes it something the
reviewer's own machine proves, and a modified client is outside what any of it reaches. The
offline verifier remains the only tool that checks this binding independently, and it does
so after the fact rather than at the moment of decision.
One narrower thing also stays open and is not closed by the character class. Homoglyphs
are addressed above; the payee-name collision in normaliseName has its own entry, because
"left alone, deliberately, for this reason" is a property in its own right rather than a
footnote to this one — see entry 12.
Homoglyphs stay unmitigated, and the character class does not close them. A Cyrillic
о in a Latin vendor name is a letter. It passes the permitted class deliberately, and it
renders identically to the Latin o. Rejecting it would mean rejecting a legitimate
non-Latin supplier name billing a UK firm — a control failing on real input, which is the
failure this register exists to refuse. What limits it today is indirect and worth stating
precisely: the payee fingerprint and the CoP comparator both normalise through [^a-z0-9],
so a homoglyph changes the fingerprint and yields NO_MATCH rather than EXACT_MATCH,
and the payment elevates. What would close it is a confusables check (Unicode TR39) at
ingest, surfaced to the reviewer as a risk reason rather than used to reject.
3. Rendering deception is unmitigated
What it is. The console asks a human to read digits off a rendered PDF. A document crafted to display one account number while its text layer encodes another — or to render differently at different zoom levels — defeats that, and nothing in V1 detects it. The typed challenge is the most exposed surface, because it exists to make the operator read the render.
What it is not. It is not a parser compromise. pdf.js runs in a sandboxed frame with an opaque origin and cannot reach the session, so a malicious parser is contained. This is a document that parses correctly and lies.
What limits the damage today. For the five compared fields, the comparison against the
invoice data the customer supplies catches a mismatch — but only where source is
STRUCTURED_FEED (see entry 4). The console labels which source it is comparing
against.
What would close it. Comparing the rendered raster against the extracted text layer, or requiring a structured feed for high-value payments. Neither is in V1.
4. OCR-sourced ground truth is not independent of the document it is compared against
What it is. The comparison table is the mitigation for a fraudulent payee plus a
matching doctored invoice. It works because the two columns are independent witnesses.
For source: 'STRUCTURED_FEED' they are — the finance system never saw the agent's PDF.
For source: 'OCR' they are not: the ground truth is read out of the same
attacker-supplied artifact, so a doctored invoice moves both columns together and the
diff agrees with itself while being wholly wrong.
What it is not. It is not a reason to distrust the diff generally. Against a structured feed the mitigation is real and is the product's main answer to threat #1.
What limits the damage today. The console shows the source, and the wording differs
materially: "compared against your finance system" versus "compared against OCR of
this same document". Low-confidence OCR raises GROUND_TRUTH_LOW_CONFIDENCE and forces
ELEVATED. CHALLENGE_FAILED on OCR-sourced ground truth is excluded from verifier
metrics, because a mismatch there can mean the document deceived the operator rather
than that they were careless. The README's claim is narrowed to STRUCTURED_FEED
explicitly.
What would close it. Requiring STRUCTURED_FEED above a value threshold. A policy
decision, not a technical one.
5. The render-failure signal is client-reported and can be suppressed
What it is. The console treats any warning from the PDF renderer during load or render
as a render failure
— approval unarmed, CHALLENGE_UNVERIFIABLE. That detection happens in the browser.
A modified client that omits a genuine warning arms approval over a degraded or
substituted render.
What it is not. It is not symmetric. A client that falsely claims a failure only blocks its own approval, which is harmless. Only omission fails open.
What limits the damage today. It requires a modified client, so it is threat #3
(insider) rather than the agent. The warning taxonomy separates structural recovery from
substitution, and /v1/metrics/verifiers reports the rate and kind, so a systematic
class of documents is distinguishable from a one-off.
What would close it. Rendering the captured document server-side at ingest and
recording whether pdf.js warned. The bytes are already content-addressed and already
parsed once for magic-byte sniffing, so the marginal cost is a headless render. This is
named as the completion of the render check rather than a general roadmap item. Until it exists the
finding is provisional by construction, and unlike serverDwellMs there is no
server-side measurement of the same quantity to fall back on.
6. Shadow detection cannot see settings-file allow rules
What it is. The SDK refuses to start when a gated tool is in allowedTools or the
permission mode bypasses the gate. It cannot see allow rules in the Claude Code
settings files — upstream documents this explicitly — and it cannot see a consumer who
never calls withSignetGate at all.
What it is not. It is not a defence against a compromised agent in any case. A compromised agent simply omits the attestation and looks clean. It closes the honest-but-misconfigured case, which is the common one.
What limits the damage today. With the PreToolUse hook installed, allowedTools
and bypassPermissions do not actually bypass the gate — hooks run first and a hook
denial holds. The finding is sent with every ingest and raises AGENT_CONFIG_SHADOWED,
so the verifier deciding the payment sees that this agent is running a configuration
that can route around the control they are the last line of. It is labelled
selfReported everywhere it is shown.
What would close it. Server-side silence detection: "API key active, zero verifications in N hours". The backend already knows every agent's volume. It is the only check that covers both blind spots, and it is named as the completion of shadow detection.
7. PreToolUse hook precedence is unresolved
What it is. If a consumer co-registers another PreToolUse hook that can see a gated
tool, it is not known whether that hook's allow can override SignetGate's deny. The
rule that reduces several hook results into one decision lives in the Claude Code CLI
binary, not in the SDK, and cannot be answered from source.
What it is not. It is not an unbounded unknown. Of the three possible answers, two
are already mitigated: if deny is authoritative the vector is closed, and if the first
hook wins then installing ours first — which withSignetGate does, deliberately —
is the mitigation. Only "allow is authoritative" would be a live bypass.
What limits the damage today. Co-registration is reported as a finding at warning
severity, which is the conservative answer, and reaches the verifier through
AGENT_CONFIG_SHADOWED. Our matcher is installed first, and that ordering is documented
as load-bearing so it is not "tidied" into append order.
What would close it. A probe written for exactly this answers it empirically and self-tests clean over 24 fixtures with no key. It has not produced a verdict — an identity-linked API key and a TLS-intercepting endpoint agent stopped two attempts. Thirty minutes of someone else's machine closes this.
8. Confirmation of Payee is simulated
What it is. No CoP network call is made. simulateCop compares the agent's vendor
name against the invoice's and returns a match status.
What it is not. It is not presented as real. copSimulated: true is hardcoded, not
derived, so no configuration can make a simulated result look live, and every surface
showing a CoP result carries a persistent SIMULATED chip. Configuring a live check is
unimplemented and throws at boot rather than silently degrading.
What limits the damage today. The labelling, and the fact that CoP is one input to risk scoring rather than a gate on its own.
What would close it. Integration with a live CoP provider, which is out of scope for this version.
9. A single verifier can approve any payment
What it is. One authenticated verifier, one decision. There is no dual control, no value threshold above which a second approver is required, and no separation between who may review and who may approve.
What it is not. It is not unmeasured. Canaries measure whether verifiers actually catch planted defects, and the catch rate is reported per verifier.
What limits the damage today. The friction engine (dwell, typed challenge), the canary programme, and the fact that every decision is bound into signed evidence naming the verifier. All of that makes a bad approval attributable; none of it makes one impossible.
What would close it. Dual control, with both reviewer accounts in the receipt. It is also
names the trigger: if the CHALLENGE_UNVERIFIABLE rate stops being rare, the control has
become a reason to route invoices around SignetGate, and dual control is the fix because
it is server-verifiable with no ground truth at all.
10. Coverage is not uniform: some paths are tested only against stubs
What it is. "Tested" means different things in different places, and the difference
is not visible from a passing suite. Some paths have been driven over real HTTP against
the real backend process; others exist only in unit tests through app.inject or against
the SDK's stub server.
Why this belongs in a risk register. Step 8 built a harness that ran the real backend and the real built console together for the first time and immediately found seven defects that every other runner reported green — including a console that never hydrated, so no button or hotkey worked at all, and an SSE client that killed every stream at fifteen seconds. A stub had also diverged from the real server and concealed an unreachable branch in a third component.
What limits the damage today. An internal review carries
an endpoint-by-endpoint table naming exactly which routes have been exercised end-to-end
and which have not. As of this writing all twenty routes have been, following the
fixes in b95d298. The residual is not a list of untested routes; it is that nothing
enforces the distinction, so the next endpoint added can be stub-only and look
identical.
What would close it. A coverage rule that treats "exercised over real HTTP" as a
property to be asserted rather than a habit — the same shape as the audits that already
keep repo-wide scans in audit:repo and behavioural enums in the mutation registry.
11. The offline verifier moves the auditor's trust, it does not remove it
What it is. The promise is that a customer's auditor can confirm a receipt without us. The offline checker bundle makes that practical for someone with no developer tooling — but "without us" is a claim about dependencies, and it is worth naming precisely which ones remain.
Three, in descending order of how much they should worry a reviewer:
- The public key. The tool answers "was this signed by the holder of the key you
supplied". If the key came from us and we are the disputed party, that is close to
circular. A firm that may one day contest a payment should record the published key at
deployment time, from
/.well-known/signetgate-keys, and keep its own copy — the verifier is only as independent as the key handed to it. - The page itself. It is a file we produced. Its SHA-256 is published beside it and the page pins its own script by hash, so an altered copy refuses to run rather than running altered. Neither helps against someone who can change the file and the published hash together, so the hash is worth comparing against a copy obtained separately rather than from the same email.
- The browser's Ed25519. Deliberately not ours: no crypto library is bundled, which is why there is no opaque blob to trust, and the page self-tests against RFC 8032's published vector — including confirming it refuses a signature it should refuse — so a broken implementation is caught rather than believed.
What limits the damage today. verify.js is a second, independent path using Node's
own crypto, and the canonicaliser both share is inlined from the product's compiled source
rather than reimplemented, so the tool cannot disagree with the system about what bytes
were signed. Where the browser cannot do Ed25519 the page reports CANNOT_CHECK and never
a verdict — measured, not assumed.
Safari has now been checked by hand, once. On 17 September 2026 the page was opened in Safari on a Mac and its self-test banner read green — which is precisely the check this entry named as what would close it. Recorded for what it is: one machine, one reading, by the author of this build rather than by a test. The Safari version was not recorded, nothing was driven through the corpus of genuine and tampered receipts there, and no automated run covers it. The paragraphs below are kept because they still describe what the automated coverage does and does not say.
Automated coverage still does not include Safari.
State it that way round, because the shape of the evidence invites the opposite reading.
Three engines were driven against the real artifact over file://: Chromium and Firefox
verify correctly, and Playwright's WebKit throws NotSupportedError on an Ed25519 key.
Three engines tested reads like coverage. It is not.
Playwright's WebKit is not Safari, and does not stand in for it in either direction.
Apple's Ed25519 arrives through a framework absent from the open-source WebKit build, so
that NotSupportedError is evidence about Playwright's build and nothing else. It does
not show Safari fails, and the other two passing does not show Safari passes. Safari is
covered by one manual check, not by the suite.
That matters more than a third-browser footnote usually would, because Safari is the default on macOS and iOS, and a compliance officer opening an emailed attachment on a Mac is a realistic path to this file — plausibly the most likely non-Chromium reader there is. Published support says Safari 17+ has Ed25519, and the one hand check above agrees with that on the machine it was run on.
The exposure is bounded by design rather than by the gap being small: a Safari without
Ed25519 shows "this browser cannot perform the check — nothing is wrong with your files"
and points at verify.js. It cannot produce a wrong verdict, only an unhelpful one.
What would close it: the hand check is done, so what remains is making it repeatable — a Safari run over the same corpus of genuine, tampered and wrong-key receipts that the other engines are driven through, on a machine that is available to the suite. Until then the claim is "checked once by hand on a Mac", which is what the claims sheet and the public page say.
12. Accented and unaccented payee names collide in the fingerprint
What it is. normaliseName lowercases a vendor name and replaces every run of
characters outside [a-z0-9] with a single space. Three things use it: the payee
fingerprint, the simulated CoP comparator, and the vendor-name row of the comparison
table. Because that class is ASCII-only it strips accented letters along with
punctuation — so Café Rouge normalises to caf rouge, which is exactly what Caf Rouge normalises to. Two different payees, one fingerprint.
What it is not. It is not a hashing or signing defect, and it does not touch the
evidence. payloadHash covers the vendor name exactly as supplied, accents included, so
two differently-spelled names produce two different receipts, and an auditor comparing a
payment instruction against a receipt gets the right answer either way. This is about the
derived value that answers "have we seen this payee before".
What limits the damage today. The collision needs two payees whose names differ only
in characters the normaliser discards. Where that happens, the second inherits
payeeSeenBefore: true and loses that ELEVATED trigger — but only that one. The CoP
comparator returns EXACT_MATCH only for a byte-identical name, so a payee that collides
after normalisation while differing before it yields CLOSE_MATCH, which is itself an
ELEVATED trigger. The payment still elevates; what it loses is one of the
reasons, not the tier.
What would close it. A Unicode-aware fold — NFKD, strip combining marks, casefold —
in place of [^a-z0-9].
Why it was deliberately left alone, which is why this is an entry rather than a
footnote to entry 2: the fingerprint is stored on every verification ever created, and
changing how it is computed makes every stored value incomparable with every new one.
Nothing would fail. payeeSeenBefore would simply begin answering a slightly different
question, and the rows written before the change would quietly stop meaning what the rows
after it mean. That is a migration with a backfill — and a re-check of the canary
distribution, since the canary design makes the punctuation behaviour of this same normaliser
load-bearing — rather than a line edit made inside a hardening pass.
13. Two duty-of-care checks rest on what the server cannot verify, and the release check lives in our client
What it is. A receipt carries six duty-of-care checks and an overall verdict, and every check is now enforced for approvals: the server refuses an approval unless its record would pass, the signer refuses to sign one that does not, and the SDK refuses to release on one. What that enforcement rests on is not uniform, and two of the six rest on something the server cannot verify:
| Check | What the server observes | What it cannot verify |
|---|---|---|
copResultAcknowledged |
Only that the reviewer's browser sent the claim | That anyone read the banner. A modified client makes the claim without reading anything, and looks exactly like an honest one |
verifierAuthenticated |
When the session began, on its own clock | Who proved the password (entry 1). Signing in again proves the same shared secret, typed more recently |
documentIntegrityVerified |
That it captured and hashed a file | What the file really says (entries 3 and 4) |
minimumDwellMet |
Time from claim to decision | Attention (see the claims page) |
elevatedChallengePassed |
That the typed digits match the invoice data | That the invoice data is right (entry 4), and that the browser reported every rendering problem (entry 5) |
decisionWithinDeadline |
Its own clock | Nothing — it is true on every receipt by construction, because a late decision is refused and no receipt exists |
Separately, the refusal to release on a failed record is a property of our client, not of the protocol. The SDK reads the record and refuses. An integration that calls the API directly, or runs a modified SDK, can release a payment on any receipt it likes. The server's refusal to sign such an approval does not depend on the client, so a server running this version does not issue one — but nothing downstream of the receipt forces anyone to read it.
What it is not. It is no longer a gap between what the evidence states and what the system does. Until the change that added this entry, that gap was real and was not a decision:
- An ELEVATED approval long after sign-in went through and was recorded as failing
verifierAuthenticated. - The server accepted approvals without the acknowledgement claim — only the console required
it — and recorded
copResultAcknowledgedas failed. - The SDK released on
decisionalone and never read the record.
So a signed receipt could say a check failed on a payment that was made. Neither unenforced check was chosen; the decision log records how they drifted. Both now gate, and from this version the server does not sign an APPROVED receipt with a failed check. (Whoever holds the signing key can still sign anything outside the server — entry 11.)
What limits the damage today.
- Sign-in age is refused with
422 REAUTHENTICATION_REQUIREDrather than recorded. The reviewer signs in again, and the claim is kept. - The acknowledgement claim is required by the server as well as the console. The console and the certificate both present it as the browser's report rather than a measurement.
- The signer refuses to sign an approval whose record fails. A decision-path gate that is missing — for instance, for a check added later — surfaces as a refusal rather than as signed evidence.
- One conjunction. The server, the signer and the SDK use the same conjunction, from one shared implementation, declared per check: a new check fails compilation until someone says how it passes.
What would close it.
- The acknowledgement: nothing server-side can observe reading. A per-decision WebAuthn assertion would bind the claim to a specific hardware key at the moment of decision, which is entry 1's closure; it would still not show that the banner was read.
- Sign-in age: entry 1's closure.
- The release check: move it to the payment rail. A payment system that refuses an instruction without a valid APPROVED receipt whose record passes makes the check a property of the payment, not of our library.
Receipts issued before this change can carry APPROVED with a failed check. Read them as
such. The offline checker does not display the checks at all: it verifies the signature and
shows ten identifying fields, so it will call such a receipt VALID, which it is.