feat(api): PATCH /entities/{id} + /v1/engineering/* aliases
PATCH lets users edit an active entity's description, properties,
confidence, and source_refs without cloning — closes the duplicate-trap
half-fixed by /invalidate + /supersede. Issue D just adds the
/engineering/* query surface to the /v1 allowlist.
- engineering/service.py: update_entity supports description replace,
properties shallow merge with null-delete semantics, confidence
0..1 bounds check, source_refs dedup-append. Writes audit row
- api/routes.py: PATCH /entities/{id} with EntityPatchRequest
- main.py: engineering/* query endpoints aliased under /v1 (Issue D)
- tests/test_patch_entity.py: 12 tests (merge, null-delete, bounds,
dedup, 404, audit, v1 alias)
- DEV-LEDGER.md: session log + test_count 509 -> 521
Forbidden fields via PATCH (by design): entity_type, project, name,
status. Use supersede+create or the dedicated status endpoints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
160
tests/test_patch_entity.py
Normal file
160
tests/test_patch_entity.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user