fix(identity): shrinkage guard on update_identity to prevent P1 manifest corruption - #48
fix(identity): shrinkage guard on update_identity to prevent P1 manifest corruption#48RobLe3 wants to merge 2 commits into
Conversation
…est corruption Investigation of a recurring P1-continuity corruption pattern on a running install (identity.md truncated three times in 24h, restored each time from the git-tracked copy) traced the root cause to ``update_identity`` itself. The tool's name suggests "add to" / "modify in place" semantics, but the implementation does ``path.write_text(content)`` which OVERWRITES the entire file. Smaller coder models read the name as APPEND and call the tool with reflection-style snippets, silently destroying the existing manifest content. Forensic trail from `data/memory/identity_journal.jsonl`: legitimate full write 588 → 9972 chars (2026-04-23) corruption joi-lab#1 12409 → 556 chars (2026-05-04, "reflection on provenance") corruption joi-lab#2 556 → 224 chars (2026-05-04, "recent task management") Both destructive writes were >50 chars, so the existing minimum-length threshold did not catch them. Both ``new_preview`` values in the journal show reflection-style notes that were clearly intended as journal entries, not manifest rewrites. ## Fix In ``_update_identity`` (`ouroboros/tools/control.py`): When existing identity content is >1000 chars and the proposed write would shrink it by >30%, reject with a teachable error that: - Names the exact char count change and loss percentage - States explicitly that ``update_identity`` OVERWRITES (does NOT append) - Points at the correct alternatives: - ``update_scratchpad(text=...)`` for working memory reflections - ``knowledge_write(topic=..., mode='append', ...)`` for durable journal entries - Documents the bypass path for legitimate major rewrites: prefix content with the literal sentinel ``<<CONFIRMED_TRUNCATE>>\n``, which is stripped before write so it doesn't pollute the manifest Existing 50-char minimum and journal-append behavior preserved. Growth is always allowed — no upper bound. Guard only fires when current content is substantial (>1000 chars), so bootstrap / first-write scenarios are unaffected. ## No semantic change for legitimate edits The shrinkage shape that triggers the guard (>30% loss of a >1000-char manifest) is exactly the destructive accident class. There is no realistic legitimate edit shape that this rejects: - A surgical paragraph edit shrinks <30% - A growth-style update never triggers - A bootstrap write on an existing-but-tiny manifest never triggers - A genuine major rewrite has the documented sentinel bypass ## Tests `tests/test_update_identity_shrinkage_guard.py` (new) — 8 tests: - Existing too-short rejection path still fires (regression guard) - The exact production corruption shape (12k → 500 chars) is rejected - Error message includes char counts + loss percentage - Modest edits within 30% pass through - Growth always passes - Guard disabled when existing content is under 1000 chars (bootstrap) - The ``<<CONFIRMED_TRUNCATE>>`` sentinel bypass works AND the sentinel is stripped from the persisted content - Successful updates still record old_len/new_len in ``identity_journal.jsonl`` (forensic-trail preservation) All 8 pass against this branch. ## BIBLE alignment P1 (continuity) protection. The identity manifest is the agent's most load-bearing continuity artifact; silent destruction by the agent's own tool calls is the failure class this guard closes. The bypass sentinel preserves the agent's authority to perform legitimate major rewrites — the guard does not gate intent, it gates accidents. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Code reviewFound 1 issue:
ouroboros-desktop/ouroboros/tools/control.py Lines 195 to 240 in 41232b9 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Verification + fixgon7187's finding is correct on both counts. Verified against the code and reproduced both failure modes: Case (a) — bypass path silently broken for short rewrites Case (b) — sub-50-char body written to disk Root cause: Fix applied: Strip the sentinel first, then run the 50-char check on the post-strip body. The Test updated: Test results: 8/8 shrinkage guard tests pass. 3299 pass overall (excluding three pre-existing unrelated failures: function-count ceiling, version-in-README mismatch). |
Prior order: len(content.strip()) < 50 ran on raw content before the
<<CONFIRMED_TRUNCATE>>\n sentinel was stripped. Two failure modes:
(a) sentinel + short body (< 28 chars) was rejected with "too short"
— the bypass path was silently unreachable for compact rewrites.
(b) sentinel + 28+ chars passed the raw 50-char gate, got stripped to
a sub-50-char body, and was written to disk — violating the quality
floor the check was meant to enforce.
Fix: detect and strip the sentinel first, then validate len(body) >= 50
on the post-strip content. The explicit_truncate flag and shrinkage guard
downstream are unaffected; they now operate on the already-stripped body,
which was always the correct target.
Test: updated test_explicit_truncate_sentinel_bypasses_guard body from
41 chars to 50+ chars — the old body was relying on the buggy raw-content
gate to pass. 8/8 shrinkage guard tests pass.
Reported by gon7187 in joi-lab#48 code review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The repository has no contribution guide today and the README only mentions forks in the context of signed CI builds, so a first-time human contributor has to reverse-engineer the contribution flow from git history and from docs/DEVELOPMENT.md. This change adds a deliberately short CONTRIBUTING.md that: - Sets expectations about what makes Ouroboros unusual (self-modifying agent runtime, constitution-first design, enforced size budgets, the LLMClient SSOT, the platform_layer.py guard). - Routes contributors to the canonical documents — BIBLE.md, README.md, docs/ARCHITECTURE.md, docs/DEVELOPMENT.md, and docs/CHECKLISTS.md — rather than restating them, per P7 (DRY). - Documents the contributor PR flow: branch naming, Conventional Commits, smoke-gate expectations, CI tier behaviour for fork PRs, and the explicit fact that the agent's repo_commit review machinery (advisory + triad + scope) does NOT run on PRs. - Lists realistic starter targets (already-filed bugs, the DEVELOPMENT.md-advertised module split debts, tool-description polish, cross-platform fixes) since the repo currently has no "good first issue" label. - Cites recent merged human-authored PRs (#46, #48, #51) and the Cloud.ru issue series (#39–#45) as concrete shape references for PRs and issues. The README gains a small "Contributing" section between Philosophy and Version History that points at CONTRIBUTING.md without duplicating its content. No code changes. No new tests required.
Summary
Adds a defensive shrinkage guard to
_update_identityinouroboros/tools/control.pyto prevent a P1-continuity corruption pattern observed in production: the agent callingupdate_identitywith short reflection snippets (treating "update" as "append") and silently destroying its own identity manifest.The bug
update_identity(content=...)doespath.write_text(content)— it OVERWRITES the entire file. The tool name suggests "add to" semantics, so smaller coder models call it with reflection-style snippets they intend as journal entries. The existing 50-char minimum threshold doesn't catch this — destructive snippets are typically 200-600 chars.Real production data
Forensic trail from
data/memory/identity_journal.jsonlon a running install:new_preview: "Reflecting on recent events, I've clarified the provenance tagging convention…"new_preview: "Updated identity to reflect recent task management and maintenance activities…"Both destructive writes'
new_previewvalues are clearly reflection-style notes — the agent intended journal entries but called the manifest-overwrite tool.The fix
When existing identity content is >1000 chars and the proposed write would shrink it by >30%, reject with a teachable error:
update_identityOVERWRITES (does NOT append)update_scratchpad(text=...)for working memory reflectionsknowledge_write(topic=..., mode='append', ...)for durable journal entries<<CONFIRMED_TRUNCATE>>\n(stripped before write so it doesn't pollute the manifest)The shrinkage shape that triggers the guard (>30% loss of >1000-char manifest) is exactly the destructive accident class. No realistic legitimate edit triggers this:
Existing 50-char minimum and journal-append behavior preserved.
BIBLE alignment
P1 (continuity). The identity manifest is the agent's most load-bearing continuity artifact. Silent destruction by the agent's own tool calls is the failure class this guard closes. The bypass sentinel preserves the agent's authority to perform legitimate major rewrites — the guard does not gate intent, it gates accidents.
No new abstractions, no new authority
This is a defensive boundary at one existing site, same shape as the merged grep-regex hint (#37) and the merged dirty-tree resilience (#36). It does not:
Tests
tests/test_update_identity_shrinkage_guard.py(new) — 8 tests:<<CONFIRMED_TRUNCATE>>sentinel bypass works AND sentinel is stripped from persisted contentold_len/new_leninidentity_journal.jsonl(forensic-trail preservation)All 8 pass.
Test plan
pytest tests/test_update_identity_shrinkage_guard.py -v→ 8 passedupdate_identity(content="reflection on...")against a 12k-char existing manifest is rejected with the shrinkage-guard error andupdate_scratchpadrecommendation<<CONFIRMED_TRUNCATE>>\nsucceeds and the sentinel is absent from the persisted manifestRelated
This is the third PR in a recent reliability series — same defensive-boundary shape as the merged #36 (
_repo_commit_pushdirty-tree resilience) and #37 (run_shellgrep regex hint). The currently-open #46 (v5.7.x reliability bundle) and #47 (branch_devopt-in) extend that shape further.🤖 Generated with Claude Code