Skip to content

spike: policy showcase — lossless doc inspector, doc-only builder with update diff, dApp session-key request - #202

Closed
willemneal wants to merge 14 commits into
fm/nido-applydoc-spike-n4from
fm/nido-pr168-showcase-n2
Closed

willemneal wants to merge 14 commits into
fm/nido-applydoc-spike-n4from
fm/nido-pr168-showcase-n2

Conversation

@willemneal

@willemneal willemneal commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

SPIKE — DRAFT, DO NOT MERGE. Top of the perch stack: stacked on #201 (fm/nido-applydoc-spike-n4, this PR's base) which stacks on #200 (fm/nido-perch-sdk-n1). It merges only after the stack under it resolves; if #201 changes shape, this rebases.

What this demonstrates

The policy showcase: the lossless policy view the doc layer buys, end to end, clickable — under the doc-only ruling (every policy write is one apply_doc; there is no per-rule route).

The click-through: the status-message demo dApp requests a scoped session key → the wallet composes a policy-document update and shows exactly what changes plus a readable document preview (rule cards; raw canonical JSON behind a toggle) + doc_hash → /sign/ applies it (one apply_doc tx) → the wallet's Policy page renders the document itself — the author's rule names, signer ids, and function lists, verified byte-for-byte against the hash the account stores on chain.

1. Perch-powered policy inspector (/account/policy)

Incorporates #168's inspector (cherry-picked) and layers the SDK's two-tier readPolicy on top:

  • tier a — doc-verified: the applied document, read losslessly from the on-chain copy (get_applied_doc; DocApplied event history as in-tier fallback), rendered with the doc's own names everywhere — rule names, signer ids, function chips, cap, expiry — plus a doc_hash badge, a provenance badge (Stored on chain / From event history), and a raw-JSON toggle (pretty-printed for reading; Copy gives the exact canonical bytes whose sha256 is the stored hash). Under doc-only there is no drift tier by construction.
  • tier b — decompiled: feat(frontend): policy inspector + builder for a smart account #168's raw rule cards, unchanged. The raw cards also stay below the doc view in tier a (they carry rule ids / on-chain context).
  • Fresh accounts (doc surface, nothing applied yet) still get a document view: the page renders the synthesized baseline — the founder admin rule over the live default-rule passkey, the same baseline every first-apply flow composes against — labeled Not yet applied with "Document hash (once applied)"; the first policy edit applies exactly that document and the label drops.

2. Doc-only builder with the canned template and a document-update diff

