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::ref_boundary::{validate_ref_token, validate_supplied_entry_ref};
25
26/// The `method` a `contains_entry` edge carries when a relabel put it
27/// there, so a reader can tell a label given at write from one given later.
28pub const RELABEL_METHOD: &str = "kmp_relabel";
29
30/// The change kind a relabel appends to the log. The projection reads it as
31/// edges added and removed around one entry; nothing else in the log does.
32pub const RELABEL_ENTITY_KIND: &str = "memory_relabel";
33
34/// Translates a relabel into the one change the log keeps, refusing what
35/// only the caller can fix: a label the entry already stands in, one it
36/// does not stand in, a value already used under another key, the last
37/// label an entry has, or a new label that resembles one the about holds
38/// under a policy that refuses it.
39pub 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
159/// What the additions of one relabel translate to: the labels as pairs, the
160/// coordinates the entry gains, the dimensions declared for the first time,
161/// and the resemblances the catalogue turned up.
162struct 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
195/// The labels the entry stands in now, each with the coordinate that says
196/// so, keyed by the pair a caller names them by.
197fn 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
220/// The labels to take off, each with the scope id of the edge that goes.
221/// One the entry does not stand in is refused naming what it does stand in.
222fn 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            // The about holds this scope already: its kind was fixed at
310            // first use, and a relabel reuses it or names the clash.
311            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
381/// The clocks a label added late inherits: the entry's earliest start on
382/// each clock and its latest end, read off the coordinates it already has.
383/// Every coordinate a writer emits shares the same clocks, so for those the
384/// choice is moot; it matters only for an entry catalogued by hand.
385struct 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
481/// The outcome a replay of an already-accepted relabel returns: what the
482/// caller asked for, read against what the entry stands in now, without
483/// translating — a translation after the first apply would refuse the
484/// labels the first apply put there.
485pub 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
517/// Digest of the logical relabel, taken before translation: what the caller
518/// said, which is what a replay under the same idempotency key must equal.
519pub 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(), &current())
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            &current(),
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            &current(),
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            &current(),
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(), &current())
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            &current(),
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            &current(),
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            &current(),
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(), &current())
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(), &current())
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(), &current())
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            &current(),
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, &current())
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")], &[]), &current)
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(), &current())
880                .expect("translates");
881        let (again, _) =
882            translate_memory_relabel(&command(&[("issue", "506")], &[]), &existing(), &current())
883                .expect("translates");
884        let (other, _) =
885            translate_memory_relabel(&command(&[("issue", "507")], &[]), &existing(), &current())
886                .expect("translates");
887        assert_eq!(first.logical_digest, again.logical_digest);
888        assert_ne!(first.logical_digest, other.logical_digest);
889    }
890}