Skip to content

feat(cluster-tool): per-producer finalizer keys, genesis producer registration, and the producer-registration flow (WIRE-367) - #94

Merged
heifner merged 16 commits into
masterfrom
feature/wire-367-producer-registration
Sep 11, 2026
Merged

heifner merged 16 commits into
masterfrom
feature/wire-367-producer-registration

Conversation

@heifner

@heifner heifner commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Part of WIRE-367. Gives the harness what it needs to stand up a real block producer, and adds the flow that drives one from a fresh account to producing blocks and back.

Per-producer BLS finalizer keys

The harness minted one K1+BLS set per producer node, shared by every producer account on it. regfinkey enforces a global duplicate-key check, so those accounts could never all register — meaning update_ranked_producers never published, and no harness cluster had ever run on a ranked schedule.

BLS keys are now per producer account (OperatorAccount.wireFinalizer); the K1 block-signing key stays node-shared, having no uniqueness constraint. NodeopProcess takes operators: OperatorAccount[], emits one --producer-name plus its BLS --signature-provider per hosted producer, and builds the genesis finalizer policy from producer keys. Since that K1 is shared by construction, NodeopProcess.create refuses a node whose hosted accounts don't all carry the same one.

Bootstrap ordering

Three constraints, each of which cost a failed flow run to find, now pinned by tests in ClusterBuildDefaultsGenesisProducers.test.ts:

  1. ProducerIdentities before ProducerNodes — a producing node renders one --signature-provider per hosted account at launch, so the keys must exist by then. Symptom: ClusterKeyStore: operator "defproducera" has not been provisioned.
  2. Finality before SystemContract — bios::setfinalizer stops existing once the system contract replaces bios code. Symptom: Unknown action setfinalizer in contract sysio.
  3. regoperator for genesis producers waits for OPPConfig — sysio.opreg isn't deployed until OPPContracts.

Plus one non-ordering trap: regfinkey bills its rows to the producer, and neither route to a producer account leaves room. Steps.consensus.planGrantProducerRam grants it, keyed by handle because a sponsored producer's on-chain name is generated at run time. Symptom: Account using more than allotted RAM usage.

New surface

  • SystemContractSteps — planRegproducer, planRegfinkey, planActfinkey, planUnregprod, planSetscorecfg, planSetacctram, on the generated action types.
  • ProducerNodeTool — spawns an ad-hoc producing node outside NodeConfig.plan, on a port pair reserved up front (below).
  • Ad-hoc ports are reserved with every planned node, not picked at spawn. ClusterTopologyOptions.adHocCount (default 0 — a flow asks for exactly the nodes it starts) makes resolve() claim the pairs alongside producers/batch/underwriters, and allPorts walks them, so they reach registerResolved and validate like any other node's. claimAdHocPorts(bind, label) hands out a slot and returns the SAME pair when a label asks again, so a stop/restart rebinds where it was. Previously the pair was picked when the node spawned: that took the host-global lock and honoured the registry, but never REGISTERED the claim, leaving it invisible to a parallel resolver during the window before the daemon bound — a real collision source at the concurrency these run under. A COMPLETE --bind-config now declares the count, since cardinality is cross-checked per role.
  • WireOperatorProvisioningTool — a PRODUCER without producerNodeIndex routes to the collateral-backed OPP-operator path (own keys, sponsored account, ETH+SOL funding, authex links, regoperator). Naming both producerNodeIndex and ethereumHdIndex is refused: the two routes are mutually exclusive.
  • ProtocolTiming.ScheduleRebuildIntervalMs — the flow's schedule and demotion deadlines derive from the 120-slot rebuild interval rather than from producer rotations alone.

SSM key custody

A producer node now publishes nothing under its own name. Every --signature-provider spec renders from an account's publicationLabel, and the producer-account rows already republish the shared K1 under each account's label — so the node_NN parameters had no reader and were writing live signing keys into every replication region for nothing. Removing them leaves SignatureKeySource single-valued and nodeIndex write-only, so both go.

