Skip to content

Reject case-colliding ids across all storage backends#252

Open
whimo wants to merge 1 commit into
mainfrom
artemy/id-case-collision
Open

Reject case-colliding ids across all storage backends#252
whimo wants to merge 1 commit into
mainfrom
artemy/id-case-collision

Conversation

@whimo

@whimo whimo commented Jul 22, 2026

Copy link
Copy Markdown
Member

Closes #249.

Problem

YAMLStorage uses raw logical ids as filenames, so ids differing only by case (X vs x) address the same file on case-insensitive filesystems (macOS / Windows defaults) and silently overwrite each other — datasources, models, and memories alike. Reads and deletes were equally unsafe (get_model("X") returned model x; delete_model("X") removed x.yaml), and the legacy memory migration could collapse colliding records and then delete memories.yaml. SQLiteStorage kept case variants distinct, so the two backends silently diverged.

Fix: fail loudly, uniformly

Instead of the reversible filename encoding suggested in the issue, saving an id that differs only by case (casefold()-equal, raw-different) from an existing one now raises a new IdCollisionError(SlayerError, ValueError) — enforced in the StorageBackend base templates, so every backend rejects on every platform and stores stay portable across backends and OSes. Exact-id re-saves remain upserts; a legacy store already holding a colliding pair raises on a save of either spelling, with a rename-or-delete remediation.

  • save_datasource becomes a base template method delegating to a new abstract _save_datasource_impl (same pattern as save_model/_save_model_impl).
  • save_memory checks via a new cheap _list_memory_ids() primitive (YAML: one listdir; SQLite: SELECT id; JoinSync: delegation). SQLiteStorage's atomic save_memory override bypasses the base template, so the check is duplicated inside its BEGIN IMMEDIATE transaction.
  • Model saves check both identity components: data_source against registered datasource names ∪ other models' data_sources (they share the models/<ds>/ directory namespace), and name within the same datasource.
  • save_model(_validate=False) (the migration write-back) bypasses the check so legacy data stays loadable.

YAML read/delete exact-name verification

YAML lookups and deletes now compare the exact entry against os.listdir (_exact_entry_exists) instead of os.path.exists/bare open, so on macOS a case-variant get_model/get_datasource/get_memory returns not-found and a delete is a no-op — instead of silently reading or removing the wrong entity in a store copied over from a case-sensitive system.

Migration pre-checks

migrate_memories_layout and migrate_yaml_layout (restructured to plan-then-move) refuse to run when targets would collide by case, before writing anything — legacy files preserved for manual repair.

Surfacing

REST POST /datasources → 400 (model/memory endpoints already mapped ValueError → 400); MCP create_datasource returns a friendly message and its auto-ingest loop reports per-model save failures without aborting (quoted case-variant tables like "Orders"/orders: first wins, second reported); CLI prints-and-exits on datasource create, skips-and-continues in ingest loops. ingest_datasource_idempotent already isolated per-model errors into IngestionResult.errors.

Tests

  • test_case_sensitive_ids (which pinned the old divergent behavior and only passed on case-sensitive CI filesystems) inverted to test_case_colliding_ids_rejected.
  • New tests/test_id_collision.py: helpers, all three entity kinds on both backends, cross-namespace datasource/model checks, legacy colliding pairs seeded via raw SQL, the resolve_storage (JoinSync-wrapped) production path, _validate=False bypass, migration refusals.
  • New tests/test_yaml_exact_entry.py: read/delete not-found semantics for case-variant lookups (assertions hold on both case-sensitive and case-insensitive dev machines).
  • REST 400 test. Full suite: 6847 passed. The issue's repro now raises identically on both backends with originals intact (verified on case-insensitive APFS).

Known scope choice: the key is casefold() only — slightly broader than ASCII case (ßss); Unicode-normalization aliasing (NFC vs NFD) is deliberately out of scope.

https://claude.ai/code/session_011sbQKNXcUtGAsCXtmJXHvR

