Skip to content

docs: propose the Zaino driving port design#1414

Open
zancas wants to merge 1 commit into
devfrom
docs/driving-port-proposal
Open

docs: propose the Zaino driving port design#1414
zancas wants to merge 1 commit into
devfrom
docs/driving-port-proposal

Conversation

@zancas

@zancas zancas commented Jul 17, 2026

Copy link
Copy Markdown
Member

What this proposes

This pull request presents the design of Zaino's driving port — the contract through which consumers drive Zaino — as a code-free proposal, so the design can be discussed and judged on its own rather than inside an implementation diff. It adds one directory, docs/driving-port/, holding two kinds of document:

  • CONTEXT.md — the glossary that fixes the port's language: thirteen terms (Driving port, Snapshot, Conformance kit, Block locator, Reported upgrades, Broadcast, Tip event, Fork point, Treestate, Transaction status, Spend status, Mempool view, Transient failure), each with the meanings we committed to and the near-synonyms we deliberately avoid.
  • Three ADRs (architecture decision records) recording the load-bearing decisions, each with its rejected alternatives.

The port spans snapshot-pinned chain reads, an explicit chain tip-change subscription, a mempool stream that stands apart from chain state, and transaction broadcast. Zallet is its first driver; zainod-for-lightclients is its expected second.

The three decisions

  1. ADR 0001 — Decouple the mempool from chain state. Zallet today learns of a new block through a side effect: Zaino's mempool stream ends when the tip changes. The port retires that idiom. It provides an explicit tip-change subscription, and its mempool stream survives tip changes, tagging every delivery with the tip it was validated against; drivers compose the two by resubscribing on tip events.
  2. ADR 0002 — Raw consensus-serialized bytes at the boundary. Payloads (blocks, transactions, treestate frontiers) cross the port as bytes; only identifiers and locators are typed, drawn from zaino-primitives. Consumers already parse consensus bytes into their own types, and typing payloads with zcash_primitives would put Zaino's engines in version-lockstep with the wallet stack.
  3. ADR 0003 — Snapshot pinning is unconditional. Every read through a snapshot observes the chain as of the pinned tip, and that data stays readable while any clone of the snapshot lives — across reorgs, without exception. The rejected alternative (a Lapsed answer engines may give when they have forgotten a pinned view) would export the engine's storage policy into every driver's sync loop.

How the design was pressure-tested

A complete first-draft implementation was built alongside these documents and then put through a structured review: eight independent finder passes (three correctness angles, three cleanup angles, altitude, and conventions) followed by adversarial verification of all 35 deduplicated candidates. The draft — a contract crate with a scriptable in-memory mock and a conformance kit that is the executable half of this contract — lives on the docs/driving-port-design branch and is deliberately not part of this pull request.

All ten ranked findings of that review are implemented in the draft. The ones that fed back into the design itself:

  • The conformance kit now enforces ADR 0003 across all thirteen snapshot capabilities, using harness-supplied handles (an address, an outpoint, a mined txid) for the surfaces the kit cannot fabricate — closing the gap where an adapter answering address queries from the live UTXO set would have passed the kit while serving torn reads.
  • Every liveness promise in the kit is held to a deadline that panics naming the broken promise, so a nonconforming adapter fails loudly instead of hanging an adapter developer's CI.
  • The driver-side mempool view is contractually a superset trued up by resubscription: the stream signals arrivals only, and mempool presence is a hint, never authoritative (glossary: Mempool view).
  • Payload newtypes share their bytes (cloning is O(1)), and the unspent-outpoints capability takes a height range, because both are contract shapes every real engine inherits.

Open questions for this discussion

The review surfaced five design questions we deliberately left unresolved for this proposal:

  1. Crate name. The draft crate is zallet-driving-port, but the glossary defines the port as the contract through which consumers drive Zaino — a port belongs to the hexagon, not to its first adapter. Rename to zaino-driving-port?
  2. Vocabulary hoisting. The port's BlockId, Outpoint, and treestate types structurally duplicate existing workspace types (zaino-state's BlockIndex, two existing outpoint shapes, and zaino-primitives' own Treestate/TreeBytes). Should the shared vocabulary be hoisted into zaino-primitives, which both sides already depend on?
  3. Backend error structure. BackendError carries only a retry classification and a message; adapters must stringify structured transport errors. Adding a source chain preserves structure but costs the PartialEq/Eq derives.
  4. Empty domain-error enums. Twelve capabilities declare empty per-capability error enums. The review's verifier defended them (real semver headroom, grep-able names) against consolidation into a shared uninhabited type; we lean toward keeping them, but it deserves a decision.
  5. Mock helper visibility. The mock's scripted-chain accessors are pub with no in-repo external caller — plausibly the right testing API for out-of-tree adapter tests, but formally wider than anything requires.