The cross-check test asserted [K1, BLS] per producing node — an assumption, and the wrong one. It now derives the expected ids from what buildArgs renders. These steps compose only under --signature-provider-type SSM, which the e2e gate does not exercise, so the unit tests are the whole safety net.

Two gaps under SSM, both invisible in the default KEY mode: a flow-provisioned operator's keys were never published, because the bootstrap walker enumerates identities from config and a flow's label isn't there; and an existing parameter was retained on sight, so a stale value would have the daemon signing with a key the chain never saw. Provisioning now publishes its own labels through the same renderer, and the publish step compares the stored value in canonical native form and fails rather than adopting a stale copy. A parameter is still never overwritten.

The flow

flow-producer-registration, 9 phases: provision → registered-but-unbonded never enters the schedule → ETH deposit → SOL deposit → start node → enters the schedule and produces → missed rounds drive demotion → the schedule drops it and regproducer pardons it → it serves rounds without falling back → withdrawing below the minimum removes it.

The pardon assertions follow wire-sysio's schedule-gated recovery: regproducer is refused as a pardon while the producer still holds a slot, and once the schedule has dropped it there are no rounds left to serve, so the pardon clears the miss streak along with the demotion. Assertions read the active schedule via get_producer_schedule rather than head_block_producer, and the exit check requires the demoted producer absent while the schedule still holds only genesis producers at or above MIN_SCHEDULE_SIZE. Picked up by dynamic discovery, so no workflow edit.

CI

Build and test is red here until Wire-Network/wire-libraries-ts#80 merges and sdk-core is published. This repo resolves @wireio/sdk-core from the registry, and the published 1.0.89 predates the WIRE-367 ABI — every error is a missing setscorecfg, rank_score or standby_bps. No code change here fixes it, and none is needed: the e2e gate builds libraries-ts from source and is green. See the pinned comment for the unblock order.

Validation

pnpm build, pnpm run lint, and the unit suites. e2e gate on the current heads: 34474238210, scoped to this flow. A full gate is still owed before merge.

Merge order

Last. After Wire-Network/wire-sysio#599 and Wire-Network/wire-libraries-ts#80.

…istration, and the producer-registration flow (WIRE-367)

A cluster could never publish a ranked schedule. `update_ranked_producers`
schedules only producers with an active finalizer key, and every producer on a
node shared one — `regfinkey` enforces global key uniqueness, so only the first
could ever register — while genesis producers got an account and nothing else:
no `regproducer`, no `regfinkey`, no opreg row. Every flow ran on whatever
`setprodkeys` stamped at bootstrap.

Each producer ACCOUNT now owns a BLS finalizer key (the block-signing K1 stays
node-shared; `setprodkeys` maps every producer on a node to it). That turns
`NodeopOptions.operator` into `operators: OperatorAccount[]` — one
`--producer-name` and one finalizer provider per hosted account — and moves the
genesis finalizer policy onto those accounts, because
`update_ranked_producers` rebuilds it from exactly those keys and a node-keyed
policy would be replaced by one the node holds no key for: LIB freezes and the
pending schedule never activates.

Bootstrap ordering follows from that. Identities are materialized in a new
`ProducerIdentities` phase before any node starts, since a producing node
renders its accounts' signature providers at launch (and, under SSM, fetches
them — so their publication moves to the beforeNodes phase). `Finality` stays
ahead of `SystemContract`, because `bios::setfinalizer` stops existing once the
system contract replaces the bios code. `GenesisProducerRegistration` grants RAM
then registers each producer and its key; the opreg half waits for
`GenesisProducerOperators`, after sysio.opreg is deployed and configured.

New: `Steps.contracts.sysio.system.plan{Regproducer,Regfinkey,Actfinkey,
Unregprod,Setscorecfg,Setacctram}` (each producer action signed by the producer
its own data names, derived rather than carried beside it);
`Steps.consensus.plan{RegisterProducer,RegisterFinalizerKey,GrantProducerRam}`
for the keys-from-the-store paths; and `ProducerNodeTool`, which starts and
stops an ad-hoc producing node the way `OperatorDaemonTool` starts a daemon.
`WireOperatorProvisioningTool` routes a producer with no `producerNodeIndex`
through the collateral-backed path — sponsored account, authex links,
`regoperator`, plus its own finalizer key.

