Witness-DAO

Docs & audits / Product

Contents

Known limitations

This file exists because the product's entire value proposition is not over-claiming. A limitation documented here is worth more than a limitation discovered by opposing counsel.

Grouped by how much it matters if you rely on this in production.


1. Blocking for production reliance

There is no customer-held-key signing tier. At all.

Product materials have described a "higher-assurance, customer-held-key" tier as the answer to DAO scepticism about a custodial vendor. It does not exist in this codebase. There is no local signing anywhere in src/: every record is signed server-side by VDA Witness, and the only private key this software ever touches is the Ed25519 controller key used to renew an API key — which signs a renewal challenge, never a record.

The consequence is concrete: VDA can, in principle, produce a record your DID document verifies, because VDA holds the signing key. External anchoring is what constrains that (a root committed to Bitcoin cannot be back-dated), which is why the anchoring layer matters more here than it would in a self-signing design. But do not tell a counterparty that the DAO holds its own signing key.

Status: unimplemented. If it is being sold, that is a claim gap to close before the next conversation, not a roadmap item.

Failed seals are replayed only by a job you must schedule

Sealing is fail-open, and the failure is durably journalled with the complete request — but nothing replays it in the background. witness-dao seal --retry drains the queue and you have to schedule it, separately from the window job (see RUNBOOK.md §2). If you do not, a transient upstream failure becomes a permanent hole the moment --since moves past that window, and a liability pack over that period will later report inferred_absence for a signer who did in fact vote.

The backlog depth, the age of the oldest entry and the attempt count are printed by seal, exposed as metrics, and reported by selftest — but an alert on witnessdao.journal.oldest_age_seconds is what actually closes this loop.

`PROVEN` is reachable but requires trust material you must supply

The CLI and the MCP server now load anchor attestations from the durable anchor-run store and pass them into report building with a verifier, so ANCHORED_VALID and hence PROVEN are reachable. Out of the box they are not reached, because nothing pinned ships with the library: verifying a Bitcoin attestation needs a block-header source, Rekor needs its log key pinned out of band, and RFC-3161 needs a validator for your trusted TSA roots. Without them every anchor is asserted_unverified, every record caps at SIGNED_PENDING, and the report names the missing material. That is honest, and it is also a real ceiling on what a default deployment can claim.

State is durable on one host, and only one host

FileAnchorRunStore / FileSealJournal now write through temp file → fsync → rename → fsync of the directory, and a mutating command takes an advisory lock on the state directory and refuses to run concurrently rather than losing the other process's entries. That closes crash-durability and same-host concurrency. It does not give you replication, multi-host coordination, or transactional grouping across the two files. InMemoryAnchorRunStore / InMemorySealJournal remain test-only: they lose everything on exit.

Before production at scale: supply a database- or queue-backed AnchorRunStore and SealJournal via the library API.

`anchor-runs.json` is the only copy of the anchor proof material

Sealed records and the DID document are re-fetchable from upstream Witness, so a lost state directory is largely survivable — with one exception. The OTS / RFC-3161 / Rekor proof material and the per-record Merkle inclusion proofs exist only in anchor-runs.json. Lose it and pending anchors can never be upgraded, confirmed anchors can never be presented, and no record in those windows can reach ANCHORED_VALID again. Back it up; see RUNBOOK.md §6.

RFC-3161 CMS signature verification is untested against a live TSA

Minting an X.509 certificate without dependencies was out of scope, so unit tests cover our own logic (attribute lookup, signedAttrs re-tagging, digest checks) rather than a real DigiCert/Sectigo response. The implementation is deliberately conservative — an unusable certificate yields not_checked, never a false mismatch — but this is the one place where a wire-format surprise could silently downgrade every anchor.

Before production: one integration test against DigiCert and Sectigo.

Fail-open sealing requires a durable journal to be safe

Sealing is fail-open so an evidence-layer outage cannot take down a governance pipeline. That is only safe if the failure is durably queued. With an in-memory journal, a failed seal plus a restart equals a silent gap. The pipeline reports complete: false and populates problemsa caller that ignores those is choosing to accept gaps.

The credential expires, and renewal is a job you must schedule

Upstream keys are short-lived by design. keys bind-controller and keys renew implement the Ed25519 renewal protocol, and seal warns when the recorded expiry is within six hours — but the expiry is only known if a mint or renewal through this CLI recorded it, and renewal only happens if you schedule it. A deployment that binds no controller key has no recovery path: when the key expires, sealing stops, every event is journalled instead of recorded, and the backlog grows until an operator intervenes.

