Skip to content

Commit 6d5bfa1

Browse files
Paul Kyleclaude
andcommitted
feat: v0.6.1 — RETRACT operation for memory correction
New executor operation that marks memory facts as wrong with a visible strikethrough tombstone. Fact IDs are preserved so readers know what was retracted and why. Maps to IETF KU retract lifecycle state (#17). - _retract_fact() in executor.py with [RETRACTED date — reason] annotation - History file records retraction provenance - Compaction + update prompts updated with RETRACT guidance - 4 new tests (121 total) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c6eebdc commit 6d5bfa1

7 files changed

Lines changed: 126 additions & 13 deletions

File tree

docs/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
All notable changes to Palinode. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
44

5+
## [0.6.1] — 2026-04-12
6+
7+
### Added
8+
9+
**RETRACT operation**
10+
- New executor operation: `RETRACT` — marks a memory fact as wrong with a visible tombstone
11+
- Strikethrough formatting with `[RETRACTED date — reason]` annotation
12+
- Fact ID preserved (not deleted) so readers know what was retracted and why
13+
- History file records retraction provenance
14+
- Compaction and update prompts updated with RETRACT guidance
15+
- 4 new tests (121 total)
16+
17+
Maps to IETF Knowledge Unit `retract` lifecycle state — see Paul-Kyle/palinode#17 for the interop discussion.
18+
19+
---
20+
521
## [0.6.0] — 2026-04-11
622

723
### Added

palinode/consolidation/executor.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Compaction Executor
33
4-
Applies structured operations (KEEP/UPDATE/MERGE/SUPERSEDE/ARCHIVE)
4+
Applies structured operations (KEEP/UPDATE/MERGE/SUPERSEDE/ARCHIVE/RETRACT)
55
to markdown memory files. The LLM decides what to do; the executor
66
does it deterministically.
77
@@ -50,7 +50,7 @@ def apply_operations(file_path: str, operations: list[dict]) -> dict:
5050
with open(file_path) as f:
5151
content = f.read()
5252

53-
stats = {"kept": 0, "updated": 0, "merged": 0, "superseded": 0, "archived": 0}
53+
stats = {"kept": 0, "updated": 0, "merged": 0, "superseded": 0, "archived": 0, "retracted": 0}
5454

5555
for op in operations:
5656
if not isinstance(op, dict):
@@ -99,7 +99,16 @@ def apply_operations(file_path: str, operations: list[dict]) -> dict:
9999
if archived_content != content:
100100
content = archived_content
101101
stats["archived"] += 1
102-
102+
103+
elif op_type == "RETRACT":
104+
fact_id = op.get("id")
105+
reason = op.get("reason", op.get("rationale", ""))
106+
if fact_id:
107+
retracted_content = _retract_fact(content, fact_id, reason, file_path)
108+
if retracted_content != content:
109+
content = retracted_content
110+
stats["retracted"] += 1
111+
103112
# Write back
104113
with open(file_path, 'w') as f:
105114
f.write(content)
@@ -191,6 +200,34 @@ def _archive_fact(content: str, fact_id: str, reason: str, file_path: str) -> st
191200
return content
192201

193202

203+
def _retract_fact(content: str, fact_id: str, reason: str, file_path: str) -> str:
204+
"""Mark a fact as retracted — explicitly wrong, not just stale.
205+
206+
Unlike ARCHIVE (removes silently), RETRACT leaves a visible tombstone
207+
with strikethrough and reason so readers know the fact was wrong and why.
208+
Aligns with IETF Knowledge Unit lifecycle (retract = known-incorrect).
209+
"""
210+
now = _utc_now().strftime("%Y-%m-%d")
211+
reason_text = f" — {reason}" if reason else ""
212+
213+
pattern = re.compile(
214+
r'^([\s]*[-*]\s+)(.*?)(<!-- fact:' + re.escape(fact_id) + r' -->)',
215+
re.MULTILINE
216+
)
217+
218+
def replacer(m):
219+
old_text = m.group(2).strip()
220+
return f"{m.group(1)}~~{old_text}~~ [RETRACTED {now}{reason_text}] {m.group(3)}"
221+
222+
updated_content, substitutions = pattern.subn(replacer, content, count=1)
223+
if substitutions == 0:
224+
return content
225+
226+
_append_to_history(file_path, fact_id, f"Retracted ({now}): {reason}")
227+
228+
return updated_content
229+
230+
194231
def _append_to_history(file_path: str, fact_id: str, text: str) -> None:
195232
"""Append an entry to the corresponding history file."""
196233
base = re.sub(r'-status\.md$', '', file_path)

palinode/core/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,8 @@ class ContextConfig:
285285
class CompactionConfig:
286286
"""Operations controls algorithms parameters logic models layouts mapping endpoints."""
287287
# Which operations are allowed
288-
allowed_ops: list[str] = field(default_factory=lambda:
289-
["KEEP", "UPDATE", "MERGE", "SUPERSEDE", "ARCHIVE"])
288+
allowed_ops: list[str] = field(default_factory=lambda:
289+
["KEEP", "UPDATE", "MERGE", "SUPERSEDE", "ARCHIVE", "RETRACT"])
290290
# How aggressive: conservative = mostly KEEP, aggressive = more MERGE/ARCHIVE
291291
aggressiveness: str = "moderate" # "conservative" | "moderate" | "aggressive"
292292
# Layer split heuristics

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "palinode"
7-
version = "0.6.0"
7+
version = "0.6.1"
88
description = "Persistent memory that makes AI agents smarter over time."
99
authors = [
1010
{name = "Paul Kyle"}

specs/prompts/compaction.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@ Your job: decide what happens to each fact.
1616
| MERGE | Two+ facts say the same thing differently |
1717
| SUPERSEDE | A decision or fact has been explicitly changed |
1818
| ARCHIVE | Fact is stale (>60 days, never referenced), or no longer relevant |
19+
| RETRACT | Fact is **known to be wrong** — not just outdated, but incorrect. Leaves a visible tombstone. |
1920

2021
## Rules
2122

2223
1. **Default to KEEP.** Most facts are fine. Only modify what's clearly outdated.
2324
2. **SUPERSEDE requires evidence.** Don't supersede unless recent notes show an explicit change.
2425
3. **MERGE only when redundant.** Two facts about different aspects of the same topic are NOT redundant.
2526
4. **ARCHIVE aggressively for status, conservatively for decisions.** Old milestones → archive. Old decisions → keep unless superseded.
26-
5. **Preserve specificity.** "Switched to BGE-M3 on March 20" is better than "changed embedding model."
27-
6. **Include rationale.** Every UPDATE/MERGE/SUPERSEDE/ARCHIVE must explain why.
27+
5. **RETRACT only when provably wrong.** A fact that was true but is now outdated → SUPERSEDE. A fact that was never true → RETRACT.
28+
6. **Preserve specificity.** "Switched to BGE-M3 on March 20" is better than "changed embedding model."
29+
7. **Include rationale.** Every UPDATE/MERGE/SUPERSEDE/ARCHIVE/RETRACT must explain why.
2830

2931
## Output Format
3032

@@ -36,6 +38,7 @@ Return ONLY a JSON array:
3638
{"op": "UPDATE", "id": "fact_id", "new_text": "updated text", "rationale": "why"},
3739
{"op": "MERGE", "ids": ["id1", "id2"], "new_text": "merged text", "rationale": "why"},
3840
{"op": "SUPERSEDE", "id": "old_id", "new_text": "new text", "reason": "what changed"},
39-
{"op": "ARCHIVE", "id": "fact_id", "rationale": "why archive"}
41+
{"op": "ARCHIVE", "id": "fact_id", "rationale": "why archive"},
42+
{"op": "RETRACT", "id": "fact_id", "reason": "why this fact is wrong"}
4043
]
4144
```

specs/prompts/update.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Return a single JSON object:
2020

2121
```json
2222
{
23-
"operation": "ADD | UPDATE | NOOP | SUPERSEDE | ARCHIVE",
23+
"operation": "ADD | UPDATE | NOOP | SUPERSEDE | ARCHIVE | RETRACT",
2424
"target_id": "id-of-existing-item-to-modify",
2525
"updated_content": "the new content if UPDATE",
2626
"reason": "brief explanation of why this operation"
@@ -34,11 +34,13 @@ Return a single JSON object:
3434
3. **Existing says similar thing but candidate has new/updated info**`UPDATE` (modify the existing file, update `last_updated`)
3535
4. **Existing directly contradicts candidate (same topic, opposite conclusion)**`SUPERSEDE` (mark existing as superseded, add new)
3636
5. **Existing is clearly outdated/wrong**`ARCHIVE` (mark as archived)
37+
6. **Existing is provably incorrect (was never true)**`RETRACT` (leave visible tombstone with reason)
3738

3839
## Rules
3940

4041
- Prefer `NOOP` when in doubt. Not creating a duplicate is always better than creating a bad memory.
4142
- Prefer `UPDATE` over `ADD` when the same entity already has a file. Append to the existing file rather than creating a new one.
42-
- Never hard-delete. `ARCHIVE` sets `status: archived`. The file stays for audit.
43+
- Never hard-delete. `ARCHIVE` sets `status: archived`. `RETRACT` leaves a strikethrough tombstone. Both stay for audit.
44+
- Use `RETRACT` only when you can prove the fact was wrong, not just outdated. Outdated → SUPERSEDE. Wrong → RETRACT.
4345
- When superseding a decision: the new decision gets `supersedes: [old_id]`, the old gets `superseded_by: new_id` and `status: superseded`.
4446
- Explain your reasoning in `reason` — this gets logged for the audit trail.

tests/test_executor.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def test_missing_fields_are_skipped(temp_memory_file):
9494
{"op": "SUPERSEDE", "new_text": "Replacement"},
9595
{"op": "ARCHIVE"},
9696
])
97-
assert stats == {"kept": 0, "updated": 0, "merged": 0, "superseded": 0, "archived": 0}
97+
assert stats == {"kept": 0, "updated": 0, "merged": 0, "superseded": 0, "archived": 0, "retracted": 0}
9898