`flow-producer-registration` drives a fresh account from provisioning to
producing blocks and back out: registered-but-unbonded stays UNKNOWN and
produces nothing, both bonds flip it ACTIVE, it enters the ranked schedule and
produces, its node is stopped until demotion fires at exactly the threshold,
`regproducer` recovers it, and withdrawing the bond takes it out again.

Change-Id: I5a92bc26a7483b0f1f1b00dde56248bf1536de13
…eads, schedule-based flow asserts (WIRE-367)

Review fixes on #94:
- NodeConfig.createAdHoc / adHocNodeName / peerEndpointFor /
  producerPeerEndpoints / AdHocIndex / NodeNamePrefix: the one composition
  OperatorDaemonTool and ProducerNodeTool share. BindConfigProvider.
  findAvailableAdHocPorts with DefaultAdHocHttp/P2p in the daemon layout (the
  old 8988/9976 sat inside the reserved agave band, so the preference never
  applied).
- WireClient.activeAuthorization is the one spelling of <account>@Active;
  getProducers and getProducerSchedule are typed reads.
- ConsensusSteps runners resolve from the key store and delegate to the
  SystemContractSteps runners; Steps.consensus.ProducerRamBytes is the one
  RAM grant; KeySteps.keyGeneratorContext replaces four hand-built contexts.
- Constants.MIN_SCHEDULE_SIZE; ProtocolTiming.BlockIntervalMs /
  ProducerRepetitions / producerRotationMs / OutpostWriteBudgetMs;
  utils/producerRankUtils decodes the tier off rank_score.
- flow-producer-registration installs setscorecfg, asserts on the ACTIVE
  schedule via get_producer_schedule instead of head_block_producer, and
  checks the healthy tier off rank_score.
- EMISSION_CONFIG_DEFAULTS carries standby_bps: 800.
- Tests for every new or changed symbol, the two-producer NodeopProcess argv,
  the producer node stop runner, the identity phase, and the collateral-route
  producer; the ConsensusSteps runner tests live in ConsensusSteps.test.ts.

Change-Id: I9b98755b5e348f8c88e1d75b9ead9206d271e8c7
… deadlines, SSM publication, hosted-producer key guard (WIRE-367)

- ProtocolTiming.ScheduleRebuildIntervalMs (60 s) joins the flow's schedule
  and demotion deadlines: the ranked schedule is rebuilt at most once per 120
  slots, so a deadline sized on rotations alone undershot by one rebuild tick.
- verifyProducerLeavesSchedule asserts the demoted producer is gone AND that
  the schedule holds only genesis producers with at least MIN_SCHEDULE_SIZE
  entries; the row read after demotion is null-guarded.
- WireOperatorProvisioningTool: a collateral-backed PRODUCER's phase reports
  under the Producer actor, defaults to a non-bootstrapped registration, and a
  spec naming both producerNodeIndex and ethereumHdIndex is refused.
- NodeopProcess.create refuses a node whose hosted producer accounts do not
  share one block-signing K1.
- SSM: a flow-provisioned operator's freshly materialized keys are published
  by its own provisioning phase (K1/EM/ED, plus BLS for a producer) through
  the same renderer the bootstrap walker uses, for labels the bootstrap does
  not publish itself; runPublishSignatureProviderKey refuses an existing
  parameter that holds a different key instead of retaining a stale copy.
- Stale comments in ClusterState, ClusterBuild, and the external-config test.

Change-Id: I548be9406604c0ad4877f6f26fdc606a0924a875
…tscorecfg (WIRE-367)

`prodscorecfg` gained the rolling window a producer's miss RATE is measured
over and the percentage limit that goes with it, mirroring the batch-operator
availability gates. Both are required fields on the generated action type, so
every payload that installs a score config carries them.

