From 5fbd7e6094fcc4fcd1f564820bc1b4606275b4b9 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Tue, 21 Apr 2026 20:04:46 -0400 Subject: [PATCH] feat(api): /v1 alias router for stable external contract (Issue A) Mounts an explicit allowlist of public handlers under /v1 alongside the existing unversioned paths. External clients (AKC, OpenClaw, future tools) should target /v1; internal callers (hooks, wiki, admin UI) keep working unchanged. Breaking schema changes will bump the prefix to /v2. - src/atocore/main.py: _V1_PUBLIC_PATHS allowlist + second router - tests/test_v1_aliases.py: parity + OpenAPI presence (5 tests) - README.md: API versioning section - DEV-LEDGER.md: session log, test_count 459 -> 463 Co-Authored-By: Claude Opus 4.7 (1M context) --- DEV-LEDGER.md | 4 ++- README.md | 20 +++++++++++++ src/atocore/main.py | 54 ++++++++++++++++++++++++++++++++++- tests/test_v1_aliases.py | 61 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 tests/test_v1_aliases.py diff --git a/DEV-LEDGER.md b/DEV-LEDGER.md index 4582a44..09261f5 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**: 395 (21 new Phase 7A dedup tests + accumulated Phase 5/6 tests since last ledger refresh) +- **test_count**: 463 (prior 459 + 5 new /v1 alias tests; `main` not yet advanced past the alias commit at session end) - **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-21 Claude** Issue A (API versioning) landed on `main` working tree (not yet committed/deployed). `src/atocore/main.py` now mounts a second `/v1` router that re-registers an explicit allowlist of public handlers (`_V1_PUBLIC_PATHS`) against the same endpoint functions — entities, relationships, ingest, context/build, query, projects, memory, interactions, project/state, health, sources, stats, and their sub-paths. Unversioned paths are untouched; OpenClaw and hooks keep working. Added `tests/test_v1_aliases.py` (5 tests: health parity, projects parity, entities reachable, v1 paths present in OpenAPI, unversioned paths still present in OpenAPI) and a "API versioning" section in the README documenting the rule (new endpoints at latest prefix, breaking changes bump prefix, unversioned retained for internal callers). Tests 459 → 463. Next: commit + deploy, then relay to the AKC thread so Phase 2 can code against `/v1`. Issues B (wiki redlinks) and C (inbox/cross-project) remain open, unstarted. + - **2026-04-19 Claude** Shipped Phases 7A.1 (tiered auto-merge), 7C (tag canonicalization), 7D (confidence decay), 7I (OpenClaw context injection), UI refresh (memory/domain/activity pages + topnav), and closed the Claude Code retrieval asymmetry. Builds deployed: `028d4c3` → `56d5df0` → `e840ef4` → `877b97e` → `6e43cc7` → `9c91d77`. New capture-surface scope: Claude Code (Stop + UserPromptSubmit hooks, both installed and verified live) + OpenClaw (v0.2.0 plugin with capture + context injection, verified loaded on T420 gateway). `/wiki/capture` paste form removed from topnav; kept as labeled fallback. Anthropic API polling explicitly out of scope per user. Tests 414 → 459. `docs/capture-surfaces.md` documents the sanctioned scope. - **2026-04-18 Claude** **Phase 7A — Memory Consolidation V1 ("sleep cycle") landed on branch.** New `docs/PHASE-7-MEMORY-CONSOLIDATION.md` covers all 8 subphases (7A dedup, 7B contradictions, 7C tag canon, 7D confidence decay, 7E memory detail, 7F domain view, 7F re-extract, 7H vector hygiene). 7A implementation: schema migration `memory_merge_candidates`, `atocore.memory.similarity` (cosine + transitive cluster), stdlib-only `atocore.memory._dedup_prompt` (llm drafts unified content preserving all specifics), `merge_memories()` + `create_merge_candidate()` + `get_merge_candidates()` + `reject_merge_candidate()` in service.py, host-side `scripts/memory_dedup.py` (HTTP + claude -p, idempotent via sorted-id set), 5 new endpoints under `/admin/memory/merge-candidates*` + `/admin/memory/dedup-scan` + `/admin/memory/dedup-status`, purple-themed "🔗 Merge Candidates" section in /admin/triage with editable draft + approve/reject buttons, "🔗 Scan for duplicates" control bar with threshold slider, nightly Step B3 in batch-extract.sh (0.90 daily, 0.85 Sundays deep), `deploy/dalidou/dedup-watcher.sh` host watcher for UI-triggered scans (mirrors graduation-watcher pattern). 21 new tests (similarity, prompt parse, idempotency, merge happy path, override content/tags, audit rows, abort-if-source-tampered, reject leaves sources alone, schema). Tests 374 → 395. Not yet deployed; harness not re-run. Next: push + deploy, install `dedup-watcher.sh` in host cron, trigger first scan, review proposals in UI. diff --git a/README.md b/README.md index 171c7b2..d46df7a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,26 @@ python scripts/atocore_client.py audit-query "gigabit" 5 | GET | /health | Health check | | GET | /debug/context | Inspect last context pack | +## API versioning + +The public contract for external clients (AKC, OpenClaw, future tools) is +served under a `/v1` prefix. Every public endpoint is available at both its +unversioned path and under `/v1` — e.g. `POST /entities` and `POST /v1/entities` +route to the same handler. + +Rules: + +- New public endpoints land at the latest version prefix. +- Backwards-compatible additions stay on the current version. +- Breaking schema changes to an existing endpoint bump the prefix (`/v2/...`) + and leave the prior version in place until clients migrate. +- Unversioned paths are retained for internal callers (hooks, scripts, the + wiki/admin UI). Do not rely on them from external clients — use `/v1`. + +The authoritative list of versioned paths is in `src/atocore/main.py` +(`_V1_PUBLIC_PATHS`). `GET /openapi.json` reflects both the versioned and +unversioned forms. + ## Architecture ```text diff --git a/src/atocore/main.py b/src/atocore/main.py index b9e5059..509bd13 100644 --- a/src/atocore/main.py +++ b/src/atocore/main.py @@ -2,7 +2,8 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI +from fastapi.routing import APIRoute from atocore import __version__ from atocore.api.routes import router @@ -53,6 +54,57 @@ app = FastAPI( app.include_router(router) +# Public API v1 — stable contract for external clients (AKC, OpenClaw, etc.). +# Paths listed here are re-mounted under /v1 as aliases of the existing +# unversioned handlers. Unversioned paths continue to work; new endpoints +# land at the latest version; breaking schema changes bump the prefix. +_V1_PUBLIC_PATHS = { + "/entities", + "/entities/{entity_id}", + "/entities/{entity_id}/promote", + "/entities/{entity_id}/reject", + "/entities/{entity_id}/audit", + "/relationships", + "/ingest", + "/ingest/sources", + "/context/build", + "/query", + "/projects", + "/projects/{project_name}", + "/projects/{project_name}/refresh", + "/projects/{project_name}/mirror", + "/projects/{project_name}/mirror.html", + "/memory", + "/memory/{memory_id}", + "/memory/{memory_id}/audit", + "/memory/{memory_id}/promote", + "/memory/{memory_id}/reject", + "/project/state", + "/project/state/{project_name}", + "/interactions", + "/interactions/{interaction_id}", + "/interactions/{interaction_id}/reinforce", + "/interactions/{interaction_id}/extract", + "/health", + "/sources", + "/stats", +} + +_v1_router = APIRouter(prefix="/v1", tags=["v1"]) +for _route in list(router.routes): + if isinstance(_route, APIRoute) and _route.path in _V1_PUBLIC_PATHS: + _v1_router.add_api_route( + _route.path, + _route.endpoint, + methods=list(_route.methods), + response_model=_route.response_model, + response_class=_route.response_class, + name=f"v1_{_route.name}", + include_in_schema=True, + ) +app.include_router(_v1_router) + + if __name__ == "__main__": import uvicorn diff --git a/tests/test_v1_aliases.py b/tests/test_v1_aliases.py new file mode 100644 index 0000000..d5ee140 --- /dev/null +++ b/tests/test_v1_aliases.py @@ -0,0 +1,61 @@ +"""Tests for /v1 API aliases — stable contract for external clients.""" + +from fastapi.testclient import TestClient + +from atocore.main import app + + +def test_v1_health_alias_matches_unversioned(): + client = TestClient(app) + unversioned = client.get("/health") + versioned = client.get("/v1/health") + assert unversioned.status_code == 200 + assert versioned.status_code == 200 + assert versioned.json()["build_sha"] == unversioned.json()["build_sha"] + + +def test_v1_projects_alias_returns_same_shape(): + client = TestClient(app) + unversioned = client.get("/projects") + versioned = client.get("/v1/projects") + assert unversioned.status_code == 200 + assert versioned.status_code == 200 + assert versioned.json() == unversioned.json() + + +def test_v1_entities_get_alias_reachable(tmp_data_dir): + from atocore.engineering.service import init_engineering_schema + + init_engineering_schema() + client = TestClient(app) + response = client.get("/v1/entities") + assert response.status_code == 200 + body = response.json() + assert "entities" in body + + +def test_v1_paths_appear_in_openapi(): + client = TestClient(app) + spec = client.get("/openapi.json").json() + paths = spec["paths"] + expected = [ + "/v1/entities", + "/v1/relationships", + "/v1/ingest", + "/v1/context/build", + "/v1/projects", + "/v1/memory", + "/v1/projects/{project_name}/mirror", + "/v1/health", + ] + for path in expected: + assert path in paths, f"{path} missing from OpenAPI spec" + + +def test_unversioned_paths_still_present_in_openapi(): + """Regression: /v1 aliases must not displace the original paths.""" + client = TestClient(app) + spec = client.get("/openapi.json").json() + paths = spec["paths"] + for path in ("/entities", "/projects", "/health", "/context/build"): + assert path in paths, f"unversioned {path} missing — aliases must coexist"