2 Commits

Author SHA1 Message Date
4e6fba7cb9 docs(ledger): Wave 1 session log + Orientation refresh
Records the 2026-04-29 audit findings, the Codex review, and the three
memory-write-path fixes shipped on this branch. Refreshes Orientation
with live state verified against Dalidou: build 7042eae, harness 20/20,
1054 interactions, 315/1091 dashboard/integrity memory counts (the
sampling bug behind the discrepancy is fixed in the prior commit; the
two will agree post-deploy).

Also captures the unregistered emerging-projects list (apm=63, openclaw=9,
lead-space=2, drill=1, optiques-fullum=1) so the Wave 1 follow-up
(one-click registration proposal) doesn't get lost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:41:18 -04:00
fb4d55cbcd fix(memory): SQL-aggregate dashboard counts, project on update, id-based invalidate
Three bugs surfaced by the 2026-04-29 Codex review of the state-of-the-service
plan, all in the memory write/read path:

1. /admin/dashboard memory counts were derived from a confidence-sorted
   get_memories(limit=500) sample. With prod at 1091 active memories the
   dashboard reported 315 ("active in the top 500"), while integrity
   reported the SQL aggregate 1091. Replaced the sampling block with a
   new get_memory_count_summary() helper that does straight SQL aggregates
   over status/type/project. Dashboard memories.{active,candidates,...}
   now match integrity. Adds memories.{by_status,total} for completeness.

2. PUT /memory/{id} silently dropped project changes because
   MemoryUpdateRequest had no project field and update_memory() didn't
   accept one. auto_triage.py:407 detects suggested_project drift and
   issues a PUT to fix it; the fix never landed. Added project to the
   request schema and the service signature, with resolve_project_name
   canonicalization, before/after audit snapshot, and the existing
   duplicate-active check now scoped to the new project.

3. POST /memory/{id}/invalidate did _get_memories(status="active",
   limit=1) and looked for the target inside that single
   highest-confidence row. Any other active memory 404'd. Replaced with
   a direct id lookup via the new get_memory(id) helper; status branching
   stays the same (404 unknown / 200 already-invalid / 409 wrong-status /
   200 invalidated).

Tests added (9):
- test_get_memory_count_summary_returns_full_table_aggregates
- test_get_memory_returns_single_row_or_none
- test_update_memory_can_change_project_with_canonicalization
- test_update_memory_project_unchanged_when_not_passed
- test_api_invalidate_finds_active_memory_outside_top_one
- test_api_invalidate_already_invalid_is_idempotent
- test_api_invalidate_candidate_returns_409
- test_api_invalidate_unknown_id_is_404
- test_admin_dashboard_active_count_matches_full_table

Test count: 572 -> 581. Full suite green locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:40:10 -04:00
11 changed files with 21 additions and 859 deletions

View File