The flow's snapshot weight now matches what the contract ships, a tenth of
collateral rather than parity, so the config it installs stays representative
of production. It changes none of the flow's assertions: no producer in it
holds an attestation, so the factor is zero either way.

Worth knowing for the next ABI change here: the first `pnpm build` after
regenerating passed while this config was still missing both new fields,
because `tsc -b` kept a stale output for the dependent package. `pnpm clean`
first, or the error arrives as a runtime serialization failure in the gate
instead of a compile error.

Change-Id: Id9290f06f512a0bdb6150e41c538bdea3b47b180
…t the old one (WIRE-367)

The flow asserted that `regproducer` clears both the demotion AND the miss
streak. That was the old behaviour; the streak now deliberately survives
re-registration, because re-registering costs nothing but a signature and can
be repeated, so clearing it there would let an absent operator call it on a
timer and never produce a block. The gate caught this as a failed
`demotion-cleared` step -- the assertion was encoding a contract the contract
no longer offers.

It now checks both halves of the rule, which is stronger than what it replaced:
re-registration must clear the demotion and must NOT clear the streak, and the
block the producer subsequently makes is what clears it. That second half is a
new step, so the flow now proves the recovery path end to end rather than
assuming it.

Change-Id: I7d6ef7550a2e8e5ee36c83e4f6f4d31ab1e2ea79
…identity (WIRE-367)

`min_blocks_per_round` rides `setscorecfg`. The producer-registration flow sets it
to 0 -- the disabled spelling -- because it demotes by stopping a node outright, so
every round it charges is a whole round with no blocks at all; leaving the check
armed would make the assertions depend on how many blocks the node happened to land
before it died.

`NodeopProcess.create` now asserts the hosted operators ARE the node's producers,
not merely that there are the same number of them. `buildArgs` renders
`--producer-name` from the operators while the key store, the SSM secret ids and
start.sh all read `node.producers`, so matching counts alone let a caller hand over
the right number of the wrong accounts -- or the right accounts in a different
order -- and the node would sign for producers it holds no slot for, silently.
`NodeConfig.createAdHoc` now stores the key-store LABEL there, as `plan` does; an
on-chain account in that field reads as a label that resolves to nothing.

`producerTier` refuses a tiered `rank_score` rendered as a number rather than
decoding a rounded one. Every non-healthy tier sets bit 62 or 63, so such a value is
far past the safe-integer range: the demoted tier's `unscored()` rounds UP across
the tier boundary and reads as a tier the contract never packs -- on a correctly
demoted producer, which is exactly what the flow asserts on.

Change-Id: Ice0bdfb4a39f1eb6823e5a5d99fbb63c3030bfc7
…abel alone (WIRE-367)

The identity assertion added with the node/operator pinning rejected the BIOS
node and took every e2e flow down with it: `node.producers` is not uniformly one
identifier. A planned producer node carries key-store labels -- and a producer
never calls `roa::newuser`, so its label IS its account -- while the bios node
carries the ON-CHAIN genesis producer name `sysio` under an operator labelled
`node_bios`. Demanding labels failed at `start-bios`, which aborted every
downstream phase, so all fifteen flows died in about two seconds without
reaching any of their own assertions.

Each producer name is now matched by an operator on EITHER its label or its
account, which still catches the defect the assertion exists for -- the right
NUMBER of the wrong accounts, or the right accounts in a different order.

The unit tests missed this because the `producerOperator` fixture sets
`label = account`, so every shape exercised had the two identifiers identical --
precisely the condition under which the bug is invisible. Two tests now pin the
shapes that differ: the bios node is accepted with `label != account` and still
renders `--producer-name sysio`, and an operator matching by neither is rejected.

Change-Id: Ia77daf62129812fc400d8ce530ed2f3b0ec88972
… (WIRE-367)

