Skip to content

August Updates - #393

Open
Andre-Diamond wants to merge 62 commits into
mainfrom
preprod
Open

August Updates#393
Andre-Diamond wants to merge 62 commits into
mainfrom
preprod

Conversation

@Andre-Diamond

Copy link
Copy Markdown
Collaborator

No description provided.

kanyuku and others added 30 commits March 28, 2026 12:38
- Update Wallet type to include capabilities\n- Modify buildWallet to compute capabilities for Summon, SDK, and Legacy wallets\n- Update hooks (useAppWallet, useMultisigWallet, useWalletBalances) to leverage capabilities\n- Update UI components (CardWallet, ShowInfo) to use capability-driven rendering\n- Add unit tests for Summon wallet capabilities
- Bot onboarding for AI agents: public /bot-setup page, agent-readable
  /api/v1/botSetupGuide markdown endpoint, /llms.txt discovery, and a
  "Copy agent prompt" button on the user Bot accounts card so any AI
  agent pointed at the instance URL can register itself end-to-end.

- Cross-instance wallet transfer: new /api/v1/wallet/transfer/export
  (owner JWT) and /api/v1/wallet/transfer/import endpoints with a
  shared WalletTransferPayloadV1 type. UI exposes "Transfer wallet"
  on the wallet info page (download JSON or push directly to a remote
  instance URL, optional contacts/ballots payloads) and "Import
  Transfer" on the wallets list page. Imports land as NewWallet so the
  existing invite/claim flow takes over.

- Governance overview improvements:
  - Wallet governance dashboard summary card (proposal status counts,
    ballot progress, voting power, last ballot activity)
  - Live network stats strip on the public /governance landing
  - DRep list aggregate header, active/inactive filter, surfaced
    active_epoch and hex per row
  - Proposal detail "Your ballot entry" (rationale + anchor) and
    "Technical details" sections surfacing fields that were already
    fetched but never rendered

- Ballot UX + standalone rationale: shared rationale module
  (build JSON-LD, hash, upload to IPFS, load-from-URL) and a reusable
  RationaleEditor component. The vote card now offers an "Attach
  voting rationale" toggle so a user can attach a CIP-100 anchor to a
  single-proposal vote without creating a Ballot. VoteButton threads
  the anchor through to txBuilder.vote(). Ballot summary now reports
  rationale-uploaded / draft counts; moving a proposal between
  ballots is gated by a Keep / Add to both / Move here dialog instead
  of silently relocating it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…m/hours line

The month headings were renumbered on 2026-08-03 (984aa46) so that Month 1 =
April, but MRP task cards created before that date still carry bullet text for
the following month — the card headed "MRP Month 2" lists the June workstreams.
That mismatch has now cost enough time to be worth writing down.

- Add an MRP task mapping table at the top: MRP Month N = roadmap Month N =
  calendar month, for all twelve months, with the on-chain task hashes we have
  and a note explaining why the cards disagree.
- Route each MRP month to the actual merged PRs behind it (April 10, May 3,
  June 51, July 16), each count linking to the exact GitHub search so any row
  can be reproduced rather than taken on trust.
