2026-04-05 09:41:59 -04:00
|
|
|
"""Tests for Trusted Project State."""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from atocore.context.project_state import (
|
|
|
|
|
CATEGORIES,
|
|
|
|
|
ensure_project,
|
|
|
|
|
format_project_state,
|
|
|
|
|
get_state,
|
|
|
|
|
init_project_state_schema,
|
|
|
|
|
invalidate_state,
|
|
|
|
|
set_state,
|
|
|
|
|
)
|
|
|
|
|
from atocore.models.database import init_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def setup_db(tmp_data_dir):
|
|
|
|
|
"""Initialize DB and project state schema for every test."""
|
|
|
|
|
init_db()
|
|
|
|
|
init_project_state_schema()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_project_creates():
|
|
|
|
|
"""Test creating a new project."""
|
|
|
|
|
pid = ensure_project("test-project", "A test project")
|
|
|
|
|
assert pid
|
|
|
|
|
# Second call returns same ID
|
|
|
|
|
pid2 = ensure_project("test-project")
|
|
|
|
|
assert pid == pid2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_set_state_creates_entry():
|
|
|
|
|
"""Test creating a project state entry."""
|
|
|
|
|
entry = set_state("myproject", "status", "phase", "Phase 0.5 — PoC complete")
|
|
|
|
|
assert entry.category == "status"
|
|
|
|
|
assert entry.key == "phase"
|
|
|
|
|
assert entry.value == "Phase 0.5 — PoC complete"
|
|
|
|
|
assert entry.status == "active"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_set_state_upserts():
|
|
|
|
|
"""Test that setting same key updates the value."""
|
|
|
|
|
set_state("myproject", "status", "phase", "Phase 0")
|
|
|
|
|
entry = set_state("myproject", "status", "phase", "Phase 1")
|
|
|
|
|
assert entry.value == "Phase 1"
|
|
|
|
|
|
|
|
|
|
# Only one entry should exist
|
|
|
|
|
entries = get_state("myproject", category="status")
|
|
|
|
|
assert len(entries) == 1
|
|
|
|
|
assert entries[0].value == "Phase 1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_set_state_invalid_category():
|
|
|
|
|
"""Test that invalid category raises ValueError."""
|
|
|
|
|
with pytest.raises(ValueError, match="Invalid category"):
|
|
|
|
|
set_state("myproject", "invalid_category", "key", "value")
|
|
|
|
|
|
|
|
|
|
|
2026-04-05 17:53:23 -04:00
|
|
|
def test_set_state_validates_confidence():
|
|
|
|
|
"""Project-state confidence should stay within the documented range."""
|
|
|
|
|
with pytest.raises(ValueError, match="Confidence must be between 0.0 and 1.0"):
|
|
|
|
|
set_state("myproject", "status", "phase", "Phase 1", confidence=1.2)
|
|
|
|
|
|
|
|
|
|
|
2026-04-05 09:41:59 -04:00
|
|
|
def test_get_state_all():
|
|
|
|
|
"""Test getting all state entries for a project."""
|
|
|
|
|
set_state("proj", "status", "phase", "Phase 1")
|
|
|
|
|
set_state("proj", "decision", "database", "SQLite for v1")
|
|
|
|
|
set_state("proj", "requirement", "latency", "<2 seconds")
|
|
|
|
|
|
|
|
|
|
entries = get_state("proj")
|
|
|
|
|
assert len(entries) == 3
|
|
|
|
|
categories = {e.category for e in entries}
|
|
|
|
|
assert categories == {"status", "decision", "requirement"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_state_by_category():
|
|
|
|
|
"""Test filtering by category."""
|
|
|
|
|
set_state("proj", "status", "phase", "Phase 1")
|
|
|
|
|
set_state("proj", "decision", "database", "SQLite")
|
|
|
|
|
set_state("proj", "decision", "vectordb", "ChromaDB")
|
|
|
|
|
|
|
|
|
|
entries = get_state("proj", category="decision")
|
|
|
|
|
assert len(entries) == 2
|
|
|
|
|
assert all(e.category == "decision" for e in entries)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_state_nonexistent_project():
|
|
|
|
|
"""Test getting state for a project that doesn't exist."""
|
|
|
|
|
entries = get_state("nonexistent")
|
|
|
|
|
assert entries == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalidate_state():
|
|
|
|
|
"""Test marking a state entry as superseded."""
|
|
|
|
|
set_state("invalidate-test", "decision", "approach", "monolith")
|
|
|
|
|
success = invalidate_state("invalidate-test", "decision", "approach")
|
|
|
|
|
assert success
|
|
|
|
|
|
|
|
|
|
# Active entries should be empty
|
|
|
|
|
entries = get_state("invalidate-test", active_only=True)
|
|
|
|
|
assert len(entries) == 0
|
|
|
|
|
|
|
|
|
|
# But entry still exists if we include inactive
|
|
|
|
|
entries = get_state("invalidate-test", active_only=False)
|
|
|
|
|
assert len(entries) == 1
|
|
|
|
|
assert entries[0].status == "superseded"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalidate_nonexistent():
|
|
|
|
|
"""Test invalidating a nonexistent entry."""
|
|
|
|
|
success = invalidate_state("proj", "decision", "nonexistent")
|
|
|
|
|
assert not success
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_format_project_state():
|
|
|
|
|
"""Test formatting state entries for context injection."""
|
|
|
|
|
set_state("proj", "status", "phase", "Phase 1")
|
|
|
|
|
set_state("proj", "decision", "database", "SQLite", source="Build Spec V1")
|
|
|
|
|
entries = get_state("proj")
|
|
|
|
|
|
|
|
|
|
formatted = format_project_state(entries)
|
|
|
|
|
assert "--- Trusted Project State ---" in formatted
|
|
|
|
|
assert "--- End Project State ---" in formatted
|
|
|
|
|
assert "phase: Phase 1" in formatted
|
|
|
|
|
assert "database: SQLite" in formatted
|
|
|
|
|
assert "(source: Build Spec V1)" in formatted
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_format_empty():
|
|
|
|
|
"""Test formatting empty state."""
|
|
|
|
|
assert format_project_state([]) == ""
|
fix(P1+P2): canonicalize project names at every trust boundary
Three findings from codex's review of the previous P1+P2 fix. The
earlier commit (f2372ef) only fixed alias resolution at the context
builder. Codex correctly pointed out that the same fragmentation
applies at every other place a project name crosses a boundary —
project_state writes/reads, interaction capture/listing/filtering,
memory create/queries, and reinforcement's downstream queries. Plus
a real bug in the interaction `since` filter where the storage
format and the documented ISO format don't compare cleanly.
The fix is one helper used at every boundary instead of duplicating
the resolution inline.
New helper: src/atocore/projects/registry.py::resolve_project_name
---------------------------------------------------------------
- Single canonicalization boundary for project names
- Returns the canonical project_id when the input matches any
registered id or alias
- Returns the input unchanged for empty/None and for unregistered
names (preserves backwards compat with hand-curated state that
predates the registry)
- Documented as the contract that every read/write at the trust
boundary should pass through
P1 — Trusted Project State endpoints
------------------------------------
src/atocore/context/project_state.py: set_state, get_state, and
invalidate_state now all canonicalize project_name through
resolve_project_name BEFORE looking up or creating the project row.
Before this fix:
- POST /project/state with project="p05" called ensure_project("p05")
which created a separate row in the projects table
- The state row was attached to that alias project_id
- Later context builds canonicalized "p05" -> "p05-interferometer"
via the builder fix from f2372ef and never found the state
- Result: trusted state silently fragmented across alias rows
After this fix:
- The alias is resolved to the canonical id at every entry point
- Two captures (one via "p05", one via "p05-interferometer") write
to the same row
- get_state via either alias or the canonical id finds the same row
Fixes the highest-priority gap codex flagged because Trusted Project
State is supposed to be the most dependable layer in the AtoCore
trust hierarchy.
P2.a — Interaction capture project canonicalization
----------------------------------------------------
src/atocore/interactions/service.py: record_interaction now
canonicalizes project before storing, so interaction.project is
always the canonical id regardless of what the client passed.
Downstream effects:
- reinforce_from_interaction queries memories by interaction.project
-> previously missed memories stored under canonical id
-> now consistent because interaction.project IS the canonical id
- the extractor stamps candidates with interaction.project
-> previously created candidates in alias buckets
-> now creates candidates in the canonical bucket
- list_interactions(project=alias) was already broken, now fixed by
canonicalizing the filter input on the read side too
Memory service applied the same fix:
- src/atocore/memory/service.py: create_memory and get_memories
both canonicalize project through resolve_project_name
- This keeps stored memory.project consistent with the
reinforcement query path
P2.b — Interaction `since` filter format normalization
------------------------------------------------------
src/atocore/interactions/service.py: new _normalize_since helper.
The bug:
- created_at is stored as 'YYYY-MM-DD HH:MM:SS' (no timezone, UTC by
convention) so it sorts lexically and compares cleanly with the
SQLite CURRENT_TIMESTAMP default
- The `since` parameter was documented as ISO 8601 but compared as
a raw string against the storage format
- The lexically-greater 'T' separator means an ISO timestamp like
'2026-04-07T12:00:00Z' is GREATER than the storage form
'2026-04-07 12:00:00' for the same instant
- Result: a client passing ISO `since` got an empty result for any
row from the same day, even though those rows existed and were
technically "after" the cutoff in real-world time
The fix:
- _normalize_since accepts ISO 8601 with T, optional Z suffix,
optional fractional seconds, optional +HH:MM offsets
- Uses datetime.fromisoformat for parsing (Python 3.11+)
- Converts to UTC and reformats as the storage format before the
SQL comparison
- The bare storage format still works (backwards compat path is a
regex match that returns the input unchanged)
- Unparseable input is returned as-is so the comparison degrades
gracefully (rows just don't match) instead of raising and
breaking the listing endpoint
builder.py refactor
-------------------
The previous P1 fix had inline canonicalization. Now it uses the
shared helper for consistency:
- import changed from get_registered_project to resolve_project_name
- the inline lookup is replaced with a single helper call
- the comment block now points at representation-authority.md for
the canonicalization contract
New shared test fixture: tests/conftest.py::project_registry
------------------------------------------------------------
- Standardizes the registry-setup pattern that was duplicated
across test_context_builder.py, test_project_state.py,
test_interactions.py, and test_reinforcement.py
- Returns a callable that takes (project_id, [aliases]) tuples
and writes them into a temp registry file with the env var
pointed at it and config.settings reloaded
- Used by all 12 new regression tests in this commit
Tests (12 new, all green on first run)
--------------------------------------
test_project_state.py:
- test_set_state_canonicalizes_alias: write via alias, read via
every alias and the canonical id, verify same row id
- test_get_state_canonicalizes_alias_after_canonical_write
- test_invalidate_state_canonicalizes_alias
- test_unregistered_project_state_still_works (backwards compat)
test_interactions.py:
- test_record_interaction_canonicalizes_project
- test_list_interactions_canonicalizes_project_filter
- test_list_interactions_since_accepts_iso_with_t_separator
- test_list_interactions_since_accepts_z_suffix
- test_list_interactions_since_accepts_offset
- test_list_interactions_since_storage_format_still_works
test_reinforcement.py:
- test_reinforcement_works_when_capture_uses_alias (end-to-end:
capture under alias, seed memory under canonical, verify
reinforcement matches)
- test_get_memories_filter_by_alias
Full suite: 174 passing (was 162), 1 warning. The +12 is the
new regression tests, no existing tests regressed.
What's still NOT canonicalized (and why)
----------------------------------------
- _rank_chunks's secondary substring boost in builder.py — the
retriever already does the right thing via its own
_project_match_boost which calls get_registered_project. The
redundant secondary boost still uses the raw hint but it's a
multiplicative factor on top of correct retrieval, not a
filter, so it can't drop relevant chunks. Tracked as a future
cleanup but not a P1.
- update_memory's project field (you can't change a memory's
project after creation in the API anyway).
- The retriever's project_hint parameter on direct /query calls
— same reasoning as the builder boost, plus the retriever's
own get_registered_project call already handles aliases there.
2026-04-07 08:29:33 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Alias canonicalization regression tests --------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_set_state_canonicalizes_alias(project_registry):
|
|
|
|
|
"""Writing state via an alias should land under the canonical project id.
|
|
|
|
|
|
|
|
|
|
Regression for codex's P1 finding: previously /project/state with
|
|
|
|
|
project="p05" created a separate alias row that later context builds
|
|
|
|
|
(which canonicalize the hint) would never see.
|
|
|
|
|
"""
|
|
|
|
|
project_registry(("p05-interferometer", ["p05", "interferometer"]))
|
|
|
|
|
|
|
|
|
|
set_state("p05", "status", "next_focus", "Wave 2 ingestion")
|
|
|
|
|
|
|
|
|
|
# The state must be reachable via every alias AND the canonical id
|
|
|
|
|
via_alias = get_state("p05")
|
|
|
|
|
via_canonical = get_state("p05-interferometer")
|
|
|
|
|
via_other_alias = get_state("interferometer")
|
|
|
|
|
|
|
|
|
|
assert len(via_alias) == 1
|
|
|
|
|
assert len(via_canonical) == 1
|
|
|
|
|
assert len(via_other_alias) == 1
|
|
|
|
|
# All three reads return the same row id (no fragmented duplicates)
|
|
|
|
|
assert via_alias[0].id == via_canonical[0].id == via_other_alias[0].id
|
|
|
|
|
assert via_canonical[0].value == "Wave 2 ingestion"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_state_canonicalizes_alias_after_canonical_write(project_registry):
|
|
|
|
|
"""Reading via an alias should find state written under the canonical id."""
|
|
|
|
|
project_registry(("p04-gigabit", ["p04", "gigabit"]))
|
|
|
|
|
|
|
|
|
|
set_state("p04-gigabit", "status", "phase", "Phase 1 baseline")
|
|
|
|
|
via_alias = get_state("gigabit")
|
|
|
|
|
|
|
|
|
|
assert len(via_alias) == 1
|
|
|
|
|
assert via_alias[0].value == "Phase 1 baseline"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalidate_state_canonicalizes_alias(project_registry):
|
|
|
|
|
"""Invalidating via an alias should hit the canonical row."""
|
|
|
|
|
project_registry(("p06-polisher", ["p06", "polisher"]))
|
|
|
|
|
|
|
|
|
|
set_state("p06-polisher", "decision", "frame", "kinematic mounts")
|
|
|
|
|
success = invalidate_state("polisher", "decision", "frame")
|
|
|
|
|
|
|
|
|
|
assert success is True
|
|
|
|
|
active = get_state("p06-polisher")
|
|
|
|
|
assert len(active) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unregistered_project_state_still_works(project_registry):
|
|
|
|
|
"""Hand-curated state for an unregistered project must still round-trip.
|
|
|
|
|
|
|
|
|
|
Backwards compatibility with state created before the project
|
|
|
|
|
registry existed: resolve_project_name returns the input unchanged
|
|
|
|
|
when the registry has no record, so the raw name is used as-is.
|
|
|
|
|
"""
|
|
|
|
|
project_registry() # empty registry
|
|
|
|
|
|
|
|
|
|
set_state("orphan-project", "status", "phase", "Standalone")
|
|
|
|
|
entries = get_state("orphan-project")
|
|
|
|
|
assert len(entries) == 1
|
|
|
|
|
assert entries[0].value == "Standalone"
|
docs+test: clarify legacy alias compatibility gap, add gap regression test
Codex caught a real documentation accuracy bug in the previous
canonicalization doc commit (f521aab). The doc claimed that rows
written under aliases before fb6298a "still work via the
unregistered-name fallback path" — that is wrong for REGISTERED
aliases, which is exactly the case that matters.
The unregistered-name fallback only saves you when the project was
never in the registry: a row stored under "orphan-project" is read
back via "orphan-project", both pass through resolve_project_name
unchanged, and the strings line up. For a registered alias like
"p05", the helper rewrites the read key to "p05-interferometer"
but does NOT rewrite the storage key, so the legacy row becomes
silently invisible.
This commit corrects the doc and locks the gap behavior in with
a regression test, so the issue cannot be lost again.
docs/architecture/project-identity-canonicalization.md
------------------------------------------------------
- Removed the misleading claim from the "What this rule does NOT
cover" section. Replaced with a pointer to the new gap section
and an explicit statement that the migration is required before
engineering V1 ships.
- New "Compatibility gap: legacy alias-keyed rows" section between
"Why this is the trust hierarchy in action" and "The rule for
new entry points". This is the natural insertion point because
the gap is exactly the trust hierarchy failing for legacy data.
The section covers:
* a worked T0/T1 timeline showing the exact failure mode
* what is at risk on the live Dalidou DB, ranked by trust tier:
projects table (shadow rows), project_state (highest risk
because Layer 3 is most-authoritative), memories, interactions
* inspection SQL queries for measuring the actual blast radius
on the live DB before running any migration
* the spec for the migration script: walk projects, find shadow
rows, merge dependent state via the conflict model when there
are collisions, dry-run mode, idempotent
* explicit statement that this is required pre-V1 because V1
will add new project-keyed tables and the killer correctness
queries from engineering-query-catalog.md would report wrong
results against any project that has shadow rows
- "Open follow-ups" item 1 promoted from "tracked optional" to
"REQUIRED before engineering V1 ships, NOT optional" with a
more honest cost estimate (~150 LOC migration + ~50 LOC tests
+ supervised live run, not the previous optimistic ~30 LOC)
- TL;DR rewritten to mention the gap explicitly and re-order
the open follow-ups so the migration is the top priority
tests/test_project_state.py
---------------------------
- New test_legacy_alias_keyed_state_is_invisible_until_migrated
- Inserts a "p05" project row + a project_state row pointing at
it via raw SQL (bypassing set_state which now canonicalizes),
simulating a pre-fix legacy row
- Verifies the canonicalized get_state path can NOT see the row
via either the alias or the canonical id — this is the bug
- Verifies the row is still in the database (just unreachable),
so the migration script has something to find
- The docstring explicitly says: "When the legacy alias migration
script lands, this test must be inverted." Future readers will
know exactly when and how to update it.
Full suite: 175 passing (was 174), 1 warning. The +1 is the new
gap regression test.
What this commit does NOT do
----------------------------
- The migration script itself is NOT in this commit. Codex's
finding was a doc accuracy issue, and the right scope is fix
the doc + lock the gap behavior in. Writing the migration is
the next concrete step but is bigger (~200 LOC + dry-run mode
+ collision handling via the conflict model + supervised run
on the live Dalidou DB), warrants its own commit, and probably
warrants a "draft + review the dry-run output before applying"
workflow rather than a single shot.
- Existing tests are unchanged. The new test stands alone as a
documented gap; the 12 canonicalization tests from fb6298a
still pass without modification.
2026-04-07 20:14:19 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_legacy_alias_keyed_state_is_invisible_until_migrated(project_registry):
|
|
|
|
|
"""Documents the compatibility gap from project-identity-canonicalization.md.
|
|
|
|
|
|
|
|
|
|
Rows that were written under a registered alias BEFORE the
|
|
|
|
|
canonicalization landed in fb6298a are stored in the projects
|
|
|
|
|
table under the alias name (not the canonical id). Every read
|
|
|
|
|
path now canonicalizes to the canonical id, so those legacy
|
|
|
|
|
rows become invisible.
|
|
|
|
|
|
|
|
|
|
This test simulates the legacy state by inserting a shadow
|
|
|
|
|
project row and a state row that points at it via raw SQL,
|
|
|
|
|
bypassing set_state() which now canonicalizes. Then it
|
|
|
|
|
verifies the canonicalized get_state() does NOT find the
|
|
|
|
|
legacy row.
|
|
|
|
|
|
|
|
|
|
When the legacy alias migration script lands (see the open
|
|
|
|
|
follow-ups in docs/architecture/project-identity-canonicalization.md),
|
|
|
|
|
this test must be inverted: after running the migration the
|
|
|
|
|
legacy state should be reachable via the canonical project,
|
|
|
|
|
not invisible. The migration is required before engineering
|
|
|
|
|
V1 ships.
|
|
|
|
|
"""
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
from atocore.models.database import get_connection
|
|
|
|
|
|
|
|
|
|
project_registry(("p05-interferometer", ["p05", "interferometer"]))
|
|
|
|
|
|
|
|
|
|
# Simulate a pre-fix legacy row by writing directly under the
|
|
|
|
|
# alias name. This is what the OLD set_state would have done
|
|
|
|
|
# before fb6298a added canonicalization.
|
|
|
|
|
legacy_project_id = str(uuid.uuid4())
|
|
|
|
|
legacy_state_id = str(uuid.uuid4())
|
|
|
|
|
with get_connection() as conn:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO projects (id, name, description) VALUES (?, ?, ?)",
|
|
|
|
|
(legacy_project_id, "p05", "shadow row created before canonicalization"),
|
|
|
|
|
)
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO project_state "
|
|
|
|
|
"(id, project_id, category, key, value, source, confidence) "
|
|
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
legacy_state_id,
|
|
|
|
|
legacy_project_id,
|
|
|
|
|
"status",
|
|
|
|
|
"legacy_focus",
|
|
|
|
|
"Wave 1 ingestion",
|
|
|
|
|
"pre-canonicalization",
|
|
|
|
|
1.0,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# The canonicalized read path looks under "p05-interferometer"
|
|
|
|
|
# and cannot see the legacy row. THIS IS THE GAP.
|
|
|
|
|
via_alias = get_state("p05")
|
|
|
|
|
via_canonical = get_state("p05-interferometer")
|
|
|
|
|
assert all(entry.value != "Wave 1 ingestion" for entry in via_alias)
|
|
|
|
|
assert all(entry.value != "Wave 1 ingestion" for entry in via_canonical)
|
|
|
|
|
|
|
|
|
|
# The legacy row is still in the database — it's just unreachable
|
|
|
|
|
# from the canonicalized read path. The migration script (open
|
|
|
|
|
# follow-up) is what closes the gap.
|
|
|
|
|
with get_connection() as conn:
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"SELECT value FROM project_state WHERE id = ?", (legacy_state_id,)
|
|
|
|
|
).fetchone()
|
|
|
|
|
assert row is not None
|
|
|
|
|
assert row["value"] == "Wave 1 ingestion"
|