Skip to content

Perch policy-document integration: doc-only smart accounts, policy UI, and account recovery - #207

Closed
willemneal wants to merge 44 commits into
mainfrom
fm/nido-perch-policy-integration-n9
Closed

willemneal wants to merge 44 commits into
mainfrom
fm/nido-perch-policy-integration-n9

Conversation

@willemneal

Copy link
Copy Markdown
Contributor

Perch policy-document integration: doc-only smart accounts, policy UI, and account recovery

Nido smart accounts now use a Perch policy document as their single source
of truth for on-chain authority. This PR brings that integration together
end to end: the doc-only contract layer, the policy page and document
builder, the dApp connect/sign/delegate flows, and guardian + ZK account
recovery — six previously separate branches, merged onto one, reconciled
where their frontend surfaces overlapped, and reframed as one feature.

What this ships

Doc-only smart accounts. apply_doc is the account's sole policy write
path: one transaction parses, validates, lowers, and atomically installs a
whole rule set, and stores the canonical document alongside its hash
(get_applied_doc, lossless — no indexer needed to reconstruct a live
account's policy). The factory embeds the doc-capable smart-account wasm at
deploy time, so every account this factory creates gets the doc surface for
free.

Policy page, builder, and dApp flow. /account/policy/ is the account's
full policy inspector: every live rule, the currently-applied document, and
a builder for session keys and admin keys that always previews the exact
diff before submitting. /security/delegate-doc/ and /sign/ carry the
same doc-aware flow into third-party dApp requests — a dApp can request a
scoped session key or a message signature and the user sees precisely what
changes before approving.

Account recovery — guardian quorum, ZK, or both. A shared
RecoveryController (contracts/recovery-controller) implements
guardian-only, ZK-only, and combined recovery against one proposal-
commitment model, completing through the account's own apply_doc (no
smart-account code changes for completion). Guardian-only enrollment needs
no ZK machinery at all. An account enrolled in one evidence factor can add
the other later via reconfigure, converging on Combined regardless of
which was set up first. A companion recovery-verifier (constructorless
UltraHonk verifier, VK baked in at compile time) and an adapted Noir circuit
(circuits/zk_recovery_doc, isolated from the pre-existing M1 circuit)
back the ZK path with real, on-chain-verified proofs.

The design work behind this is recorded in three layers, still visible in
the code/doc layout — see AGENTS.md's "Account recovery" section: a
transition spec + executable reference model (docs/recovery/TRANSITION_SPEC.md,
packages/recovery-spec/), a completion-mechanism comparison
(docs/recovery/stage2-findings.md), and the controller/circuit
implementation with real measurements (docs/recovery/stage3-measurements.md).

Reconciling two frontend lineages

