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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user