What happens
A first-run agent node provisions its node key successfully and then edge refuses to open it, because the two halves derive its name independently and disagree:
alias the agent passes ciris-agent-bootstrap
server provisioning mints ciris-node-bootstrap-peulxofzaj ← fixed base alias
edge tries to open ciris-agent-bootstrap-node ← alias + "-node"
Disk confirms which one exists:
identity/ciris-node-bootstrap.ed25519.seed.blob present
identity/ciris-agent-bootstrap-node.* absent
Edge's refusal is correct — it is #541 declining to fall back to the agency-bearing key. The defect is that provisioning succeeded and put the key somewhere edge will never look. Tracked from the server side as CIRISAI/CIRISServer#511.
Why it is an identity split, not a missing file
fedcode::derive_key_id(alias, ed_pub) takes the alias as an input. The same key material under two aliases is two key_ids, which is two identities. This is not a lookup miss that a fallback could paper over.
Where the two rules live
Edge (src/ffi/pyo3.rs):
// :4146
const NODE_ALIAS_SUFFIX: &str = "-node";
// :4152
fn node_alias(keystore_alias: &str) -> String {
if keystore_alias.ends_with(NODE_ALIAS_SUFFIX) { return keystore_alias.to_owned(); }
format!("{keystore_alias}{NODE_ALIAS_SUFFIX}")
}
// :4240 — open_node_identity_halves
let alias = node_alias(host_keystore_alias);
Server (src/key_convention.rs, 0.5.195+): one fixed literal, ciris-node-bootstrap, for every node. Deployment identity comes from the fingerprint the federation appends, not from the alias.
Why the existing guard could not catch it
node_alias_matches_the_server_rule (src/ffi/pyo3.rs:11250) asserts edge's copy against a hardcoded literal:
assert_eq!(NODE_ALIAS_SUFFIX, "-node");
assert_eq!(node_alias("ciris-agent-bootstrap"), "ciris-agent-bootstrap-node");
It pins edge's own implementation, not the server's. When the server rule changed the mirror forked and this test still passed — it can only ever detect edge editing its own copy, which is the one case that is not the risk. The doc comment above NODE_ALIAS_SUFFIX says "if it ever drifts, that test is where it surfaces"; it did drift, and it did not surface.
Edge is right that it cannot import the constant — CIRISServer rides edge, and a dependency that way inverts the substrate relationship. But that argument rules out importing, not being told.
Impact
Every first-run agent node on server ≥ 0.5.195. Provisioning reports success; edge then fail-closes on a key that does not exist; the node cannot bring up its transport identity at all. There is no caller-side workaround — there is no alias A where substitute(A, agent→node) equals A + "-node", so no choice of host_keystore_alias reconciles the two rules.
Proposed fix
Let the caller name the key. provision_node_identity already returns the minted key_id; the value crosses the boundary on the way out and has nowhere to go on the way back in.
Add an optional node keystore alias to init_edge_runtime (and open_node_identity_halves):
- when supplied → use it verbatim, no derivation;
- when absent → fall back to today's
node_alias(host_keystore_alias), so existing deployments are unaffected.
That makes the name travel as a parameter and leaves exactly one component naming the key: the one that mints it. Retiring the mirrored node_alias once callers pass the name would remove the whole class.
If the constant should instead live in shared substrate (ciris-verify-core::fedcode is the natural home — it already owns derive_key_id, the function that makes the alias identity-bearing), that works too and is a strictly bigger change. Either way, two implementations of one rule is the thing to remove.
#541 is closed, and it is the issue that introduced this mirror. Edge's code cites it as the rationale, so nothing currently tracks the fork it left behind — hence this issue rather than a comment there.
What happens
A first-run agent node provisions its node key successfully and then edge refuses to open it, because the two halves derive its name independently and disagree:
Disk confirms which one exists:
Edge's refusal is correct — it is #541 declining to fall back to the agency-bearing key. The defect is that provisioning succeeded and put the key somewhere edge will never look. Tracked from the server side as CIRISAI/CIRISServer#511.
Why it is an identity split, not a missing file
fedcode::derive_key_id(alias, ed_pub)takes the alias as an input. The same key material under two aliases is twokey_ids, which is two identities. This is not a lookup miss that a fallback could paper over.Where the two rules live
Edge (
src/ffi/pyo3.rs):Server (
src/key_convention.rs, 0.5.195+): one fixed literal,ciris-node-bootstrap, for every node. Deployment identity comes from the fingerprint the federation appends, not from the alias.Why the existing guard could not catch it
node_alias_matches_the_server_rule(src/ffi/pyo3.rs:11250) asserts edge's copy against a hardcoded literal:It pins edge's own implementation, not the server's. When the server rule changed the mirror forked and this test still passed — it can only ever detect edge editing its own copy, which is the one case that is not the risk. The doc comment above
NODE_ALIAS_SUFFIXsays "if it ever drifts, that test is where it surfaces"; it did drift, and it did not surface.Edge is right that it cannot import the constant — CIRISServer rides edge, and a dependency that way inverts the substrate relationship. But that argument rules out importing, not being told.
Impact
Every first-run agent node on server ≥ 0.5.195. Provisioning reports success; edge then fail-closes on a key that does not exist; the node cannot bring up its transport identity at all. There is no caller-side workaround — there is no alias
Awheresubstitute(A, agent→node)equalsA + "-node", so no choice ofhost_keystore_aliasreconciles the two rules.Proposed fix
Let the caller name the key.
provision_node_identityalready returns the mintedkey_id; the value crosses the boundary on the way out and has nowhere to go on the way back in.Add an optional node keystore alias to
init_edge_runtime(andopen_node_identity_halves):node_alias(host_keystore_alias), so existing deployments are unaffected.That makes the name travel as a parameter and leaves exactly one component naming the key: the one that mints it. Retiring the mirrored
node_aliasonce callers pass the name would remove the whole class.If the constant should instead live in shared substrate (
ciris-verify-core::fedcodeis the natural home — it already ownsderive_key_id, the function that makes the alias identity-bearing), that works too and is a strictly bigger change. Either way, two implementations of one rule is the thing to remove.Note on #541
#541 is closed, and it is the issue that introduced this mirror. Edge's code cites it as the rationale, so nothing currently tracks the fork it left behind — hence this issue rather than a comment there.