From b1a3dd071e99b0ec2dddb6ae41d0c6fb2d765f7a Mon Sep 17 00:00:00 2001 From: Anto01 Date: Tue, 21 Apr 2026 20:17:32 -0400 Subject: [PATCH] feat(entities): inbox + cross-project (project="") support (Issue C) Makes `inbox` a reserved pseudo-project and `project=""` a first-class cross-project bucket. Unblocks AKC capturing pre-project leads/quotes and cross-project facts (materials, vendors) that don't fit a single registered project. - projects/registry.py: INBOX_PROJECT/GLOBAL_PROJECT constants, is_reserved_project(), register/update guards, resolve_project_name passthrough for "inbox" - engineering/service.py: get_entities scoping rules (inbox-only, global-only, real+global default, scope_only=true strict). promote_entity accepts target_project to retarget on promote - api/routes.py: GET /entities gains scope_only; POST /entities accepts project=null as ""; POST /entities/{id}/promote accepts {target_project, note} - engineering/wiki.py: homepage shows "Inbox & Global" cards with live counts linking to scoped lists - tests/test_inbox_crossproject.py: 15 tests (reserved enforcement, scoping rules, API shape, promote retargeting) - DEV-LEDGER.md: session log, test_count 463 -> 478 Co-Authored-By: Claude Opus 4.7 (1M context) --- DEV-LEDGER.md | 4 +- src/atocore/api/routes.py | 48 ++++++- src/atocore/engineering/service.py | 61 ++++++++- src/atocore/engineering/wiki.py | 43 +++++- src/atocore/projects/registry.py | 29 +++++ tests/test_inbox_crossproject.py | 201 +++++++++++++++++++++++++++++ 6 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 tests/test_inbox_crossproject.py diff --git a/DEV-LEDGER.md b/DEV-LEDGER.md index 09261f5..8130527 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**: 463 (prior 459 + 5 new /v1 alias tests; `main` not yet advanced past the alias commit at session end) +- **test_count**: 478 (prior 463 + 15 new Issue C 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-21 Claude (pm)** Issue C (inbox + cross-project entities) landed. `inbox` is a reserved pseudo-project: auto-exists, cannot be registered/updated/aliased (enforced in `src/atocore/projects/registry.py` via `is_reserved_project` + `register_project`/`update_project` guards). `project=""` remains the cross-project/global bucket for facts that apply to every project. `resolve_project_name("inbox")` is stable and does not hit the registry. `get_entities` now scopes: `project=""` → only globals; `project="inbox"` → only inbox; `project=""` default → that project plus globals; `scope_only=true` → strict. `POST /entities` accepts `project=null` as equivalent to `""`. `POST /entities/{id}/promote` accepts `{target_project}` to retarget an inbox/global lead into a real project on promote (new "retargeted" audit action). Wiki homepage shows a new "📥 Inbox & Global" section with live counts, linking to scoped `/entities` lists. 15 new tests in `test_inbox_crossproject.py` cover reserved-name enforcement, scoping rules, API shape, and promote retargeting. Tests 463 → 478. Pending: commit, push, deploy. Issue B (wiki redlinks) deferred per AKC thread — P1 cosmetic, not a blocker. + - **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. diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index f1e1db3..a2b536e 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -1374,7 +1374,11 @@ def api_dashboard() -> dict: class EntityCreateRequest(BaseModel): entity_type: str name: str - project: str = "" + # project accepts: "" (global, cross-project), "inbox" (pre-project + # lead / quote bucket), or any registered project id/alias. Unknown + # project names are stored verbatim (trust-preserving, same as + # pre-registry contract). + project: str | None = "" description: str = "" properties: dict | None = None status: str = "active" @@ -1382,6 +1386,11 @@ class EntityCreateRequest(BaseModel): source_refs: list[str] | None = None +class EntityPromoteRequest(BaseModel): + target_project: str | None = None + note: str = "" + + class RelationshipCreateRequest(BaseModel): source_entity_id: str target_entity_id: str @@ -1397,7 +1406,7 @@ def api_create_entity(req: EntityCreateRequest) -> dict: entity = create_entity( entity_type=req.entity_type, name=req.name, - project=req.project, + project=req.project or "", description=req.description, properties=req.properties, status=req.status, @@ -1417,14 +1426,21 @@ def api_list_entities( status: str = "active", name_contains: str | None = None, limit: int = 100, + scope_only: bool = False, ) -> dict: - """List engineering entities with optional filters.""" + """List engineering entities with optional filters. + + When ``project`` names a real project, cross-project entities + (``project=""``) are included by default. Pass ``scope_only=true`` to + restrict the result to that project's own entities only. + """ entities = get_entities( entity_type=entity_type, project=project, status=status, name_contains=name_contains, limit=limit, + scope_only=scope_only, ) return { "entities": [ @@ -2078,13 +2094,31 @@ def api_evidence_chain(entity: str) -> dict: @router.post("/entities/{entity_id}/promote") -def api_promote_entity(entity_id: str) -> dict: - """Promote a candidate entity to active (Phase 5 Engineering V1).""" +def api_promote_entity( + entity_id: str, + req: EntityPromoteRequest | None = None, +) -> dict: + """Promote a candidate entity to active. + + Optional ``target_project`` in the body retargets the entity's + project on promote — used to graduate inbox/global leads into a real + project when they mature (Issue C). + """ from atocore.engineering.service import promote_entity - success = promote_entity(entity_id, actor="api-http") + target_project = req.target_project if req is not None else None + note = req.note if req is not None else "" + success = promote_entity( + entity_id, + actor="api-http", + note=note, + target_project=target_project, + ) if not success: raise HTTPException(status_code=404, detail=f"Entity not found or not a candidate: {entity_id}") - return {"status": "promoted", "id": entity_id} + result = {"status": "promoted", "id": entity_id} + if target_project is not None: + result["target_project"] = target_project + return result @router.post("/entities/{entity_id}/reject") diff --git a/src/atocore/engineering/service.py b/src/atocore/engineering/service.py index 4b02996..9b8a3be 100644 --- a/src/atocore/engineering/service.py +++ b/src/atocore/engineering/service.py @@ -333,9 +333,20 @@ def _set_entity_status( return True -def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool: +def promote_entity( + entity_id: str, + actor: str = "api", + note: str = "", + target_project: str | None = None, +) -> bool: """Promote a candidate entity to active. + When ``target_project`` is provided (Issue C), also retarget the + entity's project before flipping the status. Use this to graduate an + inbox/global lead into a real project (e.g. when a vendor quote + becomes a contract). ``target_project`` is canonicalized through the + registry; reserved ids (``inbox``) and ``""`` are accepted verbatim. + Phase 5F graduation hook: if this entity has source_refs pointing at memories (format "memory:"), mark those source memories as ``status=graduated`` and set their ``graduated_to_entity_id`` forward @@ -346,6 +357,27 @@ def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool: if entity is None or entity.status != "candidate": return False + if target_project is not None: + new_project = ( + resolve_project_name(target_project) if target_project else "" + ) + if new_project != entity.project: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + with get_connection() as conn: + conn.execute( + "UPDATE entities SET project = ?, updated_at = ? " + "WHERE id = ?", + (new_project, now, entity_id), + ) + _audit_entity( + entity_id=entity_id, + action="retargeted", + actor=actor, + before={"project": entity.project}, + after={"project": new_project}, + note=note, + ) + ok = _set_entity_status(entity_id, "active", actor=actor, note=note) if not ok: return False @@ -470,7 +502,24 @@ def get_entities( status: str = "active", name_contains: str | None = None, limit: int = 100, + scope_only: bool = False, ) -> list[Entity]: + """List entities with optional filters. + + Project scoping rules (Issue C — inbox + cross-project): + + - ``project=None``: no project filter, return everything matching status. + - ``project=""``: return only cross-project (global) entities. + - ``project="inbox"``: return only inbox entities. + - ``project=""`` and ``scope_only=False`` (default): return entities + scoped to that project PLUS cross-project (``project=""``) entities. + - ``project=""`` and ``scope_only=True``: return only that project, + without the cross-project bleed. + """ + from atocore.projects.registry import ( + INBOX_PROJECT, GLOBAL_PROJECT, is_reserved_project, + ) + query = "SELECT * FROM entities WHERE status = ?" params: list = [status] @@ -478,8 +527,14 @@ def get_entities( query += " AND entity_type = ?" params.append(entity_type) if project is not None: - query += " AND project = ?" - params.append(project) + p = (project or "").strip() + if p == GLOBAL_PROJECT or is_reserved_project(p) or scope_only: + query += " AND project = ?" + params.append(p) + else: + # Real project — include cross-project entities by default. + query += " AND (project = ? OR project = ?)" + params.extend([p, GLOBAL_PROJECT]) if name_contains: query += " AND name LIKE ?" params.append(f"%{name_contains}%") diff --git a/src/atocore/engineering/wiki.py b/src/atocore/engineering/wiki.py index bfd253e..62b4531 100644 --- a/src/atocore/engineering/wiki.py +++ b/src/atocore/engineering/wiki.py @@ -23,7 +23,11 @@ from atocore.engineering.service import ( get_relationships, ) from atocore.memory.service import get_memories -from atocore.projects.registry import load_project_registry +from atocore.projects.registry import ( + GLOBAL_PROJECT, + INBOX_PROJECT, + load_project_registry, +) _TOP_NAV_LINKS = [ @@ -147,6 +151,43 @@ def render_homepage() -> str: except Exception: pass + # Issue C: Inbox + Global pseudo-projects alongside registered projects. + # scope_only=True keeps real-project entities out of these counts. + try: + inbox_count = len(get_entities( + project=INBOX_PROJECT, scope_only=True, limit=500, + )) + global_count = len(get_entities( + project=GLOBAL_PROJECT, scope_only=True, limit=500, + )) + except Exception: + inbox_count = 0 + global_count = 0 + + lines.append('

📥 Inbox & Global

') + lines.append( + '

Entities that don\'t belong to a specific ' + 'project yet. Inbox holds pre-project leads and quotes. ' + 'Global holds cross-project facts (material properties, ' + 'vendor capabilities) that apply everywhere.

' + ) + lines.append('') + for bucket_name, items in buckets.items(): if not items: continue diff --git a/src/atocore/projects/registry.py b/src/atocore/projects/registry.py index a027eb5..6786b4e 100644 --- a/src/atocore/projects/registry.py +++ b/src/atocore/projects/registry.py @@ -11,6 +11,20 @@ import atocore.config as _config from atocore.ingestion.pipeline import ingest_folder +# Reserved pseudo-projects. `inbox` holds pre-project / lead / quote +# entities that don't yet belong to a real project. `""` (empty) is the +# cross-project bucket for facts that apply to every project (material +# properties, vendor capabilities). Neither may be registered, renamed, +# or deleted via the normal registry CRUD. +INBOX_PROJECT = "inbox" +GLOBAL_PROJECT = "" +_RESERVED_PROJECT_IDS = {INBOX_PROJECT} + + +def is_reserved_project(name: str) -> bool: + return (name or "").strip().lower() in _RESERVED_PROJECT_IDS + + @dataclass(frozen=True) class ProjectSourceRef: source: str @@ -56,8 +70,17 @@ def build_project_registration_proposal( normalized_id = project_id.strip() if not normalized_id: raise ValueError("Project id must be non-empty") + if is_reserved_project(normalized_id): + raise ValueError( + f"Project id {normalized_id!r} is reserved and cannot be registered" + ) normalized_aliases = _normalize_aliases(aliases or []) + for alias in normalized_aliases: + if is_reserved_project(alias): + raise ValueError( + f"Alias {alias!r} is reserved and cannot be used" + ) normalized_roots = _normalize_ingest_roots(ingest_roots or []) if not normalized_roots: raise ValueError("At least one ingest root is required") @@ -129,6 +152,10 @@ def update_project( ingest_roots: list[dict] | tuple[dict, ...] | None = None, ) -> dict: """Update an existing project registration in the registry file.""" + if is_reserved_project(project_name): + raise ValueError( + f"Project {project_name!r} is reserved and cannot be modified" + ) existing = get_registered_project(project_name) if existing is None: raise ValueError(f"Unknown project: {project_name}") @@ -272,6 +299,8 @@ def resolve_project_name(name: str | None) -> str: """ if not name: return name or "" + if is_reserved_project(name): + return name.strip().lower() project = get_registered_project(name) if project is not None: return project.project_id diff --git a/tests/test_inbox_crossproject.py b/tests/test_inbox_crossproject.py new file mode 100644 index 0000000..22794ac --- /dev/null +++ b/tests/test_inbox_crossproject.py @@ -0,0 +1,201 @@ +"""Issue C — inbox pseudo-project + cross-project (project="") entities.""" + +import pytest +from fastapi.testclient import TestClient + +from atocore.engineering.service import ( + create_entity, + get_entities, + init_engineering_schema, + promote_entity, +) +from atocore.main import app +from atocore.projects.registry import ( + GLOBAL_PROJECT, + INBOX_PROJECT, + is_reserved_project, + register_project, + resolve_project_name, + update_project, +) + + +@pytest.fixture +def seeded_db(tmp_data_dir, tmp_path, monkeypatch): + # Isolate the project registry so "p05" etc. don't canonicalize + # to aliases inherited from the host registry. + 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_engineering_schema() + # Audit table lives in the memory schema — bring it up so audit rows + # don't spam warnings during retargeting tests. + from atocore.models.database import init_db + init_db() + yield tmp_data_dir + + +def test_inbox_is_reserved(): + assert is_reserved_project("inbox") is True + assert is_reserved_project("INBOX") is True + assert is_reserved_project("p05-interferometer") is False + assert is_reserved_project("") is False + + +def test_resolve_project_name_preserves_inbox(): + assert resolve_project_name("inbox") == "inbox" + assert resolve_project_name("INBOX") == "inbox" + assert resolve_project_name("") == "" + + +def test_cannot_register_inbox(tmp_path, monkeypatch): + monkeypatch.setenv( + "ATOCORE_PROJECT_REGISTRY_PATH", + str(tmp_path / "registry.json"), + ) + from atocore import config + config.settings = config.Settings() + + with pytest.raises(ValueError, match="reserved"): + register_project( + project_id="inbox", + ingest_roots=[{"source": "vault", "subpath": "incoming/inbox"}], + ) + + +def test_cannot_update_inbox(tmp_path, monkeypatch): + monkeypatch.setenv( + "ATOCORE_PROJECT_REGISTRY_PATH", + str(tmp_path / "registry.json"), + ) + from atocore import config + config.settings = config.Settings() + + with pytest.raises(ValueError, match="reserved"): + update_project(project_name="inbox", description="hijack attempt") + + +def test_create_entity_with_empty_project_is_global(seeded_db): + e = create_entity(entity_type="material", name="Invar", project="") + assert e.project == "" + + +def test_create_entity_in_inbox(seeded_db): + e = create_entity(entity_type="vendor", name="Zygo", project="inbox") + assert e.project == "inbox" + + +def test_get_entities_inbox_scope(seeded_db): + create_entity(entity_type="vendor", name="Zygo", project="inbox") + create_entity(entity_type="material", name="Invar", project="") + create_entity(entity_type="component", name="Mirror", project="p05") + + inbox = get_entities(project=INBOX_PROJECT, scope_only=True) + assert {e.name for e in inbox} == {"Zygo"} + + +def test_get_entities_global_scope(seeded_db): + create_entity(entity_type="vendor", name="Zygo", project="inbox") + create_entity(entity_type="material", name="Invar", project="") + create_entity(entity_type="component", name="Mirror", project="p05") + + globals_ = get_entities(project=GLOBAL_PROJECT, scope_only=True) + assert {e.name for e in globals_} == {"Invar"} + + +def test_real_project_includes_global_by_default(seeded_db): + create_entity(entity_type="material", name="Invar", project="") + create_entity(entity_type="component", name="Mirror", project="p05") + create_entity(entity_type="component", name="Other", project="p06") + + p05 = get_entities(project="p05") + names = {e.name for e in p05} + assert "Mirror" in names + assert "Invar" in names, "cross-project material should bleed in by default" + assert "Other" not in names + + +def test_real_project_scope_only_excludes_global(seeded_db): + create_entity(entity_type="material", name="Invar", project="") + create_entity(entity_type="component", name="Mirror", project="p05") + + p05 = get_entities(project="p05", scope_only=True) + assert {e.name for e in p05} == {"Mirror"} + + +def test_api_post_entity_with_null_project_stores_global(seeded_db): + client = TestClient(app) + r = client.post("/entities", json={ + "entity_type": "material", + "name": "Titanium", + "project": None, + }) + assert r.status_code == 200 + + globals_ = get_entities(project=GLOBAL_PROJECT, scope_only=True) + assert any(e.name == "Titanium" for e in globals_) + + +def test_api_get_entities_scope_only(seeded_db): + create_entity(entity_type="material", name="Invar", project="") + create_entity(entity_type="component", name="Mirror", project="p05") + + client = TestClient(app) + mixed = client.get("/entities?project=p05").json() + scoped = client.get("/entities?project=p05&scope_only=true").json() + + assert mixed["count"] == 2 + assert scoped["count"] == 1 + + +def test_promote_with_target_project_retargets(seeded_db): + e = create_entity( + entity_type="vendor", + name="ZygoLead", + project="inbox", + status="candidate", + ) + ok = promote_entity(e.id, target_project="p05") + assert ok is True + + from atocore.engineering.service import get_entity + promoted = get_entity(e.id) + assert promoted.status == "active" + assert promoted.project == "p05" + + +def test_promote_without_target_project_keeps_project(seeded_db): + e = create_entity( + entity_type="vendor", + name="ZygoStay", + project="inbox", + status="candidate", + ) + ok = promote_entity(e.id) + assert ok is True + + from atocore.engineering.service import get_entity + promoted = get_entity(e.id) + assert promoted.status == "active" + assert promoted.project == "inbox" + + +def test_api_promote_with_target_project(seeded_db): + e = create_entity( + entity_type="vendor", + name="ZygoApi", + project="inbox", + status="candidate", + ) + client = TestClient(app) + r = client.post( + f"/entities/{e.id}/promote", + json={"target_project": "p05"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "promoted" + assert body["target_project"] == "p05"