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

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