1use std::collections::{BTreeMap, BTreeSet};
11
12use kmp_domain::{
13 MemoryDimensionIdentity, SourceKind, TemporalCoordinate, compare_temporal_instants,
14 label_resemblances,
15};
16
17use crate::ApplicationError;
18use crate::commands::{UpdateContextChange, UpdateContextCommand};
19use crate::memory::{
20 EntryLabelData, ExistingMemoryRefs, LabelPolicy, MemoryCoordinateData, MemoryDimensionData,
21 MemoryRelabelCommand, MemoryRelabelOutcome, ResemblingLabelData,
22};
23
24use super::dimension_registry::DimensionRegistry;
25use super::ref_boundary::{validate_ref_token, validate_supplied_entry_ref};
26
27pub const RELABEL_METHOD: &str = "kmp_relabel";
30
31pub const RELABEL_ENTITY_KIND: &str = "memory_relabel";
34
35pub fn translate_memory_relabel(
41 command: &MemoryRelabelCommand,
42 existing: &ExistingMemoryRefs,
43 current: &[TemporalCoordinate],
44) -> Result<(UpdateContextCommand, MemoryRelabelOutcome), ApplicationError> {
45 validate_command(command)?;
46 if !existing.refs.contains(&command.ref_id) {
47 return Err(ApplicationError::NotFound(format!(
48 "`{}` is not a memory of `{}`",
49 command.ref_id, command.about
50 )));
51 }
52 if current.is_empty() {
53 return Err(ApplicationError::Validation(format!(
54 "`{}` stands in no label, so it is not an entry that can be relabelled",
55 command.ref_id
56 )));
57 }
58
59 let standing = standing_labels(current);
60 let removed = removals(command, &standing)?;
61 let additions = additions(command, existing, &standing, &removed, current)?;
62
63 let mut labels = standing.keys().cloned().collect::<BTreeSet<_>>();
64 for (label, _) in &removed {
65 labels.remove(label);
66 }
67 for added in &additions.added {
68 labels.insert(added.clone());
69 }
70 if labels.is_empty() {
71 return Err(ApplicationError::Validation(format!(
72 "`{}` would stand in no label; an entry stands in at least one, which is where its time lives. Add a label before taking the last one off",
73 command.ref_id
74 )));
75 }
76
77 if command.label_policy == LabelPolicy::Refuse && !additions.resembling.is_empty() {
78 return Err(ApplicationError::Validation(format!(
79 "labels resemble ones the about already holds: {}. Reuse the existing label, or name the key in `intended_new` to insist on the new one",
80 additions
81 .resembling
82 .iter()
83 .map(|label| label.why.clone())
84 .collect::<Vec<_>>()
85 .join(" ")
86 )));
87 }
88
89 let mut changes = Vec::new();
90 for dimension in &additions.dimensions {
91 changes.push(change(
92 "memory_dimension",
93 &dimension.id,
94 serde_json::to_string(dimension),
95 "KMP memory dimension ingest",
96 vec![dimension.id.clone()],
97 )?);
98 }
99 let provenance = command.provenance.as_ref();
100 let payload = serde_json::json!({
101 "ref": command.ref_id,
102 "add": additions.coordinates,
103 "remove": removed
104 .iter()
105 .map(|(label, scope_id)| serde_json::json!({
106 "dimension": label.key,
107 "scope_id": scope_id,
108 }))
109 .collect::<Vec<_>>(),
110 "why": command.why.trim(),
111 "actor": provenance.map(|provenance| provenance.source_agent.as_str()),
112 "observed_at": provenance.and_then(|provenance| provenance.observed_at.as_deref()),
113 });
114 let mut scopes = additions
115 .coordinates
116 .iter()
117 .map(|coordinate| coordinate.scope_id.clone())
118 .collect::<Vec<_>>();
119 scopes.extend(removed.iter().map(|(_, scope_id)| scope_id.clone()));
120 changes.push(change(
121 RELABEL_ENTITY_KIND,
122 &command.ref_id,
123 serde_json::to_string(&payload),
124 command.why.trim(),
125 scopes,
126 )?);
127
128 let outcome = MemoryRelabelOutcome {
129 about: command.about.clone(),
130 ref_id: command.ref_id.clone(),
131 added: additions.added,
132 removed: removed.into_iter().map(|(label, _)| label).collect(),
133 labels: labels.into_iter().collect(),
134 created_dimensions: additions.created_dimensions,
135 warnings: additions
136 .resembling
137 .iter()
138 .map(|label| label.why.clone())
139 .collect(),
140 resembling_labels: additions.resembling,
141 read_after_write_ready: false,
142 };
143
144 Ok((
145 UpdateContextCommand {
146 root_node_id: command.about.clone(),
147 role: "memory".to_string(),
148 work_item_id: command.idempotency_key.clone(),
149 changes,
150 expected_revision: None,
151 expected_content_hash: None,
152 idempotency_key: Some(command.idempotency_key.clone()),
153 logical_digest: Some(relabel_logical_digest(command)),
154 requested_by: provenance.map(|provenance| provenance.source_agent.clone()),
155 },
156 outcome,
157 ))
158}
159
160struct Additions {
164 added: Vec<EntryLabelData>,
165 coordinates: Vec<MemoryCoordinateData>,
166 dimensions: Vec<MemoryDimensionData>,
167 created_dimensions: Vec<String>,
168 resembling: Vec<ResemblingLabelData>,
169}
170
171fn validate_command(command: &MemoryRelabelCommand) -> Result<(), ApplicationError> {
172 require_non_empty(&command.about, "about")?;
173 validate_ref_token("about", &command.about).map_err(ApplicationError::Validation)?;
174 require_non_empty(&command.ref_id, "ref")?;
175 validate_supplied_entry_ref(&command.about, "ref", &command.ref_id)
176 .map_err(ApplicationError::Validation)?;
177 require_non_empty(&command.why, "why")?;
178 require_non_empty(&command.idempotency_key, "idempotency_key")?;
179 if command.add.is_empty() && command.remove.is_empty() {
180 return Err(ApplicationError::Validation(
181 "nothing to relabel: give `add`, `remove` or both".to_string(),
182 ));
183 }
184 if let Some(provenance) = command.provenance.as_ref() {
185 SourceKind::parse(&provenance.source_kind).map_err(|error| {
186 ApplicationError::Validation(format!(
187 "memory provenance source_kind is invalid: {error}"
188 ))
189 })?;
190 require_non_empty(&provenance.source_agent, "provenance.source_agent")?;
191 require_non_empty(
192 provenance.observed_at.as_deref().unwrap_or_default(),
193 "provenance.observed_at",
194 )?;
195 }
196 Ok(())
197}
198
199fn standing_labels(
202 current: &[TemporalCoordinate],
203) -> BTreeMap<EntryLabelData, &TemporalCoordinate> {
204 current
205 .iter()
206 .map(|coordinate| {
207 (
208 EntryLabelData {
209 key: coordinate.dimension().to_string(),
210 value: bare_value(coordinate.scope_id()),
211 },
212 coordinate,
213 )
214 })
215 .collect()
216}
217
218fn bare_value(scope_id: &str) -> String {
219 MemoryDimensionIdentity::parse(scope_id)
220 .map(|identity| identity.dimension_id().to_string())
221 .unwrap_or_else(|| scope_id.trim().to_string())
222}
223
224fn removals(
227 command: &MemoryRelabelCommand,
228 standing: &BTreeMap<EntryLabelData, &TemporalCoordinate>,
229) -> Result<Vec<(EntryLabelData, String)>, ApplicationError> {
230 let mut seen = BTreeSet::new();
231 let mut removed = Vec::new();
232 for label in &command.remove {
233 let label = normalized_label(label, "remove[]")?;
234 if !seen.insert(label.clone()) {
235 return Err(ApplicationError::Validation(format!(
236 "`{}={}` is given twice in `remove`",
237 label.key, label.value
238 )));
239 }
240 let Some(coordinate) = standing.get(&label) else {
241 return Err(ApplicationError::Validation(format!(
242 "`{}` does not stand in `{}={}`; it stands in {}",
243 command.ref_id,
244 label.key,
245 label.value,
246 describe_labels(standing.keys())
247 )));
248 };
249 removed.push((label, coordinate.scope_id().to_string()));
250 }
251 Ok(removed)
252}
253
254fn additions(
255 command: &MemoryRelabelCommand,
256 existing: &ExistingMemoryRefs,
257 standing: &BTreeMap<EntryLabelData, &TemporalCoordinate>,
258 removed: &[(EntryLabelData, String)],
259 current: &[TemporalCoordinate],
260) -> Result<Additions, ApplicationError> {
261 let catalogue = existing
262 .labels
263 .iter()
264 .map(|(kind, value)| (kind.as_str(), value.as_str()))
265 .collect::<Vec<_>>();
266 let clocks = inherited_clocks(current);
267 let mut max_sequences = existing.max_sequences.clone();
268 let mut added = Vec::new();
269 let mut coordinates = Vec::new();
270 let mut dimensions = Vec::new();
271 let mut created_dimensions = Vec::new();
272 let mut resembling = Vec::new();
273 let mut values_added = BTreeSet::new();
274 let mut registry = DimensionRegistry::new(&command.about, existing)?;
275
276 for label in &command.add {
277 let label = normalized_label(label, "add[]")?;
278 let reference = MemoryDimensionIdentity::new(&command.about, &label.key, &label.value)
280 .map_err(|error| ApplicationError::Validation(error.to_string()))?
281 .node_id();
282 let scope_id = registry.declare(&label.key, &reference)?;
283 if !values_added.insert(label.clone()) {
284 return Err(ApplicationError::Validation(format!(
285 "`add` repeats `{}={}`",
286 label.key, label.value
287 )));
288 }
289 if removed.iter().any(|(removed, _)| *removed == label) {
290 return Err(ApplicationError::Validation(format!(
291 "`{}={}` is both added and removed",
292 label.key, label.value
293 )));
294 }
295 if standing.contains_key(&label) {
296 return Err(ApplicationError::Validation(format!(
297 "`{}` already stands in `{}={}`; it stands in {}",
298 command.ref_id,
299 label.key,
300 label.value,
301 describe_labels(standing.keys())
302 )));
303 }
304 if !existing.dimensions.contains(&scope_id) {
305 if !command.intended_new.contains(&label.key) {
306 resembling.extend(
307 label_resemblances(&label.key, &label.value, catalogue.iter().copied())
308 .into_iter()
309 .map(|resemblance| ResemblingLabelData {
310 key: resemblance.key().to_string(),
311 value: resemblance.value().to_string(),
312 existing_key: resemblance.existing_key().to_string(),
313 existing_value: resemblance.existing_value().to_string(),
314 kind: resemblance.kind().name().to_string(),
315 why: resemblance.why(),
316 }),
317 );
318 }
319 let mut metadata = BTreeMap::new();
320 metadata.insert("memory_about".to_string(), command.about.clone());
321 metadata.insert("memory_dimension_id".to_string(), label.value.clone());
322 dimensions.push(MemoryDimensionData {
323 id: scope_id.clone(),
324 kind: label.key.clone(),
325 title: Some(format!("{}={}", label.key, label.value)),
326 metadata,
327 });
328 created_dimensions.push(scope_id.clone());
329 }
330
331 let frontier = max_sequences
332 .entry((label.key.clone(), scope_id.clone()))
333 .or_default();
334 *frontier = frontier.checked_add(1).ok_or_else(|| {
335 ApplicationError::Validation(
336 "memory coordinate sequence space is exhausted".to_string(),
337 )
338 })?;
339 coordinates.push(MemoryCoordinateData {
340 dimension: label.key.clone(),
341 scope_id,
342 occurred_at: clocks.occurred_at.clone(),
343 observed_at: clocks.observed_at.clone(),
344 ingested_at: clocks.ingested_at.clone(),
345 valid_from: clocks.valid_from.clone(),
346 valid_until: clocks.valid_until.clone(),
347 sequence: Some(*frontier),
348 rank: None,
349 metadata: BTreeMap::new(),
350 });
351 added.push(label);
352 }
353
354 Ok(Additions {
355 added,
356 coordinates,
357 dimensions,
358 created_dimensions,
359 resembling,
360 })
361}
362
363struct InheritedClocks {
368 occurred_at: Option<String>,
369 observed_at: Option<String>,
370 ingested_at: Option<String>,
371 valid_from: Option<String>,
372 valid_until: Option<String>,
373}
374
375fn inherited_clocks(current: &[TemporalCoordinate]) -> InheritedClocks {
376 InheritedClocks {
377 occurred_at: earliest(current.iter().filter_map(TemporalCoordinate::occurred_at)),
378 observed_at: earliest(current.iter().filter_map(TemporalCoordinate::observed_at)),
379 ingested_at: earliest(current.iter().filter_map(TemporalCoordinate::ingested_at)),
380 valid_from: earliest(current.iter().filter_map(TemporalCoordinate::valid_from)),
381 valid_until: latest(current.iter().filter_map(TemporalCoordinate::valid_until)),
382 }
383}
384
385fn earliest<'a>(instants: impl Iterator<Item = &'a str>) -> Option<String> {
386 instants
387 .reduce(
388 |kept, candidate| match compare_temporal_instants(candidate, kept) {
389 Some(std::cmp::Ordering::Less) => candidate,
390 _ => kept,
391 },
392 )
393 .map(str::to_string)
394}
395
396fn latest<'a>(instants: impl Iterator<Item = &'a str>) -> Option<String> {
397 instants
398 .reduce(
399 |kept, candidate| match compare_temporal_instants(candidate, kept) {
400 Some(std::cmp::Ordering::Greater) => candidate,
401 _ => kept,
402 },
403 )
404 .map(str::to_string)
405}
406
407fn normalized_label(
408 label: &EntryLabelData,
409 field: &str,
410) -> Result<EntryLabelData, ApplicationError> {
411 let key = label.key.trim();
412 let value = label.value.trim();
413 require_non_empty(key, &format!("{field}.key"))?;
414 require_non_empty(value, &format!("{field}.value"))?;
415 validate_ref_token(&format!("{field}.key"), key).map_err(ApplicationError::Validation)?;
416 Ok(EntryLabelData {
417 key: key.to_string(),
418 value: value.to_string(),
419 })
420}
421
422fn describe_labels<'a>(labels: impl Iterator<Item = &'a EntryLabelData>) -> String {
423 let described = labels
424 .map(|label| format!("`{}={}`", label.key, label.value))
425 .collect::<Vec<_>>();
426 if described.is_empty() {
427 "no label".to_string()
428 } else {
429 described.join(", ")
430 }
431}
432
433fn change(
434 entity_kind: &str,
435 entity_id: &str,
436 payload: Result<String, serde_json::Error>,
437 reason: &str,
438 scopes: Vec<String>,
439) -> Result<UpdateContextChange, ApplicationError> {
440 Ok(UpdateContextChange {
441 operation: "UPSERT".to_string(),
442 entity_kind: entity_kind.to_string(),
443 entity_id: entity_id.to_string(),
444 payload_json: payload.map_err(|error| {
445 ApplicationError::Validation(format!("relabel payload could not serialize: {error}"))
446 })?,
447 reason: reason.to_string(),
448 scopes,
449 })
450}
451
452fn require_non_empty(value: &str, field: &str) -> Result<(), ApplicationError> {
453 if value.trim().is_empty() {
454 Err(ApplicationError::Validation(format!(
455 "{field} cannot be empty"
456 )))
457 } else {
458 Ok(())
459 }
460}
461
462pub fn replayed_relabel_outcome(
467 command: &MemoryRelabelCommand,
468 current: &[TemporalCoordinate],
469) -> Result<MemoryRelabelOutcome, ApplicationError> {
470 let pairs = |labels: &[EntryLabelData], field: &str| {
471 labels
472 .iter()
473 .map(|label| normalized_label(label, field))
474 .collect::<Result<Vec<_>, ApplicationError>>()
475 };
476 Ok(MemoryRelabelOutcome {
477 about: command.about.clone(),
478 ref_id: command.ref_id.clone(),
479 added: pairs(&command.add, "add[]")?,
480 removed: pairs(&command.remove, "remove[]")?,
481 labels: standing_labels(current).into_keys().collect(),
482 created_dimensions: Vec::new(),
483 resembling_labels: Vec::new(),
484 read_after_write_ready: true,
485 warnings: vec![format!(
486 "idempotency_key `{}` was already accepted with this relabel; returning its success without writing again",
487 command.idempotency_key
488 )],
489 })
490}
491
492pub fn relabel_logical_digest(command: &MemoryRelabelCommand) -> String {
495 use sha2::{Digest, Sha256};
496 let mut hasher = Sha256::new();
497 hasher.update(command.about.as_bytes());
498 hasher.update([0]);
499 hasher.update(command.ref_id.as_bytes());
500 hasher.update([0]);
501 let labels = serde_json::to_vec(&(&command.add, &command.remove))
502 .expect("labels serialize: they hold only strings");
503 hasher.update(&labels);
504 hasher.update([0]);
505 hasher.update(command.why.trim().as_bytes());
506 hasher.update([0]);
507 if let Some(provenance) = &command.provenance {
508 let provenance =
509 serde_json::to_vec(provenance).expect("provenance serializes: it holds only strings");
510 hasher.update(&provenance);
511 }
512 format!("{:x}", hasher.finalize())
513}
514
515#[cfg(test)]
516mod tests {
517 use std::collections::{BTreeMap, BTreeSet};
518
519 use kmp_domain::{RelationExplanation, RelationSemanticClass, TemporalCoordinate};
520
521 use crate::ApplicationError;
522 use crate::memory::{
523 EntryLabelData, ExistingMemoryRefs, LabelPolicy, MemoryProvenanceData, MemoryRelabelCommand,
524 };
525
526 use super::{RELABEL_ENTITY_KIND, translate_memory_relabel};
527
528 const ABOUT: &str = "project:kmp";
529 const REF: &str = "project:kmp:decision:relabel";
530 const PROCESS: &str = "label:v1:project%3Akmp:agentic_process:harness";
531 const TASK: &str = "label:v1:project%3Akmp:task:launch";
532
533 fn label(key: &str, value: &str) -> EntryLabelData {
534 EntryLabelData {
535 key: key.to_string(),
536 value: value.to_string(),
537 }
538 }
539
540 fn coordinate(
541 kind: &str,
542 scope_id: &str,
543 occurred_at: &str,
544 sequence: u32,
545 ) -> TemporalCoordinate {
546 TemporalCoordinate::from_relation_explanation(
547 &RelationExplanation::new(RelationSemanticClass::Structural)
548 .with_dimension(kind)
549 .with_scope_id(scope_id)
550 .with_occurred_at(occurred_at)
551 .with_observed_at(occurred_at)
552 .with_ingested_at("unix:101788000000:000000000")
553 .with_valid_from(occurred_at)
554 .with_sequence(sequence),
555 )
556 .expect("a coordinate")
557 .expect("a coordinate with a scope")
558 }
559
560 fn existing() -> ExistingMemoryRefs {
561 ExistingMemoryRefs {
562 refs: BTreeSet::from([ABOUT.to_string(), REF.to_string()]),
563 dimensions: BTreeSet::from([PROCESS.to_string(), TASK.to_string()]),
564 labels: BTreeSet::from([
565 ("agentic_process".to_string(), "harness".to_string()),
566 ("task".to_string(), "launch".to_string()),
567 ("component".to_string(), "viewer".to_string()),
568 ]),
569 foreign: BTreeSet::new(),
570 max_sequences: BTreeMap::from([
571 (("agentic_process".to_string(), PROCESS.to_string()), 4),
572 (("task".to_string(), TASK.to_string()), 2),
573 ]),
574 }
575 }
576
577 fn current() -> Vec<TemporalCoordinate> {
578 vec![
579 coordinate("agentic_process", PROCESS, "2026-09-01T10:00:00Z", 3),
580 coordinate("task", TASK, "2026-09-01T10:00:00Z", 2),
581 ]
582 }
583
584 fn command(add: &[(&str, &str)], remove: &[(&str, &str)]) -> MemoryRelabelCommand {
585 MemoryRelabelCommand {
586 about: ABOUT.to_string(),
587 ref_id: REF.to_string(),
588 add: add.iter().map(|(key, value)| label(key, value)).collect(),
589 remove: remove
590 .iter()
591 .map(|(key, value)| label(key, value))
592 .collect(),
593 why: "The decision belongs to the issue it closed.".to_string(),
594 provenance: Some(MemoryProvenanceData {
595 source_kind: "agent".to_string(),
596 source_agent: "claude".to_string(),
597 observed_at: Some("2026-09-05T12:00:00Z".to_string()),
598 correlation_id: None,
599 causation_id: None,
600 }),
601 idempotency_key: "relabel:test".to_string(),
602 dry_run: false,
603 label_policy: LabelPolicy::Warn,
604 intended_new: BTreeSet::new(),
605 }
606 }
607
608 #[test]
609 fn an_added_label_creates_its_dimension_and_inherits_the_entry_clocks() {
610 let (update, outcome) =
611 translate_memory_relabel(&command(&[("issue", "506")], &[]), &existing(), ¤t())
612 .expect("a new label translates");
613
614 assert_eq!(
615 update
616 .changes
617 .iter()
618 .map(|change| change.entity_kind.as_str())
619 .collect::<Vec<_>>(),
620 ["memory_dimension", RELABEL_ENTITY_KIND]
621 );
622 assert_eq!(
623 update.changes[0].entity_id,
624 "label:v1:project%3Akmp:issue:506"
625 );
626 assert_eq!(update.changes[1].entity_id, REF);
627 assert_eq!(
628 update.changes[1].reason,
629 "The decision belongs to the issue it closed."
630 );
631 let payload: serde_json::Value =
632 serde_json::from_str(&update.changes[1].payload_json).expect("payload json");
633 let added = &payload["add"][0];
634 assert_eq!(added["dimension"], "issue");
635 assert_eq!(added["scope_id"], "label:v1:project%3Akmp:issue:506");
636 assert_eq!(
637 added["occurred_at"], "2026-09-01T10:00:00Z",
638 "inherited, not today"
639 );
640 assert_eq!(added["ingested_at"], "unix:101788000000:000000000");
641 assert_eq!(added["sequence"], 1, "a counter of its own label");
642 assert_eq!(payload["remove"].as_array().map(Vec::len), Some(0));
643 assert_eq!(payload["actor"], "claude");
644 assert_eq!(payload["observed_at"], "2026-09-05T12:00:00Z");
645 assert_eq!(update.requested_by.as_deref(), Some("claude"));
646
647 assert_eq!(outcome.added, [label("issue", "506")]);
648 assert!(outcome.removed.is_empty());
649 assert_eq!(
650 outcome.labels,
651 [
652 label("agentic_process", "harness"),
653 label("issue", "506"),
654 label("task", "launch")
655 ]
656 );
657 assert_eq!(
658 outcome.created_dimensions,
659 ["label:v1:project%3Akmp:issue:506"]
660 );
661 assert!(outcome.resembling_labels.is_empty());
662 assert!(outcome.warnings.is_empty());
663 }
664
665 #[test]
666 fn a_reused_label_declares_no_dimension_and_continues_its_counter() {
667 let mut existing = existing();
668 existing
669 .dimensions
670 .insert("label:v1:project%3Akmp:component:viewer".to_string());
671 existing.max_sequences.insert(
672 (
673 "component".to_string(),
674 "label:v1:project%3Akmp:component:viewer".to_string(),
675 ),
676 9,
677 );
678
679 let (update, outcome) = translate_memory_relabel(
680 &command(&[("component", "viewer")], &[]),
681 &existing,
682 ¤t(),
683 )
684 .expect("a reuse translates");
685
686 assert_eq!(update.changes.len(), 1, "no dimension declared");
687 let payload: serde_json::Value =
688 serde_json::from_str(&update.changes[0].payload_json).expect("payload json");
689 assert_eq!(payload["add"][0]["sequence"], 10);
690 assert!(outcome.created_dimensions.is_empty());
691 }
692
693 #[test]
694 fn a_removed_label_names_the_edge_that_goes() {
695 let (update, outcome) = translate_memory_relabel(
696 &command(&[], &[("task", "launch")]),
697 &existing(),
698 ¤t(),
699 )
700 .expect("a removal translates");
701
702 let payload: serde_json::Value =
703 serde_json::from_str(&update.changes[0].payload_json).expect("payload json");
704 assert_eq!(payload["remove"][0]["dimension"], "task");
705 assert_eq!(payload["remove"][0]["scope_id"], TASK);
706 assert_eq!(update.changes[0].scopes, [TASK]);
707 assert_eq!(outcome.removed, [label("task", "launch")]);
708 assert_eq!(outcome.labels, [label("agentic_process", "harness")]);
709 }
710
711 #[test]
712 fn the_last_label_cannot_be_taken_off() {
713 let error = translate_memory_relabel(
714 &command(&[], &[("task", "launch"), ("agentic_process", "harness")]),
715 &existing(),
716 ¤t(),
717 )
718 .expect_err("an entry keeps at least one label");
719 assert!(
720 matches!(&error, ApplicationError::Validation(message) if message.contains("would stand in no label")),
721 "{error}"
722 );
723 }
724
725 #[test]
726 fn a_label_the_entry_does_not_stand_in_is_refused_naming_what_it_stands_in() {
727 let error =
728 translate_memory_relabel(&command(&[], &[("issue", "506")]), &existing(), ¤t())
729 .expect_err("cannot remove what is not there");
730 let ApplicationError::Validation(message) = error else {
731 panic!("a validation refusal: {error}");
732 };
733 assert!(
734 message.contains("does not stand in `issue=506`"),
735 "{message}"
736 );
737 assert!(
738 message.contains("`agentic_process=harness`, `task=launch`"),
739 "{message}"
740 );
741 }
742
743 #[test]
744 fn a_label_the_entry_already_stands_in_is_refused() {
745 let error = translate_memory_relabel(
746 &command(&[("task", "launch")], &[]),
747 &existing(),
748 ¤t(),
749 )
750 .expect_err("already there");
751 assert!(
752 error
753 .to_string()
754 .contains("already stands in `task=launch`"),
755 "{error}"
756 );
757 }
758
759 #[test]
760 fn a_value_used_under_another_key_gets_an_independent_membership() {
761 let (_, outcome) = translate_memory_relabel(
762 &command(&[("owner", "launch")], &[]),
763 &existing(),
764 ¤t(),
765 )
766 .expect("the key distinguishes the two labels");
767 assert!(outcome.labels.contains(&label("task", "launch")));
768 assert!(outcome.labels.contains(&label("owner", "launch")));
769 assert_eq!(
770 outcome.created_dimensions,
771 ["label:v1:project%3Akmp:owner:launch"]
772 );
773 assert!(outcome.resembling_labels.is_empty());
774 }
775
776 #[test]
777 fn a_resembling_label_is_written_and_said_under_warn_and_refused_under_refuse() {
778 let (_, outcome) = translate_memory_relabel(
779 &command(&[("component", "Viewer")], &[]),
780 &existing(),
781 ¤t(),
782 )
783 .expect("warn writes");
784 assert_eq!(
785 outcome.resembling_labels.len(),
786 1,
787 "{:?}",
788 outcome.resembling_labels
789 );
790 assert_eq!(outcome.warnings.len(), 1);
791
792 let mut refusing = command(&[("component", "Viewer")], &[]);
793 refusing.label_policy = LabelPolicy::Refuse;
794 let error = translate_memory_relabel(&refusing, &existing(), ¤t())
795 .expect_err("refuse refuses");
796 assert!(error.to_string().contains("resemble"), "{error}");
797
798 refusing.intended_new.insert("component".to_string());
799 translate_memory_relabel(&refusing, &existing(), ¤t())
800 .expect("an intended-new key is left alone");
801 }
802
803 #[test]
804 fn nothing_to_do_and_contradictory_changes_are_refused() {
805 let error = translate_memory_relabel(&command(&[], &[]), &existing(), ¤t())
806 .expect_err("nothing to relabel");
807 assert!(error.to_string().contains("nothing to relabel"), "{error}");
808
809 let error = translate_memory_relabel(
810 &command(&[("task", "launch")], &[("task", "launch")]),
811 &existing(),
812 ¤t(),
813 )
814 .expect_err("both added and removed");
815 assert!(
816 error.to_string().contains("both added and removed"),
817 "{error}"
818 );
819 }
820
821 #[test]
822 fn a_memory_the_about_does_not_hold_is_not_found() {
823 let mut existing = existing();
824 existing.refs.remove(REF);
825 let error =
826 translate_memory_relabel(&command(&[("issue", "506")], &[]), &existing, ¤t())
827 .expect_err("not found");
828 assert!(matches!(error, ApplicationError::NotFound(_)), "{error}");
829 }
830
831 #[test]
832 fn a_replay_answers_from_what_the_entry_stands_in_without_translating() {
833 let mut current = current();
834 current.push(coordinate(
835 "issue",
836 "label:v1:project%3Akmp:issue:506",
837 "2026-09-01T10:00:00Z",
838 1,
839 ));
840 let outcome = super::replayed_relabel_outcome(&command(&[("issue", "506")], &[]), ¤t)
841 .expect("a replay answers");
842 assert_eq!(outcome.added, [label("issue", "506")]);
843 assert_eq!(outcome.labels.len(), 3);
844 assert!(outcome.read_after_write_ready);
845 assert!(
846 outcome.warnings[0].contains("already accepted"),
847 "{:?}",
848 outcome.warnings
849 );
850 }
851
852 #[test]
853 fn the_logical_digest_reads_what_the_caller_said() {
854 let (first, _) =
855 translate_memory_relabel(&command(&[("issue", "506")], &[]), &existing(), ¤t())
856 .expect("translates");
857 let (again, _) =
858 translate_memory_relabel(&command(&[("issue", "506")], &[]), &existing(), ¤t())
859 .expect("translates");
860 let (other, _) =
861 translate_memory_relabel(&command(&[("issue", "507")], &[]), &existing(), ¤t())
862 .expect("translates");
863 assert_eq!(first.logical_digest, again.logical_digest);
864 assert_ne!(first.logical_digest, other.logical_digest);
865 }
866}