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