diff --git a/DEV-LEDGER.md b/DEV-LEDGER.md index 8955925..64743d2 100644 --- a/DEV-LEDGER.md +++ b/DEV-LEDGER.md @@ -9,7 +9,7 @@ - **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z) - **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed) - **main_tip**: `999788b` -- **test_count**: 509 (prior 494 + 15 new Issue E tests) +- **test_count**: 521 (prior 509 + 12 new PATCH-entity tests) - **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression) - **vectors**: 33,253 - **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity) @@ -160,6 +160,8 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha ## Session Log +- **2026-04-22 Claude** PATCH `/entities/{id}` + Issue D (/v1/engineering/* aliases) landed. New `update_entity()` in `src/atocore/engineering/service.py` supports partial updates to description (replace), properties (shallow merge — `null` value deletes a key), confidence (0..1, 400 on bounds violation), source_refs (append + dedup). Writes an `updated` audit row with full before/after snapshots. Forbidden via this path: entity_type / project / name / status — those require supersede+create or the dedicated status endpoints, by design. New route `PATCH /entities/{id}` aliased under `/v1`. Issue D: all 10 `/engineering/*` query paths (decisions, systems, components/{id}/requirements, changes, gaps + sub-paths, impact, evidence) added to the `/v1` allowlist. 12 new PATCH tests (merge, null-delete, confidence bounds, source_refs dedup, 404, audit row, v1 alias). Tests 509 → 521. Next: commit + deploy, then Issue B (wiki redlinks) as the last remaining P2 per Antoine's sprint order. + - **2026-04-21 Claude (night)** Issue E (retraction path for active entities + memories) landed. Two new entity endpoints and two new memory endpoints, all aliased under `/v1`: `POST /entities/{id}/invalidate` (active→invalid, 200 idempotent on already-invalid, 409 if candidate/superseded, 404 if missing), `POST /entities/{id}/supersede` (active→superseded + auto-creates `supersedes` relationship from the new entity to the old one; rejects self-supersede and unknown superseded_by with 400), `POST /memory/{id}/invalidate`, `POST /memory/{id}/supersede`. `invalidate_memory`/`supersede_memory` in service.py now take a `reason` string that lands in the audit `note`. New service helper `invalidate_active_entity(id, reason)` returns `(ok, code)` where code is one of `invalidated | already_invalid | not_active | not_found` for a clean HTTP-status mapping. 15 new tests. Tests 494 → 509. Unblocks correction workflows — no more SQL required to retract mistakes. - **2026-04-21 Claude (cleanup)** One-time SQL cleanup on live Dalidou: flipped 8 `status='active' → 'invalid'` rows in `entities` (CGH, tower, "interferometer mirror tower", steel, "steel (likely)" in p05-interferometer + 3 remaining `AKC-E2E-Test-*` rows that were still active). Each update paired with a `memory_audit` row (action=`invalidated`, actor=`sql-cleanup`, note references Issue E pending). Executed inside the `atocore` container via `docker exec` since `/srv/storage/atocore/data/db/atocore.db` is root-owned and the service holds write perms. Verification: `GET /entities?project=p05-interferometer&scope_only=true` now 21 active, zero pollution. Issue E (public `POST /v1/entities/{id}/invalidate` for active→invalid) remains open — this cleanup should not be needed again once E ships. diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 3c23868..7ba1249 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -2197,6 +2197,20 @@ def api_reject_entity(entity_id: str) -> dict: return {"status": "rejected", "id": entity_id} +class EntityPatchRequest(BaseModel): + """Partial update for an existing entity. + + ``properties`` is a shallow merge: keys with ``null`` delete, + keys with a value overwrite. ``source_refs`` is append-only + (duplicates filtered). Omit a field to leave it unchanged. + """ + description: str | None = None + properties: dict | None = None + confidence: float | None = None + source_refs: list[str] | None = None + note: str = "" + + class EntityInvalidateRequest(BaseModel): reason: str = "" @@ -2206,6 +2220,43 @@ class EntitySupersedeRequest(BaseModel): reason: str = "" +@router.patch("/entities/{entity_id}") +def api_patch_entity(entity_id: str, req: EntityPatchRequest) -> dict: + """Update mutable fields on an existing entity. + + Allowed: description, properties (shallow merge, null=delete key), + confidence (0..1), source_refs (append, dedup). Forbidden: + entity_type, project, name, status — those require supersede+create + or the dedicated status endpoints. + """ + from atocore.engineering.service import update_entity + + try: + updated = update_entity( + entity_id, + description=req.description, + properties_patch=req.properties, + confidence=req.confidence, + append_source_refs=req.source_refs, + actor="api-http", + note=req.note, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + if updated is None: + raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}") + return { + "status": "updated", + "id": updated.id, + "entity_type": updated.entity_type, + "name": updated.name, + "description": updated.description, + "properties": updated.properties, + "confidence": updated.confidence, + "source_refs": updated.source_refs, + } + + @router.post("/entities/{entity_id}/invalidate") def api_invalidate_entity( entity_id: str, diff --git a/src/atocore/engineering/service.py b/src/atocore/engineering/service.py index aaf77d0..5fd314a 100644 --- a/src/atocore/engineering/service.py +++ b/src/atocore/engineering/service.py @@ -534,6 +534,106 @@ def invalidate_active_entity( return ok, "invalidated" if ok else "not_active" +def update_entity( + entity_id: str, + *, + description: str | None = None, + properties_patch: dict | None = None, + confidence: float | None = None, + append_source_refs: list[str] | None = None, + actor: str = "api", + note: str = "", +) -> Entity | None: + """Update mutable fields on an existing entity (Issue E follow-up). + + Field rules (kept narrow on purpose): + + - ``description``: replaces the current value when provided. + - ``properties_patch``: merged into the existing ``properties`` dict, + shallow. Pass ``None`` as a value to delete a key; pass a new + value to overwrite it. + - ``confidence``: replaces when provided. Must be in [0, 1]. + - ``append_source_refs``: appended verbatim to the existing list + (duplicates are filtered out, order preserved). + + What you cannot change via this path: + + - ``entity_type`` — requires supersede+create (a new type is a new + thing). + - ``project`` — use ``promote_entity`` with ``target_project`` for + inbox→project graduation, or supersede+create for anything else. + - ``name`` — renames are destructive to cross-references; + supersede+create. + - ``status`` — use the dedicated promote/reject/invalidate/supersede + endpoints. + + Returns the updated entity, or None if no such entity exists. + """ + entity = get_entity(entity_id) + if entity is None: + return None + if confidence is not None and not (0.0 <= confidence <= 1.0): + raise ValueError("confidence must be in [0, 1]") + + before = { + "description": entity.description, + "properties": dict(entity.properties or {}), + "confidence": entity.confidence, + "source_refs": list(entity.source_refs or []), + } + + new_description = entity.description if description is None else description + new_confidence = entity.confidence if confidence is None else confidence + new_properties = dict(entity.properties or {}) + if properties_patch: + for key, value in properties_patch.items(): + if value is None: + new_properties.pop(key, None) + else: + new_properties[key] = value + new_refs = list(entity.source_refs or []) + if append_source_refs: + existing = set(new_refs) + for ref in append_source_refs: + if ref and ref not in existing: + new_refs.append(ref) + existing.add(ref) + + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + with get_connection() as conn: + conn.execute( + """UPDATE entities + SET description = ?, properties = ?, confidence = ?, + source_refs = ?, updated_at = ? + WHERE id = ?""", + ( + new_description, + json.dumps(new_properties), + new_confidence, + json.dumps(new_refs), + now, + entity_id, + ), + ) + + after = { + "description": new_description, + "properties": new_properties, + "confidence": new_confidence, + "source_refs": new_refs, + } + _audit_entity( + entity_id=entity_id, + action="updated", + actor=actor, + before=before, + after=after, + note=note, + ) + log.info("entity_updated", entity_id=entity_id, actor=actor) + return get_entity(entity_id) + + 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: diff --git a/src/atocore/main.py b/src/atocore/main.py index c214b62..0e17147 100644 --- a/src/atocore/main.py +++ b/src/atocore/main.py @@ -98,6 +98,18 @@ _V1_PUBLIC_PATHS = { "/assets/{asset_id}/thumbnail", "/assets/{asset_id}/meta", "/entities/{entity_id}/evidence", + # Issue D: engineering query surface (decisions, systems, components, + # gaps, evidence, impact, changes) + "/engineering/projects/{project_name}/systems", + "/engineering/decisions", + "/engineering/components/{component_id}/requirements", + "/engineering/changes", + "/engineering/gaps", + "/engineering/gaps/orphan-requirements", + "/engineering/gaps/risky-decisions", + "/engineering/gaps/unsupported-claims", + "/engineering/impact", + "/engineering/evidence", } _v1_router = APIRouter(prefix="/v1", tags=["v1"]) diff --git a/tests/test_patch_entity.py b/tests/test_patch_entity.py new file mode 100644 index 0000000..fb3fe4f --- /dev/null +++ b/tests/test_patch_entity.py @@ -0,0 +1,160 @@ +"""PATCH /entities/{id} — edit mutable fields without cloning (sprint P1).""" + +import pytest +from fastapi.testclient import TestClient + +from atocore.engineering.service import ( + create_entity, + get_entity, + init_engineering_schema, + update_entity, +) +from atocore.main import app +from atocore.models.database import init_db + + +@pytest.fixture +def env(tmp_data_dir, tmp_path, monkeypatch): + registry_path = tmp_path / "test-registry.json" + registry_path.write_text('{"projects": []}', encoding="utf-8") + monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path)) + from atocore import config + config.settings = config.Settings() + init_db() + init_engineering_schema() + yield tmp_data_dir + + +def test_update_entity_description(env): + e = create_entity(entity_type="component", name="t", description="old desc") + updated = update_entity(e.id, description="new desc") + assert updated.description == "new desc" + assert get_entity(e.id).description == "new desc" + + +def test_update_entity_properties_merge(env): + e = create_entity( + entity_type="component", + name="t2", + properties={"color": "red", "kg": 5}, + ) + updated = update_entity( + e.id, properties_patch={"color": "blue", "material": "invar"}, + ) + assert updated.properties == {"color": "blue", "kg": 5, "material": "invar"} + + +def test_update_entity_properties_null_deletes_key(env): + e = create_entity( + entity_type="component", + name="t3", + properties={"color": "red", "kg": 5}, + ) + updated = update_entity(e.id, properties_patch={"color": None}) + assert "color" not in updated.properties + assert updated.properties.get("kg") == 5 + + +def test_update_entity_confidence_bounds(env): + e = create_entity(entity_type="component", name="t4", confidence=0.5) + with pytest.raises(ValueError): + update_entity(e.id, confidence=1.5) + with pytest.raises(ValueError): + update_entity(e.id, confidence=-0.1) + + +def test_update_entity_source_refs_append_dedup(env): + e = create_entity( + entity_type="component", + name="t5", + source_refs=["session:a", "session:b"], + ) + updated = update_entity( + e.id, append_source_refs=["session:b", "session:c"], + ) + assert updated.source_refs == ["session:a", "session:b", "session:c"] + + +def test_update_entity_returns_none_for_unknown(env): + assert update_entity("nonexistent", description="x") is None + + +def test_api_patch_happy_path(env): + e = create_entity( + entity_type="component", + name="tower", + description="old", + properties={"material": "steel"}, + confidence=0.6, + ) + client = TestClient(app) + r = client.patch( + f"/entities/{e.id}", + json={ + "description": "three-stage tower", + "properties": {"material": "invar", "height_mm": 1200}, + "confidence": 0.9, + "source_refs": ["session:s1"], + "note": "from voice session", + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["description"] == "three-stage tower" + assert body["properties"]["material"] == "invar" + assert body["properties"]["height_mm"] == 1200 + assert body["confidence"] == 0.9 + assert "session:s1" in body["source_refs"] + + +def test_api_patch_omitted_fields_unchanged(env): + e = create_entity( + entity_type="component", + name="keep-desc", + description="keep me", + ) + client = TestClient(app) + r = client.patch( + f"/entities/{e.id}", + json={"confidence": 0.7}, + ) + assert r.status_code == 200 + assert r.json()["description"] == "keep me" + + +def test_api_patch_404_on_missing(env): + client = TestClient(app) + r = client.patch("/entities/does-not-exist", json={"description": "x"}) + assert r.status_code == 404 + + +def test_api_patch_rejects_bad_confidence(env): + e = create_entity(entity_type="component", name="bad-conf") + client = TestClient(app) + r = client.patch(f"/entities/{e.id}", json={"confidence": 2.0}) + assert r.status_code == 400 + + +def test_api_patch_aliased_under_v1(env): + e = create_entity(entity_type="component", name="v1-patch") + client = TestClient(app) + r = client.patch( + f"/v1/entities/{e.id}", + json={"description": "via v1"}, + ) + assert r.status_code == 200 + assert get_entity(e.id).description == "via v1" + + +def test_api_patch_audit_row_written(env): + from atocore.engineering.service import get_entity_audit + + e = create_entity(entity_type="component", name="audit-check") + client = TestClient(app) + client.patch( + f"/entities/{e.id}", + json={"description": "new", "note": "manual edit"}, + ) + audit = get_entity_audit(e.id) + actions = [a["action"] for a in audit] + assert "updated" in actions