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"
|