MVF-2026-0002 — the disclosure ledger's own records could be redacted
The four ledgerredact builtins checked the handle, the id, the reason and whether the reason leaked what it redacted — and nothing about what the node or edge was.
| Identifier | MVF-2026-0002 | | Internal row | M26-CUS-001 | | Severity | S2, priority P0 | | Affected | no released version | | Not affected | every release: 1.0.0, 1.0.1, 2.0.0, 2.0.1, 2.1.0, 2.2.0, 2.3.0, 2.4.0 and 2.5.0 | | Fixed in | 2.6.0 | | Reported | internally; design review, confirmed 2026-09-24 | | Published | 2026-10-05 | | CVE / GHSA | none assigned | | CVSS:3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N | | CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
Summary#
The four ledger_redact_* builtins checked the handle, the id, the reason and
whether the reason leaked what it redacted — and nothing about what the node
or edge was. A script holding a writable ledger handle could therefore redact
the disclosure schema's own records: a Withdrawal, a Disclosure, a
ReclassEvent. Because every disclose_* read finds its records through a
property index and a redaction deletes the index entry with the node, removing a
Withdrawal did not merely hide it — it made the withdrawal invisible to the
code that decides whether to issue a grant, so the record could be disclosed
again to the recipient whose access had been withdrawn.
Why this page exists although no release was affected#
Every identifier in this register has a page, whether or not an operator can be affected by it, so that a citation never lands on a gap and a reader never has to work out from the absence of a page whether one was owed. What this page can tell an operator is short and worth saying plainly: there is nothing to do. The disclosure ledger is not in any released version of Mutant, so no installed copy has this defect and no case material produced by a release can carry it. The affected code lived only on the development branch, for four days.
Affected range, checked at each tag#
builtin/ledger_redact.go first appears in f0190e2 (2026-09-21), which is
30 commits after the v2.5.0 tag — git describe --tags f0190e2 gives
v2.5.0-30-gf0190e2, and git tag --contains f0190e2 is empty. At every one of
the nine tags, the file is absent, no ledger_* builtin is registered, and the
whole disclosure surface is absent with it. The defect existed on dev in a
window of 12 commits between 2026-09-21 and the fix on 2026-09-25.
Details#
ledgerRedactNodeScope and ledgerRedactEdgeScope are the shared bodies of
ledger_redact_node, ledger_redact_node_properties, ledger_redact_edge and
ledger_redact_edge_properties. Each validated the handle, resolved the id,
required a reason, and checked that the reason did not quote the property values
it was about to destroy — then called straight through to the store's
RedactNode, RedactNodeProperties, RedactEdge or RedactEdgeProperties.
A redaction at node scope deletes the node, its labels and its index entries.
The disclosure readers locate records through those index entries, so the
deletion removes the record from the answers rather than marking it removed. A
property-scope redaction clears the index entry too, with the same effect. The
reproduction recorded a new grant issued to a recipient whose access had been
withdrawn, and disclose_history reporting the withdrawal count as zero.
The fix#
A subject check, ledgerSchemaSubject, called in both scopes before anything is
destroyed. It decides by label range rather than by a list of names to protect:
ledger_add_node writes exactly one label and refuses any type outside 0–127,
so a script can only ever produce labels in that window, and anything outside it
— the store's own built-in Case type, the disclosure schema's types, and an
unlabelled node — is not script-written and is refused. For a whole-node
redaction the guard also walks the cascade and refuses when it would carry a
protected edge away with the node, which is what stops the protection being
bypassed indirectly. A script's own edge that merely touches a schema node stays
redactable, and the fixture exercises exactly that case so the guard cannot be
widened into a blanket refusal without a test noticing.
ledger_redaction_impact gained protected and protected_reason so a preview
does not describe a removal that will then be refused, and
docs/DISCLOSURE_POLICY.md gained a paragraph stating that a disclosure record
is corrected by appending a new record, never by removing an old one.
What this fix does not do, stated because the obvious reading is wrong#
It prevents; it does not detect. No disclose_* reader consults the
redaction log, before the fix or after it. The one place a reader notices a
missing node is disclose_verify's lookup, and it fires only because the lookup
failed — it reports that this is either not the ledger the disclosure was
recorded in, or the node has been redacted, and it cannot tell those apart. A
schema record destroyed by any route other than those four builtins leaves the
grant path exactly as blind as it was.
It is a check at Mutant's API boundary, not protection of the bytes. The
ledger is a store on a local filesystem. Nothing here protects it from the store
library used directly, from another program, or from an editor. What was verified
is that the builtin surface is closed: all four calls into the store's redaction
API are reachable only through the two guarded scopes, and a ledger handle comes
from ledger_open and is not interchangeable with a graph handle.
No role prevented this, and none prevents it now. At the time of the fix the redaction scopes did not consult the role at all; a later commit refused an auditor, and even that is not authorisation — the codebase says so in its own words: a role is asserted, not authenticated.
Fixture#
builtin/ledger_schema_redact_test.go, TestNoSchemaNodeOrEdgeIsRedactable. It
walks the protected types, asserts each is refused at both scopes, and asserts
that a script's own edge touching a schema node is still redactable.
Workarounds#
None is needed: no released version is affected. For anyone who ran a development build inside the 2026-09-21 to 2026-09-25 window, there was no switch that disabled the redaction builtins — Mutant takes no configuration from environment variables and these have no feature gate — so the only measure was procedural: do not hand a writable ledger handle to a script you did not write. Opening the ledger as an auditor would not have helped, for the reason above.
After the fact, ledger_redactions, ledger_redactions_review and the ledger
verification builtins can enumerate and attest redactions, so a destroyed schema
record does leave an auditable trace if someone looks. That is detection
only. It does not restore the node, does not restore the index entry the readers
read through, and does not make any grant path refuse a recipient whose
withdrawal was removed. It is a partial, after-the-fact measure and is not a
mitigation.
Credit#
Found by this project's own 2.6.0 security review, in a design review of the disclosure schema on 2026-09-24, and confirmed the same day. No external reporter.
References#
- The entry in CHANGELOG.md under 2.6.0, Security.
- SECURITY.md for the severity scale and the reporting process.
- The advisory register, which carries both severity vectors for every identifier.
- docs/DISCLOSURE_POLICY.md, which now states the append-only rule this defect broke.