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.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
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(
192            provenance.observed_at.as_deref().unwrap_or_default(),
193            "provenance.observed_at",
194        )?;
195    }
196    Ok(())
197}
198
199/// The labels the entry stands in now, each with the coordinate that says
200/// so, keyed by the pair a caller names them by.
201fn 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
224/// The labels to take off, each with the scope id of the edge that goes.
225/// One the entry does not stand in is refused naming what it does stand in.
226fn 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        // Relabel takes literal values, even when a value looks like a ref.
279        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
363/// The clocks a label added late inherits: the entry's earliest start on
364/// each clock and its latest end, read off the coordinates it already has.
365/// Every coordinate a writer emits shares the same clocks, so for those the
366/// choice is moot; it matters only for an entry catalogued by hand.
367struct 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
462/// The outcome a replay of an already-accepted relabel returns: what the
463/// caller asked for, read against what the entry stands in now, without
464/// translating — a translation after the first apply would refuse the
465/// labels the first apply put there.
466pub 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
492/// Digest of the logical relabel, taken before translation: what the caller
493/// said, which is what a replay under the same idempotency key must equal.
494pub 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(), &current())
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            &current(),
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            &current(),
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            &current(),
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(), &current())
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            &current(),
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            &current(),
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            &current(),
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(), &current())
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(), &current())
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(), &current())
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            &current(),
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, &current())
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")], &[]), &current)
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(), &current())
856                .expect("translates");
857        let (again, _) =
858            translate_memory_relabel(&command(&[("issue", "506")], &[]), &existing(), &current())
859                .expect("translates");
860        let (other, _) =
861            translate_memory_relabel(&command(&[("issue", "507")], &[]), &existing(), &current())
862                .expect("translates");
863        assert_eq!(first.logical_digest, again.logical_digest);
864        assert_ne!(first.logical_digest, other.logical_digest);
865    }
866}