9999
def test_missing_fact_id_is_noop(temp_memory_file):
100100
stats = apply_operations(temp_memory_file, [{"op": "SUPERSEDE", "id": "missing", "new_text": "Replacement"}])
@@ -108,4 +108,59 @@ def test_empty_operations_leave_file_unchanged(temp_memory_file):
108108
with open(temp_memory_file) as f:
109109
after = f.read()
110110
assert before == after
111-
assert stats == {"kept": 0, "updated": 0, "merged": 0, "superseded": 0, "archived": 0}
111+
assert stats == {"kept": 0, "updated": 0, "merged": 0, "superseded": 0, "archived": 0, "retracted": 0}
112+
113+
114+
def test_retract_operation(temp_memory_file):
115+
"""RETRACT leaves a visible tombstone with strikethrough and reason."""
116+
ops = [{"op": "RETRACT", "id": "f2", "reason": "This was never true"}]
117+
stats = apply_operations(temp_memory_file, ops)
118+
assert stats["retracted"] == 1
119+
with open(temp_memory_file) as f:
120+
content = f.read()
121+
# Fact should be struck through with RETRACTED label
122+
assert "~~[2024-01-02] An update occurred~~" in content
123+
assert "[RETRACTED" in content
124+
assert "This was never true" in content
125+
# Fact ID should still be present (tombstone, not deleted)
126+
assert "<!-- fact:f2 -->" in content
127+
128+
# Check history file
129+
history_file = temp_memory_file.replace(".md", "-history.md")
130+
assert os.path.exists(history_file)
131+
with open(history_file) as f:
132+
hist = f.read()
133+
assert "Retracted" in hist
134+
assert "This was never true" in hist
135+
os.remove(history_file)
136+
137+
138+
def test_retract_without_reason(temp_memory_file):
139+
"""RETRACT should work even without a reason."""
140+
ops = [{"op": "RETRACT", "id": "f1"}]
141+
stats = apply_operations(temp_memory_file, ops)
142+
assert stats["retracted"] == 1
143+
with open(temp_memory_file) as f:
144+
content = f.read()
145+
assert "~~[2024-01-01] The project started today~~" in content
146+
assert "[RETRACTED" in content
147+
# No reason text after the date
148+
assert "— " not in content.split("RETRACTED")[1].split("]")[0]
149+
150+
history_file = temp_memory_file.replace(".md", "-history.md")
151+
if os.path.exists(history_file):
152+
os.remove(history_file)
153+
154+
155+
def test_retract_missing_fact_is_noop(temp_memory_file):
156+
"""RETRACT on a non-existent fact ID should be a no-op."""
157+
ops = [{"op": "RETRACT", "id": "nonexistent", "reason": "test"}]
158+
stats = apply_operations(temp_memory_file, ops)
159+
assert stats["retracted"] == 0
160+
161+
162+
def test_retract_missing_id_is_skipped(temp_memory_file):
163+
"""RETRACT without an ID field should be skipped."""
164+
ops = [{"op": "RETRACT", "reason": "no id"}]
165+
stats = apply_operations(temp_memory_file, ops)
166+
assert stats["retracted"] == 0

0 commit comments

Comments
 (0)