feat: Phase 5A — Engineering V1 foundation

First slice of the Engineering V1 sprint. Lays the schema + lifecycle
plumbing so the 10 canonical queries, memory graduation, and conflict
detection can land cleanly on top.

Schema (src/atocore/models/database.py):
- conflicts + conflict_members tables per conflict-model.md (with 5
  indexes on status/project/slot/members)
- memory_audit.entity_kind discriminator — same audit table serves
  both memories ("memory") and entities ("entity"); unified history
  without duplicating infrastructure
- memories.graduated_to_entity_id forward pointer for graduated
  memories (M → E transition preserves the memory as historical
  pointer)

Memory (src/atocore/memory/service.py):
- MEMORY_STATUSES gains "graduated" — memory-entity graduation flow
  ready to wire in Phase 5F

Engineering service (src/atocore/engineering/service.py):
- RELATIONSHIP_TYPES organized into 4 families per ontology-v1.md:
  + Structural: contains, part_of, interfaces_with
  + Intent: satisfies, constrained_by, affected_by_decision,
    based_on_assumption (new), supersedes
  + Validation: analyzed_by, validated_by, supports (new),
    conflicts_with (new), depends_on
  + Provenance: described_by, updated_by_session (new),
    evidenced_by (new), summarized_in (new)
- create_entity + create_relationship now call resolve_project_name()
  on write (canonicalization contract per doc)
- Both accept actor= parameter for audit provenance
- _audit_entity() helper uses shared memory_audit table with
  entity_kind="entity" — one observability layer for everything
- promote_entity / reject_entity_candidate / supersede_entity —
  mirror the memory lifecycle exactly (same pattern, same naming)
- get_entity_audit() reads from the shared table filtered by
  entity_kind

API (src/atocore/api/routes.py):
- POST /entities/{id}/promote (candidate → active)
- POST /entities/{id}/reject (candidate → invalid)
- GET /entities/{id}/audit (full history for one entity)
- POST /entities passes actor="api-http" through

Tests: 317 → 326 (9 new):
- test_entity_project_canonicalization (p04 → p04-gigabit)
- test_promote_entity_candidate_to_active
- test_reject_entity_candidate
- test_promote_active_entity_noop (only candidates promote)
- test_entity_audit_log_captures_lifecycle (before/after snapshots)
- test_new_relationship_types_available (6 new types present)
- test_conflicts_tables_exist
- test_memory_audit_has_entity_kind
- test_graduated_status_accepted

What's next (5B-5I, deferred): entity triage UI tab, core structure
queries, the 3 killer queries, memory graduation script, conflict
detection, MCP + context pack integration. See plan file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 07:01:28 -04:00
parent bb46e21c9b
commit 07664bd743
5 changed files with 388 additions and 2 deletions

View File

@@ -1286,6 +1286,7 @@ def api_create_entity(req: EntityCreateRequest) -> dict:
status=req.status,
confidence=req.confidence,
source_refs=req.source_refs,
actor="api-http",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -1326,6 +1327,34 @@ def api_list_entities(
}
@router.post("/entities/{entity_id}/promote")
def api_promote_entity(entity_id: str) -> dict:
"""Promote a candidate entity to active (Phase 5 Engineering V1)."""
from atocore.engineering.service import promote_entity
success = promote_entity(entity_id, actor="api-http")
if not success:
raise HTTPException(status_code=404, detail=f"Entity not found or not a candidate: {entity_id}")
return {"status": "promoted", "id": entity_id}
@router.post("/entities/{entity_id}/reject")
def api_reject_entity(entity_id: str) -> dict:
"""Reject a candidate entity (Phase 5)."""
from atocore.engineering.service import reject_entity_candidate
success = reject_entity_candidate(entity_id, actor="api-http")
if not success:
raise HTTPException(status_code=404, detail=f"Entity not found or not a candidate: {entity_id}")
return {"status": "rejected", "id": entity_id}
@router.get("/entities/{entity_id}/audit")
def api_entity_audit(entity_id: str, limit: int = 100) -> dict:
"""Return the audit history for a specific entity."""
from atocore.engineering.service import get_entity_audit
entries = get_entity_audit(entity_id, limit=limit)
return {"entity_id": entity_id, "entries": entries, "count": len(entries)}
@router.get("/entities/{entity_id}")
def api_get_entity(entity_id: str) -> dict:
"""Get an entity with its relationships and related entities."""

View File