Summary by CodeRabbit

  • Bug Fixes

    • Prevented accidental conflicts when datasource, model, or memory IDs differ only by letter case.
    • Improved exact-case lookups and deletion behavior for stored YAML entries.
    • Added safeguards to prevent case-colliding data during storage migrations.
  • API & CLI

    • Datasource conflicts now return clear errors instead of unexpected failures.
    • Ingestion continues for valid models while reporting individual save failures.
  • Documentation

    • Clarified case-collision rules, migration behavior, and error handling across storage, API, CLI, and MCP interfaces.

YAMLStorage uses raw ids as filenames, so ids differing only by case
(X vs x) address the same file on case-insensitive filesystems (macOS,
Windows) and silently overwrite each other — datasources, models, and
memories alike. SQLiteStorage kept them distinct, so the backends
silently diverged.

Saving an id that differs only by case from an existing one now raises
IdCollisionError(SlayerError, ValueError), enforced in the
StorageBackend base templates so every backend rejects on every
platform and stores stay portable. Exact-id re-saves remain upserts; a
legacy store already holding a colliding pair raises on a save of
either spelling.

- save_datasource becomes a template method delegating to a new
  abstract _save_datasource_impl (same pattern as save_model)
- save_memory checks via a new cheap _list_memory_ids() primitive;
  SQLiteStorage's atomic save_memory override duplicates the check
  inside its transaction
- model saves check both identity components: data_source against
  datasource names and other models' data_sources, name within the
  same datasource
- YAML reads/deletes verify the exact directory entry via os.listdir
  comparison, so a case-variant lookup reads as not-found and a delete
  is a no-op instead of hitting the wrong file
- migrate_memories_layout and migrate_yaml_layout pre-check collisions
  before writing anything, preserving legacy files on failure
- surfacing: REST POST /datasources maps to 400; MCP create_datasource
  returns a friendly message and isolates per-model ingest-save
  failures; CLI prints and exits or skips-and-continues in ingest loops

Closes #249

Claude-Session: https://claude.ai/code/session_011sbQKNXcUtGAsCXtmJXHvR
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Case-collision persistence safeguards