- Widen "Delivered to date" from May–July to April–July. April's output is
  infrastructure, so it folds into the existing sections: the preprod
  environment and real-chain smoke CI (#218, #217) under Testing & CI, and a
  new transaction-and-signing-integrity through-line under Platform (#217 VKey
  witness filtering -> #227 invalid-CBOR guard -> #257 Mesh pin + witness
  verification guard).
- Recover two May items the 2026-07-26 audit had missed: the Import Wallet
  wizard (#259) and the #257 signing fix.
- Fix the timeline left stale by the renumbering: April 2026 – March 2027, in
  ROADMAP.md, the /roadmap page and the SEO description.
- Remove the "Quirin + Andre · ~25 h/wk" line from ROADMAP.md and the public
  roadmap page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gn-off

Implements PRD-001: a wallet-native, off-chain approval layer where a team
binds approval to an exact content hash, inherits the wallet's signer set and
threshold, and exports a proof anyone can verify without an account.

Two rules carry the feature, and both are enforced server-side rather than in
the UI:

1. Version-hash binding. submitSignerAction rebuilds the canonical signed
   payload from the server's own records and requires a byte-identical match
   *before* the signature is checked, so a signature collected for one version
   can never be replayed onto another, and a tampered comment invalidates the
   submission. Inline content is re-hashed server-side and rejected on mismatch.

2. Threshold inheritance from a frozen snapshot. DocumentSignerSnapshot captures
   the wallet's signers, threshold and policy hash when the round starts;
   approval counting reads the snapshot, never the live wallet, so changing
   wallet membership cannot rewrite a decision already made.

Data model (5 models + migration 20260805090000): Document, DocumentVersion,
DocumentReview, DocumentSignerSnapshot, DocumentEvent. The migration enables RLS
with deny-all PostgREST policies on all five tables, matching the contract in
20260706100000_enable_rls_followup_tables (#332).

Router (src/server/api/routers/documents.ts): createDocument, uploadVersion,
startReview, submitSignerAction, exportProof, verifyProof, plus the reads the
pages need. CIP-8 verification uses Mesh's checkSignature against the signer's
address. verifyProof is public on purpose — a counterparty holding the JSON and
the file must be able to check it without an account, and it touches no DB.

Four routes under /wallets/[wallet]/documents: list, create, detail, and the
version review page. Files are hashed in the browser (SHA-256 via WebCrypto);
only the digest is sent, so the bytes never leave the signer's machine.

src/lib/documents/ is dependency-free apart from node crypto, with the signature
check injected into the verifier — the same code can run in an offline verifier
with no Mesh install.

Uploading a new version supersedes the previous one and starts a fresh round at
zero approvals: approval is bound to the hash, not the title.

Tests: 28 covering canonicalization, version-hash binding, threshold evaluation
and proof verification including tampering, duplicate signers, out-of-snapshot
signers and non-canonical payloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding or removing someone from a proxy had no in-app path at all: a proxy
was reachable only by signers of its controlling multisig, so sharing it
with an auditor or an ops account meant adding them as a signer, which
changes the multisig script and its address.

Introduce ProxyMember, a database-only access grant keyed by address so a
grant can be issued before the invitee has ever signed in. Owners (signers
of the controlling multisig, or the proxy's own user) always have access
and cannot be removed; a `manager` member can also edit the access list, a
`viewer` is read-only, and any member can remove themselves. The grant is
deliberately off-chain: spending and voting still require an auth token,
which lives at the multisig, so membership never confers on-chain
authority. Every surface says so explicitly.

The management dialog is built for one-shot use — paste an address and
press Enter, one-click chips for existing wallet contacts, inline role
changes, and removal with an Undo toast instead of a confirmation step.
Signers are listed separately as permanent access so the two kinds of
access are never confused. Recipients who are not signers cannot reach a
wallet page, so shared proxies surface on /user instead.

Alongside it, a glass FAQ on the proxy card explains what a proxy is, why
it exists, and what the ten auth tokens do, with a CSS-only animation of
the UTxO flow embedded in the setup and spend dialogs at their respective
steps. The animation freezes on completed steps and honours
prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 1 opened with a JSON config file, which desktop and web assistant apps
do not have. Anyone connecting through a connector screen — the common case
— had to infer that the URL buried in the snippet was the only thing they
needed. It now leads with that URL and the Settings → Connectors → Add
custom connector path, with the config file and the CLI one-liner kept
underneath for clients that use them.

The endpoint is derived from SITE_URL instead of hardcoded. It printed the
production host on every environment, so preprod was handing out setup
instructions pointing at a different deployment than the one you were
reading them on.

Adds the two things that were missing rather than wrong:

Step 2 says the permissions are individual checkboxes you can untick, which
only became true when the consent screen grew them. Step 3 gives a way to
tell a successful connect from a half-scoped one — 13 tools for everything,
7/4/2 per permission, counted from the registry so the numbers cannot drift.
Without it both states look identical: a working server with fewer tools.

And a callout for the asymmetry that is easy to read as a bug: removing a
permission bites on the client's next request, while adding one cannot reach
a token already issued, so the client must reconnect. The toast after saving
permissions said only "Applies to the client's next request" — true of a
removal, false of an addition, and it was the addition case that looked
broken.

Verified against a production build at desktop and 375px: no content
clipped, the config block scrolls internally, and the tool counts render
from MCP_TOOL_SUMMARIES.
docs(mcp): setup instructions that work for GUI clients
…58102

feat(proxy): manage proxy access in-app and explain the on-chain flow
Both branches appended new models to the end of prisma/schema.prisma —
Document Sign-Off (five entities) here, the OAuth 2.1 authorization server
on preprod — so the conflict was positional, not semantic. Kept both
blocks. Everything else auto-merged: the `document` router sits alongside
the new `mcp` router in root.ts, and preprod's `/oauth` noindex prefix
landed next to the existing ones in seo.ts.
Every page shared the same og-image, so a /governance link and a /roadmap
link were indistinguishable in a feed — the preview said "Mesh Multisig"
and nothing about where it pointed.

- generate-og-image.mjs now renders ten cards from one template. Each has
  its own eyebrow, headline, subhead, footer chips and accent tint, so the
  card identifies the destination at thumbnail size. Copy is authored as
  explicit lines (SVG has no wrapping) and the script warns when a line's
  estimated width would overflow the content column.
- routeSeo entries carry `image` + `imageAlt`; getRouteSeo resolves them,
  _app falls back route-card-first, and Metatags emits real alt text
  instead of repeating the title. Blog posts without their own artwork now
  get the blog card rather than the home card.
- og:image URLs carry ?v= (OG_IMAGE_VERSION). Scrapers cache by URL and
  would otherwise keep serving the old artwork indefinitely.

Nothing at build time ties the PNGs to the paths in seo.ts, so ogCards
asserts each referenced card exists, is 1200x630, and has alt text.
Document Sign-Off MVP + roadmap MRP mapping
Resolves the 10-commit drift between this branch and preprod.

Conflict resolutions:
- src/lib/governance/rationale.ts (add/add): both branches wrote a CIP-100
  rationale module. Merged into one that keeps preprod's `uploadRationale` /
  `findBallotRowForVote` and this branch's typed builder, `computeAnchorHash`,
  `uploadRationaleToPinata` and `loadRationaleFromUrl`. The CIP-100 context key
  order is identical on both sides, so anchor hashes are unchanged.
  `loadRationaleFromUrl` now goes through `fetchIpfsJson` instead of a bare
  `fetch`, so anchor URLs (attacker-controlled) take the guarded resolver proxy
  and non-IPFS URLs are restricted to https.
- ballot.tsx: kept preprod's `fetchIpfsJson` load path (multi-gateway proxy +
  `loadedAnchorsRef` de-dup) over this branch's plain fetch.
- wallets.ts: kept both sides' procedures — preprod's `exportWallet` /
  `importWallet` and this branch's `exportTransferPayload`.
- voteButtton.tsx: union of preprod's `voteKind` label and this branch's
  "+ rationale" suffix.
- drep, wallets list, public-routes: additive, both sides kept.
- /llms.txt was added twice. Dropped this branch's rewrite + `/api/llms-txt`
  handler (dead — preprod's page wins route resolution) and folded its new
  pointers (bot-setup guide, wallet transfer endpoints) into preprod's page.

Verified: `npm run typecheck` clean, `npm test` 1084 + 87 passing.
Retargets this branch at `preprod` (per CONTRIBUTING.md all PRs land on
preprod first) and resolves the drift.

Review feedback:
- useWalletBalances.ts: `wallet.capabilities!.address` no longer asserts.
  `capabilities` is optional on `Wallet`, so a row that reaches the hook
  without going through `buildWallet()` would have crashed. Guarded, with the
  previous address-resolution path as the fallback and `wallet.address` as the
  last resort; restored `network` to the dependency array.
- common.ts: `canVote: false` for Summon now carries a TODO explaining that
  Summon's rawImportBodies.multisig has no DRep script to derive a credential
  from, pointing at the PR review thread.
- run-snapshots-batch.ts: added the note explaining that buildWallet() now
  subsumes the conditional key-ordering branch this PR deleted; dropped the
  imports that branch was the only user of.
- common.ts: restored the `@/…` path-alias imports this PR had switched to
  relative paths.

Conflict resolutions against preprod:
- freeUtxos.ts: preprod's `resolveWalletScriptAddress` helper supersedes the
  local buildWallet call.
- useAppWallet / useMultisigWallet: kept preprod's memoized form.
- card-info.tsx: dropped this branch's "Register Wallet — coming soon"
  placeholder; preprod ships the real register-wallet.tsx.
- types/wallet.ts: additive, both sides kept.

Verified: `npm run typecheck` clean, `npm test` 1087 + 87 passing, including
this PR's 3 Summon cases (unordered-CBOR compatibility).

Co-authored-by: peter maina <kanyuku@users.noreply.github.com>
feat: agent onboarding, governance overview, wallet transfer, ballot UX
…ties

feat: Summon wallets via capability-based metadata (supersedes #212)
… disclosure

Turns a Markdown vault into a single signed object, and lets one document be
proved to belong to it without revealing its siblings.

Two relations over one vault. A body [[wikilink]] carries a name only, creates no
hash dependency, and may cycle freely. A frontmatter `trusts:` edge carries the
target's hash, so a document's hash covers everything it trusts transitively —
and those edges must form a DAG. A cycle fails the build naming the offending
chain rather than reporting that one exists.

The root commits to its children's hashes and never their titles. Without that
blinding the disclosure leak just moves up a level: proving one facet would name
every other facet. What remains is a count.

Disclosure reveals the target, the documents on the path to a chosen hub, and the
bare hashes of withheld siblings. Verification needs SHA-256 and the expected
root — no database, no network, no client from us, so a third party can
re-implement it and check our work without trusting us.

Scope: proves MEMBERSHIP, not a predicate. Revealing a document reveals all of
it; "the limit is at least X without showing X" is out of scope and belongs to
BBS+ or SD-JWT if ever needed.

Also adds a CI lane so these tests actually run on preprod PRs.

20 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Obsidian-style view of the feature vault -- searchable tree, note reader,
logical links -- with the trust graph drawn over it rather than on a separate
screen. In this construction the drill-down IS the disclosure path: selecting a
note shows the documents a proof of it would reveal and the siblings it would
keep sealed, so navigating and understanding the cost of disclosing are the same
gesture.

The trust edges need no new frontmatter. `area:` is already a downward edge from
a workstream to the work in it and areas never point back, so those edges are
acyclic by construction and each area note is a proxy hub. Body [[wikilinks]]
stay the logical relation: they cycle freely and are excluded from every hash.
Over this repo's own vault that yields 10 hubs, 62 notes, 52 trust edges and no
orphans.

The path animation traces the order a verifier recomputes in, bottom-up to the
blinded root, and restarts on each selection -- it is the mechanism, not
decoration.

getServerSideProps, not static, for the reason /roadmap/graph documents:
prerendering this SPA dies on "NextRouter was not mounted" in the deployed build
while surviving a local one.

Salts here are derived rather than stored, so hashes are stable across builds.
That is fine for a public vault with nothing to withhold and is called out in
the loader as not fine anywhere real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to the vault view:

- Register /vault in publicRoutes. Without it the layout swapped in
  <PageHomepage /> for anyone without a connected wallet, so the page
  returned its own props and rendered the landing page over them. The
  route holds nothing user-specific, so it renders unauthenticated.

- Add an interactive knowledge graph drawing both relations at once:
  trust edges solid and directed, logical wikilinks dashed. Turning the
  logical layer off leaves the DAG the root commits to, which makes the
  acyclicity argument visible rather than asserted. Force layout is
  hand-rolled — 62 nodes make the O(n^2) pass free and a dependency
  would cost more than it saves. Selecting a node drives the trust-path
  overlay, so the graph and the drill-down share one selection.

- Render note bodies as Markdown instead of dumping source, with
  wikilinks inline and clickable. Reuses the react-markdown already in
  the tree; links become #vault/ hrefs because react-markdown's default
  urlTransform drops schemes it does not know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/vault was registered as a public route but was invisible to the SEO
system, so it inherited DEFAULT_TITLE and DEFAULT_DESCRIPTION — the
homepage copy — on a page about something else entirely, and it never
appeared in the sitemap.

Adds the routeSeo entry, an INDEXABLE_ROUTES entry, and its own social
card. The generator's own note is that every marketing route gets its
own card: a shared card makes two different links indistinguishable in
a feed. Regenerating produced byte-identical output for the other ten,
so only the new card is added here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without this the page ships reachable only by typing the URL — the same
defect that left the Document Sign-Off pages unreachable. Follows the
pattern /roadmap/graph already uses: a footer entry for people and an
entry in the no-JS fallback list so crawlers can find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #356 added four routes under /wallets/[wallet]/documents -- list, new,
detail and version review -- and touched no navigation file. The feature is live
on preprod and reachable only by typing the URL.

Adds the sidebar entry, placed before Signing since a document round is what
produces something to sign. Active state uses a prefix match rather than
equality, because the section has four routes and equality would drop the
highlight as soon as a user opened a document.

One render site covers both desktop and mobile: layout.tsx mounts MenuWallet
once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sign()` verified with `checkSignature(generateNonce(payload), ...)`.
`generateNonce(label)` returns `hex(label + 32 random characters)`, while
the wallet signs `payload`, so the COSE payload and the value being
checked against could never match. Verification returned false for every
honest signature and `sign()` threw "Signature failed verification" on
every call.

Confirmed at runtime against the installed @meshsdk build: signing a
canonical sign-off statement with a real key gives
  checkSignature(generateNonce(payload), sig, addr) === false
  checkSignature(payload, sig, addr)               === true

Three call sites depend on this helper — the wallet Signing page, the
signable card, and Document Sign-Off review — so document sign-off could
not complete a single approval.

Why the existing test missed it: signing.test.ts mocks both
`checkSignature` and `generateNonce`, so it proves the control flow
(throw when verification fails) but cannot prove verification ever
succeeds. The defect lived exactly in the gap the mock created. The new
signDataRoundTrip test signs with a real key through the real @meshsdk
helpers and asserts the round trip closes; it fails against the previous
code with the exact "Signature failed verification" error. The dead
generateNonce mock is dropped from the old test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…efects

Findings from an adversarial review of this branch. The first is a
soundness bug in the construction itself.

IDENTITY WAS NOT COMMITTED TO
hashNode covered salt, content and child hashes but discarded doc.id,
while the library models identity BY id: trust edges name targets by id,
and Disclosure and VerifyResult both hand ids to a relying party. Two
consequences, each reproduced as a test that fails against the previous
code:
  - An honest disclosure could be RELABELLED. Take a real proof of one
    document, rewrite targetId and the path ids, and verifyDisclosure
    still returned ok:true — affirming membership of a document by a
    name that was never in the vault, under a hub that does not exist.
  - Two vaults whose documents differ only in name shared one root hash,
    so a signature over the root did not say which signer held which
    weight.
Binding doc.id fixes both and does not weaken the blinded root: blinding
means the root does not REVEAL titles — it is a hash over salted node
hashes — not that it is invariant to them. The old test asserted that
stronger, wrong property and pinned the defect in place; it now asserts
the binding.

CYCLE DETECTION
A node whose subtree failed was left in "visiting", so every later branch
reaching it reported a fresh, invented cycle around the one real one.
Marking it "done" instead crashes — no node was ever built for it, so the
height lookup dereferences undefined (verified). It needs its own
"failed" state, with a test that walks a second edge into the cycle.

DATA LAYER
- buildVaultTrustView re-read and re-hashed all 67 files on every
  request. /vault is public and sitemap-indexed, so every crawler hit
  paid for it. Memoised in production, matching loadVaultGraph.
- hubs came from graph.roots, but an orphaned feature has no parent
  either, so it was listed as a hub AND under "outside the spine", and
  inflated the hub count. Hubs are the area notes.
- Replaced a second, divergent wikilink regex with the vault's own
  extractWikilinks, which also strips `#` heading anchors.
- disclosureFor omitted a hub's own children from `withheld`, so the
  first thing every visitor sees under-reported what disclosing a hub
  costs, and reported "nothing withheld" for a note outside the spine
  when every other root is in fact sealed.

UI
- The reduced-motion branch returned no cleanup, but dragging calls
  reheat(), which starts a loop on that path too — leaking a live
  requestAnimationFrame past unmount.
- Drags ignored pointerId and had no pointercancel handler, so a second
  touch could hijack a node and a cancelled gesture left it stuck to the
  cursor.
- The trust-path trace played once and never again: SMIL `begin` is
  measured from the document timeline, not from insertion, so a
  remounted <g> renders straight to its frozen end state. Replaced with
  a state-driven stepped reveal that restarts on every selection and
  honours reduced motion.
- The graph rendered nothing until the first animation frame, which
  never arrives in a background tab. Positions are seeded synchronously.

New CI guard: a test builds the repo's own vault and asserts it is a DAG.
buildVaultTrustView throws on a non-DAG from getServerSideProps, so a bad
`area:` would have been a 500 on a public page that no build step or test
could have caught.

1119 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The platform signs as a notary; humans still enact.

Each document version gets one Ed25519 attestation over a canonical
statement naming the content hash, the version, the wallet, the server
time, and its position in the document's chain. Every attestation commits
to the hash of the previous one, so inserting, removing, reordering or
back-dating a version breaks the links — detectably, by anyone holding
the public key, without this app or its database.

WHY HOLDING THIS KEY IS ACCEPTABLE
It is the least powerful key the platform could hold. It cannot approve a
document: approval is DocumentReview, CIP-8 signatures from the wallet's
own signers against the frozen DocumentSignerSnapshot, and this key is
nowhere on that path. It cannot witness a transaction, so it cannot move
funds. Stealing it buys forged timestamps and nothing else — and rotating
it out while leaving its public half in the prior-keys registry refuses
new forgeries while genuine history keeps verifying.

The bound is enforced by the bytes, not by documentation: every
attestation signs a statement that says, verbatim, that it is a timestamp
and ordering record only, is not an approval, and grants no authority.
Changing that statement invalidates the signature, and the chain verifier
rejects any attestation whose statement is not the one this domain
defines.

DESIGN NOTES
- attestation.ts is dependency-free (node crypto only), matching
  payload.ts and proof.ts, so a third party can verify a chain offline.
- Attestation is OPTIONAL. With DOCUMENT_ATTESTATION_KEY unset it is a
  no-op and the rest of the feature is unchanged; a missing key must
  never block creating or approving a document.
- When a key IS configured, attestation is atomic with version creation
  rather than best-effort. A silently unattested version makes the audit
  trail lie by omission, which is worse than a failed upload.
- Concurrency is handled by @@unique([documentId, sequence]): two
  simultaneous uploads cannot both claim the same link, so one rolls back
  instead of forking the chain — mirroring the existing
  @@unique([documentId, versionNumber]).
- Export and verification are a separate surface, not folded into
  ProofPackage: `...proof.v1` is a versioned format whose field names are
  a contract, and adding to it would change what existing verifiers
  parse. Folding it in belongs with a format bump.

17 tests cover the tamper cases, including the strongest one: an attacker
who HAS the key rewriting history in place is still caught, because the
next link commits to the original.

MIGRATION: adds DocumentAttestation. Verified by applying every migration
from scratch against a throwaway Postgres. Note this repo ships
migrations via a path-filtered action on main that does not self-retry —
merging here does not apply it; confirm with `prisma migrate status`.

1104 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `document_list` and `document_get` under a new `documents:read`
scope, so an agent can answer "what needs signing, and who hasn't signed
it" without being able to act on the answer.

WHY THIS STARTS WITH REST
Every MCP tool wraps a `src/pages/api/v1/*` handler through `invokeV1`,
and mcpTools.test.ts asserts on disk that each `v1Path` exists. There was
no document handler — sign-off is tRPC-only — so the REST surface comes
first. It has standalone value, and routing it through `createCaller`
means the signer-or-owner rule is not re-implemented: there is still one
authorization path, the one the UI uses.

THE BOUNDARY IS UNCHANGED
Both tools are readOnlyHint, so the existing "exposes no tool that can
sign, spend or broadcast" assertion still lists exactly the two ballot
write tools. Nothing here can create, edit, approve or sign a document —
approval is a CIP-8 signature from a named wallet signer against a frozen
snapshot, which is not reachable from this surface by construction.

Bot keys are excluded twice over: `mcpScopesForBot` is an explicit
allowlist that does not map `documents:read`, so the tools never appear
for a bot, and both handlers reject bot JWTs anyway. Sign-off is a human
accountability record and an automated identity has no standing in it.

RESPONSES ARE A PROJECTION, NOT THE ROWS
`summariseDocument` is an allowlist. A version can carry up to 512KB of
inline base64, which is pure waste as model context and never what a
caller asking about signatures wants — and because this output becomes
model context, a new column on DocumentVersion must not be able to start
flowing to a model silently. The outcome field is recomputed from the
frozen snapshot with `evaluateThreshold`, so a caller sees the same rule
the server enforces rather than a denormalised copy.

Human wallet JWTs receive the new scope automatically, matching the
existing rule that a wallet JWT grants what the signed-in user can
already do in the UI.

1087 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(signing): sign() rejected every valid signature
fix: Document Sign-Off shipped with no way to reach it
QSchlegel and others added 24 commits August 22, 2026 22:14
fix(security): unaccepted invitations are a count, not attacker-chosen text
feat(documents): DocumentDraft — a mutable body that cannot be signed
The demo at /vault reads this repo's `vault/` directory. It answers "what
would shielded sign-off look like" and not "how do I get one" — there was
no path from seeing the idea to having it. This builds the same
VaultTrustView from a wallet's real documents, so the same browser renders
it and no UI has to know where the notes came from.

THE MAPPING
`Document.documentType` already groups documents the way `area:` grouped
features, so it becomes the proxy hub:

    blinded root -> document type -> document

Hubs never point at each other and documents never point at hubs, so the
graph is acyclic by construction — the same property the file-backed vault
gets from `area:`.

WHAT A DOCUMENT NODE COMMITS TO
Its latest version's identity — content hash, version number, status —
canonicalised, not its bytes. Most documents here are hashOnly and their
bytes are not on the server at all, and the content hash already commits
to them. So the vault commits to the hash that commits to the document,
and behaves identically whether or not a body was ever stored.

REAL SALTS, NOT DERIVED ONES
The demo derives salts from the note title and says in its own comment
that this is fine there and nowhere real: a salt that is a public function
of the title cannot stop anyone brute-forcing a short document from a
guessed title, which is the only thing the salt is for. This adds one
32-byte secret per wallet and derives each node's salt as
HMAC(vaultSalt, nodeId) — unguessable without the secret, one nullable
column, generated lazily so no wallet needs backfilling.

COLLISIONS ARE NORMAL, NOT FATAL
Node ids double as display labels and buildTrustGraph refuses duplicates,
so two documents sharing a title — or a document named after its own type
— would have thrown the whole page. They are disambiguated
deterministically instead. Both cases are tested.

Navigation: the Documents header now points at the wallet's own vault, and
the demo becomes the "see how it works" link from the empty state, which
reads as one story rather than two competing buttons.

MIGRATION: adds the nullable Wallet.vaultSalt. Verified by applying every
migration from scratch against a throwaway Postgres. Migrations ship here
via a path-filtered action on main that does not self-retry — merging does
not apply it.

1150 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): a wallet's own vault, built from the database
UPLOAD
The new-version control was a bare <input type="file">, so it rendered the
browser's own "Choose file / No file chosen" — unstyleable, out of place
against the rest of the UI, and silent about what happens next. Replaced
with a drop zone that accepts drag-and-drop and says the thing worth
saying: with storageMode hashOnly the file is hashed in the browser and
only the digest is sent, so the bytes never leave the machine. That is a
surprising property and it belongs on the control, not in documentation.

DELETE
`archiveDocument` already existed server-side and was reachable from
nowhere. Both are now in a header menu, with archive listed first because
it is almost always the right one.

Deleting is genuinely destructive here: versions, signatures, signer
snapshots, attestations and the document's own event log all cascade, and
there is no tombstone. So the mutation has two guards.

A document anyone has signed requires the caller to retype its title. The
dialog says plainly what is being destroyed and how many signatures go
with it — in a product whose purpose is proving who approved what, that
must not be one stray click.

And an AuditLog row is written, awaited, BEFORE the delete. AuditLog holds
no foreign key to Document, so unlike DocumentEvent it survives the
cascade: the document goes, the record that someone deleted it — with the
title, version count and signature count — does not.

Verified the control by rendering it in isolation against a real build;
the documents page itself is wallet-gated so it could not be driven
end to end here.

1139 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): real upload control, and archive / delete actions
A Markdown editor over the DocumentDraft layer: split write/preview,
autosave, and publish as a separate deliberate action.

WHY IT NEVER AUTOSAVES A VERSION
`uploadVersion` supersedes the previous version and resets approvals to
zero. Autosaving through it would destroy in-flight approvals every few
keystrokes and append a link to the attestation chain each time. The
editor writes only to DocumentDraft; `publishDraft` is the one-way door,
and it hashes server-side so the published bytes are the server's
serialisation rather than something the client asserted.

COLLABORATION, SAID HONESTLY
This deployment runs stock `next start` with no custom server and no
broker, so there is nowhere to terminate a WebSocket and nothing to fan
out through. A CRDT cannot be hosted here, and shipping something that
looks like live co-editing but silently drops writes would be worse than
not shipping it.

So: the draft is polled, a newer revision is adopted automatically while
nothing local is unsaved, and if someone else saves while this author HAS
unsaved edits, the editor stops and says so — leaving their text untouched
and offering to load the other version. `saveDraft` enforces the same rule
server-side through the revision, so a lost update is impossible even if
this UI is wrong.

THE SYNC RULE IS A PURE FUNCTION
`decideDraftSync` is extracted from the component because its failure mode
is silently destroying someone's writing and there is no React testing
library here to exercise it in place. Seven cases are pinned, including
the two that lose work: adopting over unsaved edits, and rolling an author
backwards when a poll issued before their save answers after it.

Server storage is off by default and the editor says why: the feature's
posture is that bytes never reach the server, and storing a draft is a
deliberate per-document trade.

1161 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): draft editor with live preview and honest collaboration
The construction was shipped without anywhere that explains it. The vault
pages show a trust graph and a disclosure path to someone who has not been
told what either means.

The guide covers the three things that actually change how you use it: why
the logical and trust relations must stay apart (hashes cannot cycle, so
the moment you want tamper-evidence you must decide which references carry
weight), why one root is not enough (proving anything reveals the root,
and the root tells the recipient how your organisation is grouped), and
exactly what a disclosure gives away — including the part that is easy to
miss, that it leaks the COUNT of documents under the disclosed hub and the
count of hubs.

It also says plainly what is not a button yet. `disclose` and
`verifyDisclosure` exist with tests covering relabelling, reordering,
tampering and cross-vault splicing, but grep confirms they are imported
only by the test file: nothing in the product exports a disclosure
artefact. The vault view SHOWS what one would reveal, which is the part
that changes how you organise a vault, and the guide is explicit that
handing the artefact to a counterparty is the next step rather than a
shipped one. Promising otherwise in a guide about proofs would be a
strange place to start being loose.

Linked from the wallet's own vault and from the public demo, because a
guide nobody can find from the thing it explains is not documentation.

1161 tests pass; tsc clean; next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other table in this schema gets Row Level Security in the migration
that creates it — 20251215090000_enable_rls_disable_postgrest, its
follow-up 20260706100000_enable_rls_followup_tables, and the per-table
blocks in 20260805090000_add_document_signoff and
20260813000000_add_proxy_member. I added these two tables without it.

Verified against a throwaway Postgres with the `anon` and `authenticated`
roles present so both branches of the migration actually run:

  with this migration     every table reports relrowsecurity = true,
                          and four deny-all policies exist for the two
  without it              DocumentDraft and DocumentAttestation are the
                          ONLY two tables in the schema with RLS off

This deployment is Supabase-backed, where RLS off plus the PostgREST roles
is what stands between a table and the anon key. It matters more for these
two than for most: DocumentDraft is the one table in the document stack
that holds document BODIES rather than hashes, and DocumentAttestation
holds the signed notary chain.

Written as a follow-up rather than by editing those two migrations. Both
are merged but applied nowhere, so editing them would work today — and
would fail with a checksum error against any environment that had already
applied them, and this repo ships migrations through an action that does
not self-retry, so one failed deploy blocks every later migration too. The
follow-up is correct under either state.

No schema change: RLS is not modelled by Prisma, so prisma/schema.prisma
is untouched and there is no drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… keys

Document Sign-Off had no integration test. Every existing test covers one
piece of it in isolation, and the chain those pieces form — create, draft,
publish, freeze the signer set, sign, reach the threshold, export a proof,
verify it — had never been executed.

That is exactly where this feature's worst bug lived. `sign()` rejected
every valid signature for as long as the feature existed, and its unit
test did not catch it because it mocked `checkSignature` — the one thing
it was meant to prove. So this test signs with a real key, through the
real helper, against a real Postgres. The wallet is seeded with an address
whose mnemonic the test holds, which is what makes a genuine CIP-8
signature possible instead of a fixture pretending to be one.

Three cases:

- The whole lifecycle, ending in `verifyProof({ valid: true })` through
  the public procedure. It also asserts the published contentHash equals
  sha256 of the bytes the server stored, which is the claim that makes
  "what you sign is what was published" true rather than asserted, and
  that publishing is refused outright when server storage is off.
- A forged payload is rejected and the version stays InReview.
- A new version supersedes the signed one and resets approvals to zero.

Two bugs found writing it were mine, not the product's, and both are worth
recording. `publishDraft` creates a version without bumping the draft
revision, so my second save presented a stale expectedRevision. And the
first tamper attempt edited prose in the body — which proved nothing,
because the payload binds the content HASH, not the text. It now tampers
with the hash itself, and asserts the string actually changed so the test
cannot silently go vacuous again.

Runs in the existing trpc-integration-tests workflow. 88 integration tests
pass, 1161 unit tests pass, tsc clean, next build exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs(vault): a how-to for shielded sign-off, linked from both vaults
fix(security): RLS for DocumentDraft and DocumentAttestation
test(documents): end-to-end sign-off against a real database and real keys
…oles

Replaces the closed #385 after both open questions came back maximal: one
human MAY hold two roles, and optional / non-signing parties ARE a launch
requirement. They turn out to be the same change — both need the capacity
in the signed bytes — so doing them together costs far less than either
alone.

NO SIGNOFF_DOMAIN BUMP, CONTRARY TO WHAT I EXPECTED
`partyId` is an OPTIONAL payload field. `canonicalize` filters undefined
keys, so a threshold signature produces bytes byte-identical to before
this field existed and every proof already issued keeps verifying. A
domain bump is only needed to RENAME or re-mean a field, not to add one.
Proven by a test that asserts threshold bytes contain no partyId and that
partyId: null canonicalizes identically to omitting it.

It has to be in the SIGNED bytes rather than beside them: a contract's
claim is "the Buyer signed as Buyer", and once one human can hold two
roles, two signatures from one address are otherwise indistinguishable.
The verifier binds it, so a relabelled capacity fails verification instead
of quietly re-attributing a signature.

A PARTY-AWARE OUTCOME RULE
`evaluateThreshold` counts approvals anonymously, which cannot decide a
contract: an optional Witness in the snapshot lets one be Approved over a
REQUIRED party's explicit rejection, and leaving the Witness out denies
them at submission instead. `evaluateContractOutcome` decides on WHICH
parties acted — every required party approved, any required party
rejected, optional parties recorded and never counted. A test asserts the
two rules genuinely diverge on the same facts.

It also refuses to decide a party set with nothing required: "every
required party approved" is vacuously true over an empty set, and
approving a contract nobody had to sign is the worst possible default.

THE COST OF DUAL ROLES, PAID EXPLICITLY
Allowing one address two capacities means dropping
DocumentReview_versionId_signerAddress_key. That would silently weaken
THRESHOLD mode, where one signer acting twice on a version is still wrong
and the in-transaction check reads rows fetched before the write. A
partial unique index restores exactly the old guarantee exactly where it
still applies (WHERE partyId IS NULL). Prisma cannot express a WHERE on
@@unique, so it is raw SQL with a note on how migrations are generated
here and why that keeps it safe.

VERIFIED AGAINST A THROWAWAY POSTGRES
- one human on two parties with one address: allowed
- that human signs the same version twice, once per capacity: allowed
- twice as the SAME party: refused
- threshold mode, one signer twice: still refused
- RLS on both new tables, in this migration rather than a follow-up

1174 unit tests, 85 integration tests, tsc clean, next build exit 0.

Still required before parties mode works, unchanged from #385: the access
layer (a named party cannot reach any document procedure today), the
startReview parties branch, the ordering gate and row lock in
submitSignerAction, and rendering ContractField values into the body
before hashing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): contract parties, roles, optional parties and dual roles
The gate the whole contract feature was blocked behind. Every document
procedure authorizes through assertWalletAccess, which admits only
signersAddresses and ownerAddress — and a counterparty is neither, so
they were rejected with FORBIDDEN before any party logic could run.

REDEMPTION IS IDENTITY, NAMING IS NOT
`resolveDocumentAccess` admits a caller who is a wallet member OR a party
on that one document whose invite has been REDEEMED. An unredeemed row
carries an address nobody has proved control of — it is an intention to
invite, not an identity — so it grants nothing. A test writes an address
straight onto a party row and confirms the outsider is still locked out.

Access is scoped to the single document. Being a party grants no access to
the wallet, to its other documents, or to the party roster: all three are
asserted from the outside.

IT RETURNS EVERY PARTY THE CALLER HOLDS, NOT ONE
One human may hold two roles now, so picking a single party here would
silently choose a capacity on their behalf. Anything that acts as a party
must be told which one.

THE INVITE
A 32-byte token, returned exactly once and stored only as its sha256 —
the same shape as every other credential in this schema. Redemption is a
conditional updateMany on inviteConsumedAt IS NULL rather than
read-then-write, so a forwarded link cannot bind a second person and the
loser of a race loses cleanly.

Every failure — no such token, already used, expired — returns one
message. Distinguishing them turns the endpoint into an oracle for
guessing tokens.

THE ROSTER FREEZES WITH THE ROUND
startReview copies parties into DocumentSignerSnapshot, and the feature
rests on that snapshot being immutable. Adding or removing a party
afterwards would leave the frozen list describing a set that no longer
exists — exactly the drift the snapshot exists to prevent. Mirrors the
existing "A review round has already started" guard.

Deleting a party who has signed is refused with an explanation rather than
the foreign key error the NoAction constraint would otherwise produce.

Seven integration tests against a real database, written from the
outsider's side. 95 integration tests, 1174 unit tests, tsc clean, next
build exit 0.

Signing as a party still needs the startReview parties branch and the
ordering gate in submitSignerAction; this is the access half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(documents): let a named contract party reach the contract
…andling

- Implemented `applyMetadataMessage` to handle CIP-20 metadata messages in transactions.
- Refactored `useTransaction` to utilize the new metadata application method.
- Updated `draftToTokenFlow` to incorporate fee and change outputs from completed transactions.
- Introduced `splitTrailingChange` utility to differentiate between payment and change outputs.
- Added comprehensive tests for transaction building, metadata application, and change output handling.
- Created a new `BuildResultPanel` component to display transaction build results and errors.
- Developed `buildDraftTx` function to construct unsigned transactions with metadata and change outputs.
…rafts

- Added DraftSource type to represent different funding sources (multisig, connected wallet, arbitrary address).
- Implemented createDraft function to initialize drafts with a default multisig source.
- Introduced setSource function to change the funding source, clearing unsupported actions (certificates and votes) when switching away from multisig.
- Enhanced validation logic to check for source-related issues, including missing addresses and invalid formats.
- Updated applyDraftToTxBuilder to handle different input types based on the selected source.
- Created SourcePicker component for user interface to select the funding source.
- Added SwitchSourceDialog for confirmation when switching from multisig to another source.
- Implemented useAddressUtxos hook to fetch UTxOs for arbitrary addresses.
- Developed useSignAndSubmit hook for signing and submitting transactions from connected wallets.
- Added comprehensive tests for source management functionality, ensuring correct behavior across different scenarios.
…d reached notifications

- Add tests for threshold reached notifications and related functions.
- Create governance provider for Blockfrost API interactions.
- Implement ballot deadline reminder logic, including fetching proposals and sending notifications.
- Develop email templates for ballot deadline reminders and threshold reached notifications.
- Create API endpoint for triggering ballot deadline reminder scans.
…trations

feat: implement resolveRegistrationScript API for resolving native scripts in registrations

chore: update Discover Page documentation to reflect current status and scope

test: add end-to-end tests for Discover tab functionality in import wizard

test: create unit tests for discoverQuery utility functions

test: add unit tests for resolveScript API endpoint

feat: implement resolveScript API for resolving native scripts by hash or address

feat: add discoverQuery utility for classifying user input in Discover tab

feat: create nativeScriptJson utility for handling provider native script JSON
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multisig Ready Ready Preview Aug 31, 2026 9:31am

Request Review

Comment thread src/components/pages/vault/note-body.tsx Fixed
@Andre-Diamond Andre-Diamond changed the title feat(roadmap): add project task board with multisig payouts to roadmap August Updates Aug 31, 2026
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.

4 participants