A renewed key is written to disk, not into the running process

keys renew writes the new credential to a 0600 file. Scheduled jobs must re-read that file (or your secrets manager) before their next run. A renewed key nobody loads is the same outage as no renewal at all.


2. Correct-but-limited: verification honesty boundaries

These are all cases where the code deliberately reports less than it could be tempted to claim. They are working as intended.

Bitcoin attestations are `asserted_unverified` without a block-header source

A walked OTS proof establishes "this digest is bound to value V, and the proof asserts V is block N's Merkle root". Confirming that block N actually has root V needs a Bitcoin node or explorer. Supply blockHeaderLookup to reach verified; without it the verdict stays asserted_unverified and the offline verifier will not reach ANCHORED_VALID.

Rekor cannot reach `verified` without a pinned log key

Fetching the key from the log you are verifying is circular, and the code refuses it as a basis for verified. Rekor also needs a signing key for hashedrekord entries (ECDSA P-256; Ed25519 is unusable because Rekor verifies against a digest). Consequence: Rekor is off unless explicitly configured, so zero-config anchoring is RFC-3161 + Bitcoin, and the default quorum leans on RFC-3161 until Bitcoin confirms.

Keccak-256 attestations are not evaluated

node:crypto has no keccak-256, and SHA3-256 is a different padding. Rather than guess, OTS attestations behind a keccak op are not counted and the verdict is incomplete. Affects only Ethereum attestations, never Bitcoin.

Reports over a partial record window grade as INSUFFICIENT_PROOF

A chain that does not start at genesis cannot demonstrate continuity, so it is honestly graded INSUFFICIENT_PROOF rather than claiming chainValid: true. This is correct but has real blast radius: windowed reports will not reach PROVEN unless you supply the full predecessor chain.

A report is only as complete as the anchor store on the host running it

Report content comes from the signed records (no live re-ingest, so the artefact is reproducible), but the anchor attestations come from the local anchor-runs.json. Generate a report on a fresh host or an empty volume and every line legitimately drops to SIGNED_PENDING, because nothing there can attest to the anchoring. That is honest rather than wrong, and it is a foot-gun: run reports where the state lives, or restore the backup first.


3. Coverage gaps in the binding layer

Each of these is recorded in the event's detail / rationale rather than silently absorbed.

Gap Effect
Snapshot skip ceiling (~5000) Very large proposals cannot be fully enumerated → voteSetTruncated: true, tally is never upgraded above api_asserted
Snapshot quadratic / ranked-choice voting Tally recomputation is refused with a stated reason rather than approximated
Safe MultiSend batches Not unpacked → batchedMultiSendNotDecoded: true, no treasury.transfer events emitted for the inner calls
ERC-20 transfers Decoded from calldata intent, not re-read from Transfer logs — so a reverted or rewritten transfer could be over-reported
Safe APPROVED_HASH / EIP-1271 confirmations Recorded as api_asserted: the on-chain approval is real but the endpoint gives no block number, and EIP-1271 needs an eth_call we do not make
Governor clock (block vs timestamp) Interpreted by magnitude heuristic, recorded as voteStartInterpretedAs
GovernorAlpha boolean VoteCast Off by default — it cannot express abstain
Governor listEvents Requires since or fromBlock rather than inventing a lookback window that would hide events
Safe addresses Must be EIP-55 checksummed. Keccak-256 is unavailable in node:crypto and we ship no dependencies, so a lowercase address returns an actionable SAFE_ADDRESS_NOT_CHECKSUMMED rather than being silently corrected

Witness-DAO produces evidence. Turning evidence into a regulatory artefact requires a human attestation by someone accountable — typically the legal wrapper or a compliance officer. Every compliance-facing claim needs counsel review before commercial use. assertCompliancePosture blocks unanchored Sealed-tier records from reports by default precisely so this line is not crossed by accident.

The commercial thesis has a single point of failure

The product's wedge is that the chain does not already do this. If a design partner shrugs and says "our votes are already immutable on-chain", the thesis is wrong for that buyer. The liability-defence pack is the intended emotional and commercial hook — if a named, personally-exposed signer will not pay for it, that is the signal to stop.

Who signs the attestation is often unresolved

Many DAOs have no single accountable compliance officer. This reinforces that the customer is the wrapper / foundation, not the amorphous DAO.

The `verdict` on every sealed record is `RECORDED`, never `PASS`

We are witnessing what happened, not adjudicating it. A meaningful PASS/FAIL would require evaluating the action against the rule (quorum met? within the spending limit?), which is the Agent Control Plane's job. Emitting PASS here would imply a compliance judgement this library has not made.

