Skip to content

Commit 31e6f87

Browse files
mattleavertonclaude
andcommitted
fix: accept string-encoded numeric keys in context metadata extraction
extract_context_metadata and extract_provenance only matched integer msgpack keys, but CLIENT_SPEC.md §3.1 mandates accepting digit-strings too. Go's msgpack encoder produces string keys, so Kilroy's provenance data was silently dropped. Add key_to_tag helper (matching projection layer logic) and unit tests for both key formats. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b3dccfd commit 31e6f87

1 file changed

Lines changed: 121 additions & 17 deletions

File tree

server/src/store.rs

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -602,17 +602,9 @@ fn extract_context_metadata(payload: &[u8]) -> Option<ContextMetadata> {
602602
};
603603

604604
// Find key 30 (context_metadata)
605-
let context_metadata_value = map.iter().find_map(|(k, v)| {
606-
let key = match k {
607-
Value::Integer(i) => i.as_u64()?,
608-
_ => return None,
609-
};
610-
if key == 30 {
611-
Some(v)
612-
} else {
613-
None
614-
}
615-
})?;
605+
let context_metadata_value =
606+
map.iter()
607+
.find_map(|(k, v)| if key_to_tag(k)? == 30 { Some(v) } else { None })?;
616608

617609
let metadata_map = match context_metadata_value {
618610
Value::Map(m) => m,
@@ -622,9 +614,9 @@ fn extract_context_metadata(payload: &[u8]) -> Option<ContextMetadata> {
622614
let mut metadata = ContextMetadata::default();
623615

624616
for (k, v) in metadata_map.iter() {
625-
let key = match k {
626-
Value::Integer(i) => i.as_u64().unwrap_or(0),
627-
_ => continue,
617+
let key = match key_to_tag(k) {
618+
Some(t) => t,
619+
None => continue,
628620
};
629621

630622
match key {
@@ -685,9 +677,9 @@ fn extract_provenance(prov_map: &[(Value, Value)]) -> Provenance {
685677
let mut prov = Provenance::default();
686678

687679
for (k, v) in prov_map.iter() {
688-
let key = match k {
689-
Value::Integer(i) => i.as_u64().unwrap_or(0),
690-
_ => continue,
680+
let key = match key_to_tag(k) {
681+
Some(t) => t,
682+
None => continue,
691683
};
692684

693685
match key {
@@ -741,6 +733,20 @@ fn extract_provenance(prov_map: &[(Value, Value)]) -> Provenance {
741733
prov
742734
}
743735

736+
/// Interpret a msgpack map key as a numeric tag.
737+
/// Accepts both integer keys and string-encoded integers (e.g., "30").
738+
/// Matches the projection layer's key_to_tag behavior (CLIENT_SPEC.md §3.1).
739+
fn key_to_tag(key: &Value) -> Option<u64> {
740+
match key {
741+
Value::Integer(int) => int.as_u64().or_else(|| {
742+
int.as_i64()
743+
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
744+
}),
745+
Value::String(s) => s.as_str()?.parse::<u64>().ok(),
746+
_ => None,
747+
}
748+
}
749+
744750
fn extract_string(v: &Value) -> Option<String> {
745751
if let Value::String(s) = v {
746752
s.as_str().map(|s| s.to_string())
@@ -784,3 +790,101 @@ fn extract_string_map(v: &Value) -> Option<HashMap<String, String>> {
784790
None
785791
}
786792
}
793+
794+
#[cfg(test)]
795+
mod tests {
796+
use super::*;
797+
use rmpv::Value;
798+
799+
fn str_val(s: &str) -> Value {
800+
Value::String(s.into())
801+
}
802+
803+
fn int_val(n: u64) -> Value {
804+
Value::Integer(rmpv::Integer::from(n))
805+
}
806+
807+
#[test]
808+
fn key_to_tag_accepts_integer_keys() {
809+
assert_eq!(key_to_tag(&int_val(30)), Some(30));
810+
assert_eq!(key_to_tag(&int_val(0)), Some(0));
811+
assert_eq!(key_to_tag(&int_val(80)), Some(80));
812+
}
813+
814+
#[test]
815+
fn key_to_tag_accepts_string_encoded_integers() {
816+
assert_eq!(key_to_tag(&str_val("30")), Some(30));
817+
assert_eq!(key_to_tag(&str_val("1")), Some(1));
818+
assert_eq!(key_to_tag(&str_val("80")), Some(80));
819+
assert_eq!(key_to_tag(&str_val("0")), Some(0));
820+
}
821+
822+
#[test]
823+
fn key_to_tag_rejects_non_numeric_strings() {
824+
assert_eq!(key_to_tag(&str_val("hello")), None);
825+
assert_eq!(key_to_tag(&str_val("")), None);
826+
assert_eq!(key_to_tag(&str_val("-1")), None);
827+
}
828+
829+
#[test]
830+
fn key_to_tag_rejects_other_types() {
831+
assert_eq!(key_to_tag(&Value::Boolean(true)), None);
832+
assert_eq!(key_to_tag(&Value::Nil), None);
833+
}
834+
835+
/// Build a msgpack payload where the outer map uses string keys and
836+
/// the context_metadata inner maps also use string keys — matching
837+
/// what Go's msgpack encoder produces.
838+
fn encode_context_metadata_with_string_keys(
839+
client_tag: &str,
840+
service_name: &str,
841+
correlation_id: &str,
842+
) -> Vec<u8> {
843+
let provenance = Value::Map(vec![
844+
(str_val("40"), str_val(service_name)),
845+
(str_val("12"), str_val(correlation_id)),
846+
]);
847+
let context_metadata = Value::Map(vec![
848+
(str_val("1"), str_val(client_tag)),
849+
(str_val("10"), provenance),
850+
]);
851+
let payload = Value::Map(vec![(str_val("30"), context_metadata)]);
852+
let mut buf = Vec::new();
853+
rmpv::encode::write_value(&mut buf, &payload).unwrap();
854+
buf
855+
}
856+
857+
fn encode_context_metadata_with_integer_keys(client_tag: &str, service_name: &str) -> Vec<u8> {
858+
let provenance = Value::Map(vec![(int_val(40), str_val(service_name))]);
859+
let context_metadata = Value::Map(vec![
860+
(int_val(1), str_val(client_tag)),
861+
(int_val(10), provenance),
862+
]);
863+
let payload = Value::Map(vec![(int_val(30), context_metadata)]);
864+
let mut buf = Vec::new();
865+
rmpv::encode::write_value(&mut buf, &payload).unwrap();
866+
buf
867+
}
868+
869+
#[test]
870+
fn extract_context_metadata_with_string_keys() {
871+
let payload =
872+
encode_context_metadata_with_string_keys("kilroy/run-123", "kilroy", "run-123");
873+
let meta = extract_context_metadata(&payload).expect("should extract metadata");
874+
assert_eq!(meta.client_tag.as_deref(), Some("kilroy/run-123"));
875+
876+
let prov = meta.provenance.expect("should have provenance");
877+
assert_eq!(prov.service_name.as_deref(), Some("kilroy"));
878+
assert_eq!(prov.correlation_id.as_deref(), Some("run-123"));
879+
}
880+
881+
#[test]
882+
fn extract_context_metadata_with_integer_keys() {
883+
let payload = encode_context_metadata_with_integer_keys("test-tag", "my-service");
884+
let meta = extract_context_metadata(&payload).expect("should extract metadata");
885+
assert_eq!(meta.client_tag.as_deref(), Some("test-tag"));
886+
887+
let prov = meta.provenance.expect("should have provenance");
888+
assert_eq!(prov.service_name.as_deref(), Some("my-service"));
889+
}
890+
}

0 commit comments

Comments
 (0)