Under SSM the bootstrap wrote a live BLS private key into every replication
region for each producer node, at `/wire/<cluster>/node_NN/BLS`, that no daemon
ever read. Finality moved to per-ACCOUNT finalizer keys in this work -- a shared
node key cannot pass `regfinkey`'s global uniqueness check -- and both former
readers moved with it, but the publishing side did not.

Tracing what a node actually resolves showed the whole node identity was dead,
not just its BLS half. `buildArgs` renders every `--signature-provider` spec from
an ACCOUNT's `publicationLabel`: the block-signing K1 from the first hosted
account, one finalizer BLS per hosted account. A `node_NN` parameter is fetched
by nobody, and the producer-ACCOUNT rows already republish that same K1 under
each account's own label -- which is the point of publishing per identity. The
bios node is not an exception: its identity is an operator row whose label IS the
node name its daemon renders.

Removing the row left `SignatureKeySource` single-valued and `nodeIndex`
write-only, so both go, and the key resolver collapses to the one lookup it
always was. Every published identity is an operator account keyed by its own
label.

The cross-check test asserted [K1, BLS] per producing node -- an assumption, and
the wrong one. It now DERIVES the expected ids from what `buildArgs` renders, and
names the bios label divergence explicitly, so it fails when a rendered spec has
no publication rather than when a guess about node keys stops holding.

Note the coverage limit: these steps are composed only when
`signatureProvider.type === SSM`, and the e2e gate runs KEY mode, so the gate
exercises none of this. The unit tests are the whole safety net until a cluster
workflow boots an SSM cluster against it.

Change-Id: I392a8ab918b6f79ec99a2cb7c7b0b957ef532faf
…ecfg (WIRE-367)

`missed_round_window_ms` and `max_pct_missed_rounds_in_window` no longer exist in
the ABI. The flow keeps `min_blocks_per_round` at 0 — it demotes by stopping a
node, so every round it charges has no blocks at all.

Change-Id: If0670038aeeb845d729bb2bff9c5ccb573e57815
…E-367)

A round is now scored once, when it ends — the block count is only known at the
transition to the next producer. The step asserted the streak was clear on the
first block, which is up to a round too early. Renamed: serving the round is what
clears it, not the block.

Change-Id: I4e1ae256716ae645ec0225317bfdfae871c27f2a
…RE-367)

wire-sysio 9c19796f19 gated the `regproducer` pardon on the producer having
actually left the schedule, and made that pardon clear the miss streak as well
as the demotion. A producer still holding a slot is now refused the pardon
outright and recovers by serving a round, so the repeatable-regproducer loop the
preserved streak used to defend against no longer has anything to clear -- and
off the schedule there are no rounds left to serve, so a preserved streak would
be permanent.

The flow asserted the old contract and failed on the current head: it required a
NONZERO streak after re-registration. It now requires zero, at the point where
the previous step has already asserted the schedule dropped the producer.

`serving-a-round-clears-the-streak` tested a clear the pardon now performs, so it
would pass without exercising anything. Repurposed to assert the recovered
producer does not fall straight back: wait for the head to move off it, which is
the transition where its round is scored, then require a SERVED verdict -- streak
still zero and the demotion not returned.

Change-Id: I94e53017d452d61a234beefb288371f78bf5f1af
@heifner

heifner commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

CI is red here for a cross-repo reason, not a defect in this branch — and it cannot go green until the sdk-core types are published.

The failure is:

TS2339: Property 'rank_score' does not exist on type 'SysioSystemProducerInfoType'
TS2339: Property 'setscorecfg' does not exist on type '{ readonly accrueepoch: ... }'

SysioContractTypes.ts in @wireio/sdk-core is hand-generated and committed rather than produced by the build. Wire-Network/wire-sysio#599 adds setscorecfg and rank_score to the system-contract ABI, so this branch's source only type-checks against a regenerated sdk-core. That regeneration is Wire-Network/wire-libraries-ts#80, still open.

