Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions evidence/CIRISServer.cc_impl.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
#
# ─── RESOLVED ──────────────────────────────────────────────────────────────────
decimal_id claim_id repo path#symbol crate@version
3.1 CLM-nsproc-dimension CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.239
4.4.2 CLM-nsproc-aggregation-policy CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.239
4.4.3.4.3 CLM-nsproc-cohort-scope CIRISServer src/graph_config.rs#set_config ciris-server@v0.5.239
4.2.2.1 CLM-nsproc-attestation-evidence CIRISServer src/hardware_attestation.rs#admit_hardware_class_against_root ciris-server@v0.5.239
2.6.6.1 CLM-location CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.239
2.6.6 CLM-canonicalization-cell CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.239
3.4.5 CLM-capacity-score CIRISServer src/scorer.rs#score_and_emit ciris-server@v0.5.239
3.3.7 CLM-consent-directed CIRISServer src/peer.rs#emit_replication_consent ciris-server@v0.5.239
3.1 CLM-nsproc-dimension CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.240
4.4.2 CLM-nsproc-aggregation-policy CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.240
4.4.3.4.3 CLM-nsproc-cohort-scope CIRISServer src/graph_config.rs#set_config ciris-server@v0.5.240
4.2.2.1 CLM-nsproc-attestation-evidence CIRISServer src/hardware_attestation.rs#admit_hardware_class_against_root ciris-server@v0.5.240
2.6.6.1 CLM-location CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.240
2.6.6 CLM-canonicalization-cell CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.240
3.4.5 CLM-capacity-score CIRISServer src/scorer.rs#score_and_emit ciris-server@v0.5.240
3.3.7 CLM-consent-directed CIRISServer src/peer.rs#emit_replication_consent ciris-server@v0.5.240
#
# ─── DECLARED GAPS ─────────────────────────────────────────────────────────────
# Controls the Constitution attributes to this repo that are NOT enforced here.
Expand Down
79 changes: 70 additions & 9 deletions src/contacts_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2815,6 +2815,18 @@ async fn add_contact_for_owner(st: ChatState, owner: Owner, key_id: String) -> R
ciris_edge::contact::ContactResolution::Known(_) => "federation",
ciris_edge::contact::ContactResolution::ReadyFromCode { .. } => "direct",
};
// The nodes the code names (lightnet: the person's announced nodes). Their
// Key rows are what lets this node ADMIT the person's owner-binding once it
// arrives — persist refuses a binding whose attested node it has never met —
// so the resolve pass pulls them first (tracked: CIRISEdge#907).
let code_nodes: Vec<String> = match &resolution {
ciris_edge::contact::ContactResolution::ReadyFromCode { admission, .. } => admission
.owned_nodes
.iter()
.map(|n| n.key_id.clone())
.collect(),
ciris_edge::contact::ContactResolution::Known(_) => Vec::new(),
};
let key_id = match resolution {
// Already in the directory: nothing to admit, go straight to consent.
// Re-pasting a code for someone already known lands here too, which is
Expand Down Expand Up @@ -3023,6 +3035,9 @@ async fn add_contact_for_owner(st: ChatState, owner: Owner, key_id: String) -> R
// report unknown rather than asserting either answer.
None => (false, 0),
};
// Every add, pasted once or completed by the pending pass: a person this
// node cannot resolve to a node is tracked until their owner-binding lands.
pending_contacts::track_until_resolved(&st, &owner, &key_id, &code_nodes).await;
// Whether "not reachable" is offline or STRUCTURAL: an un-announced peer
// has no node here to be reachable AT, and an un-announced self cannot be
// delivered to however reachable the peer is. Said now, at the contact,
Expand Down Expand Up @@ -5786,6 +5801,8 @@ pub(crate) mod pending_contacts {
struct Unresolved {
st: ChatState,
owner: Owner,
/// The nodes the contact code named — pulled by key before the binding.
code_nodes: Vec<String>,
since: Instant,
asked: u32,
last_report: Option<Instant>,
Expand Down Expand Up @@ -5980,7 +5997,12 @@ pub(crate) mod pending_contacts {

/// Track an added contact until their person resolves to a node here, and
/// ask for their records now. A no-op when they already resolve.
pub(crate) async fn track_until_resolved(st: &ChatState, owner: &Owner, key_id: &str) {
pub(crate) async fn track_until_resolved(
st: &ChatState,
owner: &Owner,
key_id: &str,
code_nodes: &[String],
) {
if !nodes_of(st, key_id).await.is_empty() {
return;
}
Expand All @@ -5994,6 +6016,7 @@ pub(crate) mod pending_contacts {
Unresolved {
st: st.clone(),
owner: owner.clone(),
code_nodes: code_nodes.to_vec(),
since: Instant::now(),
asked: 0,
last_report: None,
Expand All @@ -6011,13 +6034,22 @@ pub(crate) mod pending_contacts {
/// One pass over the unresolved contacts: drop the resolved and expired,
/// re-ask for the rest, and say so out loud when one stalls.
async fn resolve_pass() {
let due: Vec<(Key, ChatState, Owner, Instant)> = {
type ResolveDue = (Key, ChatState, Owner, Vec<String>, Instant);
let due: Vec<ResolveDue> = {
let t = unresolved().lock().expect("unresolved contacts lock");
t.iter()
.map(|(k, u)| (k.clone(), u.st.clone(), u.owner.clone(), u.since))
.map(|(k, u)| {
(
k.clone(),
u.st.clone(),
u.owner.clone(),
u.code_nodes.clone(),
u.since,
)
})
.collect()
};
for (k, st, owner, since) in due {
for (k, st, owner, code_nodes, since) in due {
let key_id = &k.1;
if since.elapsed() > PENDING_TTL {
unresolved().lock().expect("lock").remove(&k);
Expand All @@ -6042,7 +6074,35 @@ pub(crate) mod pending_contacts {
crate::compose::kick_replication("contact resolved to a node");
continue;
}
let (peers, failed) = pull_person_records(&st, &owner, key_id).await;
// The node keys first: persist admits `delegates_to(owner → node)` only
// once it holds the node's Key row, so a binding that arrives before
// it is refused (`attested_key_id … resolves as neither`, the
// 2026-10-27 run) and only re-admitted when asked for again — which
// the person-records Pull below does every pass.
let mut missing_nodes = Vec::new();
for node in &code_nodes {
let held = matches!(
st.engine
.federation_directory()
.lookup_public_key(node)
.await,
Ok(Some(_))
);
if !held {
missing_nodes.push(node.clone());
let _ = pull_person_records(&st, &owner, node).await;
}
}
// Ask for the person's records only once the code's node keys are
// held: a binding refused for its unknown node is then held back by
// edge for a backoff window (CIRISEdge#544) before it is re-offered.
// A code naming a node no peer ever produces must not block forever,
// so after STALL_AFTER the person's records are asked for regardless.
let (peers, failed) = if missing_nodes.is_empty() || since.elapsed() >= STALL_AFTER {
pull_person_records(&st, &owner, key_id).await
} else {
(Vec::new(), Vec::new())
};
let report = {
let mut t = unresolved().lock().expect("lock");
t.get_mut(&k).and_then(|u| {
Expand All @@ -6064,10 +6124,13 @@ pub(crate) mod pending_contacts {
asked,
pulled_from = ?peers,
pull_failed = ?failed,
code_nodes = ?code_nodes,
code_node_keys_missing = ?missing_nodes,
"contacts: CONTACT UNRESOLVED — the person is a contact but resolves to no node \
here, so a grant or message naming them has nowhere to go. This node asks each \
peer for their records every pass. Where to look: (1) pulled_from=[] → no peer \
to ask; (2) on the peer, `subject Pull refused … not publicly pullable` means \
peer for their records every pass, and the code's nodes' keys first. Where to look: \
(1) pulled_from=[] → no peer to ask; code_node_keys_missing non-empty → the \
binding cannot be admitted until those keys land; (2) on the peer, `subject Pull refused … not publicly pullable` means \
it will not serve their owner-binding on request (tracked: CIRISEdge#907); \
(3) on the peer, is there a live federation `delegates_to` from this key_id to \
a node at all — if not, they have not announced"
Expand Down Expand Up @@ -6134,7 +6197,6 @@ pub(crate) mod pending_contacts {
}
continue;
}
let st_for_resolve = st.clone();
let resp = add_contact_for_owner(st, owner.clone(), k.1.clone()).await;
let status = resp.status();
if status == axum::http::StatusCode::ACCEPTED {
Expand All @@ -6143,7 +6205,6 @@ pub(crate) mod pending_contacts {
table().lock().expect("lock").remove(&k);
if status.is_success() {
tracing::info!(owner = %owner.key_id, key_id, waited_secs = since.elapsed().as_secs(), asked, "contacts: pending contact COMPLETED — the key arrived and matched the code");
track_until_resolved(&st_for_resolve, &owner, &key_id).await;
} else {
tracing::warn!(owner = %owner.key_id, key_id, status = status.as_u16(), "contacts: pending contact discarded — the add path refused it once the key arrived (see the refusal logged just above)");
}
Expand Down
54 changes: 50 additions & 4 deletions tests/contact_key_pull_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,9 @@ async fn a_contacts_announced_owner_binding_arrives_on_a_subject_pull() {
carry_key(&canonical, &asker.engine, &asker.key_id).await;
carry_key(&asker, &canonical.engine, &canonical.key_id).await;
carry_key(&asker, &canonical.engine, &contact).await;
carry_key(&asker, &canonical.engine, &contact_node).await;
// NOT the contact's node key: the 2026-10-27 run's asker held the person's
// key (the add) but not their node's, and persist refused the binding. The
// node is named by the contact code (`owned_nodes`); its key is pulled first.
accept_shared_root(&[&asker, &canonical], "resolve-root", &canonical.key_id).await;
assert!(
announced_nodes_of(&asker.engine, &contact)
Expand Down Expand Up @@ -615,14 +617,48 @@ async fn a_contacts_announced_owner_binding_arrives_on_a_subject_pull() {
EnvelopeKind::Attestation,
SessionRole::Responder,
Arc::new(DirectoryStateAdapter::new(canon_bridge.clone()).with_peer(asker.key_id.clone())),
Arc::new(MutableDirectoryStateAdapter::new(canon_bridge)),
Arc::new(MutableDirectoryStateAdapter::new(canon_bridge.clone())),
);

// What `pull_subject_testimony(canonical, contact)` sends on this plane.
// The resolve pass: the code's node key first (Key plane, public), then the
// person's records again.
let (ka_tx, mut ka_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let (kc_tx, mut kc_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let asker_key_coord = ReplicationCoordinator::new(
Arc::new(Loopback { to_peer: kc_tx }),
canonical.key_id.clone(),
EnvelopeKind::Key,
SessionRole::Initiator,
Arc::new(
DirectoryStateAdapter::new(asker_bridge.clone()).with_peer(canonical.key_id.clone()),
),
Arc::new(MutableDirectoryStateAdapter::new(asker_bridge.clone())),
);
let canon_key_coord = ReplicationCoordinator::new(
Arc::new(Loopback { to_peer: ka_tx }),
asker.key_id.clone(),
EnvelopeKind::Key,
SessionRole::Responder,
Arc::new(DirectoryStateAdapter::new(canon_bridge.clone()).with_peer(asker.key_id.clone())),
Arc::new(MutableDirectoryStateAdapter::new(canon_bridge.clone())),
);
asker_key_coord
.start_pull(&contact_node)
.await
.expect("Key subject Pull for the code's node sends");
pump(&asker_key_coord, &mut ka_rx, &canon_key_coord, &mut kc_rx).await;
assert!(
asker_dir_holds(&asker, &contact_node).await,
"the code's node key arrived on the public Key plane"
);
// Then what `pull_subject_testimony(canonical, contact)` sends on the
// Attestation plane. Asked BEFORE the node key, persist refuses the binding
// (`attested_key_id … resolves as neither` — the 2026-10-27 deadlock) and
// edge then backs off re-asking it (CIRISEdge#544).
asker_coord
.start_pull(&contact)
.await
.expect("Attestation subject Pull sends");
.expect("the person-records Pull");
pump(&asker_coord, &mut a_rx, &canon_coord, &mut c_rx).await;

assert!(
Expand All @@ -634,3 +670,13 @@ async fn a_contacts_announced_owner_binding_arrives_on_a_subject_pull() {
directed grant naming them has a node to reach (tracked: CIRISEdge#907)"
);
}

async fn asker_dir_holds(n: &Node, key_id: &str) -> bool {
matches!(
n.engine
.federation_directory()
.lookup_public_key(key_id)
.await,
Ok(Some(_))
)
}
Loading