@@ -6,26 +6,26 @@
## Orientation
- **live_sha** (Dalidou `/health` build_sha): `23cdb31` (verified 2026-04-29T17:04:05Z post V1-A deploy; status=ok)
- **last_updated**: 2026-04-29 by Claude (Wave 1 + Wave 1.5 + V1-A all squash-merged and deployed today)
- **main_tip**: `23cdb31`
- **test_count**: 605 on `main` (572 + 14 Wave 1 + 10 Wave 1.5 + 9 V1-A)
- **harness**: `20/20 PASS` on live Dalidou against `23cdb31`, 0 blocking failures, 0 known issues
- **live_sha** (Dalidou `/health` build_sha): `7042eae` (verified 2026-04-29T01:19Z; status=ok; deployed 2026-04-25T01:04Z, docs-only on top of `d3de9f6`)
- **last_updated**: 2026-04-29 by Claude (Wave 1 debt-pay branch open; Codex review of plan applied)
- **main_tip**: `7042eae`
- **test_count**: 581 on `claude/wave1-dashboard-counts-and-memory-fixes` (572 on main + 9 Wave 1 regressions)
- **harness**: `20/20 PASS` on live Dalidou, 0 blocking failures, 0 known issues (last harness run nightly 2026-04-28T03:00:30Z)
- **vectors**: 33,253
- **active_memories**: 1170 dashboard (live SQL post-fix) vs 1091 integrity-panel snapshot (last nightly 2026-04-28T03:00:30Z; difference is ~80 captures since the snapshot — no longer a sampling artifact). Total memories: 2436. Pre-deploy the dashboard reported 315 due to a confidence-sorted limit=500 sampling bug, now closed.
- **candidate_memories**: 44 (live as of 2026-04-29T02:00Z; queue accumulated since the 2026-04-28 nightly autotriage)
- **active_memories**: 315 dashboard / 1091 integrity (verified live 2026-04-29). Discrepancy was a dashboard sampling bug — Wave 1 commit `fb4d55c` replaces it with SQL aggregates; post-deploy the two will agree.
- **candidate_memories**: 0 (queue drained, +103 captures since 2026-04-25)
- **interactions**: 1054 (claude-code 474, openclaw 576; verified `/admin/dashboard` 2026-04-29)
- **registered_projects** (8): atocore, p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, abb-space (aliased p08), **apm** (registered 2026-04-29 — Atomaste Parts Manager I03), **openclaw** (registered 2026-04-29 — external LLM agent harness, read-only pull integration; alias `clawd`). `/admin/projects/proposals?min_active=10` is now empty.
- **registered_projects**: atocore, p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, abb-space (aliased p08). **Auto-detected, unregistered**: apm (63 active memories), openclaw (9), lead-space (2), drill (1), optiques-fullum (1) — see Wave 1 follow-up below.
- **project_state_entries**: 128 across registered projects (verified 2026-04-29)
- **entities**: 66 (V1-0 backfill complete; 0 open conflicts)
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron, verified
- **nightly_pipeline**: backup → cleanup → rsync → OpenClaw import → vault refresh → extract → auto-triage → auto-promote/expire → weekly synth/lint Sundays → retrieval harness → pipeline summary
- **capture_clients**: claude-code (Stop hook + cwd project inference), openclaw (before_agent_start + llm_output plugin, verified live)
- **wiki**: http://dalidou:8100/wiki (browse), /wiki/projects/{id}, /wiki/entities/{id}, /wiki/search
- **dashboard**: http://dalidou:8100/admin/dashboard (now reports SQL-aggregate counts; new fields: `memories.total`, `memories.by_status`)
- **active_track**: Wave 1 + Wave 1.5 + V1-A closed → V1-B is the next phase (KB-CAD/KB-FEM ingest + D-2 schema docs per the V1 completion plan). Plan: `docs/plans/engineering-v1-completion-plan.md`. Resume map: `docs/plans/v1-resume-state.md`.
- **dashboard**: http://dalidou:8100/admin/dashboard
- **active_track**: Wave 1 debt-pay (this session) → V1-A start. V1-A gates have cleared (soak ended 2026-04-26; density 315 ≫ 100). Plan: `docs/plans/engineering-v1-completion-plan.md`. Resume map: `docs/plans/v1-resume-state.md`.
- **last_nightly_pipeline**: `2026-04-28T03:00:30Z` — harness 20/20, triage promoted=1 rejected=1 human=0
- **open_branches**: none. Wave 1 (`4c70756`), Wave 1.5 (`b69d2c7`), and V1-A (`23cdb31`) all squash-merged and deployed today; branches cleaned up.
- **open_branches**: `claude/wave1-dashboard-counts-and-memory-fixes` (commit `fb4d55c`) — three memory-write-path bugs surfaced by Codex review; awaiting Codex audit before merge/deploy.
## Active Plan
@@ -170,18 +170,6 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
## Session Log
- **2026-04-29 Codex + Claude (V1-A landed and deployed)** Engineering V1 Completion Plan Phase V1-A done. Branch `claude/v1-a-pillar-query-slice` shipped the Q-001 subsystem-scoped shape fix exactly per `engineering-query-catalog.md:71`: new `subsystem_contents()` helper in `queries.py` walking inbound `part_of` edges, new `GET /entities/Subsystem/{id}?expand=contains` route returning `{subsystem, contains: [{id, type, entity_type, name, status}]}`, dual-emit of `type` (spec) and `entity_type` (rest-of-module convention) for compat. The existing project-wide `/engineering/projects/{name}/systems` route stays for Q-004. Aliased under `/v1`. V1-A acceptance integration test runs all four pillar queries (Q-001 subsystem, Q-005 requirements_for, Q-006 orphan_requirements, Q-017 evidence_chain) plus Q-009/Q-011 against a single p05-interferometer seed graph. Codex audited at `b575773` (GO WITH CONDITIONS, two P2s + two P3s) → all four amends folded in at `0e83525` → re-review GO. Squash-merged to main as `23cdb31`. Deployed via canonical script; live `/health` build_sha `23cdb3149fde2185f0eaf1350c5b065b08f80319`, build_time `2026-04-29T17:04:05Z`, status=ok. **Live post-deploy probes** all green: `/entities/Subsystem/{p05-id}?expand=contains` → 200, spec-shaped response; `/v1/entities/Subsystem/{p05-id}?expand=contains` → 200; `?expand=parents` → 400 with informative detail; component-id against the Subsystem route → 404 with informative detail. Live retrieval harness 20/20 against `23cdb31`. Tests on main: 596 → 605 (+9). master-plan-status.md V1-A status flipped to ✅; V1-B is now "next".
- **2026-04-29 Antoine + Claude (openclaw registered, proposals queue drained)** Talked through the framing tension — openclaw is "a tool" but external (we don't own its product), so the 17 memories are really integration notes (plugin architecture, `cleanedBody` hook field, read-only pull contract, hermes-agent comparison, atocore-capture v0.2.0 plugin). Picked Option A from the four options laid out: register `openclaw` straight (zero migration) with description that names the integration framing explicitly. Aliases: `clawd` (matches local repo path on Dalidou + T420). POSTed to `/admin/projects/register-emerging`. Live `/admin/projects/proposals?min_active=10` now returns count=0 — registered project set is 8, no unregistered labels above threshold. Wave 1.5 closed end-to-end: code + audit + deploy + both registrations.
- **2026-04-29 Antoine + Claude (apm registered)** Antoine confirmed apm is a product alongside atomizer-v2 (I03 in the Atomaste tools I-series). Registered via `POST /admin/projects/register-emerging` mirroring atomizer-v2's pattern: aliases `i03`, `atomaste-parts-manager`, `parts-manager`; description "Atomaste Parts Manager — parts/COTS lifecycle tool (I03 in Atomaste tool series alongside I01 Atomizer-v2 and I02 AtoCore)". Default ingest_root `vault:incoming/projects/apm/` (does not yet exist on disk; may want a `/repo` mirror via PUT /projects/apm if a code repo gets staged, like atomizer-v2's `incoming/projects/atomizer-v2/repo`). Live `/admin/projects/proposals?min_active=10` post-registration: count drops 2→1 (apm gone, openclaw remains). 165 active + 22 candidate apm-tagged memories now resolve to a registered canonical id.
- **2026-04-29 Codex + Claude (Wave 1.5 audited, merged, deployed)** Branch `claude/wave1.5-emerging-project-proposals` shipped `GET /admin/projects/proposals?min_active=N` — live, on-demand companion to the nightly `scripts/detect_emerging.py` cache. Each proposal includes project_id, active+candidate counts, sample_memories (3 most recent), suggested_aliases (sibling labels sharing a >=4-char token, case-insensitive), guessed_ingest_root. New `propose_emerging_projects()` helper in `service.py`. Codex audited tip `e8ac8bb`: verdict GO with one P2 (test was trivially true) and one P3 (case-sensitive tokens). Both folded into `f70fa6b` plus a registered-token-leak guard test. Tip `f70fa6b` squash-merged to main as `b69d2c7`. Deployed via canonical script; live `/health` reports build_sha `b69d2c70881b661e6fd8f25194cbc5c20a3947be`, build_time `2026-04-29T02:16:25Z`, status=ok. Live `/admin/projects/proposals?min_active=10` returns the expected 2 proposals: **apm (165 active / 22 candidate, no aliases)** and **openclaw (17 active / 0 candidate, no aliases)**. Hydrotech and lead-space variants below threshold or fewer captures than the dashboard `by_project` showed pre-deploy (live SQL is current). apm sample memories are clearly real APM project content (parts manager, McMaster cleanup workflows). Tests: 586 → 596 (+10). Branches deleted local + remote. **Open Wave 1.5 follow-on (P2)**: Codex recommended a one-step shortcut so the operator doesn't have to copy `suggested_aliases` from the proposal JSON into the register-emerging POST — e.g., `POST /admin/projects/register-emerging` with `from_proposal: {project_id}` that reads server-side. Deferred — the JSON copy is acceptable for the small number of projects in flight. Next: register apm (operator decision pending).
- **2026-04-29 Claude (Wave 1 deployed, post-deploy probes green)** Squash-merged `claude/wave1-dashboard-counts-and-memory-fixes` (tip `9604c3e`) to main as `4c70756` after Codex's GO verdict on the amended branch. Pushed main, deployed via `ssh papa@dalidou "bash /srv/storage/atocore/app/deploy/dalidou/deploy.sh"`. Live `/health` reports build_sha `4c7075650c8187473bc5992b306fb8fa67542074`, build_time `2026-04-29T01:58:59Z`, status=ok. **Post-deploy acceptance** per Codex's checklist: (1) `/admin/dashboard memories.active = 1170` — was 315 pre-deploy, sampling bug closed; new fields `memories.total = 2436`, `memories.by_status` and full `memories.by_project` breakdown live; integrity panel still reports the 22h-old 1091 snapshot from last nightly which is expected behavior. (2) Live retrieval harness 20/20 PASS against `4c70756`, no regression. (3) Supersede/invalidate route branches probed live: unknown id → 404; candidate target → 409 with status-specific detail (`...is candidate; only active memories can be superseded`, `...is candidate; use /reject for candidates`). (4) Auto-triage suggested-project correction path covered by the script-source invariant test plus end-to-end TestClient coverage of the new `MemoryUpdateRequest.project` plumbing — not exercised against live prod state to avoid casual mutation. **Live by-project dashboard breakdown** revealed 14 unregistered projects accumulating memories: apm (165!), openclaw (17), hydrotech-mining variants (13), lead-space variants (5), and ten others. apm is severely overdue. Wave 1.5 starts next: one-click registration proposal endpoint with sample-memory + alias suggestions. **Test count**: 586 on main. **Open branches**: none — Wave 1 closed.
- **2026-04-29 Codex + Claude (Wave 1 formal audit closed + amends)** Codex's formal audit of `fb4d55c` (Wave 1 first commit on `claude/wave1-dashboard-counts-and-memory-fixes`): verdict GO WITH CONDITIONS. Two P1-prior closures confirmed (dashboard count bug + invalidate top-1 lookup). Project-update P2 was only "partially closed" — API/service plumbing fine, but `scripts/auto_triage.py:417` still PUT `{"content": cand["content"]}` so the operational suggested-project correction was unreachable even with `MemoryUpdateRequest.project` in place. Codex also flagged the symmetric supersede-route gap as same-class adjacent surface and recommended pulling it in here, not in Wave 1.5. Plus one P3: cover retarget-to-empty-project against a global active duplicate. Amended on `3a474f7`: (1) auto_triage PUT body now `{"project": suggested}` with a guard test that lints the script source for the new shape; (2) `/memory/{id}/supersede` mirrors the invalidate guard via `get_memory(id)` — 404 unknown / 200 already_superseded / 409 wrong-status / 200 superseded; (3) regression test for project-empty duplicate detection. Test count 581 → 586. Codex's recommended deployment checklist (post-deploy verifications, including the run-or-simulate auto-triage retarget probe) carried into the merge plan. Awaiting Codex re-review of the amended tip before squash-merge.
- **2026-04-29 Claude (Wave 1 debt-pay started; Codex review of state-of-service plan)** Audited live state on Dalidou: `/health` build_sha `7042eae` (4d old), harness 20/20, 33,253 vectors, 1,748 docs, 1054 interactions (+103/4d), dashboard memories.active=315 vs integrity.active_memory_count=1091. Drafted a state-of-service assessment + Wave 1/2/3/4 plan, then asked Codex (gpt-5.5) for an adversarial review via `codex exec`. Codex verdict: assessment MIXED, plan ENDORSE WITH CHANGES. Codex caught two factual corrections (the 315-vs-1091 gap is a *sampling bug* not a definitional gap; my "R9 drops unregistered tags" framing is wrong — `extractor_llm.py:213-233` preserves them) and two new memory-write-path bugs I missed. Branched `claude/wave1-dashboard-counts-and-memory-fixes` from `7042eae`, fixed all three: (1) `/admin/dashboard` now uses a new `get_memory_count_summary()` SQL aggregate helper instead of counting inside a confidence-sorted `get_memories(limit=500)` sample; (2) `MemoryUpdateRequest` and `update_memory()` accept `project` with `resolve_project_name` canonicalization + before/after audit, so `auto_triage.py:407` suggested-project corrections will now actually apply; (3) `POST /memory/{id}/invalidate` replaces the `_get_memories(status="active", limit=1)` lookup (which only saw the highest-confidence active row) with a direct id lookup via new `get_memory(id)` helper. 9 regression tests added across `test_memory.py` and `test_invalidate_supersede.py`. Full local suite: 581 passed (572 → 581). Commit `fb4d55c`. Branch not pushed/deployed yet — awaiting Codex audit per working model. Refreshed Orientation block (live_sha, last_updated, test_count, memory counts, capture cadence, registered/unregistered project breakdown, open_branches). Wave 1 follow-up still open: (W1.2) one-click registration proposal for unregistered projects with ≥10 active memories (apm=63 is overdue); (W1.3 done by this entry) sync ledger; (W1.4) committed measurable-win probe fixture+JSON output. After this branch lands, V1-A is unblocked: gates have effectively cleared (soak ended 2026-04-26; density 315 ≫ 100 target).
- **2026-04-25 Codex (p04 constraint gap closed; harness fully green)** Root-caused the remaining `p04-constraints` fixture: the `Zerodur` / `1.2` fact already existed in Trusted Project State (`requirement/key_constraints`), but project-state formatting was category/key ordered and then truncated to the 20% state budget, so contacts/decisions consumed the budget before the relevant requirement. Added query-relevance ranking for Trusted Project State entries before formatting/truncation, with regression coverage in `test_project_state_query_relevance_before_truncation`. Removed the fixture's `known_issue` lane so future p04 constraint regressions are blocking. Cleaned up a duplicate live requirement entry created during diagnosis by invalidating `requirement/mirror-blank-core-constraints`; canonical `requirement/key_constraints` remains active. Verified focused suite: 35 passed. Verified full local suite: 572 passed. Deployed `d3de9f67eaa08dfc5b2d86e8221b8c70fef266d3`; live exact p04 probe now surfaces `[REQUIREMENT] key_constraints` with `1.2` and `Zerodur`. Live retrieval harness: 20/20, 0 known issues, 0 blocking failures.

