useCapabilityManifest

Read the api's capability manifest (procedures + tables + schemas) from the browser, without codegen.

What does this api actually expose? useCapabilityManifest answers that at runtime: a one-shot fetch of /_voltro/inspect/manifest — the same inspect surface useProvenance rides. It is a structural lookup, not a live subscription, so it does not re-fetch on data changes.

import { useCapabilityManifest } from '@voltro/client'

const { manifest, loading, error } = useCapabilityManifest('app')

The manifest carries:

Field Contents
procedures Every procedure's tag, kind, input/output Schema, the source table a query reads, the targets ({ table, op }) a mutation writes, and the guards it declares.
tables The user tables — name, columns (type, nullable, refersTo, enum, and the three exposure axes), pkColumn, editable, and whether the table is reactive. Framework _voltro_* tables are flagged framework.
workflows The discovered workflow names.
widgets The registered widget ids.
scopes Every scope the installed plugins declare. Empty means "not declared" — never "no scopes exist".
version The manifest format version.

Deriving an admin surface

deriveEntityAdmins(manifest) is the pure projection the admin template is built on. It joins each user table to the procedures that read and write it, so a back-office binds to tags that actually exist instead of guessing them from a naming convention:

import { useCapabilityManifest, deriveEntityAdmins } from '@voltro/client'

const { manifest } = useCapabilityManifest('app')
const entities = manifest ? deriveEntityAdmins(manifest) : []
// each: { table, columns, serverOnlyColumns, sensitiveColumns, reactive,
//         pkColumn?, editable,
//         list, create, update, delete }   // each an { tag?, guards? }

An action's tag is undefined when the app exposes no procedure for that operation — render that affordance read-only rather than binding to a tag that does not resolve.

Why derive at runtime instead of generating an admin

A generated back-office snapshots the answer at codegen time. The moment you edit guards: on a mutation the generated gate is stale — and stale in the silent direction: it renders a control the server now refuses, or hides one that would work. The manifest is served by the running app, so a derived surface cannot drift from it. Customisation is not the tradeoff it looks like: the derived spec is plain data, and the component that maps over it is your own template code.

Gating on the access the api declares

Each action carries guards — the procedure's own guards: / openAccess: declaration, the same data the server enforces. useAccessDecision turns it into a decision against the scopes you fed <PermissionProvider>:

import { useAccessDecision, requiredScopes } from '@voltro/client'

const decision = useAccessDecision(entity.create.guards)  // 'allowed' | 'denied' | 'unknown'
{entity.create.tag && decision !== 'denied' ? <AutoForm /> : null}
{decision === 'denied' ? <p>Requires {requiredScopes(entity.create.guards).join(', ')}</p> : null}

The decision is three-valued, and unknown is the important one. A guard that carries a resource extractor is answered per row by the server, and a browser holding only the subject's global scopes cannot pre-compute it. Both ways of collapsing that gap are bugs:

Collapse What ships
unknowndenied Every affordance disappears for callers whose authority is per-resource — the multi-tenant case, where subjects are minted with no global scopes. A total outage wearing a permission check's clothes.
unknownallowed A control that always errors.

So show it and let the server answer: it is the authorization boundary, and it replies with a typed ScopeError. decideAccess(guards, scopes) is the pure form if you need it outside React.

guards: undefined means the procedure declared neither guards: nor openAccess: — undecided, not open. That is refused outright under security.defaultDeny, so it is reported as unknown rather than guessed.

The three exposure axes in a derived UI

The manifest's columns carry all three schema markers, and they are orthogonalderiveEntityAdmins treats each one differently, and so must you:

Marker What it says What the admin does
.serverOnly() Never crosses any wire; the runtime refuses a mutation input that sets it Excluded from columns; listed in serverOnlyColumns so the UI can say why it is absent
.encrypted() Ciphertext at rest Kept. Your procedures read it decrypted — hiding it is a category error
.sensitive(class) Personal data; the export-masking axis Kept, and listed in sensitiveColumns so a bulk export masks it

pkColumn is the column row-keyed actions must target — do not hard-code id. When a table has no single primary key, editable is false and no row can be addressed for update or delete.

The manifest GET is bearer-gated wherever it runs — /_voltro/inspect/* is fail-closed, so no configured VOLTRO_INSPECT_TOKEN means 401, not "everyone". Under voltro dev that is handled for you (the dev server mints a token and its proxy attaches it server-side). An admin UI pointed at a deployed api has to supply the token itself — a deployment concern, not something this hook handles.