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) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 20:17:32 -04:00
parent 5fbd7e6094
commit b1a3dd071e
6 changed files with 374 additions and 12 deletions

View File

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

View File

@@ -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:<uuid>"), 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="<real>"`` and ``scope_only=False`` (default): return entities
scoped to that project PLUS cross-project (``project=""``) entities.
- ``project="<real>"`` 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}%")

View File

@@ -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('<h2>📥 Inbox &amp; Global</h2>')
lines.append(
'<p class="emerging-intro">Entities that don\'t belong to a specific '
'project yet. <strong>Inbox</strong> holds pre-project leads and quotes. '
'<strong>Global</strong> holds cross-project facts (material properties, '
'vendor capabilities) that apply everywhere.</p>'
)
lines.append('<div class="card-grid">')
lines.append(
f'<a href="/entities?project=inbox&scope_only=true" class="card">'
f'<h3>📥 Inbox</h3>'
f'<p>Pre-project leads, quotes, early conversations.</p>'
f'<div class="stats">{inbox_count} entities</div>'
f'</a>'
)
lines.append(
f'<a href="/entities?project=&scope_only=true" class="card">'
f'<h3>🌐 Global</h3>'
f'<p>Cross-project facts: materials, vendors, shared knowledge.</p>'
f'<div class="stats">{global_count} entities</div>'
f'</a>'
)
lines.append('</div>')
for bucket_name, items in buckets.items():
if not items:
continue

View File

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