The recovery work and the policy-page/dApp work developed on sibling
branches, both stacked on the doc-only contracts. Both touched
packages/frontend/src/pages/security/index.astro; everywhere else they
were disjoint. Merged with one real conflict (a stale CLAUDE.md/AGENTS.md
split, resolved by keeping the @AGENTS.md pointer convention and folding
the other side's content in) — full workspace + frontend suites, plus the
fast UI e2e tier across all three browsers, re-verified green after the
merge and again after a documentation pass that removed "spike"/staged-
experiment framing now that all three stages ship together (see the
docs: commit on this branch for the exact scope of that pass).

Explicitly still open

follow-up.md §7 — what happens to ordinary account activity and pending
policy writes during a live recovery attempt (freeze / continue / restrict)
— remains a deliberately undecided, documented gate. Nothing in this PR
resolves it or infers a default from adjacent behavior; see
contracts/recovery-controller/src/lib.rs's crate doc comment for the full
"Known limits" list, including the pre-existing add_context_rule
completion-window gap this PR does not close (would need a smart-account
change) and the recovery config's deployed-testnet limitations
(reconfigure's Protected-profile ZK-evidence path, no factory/registry
auto-wiring).

Verification

  • just check / just test (full Rust workspace) green
  • passkey-sdk: tsc --noEmit clean, vitest 265/265
  • frontend: vitest 503/503, astro check 0 errors, production build
    (npm run build) clean, 17 pages
  • Fast UI e2e tier (just test-e2e, all three browsers): 98 passed
  • Live testnet smoke: /account/policy/ driving a real apply_doc
    (add an admin key, verify the hash changed on-chain, reload and
    confirm the page reflects it) — tests/e2e/testnet/account-policy-doc-apply.testnet.spec.ts
  • Live testnet: guardian-only and ZK-only recovery lifecycles including
    a real bb-proved UltraHonk proof verified on-chain, both converging
    on Combined mode regardless of enrollment order
    (tests/e2e/testnet/recovery-stage3-combined.testnet.spec.ts)
  • Live testnet: the friend/guardian recovery UI and the ZK-recovery
    setup UI both route through the same controller instead of a legacy
    stub or a separate pool (tests/e2e/testnet/security-recovery-install.testnet.spec.ts)

Superseding

This branch supersedes and closes #200, #201, #202, #204, #205, #206 — all
their work is included here, reconciled and re-verified together. Those
branches remain on origin for history.

willemneal and others added 30 commits September 8, 2026 21:29
Integrate perch (stellar-registry/perch) at the doc layer: the perch
PolicyDoc becomes nido's policy source of truth, lowered onto the OZ
context rules nido accounts already run on.

- packages/perch: vendored @stellar-registry/perch (perch-js) pinned to
  perch rev f5676a6, plus the frozen golden vectors. npm cannot install
  a git dep from a repo subdirectory, so a vendored workspace package
  under the final npm name is the workspace-compatible pin; VENDORED.md
  carries the provenance and the TODO to swap to the npm release once
  perch-publish-p1 lands (imports won't change).
- packages/contract-bindings/perch-interpreter: generated bindings for
  the perch interpreter (canonical registry testnet deployment, wasm
  f8320d30…), regen via `just bindings-perch-interpreter`.
  fix-bindings.sh now skips the Context shim when the generator (CLI 27)
  defines its own Context type.
- passkey-sdk src/policyDoc: the new doc layer.
  * build: buildPolicyDoc/scopedSessionKeyDoc — nido conventions
    (passkey signers as external(verifier, key), session keys as
    delegated(G…)); the v1 template composes named-function + expiry
    constraints with a cumulative cap.
  * lower: faithful TS mirror of perch-compile (INV-1/INV-2): bare
    all-rules ride OZ natively; constrained rules attach the perch
    interpreter (canonical [MinSigners, FnIn?, args…, All] program);
    caps attach nido's stock spending-limit policy as a sibling;
    not-after-ledger (exclusive) → valid_until (inclusive, -1).
  * txs: one add_context_rule TxBuild per rule via the smart-account
    bindings, install params spec-encoded (dual-SDK hazard #72).
  * decompile: best-effort chain rules → doc view with per-rule
    {doc|raw} fallback and the committed doc_hash from on-chain
    interpreter programs.
  * deployment: perch canonical addresses derived offline from the
    content-addressed registry pins (never hardcoded at call sites),
    asserted against perch's testnet_pins in tests.
- tests: doc_hash parity vs perch golden vectors, byte-exact wire
  parity for interpreter programs + spending-limit params, lowering
  invariants, template round-trip (lower → chain shape → decompile),
  raw-fallback coverage, address-derivation pins.

Perch gaps found (reported upstream rather than forked): the TS schema
has no threshold principals (Rust perch-ir does) and the TS RuleBuilder
has no .cap() — cap-carrying docs are built via requestToPolicyDoc.
Both perch packages are now on npm, so replace the interim workspace
plumbing with the published releases:

- @stellar-registry/perch 0.2.0 (adds threshold principals + RuleBuilder
  .cap): remove the vendored packages/perch copy and depend on the npm
  package. lowerDoc now lowers Principals::threshold to the interpreter's
  MinSigners(m) quorum (a threshold rule is never policy-free, per
  perch-compile INV-2); the earlier all-only note is gone.
- @stellar-registry/perch-interpreter 0.1.0 (upstream bindings; captain
  ruled bindings live in perch): drop the nido-owned
  @nidohq/perch-interpreter binding and its npm-publish.yml entry, and
  point the policyDoc module at the published client.

The published packages ship only dist/, not perch's golden vectors, so
the doc_hash + wire-format fixtures move to
packages/passkey-sdk/src/policyDoc/testdata/ (frozen; provenance in its
README). A root overrides entry pins the interpreter bindings to the
workspace's single @stellar/stellar-sdk 15.x copy — the #72 dual-SDK
hazard, now a compile-time type conflict since the upstream bindings
target sdk 17.

tsc + vitest (250 passing, incl the new threshold-lowering tests and the
relocated golden parity) + astro build all green.
The previous npm-swap commit regenerated package-lock.json from scratch
(rm node_modules + lock), which re-resolved the whole workspace to newer
transitive deps and pulled in lightningcss platform binaries marked
non-optional — breaking `npm ci` on the Example dApp PR Preview leg with
EBADPLATFORM (lightningcss-android-arm64 on a linux/x64 runner).

Rebuild the lock from the pre-swap baseline plus only the perch changes:
@stellar-registry/perch 0.1→0.2, the new @stellar-registry/perch-interpreter,
and removal of the vendored/interim packages. The interpreter's nested
@stellar/stellar-sdk 17.x is deduped to the workspace's single 15.x copy
via the existing root override (the #72 dual-SDK requirement). Net lock
diff is now exactly those perch entries — no lightningcss, no unrelated
platform-specific churn. `npm ci` clean-installs; tsc + vitest (250) +
frontend and example-dApp builds all green.
The swap commit dropped the `packages/perch/dist/` line from .gitignore
and then `git add -A` swept the stale build output (left on disk from an
earlier npm install) into git, even as it deleted the package's source.
Remove the whole leftover directory — the vendored package is gone; only
its orphaned build artifacts were tracked.
Adds apply_doc/applied_doc_hash/doc_rule_ids to nido-smart-account,
consuming perch's stateless doc-compiler protocol (git-pinned
perch-doc-compiler at rev f5676a6, client mode) with the compiler and
interpreter addresses derived from the same testnet pins the SDK already
carries. HYBRID: apply_doc diffs only its own tracked rules; the default
passkey rule, recovery rule and all legacy mutators are untouched. Stores
the canonical doc_hash, emits the full doc JSON as a DocApplied event,
refuses capped docs (DocCapUnsupported) rather than installing weaker
than reviewed.

A [patch] redirects perch's theahaco stellar-accounts fork pin to nido's
upstream OZ rev so the wire types unify — a flagged adoption cost.

e2e (real wasm account + real compiler/interpreter at derived
addresses): install/hash/event + doc recovery parity, hybrid re-apply
diff, cap refusal, network binding. Factory: no code change needed
(embedded wasm); probe test proves new accounts expose the doc surface.
- Regenerate @nidohq/smart-account bindings from the apply_doc wasm
  (just bindings smart-account; only src/index.ts changes).
- buildApplyDocTx: one-tx apply mode beside buildDocInstallTxs — submits
  the CANONICAL doc JSON to apply_doc, so the DocApplied event carries
  the canonical form; refuses wrong-network and capped docs before any
  network round-trip (capped docs keep the per-rule install path).
- readPolicy: pure three-tier classifier. (a) doc-verified: event-
  recovered doc matches the stored applied_doc_hash AND its lowering
  matches the live doc-managed rules (scope/name/signers/expiry/
  interpreter attachment, plus each fetched program's committed doc_hash);
  (b) doc-drift: hash verifies but live rules drifted — doc returned with
  the drift list; (c) decompiled: existing decompileRules fallback.
- Tests: tier a/b/c coverage incl. the spec'd legacy-mutation drift case;
  suite: 260 passing.
…eadPolicy

Captain extension to the spike: keep a lossless on-chain copy of the
applied policy document so the showcase can read it without event
history or an indexer (the DocApplied event stays as-is and remains the
eventual recovery method).

- apply_doc persists the full canonical doc JSON in a PERSISTENT entry
  (DOC_JSON) — persistent, not instance, so the KB-scale blob is not
  loaded on every __check_auth; only doc_hash + rule ids stay in
  instance storage. New get_applied_doc view returns it.
- apply_doc now REFUSES non-canonical byte submissions
  (DocNotCanonical, code 17): stored == emitted == canonical by
  construction, so either copy verifies against the stored hash with a
  bare sha256. A flagged divergence from perch's format-agnostic
  compiler; buildApplyDocTx already always submits canonicalJson(doc).
- SDK readPolicy: tier-a doc source is now view-first (storedDocJson
  from get_applied_doc), event recovery is the in-tier fallback; drift
  tier unchanged. Candidates are trusted only if they parse AND hash to
  the stored identity.
- Tests: round-trip through the view (bare sha256 of chain-returned
  bytes == stored hash), view tracks latest apply, non-canonical
  refusal, view-first + corrupt-view-falls-back-to-event SDK cases.
  Bindings regenerated. Suites: cargo workspace green, SDK 262 green.
Captain ruling: drop hybrid. The OZ mutation surface (add_signer,
remove_signer, remove_context_rule, add_policy, remove_policy,
update_context_rule_*, add_multisig_recovery) is no longer implemented
or exported; apply_doc atomically replaces EVERY rule except the
protected zk-recovery rule. The constructor's default passkey rule
lasts until the first apply; the re-imported anti-brick check
(DocAdminLockout) refuses docs without a policy-free self-admin rule.
Caps now install in-contract: CompiledCap lowers onto nido's stock
spending-limit policy at its pinned deployed address (same audited-pin
pattern as the compiler/interpreter); DocCapUnsupported retired.

add_context_rule survives solely as the zk-recovery COMPLETION vehicle,
hard-gated (DocOnlyWritePath) to the completion window: has_pending ||
completion_granted. The grant is new — Policy::enforce consumes the
pending during the completing call's __check_auth, BEFORE the entry
point body runs, so the controller now records a same-ledger TEMPORARY
CompletionGrant at consume time and exposes a completion_granted view.

Tests: unit suite rewritten for the doc-only surface (16 green);
integration tests ported — policy-mechanics suites stage rule shapes
through as_contract library helpers (test-only backdoor), guard e2e
checks moved onto the surviving guarded ops (initiate_upgrade), apply_doc
e2e now proves whole-rule-set replacement, cap install, anti-brick; 89
green; factory 28 green; clippy pedantic clean.
- Regenerate @nidohq/smart-account bindings from the doc-only wasm: the
  rule mutators are gone from the client; add_context_rule (completion
  vehicle), the doc surface, reads, and recovery machinery remain.
- Remove buildDocInstallTxs (the per-rule install route) and its
  DocInstallStep types/tests — apply_doc is the sole apply route.
- buildApplyDocTx: capped docs now ride the single apply_doc tx (the
  contract lowers caps onto its pinned stock spending-limit policy);
  the client-side cap refusal is gone.
- readPolicy: two tiers — doc-verified (view-first, event fallback,
  hash-verified) / decompiled fallback. The drift tier disappears by
  construction: no mutators, no drift.
- policyBlocks: scoped-session-key install/revoke and multisig-recovery
  install/revoke/rotation now THROW doc-only guidance (session keys have
  the doc route: compose + buildApplyDocTx; M-of-N friend recovery has
  NO doc route in doc v1 — headline cost, in the PR description). The
  read-side (fromChain/summarize) still renders existing rules. The
  frontend's remove-context-rule op builder throws the same guidance.

SDK vitest 256 green; frontend build + 408 tests green; cargo gates
green.
Testnet deploy (identity: theahaco, the existing factory admin & registry
name owner):
- smart-account wasm f962dc8e… uploaded on-chain (embed==installed
  verified). The unverified/smart-account registry LABEL is owned by a
  different author key, so the 0.2.0 label publish was refused
  (WasmNameAlreadyTaken) — functional path is the direct upload, exactly
  the deploy script's own fallback; noted in DEPLOYED.md.
- Fresh doc-only factory CCJFOM6UGOH7JSAX22C3FAECG5657HKIUYDBTCMUMILKDA6LOA2J2EGG
  (admin theahaco); registry name 'factory' repointed to it (direct
  registry-contract invoke — the newer scaffold-registry CLI defaults to
  a different root registry than nido's unverified one).
- First zk-recovery testnet deploy, spike params (delay 60s, window 7d,
  max-cancels 2, floor 0): pool CAUZ6WFU… (factory-wired, reached via the
  factory's set_recovery_pool override; not registry-named), UltraHonk
  zk-verifier CDMNKDMP… with the committed depth-24 vk. Deployed via
  plain stellar CLI — the deploy-zk-recovery.mjs DEPLOY_SECRET path can't
  read a Secure-Store identity, and the CLI ctor-args bug it worked
  around is fixed.
- Live probe: create_account minted CBPE5AJY… (genesis leaf 0 inserted);
  the account's interface exposes apply_doc/get_applied_doc/
  applied_doc_hash/doc_rule_ids and none of the legacy mutators; both doc
  views return null (no doc applied yet).
- DEPLOYED.md rewritten for the new topology; passkey-sdk
  REGISTRY_FALLBACKS.factory updated (registry name already repointed;
  fallback fires only when the registry is unreachable). SDK 256 +
  frontend 408 tests green.
…re-bytes e2e

Live testnet failure (captain repro): every apply_doc trapped with
Error(Object, UnexpectedSize) decoding compile_doc's return. Root cause:
the account's wire types came from perch SOURCE rev f5676a6 (6-field
CompiledRule, with cap), but the canonical DEPLOYED compiler — hash
3645bd0d, still the registry's LATEST publish; perch tagged
doc-compiler-v0.2.0 but never published a newer wasm — is an older build:
5-field CompiledRule, no cap lowering, and CapUnsupported=6 in its error
enum. The pins were right; the source-vs-artifact skew was the bug, and
the e2e masked it by registering the NATIVE source-rev compiler at the
derived address.

Fix, aligned to ground truth:
- contracts/smart-account: drop the perch-doc-compiler crate entirely;
  mirror the DEPLOYED wasm's spec locally (CompiledDoc/CompiledRule/
  RuleScope/DocCompilerError incl. CapUnsupported; install as raw
  Vec<Val> passthrough — no interpreter type surface to mirror).
  Cap install branch removed (the deployed compiler refuses capped docs);
  DocCapUnsupported=10 returns as the relay of CapUnsupported. Cap
  support resumes when perch publishes the cap-capable compiler (the
  lowering branch is in this branch's history).
- Workspace: perch git deps reduced to perch-ir only; the theahaco
  stellar-accounts [patch] is GONE (nothing pulls the fork anymore) —
  that adoption cost disappears with artifact-aligned wire types.
- e2e now registers the FETCHED ON-CHAIN BYTES of the compiler +
  interpreter (fixtures/perch/*.wasm) at the derived addresses, plus a
  live-shape guard test: sha256(fixture) must equal the account's pins.
  The capped-doc test now proves the CapUnsupported refusal against the
  real deployed build. This configuration reproduces the live trap class
  locally.
- factory: Upgradable::upgrade override clears the cached embedded-wasm
  hash (stale-cache-after-upgrade bug found preparing the in-place
  testnet upgrade) + admin-gated refresh_account_wasm_hash for factories
  upgraded from pre-override code; two unit tests.
- SDK: buildApplyDocTx pre-refuses capped docs with the deployed-compiler
  reason; bindings regenerated (smart-account, factory).

Gates: just check/test green (7 apply_doc e2e vs deployed bytes, factory
30), SDK 257, frontend 408.
…ed end to end

- Uploaded account wasm b25e3287… (deployed-artifact-aligned wire types);
  in-place factory upgrade to f5374df1… on the SAME address
  CCJFOM6UGOH7JSAX22C3FAECG5657HKIUYDBTCMUMILKDA6LOA2J2EGG, then
  refresh_account_wasm_hash (the new admin entry) repaired the stale
  embed-hash cache the old code left behind. Registry name + pool wiring
  untouched.
- LIVE PROBE, the captain's exact failing flow: an add-admin-key policy
  document (owner passkey + second admin key on the self-admin rule)
  applied on-chain to fresh account CD5X4AWM…7JQI, signed via the
  wallet's own path (bindings-recorded auth entry + synthetic WebAuthn
  assertion over digest(payload || [0])). Verified live:
  applied_doc_hash 4fb7ae94…, get_applied_doc returns the full canonical
  JSON, doc_rule_ids [2], recovery rule preserved, default rule replaced.
  The previous wasm's UnexpectedSize trap is gone.
- DEPLOYED.md updated (new wasm hash, realignment note, live-probe
  evidence).
Perch's release CI published + deployed the cap-capable doc-compiler
0.2.1 (publish receipt on the perch-doc-compiler-v0.2.1 release):
registry moved to CDX2DMYM…, compiler CDWBJPDM… (wasm 35f248f0…),
interpreter CDR2OTZI… (wasm f63cae53…, same generation). Verified the
receipt's contract id equals the content-address derivation.

- contracts/smart-account: pins bumped to the new registry + both 0.2.1
  hashes; wire types mirror the 0.2.1 spec (6-field CompiledRule with
  cap; DocCompilerError back to 5 variants — CapUnsupported is gone);
  cap lowering restored onto the pinned stock spending-limit policy;
  anti-brick re-checks cap-free admin; DocCapUnsupported retired again.
- fixtures: both perch wasms refetched from the NEW on-chain instances;
  pin-fixture sha guard unchanged (and passing).
- e2e: capped doc installs interpreter + pinned spending-limit on one
  rule, proven against the fetched 0.2.1 build; 7 green.
- SDK: deployment.ts pins moved (new registry, interpreter f63cae53…,
  compiler hash added; golden derived addresses updated); the
  buildApplyDocTx cap refusal is gone.

Gates: just check/test green; SDK 256; frontend 408.
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.
…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.
Transition specification (docs/recovery/TRANSITION_SPEC.md) and an
executable TypeScript reference state machine + adversarial test suite
(packages/recovery-spec/) implementing Stage 1 of the perch/nido recovery
plan in firstmate/data/perch-zk-recovery-scout-p5/follow-up.md §8.

Scope: Stage 1 only (spec + reference model), no contracts or circuits.
Section 7 (pending-activity policy) is modeled as an explicit,
non-defaulted parameter with every candidate branch exercised - it is
recorded as an open gate, not resolved here.
Compares two ways to complete a doc-hash-committed recovery attempt on the
doc-only smart account, per firstmate/data/perch-zk-recovery-scout-p5/
follow-up.md §8 Stage 2: Variant A authorizes the existing apply_doc with no
smart-account code changes; Variant B adds a dedicated complete_recovery
entry point sharing apply_doc's internal pipeline, gated by a value-bound
single-use completion grant instead of the original spike's ledger-scoped
boolean flag (follow-up.md §3.1).

New contracts/recovery-doc-completion crate implements a minimal controlled-
test-authenticator recovery controller for both variants. 15 new integration
tests (crates/integration-tests/tests/it/recovery_stage2_*.rs) drive real
account authorization and a real perch doc-compiler end to end, proving
exact-target installation, single/atomic completion, and that ordinary admin
authorization cannot exercise recovery-only authority under either variant.

docs/recovery/stage2-findings.md recommends Variant A, with the call-ordering
analysis explaining why it needs no completion-grant primitive at all while
Variant B does.
Stage 3 of the staged recovery plan (firstmate/data/perch-zk-recovery-scout-p5/follow-up.md
§8): contracts/recovery-controller (shared controller implementing
guardian-only, ZK-only, and combined evidence paths against Stage 1's
proposal model, completing via Stage 2's Variant A) and
contracts/recovery-verifier (a new, fully constructorless UltraHonk
verifier). circuits/zk_recovery_doc adapts the existing zk_recovery Noir
circuit to bind a target-document hash instead of a raw pubkey, as a
NEW, isolated circuit crate (not an in-place edit — the M1
circuits/zk_recovery module and its 33+ integration tests are
byte-for-byte untouched and re-verified green).

All three modes validated end-to-end against real contracts, including a
real bb-proved UltraHonk proof verified on-chain (recovery_stage3_zk_only.rs,
recovery_stage3_combined.rs) and the full guardian-only lifecycle through a
real apply_doc completion (recovery_stage3_guardian_only.rs), matching
Stage 2's Variant A call-ordering proof.

See contracts/recovery-controller/src/lib.rs's crate doc comment for the
architecture and the canonical "Known limits" list, and
docs/recovery/stage3-measurements.md for proof generation/verification
measurements. just test and just check both green.

Client/SDK work is in progress in a follow-up commit.
The matrix wasn't kept in sync when these two Stage 3 crates were added
(same maintenance gap the justfile's fmt-pkgs list already had for
recovery-doc-completion, fixed in the previous commit).
packages/passkey-sdk/src/recoveryStage3/: enrollment config builders,
target-document construction + diff (lost-key vs compromise, per
follow-up.md §4.2), attempt/evidence builders for all three modes, read
wrappers, and a JS reimplementation of the Rust contract's
compute_doc_auth_hash (parity-tested against the same pinned zk.rs
fixture, cross-validating the whole ZK path end to end).

scripts/generate-recovery-proof.mjs: a Node CLI shelling out to nargo/bb
for proof generation (no in-browser/mobile proving — a named limit, see
the crate doc comment) — verified for real against the pinned
circuits/zk_recovery_doc fixture: computed root/nullifier/auth_hash and
VK sha256 both matched the committed Rust-side values exactly.

packages/frontend/src/pages/security/recover-v3/: a plain, linear
experimental page (enroll -> status -> begin attempt w/ diff preview ->
evidence -> complete), reusing existing signing infrastructure
(signAndSubmit, walletConnect.ts's kit) rather than inventing a new flow.
Does not touch or modify the existing /security/recover (M1) page.

Contract bindings generated for recovery-controller/recovery-verifier.

Verified: tsc clean, 257/257 passkey-sdk tests, npm run build + astro
check clean for the frontend.
…_hash commitment

Captain live-tested PR 206 and found a real testnet account's Enroll click
was a no-op: `RecoveryController::enroll` only writes the controller's own
storage — nothing ever checked or established the account's own
`recovery_controller` field, so an account already wired to a different
controller (or never wired at all) got orphaned, never-cross-called config.

Adds `packages/passkey-sdk/src/recoveryStage3/accountWiring.ts`
(checkAccountWiring/buildWireAccountTx) and wires it into the recover-v3
page: Enroll is disabled until wiring is confirmed, a "wire account" action
handles the fresh-account case, and a different-controller mismatch blocks
with an explanation instead of writing dead state.

Also investigated follow-up.md §5.5's "reviewable configuration commitment
in the doc" ask and confirmed it's unreachable today: perch's schema is
strict, nido's lowering throws for the one principal shape that could fit,
and the deployed/pinned perch-doc-compiler's wire-level CompiledRule has no
field for an arbitrary policy address at all. Added
RecoveryController::config_hash (sha256(xdr(RecoveryConfig)), on-chain,
recomputable) as an equivalent, independently verifiable substitute, and
documented the doc-embedding finding in the crate's Known Limits.
…-wired to M1

Added a live-testnet Playwright probe
(tests/e2e/testnet/recover-v3-wiring.testnet.spec.ts) for the account-wiring
fix. Running it surfaced something more specific than the wiring check was
written to handle: a BRAND NEW account from the doc-only factory is not
"fresh and unwired" — it already reports recovery_controller() ==
CAUZ6WFU... (the M1 nido-zk-recovery pool) at construction, per DEPLOYED.md's
M2 genesis-insert behavior. So the captain's bug wasn't a one-off
misconfiguration on his test account; it's the universal starting state for
every account this factory has ever minted, and the only way into this
Stage 3 controller today is the real 7-day rule-removal migration.

Updated recovery-controller's crate doc comment and accountWiring.ts's
module doc comment to state this as a confirmed, live-verified fact instead
of a hypothetical case. The probe itself asserts the mismatch-detection path
(the part that IS live-reachable): checkAccountWiring correctly reports
'wired-to-different-controller', Enroll stays disabled, and even a forced
click is refused by the handler's own defensive re-check before any
transaction is built.
Regenerating bindings (needed to add config_hash to the TS client) with a
newer stellar-cli emitted a package.json missing the @nidohq scope
(name: "recovery-controller" instead of "@nidohq/recovery-controller"),
downgraded version 0.1.0 -> 0.0.0, and dropped publishConfig. npm workspaces
no longer recognized it as satisfying passkey-sdk's
"@nidohq/recovery-controller": "^0.1.0" dependency, so any fresh `npm
install` (the PR-preview deploy jobs) tried the public registry and 404'd.
Restored the fields to match every sibling bindings package.
…sh in measurements

Adds two entries to stage3-measurements.md's limits/enrollment-data sections
mirroring what the account-wiring fix and its live probe established: (1)
account wiring is a separate, currently-unreachable-without-a-7-day-migration
precondition from enroll, and (2) config_hash is the on-chain substitute for
follow-up.md §5.5's reviewable-commitment ask, since literal doc embedding is
blocked by the deployed perch-doc-compiler's wire protocol.
…egacy stub)

Second captain live-fail on PR 206: installing 1-of-1 friend recovery on the
REAL /security/ page (not the recover-v3 spike) failed with
"multisig-recovery.buildInstall: doc-only: the account has no rule
mutators; M-of-N friend recovery is not yet expressible as a policy
document" — a stale, inaccurate error from the 201 rework. buildInstall
threw unconditionally for every account regardless of state; the stub's
"doc v1 has all-signers principals only" claim was also stale (threshold
has existed since perch 0.2.0).

Rewrites multisigRecoveryModule to route through Stage 3's
RecoveryController (GuardianOnly mode) instead of the dead stub:

- buildInstall checks account wiring first (checkAccountWiring): wires
  then enrolls a fresh account (two ops, one InvokeHostFunction each,
  submitted sequentially), just enrolls an already-wired account, and
  REFUSES with an accurate error (naming the real 7-day
  initiate_recovery_rule_removal -> execute_recovery_rule_removal
  constraint, not the false doc-schema claim) for an account already wired
  to a different controller.
- Two real, documented implementer choices where the config has no
  scriptable default: baseline_doc_hash is an inert sentinel hash (this
  simplified form never exposes Compromise-mode recovery, the only case
  that field is checked against); pending_activity_policy defaults to
  Freeze, the more conservative of the two options TRANSITION_SPEC.md §10 /
  follow-up.md §7 explicitly forbid a SPEC-level default for (this is an
  implementer default at the UI layer, not a silently-chosen spec default).
- buildRevoke now explains the real constraint (no one-step revoke exists;
  needs the same 7-day migration) instead of throwing the stale doc-only
  message.
- fromChain recognizes BOTH the legacy multisig-policy on-chain-signers
  shape (unchanged, so an already-installed legacy rule doesn't lose its
  only Revoke path from the UI) and the new Stage 3 shape (guardians from
  PolicyState, not on-chain signers — Stage 3's rule is zero-signer
  CallContract(self)).
- policyChainFetch.ts's fetchPolicyState gets a branch for the Stage 3
  controller address, reading RecoveryController::config and shaping it to
  {guardians, threshold} for fromChain.
- New packages/passkey-sdk/src/recoveryStage3/deployment.ts holds the
  canonical testnet controller/verifier addresses (not registry-resolvable
  yet); recorded in DEPLOYED.md with provenance/verification notes.

Live-probed end to end against real testnet
(tests/e2e/testnet/security-recovery-install.testnet.spec.ts): a genuinely
fresh, unwired account (raw-deployed, bypassing the doc-only factory's
universal M1 pre-wiring found while fixing captain issue #1) completes
"Set up recovery" for 1 of 1 friend through the real production form, and
reloading /security/ renders "1 of 1 friend can rotate this account's
signers and rules" — confirming the full wire -> enroll -> render round
trip. Independently confirmed on-chain via `stellar contract invoke
... config` / `recovery_controller` (both match exactly).

just check (fmt + clippy -D pedantic) green; passkey-sdk: tsc clean,
263/263 vitest; astro check + npm run build clean.
Third captain live-fail on PR 206: enrolling ZK recovery first (via
/security/'s "Add ZK recovery") wired the account directly to the M1
nido-zk-recovery pool -- a DIFFERENT controller from the guardian flow's
Stage 3 RecoveryController -- so adding guardian recovery second always hit
the wired-to-different-controller refusal shipped in the previous fix, and
vice versa. On this stack ZK and guardians must be able to coexist on ONE
controller (AuthMode::Combined) regardless of which is added first.

A. New RecoveryController::reconfigure entry point (removes the "no
   reconfigure entry point" Known Limit -- lib.rs's crate doc comment
   explains what replaced it and its remaining bound). Accepts only two
   strictly-additive transitions (GuardianOnly -> Combined, ZkOnly ->
   Combined); every other field must match the stored config exactly or it
   refuses; blocked while has_pending; Profile::Loss needs only
   account.require_auth(), Profile::Protected + existing GuardianOnly needs
   the enrolled guardian quorum's nested require_auth_for_args in the same
   transaction, Profile::Protected + existing ZkOnly explicitly refuses
   (ReconfigureZkEvidenceUnsupported -- a real ZK reconfigure-evidence path
   needs a new circuit auth_hash domain, out of scope here; reusing an
   existing domain was considered and rejected as a cross-domain replay
   hazard). 12 new unit tests cover both transitions, all rejection cases,
   and both profiles.

   Deployed as a NEW testnet instance (v2) -- the constructorless
   controller has no upgrade/admin entry point at all, so the existing
   deployed instance could not gain reconfigure in place. See
   deployment.ts/DEPLOYED.md for the v1 -> v2 addresses and the "explicit
   upgrade = new immutable artifact" rationale.

B. Killed the factory's genesis trap: contracts/factory::create_account/
   create_account_v2 no longer unconditionally wire every new account to
   the M1 pool (recovery_controller: Some(M1), atomic genesis Merkle-leaf
   insert) -- a live probe already proved this made it impossible for any
   fresh account to ever reach Stage 3 without a real 7-day migration.
   Accounts now mint with recovery_controller: None; create_account_v2's
   commitment argument is ignored (kept for ABI compatibility). Redeployed
   the live factory in place (same address, upgrade + refresh_account_wasm_hash)
   and live-verified a freshly minted account reads recovery_controller() ==
   null.

C. Client flows route through Stage 3 consistently: security/index.astro's
   runZkEnrollment and new-account/index.astro's enrollRecoveryPostCreate
   both now check wiring (checkAccountWiring), wire if needed, and
   enroll-or-reconfigure based on RecoveryController::config(account)'s
   actual presence/mode -- instead of enroll_zk_recovery(M1 pool) directly.
   multisigRecoveryModule.buildInstall gained the mirror case: reconfigure
   (adding guardians) when ZK was already enrolled on the target controller.
   Shared enrollment defaults (baseline-doc sentinel, pending-activity
   policy, delay/expiry/max-cancels) extracted to defaults.ts so both flows
   agree on every field reconfigure requires to match exactly.

D. Live-probed both orderings against the real production /security/ forms
   (tests/e2e/testnet/recovery-stage3-combined.testnet.spec.ts) -- caught
   and fixed a real bug in the process: policyChainFetch.ts's
   fetchRecoveryControllerState only returned guardian data for
   GuardianOnly mode, so a Combined-mode account's "N of M friends can
   rotate..." block silently vanished from the Security page even though
   the on-chain config was correct. Both accounts independently confirmed
   via RecoveryController::config() to reach identical Combined state
   (same guardians/threshold/verifier/zk_pool) regardless of order.

just check / just test green (workspace); tsc/vitest/astro check/npm build
green (client).
… fm/nido-perch-policy-integration-n9

# Conflicts:
#	CLAUDE.md
…taged spikes

Consolidation pass (PRs 200/201/202/204/205/206 merging into one branch):
strip "spike"/"Stage N (spike)" framing from titles, top-level docs, and
inline comments now that the whole staged plan is complete and shipping
together, while keeping every substantive technical claim and the
deliberately-open pending-activity decision (follow-up.md §7) exactly as
documented.

- AGENTS.md: reframe "Recovery spec (Stage 1) + completion experiment
  (Stage 2) + end-to-end spike (Stage 3)" as "Account recovery (guardian
  quorum + ZK)" — one section describing three layers, not three gated
  deliverables.
- DEPLOYED.md: drop "spike deploy"/"spike PR"/"spike params"/"spike build"
  qualifiers; the deploys and params are real testnet facts, not caveats
  about throwaway status.
- docs/recovery/{TRANSITION_SPEC,stage2-findings,stage3-measurements}.md:
  reword each doc's "Status: Spike, Stage N... not authorization to proceed"
  gate language (obsolete now that all stages shipped together) into a
  plain description of the document's role in the finished feature.
- 18 "SPIKE (...)"/"STAGE N SPIKE (...)" bracket-marker code comments across
  9 contract/SDK files reworded to state their content directly.
- Remove "captain live-fail #N" / "NNN.msg" firstmate-process references
  from contract doc comments, test file headers, and deployment.ts —
  replaced with a plain description of what each fix actually does.
- packages/recovery-spec/package.json: drop "Stage 1 spike ... Not
  production code" from the description.

just check / just test (workspace) and tsc/vitest/astro check (passkey-sdk,
frontend) all re-verified green after this pass — comment-only + doc
changes, no behavioral diff.
Captain directive for the PR 200/201/202/204/205/206 consolidation: "full
suites green + one live smoke of the combined UI (policy page + a doc
apply)". Adds a real testnet probe driving /account/policy/ (the policy
inspector + doc builder from the policy-document UI work, PR 202) end to
end: load the page for a live account, switch to the Admin keys tab, add a
delegated-key admin rule through the actual form, sign via the account's
own passkey, and independently confirm on-chain via applied_doc_hash() that
a real apply_doc landed and changed the hash. Reloads afterward and checks
the new rule renders — proving the page's read path agrees with what it
just wrote, not just an optimistic in-memory update.

Reuses the already-live ZK/guardian-convergence probe account (no new
deploy needed) and is idempotent against a re-run: if a prior run already
added the same admin key, it skips the add (the contract's own duplicate
refusal proves the same real path) and verifies the current rendered state
instead of asserting a hash change.

Verified live: first run added the admin rule and independently confirmed
the hash change; second run correctly detected the existing rule and
skipped to verification. Passes on testnet-chromium.
@almanax-ai

almanax-ai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Plan expired

Your subscription has expired. Please renew your subscription to continue using CI/CD integration and other features.

@github-actions

Copy link
Copy Markdown

Example dApp preview deployed!

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

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

@github-actions

Copy link
Copy Markdown

Preview deployed!

https://207.nido.fyi

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

@elizabethengelman elizabethengelman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just commenting things that i've gotten to so far. i'll dig back in again tomorrow!

- name-registry
- recovery-controller
- recovery-verifier
- smart-account

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also add:

  • preauth-sweep-policy
  • recovery-doc-completion

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — recovery-doc-completion and preauth-sweep-policy were both missing from the Scout matrix. Fixing this as part of the branch-stack rebuild (recovery-doc-completion lands with the recovery layer; preauth-sweep-policy is an unrelated pre-existing gap I'm fixing alongside it since I'm already touching this file).

doctest = false

[dependencies]
soroban-sdk = { workspace = true, features = ["hazmat-address"] }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a huge deal, but just curious why we need this feature

#![no_std]
#![allow(dead_code)]

//! Stage 2 bounded experiment (`firstmate/data/perch-zk-recovery-scout-p5/follow-up.md`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

follow-up.md doesn't seem to be included in this PR - is this doc comment still relevant?

#![no_std]
#![allow(dead_code)]

//! Stage 3 of the staged Perch/Nido recovery plan

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I fully understand the different stages in this - maybe they are no longer relevant? But if they are, could we have them documented somewhere?

Comment thread contracts/smart-account/src/doc.rs
Comment on lines +1 to +3
//! SPIKE: perch `apply_doc` for the nido smart account — DOC-ONLY (the
//! captain's ruling on this spike: `apply_doc` is the sole policy write path,
//! including for the dapp).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//! SPIKE: perch `apply_doc` for the nido smart account — DOC-ONLY (the
//! captain's ruling on this spike: `apply_doc` is the sole policy write path,
//! including for the dapp).
//! perch `apply_doc` for the nido smart account — DOC-ONLY (`apply_doc` is the sole policy write path, including for the dapp).

//! policy (the stateful cumulative cap the stateless interpreter cannot
//! express) beside the interpreter, resolved from the pinned deployed
//! address — live since perch published the cap-capable compiler 0.2.1.
//! OZ enforces all attached policies (AND).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//! OZ enforces all attached policies (AND).
//! OZ enforces all attached policies (policies are AND-ed).

Comment thread contracts/smart-account/src/doc.rs
// canonical, and either verifies against the stored hash by a bare
// sha256, no client-side canonicalization needed. The SDK's
// `buildApplyDocTx` always submits canonical bytes. (A spike-flagged
// divergence from upstream perch, easy to relax.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(A spike-flagged divergence from upstream perch, easy to relax.)

Just adding this comment to confirm my understanding - i think that this means that this is something that differs in Nido from Perch - perch doesn't care if the submitted bytes are in a canonical form or not. But Nido does, and this is so we can make sure that the Nido's stored doc bytes always hash to nido's stored doc_hash. both need to be in canonical form for that to hold, otherwise a minified document for example could fail its own verification check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's exactly right — canonical-only keeps stored == emitted == canonical as an invariant everywhere, without needing client-side canonicalization to verify. Adopting your phrasing into the comment, it's clearer than what was there.

matches!(r.scope, RuleScope::SelfAdmin)
&& !r.signers.is_empty()
&& r.install.is_empty()
&& r.cap.is_empty()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this check need to check the rules' valid_until?

Comment on lines +78 to +85
// --- `apply_doc` (see `doc.rs`). 10 is the doc layer's own
// refusal; 11–15 mirror perch's `DocCompilerError` variants 1–5 (offset
// +10 so the two error spaces can't collide in this contract's codes);
// 16 is the fail-closed cross-call fallback. ---
// 10 was `DocCapUnsupported` (twice: the hybrid cut's own refusal,
// then the relay of the pre-cap deployed compiler's `CapUnsupported`).
// Compiler 0.2.1 lowers caps, so the code is retired again.
/// Compiler: the submitted document bytes are not UTF-8.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// --- `apply_doc` (see `doc.rs`). 10 is the doc layer's own
// refusal; 11–15 mirror perch's `DocCompilerError` variants 1–5 (offset
// +10 so the two error spaces can't collide in this contract's codes);
// 16 is the fail-closed cross-call fallback. ---
// 10 was `DocCapUnsupported` (twice: the hybrid cut's own refusal,
// then the relay of the pre-cap deployed compiler's `CapUnsupported`).
// Compiler 0.2.1 lowers caps, so the code is retired again.
/// Compiler: the submitted document bytes are not UTF-8.
// --- `apply_doc` (see `doc.rs`). 10 is skipped (see below for why);
// 11–15 mirror perch's `DocCompilerError` variants 1–5 (offset
// +10 so the two error spaces can't collide in this contract's codes);
// 16 is the fail-closed cross-call fallback. ---
// 10 was `DocCapUnsupported` (twice: the hybrid cut's own refusal,
// then the relay of the pre-cap deployed compiler's `CapUnsupported`).
// Compiler 0.2.1 lowers caps, so the code is retired again.
/// Compiler: the submitted document bytes are not UTF-8.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole +10-offset scheme is gone now — apply_doc's error space is composed via soroban-sdk-tools's scerr (ApplyDocError::DocCompiler(DocCompilerError) via #[from_contract_client]) instead of a hand-maintained numeric offset, so there's no "10 is skipped" case to document anymore. Landing as part of the branch-stack rebuild.

@@ -95,6 +149,21 @@ pub enum NidoSmartAccountError {
#[contractclient(name = "RecoveryControllerClient")]
trait RecoveryController {
fn has_pending(e: Env, account: Address) -> bool;
// Doc-only completion: true iff a completion was consumed for `account`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Doc-only completion: true iff a completion was consumed for `account`
// True iff a completion was consumed for `account`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied — simplified to just "True iff a completion was consumed for account", the rest of the comment already covers the mechanics.

Comment on lines +511 to +515
// DOC-ONLY: the `add_multisig_recovery` typed wrapper is REMOVED. It was
// a doc-bypassing rule mutator (install an M-of-N friends rule via the
// stock multisig policy). Doc v1 cannot express M-of-N principals, so
// this product flow has NO doc equivalent yet -- a headline cost of the
// doc-only ruling, recorded in the PR description.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// DOC-ONLY: the `add_multisig_recovery` typed wrapper is REMOVED. It was
// a doc-bypassing rule mutator (install an M-of-N friends rule via the
// stock multisig policy). Doc v1 cannot express M-of-N principals, so
// this product flow has NO doc equivalent yet -- a headline cost of the
// doc-only ruling, recorded in the PR description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied — deleted this tombstone comment entirely. It's also stale now beyond just being noise: once the recovery layer (this same stack's next PR) lands, M-of-N friend recovery IS available again, via the RecoveryController's GuardianOnly mode instead of a raw policy-document rule.

Comment on lines +649 to +685
// -----------------------------------------------------------------
// Variant B: a dedicated recovery completion entry
// point, compared against Variant A (recovery completing through the
// EXISTING `apply_doc` above with no code changes at all) in
// `firstmate/data/perch-zk-recovery-scout-p5/follow-up.md` §8 Stage 2.
// See `docs/recovery/stage2-findings.md` for the full comparison and
// recommendation.
// -----------------------------------------------------------------

/// The Variant B completion vehicle: calls the exact same internal
/// parse/validate/compile/install/commit pipeline [`Self::apply_doc`]
/// uses ([`crate::doc::apply`]) -- shared private pipeline, no separate
/// raw mutator (follow-up.md §5.1). Requires this account's own auth,
/// exactly like `apply_doc`; the recovery controller's zero-signer
/// `CallContract(self)` rule can satisfy that auth (via its `Policy`,
/// gating this exact call -- see
/// `nido_recovery_doc_completion::Policy::enforce`), but so can the
/// account's ordinary admin rule, since `require_auth()` alone cannot
/// tell the entry point's body WHICH rule authorized this specific call.
///
/// That is the one structural difference from `apply_doc`: `apply_doc`'s
/// own guard (`guard_no_pending`, block-while-pending) happens to end up
/// correct no matter which rule authorized the call, because by the time
/// its body runs, a legitimate completion has already cleared the
/// pending as a side effect of its OWN `enforce` -- there is nothing left
/// to distinguish. A DEDICATED, recovery-only entry point needs the
/// opposite polarity ("permit only when completing"), and that state is
/// exactly what `enforce` consumed before this body could read it
/// (`docs/recovery/stage2-findings.md`'s call-ordering section). So this
/// body independently cross-calls the controller's
/// `take_completion_grant`, which returns the target document hash
/// `enforce` bound to THIS exact call (or `None` if this call's own auth
/// did not resolve through the recovery policy at all) -- a single-use,
/// value-bound hand-off, not the general boolean/ledger flag
/// follow-up.md §3.1 and §5.1 warn against: it can authorize nothing
/// beyond the one document it was granted for, and it is consumed the
/// instant this body reads it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be helpful to move this (and all other Variant A vs B description & decision making) into the docs dir, and out of the code. It's super helpful context to keep, but I think if we isolate it to the docs it would make the source code easier to read & maintain going forward.

Suggested change
// -----------------------------------------------------------------
// Variant B: a dedicated recovery completion entry
// point, compared against Variant A (recovery completing through the
// EXISTING `apply_doc` above with no code changes at all) in
// `firstmate/data/perch-zk-recovery-scout-p5/follow-up.md` §8 Stage 2.
// See `docs/recovery/stage2-findings.md` for the full comparison and
// recommendation.
// -----------------------------------------------------------------
/// The Variant B completion vehicle: calls the exact same internal
/// parse/validate/compile/install/commit pipeline [`Self::apply_doc`]
/// uses ([`crate::doc::apply`]) -- shared private pipeline, no separate
/// raw mutator (follow-up.md §5.1). Requires this account's own auth,
/// exactly like `apply_doc`; the recovery controller's zero-signer
/// `CallContract(self)` rule can satisfy that auth (via its `Policy`,
/// gating this exact call -- see
/// `nido_recovery_doc_completion::Policy::enforce`), but so can the
/// account's ordinary admin rule, since `require_auth()` alone cannot
/// tell the entry point's body WHICH rule authorized this specific call.
///
/// That is the one structural difference from `apply_doc`: `apply_doc`'s
/// own guard (`guard_no_pending`, block-while-pending) happens to end up
/// correct no matter which rule authorized the call, because by the time
/// its body runs, a legitimate completion has already cleared the
/// pending as a side effect of its OWN `enforce` -- there is nothing left
/// to distinguish. A DEDICATED, recovery-only entry point needs the
/// opposite polarity ("permit only when completing"), and that state is
/// exactly what `enforce` consumed before this body could read it
/// (`docs/recovery/stage2-findings.md`'s call-ordering section). So this
/// body independently cross-calls the controller's
/// `take_completion_grant`, which returns the target document hash
/// `enforce` bound to THIS exact call (or `None` if this call's own auth
/// did not resolve through the recovery policy at all) -- a single-use,
/// value-bound hand-off, not the general boolean/ledger flag
/// follow-up.md §3.1 and §5.1 warn against: it can authorize nothing
/// beyond the one document it was granted for, and it is consumed the
/// instant this body reads it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and applied — trimmed complete_recovery's doc comment down to a short pointer at docs/recovery/stage2-findings.md (which already has this exact analysis in full, in §3 "Call-ordering analysis"); the code comment now just says what the entry point does and where to read why it needs the grant hand-off apply_doc doesn't.

// `StubRecoveryPolicy` test double -- only ever cross-called against a
// Stage 2 `nido-recovery-doc-completion` controller from
// `complete_recovery`, so no existing controller needs to export it.
fn take_completion_grant(e: Env, account: Address) -> Option<BytesN<32>>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the comment above, it sounds like this fn isnt being used in the current state of the code -can we remove it from the trait?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is used — by complete_recovery (Variant B, see the reply on that entry point below). Kept for the same reason: it's the value-bound grant hand-off Variant B's live comparison test suite exercises. Not removing it alongside complete_recovery for now — same follow-up-cleanup flag as that reply.

Some(controller) => {
let client = RecoveryControllerClient::new(e, &controller);
let me = e.current_contract_address();
client.has_pending(&me) || client.completion_granted(&me)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

client.completion_granted always returns false for the real controller, so in_completion_window here reduces to just has_pending.

I don't think the new recovery system (Variant A) ever calls add_context_rule at all, so this check is opening up add_context_rule for the entire duration a recovery is pending. That seems like risk that we don't need to have for the new recovery system. Is there a reason to keep this open, or can it be removed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on completion_granted always being false for the real controller — that's correct, in_completion_window does reduce to has_pending for Variant A/the Stage 3 controller.

On removing it, though: this gate isn't Variant-A-specific — it predates the recovery-controller entirely. It's add_context_rule's own completion vehicle for the ORIGINAL M1 nido-zk-recovery pool-based flow (rotate in a new passkey via add_context_rule(Default, "recovered", ...)), which is still a live, supported opt-in recovery path alongside the new controller (see DEPLOYED.md — new accounts mint with recovery_controller: None, and users can opt into either the old M1 pool flow or the new Stage 3 controller). That flow genuinely needs add_context_rule open during its own completion window, so this isn't something Variant A's arrival lets us close.

It IS a real, already-documented risk though — contracts/recovery-controller/src/lib.rs's crate doc comment ("Known limits") calls this out explicitly: any has_pending-reporting controller (including this one) makes an ordinarily-admin-authorized add_context_rule call possible for the whole pending-recovery duration, and closing it needs a smart-account code change — which is out of scope for Variant A's "zero smart-account changes" property. Not closed in this PR, but not silently accepted either — it's in that doc comment's limits list. Happy to open a tracking issue for it if that's useful.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha - i think i missed the fact that we were keeping the original nido-zk-recovery live as an optional thing. Makes sense that that flow needs add_context_rule.

I think that an issue would be helpful for tracking this!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@willemneal I just created an issue, feel free to update as needed!
#214

Comment on lines +11 to +14
/// Which evidence factor(s) an account's recovery requires. Follow-up.md
/// §5.2's HARD requirement: `GuardianOnly` must not require ANY ZK
/// machinery — no secret, no Merkle witness, no proof. Enforced at `enroll`
/// (see `lib.rs`), not just documented here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Which evidence factor(s) an account's recovery requires. Follow-up.md
/// §5.2's HARD requirement: `GuardianOnly` must not require ANY ZK
/// machinery — no secret, no Merkle witness, no proof. Enforced at `enroll`
/// (see `lib.rs`), not just documented here.
`GuardianOnly` must not require ANY ZK
/// machinery — no secret, no Merkle witness, no proof. Enforced at `enroll`
/// (see `lib.rs`), not just documented here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text no longer exists — the follow-up.md citation was already stripped by an earlier sanitization pass (now reads "is a HARD requirement: it must not require ANY ZK machinery..." with no external-doc citation, matches your suggestion's substance).

Comment on lines +23 to +27
/// Follow-up.md §2.1: routine recovery-CONFIGURATION change authority (not
/// modeled by this experiment — no `reconfigure` entry point exists, see the
/// crate doc comment's "Known limits" — this field is retained on
/// `RecoveryConfig` because it is part of the account's reviewable
/// commitment even though this experiment fixes it at enrollment).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this needs to be included here

Suggested change
/// Follow-up.md §2.1: routine recovery-CONFIGURATION change authority (not
/// modeled by this experiment — no `reconfigure` entry point exists, see the
/// crate doc comment's "Known limits" — this field is retained on
/// `RecoveryConfig` because it is part of the account's reviewable
/// commitment even though this experiment fixes it at enrollment).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text is gone for a different reason than just wording — it's now stale rather than removable-as-noise: reconfigure/config_hash shipped in the recovery-controller's v2 (see DEPLOYED.md), so "no reconfigure entry point exists" is no longer true. The Profile doc comment now explains what Loss vs Protected actually requires for reconfigure instead.

Comment on lines +256 to +261
/// TEMPORARY: value-bound completion marker, written by `enforce` the
/// instant it consumes an attempt. Not read by this experiment's
/// Variant-A-only completion path (Variant A needs no such bridge — see
/// `docs/recovery/stage2-findings.md` §3) but kept so a future Variant B
/// vehicle could reuse it without a storage-key ABI break.
CompletionGrant(Address),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove this because it seems to be for variant b?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right that it's for Variant B — same as take_completion_grant/complete_recovery above, it's the completion signal Policy::enforce writes and take_completion_grant reads-and-deletes. Kept live for the same reason (grounds the Stage 2 comparison in real, tested code); flagged as the same follow-up cleanup.

Comment on lines +1 to +9
//! The Stage 3 shared recovery controller (follow-up.md §5.2): ONE
//! deployed instance, shared across accounts, dispatching to a
//! guardian-quorum adapter and/or a ZK-verifier adapter per account's
//! enrolled `AuthMode` — `Combined` checks BOTH against the SAME attempt
//! commitment before promoting it. Completion is Variant A
//! (`docs/recovery/stage2-findings.md`'s recommendation): this contract's
//! `Policy::enforce` gates the account's EXISTING `apply_doc` entry point,
//! zero smart-account code changes, same call-ordering argument Stage 2
//! already proved (see `enforce`'s doc comment below).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//! The Stage 3 shared recovery controller (follow-up.md §5.2): ONE
//! deployed instance, shared across accounts, dispatching to a
//! guardian-quorum adapter and/or a ZK-verifier adapter per account's
//! enrolled `AuthMode` — `Combined` checks BOTH against the SAME attempt
//! commitment before promoting it. Completion is Variant A
//! (`docs/recovery/stage2-findings.md`'s recommendation): this contract's
//! `Policy::enforce` gates the account's EXISTING `apply_doc` entry point,
//! zero smart-account code changes, same call-ordering argument Stage 2
//! already proved (see `enforce`'s doc comment below).
//! Shared recovery controller: ONE
//! deployed instance, shared across accounts, dispatching to a
//! guardian-quorum adapter and/or a ZK-verifier adapter per account's
//! enrolled `AuthMode` — `Combined` checks BOTH against the SAME attempt
//! commitment before promoting it. This contract's
//! `Policy::enforce` gates the account's EXISTING `apply_doc` entry point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text already matches your suggestion almost verbatim — an earlier pass already dropped the "Stage 3"/follow-up.md framing from this module doc comment.

@willemneal

Copy link
Copy Markdown
Contributor Author

Splitting this into a stack of four focused PRs, each standalone and referencing stellar-registry/perch#83 where the perch-side feature matters — reviewing as one unit was asking a lot of any one PR:

  1. feat(sdk): Perch PolicyDoc layer — build, lower, decompile #209 — feat(sdk): Perch PolicyDoc layer — build, lower, decompile
  2. feat(contracts): doc-only smart account — apply_doc as the sole policy write path #210 — feat(contracts): doc-only smart account — apply_doc as the sole policy write path (stacked on 1)
  3. feat(frontend): policy page, doc builder with diff preview, and dApp session flow #211 — feat(frontend): policy page, doc builder with diff preview, and dApp session flow (stacked on 2)
  4. feat(contracts): account recovery — guardian, ZK, and combined evidence paths #212 — feat(contracts): account recovery — guardian, ZK, and combined evidence paths (stacked on 3)

Every comment on both review passes here has a disposition recorded on its own thread — fixes landed in the layer that owns the content, a few were already moot by the time of the second pass (superseded by other fixes in the same rebuild), and the couple of open architectural questions (whether the Variant B completion machinery should eventually come out of production code, and the add_context_rule completion-window's pre-existing shared risk) got real answers rather than silent drops — see the thread replies for both. CI is green on all four. Closing this in favor of the stack.

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.

2 participants