@@ -9,6 +9,7 @@ from datetime import datetime, timezone
from atocore.models.database import get_connection
from atocore.observability.logger import get_logger
from atocore.projects.registry import resolve_project_name
log = get_logger("engineering")
@@ -31,18 +32,29 @@ ENTITY_TYPES = [
]
RELATIONSHIP_TYPES = [
# Structural family
"contains",
"part_of",
"interfaces_with",
# Intent family
"satisfies",
"constrained_by",
"affected_by_decision",
"based_on_assumption", # Phase 5 — Q-009 killer query
"supersedes",
# Validation family
"analyzed_by",
"validated_by",
"supports", # Phase 5 — Q-011 killer query
"conflicts_with", # Phase 5 — Q-012 future
"depends_on",
"uses_material",
# Provenance family
"described_by",
"supersedes",
"updated_by_session", # Phase 5 — session→entity provenance
"evidenced_by", # Phase 5 — Q-017 evidence trace
"summarized_in", # Phase 5 — mirror caches
# Domain-specific (pre-existing, retained)
"uses_material",
]
ENTITY_STATUSES = ["candidate", "active", "superseded", "invalid"]
@@ -132,6 +144,7 @@ def create_entity(
status: str = "active",
confidence: float = 1.0,
source_refs: list[str] | None = None,
actor: str = "api",
) -> Entity:
if entity_type not in ENTITY_TYPES:
raise ValueError(f"Invalid entity type: {entity_type}. Must be one of {ENTITY_TYPES}")
@@ -140,6 +153,11 @@ def create_entity(
if not name or not name.strip():
raise ValueError("Entity name must be non-empty")
# Phase 5: enforce project canonicalization contract at the write seam.
# Aliases like "p04" become "p04-gigabit" so downstream reads stay
# consistent with the registry.
project = resolve_project_name(project) if project else ""
entity_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
props = properties or {}
@@ -159,6 +177,22 @@ def create_entity(
)
log.info("entity_created", entity_id=entity_id, entity_type=entity_type, name=name)
# Phase 5: entity audit rows share the memory_audit table via
# entity_kind="entity" discriminator. Same infrastructure, unified history.
_audit_entity(
entity_id=entity_id,
action="created",
actor=actor,
after={
"entity_type": entity_type,
"name": name.strip(),
"project": project,
"status": status,
"confidence": confidence,
},
)
return Entity(
id=entity_id, entity_type=entity_type, name=name.strip(),
project=project, description=description, properties=props,
@@ -167,6 +201,35 @@ def create_entity(
)
def _audit_entity(
entity_id: str,
action: str,
actor: str = "api",
before: dict | None = None,
after: dict | None = None,
note: str = "",
) -> None:
"""Append an entity mutation row to the shared memory_audit table."""
try:
with get_connection() as conn:
conn.execute(
"INSERT INTO memory_audit (id, memory_id, action, actor, "
"before_json, after_json, note, entity_kind) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'entity')",
(
str(uuid.uuid4()),
entity_id,
action,
actor or "api",
json.dumps(before or {}),
json.dumps(after or {}),
(note or "")[:500],
),
)
except Exception as e:
log.warning("entity_audit_failed", entity_id=entity_id, action=action, error=str(e))
def create_relationship(
source_entity_id: str,
target_entity_id: str,
@@ -198,6 +261,17 @@ def create_relationship(
target=target_entity_id,
rel_type=relationship_type,
)
# Phase 5: relationship audit as an entity action on the source
_audit_entity(
entity_id=source_entity_id,
action="relationship_added",
actor="api",
after={
"rel_id": rel_id,
"rel_type": relationship_type,
"target": target_entity_id,
},
)
return Relationship(
id=rel_id, source_entity_id=source_entity_id,
target_entity_id=target_entity_id,
@@ -206,6 +280,119 @@ def create_relationship(
)
# --- Phase 5: Entity promote/reject lifecycle ---
def _set_entity_status(
entity_id: str,
new_status: str,
actor: str = "api",
note: str = "",
) -> bool:
"""Transition an entity's status with audit."""
if new_status not in ENTITY_STATUSES:
raise ValueError(f"Invalid status: {new_status}")
with get_connection() as conn:
row = conn.execute(
"SELECT status FROM entities WHERE id = ?", (entity_id,)
).fetchone()
if row is None:
return False
old_status = row["status"]
if old_status == new_status:
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"UPDATE entities SET status = ?, updated_at = ? WHERE id = ?",
(new_status, now, entity_id),
)
# Action verb mirrors memory pattern
if new_status == "active" and old_status == "candidate":
action = "promoted"
elif new_status == "invalid" and old_status == "candidate":
action = "rejected"
elif new_status == "invalid":
action = "invalidated"
elif new_status == "superseded":
action = "superseded"
else:
action = "status_changed"
_audit_entity(
entity_id=entity_id,
action=action,
actor=actor,
before={"status": old_status},
after={"status": new_status},
note=note,
)
log.info("entity_status_changed", entity_id=entity_id,
old=old_status, new=new_status, action=action)
return True
def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
"""Promote a candidate entity to active."""
with get_connection() as conn:
row = conn.execute(
"SELECT status FROM entities WHERE id = ?", (entity_id,)
).fetchone()
if row is None or row["status"] != "candidate":
return False
return _set_entity_status(entity_id, "active", actor=actor, note=note)
def reject_entity_candidate(entity_id: str, actor: str = "api", note: str = "") -> bool:
"""Reject a candidate entity (status → invalid)."""
with get_connection() as conn:
row = conn.execute(
"SELECT status FROM entities WHERE id = ?", (entity_id,)
).fetchone()
if row is None or row["status"] != "candidate":
return False
return _set_entity_status(entity_id, "invalid", actor=actor, note=note)
def supersede_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
"""Mark an active entity as superseded by a newer one."""
return _set_entity_status(entity_id, "superseded", actor=actor, note=note)
def get_entity_audit(entity_id: str, limit: int = 100) -> list[dict]:
"""Fetch audit entries for an entity from the shared audit table."""
with get_connection() as conn:
rows = conn.execute(
"SELECT id, memory_id AS entity_id, action, actor, before_json, "
"after_json, note, timestamp FROM memory_audit "
"WHERE entity_kind = 'entity' AND memory_id = ? "
"ORDER BY timestamp DESC LIMIT ?",
(entity_id, limit),
).fetchall()
out = []
for r in rows:
try:
before = json.loads(r["before_json"] or "{}")
except Exception:
before = {}
try:
after = json.loads(r["after_json"] or "{}")
except Exception:
after = {}
out.append({
"id": r["id"],
"entity_id": r["entity_id"],
"action": r["action"],
"actor": r["actor"] or "api",
"before": before,
"after": after,
"note": r["note"] or "",
"timestamp": r["timestamp"],
})
return out
def get_entities(
entity_type: str | None = None,
project: str | None = None,

View File

@@ -47,6 +47,7 @@ MEMORY_STATUSES = [
"active",
"superseded",
"invalid",
"graduated", # Phase 5: memory has become an entity; content frozen, forward pointer in properties
]

View File

@@ -136,6 +136,16 @@ def _apply_migrations(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_memories_valid_until ON memories(valid_until)"
)
# Phase 5 (Engineering V1): when a memory graduates to an entity, we
# keep the memory row as an immutable historical pointer. The forward
# pointer lets downstream code follow "what did this memory become?"
# without having to join through source_refs.
if not _column_exists(conn, "memories", "graduated_to_entity_id"):
conn.execute("ALTER TABLE memories ADD COLUMN graduated_to_entity_id TEXT")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_memories_graduated ON memories(graduated_to_entity_id)"
)
# Phase 4 (Robustness V1): append-only audit log for memory mutations.
# Every create/update/promote/reject/supersede/invalidate/reinforce/expire/
# auto_promote writes one row here. before/after are JSON snapshots of the
@@ -160,6 +170,60 @@ def _apply_migrations(conn: sqlite3.Connection) -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_memory_audit_timestamp ON memory_audit(timestamp)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_memory_audit_action ON memory_audit(action)")
# Phase 5 (Engineering V1): entity_kind discriminator lets one audit
# table serve both memories AND entities. Default "memory" keeps existing
# rows correct; entity mutations write entity_kind="entity".
if not _column_exists(conn, "memory_audit", "entity_kind"):
conn.execute("ALTER TABLE memory_audit ADD COLUMN entity_kind TEXT DEFAULT 'memory'")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_memory_audit_entity_kind ON memory_audit(entity_kind)"
)
# Phase 5: conflicts + conflict_members tables per conflict-model.md.
# A conflict is "two or more active rows claiming the same slot with
# incompatible values". slot_kind + slot_key identify the logical slot
# (e.g., "component.material" for some component id). Members point
# back to the conflicting rows (memory or entity) with layer trust so
# resolution can pick the highest-trust winner.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS conflicts (
id TEXT PRIMARY KEY,
slot_kind TEXT NOT NULL,
slot_key TEXT NOT NULL,
project TEXT DEFAULT '',
status TEXT DEFAULT 'open',
resolution TEXT DEFAULT '',
resolved_at DATETIME,
detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
note TEXT DEFAULT ''
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS conflict_members (
id TEXT PRIMARY KEY,
conflict_id TEXT NOT NULL REFERENCES conflicts(id) ON DELETE CASCADE,
member_kind TEXT NOT NULL,
member_id TEXT NOT NULL,
member_layer_trust INTEGER DEFAULT 0,
value_snapshot TEXT DEFAULT ''
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_conflicts_status ON conflicts(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_conflicts_project ON conflicts(project)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_conflicts_slot ON conflicts(slot_kind, slot_key)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_conflict_members_conflict ON conflict_members(conflict_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_conflict_members_member ON conflict_members(member_kind, member_id)"
)
# Phase 9 Commit A: capture loop columns on the interactions table.
# The original schema only carried prompt + project_id + a context_pack
# JSON blob. To make interactions a real audit trail of what AtoCore fed

View File

@@ -116,3 +116,108 @@ def test_entity_name_search(tmp_data_dir):
results = get_entities(name_contains="Support")
assert len(results) == 2
# --- Phase 5: Entity promote/reject lifecycle + audit + canonicalization ---
def test_entity_project_canonicalization(tmp_data_dir):
"""Aliases resolve to canonical project_id on write (Phase 5)."""
init_db()
init_engineering_schema()
# "p04" is a registered alias for p04-gigabit
e = create_entity("component", "Test Component", project="p04")
assert e.project == "p04-gigabit"
def test_promote_entity_candidate_to_active(tmp_data_dir):
from atocore.engineering.service import promote_entity, get_entity
init_db()
init_engineering_schema()
e = create_entity("requirement", "CTE tolerance", status="candidate")
assert e.status == "candidate"
assert promote_entity(e.id, actor="test-triage")
e2 = get_entity(e.id)
assert e2.status == "active"
def test_reject_entity_candidate(tmp_data_dir):
from atocore.engineering.service import reject_entity_candidate, get_entity
init_db()
init_engineering_schema()
e = create_entity("decision", "pick vendor Y", status="candidate")
assert reject_entity_candidate(e.id, actor="test-triage", note="duplicate")
e2 = get_entity(e.id)
assert e2.status == "invalid"
def test_promote_active_entity_noop(tmp_data_dir):
from atocore.engineering.service import promote_entity
init_db()
init_engineering_schema()
e = create_entity("component", "Already Active") # default status=active
assert not promote_entity(e.id) # only candidates can promote
def test_entity_audit_log_captures_lifecycle(tmp_data_dir):
from atocore.engineering.service import (
promote_entity,
get_entity_audit,
)
init_db()
init_engineering_schema()
e = create_entity("requirement", "test req", status="candidate", actor="test")
promote_entity(e.id, actor="test-triage", note="looks good")
audit = get_entity_audit(e.id)
actions = [a["action"] for a in audit]
assert "created" in actions
assert "promoted" in actions
promote_entry = next(a for a in audit if a["action"] == "promoted")
assert promote_entry["actor"] == "test-triage"
assert promote_entry["note"] == "looks good"
assert promote_entry["before"]["status"] == "candidate"
assert promote_entry["after"]["status"] == "active"
def test_new_relationship_types_available(tmp_data_dir):
"""Phase 5 added 6 missing relationship types."""
for rel in ["based_on_assumption", "supports", "conflicts_with",
"updated_by_session", "evidenced_by", "summarized_in"]:
assert rel in RELATIONSHIP_TYPES, f"{rel} missing from RELATIONSHIP_TYPES"
def test_conflicts_tables_exist(tmp_data_dir):
"""Phase 5 conflict-model tables."""
from atocore.models.database import get_connection
init_db()
with get_connection() as conn:
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
assert "conflicts" in tables
assert "conflict_members" in tables
def test_memory_audit_has_entity_kind(tmp_data_dir):
"""Phase 5 added entity_kind discriminator."""
from atocore.models.database import get_connection
init_db()
with get_connection() as conn:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(memory_audit)").fetchall()}
assert "entity_kind" in cols
def test_graduated_status_accepted(tmp_data_dir):
"""Phase 5 added 'graduated' memory status for memory→entity transitions."""
from atocore.memory.service import MEMORY_STATUSES
assert "graduated" in MEMORY_STATUSES