This product is **not** an EU AI Act Article 12 logging facility

The upstream VDA platform calls its /api/witness/report output an "Article 12 evidence report", because that platform's evidence layer exists to serve AI Act logging obligations. That framing does not transfer here and is not adopted. Art. 12 imposes automatic event-logging duties on high-risk AI systems, so their own functioning is traceable. Witness-DAO seals human governance actions — a person's Snapshot vote, a person's multisig signature, a treasury transfer authorised by people. There is no AI system in the loop whose operation is being logged. Nothing this library produces discharges an AI Act obligation. (If a DAO's own agents seal their decisions through the MCP server, Art. 12 may become relevant to that upstream platform; that is a different surface and would need its own analysis.)

"AML/MiCA-facing reporting" was an over-claim and has been removed

MiCA obligations attach to issuers and to authorised CASPs, and consist of authorisation, prudential safeguards, governance arrangements, record-keeping, complaints handling, conflicts of interest, custody segregation and market-abuse controls. This product performs none of them. What it can honestly contribute is a tamper-evident evidence layer for the governance-arrangements and record-keeping limbs. No AML capability exists at all — there is no screening provider, no screening field and no vendor integration — and the shipped treasury.transfer example rule no longer mandates one. Read the warning in that file before adding it back: a sanctions disposition is an allegation about an identified person, and sealing it in plaintext is the single most severe data-protection outcome this product could produce.

The RFC-3161 timestamps are **not qualified** under eIDAS

The default TSAs (DigiCert, Sectigo) are publicly-trusted commercial timestamping services under CA/Browser Forum requirements. They are not qualified trust service providers on any EU Member State Trusted List for timestamping. eIDAS Art. 41(2) grants a presumption of accuracy of date/time and integrity of data only to a qualified electronic time stamp. A non-qualified timestamp gets Art. 41(1): it may not be denied legal effect or admissibility solely because it is electronic or non-qualified. Admissible, yes. Presumptively accurate, no — the party relying on it carries the evidential burden. Chain validation is not_checked by default (§1 above). Supply a QTSA endpoint and a chainValidator if you need the presumption, and note there is currently no environment binding for the TSA list, so that means code.


4b. Data protection

Full treatment in DATA-PROTECTION.md. The limitations that belong in this file:

The VDA SaaS has no delete, redact or expire operation

witness-dao erase crypto-shreds the record body in the local journal and records a durable standing directive that tombstones the record everywhere this tool handles it — including records re-fetched from the SaaS, before they reach a report, a renderer or an MCP result. Local enforcement is complete. The copy VDA holds is untouched. The tombstone records this as remote: "not_supported", the command exits 4, and every affected report says so. Raise the deletion with VDA under your Art. 28 contract; do not represent an erasure as complete until they confirm it in writing.

A third-party verifier that does not install `tombstoneAwareSignedBody` will report `BROKEN`

A tombstone retains bodyHash but destroys the body, so a naive verifier recomputes a different digest and calls it tampering. Every verify and report path in this package installs the extractor, so the verdict is INSUFFICIENT_PROOF — missing data, which is what a lawful erasure actually is. If you hand a tombstoned bundle to an external verifier, tell them. The marker (witnessDaoErasure) is inside the record so it cannot be dropped, but it still has to be honoured.

A chain containing a tombstone cannot be walked past it

Chain continuity is therefore reported as INSUFFICIENT_PROOF for the whole chain, not just the erased record. Continuity is not disprovedprevHash and bodyHash are retained and a reader can check them by hand — but the verifier will not assert a continuity it could not walk. Merkle inclusion against the anchored root still verifies, because it operates on bodyHash.

The participant-set commitment supports inclusion, not non-inclusion

detail.actorSetRoot is an ordinary ordered RFC 6962 Merkle root over the sorted participant set. It removes the incentive to seal the voter roll, and it makes a supplied roll cryptographically checkable — but demonstrating that a specific address is absent still requires producing the whole roll. A sparse/sorted tree with neighbour range proofs would let a third party verify absence from a short proof alone. That is not implemented.

Free text is sealed as a digest by default, and that costs you something

WITNESS_DAO_CONTENT_FREETEXT defaults to hash-only, so proposal bodies, titles and vote reasons are committed by digest and length rather than sealed verbatim. Tamper-evidence and re-derivability survive; the ability of a report to reproduce the text does not. Hold the plaintext in your own erasable store, keyed by the digest. Set full deliberately, knowing the text then cannot be removed from what was anchored.

