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