| name | shared-directory-namespace-collision |
| description | When a new feature introduces strict-naming sub-trees under an existing top-level directory that legacy code already writes to, recursive scans crash on legacy files unless the scan filters to canonical patterns first. Local unit tests pass on empty fixtures; CI fails the moment a real seeded vault/repo exists. |
Shared-directory namespace collision
Trigger
You are about to add a new feature that defines a strict file-path schema under an existing top-level directory. Examples:
- A new typed-action layer using
actions/<kind>/<file>.md while a legacy logger writes actions/{YYYY-MM-DD}.md
- A canonicalization layer adding
concepts/<state>/<id>.md under a vault that already has user-created notes in concepts/
- A new schema-versioned migration system writing
migrations/v2/<n>.sql while old code writes migrations/<n>.sql
If your scan code is "list every .md under actions/ and parse it as a typed action," you have this collision.
The failure mode
Local unit tests use empty fixtures or seed only the new schema's files โ they pass. But:
- Demo CI seeds a realistic vault/repo with legacy artifacts โ first request crashes
- Existing users upgrade and find the gateway hangs at startup โ recovery requires manual cleanup
- The crash bubbles all the way up to the FastAPI lifespan and turns into "socket hang up" on every request, which masquerades as a network problem
The error message is usually unambiguous (ValueError: not an action path: 'actions/2026-05-06_run.md'), but it shows up only in the demo CI's "backend logs (on failure)" step โ not in any unit test run.
The fix pattern
Filter to canon-shaped paths BEFORE calling the strict reader. Do not make the reader lenient; that hides real bugs in service-side calls. Add a predicate at the scan layer.
_KNOWN_ACTION_KINDS: frozenset[str] = frozenset(models.ACTION_KINDS)
def _is_canon_action_path(path: str) -> bool:
"""``actions/<kind>/...md`` where ``<kind>`` is a known action kind."""
parts = PurePosixPath(path).parts
if len(parts) < 3 or parts[0] != "actions":
return False
if parts[1] not in _KNOWN_ACTION_KINDS:
return False
if not path.endswith(".md"):
return False
return not (
parts[1] == "create-decision"
and (len(parts) < 4 or parts[2] not in paths.DECISION_KINDS)
)
Apply at every recursive walk site:
for path in await storage.list_files("actions"):
action = await store.read_action(path)
for path in await storage.list_files("actions"):
if not _is_canon_action_path(path):
continue
action = await store.read_action(path)
Same pattern for _reload_path (live invalidation): when an event arrives for a non-canon path, return early instead of trying to read it.
Regression test recipe
The unit test that catches this MUST seed both a legacy file and a canon file in the same vault, then assert:
rebuild_from_vault does not raise.
- The canon file is indexed.
- The legacy file is NOT indexed.
@pytest.mark.asyncio
async def test_rebuild_with_legacy_action_log_does_not_crash(storage):
await storage.write("actions/2026-05-06_run.md", "# legacy log\n")
await storage.write("actions/input-log/2026-05-06.md", "# legacy input\n")
store = NoteStore(storage)
canon_path = "actions/create-concept/20260507-140000-ml.md"
await store.write_action(models.ActionEntry(path=canon_path, kind="create-concept", ...))
index = InMemoryCanonicalizationIndex()
await index.initialize(storage)
actions = await index.list_actions(kind="create-concept")
assert len(actions) == 1
assert all("2026-05-06_run" not in a.path for a in await index.list_actions())
The first time you write this test, it will fail โ that's the point. Once it passes, it pins the contract for every future top-level dir share.
Audit checklist before adding a new sub-tree
Before merging a feature that walks <dir>/**:
grep -rn '<dir>/' bsage/ | grep -v <new_module> โ what else writes here?
- For every existing writer found, list one example file path. Compare its shape against your new schema.
- If the shapes overlap (same
.md extension, same depth, same prefix), add a predicate filter at the walk site.
- Add the regression test above with the actual legacy path string from step 2 (e.g.
"actions/2026-05-06_run.md" โ not a generic placeholder).
- Run the test locally; confirm it fails before your fix and passes after.
Why this trap is recurring
- Recursive
list_files() is a one-line API that hides what shape it returns.
- Spec-driven schemas are usually written from a clean-vault assumption.
- Demo seeders / e2e fixtures live in a different directory from unit tests, so the collision shows up only at integration time.
- The error message says "not an action path" but the actual problem is "not a CANON action path โ but a legacy one we should ignore."
When you see "everything works locally but CI demo crashes at startup," check directory-share collisions first.