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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user