This repo resolves @wireio/sdk-core from npm (^1.0.88), so PR CI gets the published package. Confirmed against the current latest, 1.0.89: the tarball ships lib/{cjs,esm}/types/SysioContractTypes.d.ts, and neither setscorecfg nor rank_score appears anywhere in it. Other PRs on this repo are green because they do not touch the new ABI surface.

Order to unblock:

  1. Merge chore(sdk-core): regenerate SysioContractTypes for the producer-score ABI (WIRE-367) wire-libraries-ts#80.
  2. Publish sdk-core (next patch, 1.0.90). This is the step that actually clears CI here — merging fix(cluster-tool): verify the Solana .so at deploy #80 alone changes nothing this repo can see.
  3. Re-run CI on this PR. No dependency bump is needed: ^1.0.88 already admits 1.0.90.

Every commit on this branch since 2026-09-05 has failed the same way, so the red predates the current head rather than being introduced by it.

The e2e gate is unaffected and passes, because it builds libraries-ts from source rather than npm — BRANCH_WIRE_LIBRARIES_TS=e64b8b2f63. Latest scoped run on this head, 1dc6e756, is green: https://github.com/Wire-Network/wire-platform-build-system/actions/runs/34474238210 (FLOW_INCLUDE=flow-producer-registration, 1/1 passed).

@heifner
heifner requested a review from jglanz September 10, 2026 13:09
type SysioContractMapping = SysioContracts.SysioContractMapping
/** sdk-core's `producer_schedule` struct, exactly as `get_producer_schedule` answers it. */
type ProducerScheduleStruct = Awaited<
ReturnType<APIClient["v1"]["chain"]["get_producer_schedule"]>

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.

The index types derived string literal members should be declared individually for the purpose of reuse. Ideally, no more then a single string literal reference should be used in the definition of any type

In this PR, theres a reference to WireClient.GetProducerScheduleResponse - which makes me think this has already been extracted/typed

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.

Fixed in 137b693. Each step of the chain is its own named type now — APIClientV1, ChainAPI, GetProducerScheduleCall, GetProducerScheduleResult — so every member is reusable and no definition spells more than one string literal. A renamed sdk-core member now fails at the single line naming it rather than somewhere inside a four-literal chain.

One correction on the guess, though: WireClient.GetProducerScheduleResponse is not this type already extracted. That one is the PROJECTED shape — get_producer_schedule mapped onto plain producer names — while ProducerScheduleStruct is the raw sdk-core struct it projects FROM. Both are needed; they are the two ends of the same projection. The struct's doc comment now says so, since the names alone do not make it obvious.

Comment on lines +670 to +684
/**
* The registry-issued `http` + `p2p` pair an AD-HOC node binds — each port
* resolved through {@link findAvailable} from the ad-hoc preferences
* ({@link DefaultAdHocHttp} / {@link DefaultAdHocP2p}), so the pair is claimed
* under the same host-global lock as every planned node's.
*
* @returns The node's binding.
*/
export async function findAvailableAdHocPorts(): Promise<BindConfigNodeopPorts> {
return {
http: await findAvailable(DefaultAdHocHttp),
p2p: await findAvailable(DefaultAdHocP2p)
}
}

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 could cause real issues when used with high concurrency if i'm reading it correctly

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.

For more context: we run with the port registry resolution being protected by a file lock because in the cloud and on dev machines we run with high concurrency and unprotected resolution without marked registration, would break a lot of things, I'm making a comment, BUT it may have implemented the registry portion, just flagging

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.

Follow-up on the ad-hoc port thread, since this one was where you first flagged it: the fix landed as a reservation up front rather than a fix to the pick.

resolve() now claims the ad-hoc pairs alongside every planned node under the one lock it already holds, and allPorts walks them so they reach registerResolved — the half that was missing. Detail and the verified run are in the sibling thread on ProducerNodeTool.

