Skip to main content

kmp_application/memory/
relabel.rs

1//! A relabel: the labels an entry stands in change, and its text does not.
2//!
3//! Translated the way an ingest is — against what the about holds — into
4//! one `memory_relabel` change the log keeps and the projection reads as
5//! `contains_entry` edges added and removed. A label added late inherits
6//! the entry's clocks, because an entry's time does not depend on its
7//! latest label; the relabel's own instant lives in the event and on the
8//! edge it added, never on the entry.
9
10use 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
27/// The `method` a `contains_entry` edge carries when a relabel put it
28/// there, so a reader can tell a label given at write from one given later.
29pub const RELABEL_METHOD: &str = "kmp_relabel";
30
31/// The change kind a relabel appends to the log. The projection reads it as
32/// edges added and removed around one entry; nothing else in the log does.
33pub const RELABEL_ENTITY_KIND: &str = "memory_relabel";
34
35/// Translates a relabel into the one change the log keeps, refusing what
36/// only the caller can fix: a label the entry already stands in, one it
37/// does not stand in, an exact duplicate pair, the last
38/// label an entry has, or a new label that resembles one the about holds
39/// under a policy that refuses it.
40pub 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
160/// What the additions of one relabel translate to: the labels as pairs, the
161/// coordinates the entry gains, the dimensions declared for the first time,
162/// and the resemblances the catalogue turned up.
163struct 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
196/// The labels the entry stands in now, each with the coordinate that says
197/// so, keyed by the pair a caller names them by.
198fn 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
221/// The labels to take off, each with the scope id of the edge that goes.
222/// One the entry does not stand in is refused naming what it does stand in.
223fn 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        // Relabel takes literal values, even when a value looks like a ref.
276        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
360/// The clocks a label added late inherits: the entry's earliest start on
361/// each clock and its latest end, read off the coordinates it already has.
362/// Every coordinate a writer emits shares the same clocks, so for those the
363/// choice is moot; it matters only for an entry catalogued by hand.
364struct 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
459/// The outcome a replay of an already-accepted relabel returns: what the
460/// caller asked for, read against what the entry stands in now, without
461/// translating — a translation after the first apply would refuse the
462/// labels the first apply put there.
463pub 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
489/// Digest of the logical relabel, taken before translation: what the caller
490/// said, which is what a replay under the same idempotency key must equal.
491pub 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(), &current())
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            &current(),
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            &current(),
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            &current(),
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(), &current())
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            &current(),
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            &current(),
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            &current(),
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(), &current())
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(), &current())
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(), &current())
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            &current(),
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, &current())
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")], &[]), &current)
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(), &current())
853                .expect("translates");
854        let (again, _) =
855            translate_memory_relabel(&command(&[("issue", "506")], &[]), &existing(), &current())
856                .expect("translates");
857        let (other, _) =
858            translate_memory_relabel(&command(&[("issue", "507")], &[]), &existing(), &current())
859                .expect("translates");
860        assert_eq!(first.logical_digest, again.logical_digest);
861        assert_ne!(first.logical_digest, other.logical_digest);
862    }
863}