Skip to content

fix(rebuild): store membership credentials at the VTA, and query the vault legally - #251

Merged
stormer78 merged 1 commit into
mainfrom
feat/health-recoverable
Aug 20, 2026
Merged

fix(rebuild): store membership credentials at the VTA, and query the vault legally#251
stormer78 merged 1 commit into
mainfrom
feat/health-recoverable

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Why

Membership recovery restored nothing. Running #250 against a live VTA found three defects — all in assumptions about the credential vault's contract rather than in the code, which is why the unit tests could not catch them: they feed the plan a listing of full credential bodies, a fair model of the design and wrong about the service.

Two of these shipped in #248 and #250. This is a fix against main.

The three defects

1. A MembershipCredential was never stored at the VTA at all. Invitations were pushed to the credential vault on receipt; VMCs went into the local CommunityRecord and nowhere else. rebuild reconstructs a membership from its credential — the right design, since the credential is what the community signed and it names both parties — but the credential has to be somewhere other than the config file a rebuild exists because you no longer have.

2. cred_vault_query({}) is refused by design. The vault is deliberately non-enumerable — CredentialQuery::is_empty() exists precisely to reject a filterless request, because "running it would be a wallet enumeration". Both rebuild and context_probe sent {}, and both read the refusal as "zero credentials" and carried on. So the occupied-context probe was under-reporting too.

3. query/0.1 returns body-free descriptorsid, types, issuerDid, purpose, status. A membership is defined by its subject, which only the body carries, so every descriptor would have been rejected as NoSubject. Each is now fetched with get/0.1 before verification.

Published Trust Tasks only

Every call dispatches through dispatch_trust_task:

  • spec/vault/credentials/receive/0.1 — store
  • spec/vault/credentials/query/0.1 — find, filtered
  • spec/vault/credentials/get/0.1 — fetch a body

No bespoke routes.

Design notes

The sync runs on connect, not at message ingest, because the dispatch path has no VTA session. One mechanism then covers three cases: a join that just completed, a credential received while the VTA was unreachable, and the back-fill of every membership from before this existed. It's idempotent — the vault keys a received credential on the VC's own id — so the query pass exists only to avoid pointless writes on every launch.

The probe no longer counts credentials. Non-enumeration is the point, so "how many credentials are here?" is a question the contract doesn't answer. Personas and sub-contexts are enough to tell a context is in use, and both are properly enumerable.

Both query filters are factored into named functions so a test can assert they carry a filter. A future edit that drops one fails in CI rather than in production, silently restoring nothing.

openvtc health --recoverable

The read-only diagnostic that found all of this, kept because "if this machine were lost, could this account be rebuilt?" is worth being able to ask at any time. It runs the same plan the wizard's Recover option would and prints it instead of applying it.

It reports credentials held locally alongside those held at the VTA — without that a reader cannot tell "no memberships" from "not stored at the VTA yet", and those need different actions.

Verified end to end against a live VTA

Before:

  held locally     1 membership credential(s)
  would restore    1 persona, 0 memberships
  ⚠ 1 membership credential(s) are held locally but not at the VTA…

After:

  at the VTA       1 persona, 1 membership
  held locally     1 membership credential(s)
  would restore    1 persona, 1 membership
  RECOVERABLE: every identity in this context maps to its keys.

Stored via receive, found via filtered query, body fetched via get, verified against the persona. The key mapping (select_secret_kid) was also confirmed working — 24 keys present, correctly attributed, none mis-assigned.

Full gate green: fmt, clippy -D warnings, both test configurations, and cargo doc -D warnings.

…vault legally

Membership recovery restored nothing. Running it against a live VTA found
three defects, all in assumptions about the credential vault's contract
rather than in the code — which is why the unit tests, which feed the plan
a listing of full credential bodies, could not catch any of them.

1. A MembershipCredential was never stored at the VTA at all. Invitations
   were pushed to the credential vault on receipt; VMCs went into the local
   CommunityRecord and nowhere else. `rebuild` reconstructs a membership
   FROM its credential — the right design, since the credential is what the
   community signed and it names both parties — but the credential has to
   be somewhere other than the config file a rebuild exists because you no
   longer have.

2. `cred_vault_query({})` is refused by design. The vault is deliberately
   non-enumerable: `CredentialQuery::is_empty()` exists precisely to reject
   a filterless request, because running one would be a wallet enumeration.
   Both `rebuild` and `context_probe` sent `{}` — and both read the refusal
   as "zero credentials" and carried on, so the probe was under-reporting
   occupied contexts too.

3. `query/0.1` returns body-free descriptors — id, types, issuer, purpose,
   status. A membership is defined by its SUBJECT, which only the body
   carries, so every descriptor would have been rejected as `NoSubject`.
   Each is now fetched with `get/0.1` before verification.

Everything here goes through published Trust Tasks dispatched by
`dispatch_trust_task` — `vault/credentials/{receive,query,get}/0.1`. No
bespoke routes.

The sync runs on connect rather than at message ingest, because the
dispatch path has no VTA session. One mechanism then covers three cases: a
join that just completed, a credential received while the VTA was
unreachable, and the back-fill of every membership from before this
existed. It is idempotent — the vault keys a received credential on the
VC's own `id` — so the query pass exists only to avoid pointless writes.

The probe no longer counts credentials at all. Non-enumeration is the
point, so "how many credentials are here?" is a question the contract does
not answer; personas and sub-contexts are enough to tell a context is in
use and both are properly enumerable.

Both query filters are factored into named functions so a test can assert
they carry a filter. A future edit that drops one fails in CI rather than
in production, silently restoring nothing.

Also adds `openvtc health --recoverable`, the read-only diagnostic that
found all of this. It answers "if this machine were lost, could this
account be rebuilt?" — running the same plan the wizard's Recover option
would and printing it instead of applying it. It reports credentials held
locally alongside those held at the VTA, because without that a reader
cannot tell "no memberships" from "not stored at the VTA yet", and those
need different actions.

Verified end to end against a live VTA: stored via receive, found via
filtered query, body fetched via get, verified against the persona, and
reported as `would restore 1 persona, 1 membership`.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 requested a review from a team as a code owner August 20, 2026 15:15
@stormer78
stormer78 merged commit 4fc64d7 into main Aug 20, 2026
14 checks passed
@stormer78
stormer78 deleted the feat/health-recoverable branch August 20, 2026 15:22
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.

1 participant