wireFinalizer: nodeKeys.keys.wireFinalizer
}
): OperatorAccount[] {
return node.producers.map(label => ctx.keyStore.assertOperator(label))

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 would be cleaner as two seperate lines node.producers.every(...same content as map...) then return node.producers or even cleaner asOption(node.producers).filter(...the every example...).get()

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 your asOption idiom in 2010f00, but with .tap rather than .filter:

return asOption(node.producers)
  .tap(labels => labels.forEach(label => ctx.keyStore.assertOperator(label)))
  .map(labels => labels.map(label => ctx.keyStore.operator(label)))
  .get()

The split is the point you were making, and this is STYLE.md's construct-validate-unwrap shape. I went with .tap over .filter/.every because both of those flatten the failure to a boolean: assertOperator throws naming the exact label, and a filter predicate going None makes .get() throw a generic Option error instead. The message is the whole value of that failure — it says WHICH producer's materialize step never ran — and the function's doc comment leans on it ("must fail loudly rather than silently launch a node that cannot vote").

Two smaller notes. node.producers is string[] and the function returns OperatorAccount[], so that .map is the label → account conversion rather than a validation pass — return node.producers would not type. And operator is the non-throwing accessor assertOperator is documented against, so the .tap asserts and the .map reads; it costs one extra Map.get per producer.

Happy to take .filter instead if you would rather have the uniform shape and accept losing the label — your call on the idiom.

`startProducerNode: ${input.label} has no finalizer key — it could produce blocks but never vote`
)

const ports = await BindConfigProvider.findAvailableAdHocPorts()

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 will break everything with concurrency > 1

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.

You were right, and the registry half was NOT implemented — thanks for pushing on it.

findAvailable did take the host-global lock and pass readRegistryPortExclusions(), so the pick itself was protected. What it never did was REGISTER the claim: only resolveLocked and ClusterManager.run call registerResolved, both with the planned bind. So an ad-hoc pair was invisible to a parallel resolver for the whole window between the pick and the daemon binding — exactly what the registry exists to cover. It was also two lock acquisitions per pair (non-atomic), and both ad-hoc defaults being fixed constants meant every concurrent flow preferred the same two numbers.

Fixed in cce7a07 by moving the resolution up, as Kevin suggested: ClusterTopologyOptions.adHocCount makes resolve() claim the pairs alongside producers/batch/underwriters, and allPorts walks them — which is what puts them in registerResolved and in validate on relaunch. claimAdHocPorts(bind, label) hands out a slot and returns the same pair when a label asks again, so a stop/restart rebinds where it was rather than consuming a second reservation. findAvailableAdHocPorts is gone.

Default is 0 — a flow asks for exactly the nodes it starts, since reserving pairs nothing spawns is port pressure for every other cluster. flow-producer-registration and flow-operator-collateral-deposit are the two that start nodes outside NodeConfig.plan, and each declares one. A COMPLETE --bind-config now has to declare the count, since cardinality is cross-checked per role.

Two things this shook out that are worth knowing. adHocCount also had to be registered as a CLI option leaf (562a374): FlowCLI registers Scenario.defaults as yargs defaults and toClusterBuildOptions reads them back out of argv, so a member absent from the option tree is silently dropped between the two — both flows resolved 0 pairs and died at cluster-build time while every unit test stayed green. That seam now has tests. And the BindConfig shape change broke three fixtures in the debugging packages that the ROOT tsc -b does not reference (0659392) — per-package builds are what compile test sources here.

Verified end to end: https://github.com/Wire-Network/wire-platform-build-system/actions/runs/34526374766 — both ad-hoc flows green on 562a3747.

…(WIRE-367)

Ports for a node a flow starts itself were picked when the node spawned, through
findAvailableAdHocPorts. That took the host-global lock and honoured the registry
exclusions, but it never REGISTERED the claim: only resolveLocked and
ClusterManager.run call registerResolved, both with the planned bind. So the pair
was invisible to a parallel resolver during the window between the pick and the
daemon binding -- exactly the window the registry exists to cover, and a real
collision source at the concurrency these clusters run under. Two lock
acquisitions per pair made it non-atomic, and both ad-hoc defaults being fixed
constants meant every concurrent flow preferred the same two numbers.

