witness-dao
v0.1.0
Published
Governance-evidence and compliance-reporting layer for DAOs and their legal wrappers. Seals governance-significant actions to tamper-evident, externally anchored records bound to the rule that governed them.
Downloads
40
Maintainers
Readme
Witness-DAO
Governance evidence for DAOs that survives contact with a regulator, an auditor, or a court.
Witness-DAO seals every governance-significant action — proposal, vote, multisig approval, treasury movement — to a tamper-evident, externally anchored record bound to the rule that authorised it, then turns that trail into artefacts humans actually rely on.
Built on VDA Witness (the evidence layer of Verified Digital Agents).
Why this exists
The blockchain already proves a vote happened. It does not produce:
a unified record spanning off-chain deliberation, Snapshot tallies, multisig approvals and treasury movements, each mapped to the ratified rule version;
proof of process followed — or of non-participation — for a named individual;
evidence a legal wrapper can put in front of an auditor or a supervisor when asked to demonstrate that its governance decisions followed its own ratified rules.
That third bullet is deliberately narrow. This is not a MiCA report, not an AML report, and not a substitute for either — it produces evidence a human then interprets. 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. Witness-DAO performs none of them. What it can honestly contribute is a tamper-evident evidence layer for the governance-arrangements and record-keeping limbs, if your counsel concludes that is useful to you. No AML capability exists at all; see
governance-rules/treasury.transfer.md.
That third gap is the one with a buyer. After the CFTC's Ooki DAO action (a DAO treated as an unincorporated association, where voting your tokens can create personal liability) and the 2025 N.D. Cal. ruling treating Lido DAO as a general partnership (exposing token-holders and multisig signers personally), the people with real exposure are the named humans: core contributors, multisig signers, and the Wyoming / Marshall Islands LLC wrapping the DAO. For them, provable process and provable absence are defensible assets.
Witness-DAO is built for the wrapper, the signers, and their counsel — not for the anonymous token-holder.
The three disciplines this codebase enforces
These are not style preferences. They are the reason the output is worth anything.
1. Binding confidence is never laundered
A sealed record is only as truthful as the claim fed into it. Sealing an unverified assertion produces a beautifully tamper-evident lie. So every event states, in machine-readable form, how strongly it is tied to observable reality:
| Confidence | Meaning |
|---|---|
| onchain_confirmed | In a confirmed block — tx hash + block number + chain id |
| signed_offchain | Backed by a verified off-chain signature (Snapshot EIP-712), not settled on-chain |
| api_asserted | Read from a centralised API we cannot cryptographically check |
| unbound | Operator assertion, no corroboration |
assertBindingIntegrity() structurally enforces that a claimed level actually has the identifiers it requires. No layer may upgrade a confidence level.
Concretely: Snapshot's tally is computed by their centralised server, so it is recorded as api_asserted — upgraded to signed_offchain only if we independently recompute it from the complete enumerated vote set. Divergence from the API's own numbers is recorded, never hidden.
2. "Verified" is not "asserted"
Anchoring reports verified only when something was cryptographically checked against pinned, independent material. Where we can confirm a proof asserts inclusion in Bitcoin block N but cannot confirm block N itself without a header source, the verdict is asserted_unverified — and the offline verifier will not reach ANCHORED_VALID on it. We do not upgrade ourselves on trust.
3. Evidence, not a certificate
Attestation is a human act. Reports carry a non-weakenable disclaimer. Records on the free, unanchored Sealed tier are blocked from reports by default (assertCompliancePosture) because that tier is terminal — it never becomes anchored, and presenting it as compliance-grade would be a material misrepresentation.
Architecture
ingest ──▶ bind ──▶ seal ──▶ anchor ──▶ verify ──▶ report
│ │ │ │ │ │
Snapshot binding VDA Bitcoin/ offline governance
Safe confid- Witness OTS + (zero evidence +
Governor ence (+rule) Rekor + VDA liability
RFC-3161 calls) defenceTwo layers people constantly conflate — kept deliberately separate:
Binding (read) — the DAO's own chains, where governance actually happens. Not a choice; dictated by where each DAO lives. Ethereum L1 + Optimism/Arbitrum/Base + Snapshot + Safe.
Anchoring (write) — where we commit a Merkle root to prove tamper-evidence. This is a choice: Bitcoin for permanence and neutrality, Rekor + RFC-3161 timestamps from commercial TSAs for fast coverage of the window before Bitcoin confirms. Run in parallel, under a quorum policy — never instead of one another.
On eIDAS, precisely. 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. The difference is not cosmetic: eIDAS Art. 41(2) grants a presumption of accuracy of the date and time, and of integrity of the 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. Certificate-chain validation is
not_checkedby default (seeLIMITATIONS.md§1). If you need the Art. 41(2) presumption, supply a QTSA endpoint and achainValidator.
Cost follows cadence, not volume
Anchoring commits a Merkle root, not each record. One transaction covers an unbounded number of records, so on-chain cost tracks anchoring cadence (~730 tx/month hourly), not seal volume. Sealing 5,000 or 50,000,000 decisions costs the same on-chain. A private/permissioned chain is deliberately not supported as an anchor: something the vendor controls cannot back "provable even against the vendor."
Install
pnpm install --ignore-scripts
pnpm build
node dist/cli/index.js --versionRequires Node >= 22.6.0. Zero runtime dependencies — all crypto is node:crypto, all HTTP is fetch, RFC 8785 canonicalisation / Merkle trees / OTS binary format / DER encoding are implemented in-tree and unit-tested.
dist/ is the production entrypoint
Run node dist/cli/index.js (and node dist/mcp/server.js), or the published
witness-dao bin, which points at the same file. The pnpm cli / pnpm mcp
scripts run from source through --experimental-strip-types, an unstable
Node flag whose semantics change between minor releases; they exist for the
development loop and are not supported for anything you intend to rely on.
engines.node is >=22.6.0 because that is the first release where those
scripts work at all — an earlier declared floor could not run its own test
suite.
A Dockerfile (multi-stage, pinned Node, non-root, dist/ only) and a GitHub
Actions workflow ship in the repository. Operational procedures — scheduling,
alerting, backup scope, incident response — are in RUNBOOK.md.
Configure
Secrets come from the environment only. A credential found in a config file is a hard error (CONFIG_SECRET_IN_FILE) — a key in a file is a key in git.
export WITNESS_API_KEY="wtn.<id>.<secret>" # required to seal
export WITNESS_DAO_RULES_DIR="./governance-rules"
export WITNESS_DAO_RPC="1=https://…,10=https://…" # optional, for Governor
export WITNESS_DAO_STATE_DIR=/var/lib/witness-dao # durable state
export WITNESS_DAO_LOG_LEVEL=info # structured JSON on stderrState is namespaced per account and per DAO under the state directory, so two DAOs never share a journal file.
Key lifecycle
Witness API keys are short-lived by design. Bind an Ed25519 controller key once, while the current key still works, and renew on a cadence — otherwise the key expires, every seal fails, and the failures sit in the journal until somebody notices.
openssl genpkey -algorithm ed25519 -out controller.pem && chmod 600 controller.pem
export WITNESS_DAO_CONTROLLER_KEY_FILE=$PWD/controller.pem
witness-dao keys bind-controller # once
witness-dao keys renew # on a cadence; also run before expiryThe renewal protocol is POST /api/witness/renew/challenge → sign
vda.witness.renew/1|<accountId>|<nonce> → POST /api/witness/renew. Only the
public JWK and a signature ever leave the machine. A key file readable by anyone
but its owner is refused, not warned about. For production, implement the
ControllerSigner interface against your KMS or HSM rather than using a file —
it is two methods, and the library never sees private key material.
Use — CLI
# Check the wiring without sealing anything or spending quota
witness-dao selftest
# What happened, and how well is it bound to reality?
witness-dao ingest --dao ens.eth --since 2026-01-01T00:00:00Z
# Full run: ingest → seal → anchor
witness-dao seal --dao ens.eth --since 2026-01-01T00:00:00Z
witness-dao seal --dao ens.eth --dry-run # build requests, send nothing
# Replay seals that previously failed. NOT automatic — schedule it.
witness-dao seal --dao ens.eth --retry
# Independent verification — no network, no trust in the vendor
witness-dao verify --bundle ./proof-bundle.json
# The artefacts that matter
witness-dao report governance --dao ens.eth --format md --out evidence.md
witness-dao report liability --dao ens.eth --subject 0xabc… \
--purpose "Defence of signer in matter 2026-114" --format md --out defence.md
# Promote pending Bitcoin anchors once they confirm (run on a schedule)
witness-dao anchor status
witness-dao anchor upgrade
# Data protection: erase a record body, and enforce the retention policy
witness-dao erase --record rec_01H... --reason "Art.17 request REQ-2026-0142" --confirm
witness-dao erase --list
witness-dao purge --max-age-days 730 --dry-run
witness-dao purge --max-age-days 730 --confirm
# Key lifecycle: bind once, renew on a cadence
witness-dao keys bind-controller
witness-dao keys renewFour scheduled jobs, not one
Sealing is fail-open: an evidence-layer outage must not take down a
governance pipeline, so a failed seal is stored complete in the journal instead
of throwing. That is only safe because something drains the journal — and that
something is seal --retry, which you must schedule. If you run only the window
job, then once --since advances past a failed window those governance actions
are absent from the trail permanently.
| Job | Cadence | Skip it and… |
|---|---|---|
| seal --dao <id> --since … | hourly/nightly | nothing is recorded |
| seal --dao <id> --retry | every 15–30 min | failed seals are never replayed |
| anchor upgrade | hourly | records stay SIGNED_PENDING forever |
| keys renew | daily | the short-lived key expires and sealing stops |
systemd timers and Kubernetes CronJobs for all four are in
RUNBOOK.md, along with the metrics worth alerting on.
Observability
WITNESS_DAO_LOG_LEVEL=info emits structured JSON logs on stderr (never
stdout, which carries the result). WITNESS_DAO_METRICS=1 emits one JSON metric
object per line, with a pluggable sink for Prometheus or OpenTelemetry — no
dependency added. Instrumented: seal success/failure, journal backlog depth and
age-of-oldest, anchor pending age, quorum-met rate, per-provider error rate and
latency, remaining Witness quota, account tier, and key time-to-expiry. Every
tolerated problem carries a machine-classifiable code alongside its prose in
--json output.
Exit codes are meaningful (designed for CI):
| Code | Meaning | |---|---| | 0 | Success | | 1 | Failure, or a refusal to emit misleading evidence | | 2 | Usage / configuration error | | 3 | Verification returned BROKEN — possible tampering | | 4 | Incomplete run, or INSUFFICIENT_PROOF — a gap in the trail |
3 and 4 are split on purpose, mirroring the library's tampering-vs-missing-data distinction.
Use — library
import {
WitnessClient, WitnessDaoPipeline, SnapshotProvider, SafeProvider,
GitRulesResolver, DirectoryRuleResolver,
MultiAnchorer, createOtsTarget, createRfc3161Target,
} from 'witness-dao';
const pipeline = new WitnessDaoPipeline({
client: new WitnessClient({ apiKey: process.env.WITNESS_API_KEY }),
ruleResolver: new DirectoryRuleResolver(
new GitRulesResolver({ rulesDir: './governance-rules' }),
),
providers: [
new SnapshotProvider({ recomputeTally: true }),
new SafeProvider({ chainId: 1 }),
],
anchorer: new MultiAnchorer({ targets: [createOtsTarget(), createRfc3161Target()] }),
});
const run = await pipeline.run({ daoId: 'ens.eth', since: '2026-01-01T00:00:00Z' });
if (!run.complete) console.warn(run.problems); // never ignore thisUse — MCP (for the DAO's own agents)
node dist/mcp/server.js # JSON-RPC 2.0 over stdioRead-only tools, always available: ingest_governance_events, verify_record,
governance_evidence_report, anchor_status.
Mutating and bulk-export tools are disabled by default:
seal_governance_events (writes durable records, spends the Witness allowance
and the anchoring budget) and liability_defence_report (returns a named
individual's entire participation history into the agent's context, and
therefore into the transcript). Enable them deliberately, per deployment:
WITNESS_DAO_MCP_ALLOW_WRITES=1 node dist/mcp/server.jsThe reason is specific rather than generic caution. MCP over stdio is
unauthenticated by design, and this product's own premise is that a compliance
copilot reads governance proposals — attacker-authored text that reaches the
agent's context through ingest_governance_events. That is a live
prompt-injection path into a money-spending tool. With writes enabled,
seal_governance_events still defaults to dryRun: true and requires an
explicit confirm: "<daoId>" echo, and third-party text in tool output is
delimited as untrusted data.
Every result returns binding confidence, the closed verification verdict, and
caveats — never a bare ok.
Governance rules ("git holds the rules")
Rules are markdown in a git directory. ruleVersion resolves to the git commit SHA that last touched the file, which is what makes charter-vs-code disputes resolvable: the sealed record proves which version of the rule text was in force.
Resolution is most-specific-first: snapshot.vote.cast → vote.cast → snapshot → default.
See governance-rules/ for a working example.
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 that directory exactly as you treat source code: it must be
write-restricted to the people entitled to change your governance, reviewed on change, and never
pointed at a path a third party can write to. A rule file also asserts obligations inside your
evidence — see governance-rules/treasury.transfer.md for what happens when a shipped example
mandates a control the tool does not perform.
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 evidence-strength calculus
Reports grade every line on a weakest-link basis, combining "is the record untamperable?" with "is it demonstrably about a real event?":
| verification ↓ / binding → | unbound | api_asserted | signed_offchain | onchain_confirmed | |---|---|---|---|---| | ANCHORED_VALID | UNSUPPORTED | WEAK | STRONG | PROVEN | | SIGNED_PENDING | UNSUPPORTED | WEAK | WEAK | STRONG | | INSUFFICIENT_PROOF | UNSUPPORTED | UNSUPPORTED | UNSUPPORTED | WEAK | | BROKEN | UNSUPPORTED | UNSUPPORTED | UNSUPPORTED | UNSUPPORTED |
Exactly one cell is PROVEN, and it requires both an anchored-valid record and an on-chain-confirmed binding. ANCHORED_VALID × api_asserted = WEAK is perfectly anchored hearsay: we prove what we were told and when, not that it happened.
Reaching the left-hand column at all requires an anchor verified against
material you pinned in advance — a Bitcoin block-header source, Rekor's log
key, or a trusted TSA root. None of that ships with the library, deliberately: a
built-in "trusted" explorer or root would reintroduce exactly the vendor trust
this layer exists to remove. Until you supply one, every record caps at
SIGNED_PENDING and the report says which material is missing. RUNBOOK.md §7
has the three hooks.
Reports are reproducible offline
Every substantive claim in a report — who acted, what they did, when, and how
strongly it is bound to reality — is read from each record's signed
decision.inputs, not from a live re-fetch. Building a report therefore
contacts no Snapshot, no Safe and no RPC: it needs your sealed records, the DID
document, and the anchor attestations in your state directory. The same command
over the same period produces the same reportDigest, which is the property
that lets opposing counsel regenerate your document and get yours.
Proving a negative, honestly
The liability pack's core value is proof of non-participation — and you cannot prove a negative from an incomplete record set. So basis is explicit:
| Basis | Ceiling | Meaning |
|---|---|---|
| complete_tally_sealed | PROVEN | A sealed tally establishes the complete actor set; subject is absent from it |
| enumerated_votes_sealed | STRONG | Every individual vote is sealed and the subject appears in none — but no sealed statement asserts the enumeration is complete |
| inferred_absence | WEAK | We simply have no record of them acting. Always carries a caveat that absence of evidence is not evidence of absence. Never PROVEN. |
Reaching complete_tally_sealed — and why it does not require sealing the voter roll.
The top tier needs a sealed statement of the complete participant set. Naively that
means committing every voter's address to a permanent, externally anchored record, which
is a data-maximisation incentive baked straight into the evidence calculus and points the
opposite way from data-minimisation. So the bundled Snapshot provider does not seal the
roll. When it has enumerated the vote set completely it seals a Merkle root over the
sorted, de-duplicated participant set instead (detail.actorSetRoot, RFC 6962 leaves over
sha256(lowercase-address)). The roll itself never enters a record and never reaches an
anchor.
At report time you supply the roll from your own erasable store with
--participant-set <file>; the report recomputes the root and relies on the tally only
on an exact match, reporting the disagreement otherwise. Be clear about the limits: this is
an ordinary ordered Merkle tree, so it supports inclusion proofs, not non-inclusion
proofs. It does not let a third party verify "X is absent" from a short proof alone — they
need the same roll. And it binds the set the collector held; whether the collector saw
everybody remains an operational property, evidenced separately. Without a roll, the pack
falls back to a weaker basis and says so in its caveats.
Testing
pnpm typecheck && pnpm testOver a thousand tests, zero dependencies, no network access. Signature tests use real Ed25519 keypairs and real hash chains rather than mocks, so they catch real errors. The suite is deliberately adversarial about over-claiming — e.g. it asserts that an anchor supplied without an independent verifier stays SIGNED_PENDING and can never reach ANCHORED_VALID, and it reproduces each audit proof-of-concept (a pre-created credential path, a lost concurrent journal update, an oversized JSON-RPC frame) as a regression test.
Known limitations — read before relying on this
Honesty about limits is the point of the product, so they are documented rather than buried. See LIMITATIONS.md for the full list. The material ones:
- Failed seals are replayed only when you run
seal --retry. It is a scheduled job, not a background daemon. If it is not scheduled, a transient outage becomes a permanent hole in the trail. - Customer-held signing keys do not exist. All signing happens server-side at VDA Witness; there is no local-signing tier in this codebase. If you were told otherwise, you were told wrong — see
LIMITATIONS.md§1. PROVENneeds trust material you must supply. Out of the box no anchor can be independently verified (no pinned Bitcoin header source, Rekor log key or TSA root ships with the library), so records cap atSIGNED_PENDING. SeeRUNBOOK.md§7.- State is file-backed. Writes are atomic and fsynced and concurrent runs are refused by an advisory lock, but there is no replication and no multi-host coordination. Supply a DB-backed
AnchorRunStore/SealJournalif you need either. anchor-runs.jsonis the only copy of anchor proof material and must be backed up. Records and the DID document are re-fetchable from upstream; that file is not.- Rekor is off unless configured (it needs a signing key and a pinned log key), so the default quorum leans on RFC-3161 until Bitcoin confirms.
- RFC-3161 CMS verification is not yet tested against a live TSA. It is conservative by design (an unusable certificate yields
not_checked, never a false pass), but wants one integration test before production reliance. - Bitcoin attestations report
asserted_unverifiedunless you supply a block-header lookup. This is correct, not a bug. - Snapshot enumeration caps out (~5000 votes); very large proposals cannot be fully enumerated, so the tally is never upgraded and
voteSetTruncatedis set. - Safe addresses must be EIP-55 checksummed (keccak-256 is unavailable in
node:cryptoand we ship no dependencies); a lowercase address yields an actionableSAFE_ADDRESS_NOT_CHECKSUMMED. - This is not legal advice. Every compliance claim needs counsel review before commercial use.
- Data protection is a shared responsibility and half of it is yours. This product builds
permanent, externally anchored behavioural records about identified natural persons. It now ships
crypto-shredding, a retention policy, a minimisation gate, an enforced residency allowlist,
supersession and dossier access logging — but it cannot supply your lawful basis, your DPIA, your
processor contract, your limitation-period analysis or your privacy notice. Read
DATA-PROTECTION.mdbefore your first seal, not after. - The VDA SaaS has no delete endpoint.
witness-dao erasedestroys the local plaintext and suppresses the record everywhere this tool handles it, permanently and durably. It cannot delete the copy VDA holds. That gap is stated at the point of use and in every affected report.
Data protection
Read DATA-PROTECTION.md. In brief:
- Only digests leave the building. OpenTimestamps, the RFC-3161 TSAs and Rekor receive a Merkle root or a hash of one — never a body, never an address, never a vote. Plaintext lives in exactly two erasable places: the VDA SaaS and your local journal. That is what makes crypto-shredding the right erasure strategy here rather than an impossibility.
- Wallet addresses processed by this product are personal data. The liability pack is built for a named individual, so the customer holds the address-to-identity linkage by design — that linkage is the product. The pseudonymity argument does not survive the value proposition.
- Free text is sealed as a digest by default (
WITNESS_DAO_CONTENT_FREETEXT=hash-only). Setfulldeliberately, knowing that arbitrary third-party prose then becomes irreversible. - Erasure and retention are commands, not aspirations:
witness-dao erase --record … --confirmcrypto-shreds a record body while retainingbodyHash/prevHash/signature, so the record verifies asINSUFFICIENT_PROOF(missing data) and neverBROKEN(tampering);witness-dao purgeenforcesWITNESS_DAO_RETENTION_DAYSover the local journal. - EU-only mode is enforced, not advisory.
WITNESS_DAO_EU_ONLY=1plusWITNESS_DAO_RESIDENCY_ALLOW_HOSTSrefuses any endpoint you have not assessed, at startup. The allowlist ships empty on purpose: this library does not know where any third party processes data and will not guess for you. - A liability pack requires
--purpose, records it in the artefact, and logs every generation to<state-dir>/…/dossier-access.log.
Licence
Apache-2.0. See LICENSE.