View File

@@ -198,8 +198,8 @@ where surfaces are disjoint, pauses when they collide.
| Phase | Scope | Status |
|---|---|---|
| V1-0 | Write-time invariants: F-1 header fields + F-8 provenance enforcement + F-5 hook on every active-entity write + Q-3 flag-never-block | ✅ done 2026-04-22 (`2712c5d`) |
| V1-A | Minimum query slice: Q-001 subsystem-scoped variant + Q-6 killer-correctness integration test on p05-interferometer | ✅ done 2026-04-29 (`23cdb31`) |
| V1-B | KB-CAD + KB-FEM ingest (`POST /ingest/kb-cad/export`, `POST /ingest/kb-fem/export`) + D-2 schema docs | next |
| V1-A | Minimum query slice: Q-001 subsystem-scoped variant + Q-6 killer-correctness integration test on p05-interferometer | 🟡 gated — starts when soak (~2026-04-26) + density (100+ active memories) gates clear |
| V1-B | KB-CAD + KB-FEM ingest (`POST /ingest/kb-cad/export`, `POST /ingest/kb-fem/export`) + D-2 schema docs | pending V1-A |
| V1-C | Close the remaining 8 queries (Q-002/003/007/010/012/014/018/019; Q-020 to V1-D) | pending V1-B |
| V1-D | Full mirror surface (3 spec routes + regenerate + determinism + disputed + curated markers) + Q-5 golden file | pending V1-C |
| V1-E | Memory→entity graduation end-to-end + remaining Q-4 trust tests | pending V1-D (note: collides with memory extractor; pauses for multi-model triage work) |