`witness-dao purge` does not cover everything in the state directory

It evicts seal-journal entries only. anchor-runs.json (proof material; back it up), dossier-access.log (personal data about the subject and the operator), erasures.json and any --out report artefacts are in scope for your retention schedule and are not touched.

Residency enforcement covers the endpoints this library configures

Strict mode refuses the VDA base URL, Snapshot, the RPCs, the Safe services, the OTS calendars, Rekor and the default TSAs. It cannot police an HTTP proxy, a DNS override, or a provider a caller constructs directly. The allowlist is empty by default and there is no built-in "these hosts are in the EU" table: this library does not know where any third party processes data and will not guess.

RFC-3161 requests go over plaintext HTTP

A network observer sees which digests you timestamp, when, and how often — anchoring cadence and volume metadata, plus a digest that could later be correlated with a published Rekor entry. No personal data is exposed. Your Snapshot, Safe and JSON-RPC query patterns leak similarly: the RPC operator can see which addresses you are investigating.

Erasing our copy does not erase the source

Snapshot votes and Safe transactions are public and permanent independently of this product. Do not tell a data subject otherwise.


5. Deployment and agent-surface caveats

The rules directory is a trust boundary

Its contents are read, parsed and sealed verbatim into every matching record, and git metadata inside it is executed against — a .git/config placed there was an RCE vector. Treat it exactly as you treat source code: write-restricted to the people entitled to change your governance, reviewed on change, never pointed at a path a third party can write to.

Network egress is default-deny

Requests to private, loopback and link-local address ranges are refused unless you opt in with allowPrivateNetwork (library) or WITNESS_DAO_ALLOW_PRIVATE_NETWORK=1 (environment). Enable it only for a self-hosted hub, RPC or Witness instance you control, and understand that doing so re-opens SSRF reachability from attacker-influenced URLs.

The liability pack is a behavioural profile of an identified natural person

--purpose is required, recorded in the artefact and written to <state-dir>/…/dossier-access.log. The MCP tool is risk: 'export' and stays disabled unless WITNESS_DAO_MCP_ALLOW_WRITES=1. None of that makes the processing lawful — it makes it reviewable. Defending your own signer and building a file on a rival produce the identical document, and only the recorded purpose distinguishes them afterwards. See DATA-PROTECTION.md §11.

The MCP server is unauthenticated, like every stdio MCP server

Anything that can talk to the process can call its tools with the credentials of the user who started it. Because the exposed tools are not read-only — sealing writes durable records and spends the anchoring budget; the liability pack is one named person's entire participation history — mutating and bulk-export tools are disabled unless WITNESS_DAO_MCP_ALLOW_WRITES=1, sealing defaults to dryRun: true and requires a confirm: "<daoId>" echo, and third-party text in tool output is delimited as untrusted data. None of that is authentication. If you enable writes, you are trusting the agent — and everything in its context, including attacker-authored proposal bodies — with your Witness credential.

There is no per-session spend budget

With writes enabled, nothing caps how many times an agent may call seal_governance_events in one session beyond the upstream allowance. Watch witnessdao.usage.remaining.

`--out` writes wherever you point it

It refuses to clobber an existing file without --force, refuses to follow a symlink, and refuses a relative path that escapes the working directory — but an absolute path is honoured, because that is what a hand-driven CLI is for. If you ever invoke this CLI with arguments from an untrusted source, sandbox it.

6. Minor / operational

  • tsconfig.json excludes test/, so tests are not type-checked. Add a tsconfig.test.json if you want that.
  • Three TS resolve shims exist from parallel development (test/support/ts-resolve.mjs is the canonical one wired into package.json; test/report/_ts-resolve.mjs and test/helpers/loader.ts are stale duplicates). Harmless, worth consolidating.
  • test/core/http.test.ts takes ~14s: backoff is jittered and there are no fake timers, so real retry waits dominate.
  • package.json's cli / mcp scripts run from source through --experimental-strip-types, an unstable Node flag that requires Node >= 22.6 (which is why engines.node says so). The production entrypoint is dist/: pnpm build && node dist/cli/index.js.
  • test/integration/ still does not exist despite the test:integration script, so no wire format has ever been checked against a live endpoint.
  • Coverage-shortfall caveats fire on any uncovered span by default (coverageGapToleranceMs = 0). Deliberate for absence claims, but noisy on quarterly reports over sparse data — the tolerance is injectable.
  • Metrics are emitted, not aggregated: the process is short-lived and the sink sees raw events. Rate and percentile calculations belong in whatever you bridge the sink to.