Layer / File(s) Summary
Collision contracts and shared validation
slayer/core/errors.py, slayer/storage/base.py
Adds IdCollisionError and shared case-insensitive validation for datasource, model, and memory identifiers.
Backend persistence hooks and identity listing
slayer/storage/{yaml_storage,sqlite_storage,join_sync}.py
Routes backend writes through shared templates and adds backend-specific memory ID enumeration.
Exact YAML access and migration prechecks
slayer/storage/yaml_storage.py, slayer/storage/v4_migration.py
Prevents case-variant reads or deletes and rejects colliding migration targets before writing.
REST, CLI, and MCP error handling
slayer/api/server.py, slayer/cli.py, slayer/mcp/server.py
Surfaces persistence failures through HTTP 400, CLI errors, and per-model ingestion results.
Regression coverage and behavior documentation
tests/test_id_collision.py, tests/test_yaml_exact_entry.py, tests/test_api_server.py, tests/test_memory_string_ids.py, docs/*, CLAUDE.md
Covers collision, migration, wrapper, and exact-entry behavior and documents the updated contracts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Interface
  participant StorageBackend
  participant Storage
  Client->>Interface: create datasource or ingest models
  Interface->>StorageBackend: save entity
  StorageBackend->>Storage: validate identity and persist
  Storage-->>StorageBackend: IdCollisionError on case-only collision
  StorageBackend-->>Interface: validation failure
  Interface-->>Client: HTTP 400 or per-item failure
Loading

Suggested reviewers: zmeigorynych

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: rejecting case-colliding IDs across storage backends.
Linked Issues check ✅ Passed The PR implements the issue's case-collision rejection and related behavior across YAML, SQLite, migrations, and tests.
Out of Scope Changes check ✅ Passed The changes stay aligned with the collision-handling objective; the docs, API, CLI, and MCP updates support the same feature.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch artemy/id-case-collision

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
slayer/storage/join_sync.py (1)

259-282: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Delegate save_memory to the inner backend
JoinSyncStorage still needs a public save_memory override that forwards to self._inner.save_memory(...). The inherited template splits the collision check from the write and bypasses SQLiteStorage.save_memory’s transaction, even though the low-level SQLite helpers keep memory_entities in sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/storage/join_sync.py` around lines 259 - 282, Add a public save_memory
override to JoinSyncStorage that forwards the memory and any supported arguments
directly to self._inner.save_memory(...). Place it alongside the existing memory
delegation methods, ensuring calls use SQLiteStorage.save_memory’s transactional
implementation instead of the inherited template.
🧹 Nitpick comments (3)
slayer/storage/sqlite_storage.py (1)

439-452: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate "SELECT id FROM memories" literal (SonarCloud S1192).

The same literal appears 3 times (here, _next_memory_seq_sync_from_conn, _list_memory_ids_sync). Extract a module-level constant to avoid drift between the three query sites.

♻️ Proposed fix
+_SELECT_MEMORY_IDS_SQL = "SELECT id FROM memories"
+
 ...
-                    ids = [
-                        row[0] if isinstance(row[0], str) else str(row[0])
-                        for row in conn.execute("SELECT id FROM memories")
-                    ]
+                    ids = [
+                        row[0] if isinstance(row[0], str) else str(row[0])
+                        for row in conn.execute(_SELECT_MEMORY_IDS_SQL)
+                    ]

Also applies to: 494-505, 562-569

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/storage/sqlite_storage.py` around lines 439 - 452, Extract the
repeated "SELECT id FROM memories" SQL into a module-level constant, then update
the query in the transaction case-collision check and the corresponding queries
in _next_memory_seq_sync_from_conn and _list_memory_ids_sync to use that
constant.

Source: Linters/SAST tools

slayer/storage/base.py (1)

126-141: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

_find_case_colliding_id is called positionally everywhere; guideline requires keyword args for multi-param functions.

Every call site (_check_model_identity_collision lines 236/242, save_datasource line 465, save_memory line 702, and sqlite_storage.py line 446) passes candidate/existing positionally. As per coding guidelines, "Use keyword arguments for functions with more than one parameter." Consider marking the params keyword-only (*) to enforce this at every call site.

♻️ Proposed fix
 def _find_case_colliding_id(
-    candidate: str, existing: Iterable[str],
+    *, candidate: str, existing: Iterable[str],
 ) -> str | None:

Then update each call site to _find_case_colliding_id(candidate=..., existing=...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/storage/base.py` around lines 126 - 141, Make _find_case_colliding_id
parameters keyword-only by adding the separator before candidate, then update
every call in _check_model_identity_collision, save_datasource, save_memory, and
sqlite_storage.py to pass candidate= and existing= explicitly. Preserve the
function’s collision behavior unchanged.

Source: Path instructions

docs/configuration/storage.md (1)

85-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: "enforced in the shared base class" is imprecise for the SQLite memory path.

For SQLite, memory-id collision checking is actually reimplemented locally in _save_memory_atomic_sync (for transactional atomicity), not routed through the shared base-class check used by models/datasources. Worth a one-clause caveat if you want full precision, but this doesn't materially mislead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/configuration/storage.md` around lines 85 - 86, Update the SQLite
case-sensitivity documentation to clarify that collision enforcement uses the
shared base class for models and datasources, while the SQLite memory path
reimplements the check locally in _save_memory_atomic_sync for transactional
atomicity. Preserve the existing export and cross-platform migration guidance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@slayer/storage/base.py`:
- Around line 453-470: Make the collision check and persistence in
save_datasource and save_model atomic for SQLite by executing both under the
backend’s existing BEGIN IMMEDIATE transaction pattern. Keep the list/identity
checks and corresponding _save_datasource_impl or model-save implementation
within the same transaction, so concurrent case-variant saves cannot both pass
validation; preserve the existing memory-path behavior.

---

Outside diff comments:
In `@slayer/storage/join_sync.py`:
- Around line 259-282: Add a public save_memory override to JoinSyncStorage that
forwards the memory and any supported arguments directly to
self._inner.save_memory(...). Place it alongside the existing memory delegation
methods, ensuring calls use SQLiteStorage.save_memory’s transactional
implementation instead of the inherited template.

---

Nitpick comments:
In `@docs/configuration/storage.md`:
- Around line 85-86: Update the SQLite case-sensitivity documentation to clarify
that collision enforcement uses the shared base class for models and
datasources, while the SQLite memory path reimplements the check locally in
_save_memory_atomic_sync for transactional atomicity. Preserve the existing
export and cross-platform migration guidance.

In `@slayer/storage/base.py`:
- Around line 126-141: Make _find_case_colliding_id parameters keyword-only by
adding the separator before candidate, then update every call in
_check_model_identity_collision, save_datasource, save_memory, and
sqlite_storage.py to pass candidate= and existing= explicitly. Preserve the
function’s collision behavior unchanged.

In `@slayer/storage/sqlite_storage.py`:
- Around line 439-452: Extract the repeated "SELECT id FROM memories" SQL into a
module-level constant, then update the query in the transaction case-collision
check and the corresponding queries in _next_memory_seq_sync_from_conn and
_list_memory_ids_sync to use that constant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0af37df7-0ea6-446f-9710-556c7866a3a0

📥 Commits

Reviewing files that changed from the base of the PR and between cdeadff and 5c50e6b.

📒 Files selected for processing (18)
  • CLAUDE.md
  • docs/concepts/ingestion.md
  • docs/concepts/memories.md
  • docs/concepts/models.md
  • docs/configuration/storage.md
  • slayer/api/server.py
  • slayer/cli.py
  • slayer/core/errors.py
  • slayer/mcp/server.py
  • slayer/storage/base.py
  • slayer/storage/join_sync.py
  • slayer/storage/sqlite_storage.py
  • slayer/storage/v4_migration.py
  • slayer/storage/yaml_storage.py
  • tests/test_api_server.py
  • tests/test_id_collision.py
  • tests/test_memory_string_ids.py
  • tests/test_yaml_exact_entry.py

Comment thread slayer/storage/base.py
Comment on lines +453 to +470
async def save_datasource(self, datasource: DatasourceConfig) -> None:
"""Persist a datasource config.

Rejects a name that differs only by case from an existing
datasource name — or from a saved model's ``data_source`` (they
share the ``models/<ds>/`` directory namespace in the YAML
backend) — then delegates to the backend-specific
:meth:`_save_datasource_impl`. Same-name saves are upserts.
Concrete backends must NOT override this method.
"""
existing = set(await self.list_datasources())
existing.update(ds for ds, _ in await self._list_all_model_identities())
collide = _find_case_colliding_id(datasource.name, existing)
if collide is not None:
raise IdCollisionError(
kind="datasource", new_id=datasource.name, existing_id=collide,
)
await self._save_datasource_impl(datasource)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does asyncio.to_thread yield control to the event loop, allowing interleaving between two separate to_thread calls?

💡 Result:

Yes, asyncio.to_thread yields control to the event loop, allowing for interleaving [1][2]. When you call await asyncio.to_thread(...), the current coroutine suspends and yields control back to the event loop [1]. While the function submitted to to_thread is executing in a separate thread (via a ThreadPoolExecutor), the event loop remains free to run other tasks, callbacks, or perform I/O operations [1][2]. Regarding the interleaving of two separate to_thread calls: 1. If you await each to_thread call sequentially (e.g., await asyncio.to_thread(func1); await asyncio.to_thread(func2)), the second call will not start until the first has finished because the event loop is blocked at the first await expression [3]. 2. If you want two separate to_thread calls to run concurrently and interleave, you must schedule them as concurrent tasks, such as by using asyncio.gather or by creating tasks with asyncio.create_task [3]. This allows the event loop to manage both operations simultaneously while waiting for their completion in separate threads [3]. In essence, asyncio.to_thread is a non-blocking awaitable that allows you to offload blocking synchronous code to a separate thread without stalling the event loop, provided that you properly schedule the calls to run concurrently [1][2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== base.py outline ==\n'
ast-grep outline slayer/storage/base.py --view expanded | sed -n '1,220p'

printf '\n== sqlite_storage.py outline ==\n'
ast-grep outline slayer/storage/sqlite_storage.py --view expanded | sed -n '1,260p'

printf '\n== relevant save/collision methods ==\n'
rg -n "async def (save_datasource|save_model|_save_datasource_impl|_save_model_impl|_list_all_model_identities|list_datasources|_check_model_identity_collision|_save_memory_atomic_sync|BEGIN IMMEDIATE)" slayer/storage/base.py slayer/storage/sqlite_storage.py

Repository: MotleyAI/slayer

Length of output: 7188


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for p in [Path('slayer/storage/base.py'), Path('slayer/storage/sqlite_storage.py')]:
    print(f"\n### {p} ###")
    text = p.read_text()
    for needle in [
        'async def save_datasource',
        'async def save_model',
        'async def _save_datasource_impl',
        'async def _save_model_impl',
        'async def _list_all_model_identities',
        'async def list_datasources',
        'async def _check_model_identity_collision',
        'BEGIN IMMEDIATE',
        'asyncio.to_thread',
        'asyncio.Lock',
    ]:
        if needle in text:
            print(f"FOUND {needle}")
PY

Repository: MotleyAI/slayer

Length of output: 674


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,260p' slayer/storage/base.py | nl -ba | sed -n '1,260p'
printf '\n----\n'
sed -n '1,260p' slayer/storage/sqlite_storage.py | nl -ba | sed -n '1,260p'

Repository: MotleyAI/slayer

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== base.py relevant excerpts ==\n'
sed -n '200,280p' slayer/storage/base.py
printf '\n----\n'
sed -n '453,490p' slayer/storage/base.py
printf '\n====\n'

printf '\n== sqlite_storage.py relevant excerpts ==\n'
sed -n '186,380p' slayer/storage/sqlite_storage.py
printf '\n----\n'
sed -n '404,540p' slayer/storage/sqlite_storage.py
printf '\n----\n'
sed -n '540,640p' slayer/storage/sqlite_storage.py

Repository: MotleyAI/slayer

Length of output: 22447


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== sqlite_storage init/schema excerpts ==\n'
sed -n '133,185p' slayer/storage/sqlite_storage.py

printf '\n== schema-related searches ==\n'
rg -n "CREATE TABLE|CREATE UNIQUE|UNIQUE INDEX|COLLATE NOCASE|PRIMARY KEY|models \\(|datasources \\(" slayer/storage/sqlite_storage.py slayer/storage/*.py

Repository: MotleyAI/slayer

Length of output: 5760


Make the case-collision guard atomic on SQLite
save_model and save_datasource can still race across the asyncio.to_thread boundaries: SQLite’s PRIMARY KEY here is case-sensitive, so concurrent saves like Foo/foo can both pass the check and persist. The memory path already uses BEGIN IMMEDIATE; models/datasources need the same protection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@slayer/storage/base.py` around lines 453 - 470, Make the collision check and
persistence in save_datasource and save_model atomic for SQLite by executing
both under the backend’s existing BEGIN IMMEDIATE transaction pattern. Keep the
list/identity checks and corresponding _save_datasource_impl or model-save
implementation within the same transaction, so concurrent case-variant saves
cannot both pass validation; preserve the existing memory-path behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

YAMLStorage silently aliases datasources, models, and memories whose IDs are filesystem-equivalent

1 participant