View File

@@ -404,23 +404,19 @@ def process_candidate(cand, base_url, active_cache, state_cache, known_projects,
known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S,
)
# Project misattribution fix: suggested_project surfaces from tier 1.
# Earlier code POSTed only {"content": cand["content"]}, which left
# the project field unchanged because MemoryUpdateRequest had no
# project key and the service signature didn't accept one. Wave 1
# added project to MemoryUpdateRequest and update_memory(); this
# caller now actually applies the suggested project.
# Project misattribution fix: suggested_project surfaces from tier 1
suggested = (v1.get("suggested_project") or "").strip()
if suggested and suggested != project and suggested in known_projects:
# Try to re-canonicalize the memory's project
if not dry_run:
try:
import urllib.request as _ur
req = _ur.Request(
f"{base_url}/memory/{mid}", method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps({"project": suggested}).encode("utf-8"),
data=json.dumps({"content": cand["content"]}).encode("utf-8"),
)
_ur.urlopen(req, timeout=10).read()
_ur.urlopen(req, timeout=10).read() # triggers canonicalization via update
except Exception:
pass
print(f" ↺ misattribution flagged: {project!r}{suggested!r}")

View File

@@ -474,33 +474,6 @@ def api_register_emerging_project(req: RegisterEmergingRequest) -> dict:
return result
@router.get("/admin/projects/proposals")
def api_project_proposals(min_active: int = 10) -> dict:
"""Live registration proposals for unregistered projects.
Reads SQL + the registry directly, so the result is current — unlike
`/admin/dashboard.proposals.unregistered_projects` which is the
nightly cache from `scripts/detect_emerging.py`. Each proposal
includes a guessed ingest root, sibling labels suggested as aliases,
and a few sample memories so the operator can sanity-check before
POSTing to /admin/projects/register-emerging.
Query params:
min_active: minimum active-memory count for a label to surface
(default 10).
"""
from atocore.memory.service import propose_emerging_projects
if min_active < 1:
raise HTTPException(status_code=400, detail="min_active must be >= 1")
proposals = propose_emerging_projects(min_active=min_active)
return {
"proposals": proposals,
"count": len(proposals),
"min_active": min_active,
}
@router.put("/projects/{project_name}")
def api_project_update(project_name: str, req: ProjectUpdateRequest) -> dict:
"""Update an existing project registration."""
@@ -854,33 +827,15 @@ def api_supersede_memory(
memory_id: str,
req: MemorySupersedeRequest | None = None,
) -> dict:
"""Supersede an active memory (Issue E — active → superseded).
Mirrors the invalidate route's status guard: candidates and other
non-active rows must not silently flip to superseded.
"""
from atocore.memory.service import get_memory, supersede_memory
"""Supersede an active memory (Issue E — active → superseded)."""
from atocore.memory.service import supersede_memory
reason = req.reason if req else ""
target = get_memory(memory_id)
if target is None:
raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}")
if target.status == "superseded":
return {"status": "already_superseded", "id": memory_id}
if target.status != "active":
raise HTTPException(
status_code=409,
detail=(
f"Memory {memory_id} is {target.status}; "
"only active memories can be superseded"
),
)
success = supersede_memory(memory_id, actor="api-http", reason=reason)
if not success:
raise HTTPException(
status_code=409,
detail=f"Memory {memory_id} could not be superseded",
status_code=404,
detail=f"Memory not found or not active: {memory_id}",
)
return {"status": "superseded", "id": memory_id}
@@ -2393,41 +2348,6 @@ def api_entity_audit(entity_id: str, limit: int = 100) -> dict:
return {"entity_id": entity_id, "entries": entries, "count": len(entries)}
@router.get("/entities/Subsystem/{subsystem_id}")
def api_subsystem_contents(subsystem_id: str, expand: str = "contains") -> dict:
"""Q-001 (subsystem-scoped variant) — the spec-shaped invocation.
Per ``docs/architecture/engineering-query-catalog.md`` Q-001:
``GET /entities/Subsystem/<id>?expand=contains``
→ ``{ subsystem, contains: [{ id, type, name, status }] }``
Distinct from the project-wide tree at
``GET /engineering/projects/{name}/systems`` (Q-004), which stays
as-is. V1-A only adds this single shape fix; broader query catalog
closure is V1-C.
``expand`` is currently restricted to ``"contains"``; other expand
facets (Q-007 ``constraints``, Q-008 ``decisions``) land in V1-C.
"""
if expand != "contains":
raise HTTPException(
status_code=400,
detail=(
f"unsupported expand={expand!r} for /entities/Subsystem; "
"V1-A supports only 'contains'."
),
)
from atocore.engineering.queries import subsystem_contents
result = subsystem_contents(subsystem_id)
if result is None:
raise HTTPException(
status_code=404,
detail=f"Subsystem not found or not a subsystem: {subsystem_id}",
)
return result
@router.get("/entities/{entity_id}")
def api_get_entity(entity_id: str) -> dict:
"""Get an entity with its relationships and related entities."""

View File

