fix(server): install the Asset Hub genesis hash on the signing role - #729
fix(server): install the Asset Hub genesis hash on the signing role#729decrypto21 wants to merge 5 commits into
Conversation
ad85ad5 to
d3b63f4
Compare
|
@lore-bot-app review |
|
Reading the diff and checking what the record says. Back in a few minutes. |
There was a problem hiding this comment.
TL;DR
Adds the Asset Hub genesis hash to SigningHostConfig and installs it on the signing role, so trustedProducts grants resolve on iOS, Android, the CLI and the wasm signing host; also caps product-id length and adds dotNS debug logging. 2 blocking, 5 minor.
Summary
root_manifest needs services.asset_hub_chain_genesis_hash() to reach dotNS. Only PairingHost::new installed it, so the signing role returned early and refused every cross-product grant that was not already in the manifest cache. This PR threads asset_hub_chain_genesis_hash through SigningHostConfig, NativeHostRuntimeConfig, the wasm JS config, Swift/Kotlin HostRuntimeConfig, and the CLI, installs it in SigningHostRuntime::new, and repeats that install in the three test fixtures that build RuntimeServices directly. It adds a 256-byte cap on product identifiers, debug logging in dotns_lookup, a startup warning when no Asset Hub is configured, and a warning when the configured hash disagrees with supported_chains.
What the record says
- The bug is exactly as filed in #660:
SigningHostRuntime/SigningHostConfiglack the field,asset_hub_chain_genesis_hash()returnsNone,root_manifestexits early, and iOS, Android, the CLI and the wasm signing host are all affected. The issue also names regenerating the UniFFI bindings as part of the fix, which matches this repo's gitignored-bindings arrangement. - #655 has the same root cause but a longer requirement list: inject the hash into
SigningHostConfig, regenerate bindings, and update the authority logic to re-adjudicate grants while preserving user denials, plus reorder the session checks increate_account_proof. This PR does the config half only. That is a reasonable split, but #655 stays open after this lands, and the diff does not say so. - The umbrella design is #477 / #454 (scoped grants in
trustedProducts). - The manifest-cache seeding that the diff's comments lean on is
b11f84eb feat(cli): honour trustedProducts from the local product config (#702)in this branch's history, which is why the CLI e2e looked healthy while the signing role was inert. docs/rfcs/core-manifest-resolution.md:11says hosts neither fetch manifests nor decide what a grant covers, and lists "hosts lose their own trust policy" as a deliberate trade-off (line 48). It says nothing about where the Asset Hub hash comes from. This PR makes the embedder pick the registry network by config, with only a length check, which is the opposite of the rulesso_responder::allocate_smart_contract_allowancedocuments for PGAS. The newwarn_if_asset_hub_disagrees_with_chain_setacknowledges the split and defers it. The record does not contain a decision on which source should win, so this PR is choosing without one.
Concerns
1. The product-id cap does not close the hole its doc comment says it closes. rust/crates/truapi-platform/src/lib.rs:339-349
The comment states that without a cap "that is unbounded attacker-keyed core storage." The cap bounds one key's length, not the number of keys. root_manifest keys the cache off the caller-supplied target (product_manifest.rs:326-328) and caches the negative answer too: an unregistered name resolves to Ok(None) (product_manifest.rs:70-73, resolver [0u8; 20]) and gets written (product_manifest.rs:346-355), with nothing evicting it. A product can still name an unlimited number of distinct 252-character .dot labels and get one core-storage entry each, at one dotNS round trip per name. The growth predates this PR on the pairing role, but this PR is what makes the write path live on signing hosts, which is where the storage is a phone. Either bound the number of cached negatives, or reword the comment so it claims what it does: a bound on key size, not on cardinality.
2. The divergence warning silences itself permanently on the first transient failure. rust/crates/truapi-server/src/runtime/product_manifest.rs:276
ASSET_HUB_CROSS_CHECKED.swap(true, ...) runs before the fallible supported_chains call. If the first uncached manifest lookup happens while the host cannot answer (early startup, a transient host error), the else { return } on line 281 leaves the latch set and the comparison never runs again for the life of the process. The function exists to make the divergence audible, so this defeats it in exactly the case where the host is in an odd state. Latch only after a successful comparison.
Two related points on the same function: the flag is a process-global static, so a process with two runtimes only ever checks the first, and any future test of the warning becomes order-dependent; RuntimeServices already holds per-runtime OnceLocks and is the natural home. And supported_chains is now awaited on the manifest critical path with no timeout, where on the native hosts it is a synchronous UniFFI callback (native.rs:557), for a diagnostic.
3. The length check echoes the oversized input back out. rust/crates/truapi-platform/src/lib.rs:360-364
InvalidProductId { product_id: product_id.to_string() } carries the whole rejected string, and its Display formats it with {product_id:?} (line 904). Those errors reach the wire and the logs (host_core.rs:1083, signing_host.rs:228), so a product that sends a multi-megabyte id gets it copied into an error string and a log line. The precedent the comment cites does not do this: ChatFieldError::TooLong { field, limit } reports the limit and drops the value (lib.rs:424-428). Also worth noting the check runs after nfc().collect() and to_lowercase(), so it bounds what is stored, not what is allocated.
4. Two new tests each spend the full 10s OPERATION_TIMEOUT. host_core.rs:2518 and frame_server.rs:1310
The CLI one is documented (frame_server.rs:1163-1170). The server one is not: StubPlatform with no scripted responses returns futures::stream::pending() (test_support.rs:1293), so the follow never initializes and wait_for_chain_head_best_hash waits out OPERATION_TIMEOUT before erroring. That is roughly 20 seconds of pure waiting added across two crates' unit suites. Letting the stub end the follow stream would give None and an immediate Err at chain_runtime.rs:1585, with the same assertions.
5. The install stays a set-once after construction, which is the shape that produced #660. host_core.rs:580, signing_host.rs:1389, sso_responder.rs:1742, statement_store.rs:476
RuntimeServices::new already takes people_chain_genesis_hash and bulletin_chain_genesis_hash by value (services.rs:74-80). Asset Hub is the only one that arrives through a later install_* call, so correctness depends on every construction site remembering, there are now four of them, and three test fixtures carry a comment explaining that they must repeat what the constructor does. Passing it into RuntimeServices::new and dropping the OnceLock would make both roles and every fixture correct by construction, and would make the two new "does the runtime install it" tests unnecessary. As it stands one fixture still does not install: signing_host.rs:176 (new_with_ring_resolver_on), so tests built from it still see the pre-fix behaviour. If that is deliberate, it deserves the same comment the other three got.
6. js/packages/truapi-host/README.md:22-24 is now stale. It says the signing constructor's configuration requires runtimeConfig.networkSuffix. After wasm.rs:584, runtimeConfig.assetHub is also required and a config without it fails. The note went into a TSDoc on ProductRuntimeConfig.assetHub (runtime.ts:99-107), which is the pairing-host interface, not the signing one. Per CLAUDE.md the README is the thing that has to reflect it.
7. The two full-config README snippets do not compile as written. android/truapi-host/README.md:352, ios/truapi-host/README.md:399
Both pass a bare assetHubChainGenesisHash identifier that nothing in the snippet defines, while the sibling fields on the lines above are literals (ByteArray(32), Data(repeating: 0, count: 32)). The comment above it explains why all-zero is wrong, so the intent is clear, but a copy-paste does not build.
Questions for the author
- Which source is meant to win for Asset Hub in the end, the embedder's config or
supported_chains?warn_if_asset_hub_disagrees_with_chain_setdefers to "#660/#454", and neithercore-manifest-resolution.mdnor the issues settle it. Until it is settled, a host whose two sources disagree resolves grants on one network's registry and allocates PGAS on another, which the doc comment describes but nothing prevents. - This makes
HostRuntimeConfigsource-breaking for the iOS and Android embedders and adds a required key to the wasm signing config, but the PR title carries norelease:marker. Is a companion release plus the.github/consumers.jsonbump issues planned separately, or expected in this PR? - Was the rest of #655 (authority re-adjudication preserving user denials, and the
create_account_proofcheck ordering) intentionally left for a follow-up? Nothing in the diff touchescapabilities/account.rs, socontextstays inert after this.
🤖 Reviewed by Lore (Parity knowledge base) · 47 agent turns · 505.3s · knowledge as of 2026-09-11
| .is_some_and(|(label, _tld)| REMOTE_PERMISSION_TRUSTED_LABELS.contains(&label)) | ||
| } | ||
|
|
||
| /// Largest accepted product identifier, in bytes. |
There was a problem hiding this comment.
docs/rfcs/core-manifest-resolution.md:11 says hosts neither fetch manifests nor decide what a grant covers, and lists "hosts lose their own trust policy" as a deliberate trade-off (line 48). It says nothing about where the Asset Hub hash comes from. This PR makes the embedder pick the registry network by config, with only a length check, which is the opposite of the rule sso_responder::allocate_smart_contract_allowance documents for PGAS. The new warn_if_asset_hub_disagrees_with_chain_set acknowledges the split and defers it. The record does not contain a decision on which source should win, so this PR is choosing without one.
Concerns
1. The product-id cap does not close the hole its doc comment says it closes. rust/crates/truapi-platform/src/lib.rs:339-349
The comment states that without a cap "that is unbounded attacker-keyed core storage." The cap bounds one key's length, not the number of keys. root_manifest keys the cache off the caller-supplied target (product_manifest.rs:326-328) and caches the negative answer too: an unregistered name resolves to Ok(None) (product_manifest.rs:70-73, resolver [0u8; 20]) and gets written (product_manifest.rs:346-355), with nothing evicting it. A product can still name an unlimited number of distinct 252-character .dot labels and get one core-storage entry each, at one dotNS round trip per name. The growth predates this PR on the pairing role, but this PR is what makes the write path live on signing hosts, which is where the storage is a phone. Either bound the number of cached negatives, or reword the comment so it claims what it does: a bound on key size, not on cardinality.
2. The divergence warning silences itself permanently on the first transient failure. rust/crates/truapi-server/src/runtime/product_manifest.rs:276
ASSET_HUB_CROSS_CHECKED.swap(true, ...) runs before the fallible supported_chains call. If the first uncached manifest lookup happens while the host cannot answer (
There was a problem hiding this comment.
Rewritten to drop what duplicates Lore's review above. Most of what I had it already found, in several cases with more of the call graph traced than I had — its #1 (negative answers are cached too, so the cap bounds key size and not cardinality) and its #2 (the latch, the process-global static, supported_chains being a synchronous UniFFI callback on native) cover what I was going to say. Treat those as seconded rather than separate.
The core fix is right and the diagnosis convincing. Using three distinct non-zero hashes so a transposition is visible is the right instinct for a positional constructor of same-typed [u8; 32], and strengthening a_signing_host_takes_a_manifest_miss_to_the_chain from "asked a chain" to "asked this chain" — after noticing StubPlatform::connect discarded its genesis argument — is the kind of thing that usually gets missed.
What I'd block on
The one new security-adjacent helper has no test. Everything else here is mutation-checked, and the PR description is explicit about that bar. warn_if_asset_hub_disagrees_with_chain_set appears only at its definition and its call site. The process-global AtomicBool is also what makes it untestable — never reset, so in a test binary the first test to touch this path consumes the latch and no later test can observe the warning. Lore's suggestion of moving the flag onto RuntimeServices alongside the existing per-runtime OnceLocks fixes the testability and the two-runtime case together, and combined with latching only after a successful comparison it closes all three at once.
Seconding two of Lore's, with a reason to weigh them
Its #5 — Asset Hub is the only genesis hash arriving by post-construction install_*. The PR's "Not addressed" defends this ("Threading it through RuntimeServices::new would make the dropped-field class unrepresentable. Left alone because #655 is stacked on this constructor"). I'd push back: #655 is #730, it is still a draft, and rebasing a constructor signature across a draft is cheaper than carrying a class of bug the PR itself names as unrepresentable-if-fixed.
Concretely, the fourth fixture Lore names is not hypothetical. new_with_ring_resolver_on (signing_host.rs:171) builds RuntimeServices::new(...) and never installs — I verified this on this branch. It is also the fixture #730's new grant tests run on, so they execute against services with no Asset Hub and pass only because the manifest cache is pre-seeded. That is the same blind spot this PR's description identifies as the reason #660 survived. Details on #730; raising it here because the constructor change is the fix for both.
Its #3 — the length check echoes the oversized input back out. InvalidProductId carries the whole rejected string into an error that reaches the wire and the logs. The precedent the doc comment cites, ChatFieldError::TooLong, reports the limit and drops the value.
I read the diffs and surrounding code; I did not run any suite, and have not verified the iOS/Android builds.
d3b63f4 to
33e6dbe
Compare
|
CI Status: 14 required jobs green, 13 passed and 1 skipped by path filter. All job results
Commit |
Closes #660. Rebased onto
mainnow that #454 has merged and its branch is gone; the manifest machinery this repairs arrived with it.Summary
SigningHostConfigcarries the Asset Hub genesis hash, andRuntimeServices::newtakes it by valueRoot cause
install_asset_hub_genesis_hashhad one caller,runtime/pairing_host.rs:316, insidePairingHostRole::new.SigningHostRuntimebuilt its services without it andSigningHostConfighad no such field, soasset_hub_chain_genesis_hash()wasNone,root_manifestreturned before reaching dotNS, andmanifest_grants_scopeansweredfalsefor every product.On the signing role (iOS, Android,
truapi-hostCLI, wasm signing host) that refused everytrustedProductsgrant the manifest cache could not already answer. The refusal is deliberately indistinguishable from "the other product granted you nothing" (runtime/capabilities/platform.rs:179), so nothing surfaced it.Why existing tests missed it
Every grant test built a pairing role via
new_compat, and every one pre-seeded the manifest cache, whichroot_manifestserves before consulting the genesis hash. The hash could have been removed from the pairing role too and the suite would have stayed green.#702's cross-product e2e shares the short-circuit: deleting the install from this PR leaves
make e2e-cross-product-storagepassing. The new tests assert on whether the core reached the chain, which is the only observable difference.Decisions
No Asset Hub configured. All-zero, kept as the single spelling.
runtime/services.rs:153already filters it toNoneand both states behave identically, so a second sentinel buys no runtime decision. The field is required instead, so a host cannot arrive here by omission and one built before the field existed does not compile. Not a kill switch: a cached manifest is served before this is consulted, so earlier grants stay honoured until the entry expires.Bindings checkbox. #660 asks for regenerated and committed UniFFI bindings "since the FFI checksum moves". Neither half holds. No bindings are committed (
.gitignore:60, andsync-bindings.shsays so in its header), and the checksum does not move:make uniffieither side of the commit giveschecksum_constructor_nativetruapihostruntime_with_runtime_config() != 39293,truapi_serverFFI.hbyte-identical. UniFFI checksums a function by its signature's type names, not record layout. Generating them in review is still worth it; it is how the mid-enum insert was caught.Where the new FFI error variant goes. Appended, not grouped with the sibling genesis-hash variants. Declaration order is the FFI discriminant and nothing in the checksum covers it, so an insert mid-enum silently renumbers every variant below it.
Record fields are positional too, and nothing protects them either. An earlier version of this PR claimed a shifted record field fails the read where a shifted discriminant lies. That is not correct.
StringandVec<u8>are both an i32 length followed by that many bytes, so they are wire-identical. Reading aStringwhere a hash was written usually fails, but only becauseString::try_readrunsfrom_utf8and 32 random bytes are rarely valid UTF-8, which is a property of the value and not a guarantee. ReadingVec<u8>where aStringwas written always succeeds and lies. What keeps the config record honest is regenerating the bindings with the lib, not its field order.Tests
a_signing_host_runtime_installs_its_asset_hub_for_manifest_resolutionan_all_zero_asset_hub_is_how_a_signing_host_says_it_has_nonea_signing_host_takes_a_manifest_miss_to_the_chaina_pairing_host_runtime_installs_its_asset_hub_toopairing_host.rs:316left the whole suite greeneach_configured_genesis_hash_reaches_its_own_field[u8; 32]through a positional constructor. Transposing two compiled and passed everythinga_wrong_size_asset_hub_genesis_hash_is_rejected_as_its_own_fieldan_overlong_product_id_is_not_an_identifierPRODUCT_ID_MAX_BYTEScap, and the boundary, so an off-by-any-amount cap still failsMutations: deleting the all-zero filter fails 2, wiring the bulletin hash fails 3, transposing the boundary slots fails 1, removing the product-id cap fails 1, transposing People and Asset Hub at
core.rsfails 1, deleting the cross-check spawn fails 2, removing itscatch_unwindfails 1.a_signing_host_takes_a_manifest_miss_to_the_chainwas asserting the weaker property. It checked that the core asked a chain, which a lookup wired to People or Bulletin also satisfies, since those produce RPC and refuse too.StubPlatform::connectdiscarded its genesis argument, so no test could see a wrong hash. It now records every genesis it is handed and the test pins the value, and the three hashes in the fixture are distinct so any transposition among them is visible. Verified by mutation: keeping the Asset Hub gate but dialling Bulletin's hash is caught on the new assertion, and the previous assertions stayed green under that same mutation.Fixtures building
RuntimeServicesdirectly used to take the new field and drop it.install_asset_hub_genesis_hashis gone and the hash is a constructor argument, so omitting it is now a compile error. That also closednew_with_ring_resolver_on, which never installed one and is the fixture #730's grant tests run on.CLI proof
make e2e-cross-product-storagepasses all five phases. Theread-missingphase run with--log-level debug:4349b00e...isPASEO_ASSET_HUB.genesis(truapi-host-cli/src/network.rs:84). People is4a2b5b73...and Bulletin is8cfe6717..., so this pins the chain rather than just showing that some chain was reached. A dotNS lookup from the signing-role CLI is impossible without the installed hash.Phase timings say the same thing a second way, which matters because a refusal after a failed lookup is byte-identical to a refusal after a successful one:
read,read-untrusted--product-config, no chainread-missingdotns_lookup::OPERATION_TIMEOUT, proves nothingThe first two rows are a live network round trip, so they move run to run (measured 2.54s and 3.22s for
read-missingon two runs). What matters is the separation: cached reads never reach a chain, a miss completes in a few seconds, and anything landing on 10.0s is the timeout and evidences nothing. Re-measured after rebasing onto #357; thechain connectline above is byte-identical across both.An earlier revision of this PR quoted a
DotnsPopController.protocolRegistry(): contract revertedline as the proof. That line was produced before the rebase ontoee099ef2, which fixed exactly that read, and it cannot be reproduced on this tree. The refusal line itself is unchanged and still present.Hardening on the newly reachable path
Making this path executable exposed three things worth fixing in the same change.
Product ids are now length-capped.
PRODUCT_ID_MAX_BYTES = 256innormalize_product_identifier.has_dotns_tldonly inspects the suffix after the last., so every length ofaaa...aaa.dotwas a distinct valid id. Cross-product calls carry that string from the wire, where it is self-asserted, and a manifest miss caches its result keyed by it, including the authoritative "no manifest". This bounds the size of one identifier, not how many there are: nothing evicts those cache entries, so a product can still mint an unbounded number of distinct capped-length names at one dotNS round trip each. Bounding that cardinality is a cache-eviction decision and is not made here. The path was unreachable before this change only because the lookup returned early. The cap is checked after NFC normalisation, since NFC can change byte length, and it is reported by length through a new appendedProductIdTooLong { limit, actual }rather than by echoing the rejected id, whichInvalidProductIdwould have copied into an error and a log line.The two Asset Hub identities are cross-checked. Manifest resolution reads
SigningHostConfig::asset_hub. The PGAS allowance path next door insso_responder::allocate_smart_contract_allowancederives its Asset Hub fromfeatures::genesis_for(supported_chains(), AssetHub), and its doc comment argues for that specifically so a host cannot claim "against whatever chain a stale hash happens to reach". Both are live on the same role. A host whose config andsupported_chains()disagree resolves manifests from one chain's dotNS while allocating PGAS on another, which hands whoever holds that product name on the other network's registry the decision about who may read the victim product's storage.warn_if_asset_hub_disagrees_with_chain_setnow warns on divergence, once per runtime construction. It is spawned rather than awaited, so a slow host cannot stall a grant decision, and it is guarded: the task holds aWeakso it no-ops after teardown instead of calling back into a torn-down host, andcatch_unwindkeeps a panickingsupported_chainsfrom killing a worker of the shared native thread pool. It does not pick a winner: the configured hash still wins, as #660 specifies. Which source should be authoritative is a design question for #660 and #454, and thesso_responderdoc comment has been corrected to stop asserting a rule the tree no longer follows.The lookup now logs.
runtime/dotns_lookup.rshad no tracing calls at all, because nothing on the signing role could reach it. It can now do network I/O, stall forOPERATION_TIMEOUT, and fail closed into a refusal a caller cannot distinguish from "you were not granted this". It now logs the follow it opens and against which genesis, a failed follow initialisation, whether a storage read found a value or found nothing or was inaccessible, and the result of each contract view. The absence of this logging is why establishing the CLI proof above took three attempts.One test regression came out of the same review. Adding the Asset Hub to the CLI test host in
frame_server.rsturned a 0.01s in-memory refusal into a 10s stall that dialledpaseo-asset-hub-next-rpc.polkadot.iofrom a unit test, so it passed on Paseo's reachability rather than on its own logic. Those tests now point at a closed port. That keeps them off the network but does not make them fast: a refused connection costs the same 10s, because the provider retries instead of ending the follow, sowait_for_chain_head_best_hashnever seesStop.Verification
cargo test --workspace: 1288 passed, 0 failed.truapi-macros'sso_handler_contractstrybuild suite fails locally on rustc 1.91.1; it arrived frommainin feat(sso): generate typed dispatch and wire conversions #651, this branch touches nothing in that crate, andmain's own Rust workspace check is green, so it is a local toolchain artifact rather than a regression here.cargo clippy --workspace --all-targets --all-features -- -D warnings: clean. Use the workspace form: a#[cfg(test)]SigningHostConfig::newcall site in another crate is invisible tocargo build, which is how feat(cli): honour trustedProducts from the local product config #702's new call site and this constructor change break each other without conflicting textually.cargo +nightly fmt --check: cleancargo check --target wasm32-unknown-unknown -p truapi-server --features wasm-signing-host: cleanmake uniffiandmake uniffi-kotlin: green on this commit, bindings regenerate with no drift.make android-checkand the iOSxcodebuild build/build-for-testingwere green on the pre-review commit, where both were also mutation-checked (dropping the pass-through fails each with a missing-argument error). They have not been re-run against the hardening changes above: Gradle is not available on this machine's PATH and there is no wrapper in this repo. The changes since are target-agnostic Rust and add no dependency (hexwas already atruapi-serverdependency, andAtomicBoolcomes fromcore), and thewasm32-unknown-unknowncheck above exercises cross-target compilation, but that is an argument and not a green build. Worth re-running before merge.make e2e-cross-product-storage: passesscripts/battery.shboth phases: 2 failures outside the committed baseline, neither reachable from this change.Signing/sign_raw_with_legacy_account: the example asserts a non-empty legacy-account list,get_legacy_accountsreturns a hardcoded empty one (runtime/capabilities/account.rs:386). Deterministic, identical in both roles.Resource Allocation/request:no free StatementStore slot in period 20706 (max 10), an exhausted on-chain quota after repeated allowance-allocating runs. That path resolves its Asset Hub fromfeatures::genesis_for(supported_chains(), AssetHub), a different value.Committed diagnosis reports are stale independently of this change (last regenerated 2026-08-18) and are not regenerated here.
Not addressed
Properties of the newly executable path, not of the install:
ProductRuntime::in_flightand thews_bridgetask spawn are both uncapped), so a product can hold N follows for up toOPERATION_TIMEOUTeach. That shape predates this path, but a chain round trip per request makes each one dearer. The fix is a single-flight keyed by target, or a dispatch concurrency cap. Both belong with the request pipeline and not here, so this is documented inroot_manifestrather than changed. Worth taking first.LocalStorage::readcan still block for longer than one budget.DotnsLookupbudgets the follow-event waits but not the request RPCs (chain_runtime.rs:410,:436), so a live but mute connection leaves the await pending. On wasm a non-stringchainConnectresponse is logged and dropped (wasm.rs:144), stranding the request id.logging::initstarts atLevelFilter::OFF, so the UniFFI and wasm hosts drop it unless the host raised the level first. The CLI installs its own subscriber and shows it. The same applies to the new dotNS logging.The hash is applied by a separate mutation onDone: it is a constructor argument now, which is what makes the fixture class above unrepresentable. This changes a signature feat(server): make the context scope effective #730 is stacked on, so that branch needs a rebase.RuntimeServices.docs/design/product-manifest.md's error table has no row for "the host configured no Asset Hub".An earlier revision listed "unbounded request legs" here. That was wrong and is withdrawn: every loop on the lookup path is bounded (
0..4,0..2,0..LABEL_PAGE_MAX,0..CLAIM_PAGE_MAX) anddiscover_pop_controlleris at most two view hops.hosts/iosis unchanged. It consumes this repo as a remote.exactdependency and its call site already omits the pre-existingnetworkSuffix, so it needs a version bump and its own edit.Ordering
#655 is stacked on this: its authority re-adjudication runs on the signing role and needs the hash installed. #660 touches none of #655's surface and stands alone. #454 gates both.