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:
2026-04-22 09:02:13 -04:00
parent 081c058f77
commit b94f9dff56
5 changed files with 326 additions and 1 deletions

View File

@@ -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,

View File

@@ -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:

View File

@@ -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"])