@@ -118,70 +118,6 @@ def system_map(project: str) -> dict:
return out
def subsystem_contents(subsystem_id: str) -> dict | None:
"""Q-001 subsystem-scoped variant: a single subsystem and its
direct ``CONTAINS`` children.
Spec: ``GET /entities/Subsystem/<id>?expand=contains`` per
``docs/architecture/engineering-query-catalog.md`` Q-001.
Differs from :func:`system_map` (Q-004) which returns the
project-wide tree. The subsystem-scoped form is what individual
operator queries actually need: "what's inside this one subsystem?"
rather than "show me the whole project."
The relationship walk uses inbound ``part_of`` edges (the inverse
of ``CONTAINS``) so both child Components and child Subsystems
surface uniformly. Filters to active children only — superseded
or invalid rows do not belong in a "current contents" answer.
Returns:
``{"subsystem": {id, name, project, status, description},
"contains": [{id, entity_type, name, status}]}``
or ``None`` when the entity does not exist or is not a subsystem.
"""
with get_connection() as conn:
ss = conn.execute(
"SELECT * FROM entities WHERE id = ?",
(subsystem_id,),
).fetchone()
if ss is None or ss["entity_type"] != "subsystem":
return None
rows = conn.execute(
"SELECT e.id, e.entity_type, e.name, e.status "
"FROM relationships r "
"JOIN entities e ON e.id = r.source_entity_id "
"WHERE r.relationship_type = 'part_of' "
"AND r.target_entity_id = ? "
"AND e.status = 'active' "
"ORDER BY e.entity_type, e.name",
(subsystem_id,),
).fetchall()
return {
"subsystem": {
"id": ss["id"],
"name": ss["name"],
"project": ss["project"],
"status": ss["status"],
"description": ss["description"] or "",
},
"contains": [
{
"id": r["id"],
# V1-A spec uses `type` per engineering-query-catalog.md Q-001;
# `entity_type` is duplicated for parity with the rest of
# this module's response shape (see `_entity_dict`).
"type": r["entity_type"],
"entity_type": r["entity_type"],
"name": r["name"],
"status": r["status"],
}
for r in rows
],
}
def decisions_affecting(project: str, subsystem_id: str | None = None) -> dict:
"""Q-008: decisions that affect a subsystem (or whole project).

View File

@@ -66,8 +66,6 @@ _V1_PUBLIC_PATHS = {
"/entities/{entity_id}/invalidate",
"/entities/{entity_id}/supersede",
"/entities/{entity_id}/audit",
# V1-A: Q-001 subsystem-scoped variant per engineering-query-catalog
"/entities/Subsystem/{subsystem_id}",
"/relationships",
"/ingest",
"/ingest/sources",

View File

@@ -424,126 +424,6 @@ def get_memory_count_summary() -> dict:
return summary
def propose_emerging_projects(min_active: int = 10) -> list[dict]:
"""Return live, on-demand registration proposals for unregistered projects.
Differs from the nightly ``scripts/detect_emerging.py`` cache (which
is fresh once a day and lives in ``project_state.proposals``) by
reading current SQL and the registry directly. Each proposal is
operator-ready: a guessed ingest root, sibling labels suggested as
aliases, and a few sample memories so the operator can sanity-check
the bucket before committing it.
Args:
min_active: minimum number of active memories required for a
label to surface as a proposal. Defaults to 10 — anything
smaller is too noisy to register without more signal.
Returns:
list of proposal dicts, sorted by active_count desc:
{
"project_id": str,
"active_count": int,
"candidate_count": int,
"suggested_aliases": list[str],
"guessed_ingest_root": {"source": "vault", "subpath": ...},
"sample_memories": [{id, content_preview, updated_at}, ...],
}
"""
from atocore.projects.registry import load_project_registry
# Build the set of names already known to the registry (canonical + aliases),
# lowercased. Anything in this set is "registered" and not a proposal.
registered_names: set[str] = set()
try:
for project in load_project_registry():
registered_names.add(project.project_id.lower())
for alias in project.aliases:
registered_names.add(alias.lower())
except Exception:
# Fail-open: if the registry can't load, assume nothing is
# registered and let the proposal surface everything.
pass
with get_connection() as conn:
# Active counts per project (excluding empty/null project — that's
# the global bucket, not a proposal candidate).
active_rows = conn.execute(
"SELECT project, count(*) AS c FROM memories "
"WHERE status = 'active' AND project IS NOT NULL AND project != '' "
"GROUP BY project"
).fetchall()
cand_rows = conn.execute(
"SELECT project, count(*) AS c FROM memories "
"WHERE status = 'candidate' AND project IS NOT NULL AND project != '' "
"GROUP BY project"
).fetchall()
cand_counts = {r["project"]: r["c"] for r in cand_rows}
# Filter to unregistered labels above threshold
unregistered: list[tuple[str, int, int]] = [] # (project, active_n, candidate_n)
for r in active_rows:
proj = r["project"]
if proj.lower() in registered_names:
continue
if r["c"] < min_active:
continue
unregistered.append((proj, r["c"], cand_counts.get(proj, 0)))
# Sibling alias detection: two unregistered labels are siblings if
# they share a non-trivial token (length >= 4 after splitting on
# '-' and '_'). Cheap, defensible, and the operator gets to veto.
def _tokens(label: str) -> set[str]:
parts = label.lower().replace("_", "-").split("-")
return {p for p in parts if len(p) >= 4}
label_tokens = {label: _tokens(label) for label, _a, _c in unregistered}
proposals = []
for proj, active_n, candidate_n in sorted(
unregistered, key=lambda t: (-t[1], t[0])
):
siblings = [
other
for other in label_tokens
if other != proj and (label_tokens[proj] & label_tokens[other])
]
siblings.sort()
# Sample memories: top 3 active by updated_at desc
with get_connection() as conn:
sample_rows = conn.execute(
"SELECT id, content, updated_at FROM memories "
"WHERE status = 'active' AND project = ? "
"ORDER BY updated_at DESC LIMIT 3",
(proj,),
).fetchall()
samples = [
{
"id": r["id"],
"content_preview": (r["content"] or "")[:160],
"updated_at": r["updated_at"],
}
for r in sample_rows
]
proposals.append(
{
"project_id": proj,
"active_count": active_n,
"candidate_count": candidate_n,
"suggested_aliases": siblings,
"guessed_ingest_root": {
"source": "vault",
"subpath": f"incoming/projects/{proj}/",
},
"sample_memories": samples,
}
)
return proposals
def update_memory(
memory_id: str,
content: str | None = None,

View File

@@ -1,217 +0,0 @@
"""Wave 1.5 — live emerging-project registration proposals.
The nightly `scripts/detect_emerging.py` writes a stale cache to
`project_state.proposals.unregistered_projects`. This endpoint provides
the on-demand alternative that operators can hit before deciding which
unregistered project to register via `/admin/projects/register-emerging`.
"""
import json
import pytest
from fastapi.testclient import TestClient
import atocore.config as config
from atocore.main import app
from atocore.memory.service import create_memory
from atocore.models.database import init_db
@pytest.fixture
def env(tmp_data_dir, tmp_path, monkeypatch):
"""Fresh DB + a registry holding a single registered project so we
can prove the proposals endpoint excludes registered names."""
registry_path = tmp_path / "registry.json"
registry_path.write_text(
json.dumps(
{
"projects": [
{
"id": "p04-gigabit",
"aliases": ["p04", "gigabit"],
"description": "test",
"ingest_roots": [
{"source": "vault", "subpath": "incoming/projects/p04-gigabit"}
],
}
]
}
),
encoding="utf-8",
)
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
config.settings = config.Settings()
init_db()
yield tmp_data_dir
def test_proposals_excludes_registered_project_and_its_aliases(env):
"""Memories tagged on a registered canonical id or any of its
aliases must not appear as a registration proposal."""
# Registered: p04-gigabit (aliases p04, gigabit)
for i in range(15):
create_memory("knowledge", f"p04 fact {i}", project="p04-gigabit")
for i in range(15):
create_memory("knowledge", f"alias fact {i}", project="p04") # alias
# Unregistered, above threshold
for i in range(12):
create_memory("knowledge", f"apm fact {i}", project="apm")
client = TestClient(app)
body = client.get("/admin/projects/proposals?min_active=10").json()
ids = [p["project_id"] for p in body["proposals"]]
assert "apm" in ids
assert "p04-gigabit" not in ids
assert "p04" not in ids
assert "gigabit" not in ids
def test_proposals_threshold_filters_low_count_labels(env):
create_memory("knowledge", "single one-off", project="discrawl")
for i in range(3):
create_memory("knowledge", f"low-volume {i}", project="drill")
for i in range(11):
create_memory("knowledge", f"high-volume {i}", project="apm")
client = TestClient(app)
proposals = client.get("/admin/projects/proposals?min_active=10").json()["proposals"]
ids = [p["project_id"] for p in proposals]
assert "apm" in ids
assert "drill" not in ids
assert "discrawl" not in ids
def test_proposals_suggest_sibling_aliases_via_shared_tokens(env):
"""Lead-space + lead-space-exploration-ltd + space-exploration-ltd
should cluster: each proposes the others as suggested_aliases via
shared non-trivial tokens (length >= 4)."""
for label in ("lead-space", "lead-space-exploration-ltd", "space-exploration-ltd"):
for i in range(11):
create_memory("knowledge", f"{label} content {i}", project=label)
client = TestClient(app)
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
# All three appear and each suggests at least one of the others
for label in ("lead-space", "lead-space-exploration-ltd", "space-exploration-ltd"):
assert label in proposals
siblings = set(proposals[label]["suggested_aliases"])
# Every label shares "space" (and others share "exploration"/"lead")
# so at least one sibling must be present.
assert siblings & {"lead-space", "lead-space-exploration-ltd", "space-exploration-ltd"} - {label}
def test_proposals_short_token_does_not_match(env):
"""Per Codex Wave 1.5 P2: previously this test only asserted apm
and drill have empty siblings, which is trivially true because they
share no tokens at all. The real risk is an accidental relaxation
that lets <4-char tokens trigger clustering. Construct a setup where
that would matter:
- 'apm' and 'apm-fpga': only the 3-char 'apm' is shared. They must
NOT cluster, because 'apm' is too short.
- 'foo-fpga' and 'bar-fpga': the 4-char 'fpga' is shared. They
MUST cluster.
"""
for label in ("apm", "apm-fpga", "foo-fpga", "bar-fpga"):
for i in range(11):
create_memory("knowledge", f"{label} fact {i}", project=label)
client = TestClient(app)
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
# Negative: short-token match must not happen
assert "apm-fpga" not in proposals["apm"]["suggested_aliases"], (
"'apm' (3 chars) is below the 4-char minimum; 'apm' and 'apm-fpga' "
"must not cluster via the 'apm' token."
)
# Positive: long-token match must happen — both directions
assert "bar-fpga" in proposals["foo-fpga"]["suggested_aliases"]
assert "foo-fpga" in proposals["bar-fpga"]["suggested_aliases"]
# And 'apm-fpga' clusters with the others via 'fpga'
assert "apm-fpga" in proposals["foo-fpga"]["suggested_aliases"]
def test_proposals_clustering_is_case_insensitive(env):
"""Token comparison must be case-insensitive so labels captured
with mixed casing still cluster. Codex Wave 1.5 P3."""
for label in ("HydroTech-Mining", "hydrotech-split-tank"):
for i in range(11):
create_memory("knowledge", f"{label} fact {i}", project=label)
client = TestClient(app)
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
assert "hydrotech-split-tank" in proposals["HydroTech-Mining"]["suggested_aliases"]
assert "HydroTech-Mining" in proposals["hydrotech-split-tank"]["suggested_aliases"]
def test_proposals_registered_token_does_not_leak_into_sibling_set(env, monkeypatch):
"""Registered project ids must be filtered BEFORE clustering so a
registered token doesn't get suggested as an alias for an
unregistered sibling. p04-gigabit is registered in env; an
unregistered 'gigabit-other' must not list 'p04-gigabit' as alias."""
for i in range(15):
create_memory("knowledge", f"p04 fact {i}", project="p04-gigabit")
for i in range(11):
create_memory("knowledge", f"gigabit-other fact {i}", project="gigabit-other")
client = TestClient(app)
proposals = {p["project_id"]: p for p in client.get("/admin/projects/proposals").json()["proposals"]}
assert "p04-gigabit" not in proposals
assert "gigabit-other" in proposals
# And the registered name must not surface as a sibling
assert "p04-gigabit" not in proposals["gigabit-other"]["suggested_aliases"]
def test_proposals_include_sample_memories_and_guessed_root(env):
for i in range(11):
create_memory("knowledge", f"sample content {i}", project="apm")
client = TestClient(app)
body = client.get("/admin/projects/proposals").json()
apm = next(p for p in body["proposals"] if p["project_id"] == "apm")
assert apm["active_count"] == 11
assert apm["candidate_count"] == 0
assert apm["guessed_ingest_root"] == {
"source": "vault",
"subpath": "incoming/projects/apm/",
}
assert len(apm["sample_memories"]) == 3
for s in apm["sample_memories"]:
assert s["id"]
assert "sample content" in s["content_preview"]
def test_proposals_count_candidates_separately(env):
for i in range(11):
create_memory("knowledge", f"active {i}", project="apm")
for i in range(4):
create_memory("knowledge", f"candidate {i}", project="apm", status="candidate")
client = TestClient(app)
apm = next(
p for p in client.get("/admin/projects/proposals").json()["proposals"]
if p["project_id"] == "apm"
)
assert apm["active_count"] == 11
assert apm["candidate_count"] == 4
def test_proposals_min_active_param_validation(env):
client = TestClient(app)
r = client.get("/admin/projects/proposals?min_active=0")
assert r.status_code == 400
def test_proposals_sorted_by_active_count_desc(env):
for i in range(20):
create_memory("knowledge", f"big {i}", project="apm")
for i in range(11):
create_memory("knowledge", f"small {i}", project="openclaw")
client = TestClient(app)
proposals = client.get("/admin/projects/proposals").json()["proposals"]
ids = [p["project_id"] for p in proposals]
assert ids[0] == "apm"
assert ids[1] == "openclaw"

View File

@@ -249,35 +249,6 @@ def test_api_invalidate_unknown_id_is_404(env):
assert r.status_code == 404
def test_api_supersede_candidate_returns_409(env):
"""Mirror of the invalidate guard: candidates must not silently flip
to superseded via the active-only supersede route."""
m = create_memory(
memory_type="knowledge", content="candidate target", status="candidate"
)
client = TestClient(app)
r = client.post(f"/memory/{m.id}/supersede", json={"reason": "wrong route"})
assert r.status_code == 409
# Row should still be a candidate
assert _get_memory(m.id).status == "candidate"
def test_api_supersede_already_superseded_is_idempotent(env):
m = create_memory(memory_type="knowledge", content="will be superseded")
client = TestClient(app)
r1 = client.post(f"/memory/{m.id}/supersede", json={"reason": "first"})
assert r1.status_code == 200
r2 = client.post(f"/memory/{m.id}/supersede", json={"reason": "again"})
assert r2.status_code == 200
assert r2.json()["status"] == "already_superseded"
def test_api_supersede_unknown_id_is_404(env):
client = TestClient(app)
r = client.post("/memory/no-such-id/supersede", json={"reason": "ghost"})
assert r.status_code == 404
def test_admin_dashboard_active_count_matches_full_table(env):
"""/admin/dashboard memories.active must match the SQL aggregate even
when there are more active memories than the legacy sample limit (500).