Relationship to code

No code changes in this pull request. The reference draft (crate, mock, conformance kit, 33 passing tests) is on docs/driving-port-design for readers who want to see the contract executed; it will be proposed separately once this design settles.

🤖 Generated with Claude Code

This commit carries the driving-port design corpus, apart from any code,
so the design can be judged on its own: the CONTEXT.md glossary that
fixes the port's language, and the port's three architecture decision
records. ADR 0001 decouples the mempool from chain state, retiring the
stream-closes-on-tip-change idiom in favor of an explicit tip-change
subscription. ADR 0002 sends payloads across the boundary as
consensus-serialized bytes, typing only identifiers and locators from
zaino-primitives. ADR 0003 makes snapshot pinning unconditional: an
engine must retain a pinned view while any clone of the snapshot lives.

The design was pressure-tested by a draft implementation and a
structured review of that draft; the executable half lives on the
docs/driving-port-design branch and is deliberately not part of this
proposal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zancas
zancas requested a review from nachog00 July 17, 2026 22:29
@idky137
idky137 self-requested a review July 21, 2026 16:52

@idky137 idky137 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think there are some incorrect statements in docs/driving-port/0001-decouple-mempool-from-chain-state.md that need to be addressed, I think we should also consider not tying zallet into a set configuration and providing a modular system for them to use.

@@ -0,0 +1,28 @@
# Decouple the mempool from chain state in Zaino's driving port

Zallet today learns of a new block through a side effect: Zaino's mempool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not a side effect, it is an API that was specifically designed and agreed upon in both zallet API meetings and the lightclient working group calls. This is misleading.

stream ends when the chain tip changes, and the sync loop treats that closure
as its only new-block signal (zcash/zallet,
`zallet-core/src/components/sync.rs:136`). We decided that the driving port
Zallet will consume from Zaino must not carry this coupling. The port serves

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is confusing to me, the mempool requires this functionality in some form, even if not for zallet then for the light-client interface. #1416 separates this functionality from the core mempool service so that users that dont want it are not forced to use it.

If we are separating the mempool from the ChainIndex / ChainView as discussed then why would we tie zallet into any specific configuration? It should be up to the zallet devs to decide whether they want just the MempoolService or the CoherentMempoolService.

the protocol itself imposes no structural relation between them: a transaction
commits to no block hash. Consensus binds a transaction only to chain
*state* — shielded anchors, nullifier non-membership, transparent prevouts,
expiry height, and the consensus branch id — so a mempool view is coherent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think there is a bit of confusion about the issue / bug that lead to the greater tip tracking we have, it is not that a transaction must be tied to a particular block hash, the issue is that the mempool must be correct for the chain being served.

The first problem is, if a reorg occurs and the mempool and non-finalised state are out of sync, transactions could be missing completely (not shown in mempool or chain), or shown twice (both in mempool and chain), the fix that #1416 adds for the coherentmempool (freeze until the two re-agree) fixes this race condition.

The second is that clients that use the get_mempool_stream method as intended see a separate race condition: non-finalised state serves the chain_tip to clients, they then call get_mempool_stream with the given chain tip, and if out of sync with the mempool, return none, also fixed in #1416.

The port therefore provides an explicit chain tip-change subscription, and its
mempool capability is an independent stream whose view is tagged with the tip
it was validated against. Zallet composes the two: on a tip event it resyncs
and resubscribes to the mempool. This costs Zallet a sync-loop rewrite during

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

See above, this is not just a Zallet API, it is also a part of the lightclient interface.


## Considered Options

We rejected canonizing the closure idiom (keeping "stream ends on tip change"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No we have not. we must keep this behaviour, just separate from core mempool functionality.

@@ -0,0 +1,106 @@
# The Zallet driving port

@idky137 idky137 Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we sure we want a single bespoke driving port for zallet? what are the benefits / disadvantages vs providing generic, functionality based driving ports?

Eg. providing MempoolService, ChainView, BlockChainSource, etc and letting Zallet use what they want how they want, rather than tying them into a set configuration.

@zancas
zancas requested a review from idky137 July 21, 2026 17:46
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