The builder is the v1 template — scoped session key (delegated signer, one contract's named functions, expiry) plus an optional cumulative spending cap (caps now ride the single apply_doc tx; the contract lowers them onto its pinned stock spending-limit policy). #168's raw add_context_rule form is gone with the mutators.

apply_doc replaces the whole document, so every submit is an update:

  • The builder reads the applied doc and upserts the template rule into it (upsertSessionRule: same-key signer reuse, id-collision allocation session-2…, orphaned-declaration pruning, cross-network refusal).
  • First applies upsert into an ownerAdminBaseline — the account's live primary passkey with a policy-free self-admin rule — because the contract's DocAdminLockout anti-brick check refuses documents without one (and the constructor's default rule dies on first apply).
  • A What changes panel (diffPolicyDocs + renderDocDiffHtml) shows rules added / removed / modified — name-level with field-level change lines on modified rules (scope, signers, quorum, functions, expiry, cap, arg conditions, rekeyed signer ids) — before the user confirms. First applies render as all-new; identical docs as a no-op.
  • Fail-closed: no doc surface, an unreadable applied doc, or an unreadable primary passkey blocks composing an update.

3. Admin keys — add / remove

The builder's second tab enrolls another admin key: a brand-new passkey (WebAuthn ceremony on submit, verifier resolved from the account's own rule) or a pasted external/delegated key, added as a policy-free self-admin rule — the same shape the anti-brick check requires, one rule per key. The admin model is any-may-act (captain's ruling): each admin rule is independent full authority, so the UI presents the set as one list — a consolidated Admin keys card on the policy page, the single list in the builder — with naming admin (founder) / admin-2, admin-3, … (signer ids mirror rule names; the earlier owner id is gone — legacy docs are renamed on their next update, the rename shows in the diff, and the display layer already renders owner as admin so both never appear at once). One rule per key rather than one merged any-of rule because doc v1 expresses "any" as threshold m=1, which always lowers interpreter-attached — exactly what the DocAdminLockout anti-brick check refuses (the admin path must never depend on an interpreter refusal). Removal uses the same machinery: pick an enrolled admin key, review the removal diff, apply — the last admin key is refused up front with a human-readable reason. Duplicate rule names and keys that already hold admin authority are refused at validation.

4. dApp example (status-message page)

“Request a scoped session key (policy doc)” next to the existing passkey delegate button. The dApp's local G keypair becomes the delegated signer, scoped to udpate_message, 24h duration. The wallet page /security/delegate-doc/ (same anti-redirect-abuse guard as /security/delegate/, pure param-parsing lib) composes the doc update, shows the What changes diff + canonical JSON + doc_hash, and hands off to /sign/ via the apply-policy-doc descriptor — which carries prevDocJson so /sign/ shows the same diff at the confirm ceremony. On return the dApp links straight to the policy page.

Deltas / shortcuts (spike-honest)

  • Request + install only: the dApp example installs the doc-based session key; using the delegated G key to sign in-page isn't wired (delegated-signer auth plumbing is its own chunk).
  • /sign/ doc grants are confirm-only — cap/duration edits happen on the delegate-doc page before the handoff.
  • The legacy passkey delegate flow (/security/delegate/) is now doc-only too: the dApp's session passkey becomes an external signer on a session-key doc rule (re-delegation replaces it), with the same baseline/diff/preview discipline — both delegate pages build their /sign/ operation through one shared buildSessionGrantOperation helper, and the add-context-rule / remove-context-rule descriptors throw doc-only guidance offline (regression-tested). The only surviving add_context_rule builder is zk-recovery's completion vehicle (the contract's sanctioned gated window). The spending-limit edit moved from /sign/ onto the delegate page. The testnet-lane e2e specs for the old flow (tests/e2e/testnet/*) still describe the pre-doc behavior and need a pass once the stack settles.
  • Live tier-a demo needs an account running spike: doc-only perch apply_doc in the smart account (sole write path, doc-hash + JSON + event) #201's doc-only wasm. Existing accounts read as tier b (raw cards) and refuse doc composition fail-closed.
  • Navigation: the Security page carries an "Account policy" nav card to /account/policy/; the delegate-doc preview and the dApp's return banner link there too.
  • The delegate-doc expiry ledger is anchored once at page load so the previewed doc_hash is byte-identical to what gets signed.
  • @stellar-registry/perch-interpreter added to frontend deps for the typed get_program read (root overrides keeps it on the workspace's single stellar-sdk — the Deliverable 4 (Tranche 2): gas abstraction via OZ Relayer + session-key scope UI #72 hazard).

Tests

  • frontend vitest: 503 passed (new: docView, docDraft, docRequest, docDiff — diff classification, field-level lines, rekeyed signers, upsert/baseline/pruning, first-apply rendering, admin add/remove/last-admin refusal — plus docPolicyFetch helpers and operationBuilders guards)
  • passkey-sdk vitest: 256 passed (untouched, re-run on the rebased doc-only base)
  • e2e @fast: tests/e2e/ui/policy-page.spec.ts (boot, template validation, admin-keys tab, fail-closed baselines, delegate + delegate-doc rejection) — chromium green locally; firefox/webkit not installed in this environment
  • astro check: 0 errors; frontend build green

@github-actions

Copy link
Copy Markdown

Example dApp preview deployed!

https://example-pr-202.mysoroban.pages.dev

The status-message example (testnet), wallet = THIS PR's preview (https://202.nido.fyi). The live home is https://nidohq.github.io/nido/ once merged.

@github-actions

Copy link
Copy Markdown

Preview deployed!

https://202.nido.fyi

Account URLs use numeric preview suffixes, for example <contract-address>--202.nido.fyi.

@willemneal
willemneal force-pushed the fm/nido-pr168-showcase-n2 branch from 9cfc0ac to c9f03ab Compare September 10, 2026 15:29
@willemneal willemneal changed the title spike: policy showcase — lossless doc inspector, doc-mode builder, dApp session-key request spike: policy showcase — lossless doc inspector, doc-only builder with update diff, dApp session-key request Sep 10, 2026
@willemneal
willemneal force-pushed the fm/nido-pr168-showcase-n2 branch from 9902ed6 to 1725553 Compare September 10, 2026 17:07
willemneal and others added 11 commits September 11, 2026 16:20
Adds a Policy page (/account/policy) that visualizes every context rule on
a smart account and a builder for adding new rules.

Inspector: fetches all context rules (fetchAllChainRules) and renders each
as a card — scope (any contract / one contract / contract creation),
signers (passkeys vs delegated keys), attached policy conditions (labeled
from the registry where known), and expiry classified against the current
ledger. Each rule gets a plain-language sentence of what it permits, and
rule 0 is flagged as the account's primary authority.

Builder: composes a new context rule (scope, one or more passkey/delegated
signers, optional spending-limit policy, optional expiry), validates it,
lowers it to the smart-account add_context_rule arguments, and submits
through the account's existing passkey signing path (signAndSubmit). No
silent signing — the on-chain write always goes through the user's passkey.

The display model (policyView) and draft validation/lowering (policyDraft)
are pure and unit-tested (34 tests); the Security page stays the curated
front door for recovery/session keys, this is the general view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iRUGB6K1XCEsdHoxEzsbP
The policy page now runs the SDK's three-tier readPolicy:

- tier a: an applied document recovered losslessly (get_applied_doc
  storage view first, DocApplied event history as fallback) and verified
  against the stored doc_hash renders as the document itself — the doc's
  own rule names, signer ids, and function lists, with a doc_hash badge
  and a provenance badge (stored on chain / from event history).
- tier b: same view plus the SDK's drift findings, rendered prominently.
- tier c: the existing raw rule cards, unchanged.

The builder gains a doc mode (default) with the canned v1 template — a
scoped session key (delegated signer, one contract's named functions,
expiry) plus an optional cumulative spending cap — with a live canonical
JSON + doc_hash preview. Uncapped docs apply through the account's
one-transaction apply_doc surface; capped docs (and accounts without the
surface) install per-rule via the SDK's client-side lowering, where the
cap rides the stock spending-limit policy. The raw add_context_rule form
stays as the second mode.

New pure libs (docView, docDraft, docPolicyFetch helpers) carry the
display models, validation, and route choice; all vitest-covered.
@stellar-registry/perch-interpreter joins the frontend deps for the
typed get_program read (stellar-sdk pinned by the root override).
The status-message demo dApp gains a second delegation option: request a
SCOPED SESSION KEY as a perch policy document. Its existing local G
keypair becomes a delegated signer restricted to the note function, and
the click-through the showcase demonstrates is:

  dApp requests → wallet builds the doc → /sign/ applies it →
  the wallet's Policy page shows the lossless document.

New wallet page /security/delegate-doc/ (doc sibling of /security/
delegate/): parses the request (pure lib lib/policy/docRequest), offers
duration + optional spending-cap controls, previews the document's
canonical JSON and doc_hash — the exact identity the account stores on
chain — and hands off to /sign/ with a new apply-policy-doc descriptor.
The expiry ledger is anchored once at page load so the previewed hash is
byte-identical to what gets signed.

/sign/ renders the descriptor as a review card built from the document
itself (rule name, key, functions, cap, expiry, doc_hash) with the
canonical JSON behind a toggle; buildOperation routes it through
buildApplyDocTx (uncapped, apply_doc surface) or a single-rule per-rule
install, refusing multi-rule docs on the per-rule route offline.

Anti-redirect-abuse validation and the ?delegation=ok|cancelled return
contract are carried over from the passkey delegate flow unchanged.
…puts

Rebased onto 856e258 (on-chain canonical doc JSON + get_applied_doc):
pass the storage-read doc as storedDocJson and an event-recovered doc as
eventDocJson, matching the SDK's view-first input surface instead of
funnelling both through the event field.
Same conventions as account-ui.spec.ts: built-HTML anchors, no-fatal-JS
boot checks, and client-side validation that fires before any network
call — doc-mode template validation, the mode toggle, delegate-doc
request rejection (missing origin) and offline doc-JSON preview.
Captain ruling: every policy write goes through apply_doc — the per-rule
add_context_rule lowering is gone from the builder, the delegate-doc
flow, and the /sign/ descriptor (route field dropped), and the builder's
raw-rule mode is removed with it. chooseApplyRoute/docHasCap die.

apply_doc REPLACES the applied document, so both write surfaces are now
update-aware: they read the account's applied doc (get_applied_doc, event
fallback), UPSERT the requested session rule into it (upsertSessionRule:
same-key signer reuse, id-collision allocation, orphan pruning,
cross-network refusal), and preview the merged document. A new pure diff
(diffPolicyDocs) classifies rules added / removed / modified — with
field-level change lines on modified rules, including rekeyed-signer
surfacing — and renderDocDiffHtml shows it on the builder, the
delegate-doc page, and /sign/ (prevDocJson carried in the descriptor)
before the user confirms. First-time applies render as all-new.

Fail-closed guards: the delegate-doc flow refuses accounts without the
doc surface and refuses to build an update over an unrecoverable applied
doc; fetchDocSurface/fetchAppliedDocJson now swallow constructor throws
(an invalid account id reads as no-surface instead of escaping).

Note: capped documents now rely on the reworked apply_doc gaining cap
support on the base branch (PR 201's in-flight update); until that lands
the SDK's buildApplyDocTx still refuses caps at build time.
…, two-tier read

Rebased onto 464f6b0 (apply_doc is the sole policy write path):

- First applies now upsert into an owner-admin BASELINE
  (ownerAdminBaseline: the account's live primary passkey with a
  policy-free self-admin rule) instead of submitting a standalone
  session doc — apply_doc replaces EVERY rule including the constructor
  default, and the contract's DocAdminLockout refuses documents without
  that admin shape. upsertSessionRule now requires a non-null base; the
  builder and delegate-doc page read the passkey off rule 0
  (fetchDefaultRuleAuthInfo) when nothing is applied yet, and fail
  closed when neither an applied doc nor the passkey can be read.
- readPolicy is two-tier now (no mutators → no drift by construction):
  drop the docRuleIds input, the drift renderings, and the drift model
  fields; the doc view badge is always Verified · lossless.
- Capped documents ride the single apply_doc tx (the contract lowers
  caps onto its pinned spending-limit policy) — no client-side special
  case remains.
- e2e: the offline builder expectation flips to the fail-closed message;
  the first-apply preview happy path moves to pure unit coverage
  (renderDocDiffHtml + upsert-into-baseline tests).
Captain feature: the builder gains an Admin keys tab alongside the
session-key template. Adding enrolls another ADMIN signer — a brand-new
passkey created via the WebAuthn ceremony on submit (verifier resolved
from the account's own rule) or a pasted external/delegated key — as a
policy-free cap-free self-admin rule, the same shape the contract's
anti-brick check requires; each admin key gets its OWN rule (all
principals are N-of-N, so sharing one would force co-signing). Removal
falls out of the same machinery: pick an enrolled admin key, review the
removal diff, apply — refusing the LAST admin key up front with a
human-readable reason (DocAdminLockout would reject the document
anyway).

Both paths are doc updates through the shared apply plumbing: compose
against the loaded baseline, show the added/removed signer + rule in the
what-changes diff like any other doc change, one apply_doc, passkey
ceremony via signAndSubmit. The add form refuses duplicate rule names
(never a silent rule replacement) and keys that already hold admin
authority.

Pure lib: isAdminRule/adminRules/nextAdminRuleName,
validateAdminKeyDraft, addAdminKey, removeAdminRule in docDraft, with
the signer-merge/prune/revalidate helpers factored out of
upsertSessionRule and shared. 10 new vitest cases; new @fast e2e spec
for the admin tab (mount, defaults, offline fail-closed).
Captain feedback from live testing of the showcase:

- Navigation: the Security page gains an 'Account policy' nav card
  (existing navrow conventions) linking to /account/policy/, and the
  delegate-doc preview links to the Policy page where the applied
  document will be visible. The dApp's return banner already linked.
- Readable previews: the builder (both tabs) and the delegate-doc page
  now render the 'document after this update' as compact rule cards from
  the inspector's own display model — signer chips with the doc's ids,
  one mini-card per rule with the permission sentence and
  function/cap/expiry facts — with the canonical JSON folded behind a
  'Raw document JSON' toggle instead of dumped raw. The what-changes
  diff panel is unchanged and stays legible alongside.

renderDocPreviewHtml is a pure string builder in PolicyInspector,
unit-tested; styles shared across the policy and delegate-doc pages.
Captain's live test failed with Error(Contract, #19): the dApp's
'Delegate this dApp' button routes through /security/delegate/, which
still emitted add_context_rule — the doc-only gate refused it exactly as
designed. This closes that escaped path and sweeps the rest:

- /security/delegate/ is now a policy-document update, same discipline
  as delegate-doc: baseline load (applied doc, or owner-admin baseline
  on first apply; fail-closed otherwise), the dApp's session passkey
  declared as an external signer against the account's own verifier, a
  'session-key' rule upserted (re-delegation REPLACES it — the diff
  shows it as modified), what-changes diff + readable doc preview +
  doc_hash, and an apply-policy-doc handoff. The query-param contract
  and anti-redirect-abuse validation are unchanged, so startDelegation
  keeps working. The spending-limit control moved here from /sign/
  (doc grants are confirm-only there); caps ride the doc (compiler
  0.2.1 base).
- Both delegate pages now build their /sign/ operation through ONE
  shared helper (buildSessionGrantOperation in docRequest) — the
  regression tests pin that it emits apply-policy-doc, carries
  prevDocJson on updates, and replaces same-named rules.
- operationBuilders: the add-context-rule branch now THROWS doc-only
  guidance offline (like remove-context-rule) so a stale stashed
  request fails with a message instead of #19 on-chain; dead
  SmartAccountClient plumbing dropped. Regression tests cover both
  refusals.
- Emitter sweep: the only remaining add_context_rule builder is
  zkRecovery's recovery COMPLETION (the contract's sanctioned gated
  window); multisig-recovery paths already throw doc-only guidance in
  the SDK. No other legacy mutator emitters in frontend or sdk.
- Rebased onto 1979dca (caps re-enabled on perch compiler 0.2.1 +
  realigned testnet wasm).
- New @fast specs for the converted delegate page (param rejection,
  fail-closed baseline); passkey-signer draft coverage in docDraft.
@willemneal
willemneal force-pushed the fm/nido-pr168-showcase-n2 branch from 1725553 to 1e90ce3 Compare September 11, 2026 20:28
…ption C)

Ruling on the admin composition (needs-decision admin-any-principals):
keep ONE policy-free self-admin rule per admin — each independent full
authority, which already IS 'any admin may act' — no contract change,
no interpreter-gated admin rule (doc v1 has no 'any' principal type;
threshold m=1 always lowers interpreter-attached, which the anti-brick
check refuses by design). What changes is naming and presentation:

- Naming: the founder keeps signer id 'owner' (rule 'admin'); added
  admins get signer ids admin-2, admin-3, … mirroring their default
  rule names — no more bare 'admin' signer colliding with the rule name.
  nextAdminRuleName picks the slot free as BOTH rule name and signer id.
- Single-list UI: the policy page's doc view folds every admin rule
  into ONE 'Admin keys' card ('Any of these N keys can act … each holds
  independent full authority'), non-admin rules keep their per-rule
  cards; DocRuleView carries isAdmin. The builder's admin tab copy says
  the same; its list, add/remove flows, and the diff preview already
  present the set as one list.

Tests updated for the new ids plus new coverage: isAdmin classification
and the consolidated card (one data-doc-admins card, both keys listed,
no per-rule admin cards).
'It could be confusing for new users that there are both.' The founder's
signer id is now 'admin' (rule name 'admin'); added admins stay
admin-2, admin-3, …. The 'owner' id disappears everywhere:

- adminBaseline (né ownerAdminBaseline) declares the founder as 'admin'
  in the first-apply document; add/remove flows and UI copy follow (the
  doc-head card no longer says 'owner' either).
- Legacy applied docs that still declare 'owner': renameLegacyOwner is
  a guarded pure migration (only when no distinct 'admin' signer exists,
  so two different keys can never merge) applied inside rebuildDoc — so
  the rename rides the user's NEXT doc update through any compose path,
  and the diff preview renders it (signer 'owner' removed / 'admin'
  added, referencing rules modified).
- Display layer: summarizeDoc migrates ids before rendering, so an
  un-updated legacy doc already shows 'admin' on the policy page and in
  previews — the two names never appear at once.

Tests: fixtures moved to the new id; new coverage for the composed
rename (visible in the diff) and the distinct-admin guard; composed
signerId return values map through the rename.
… labeled

Captain live repro: a NEW account's /account/policy showed no document
(nothing applied yet → decompiled tier → doc section hidden). Now a
doc-surface account with no applied document renders the SYNTHESIZED
baseline — the founder admin rule over the default rule's live passkey,
built by the same adminBaseline the builder and both delegate flows
compose against on first apply (fetchUnappliedBaseline, shared) — as
the current effective policy, with the full rule-card + admin-list +
raw-JSON-toggle presentation. Clearly labeled: 'Not yet applied' badge,
lead copy saying the first policy edit applies exactly this document,
and 'Document hash (once applied)' for the would-be identity. The first
real edit applies it and the label drops (readPolicy then reads
doc-verified as usual).

readDocPolicy exposes the unapplied state (surface supported, no stored
hash); page and flows provably share one baseline. New fixture test:
the new-account doc view renders labeled, with the admins card and raw
toggle, and an applied render never carries the label.
@willemneal

Copy link
Copy Markdown
Contributor Author

Superseded by #207, which merges this branch's work together with 200/201/202/204/205/206 into one reconciled, non-draft PR off main. This branch stays on origin for history.

@willemneal willemneal closed this Sep 14, 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.

1 participant