View File

@@ -661,35 +661,3 @@ def test_update_memory_project_unchanged_when_not_passed(isolated_db):
mem = create_memory("knowledge", "untouched project", project="p06-polisher")
update_memory(mem.id, content="edited content")
assert get_memory(mem.id).project == "p06-polisher"
def test_update_memory_to_empty_project_detects_global_duplicate(isolated_db):
"""Codex P3: when retargeting to project='' (global), the duplicate
check must scope to the new project. If a global active memory with
the same content already exists, the update must raise."""
import pytest as _pytest
from atocore.memory.service import create_memory, update_memory
create_memory("knowledge", "shared global fact", project="")
scoped = create_memory("knowledge", "shared global fact", project="p04-gigabit")
with _pytest.raises(ValueError, match="duplicate active memory"):
update_memory(scoped.id, project="")
def test_auto_triage_suggested_project_put_body_uses_project_key():
"""Regression: the auto_triage caller used to PUT {"content": ...}
which silently dropped the suggested project change. The fix sends
{"project": suggested}. Inspect the script source so we don't have
to spin up a live triage run."""
from pathlib import Path
src = Path(__file__).resolve().parents[1] / "scripts" / "auto_triage.py"
text = src.read_text(encoding="utf-8")
# The block that PUTs to /memory/{mid} for a suggested_project fix
assert 'json.dumps({"project": suggested})' in text, (
"auto_triage.py must PUT {\"project\": suggested} so the "
"suggested-project correction actually applies. See Wave 1."
)
# And must not be back to the old shape
assert 'json.dumps({"content": cand["content"]})' not in text