The pairs are now reserved by resolve() alongside producers, batch and
underwriters: ClusterTopologyOptions gains adHocCount, BindConfig gains
nodeop.ports.adHoc, and allPorts walks it -- which is what puts the pairs in
registerResolved and in validate on relaunch. claimAdHocPorts hands a label its
slot and returns the SAME pair when that label asks again, so a stop/restart
rebinds where it was instead of consuming a second reservation.

adHocCount defaults to 0: a flow asks for exactly the nodes it starts, since
reserving pairs nothing spawns is port pressure for every other cluster.
flow-producer-registration takes one (its producer node, stopped and restarted
under one label) and flow-operator-collateral-deposit takes one (its operator
daemon) -- the two flows that start nodes outside NodeConfig.plan.

A COMPLETE --bind-config now has to declare the count, since cardinality is
cross-checked per role.

Change-Id: I719f9accd3fcc612c271ca4fa13eec4c4100f45a
… type (WIRE-367)

ProducerScheduleStruct spelled four string literals in one definition, so a
renamed sdk-core member failed somewhere inside the chain rather than at the one
line naming it. Each step is now its own type -- APIClientV1, ChainAPI,
GetProducerScheduleCall, GetProducerScheduleResult -- and every member is
reusable.

Also states what the struct is NOT: WireClient.GetProducerScheduleResponse is
this projected onto plain producer names, not a duplicate of it.

Change-Id: If5d5ec9f09bb54982333484ccf0ac89790882b3c
…the ad-hoc count (WIRE-367)

producerOperators asserted and converted in one map. Split per STYLE.md's
construct-validate-unwrap: asOption(...).tap(assert).map(convert).get(). The tap
keeps assertOperator's per-label throw, which names the producer whose
provisioning step never ran -- an `every`/`filter` predicate would collapse that
to a boolean and the failure would say only that one of them was missing.
`operator` is the non-throwing accessor assertOperator is documented against, so
the map reads rather than re-asserts.

The collateral flow passed adHocCount as a bare 1. Extracted to
CollateralLifecycleScenarioConstants.AdHocDaemonCount, matching how the producer
flow names its own count.

Change-Id: I5462c115438b683169136200e10815c62a27056b
…es (WIRE-367)

Adding nodeop.ports.adHoc to BindConfig made every literal of that shape
incomplete, and three fixtures outside cluster-tool were missed: the
debugging-client-shared local cluster, the tui ClusterSlice test, and the
debugging-server cluster route test.

The root `tsc -b tsconfig.json` does not reference the debugging packages, so the
root build stayed green while the platform build -- which builds each package,
and each package's tsconfig references its jest project -- failed on all three.
Per-package builds are what cover test sources.

Change-Id: Iae74e010b7fe700713046de9dfb37b22609618cd
…reservation survives (WIRE-367)

A flow's reservation never reached the resolver. FlowCLI registers
Scenario.defaults as yargs defaults and toClusterBuildOptions reads them back out
of ARGV, so a member absent from the option TREE is dropped between the two --
adHocCount was declared on ClusterBuildOptions but never as a leaf. Both ad-hoc
flows resolved 0 pairs and died on their first claimAdHocPorts at cluster-build
time, which no unit test reaches.

Declares the leaf (CLI default 0), sizes bind.nodeop.ports.adHoc from it so the
pairs are addressable as --bind-nodeop-ports-ad-hoc-N-*, and reads the count in
countsFromArgv -- without that last one the reverse parse sizes the array to zero
and drops the bind overrides even when the flag is present.

Covered at the seam that hid it: one test asserts the flag registers seeded from
a scenario's defaults, one asserts the reverse parse round-trips the count and
its bind array.

Change-Id: I0ae4a5b02cd1ef78f890fd401a09d5f8d647b070
@heifner
heifner requested a review from jglanz September 11, 2026 11:54
@heifner
heifner merged commit 1398f3c into master Sep 11, 2026
1 check failed
@heifner
heifner deleted the feature/wire-367-producer-registration branch September 11, 2026 13:35
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