Skip to content

feat: VTA management console, proof-of-presence gating, and a registry-derived core - #157

Merged
stormer78 merged 25 commits into
mainfrom
vta-management
Sep 3, 2026
Merged

feat: VTA management console, proof-of-presence gating, and a registry-derived core#157
stormer78 merged 25 commits into
mainfrom
vta-management

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

25 commits. The console itself, the two controls that act on the whole agent, and a sweep that replaced hand-written task types with the registry's.

This branch has never run CI — the workflow triggers on pull requests and pushes to main only. Everything below was verified locally (lint, 736 tests, all three dist guards, cold npm run clean && npm run build) and much of it against a live agent, but the Node 24/26 cold-install matrix runs here for the first time. That is the main reason this is a PR rather than a direct push.

The console

Thirteen panes on the Trust Context tree: Contexts, Keys, DIDs · Credentials, Memory, App state · Transports, Maintenance, Audit · Access, Approvals, Policy, Sessions.

It holds no key material. It composes typed documents with the admin/* helpers and the offscreen document signs them, so an XSS there cannot exfiltrate a key. That is why those surfaces type their parties as TaskParty rather than Identity — and the type system enforced it during this work: an obvious DID lister refused to compile here because it wanted key material.

CI keeps it structural: admin/* task URIs are banned everywhere in dist/ except manager.js, and the console is its own vite build with codeSplitting: false so there is no shared chunk to leak through.

Proof of presence, and a refusal

requirePresence runs a fresh WebAuthn assertion — no PRF extension, returns void, never consults unlock state. A step-up an earlier unlock satisfies is not a step-up. Confirmed against a real authenticator: prompts every time, even unlocked.

Behind it: Maintenance, holding backup-abort and reload-services — the two operations whose subject is the agent itself rather than anything it holds.

Backup offers abort only. initiate-export and finalize-import carry a password inbound, and a browser field is reachable by autofill, password managers, co-resident extensions and screen capture; a step-up guards the action, not the field. The pane says so and prints the CLI commands instead. A second CI guard bans vta/seeds/* from every bundle including the console — those return key material, and no browser context should be able to ask.

Types from the registry, not by hand

ContextRecord, WebvhDidRecord, AclSwapResult and others were declared locally beside the generated ones and had drifted. swapAcl was the live one: it typed the reply as a flat entry while the agent has sent { entry, previousSubject } since VTI #857, so every field read came back undefined. Latent rather than live — nothing calls it yet — but the next caller would have found out.

Two dead casing folds removed, verified against rename_all = "camelCase" on the Rust side. schema-types-are-not-restated.mjs now fails if any local type shadows a generated one.

Guards added, each proved non-vacuous

  • schema types are not restated by hand
  • a nav section without a pane fails the build rather than rendering blank
  • a form seeded from a prop must be keyed — which caught a real bug where every context showed the first context's name in a field wired to contextsUpdate

Verification

npm run clean && npm run lint && npm run build && npm test from cold — 736 tests. All three dist guards re-run by hand: admin URIs confined to manager.js, no key-material URI anywhere, console a single chunk, MV3 worker free of dynamic import(), Web Store zip clean of key/content_scripts/cookies.

Live-agent testing confirmed: Backup renders, cancel behaves, reload-services restarts, the step-up prompts twice, contexts and DIDs populate, and the pane sweep.

After merge

@openvtc/pnm-core wants a minor bump (0.7.0) — exported types changed incompatibly. npm's 0.6.0 predates all of this.

…text tree (#155)

* feat(console): a VTA management console in the wallet, on the context tree

Everything an operator does *to* their agent — contexts, keys, ACL,
approvals, policy, services — has only ever been reachable from the `pnm`
CLI, which means from a laptop with a keyring. The client library for it
already existed: `@openvtc/pnm-core/admin` covers all of the CLI's command
tree bar backup, bootstrap and auth-credential, and its doc comments were
written for a console it did not yet have.

This is the spine of that console, plus Contexts end to end.

Trust contexts are the navigation, not a section. `ContextRecord` already
carries `parent`/`basePath` and `contextId` is a filter parameter on
`keysList`, `aclList` and `auditList` — so the tree is a persistent column
and the selection scopes every pane to its right.

Two properties are load-bearing:

The console holds no key material. It composes typed documents with the
`admin/*` helpers and the offscreen document mints and signs them, so
`RUNTIME_MANAGER_TASK` carries only `type` and `payload` — the rule that the
device mints the envelope does not soften because the composer is an
extension page. Reusing `OFFSCREEN_REQUEST_TASK` inherits transport
selection, health recording and the approver ceremony; `offscreen.ts` is
unchanged. This is also why `admin/*` now types its envelope parties as
`TaskParty` (just a DID) rather than `Identity`: only `.did` was ever read,
and a surface typed on `Identity` can only be called from somewhere holding
a private key.

The wallet still ships no operator authority. The CI guard is narrowed
rather than dropped — admin task URIs are banned everywhere in `dist/`
except `manager.js`, which is its own vite build with `codeSplitting: false`
so Rollup cannot hoist a shared chunk into a wallet surface. A second
assertion fails if it ever emits more than one chunk.

The relay is gated on `sender.url`, since every content script carries this
extension's id. It does not prompt per call — the operator is the caller,
and twelve dialogs to draw one screen is dismissal, not consent; the agent's
ACL and policy engine remain the authority, and a `requireConsent` renders
as a match-code ceremony rather than a red error string.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

* feat(console): keys, DIDs, access, approvals, policy, transports and audit

Fills out the console's remaining eight panes on the spine landed in the
previous commit. Every one is scoped by the persistent context selection
where the underlying task takes a `contextId`, so choosing `work/eng` once
scopes Keys, DIDs, Access, Policy and Audit together.

Shared vocabulary rather than nine copies of it: `table.tsx` separates the
three states a list actually has — being fetched, refused, and answered with
nothing — because written per-pane they collapse and an operator reads "you
have no keys" off a permission error. `use-async.ts` keeps `data` null until
the agent answers for the same reason.

Where the agent draws a distinction, the pane draws it too:

- Keys shows `internal` origin as unrecoverable, because such a key derives
  from no seed and nothing reconstitutes it. `keysSign` is deliberately not
  surfaced — signing is use, not administration, and a console that offers a
  "sign this" box turns key management into an oracle.
- Access renders an entry with no scopes as "everywhere" and no expiry as
  "never", both in the caution colour. `aclChangeRole` carries `fromRole`
  from the row it was opened on, so a role someone else changed in between
  rejects instead of silently overwriting them.
- Policy carries `expectedVersion` on upsert *and* delete — without it two
  operators editing one module is last-write-wins and the loser never finds
  out.
- Transports pairs advertised state against observed, and presents `unknown`
  as "not observed" rather than a failure; a constructed REST channel is not
  evidence of anything.
- Audit renders `truncated` above the table, not below: a warning under a
  long list is a warning nobody reads, and concluding "nothing else
  happened" from a partial page is the failure an audit trail exists to
  prevent.
- Sessions is headed "your sessions" — the task returns only the caller's,
  and the agent's all-sessions route is deliberately not wired here.

`webvh/dids.ts` takes the same `TaskParty` widening as `admin/*`: it too read
only `.did` off its envelope parties.

Verified by rendering the built bundle against stubbed extension APIs in
Chrome, light and dark. Admin surface remains confined to manager.js.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

* refactor(console): one import path for ConsentRequiredError

carrier.ts defines it; sender.ts re-exported it, so half the panes reached
for one path and half the other. Two routes to one symbol invite the question
of whether they could ever be different classes — which matters here, because
every pane distinguishes this class from Error by identity to decide between
rendering a consent ceremony and rendering a red string.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

---------

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
First run against a real VTA. Every one of these was invisible against
stubbed data.

**The context column showed where it filters nothing.** Transports and
Sessions take no `contextId` at all, so selecting a context there looked
like it did something and did nothing — worse than clutter, because it
implies the list on screen was scoped when it never was. Each section now
declares `contextScoped`, and the column and its grid track appear only where
that is true. A new pane has to answer the question, which is the point.

**The console invented an origin, and it broke two panes.** `requestTask`
stamps `payload.ext["openvtc.origin"]` when given an origin, and its contract
says a caller without an attested one should omit it rather than invent one —
the console has no proposing page. Stamping this extension's own put an `ext`
member on every payload, and the agent rejected `acl/list` and `policy/list`
outright with "unknown field `ext`", taking out Access and Policy entirely.
The page path is untouched and still carries its attested origin; both halves
are now pinned by tests.

Worth reporting upstream separately: `ACLListPayload` and `PolicyListPayload`
both declare `ext` in the generated bindings, so those Rust structs have
drifted from the schemas they are generated alongside. SPEC §4.5.1 makes `ext`
legal on any payload. The console no longer depends on that being fixed.

**"SESSION EXPIRES 01/01/1970."** The agent sent no `expiresAt`, and
`new Date(undefined)` renders as the epoch — which reads as a real answer. A
session whose expiry is unknown is not one that expired in 1970, and that
distinction is the entire job of that banner field. Every timestamp now goes
through `formatInstant`/`formatDate`, which say "unknown" rather than
guessing, and `isPast` never reports an absent date as expired.

**One context had two names on one screen.** The tree rendered `name`
("Verifiable Trust Agent") while every table column and pane title rendered
`id` ("vta"), with nothing connecting them — and with ids like `vta`, `vtc`
and `webvh`, connecting them by eye is exactly what an operator should not be
doing. Both are shown now, everywhere, in one order: the label first because
that is what you navigate by, the id beneath in monospace because that is
what joins the tree to every table beside it.

**Audit rows open.** A row shows what fits; what does not fit is the part
answering "why" — `detail` is free-form per action, `actor`/`target` are full
DID URLs a column truncates, and `prevHash`/`entryHash` are the only evidence
of chaining there is. Rows now expand to the entry as the agent sent it. The
CHAINED column is gone: against the live agent every row read "no", and a
column that always says the same thing carries no information while costing
the width a DID needed — it is one note above the table instead. Non-DID
actors (`internal:webvh-rest-auth`) are no longer styled as DIDs.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Adds a fourth act to the rail. The deck's three answer "who am I", "how do
bytes move" and "who may act"; none of them answers "what is stored here",
which is the question these three share.

**App state** (`vta/app-state/*`) is where applications keep metadata in the
agent — the openvtc install record, sync cursors, per-app preferences. Unlike
the wallet's own storage these follow the holder to every device, so a wrong
value here follows them too. Two things the pane refuses to flatten: writes
carry `expectedVersion`, so an edit compare-and-swaps against what was read
rather than overwriting whatever an application wrote since; and deletes are
soft, so `includeDeleted` distinguishes "nothing ever wrote this" from
"something removed it".

**Memory** (`vta/memory/*`) is what the agent recalls between conversations.
Read, write and forget, as asked — with the write path stating plainly that a
hand-written memory is stored identically to one the agent formed itself, so
nothing downstream can tell them apart.

Both of those **require** a context and say so rather than showing an empty
list. `contextId` is part of the record's address in these families, not a
filter over it: `(context, namespace, key)` names one record, and two contexts
holding the same namespace and key hold unrelated records. Fanning out to
build an agent-wide view would produce a list where identical-looking rows are
different records.

**Credentials** is issuer-side only, and the pane leads with why. The
`vta/credentials` family declares `issue` and `revoke` and nothing else — no
list — so this console cannot show what has been issued, and a blank table
would read as "you have issued nothing", which is a claim it has no basis for.
It says so and points at the audit trail. The issued id is surfaced
prominently on success for the same reason: it is the only handle revocation
has, and there is no way to look it up later. Revocation likewise cannot
preview, so its confirm step states what could not be checked instead of
dressing a guess up as one.

Holder-side credentials are deliberately absent. They live in
`vault/credentials/*`, which ships no TypeScript bindings in
`@openvtc/trust-tasks`; transcribing those shapes by hand is the copy that
drifts this repo already removed once, so that half waits for the bindings
rather than being approximated. The pane says that too, rather than leaving a
gap the reader has to explain to themselves.

`app-state/records.ts` takes the same `TaskParty` widening as `admin/*`.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Closes the half the credentials pane has been carrying a placeholder for.
`vault/credentials/*` had no specification, so no bindings, so nothing to
implement against without hand-transcribing shapes out of someone's service
code. trustoverip/dtgwg-trust-tasks-tf#338 specified the family from that
implementation and shipped in @openvtc/trust-tasks 0.16.4; this consumes it.

`vault/credentials.ts` in core is the client — query, get, receive, and the
five lifecycle verbs. It sits beside the password-manager vault because they
share the wire slug and the agent's keyspace, and the module header says why
they are otherwise different things (disjoint namespaces, a presentable VC
rather than a raw secret, no sealed envelope).

Two properties the pane renders rather than flattens:

**Query refuses an unconstrained filter**, because the answer would be the
shape of the holder's whole life — every community, every role, every issuer.
`isRunnableCredentialQuery` is exported so a caller can check locally: the
Search control stays disabled and explains itself instead of firing a request
whose refusal the operator would have to interpret. The two tick-boxes widen a
search rather than narrowing one, so they deliberately do not count.

**Validity and lifecycle are orthogonal**, so they get separate columns. Against
representative data that is immediately load-bearing: an invitation that is
expired but active, a role that is valid but archived, a membership that is
revoked and in the trash with its restore deadline shown. A single "status"
column would have been wrong about three of the four, and the available actions
follow lifecycle — archive, unarchive, restore or purge — rather than validity.

Delete previews as recoverable and says what recovery would cost (going back to
the issuer, which for an invitation may not be possible at all); purge previews
as the thing with no way back, and is offered only on a row that is already a
tombstone.

`credVaultGet` is in core and deliberately not wired to a button. It is the only
call that returns credential contents, and a console has no reason to fetch a
body it would only display — presentation is use, not administration, the same
line `keysSign` sits on the far side of.

The task-surface ratchet moves 152 -> 160. Its comment listed this family as
unspecced; that is now the eight that moved, and the remaining 17 are named.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The credentials pane opened with a notice rather than a table:

    There is no list of issued credentials, and this is not an empty one.

That was true, and building the empty table would have been the easy lie —
it reads as "you have issued nothing" and is wrong the moment anything has
been. The task now exists, so the notice becomes what it was standing in for.

The chain that got here, in order: the gap was filed
(trust-tasks-tf#337), specified (trust-tasks-tf#342), implemented in the
agent (verifiable-trust-infrastructure#1235), released
(@openvtc/trust-tasks 0.16.6, vta-sdk 0.32.3), and is now consumed here.
The console kept saying "there is no list" for all of it, which was the
honest thing to say while it was true.

`listCredentials` in core admin returns metadata only — there is no task that
reads a credential body back, and this console does not pretend otherwise.
Its `holderDid` is named for the *subject* rather than `holder`, matching
`issueCredential`, because `holder` on the caller params is the envelope's
issuer: two different parties would otherwise share one member name on one
object.

The pane renders two things the shape makes easy to lose. `truncated` goes
*above* the table, because it changes what the list means — anything concluded
from a partial page, including that a credential was never issued, may be
wrong — and a caution under a long list is one nobody reads. And `status` is
derived by the agent at read time with `revoked` beating `expired`, so it is
painted as received and never recomputed or cached here.

Revoking is now a row action that hands its id to the form below. The id is an
opaque UUID, and asking an operator to copy one by eye immediately before an
irreversible action is how the wrong credential gets revoked.

Two pieces of copy that the new list made false are corrected rather than left
standing: the revoke panel no longer says there is nothing to pick from, and
its confirm no longer claims the console can show nothing about the credential
— it can show who holds it and when it was issued, and still cannot show what
it claims. That last limit is real and stays stated.

Task-surface ratchet 160 -> 161, canonical total 177 -> 178. Unlike the eight
before it, this family did not move off the unspecced list — the task did not
exist on either side until this month.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`vta/seeds/export-mnemonic/1.0` returns a BIP-39 mnemonic — the seed every
derived key in the agent comes from, and the one secret whose disclosure loses
everything at once. `list` and `rotate` are the rest of that family.

A second guard bans all three from anywhere in `dist/`, with **no exception**.
That is the difference from the admin guard beside it: `admin/*` is authority,
which the console is meant to hold, so that guard permits `manager.js`. These
return material, and no browser context should be able to ask for them.

The guard exists rather than simply not building the pane, because not building
it is indistinguishable from not having got round to it. Someone reasonable adds
a seeds view next year, nothing objects, and the refusal was never recorded
anywhere a person would look.

Verified non-vacuous the only way that means anything: a seeds URI imported into
the console, rebuilt, and the guard caught it in `manager.js` — the file the
admin guard would have let through. Reverted.

`vault/release/0.1` is deliberately not listed. It releases a secret to a site
the human just approved, which is the wallet's whole job. The line is not
"touches a secret" but "hands over material the holder cannot revoke, to a
surface that cannot contain it".

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…s way fails

The section claimed the guard was "verified non-vacuous by importing a seeds
URI into the console and watching it fail". Re-running that verification is how
I found the sentence is misleading.

An unused `export const SEEDS_PROBE = "vta/seeds/export-mnemonic/1.0"` in
`shell.tsx` does **not** trip the guard. Rollup tree-shakes it, the string never
reaches `dist/`, and the guard stays silent — correctly, since it asserts what
ships rather than what is typed. But someone re-checking the refusal reaches for
exactly that probe, sees nothing happen, and concludes the guard is a no-op.

So the note now says what actually works: put the URI somewhere the console
renders. Verified with a nav `label`, which trips `manager.js` as intended, and
reverted.

Both dist guards re-run against a cold `npm run clean && npm run build`; the
admin one confirmed non-vacuous too (`acl/grant/0.1` really is present in
`manager.js`, so its exclusion clause is doing work rather than matching
nothing). Full suite green: 720 tests across the four workspaces.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`requirePresence` runs a WebAuthn assertion and throws if it does not
complete. Backup abort and reload-services will sit behind it.

Three things it deliberately is not, each written into the module because
each is what someone would otherwise assume.

**It is not an authorization of the operation.** Nothing verifies this
assertion against the Trust Task that follows: the agent never sees it,
the document carries no trace of it, and the trail at the far end records
an ordinary request. What it buys is that an unattended console cannot be
used by whoever walks past, and that an irreversible action is deliberate
rather than one click deep. Worth having; not a second factor on the
agent, and the header says so rather than leaving a reader to assume the
stronger claim.

**It does not reuse the PRF ceremony.** `runPrfUnlockCeremony` returns PRF
output — the AES key root, as sensitive as the holder seed. A presence
check needs none of it, and a primitive that hands back key material to
answer "is someone there?" will eventually have that output stored by a
caller who only wanted the boolean. Same assertion, no `prf` extension,
returns `void`. Nothing to leak because nothing is returned.

**It never consults unlock state.** A step-up a prior unlock satisfies is
not a step-up — the wallet may have been unlocked hours ago, and reusing
that means the gate stands open for as long as the console does. Fresh
challenge, fresh assertion, every call. This is the first thing that
breaks if someone later adds a cache because the prompt is annoying.

The challenge is random rather than bound to the action, and that is a
choice rather than a shortcut: binding buys something only where a
verifier checks it against the request, and there is no verifier here.
Calling it "for this bundle" when nothing enforces that would be the
overstatement the rest of the module avoids.

A dismissal and a timeout both raise `NotAllowedError` with no way to
distinguish them — by design, so a site cannot learn that a user refused.
Both surface as `cancelled`, which is the reading a caller acts on: do
nothing, say nothing.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…torage is blocked

Seven tests, all guarding the same failure mode: a step-up that quietly
stops being one. It never breaks loudly — the prompt just stops appearing,
or appears and is not waited for, and the console looks exactly as it did.

Two of them are the ones that matter. **A second call must run a second
assertion with a different challenge**, because a step-up an earlier one
satisfies is not a step-up; and **the module must not name unlock or
session state**, checked against the source, because a cache added later
would leave every runtime assertion here passing on its first call.

That source check strips comments first. The module *discusses* the PRF
ceremony at length, explaining why it deliberately does not reuse it, and
a check matching prose would fail on the very paragraph documenting the
refusal — teaching the next person to delete the explanation to get green.

Writing the tests found a real defect in the module. `IndexedDBKVStore.get`
**throws** where IndexedDB is unavailable rather than returning nothing —
a private window, or a browser set to block site data. Looking up which
credential to name is an optimisation, so letting that propagate turned
"we could not find your credential" into "you cannot approve this action",
which is the wrong failure by a long way. Now caught, falling back to a
discoverable credential.

179 → 186 tests in the extension workspace; 727 across all four.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…ence

Both land on `@openvtc/trust-tasks` 0.16.8, which carries the six specs
from trustoverip/dtgwg-trust-tasks-tf#347.

## Backup is one verb out of five, and the pane says why

`initiate-export` and `finalize-import` both carry a `password` — the key
that protects, or unlocks, a complete copy of the agent. It travels
*inbound*, typed by the operator, and a browser form is reachable by
autofill, by a password manager, by any other extension with host access,
and by anything recording the screen. A step-up does not help: it proves a
human is present for the action, and the password was typed before the
prompt appeared.

So the wallet does not ask for it at all. The CLI collects it in a
terminal, which is not perfect and is much smaller.

That reasoning is on the surface, not just in a comment, because an empty
pane is indistinguishable from an unfinished one — someone would
reasonably "complete" the family next year. The same guard is in
`admin/backup.ts` and in the coverage ratchet's prose, which now records
`vta/backup/*` as the first family whose absence is a decision rather than
a gap upstream.

`abort` is here because it carries no secret and is the only way to close
a window that is otherwise open until it expires, with a fetchable copy of
the agent live at a known address. There is no verb that lists in-flight
bundles, so the operator pastes the id the CLI printed — the pane says
that rather than implying the console lost a picker.

## Two non-failures that would otherwise read as faults

**`aborted: false`** is rendered as "already closed", not an error. Abort
is idempotent precisely because the situation it exists for — a dropped
connection, an operator unsure whether the cancel landed — is the one that
produces duplicates.

**A dropped connection on reload** is the expected shape of *success*: a
reload restarts the transport the response would have travelled on.
Reporting it as an error would send an operator hunting for a fault that
is the agent coming back, and invite a retry that restarts it again.

## Why reload-services is gated at all

It creates, deletes and discloses nothing, which invites a lower bar than
it deserves. What it spends is availability, and that falls on every
counterparty of the agent rather than on the operator who clicked —
including this wallet's own inbound sessions, so approval requests wait at
the mediator while it restarts. The copy says so.

Both guards re-run against a cold `npm run clean && npm run build`: admin
URIs still confined to `manager.js`, no key-material URI anywhere, console
still a single chunk, and the new `vta/backup/*` URIs land only in
`manager.js`. 727 tests green.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
… blank

Three lists describe the console's navigation — the `SectionId` union,
the `ACTS` nav data, and the `switch` in `renderPane` — and nothing forced
them to agree. The union makes the switch exhaustive over ids that
*exist*, so a case cannot name a section that was never declared. The
reverse has no guard at all: an id in the union and in the nav, with no
case, compiles clean.

The failure is silent, which is what makes it worth a test. The section
appears in the sidebar, the operator clicks it, and the pane renders
nothing — no error, no console message, no failed request. A blank column
reads as "this agent has none of those" rather than as a bug. It is the
same shape as the panes that died on `unknown field 'ext'`, only quieter,
because at least those said something.

Noticed while adding the Backup section, which is exactly the change that
would introduce it.

Five assertions: nav ⊆ cases, cases ⊆ nav (a pane nothing routes to is
code that reads as live and is not), all three lists name the same set, no
duplicate ids, and a floor so none of it passes vacuously.

Proved non-vacuous rather than assumed — the lesson from the seeds guard,
where the obvious probe was tree-shaken away and proved nothing. Adding a
`probe` section to the union and the nav with no case fails two of the
five; reverted.

186 → 191 tests in the extension workspace.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…d casing folds

Answering "are we on the latest trust-tasks, and can the hand-written
tasks go?" — we are (0.16.8 everywhere), and two categories could go.

## Two compat folds for peers that do not exist

`contexts.ts` folded `base_path`/`created_at`/`updated_at`, and
`list-dids.ts` folded `context_id`/`server_id`, both justified in comments
as "agents migrate later" and "this library talks to agents it does not
control".

CLAUDE.md forbids exactly this: nothing is deployed, so "an older agent is
still a supported peer" names nobody. And it was checkable — `ContextRecord`
and `WebvhDidRecord` in `vta-sdk` both carry `#[serde(rename_all =
"camelCase")]`, so the agent *emits* the canonical spelling. Their `alias`
attributes are deserialize-only: they govern what the agent accepts, which
says nothing about what it sends.

Two tests pinned the folds in place, one of them titled "still folds a
legacy record, because agents migrate later". Both now assert the
canonical spelling instead.

## Two hand-written types that had already drifted

Worse, and the reason this is more than tidying. `ContextRecord` and
`WebvhDidRecord` were declared by hand beside the generated ones, and had
drifted in opposite directions:

- `ContextRecord.did` was `string | null`. The schema makes it OPTIONAL —
  an agent omits it — so a caller guarding `=== null` never matches, and
  TypeScript agrees with the caller.
- `WebvhDidRecord` marked `serverId` and `portable` optional where the
  schema makes them required, and omitted seven members the agent sends
  (`mnemonic`, `scid`, `logEntryCount`, `preRotationCount`,
  `nextFragmentId`, `createdAt`, `updatedAt`). Data a caller could have
  used was simply invisible.

Neither could fail anything. `task-surface.mjs` checks that the URIs are
real and current; it says nothing about payload shapes, and the type is
the one part of a Trust Task checked against a copy rather than a schema.

Both now come from the binding, along with the five `vta/contexts/*` URIs,
`vta/webvh/dids/list`, and `task-consent/request` — plus `WakeHandle`,
`WakeTriggerPolicy`, `Exposure` and `StatePin`, which were byte-identical
to the generated ones. Identical is not reassuring: it is the state a type
is in immediately before it diverges.

## The guard

`schema-types-are-not-restated.mjs` fails if any `export interface` here
shares a name with a generated `_shared/components` type. `NARROWING`
holds the one deliberate exception — `PasskeyVerificationMethod` narrows
`webauthnTransports` from the schema's `string[]` to the DOM's
`AuthenticatorTransport`, which the schema cannot name and a caller passing
these to `navigator.credentials` wants.

Proved non-vacuous: restating `ContextRecord` fails it by name and file.

479 -> 482 tests (two fold tests removed, three added). Cold
clean/lint/build/test green; both dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Continuing the sweep for hand-written task types, and this one is a live
defect rather than tidying.

`AclSwapResult` was declared here as a flat ACL entry — `did`, `role`,
`allowedContexts`, `createdAt: number`. The agent sends none of that. VTI
#857 made `acl/swap-key/0.1` respond `{ entry, previousSubject }`, and the
entry itself names `subject` not `did`, `scopes` not `allowedContexts`, and
dates as RFC 3339 strings not numbers. Wrong at three levels at once:
wrapping, member names, and types.

So every field a caller read came back `undefined`. It has not bitten
because nothing in the extension calls `swapAcl` yet — the next caller
would have been the one to find out.

**Why nothing caught it.** `sender.send<T>()` is an unchecked cast, so a
wrong `T` costs nothing at compile time. And the one test built its
response fixture from this very type rather than from the schema, then
never read it: it asserted the request and stopped. A fixture derived from
the client's assumption cannot contradict the client. Rebuilt from the
schema, the test's own surviving line `assert.equal(res.did, …)` failed —
which is the drift, demonstrated.

The test now asserts the response too, because the absence of that is what
let the type rot.

Also taken from the binding, both missing only `ext` (SPEC §4.5.1, which
any agent may send): `DeviceSetWakeResponse` and `ContextDeleteResult`,
plus the `vta/contexts/delete` and `acl/swap-key` URIs.

`*Params` and `*Options` types stay local on purpose — they are
caller-facing argument shapes that bundle parties, payload fields and
channel options, not wire types, and no schema describes them.

Cold clean/lint/build/test green; 482 core tests; both dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…iding

`schema-types-are-not-restated.mjs` matched `export interface` only. A
schema component restated as `export type X = { … }` walked straight past
it — which is how `PushRegistration` survived the sweep that added the
guard.

The schema declares it `Apns | Fcm | WebPush`. This library restated it as
an inline object modelling webpush alone. The narrowing is right — a
browser extension cannot produce an APNs or FCM registration — but writing
it out by hand states the members again, so a change to the WebPush variant
leaves this behind silently. Now `export type PushRegistration = WebPush`,
taking the generated variant: the narrowing is the only thing the line
says.

The guard now reads `export type` too, and distinguishes the two cases,
which is the part worth getting right:

  export type X = Generated;        // an alias — one source, fine
  export type X = { … };            // a restatement — a second source

`ALIAS_TO_IDENTIFIER` draws that line. Without it the check would have
flagged `KeyRecord`, which correctly aliases `SpecKeyRecord`, and the
obvious way to silence it would have been to add a NARROWING entry for a
type that is not narrowed at all.

Proved both directions: restating `PushRegistration` inline fails the
guard by name; aliasing it passes, and `KeyRecord` still passes untouched.

Cold clean/lint/build/test green; 482 core tests.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
GitHub flagged this on push. Four advisories against `fast-uri` 3.1.5, one
rated high: host confusion via skipped IDN canonicalization, plus SSRF via
malformed IPv6 normalization, repeated hostname percent-decoding, and
percent-encoded scheme normalization.

**Build tooling only, and worth saying so rather than leaving it to be
assumed.** The path is `packages/pwa -> vite-plugin-pwa -> workbox-build ->
ajv -> fast-uri`, and `fast-uri` appears nowhere in
`packages/extension/dist/` — the wallet does not ship it, so no shipped
surface parses a URI with it. That bounds the exposure to whoever runs the
build; it does not make it a non-issue.

Lockfile only: a patch bump inside the 3.x line, no manifest change and no
transitive resolution moved. `npm audit` now reports zero.

Cold clean/lint/build/test green.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…he stale R4.1

Closes the two gaps left from the type sweep.

## No hand-written task URI survives in `packages/core/src`

The last ten were `auth/authenticate/0.1` (three call sites),
`auth/step-up/approve-{request,response}`, `task-consent/{decision,granted}`
and `vta/contexts/preview-delete`. All now import `TYPE_URI` /
`RESPONSE_TYPE_URI` from their binding.

Lower stakes than the types were — `task-surface.mjs` already validates
every URI against the agent's live surface and fails on a deprecated
version, so a wrong one could not hide. The value is that there is now one
answer to "which version does this library speak", and it is the
registry's.

`STEP_UP_APPROVE_REQUEST_TYPES` keeps both 0.1 and 0.2, now as imported
constants. That is not a compat fold: the VTA pushes 0.1 over DIDComm
(`messaging/handlers.rs`) while the did-hosting control plane mints 0.2 —
two live producers, checked rather than assumed.

The three `trust-task-error` constants stay written out, as their own
comment already explains: they are historical markers, matching is done on
the slug across every 0.x, and the current version is re-exported from the
package runtime.

## R4.1 named a repo that no longer exists

The rule read "shared code with pnm-relay and vti-didcomm-js is a
liability until extracted: the relay never received this repo's body-first
error-parsing fix". Both halves were out of date.

`pnm-relay` is gone. Its `rest-channel.ts` / `request-task.ts` were
consolidated into `@openvtc/pnm-core` — the copy `pnm-extension` and
`pnm-pwa` both consume — which carries the body-first parse and the
`ConsentRequired` union. Verified in code, not taken from the plan:
`decodeTrustTaskHttpAck` reads the body and builds with `errorFromBody`,
and `errorFromResponse` appears nowhere in the module. Nothing depends on
`@openvtc/pnm-relay`; `rp-sdk-js` is a separate server-side SIOPv2
verifier, not its successor. (D8/F5, resolved by consolidation.)

What survives is the rule rather than the defect, so the note now says
that: `vti-didcomm-js` is still a second implementation of the same wire
contract, and a transport or error-shape fix has to land in both. A third
copy is what R4.1 exists to prevent.

Cold clean/lint/build/test green; 733 tests; all three dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…he agent

The console already had an entry point on the options page and none in the
popup, which is the wrong way round. The popup is the surface that tells
you which VTA you are connected to, your role and your transports — so it
is where "and now go administer it" belongs. Reaching it otherwise meant
knowing to open Settings first.

Placed after Role/Transports and before Lock wallet: it is an action about
the agent, and the two below it are teardown.

**A tab, not a pane**, and that is the point rather than a cost. Opening it
closes the popup — but the console is a working surface with a context tree
and thirteen panes, and a 400px popup that vanishes when it loses focus is
no place to destroy a context from. It also ships as its own bundle, which
is what keeps `@openvtc/pnm-core/admin` out of every wallet surface.

**Not role-gated.** Several panes are readable at any role, and the console
already refuses what the caller may not do — with the reason on the
control, rather than by hiding it. Gating here would hide the readable half
from everyone below admin.

This adds no admin code to the popup: it opens a URL. Verified rather than
assumed — the CI guard that confines `admin/*` task URIs to `manager.js`
still passes against a fresh build, as does the key-material guard, and the
console is still a single chunk.

733 tests green.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The pane argued why export and import are not in the browser and then left
the operator to go find `--help`. It already knew the answer; it just was
not saying it.

Four commands, taken from `pnm-cli/src/cli.rs` rather than remembered:

    pnm backup export
    pnm backup export --include-audit
    pnm backup import <file> --preview
    pnm backup import <file>

Two things are called out because a flag list does not convey them.
`--preview` is a real rehearsal — decrypt, validate, report, change
nothing — and it is worth running first, because the common mistake is not
a wrong password (which fails loudly) but the right password on the wrong
bundle. And `--include-audit` widens the bundle beyond the agent's own
state to its dealings with counterparties who were never party to the
export, which is why it is off by default.

The third detail strengthens the argument the pane was already making.
There is **no `--password` flag** — verified, the CLI defines none; it is
prompted, with confirmation, minimum 15 characters. So "the terminal is a
smaller surface" stops being a hand-wave: the password never enters shell
history or the process list. That is a concrete property a browser field
cannot offer, and it belongs next to the claim it supports.

Both dist guards re-run against a fresh build — the pane gained text, not
imports. 733 tests green.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…agent

Reload-services was under Transports and read wrong there, which was the
tell. Two facts say it belongs elsewhere:

The task is `vta/management/reload-services`. It re-reads the agent's
entire configuration; the transports going down is what you *observe*, not
what it is. And Transports is per-transport CRUD — enable this one, drain
that one — so an agent-wide restart sits in that list looking like another
row of it, which is exactly what it is not.

Backup was under Wire & execution for want of anywhere better, and was
never really "wire" either.

So: one pane, `Maintenance`, holding both. Everything else in this console
acts on something the agent *holds* — a context, a key, a credential, an
ACL entry. These two act on the agent. That is the grouping.

It also happens to be the step-up boundary: these are the only two controls
gated on proof of presence, precisely because they are the two that act on
the whole agent. That is not a coincidence to design around — it is the
same fact showing up twice, and a pane that matches it will keep matching
it as things are added.

Reload keeps its own role gate (admin), separate from the backup one, since
the two answer to different authority even in the same pane.

`services.tsx` shed its now-dead `reloadServices`, `requirePresence` and
`StepUpError` imports — tsc did not flag them, and a live import of
`reloadServices` in the Transports pane would have left the old path
reachable.

Behaviour is unchanged: same task, same step-up, same copy. Confirmed
working against a live agent before the move.

`manager-sections.test.mts` verified the rename end to end — the union, the
nav and the switch still name one set. Both dist guards re-run; the
reload-services URI still appears in `manager.js` and nowhere else. 733
tests green.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…ould save it

Reported from testing: every context in the tree showed `agent-memory` in
the Name field — the first context's name — while the header, id, DID and
timestamps all showed the right record.

`EditContext` seeds its fields with `useState(record.name)`, which runs on
mount and never again. The detail panel is a single instance that swaps
records as the operator moves through the tree, so React reused it and the
form kept its first value.

**It reads as a display glitch and is not one.** That field is the input to
`contextsUpdate`. An operator pressing Save would have renamed the context
they were looking at to the name of one they were not — a write, from a
screen that showed them the correct record everywhere else. Nothing else on
that panel disagreed.

Fixed with `key={record.id}`, which makes selecting a different context a
different component and re-runs the initialisers.

## The guard found four more, and one of them was itself

`manager-form-state.test.mts` fails when a component seeding state from a
prop is rendered without a `key`. Running it turned up `WriteRecord`
(app state), `WriteMemory` and `PolicyEditor` — same pattern, all now keyed,
including their create call sites so create and edit cannot share an
instance either. Those three were safe only by accident of control flow:
the panes unmount the editor between edits, so nothing had gone wrong yet.
Keyed, they are safe by construction.

It also reported `DeleteContext`, which has no `useState` at all. The scope
detection stopped at `\nfunction ` and ran straight past a component whose
successor is declared `export function`, into a sibling's state. Fixed
rather than exempted — a guard that cries wolf is one people learn to skip,
and adding a meaningless `key` to silence it would have been worse.

`RenameKey` and `ChangeRole` are exempt with the reason recorded: `Table`
renders them per row under `rowKey`, so the key is already on the `<tr>`.

Proved non-vacuous: removing the new key reports `EditContext` by name.

191 -> 194 tests; 736 across the workspaces; dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…that wrecked its own table

Three things from testing.

## The agent gets its name back

The console header showed a raw `did:webvh:Qm…` and nothing else. It now
renders the name through `useAgentNames`, which reads `alsoKnownAs` off the
DID document and verifies it — never derived from the DID's host or path,
because a derived name is a guess wearing an identity. No claim, no name:
the DID stands alone, which is the honest fallback. Same hook the setup
pane already uses.

This banner is the line an operator reads to check they are administering
the agent they think they are, so it is the right place for it.

## You can reassign a context's DID

`contextsUpdateDid` was in `pnm-core` and surfaced nowhere. That left the
operator somewhere absurd: deleting a DID a context depends on fails with
the agent's own instruction — "reassign it first: `pnm contexts update
test-vtc --did <new-did>`" — an instruction the console could carry out and
did not offer. Being told the fix and handed no way to apply it is worse
than not being told.

Its own panel, not another field on the name/description form. Those are
labels; this changes which identity the context presents to every
counterparty, and the agent refuses to delete a DID while a context points
at it. One Save button for both would make a rename and an identity change
look like one decision. The button reads "Assign" or "Reassign" depending
on whether there is a DID today, and a context with none says so — that is
a state, not a fault.

## A refusal that destroyed the row explaining it

`Destructive`'s error branch had no `maxWidth` while its preview branch has
carried one all along. Inside a table cell, a long refusal — and the DID
delete refusal is prose, naming the dependant and the fix — set the
column's width, so every identifier column collapsed and DIDs wrapped every
few characters. The most useful message on screen was wrecking the row that
gave it meaning.

`Table` already documents this exact failure at its `minWidth: 760` floor;
760 was not enough for this one. Error capped at 480 to match the preview,
and the DID column given a `26ch` floor. The first attempt used
`minmax(22ch, 1fr)`, which is a grid function and invalid as a table width
— it would have been dropped silently and looked fixed.

736 tests; dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Three from testing, plus one non-finding worth recording.

## "I have credentials but the pane says none"

The pane was right and useless. `vault/credentials/query` refuses a search
that constrains nothing, so the answer to "what do I hold?" was behind a
`type` or issuer DID the operator had to know in advance. A wallet holding
credentials looked identical to one holding none.

`status` counts as a filter, so one click gets there. Not a way round the
agent's rule — it is the rule satisfied with the narrowest thing that still
answers the question. The button is named for what it returns ("Show what
is currently valid"), because `valid` excludes expired and revoked and the
button must not imply otherwise; a line underneath says where those are.

## Reading a credential

`View` fetches the document by id through `credVaultGet` and renders it,
with a copy button.

Its own request, deliberately. The search returns metadata — the pane says
so — and rendering full documents from a list would quietly turn "show me
what I hold" into "read everything I hold". Copy hands over exactly what
the agent returned, canonically formatted, because that is the form a
verifier expects rather than a prettified approximation. A refused
clipboard says so instead of showing "Copied" over an unchanged one.

## Switching agent from the console

The console read the active VTA and offered no way to change it, so
switching meant going back to the popup. `setActiveVtaDid` writes the same
`pnm-connection/v3` envelope the readers parse, read-modify-write so a
partial view cannot forget every other agent — the failure the per-agent
inbox map exists to prevent. It refuses a DID the wallet never onboarded.

**Switching reloads the page**, deliberately. Thirteen panes each hold
fetched state; propagating a change of agent is thirteen chances to leave
one showing the previous agent's keys under the new agent's name. That is
not staleness, it is the console asserting something false about who you
are administering — on the surface whose whole job is to answer that. The
control only appears when there is somewhere to switch to.

## App state was not a bug

Its empty state already names the context it searched — "No application has
written anything into <id>". App state is per-context, so OpenVTC's records
are under the `openvtc` context. Left alone.

736 tests; all dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Asking an operator to copy a `did:webvh:Qm…` out of the DIDs pane and paste
it here made them do a lookup the console could already do — and a mistyped
identifier is not something anyone catches by reading it back.

Now a select, scoped to this context's own DIDs, which is what a context
acts as. `— none —` clears it, and says what that means rather than leaving
an empty field to be interpreted.

The current DID is always among the options, even when the agent no longer
lists it under this context. Otherwise a DID assigned from elsewhere would
vanish from the list and the select would fall to whatever happened to be
first — turning "open the panel" into "silently propose a change".

## The type system caught the right thing on the way

The obvious lister, `vtaListDids`, is typed on `Identity` — it wants key
material — and refused to compile here. That is the guard working, not an
inconvenience: this console holds no private key, which is why `admin/*`
and `vta/contexts.ts` type their parties as `TaskParty` (CLAUDE.md, "the
console holds no key material"). Weakening the signature to get a list
would have quietly undone the property the whole surface is built on.

`webvhDidList` is the `TaskParty`-typed one the DIDs pane already uses, and
it was the correct call all along.

736 tests; dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…the table

Reported from testing: "I missed that it was rendering under the list."

That is the whole defect. The panel appeared after the table, so clicking
View on the third row put the answer below the fold and the table looked
like it had done nothing. A detail panel the operator does not notice is a
detail panel that does not exist.

`Table` gains an `expanded` prop: detail rendered in the flow, directly
beneath its own row, spanning the columns. The row drops its bottom rule
when a detail follows — the row and its detail are one thing, and a line
between them reads as two.

The button toggles now and says `Hide` when open, because a control that
only ever opens leaves the operator hunting for a close button that is
somewhere else on screen.

Not a new mechanism: the Audit pane already does exactly this, hand-rolling
its own `<table>` and `colSpan` row because the shared component could not.
Putting it in `Table` means the next pane that needs it does not hand-roll
a third one, and Audit has somewhere to converge on when it is next touched.

736 tests; dist guards re-run.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit eb6845f into main Sep 3, 2026
3 checks passed
@stormer78
stormer78 deleted the vta-management branch September 3, 2026 08:01
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