View File

@@ -1,278 +0,0 @@
"""V1-A — minimum query slice that proves the entity model end-to-end.
Per ``docs/plans/engineering-v1-completion-plan.md`` Phase V1-A:
the four pillar queries (Q-001 subsystem-scoped, Q-005, Q-006, Q-017)
must run against a single seed graph and report the expected shapes.
This file is intentionally separate from ``test_engineering_queries.py``
so the V1-A acceptance is auditable in isolation. The seed graph mirrors
what the V1-A plan called for: one satisfying Component, one orphan
Requirement, one supported ValidationClaim, one unsupported one, one
Decision on a flagged Assumption.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from atocore.engineering.queries import (
evidence_chain,
orphan_requirements,
requirements_for,
risky_decisions,
subsystem_contents,
unsupported_claims,
)
from atocore.engineering.service import (
create_entity,
create_relationship,
init_engineering_schema,
invalidate_active_entity,
)
from atocore.main import app
from atocore.models.database import init_db
@pytest.fixture
def v1a_seed(tmp_data_dir):
"""Seed the Q-6 integration data exactly as V1-A's plan describes:
one satisfying Component + one orphan Requirement + one Decision on
a flagged Assumption + one supported ValidationClaim + one
unsupported ValidationClaim, plus the Subsystem they live under."""
init_db()
init_engineering_schema()
# Subsystem (Q-001 target)
optics = create_entity("subsystem", "Optics", project="p05-interferometer")
# Components — one with PART_OF the subsystem and SATISFIES a requirement,
# one without parents (orphan_components in Q-004; not the focus here).
primary = create_entity("component", "Primary Mirror", project="p05-interferometer")
diverger = create_entity("component", "Diverger Lens", project="p05-interferometer")
create_relationship(primary.id, optics.id, "part_of")
create_relationship(diverger.id, optics.id, "part_of")
# Requirements — one satisfied by primary, one orphan (Q-006)
req_satisfied = create_entity(
"requirement", "Surface figure < 25 nm RMS", project="p05-interferometer"
)
req_orphan = create_entity(
"requirement", "Calibration repeatable to lambda/20", project="p05-interferometer"
)
create_relationship(primary.id, req_satisfied.id, "satisfies")
# Decision on a flagged Assumption (Q-009 surface — not asserted in V1-A
# acceptance but useful as background data)
flagged_assumption = create_entity(
"parameter",
"Vendor lead time = 6 weeks",
project="p05-interferometer",
properties={"flagged": True},
)
risky = create_entity(
"decision", "Pre-order CGH from external vendor", project="p05-interferometer"
)
create_relationship(risky.id, flagged_assumption.id, "based_on_assumption")
# Validation claims — one supported by a Result, one unsupported (Q-017
# touches the supported one; Q-011 surfaces the unsupported one)
fea_result = create_entity(
"result", "FEA thermal sweep 2026-04", project="p05-interferometer"
)
claim_supported = create_entity(
"validation_claim", "Thermal margin is adequate", project="p05-interferometer"
)
claim_unsupported = create_entity(
"validation_claim", "Vibration isolation passes spec", project="p05-interferometer"
)
create_relationship(fea_result.id, claim_supported.id, "supports")
return {
"subsystem": optics,
"primary": primary,
"diverger": diverger,
"req_satisfied": req_satisfied,
"req_orphan": req_orphan,
"claim_supported": claim_supported,
"claim_unsupported": claim_unsupported,
"fea_result": fea_result,
"risky_decision": risky,
"flagged_assumption": flagged_assumption,
}
# ---------------------------------------------------------------------------
# Q-001 subsystem-scoped — the V1-A code change
# ---------------------------------------------------------------------------
def test_subsystem_contents_returns_spec_shape(v1a_seed):
"""The shape must match the catalog spec
(engineering-query-catalog.md Q-001):
``{subsystem, contains: [{id, type, name, status}]}``.
The implementation also emits ``entity_type`` for parity with the
rest of this module's response style — both must be present."""
result = subsystem_contents(v1a_seed["subsystem"].id)
assert result is not None
assert set(result.keys()) == {"subsystem", "contains"}
ss = result["subsystem"]
assert ss["id"] == v1a_seed["subsystem"].id
assert ss["name"] == "Optics"
assert ss["project"] == "p05-interferometer"
assert ss["status"] == "active"
contained = {c["name"] for c in result["contains"]}
assert contained == {"Primary Mirror", "Diverger Lens"}
for child in result["contains"]:
# Spec requires `type`; we also include `entity_type` for parity.
assert "type" in child
assert "entity_type" in child
assert child["type"] == child["entity_type"]
assert set(child.keys()) >= {"id", "type", "entity_type", "name", "status"}
assert child["entity_type"] == "component"
assert child["status"] == "active"
def test_subsystem_contents_returns_none_for_missing_id(tmp_data_dir):
init_db()
init_engineering_schema()
assert subsystem_contents("no-such-id") is None
def test_subsystem_contents_returns_none_when_entity_is_not_a_subsystem(v1a_seed):
"""Calling the subsystem-scoped query against a Component must
return None, not the component dressed up as a subsystem."""
assert subsystem_contents(v1a_seed["primary"].id) is None
def test_subsystem_contents_route_matches_spec(v1a_seed):
"""Spec invocation: GET /entities/Subsystem/<id>?expand=contains.
Verify the route is registered, the expand=contains path works,
and unsupported expand values 400."""
client = TestClient(app)
sid = v1a_seed["subsystem"].id
r = client.get(f"/entities/Subsystem/{sid}?expand=contains")
assert r.status_code == 200
body = r.json()
assert body["subsystem"]["id"] == sid
names = {c["name"] for c in body["contains"]}
assert names == {"Primary Mirror", "Diverger Lens"}
# Default expand is "contains" so omitting the param should also work
r = client.get(f"/entities/Subsystem/{sid}")
assert r.status_code == 200
# Unsupported expand value 400s with an informative message
r = client.get(f"/entities/Subsystem/{sid}?expand=parents")
assert r.status_code == 400
assert "expand" in r.json()["detail"].lower()
# Missing/wrong-type subsystem 404s
r = client.get(f"/entities/Subsystem/{v1a_seed['primary'].id}")
assert r.status_code == 404
def test_subsystem_contents_v1_alias_is_present():
"""The subsystem-scoped variant must also be reachable under /v1."""
client = TestClient(app)
spec = client.get("/openapi.json").json()
assert "/v1/entities/Subsystem/{subsystem_id}" in spec["paths"]
def test_subsystem_contents_v1_alias_serves_real_response(v1a_seed):
"""Behavior, not just OpenAPI presence — catches a future
route-copy or ordering regression (Codex V1-A P3)."""
client = TestClient(app)
sid = v1a_seed["subsystem"].id
r = client.get(f"/v1/entities/Subsystem/{sid}?expand=contains")
assert r.status_code == 200
body = r.json()
assert body["subsystem"]["id"] == sid
assert {c["name"] for c in body["contains"]} == {"Primary Mirror", "Diverger Lens"}
def test_subsystem_contents_includes_child_subsystems_and_excludes_inactive(tmp_data_dir):
"""Codex V1-A P3: lock in two intended behaviors that aren't
obvious from the data shape:
1. Child *Subsystems* (nested) surface in `contains` — the impl
walks part_of irrespective of child entity_type.
2. Children whose status is not 'active' are filtered out."""
init_db()
init_engineering_schema()
parent = create_entity("subsystem", "Parent System", project="p-nested")
child_ss = create_entity("subsystem", "Child Subsystem", project="p-nested")
child_comp = create_entity("component", "Child Component", project="p-nested")
invalid_comp = create_entity("component", "Invalidated Component", project="p-nested")
create_relationship(child_ss.id, parent.id, "part_of")
create_relationship(child_comp.id, parent.id, "part_of")
create_relationship(invalid_comp.id, parent.id, "part_of")
invalidate_active_entity(invalid_comp.id, reason="v1a regression seed")
result = subsystem_contents(parent.id)
names_by_type = {(c["entity_type"], c["name"]) for c in result["contains"]}
assert ("subsystem", "Child Subsystem") in names_by_type
assert ("component", "Child Component") in names_by_type
# Inactive child must not appear
assert all(c["name"] != "Invalidated Component" for c in result["contains"])
assert all(c["status"] == "active" for c in result["contains"])
# ---------------------------------------------------------------------------
# V1-A acceptance — the four pillar queries against the single seed graph
# ---------------------------------------------------------------------------
def test_v1a_pillar_queries_run_end_to_end_against_single_seed(v1a_seed):
"""The V1-A acceptance test. All four pillar queries must report
the expected shape against the same seed graph. If this test fails,
V1-A is not done — full stop. (Per the plan: "If the four pillar
queries don't work, stopping here is cheap.")"""
project = "p05-interferometer"
# Q-001 subsystem-scoped
q1 = subsystem_contents(v1a_seed["subsystem"].id)
assert q1 is not None
assert {c["name"] for c in q1["contains"]} == {"Primary Mirror", "Diverger Lens"}
# Q-005 — requirements satisfied by Primary Mirror
q5 = requirements_for(v1a_seed["primary"].id)
assert q5["count"] == 1
assert q5["requirements"][0]["name"] == "Surface figure < 25 nm RMS"
# Q-006 (killer correctness) — orphan requirements in the project
q6 = orphan_requirements(project)
orphan_names = {r["name"] for r in q6["gaps"]}
assert "Calibration repeatable to lambda/20" in orphan_names
assert "Surface figure < 25 nm RMS" not in orphan_names
# Q-017 — evidence chain for the supported ValidationClaim
q17 = evidence_chain(v1a_seed["claim_supported"].id)
via_supports = [edge for edge in q17["evidence_chain"] if edge["via"] == "supports"]
assert any(edge["source_name"] == "FEA thermal sweep 2026-04" for edge in via_supports)
# And the unsupported claim must have an empty supports chain
q17_unsup = evidence_chain(v1a_seed["claim_unsupported"].id)
assert not any(edge["via"] == "supports" for edge in q17_unsup["evidence_chain"])
def test_v1a_seed_also_exercises_q009_and_q011_killers(v1a_seed):
"""Per Codex V1-A P2: the seed already includes Q-009 (decision on
flagged assumption) and Q-011 (unsupported validation claim) data,
so the V1-A integration test should assert those killer queries
surface them. Otherwise the seed entities are dead weight."""
project = "p05-interferometer"
q9 = risky_decisions(project)
risky_names = {row["decision_name"] for row in q9["gaps"]}
assert "Pre-order CGH from external vendor" in risky_names
q11 = unsupported_claims(project)
unsupported_names = {row["name"] for row in q11["gaps"]}
assert "Vibration isolation passes spec" in unsupported_names
assert "Thermal margin is adequate" not in unsupported_names