8 Commits

Author SHA1 Message Date
365259fde0 docs(ledger): V1-A landed and deployed
build 23cdb31. Live harness 20/20. V1-A status in master-plan-status.md
flipped to ; V1-B is the next phase. 605 tests on main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:11:07 -04:00
23cdb3149f feat(engineering): V1-A — Q-001 subsystem-scoped + pillar query integration test
Phase V1-A of the Engineering V1 Completion Plan. Scope was kept tight
per the plan: a single Q-001 shape fix and an integration test that
proves the four pillar queries work end-to-end against one seed graph.

Code change:
  - subsystem_contents() in src/atocore/engineering/queries.py returns
    {subsystem, contains: [{id, type, entity_type, name, status}]} by
    walking inbound part_of edges (the inverse of CONTAINS), filtered
    to active children. `type` matches the catalog spec; `entity_type`
    preserves parity with the rest of this module's response shape.
  - GET /entities/Subsystem/{id}?expand=contains route in routes.py
    matches the Q-001 spec invocation verbatim; 400 on unsupported
    expand, 404 on missing-or-wrong-type.
  - Aliased under /v1.
  - The existing project-wide /engineering/projects/{name}/systems
    route stays as-is for Q-004 (whole-project tree).

V1-A acceptance test (tests/test_v1_a_pillar_queries.py):
  - Q-001 subsystem-scoped: Optics → Primary Mirror + Diverger Lens
  - Q-005 requirements_for: Primary Mirror has its single satisfied req
  - Q-006 orphan_requirements: orphan surfaces, satisfied does not
  - Q-017 evidence_chain: supported claim has the FEA result via
    'supports'; unsupported claim does not
  - Q-009 risky_decisions / Q-011 unsupported_claims also asserted
    against the same seed (Codex P2: don't seed data you don't assert)

Plus targeted tests for the Q-001 helper: missing id, wrong type,
nested child subsystems, inactive-child filtering, real /v1 GET
behavior.

Reviewed by Codex twice: GO WITH CONDITIONS at b575773, GO at 0e83525
after the dual-key emit + Q-009/Q-011 + /v1-behavior + nested/inactive
amends folded in.

Tests: 596 -> 605 (+9). Full local suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:03:58 -04:00
785756fb58 docs(ledger): openclaw registered, proposals queue drained
Wave 1.5 fully closed. 8 registered projects total. No unregistered
labels above the 10-active-memory threshold remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:41:21 -04:00
a8f4d51f06 docs(ledger): apm registered as product I03
Atomaste Parts Manager joins the registered project set alongside
atomizer-v2. Aliases: i03, atomaste-parts-manager, parts-manager.
165 active + 22 candidate memories now resolve to a registered
canonical id. /admin/projects/proposals count drops 2 -> 1 (openclaw
remains, framing pending).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:32:41 -04:00
848ad9db2d docs(ledger): Wave 1.5 deployed, apm + openclaw proposals live
build b69d2c7. /admin/projects/proposals returns apm (165 active) and
openclaw (17). Both have empty suggested_aliases — they're standalone,
not part of a sibling cluster. Branches cleaned up.

Operator decision pending: register apm via
POST /admin/projects/register-emerging with body {"project_id":"apm"}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:23:09 -04:00
b69d2c7088 feat(projects): Wave 1.5 — live emerging-project registration proposals
GET /admin/projects/proposals?min_active=N — on-demand companion to
the nightly scripts/detect_emerging.py cache. Reads SQL + the registry
directly so the result is current.

Each proposal:
  - project_id (literal label as captured)
  - active_count / candidate_count from current SQL
  - sample_memories: 3 most recent active rows with content preview
  - suggested_aliases: sibling labels sharing a >=4-char token,
    case-insensitive (lead-space + lead-space-exploration-ltd +
    space-exploration-ltd cluster; apm and apm-fpga do NOT cluster
    via the 3-char 'apm')
  - guessed_ingest_root: vault:incoming/projects/<id>/

Workflow: hit /admin/projects/proposals to see "what should I register?",
then POST to existing /admin/projects/register-emerging.

For prod: apm has 165 active memories, openclaw has 17,
hydrotech-mining variants combine to 13. apm is overdue.

Closes Codex's prior P2 from the state-of-service review. Reviewed by
Codex on tip e8ac8bb (verdict GO); two follow-on improvements (stronger
negative-clustering test + case-insensitive tokens) folded into f70fa6b.

10 regression tests covering: registered canonical/alias exclusion,
threshold filtering, sibling clustering, short-token negative,
case-insensitive clustering, registered-token-leak guard, sample shape,
candidate counting, param validation, sort order.

Test count: 586 -> 596.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:16:18 -04:00
d4ee52729c docs(ledger): Wave 1 deployed, post-deploy probes green
live_sha 4c70756 verified. /admin/dashboard now reports SQL-aggregate
counts (1170 active visible vs 315 pre-deploy). Harness 20/20.
Supersede/invalidate route branches probed live: 404 unknown / 409
candidate / 200 active. Wave 1 closed; Wave 1.5 (emerging-project
registration proposal) is the next branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:05:48 -04:00
4c7075650c fix(memory): Wave 1 — SQL-aggregate dashboard counts + memory write-path fixes
Closes three live-affecting bugs surfaced by the 2026-04-29 Codex review,
all in the memory write/read path. Pre-deploy on Dalidou the live
discrepancy was dashboard.memories.active=315 vs integrity active=1091.

1. /admin/dashboard counts now SQL-aggregate (no sampling).
   New get_memory_count_summary() helper. Dashboard memories.{active,
   candidates,by_type,by_project,reinforced,by_status,total} all derive
   from full-table SQL, not a confidence-sorted limit=500 sample. Post
   deploy the dashboard active count must match the integrity panel.

2. PUT /memory/{id} accepts project; auto-triage now applies it.
   Added project to MemoryUpdateRequest and update_memory() with
   resolve_project_name canonicalization, before/after audit, and
   duplicate-active check scoped to the new project. scripts/auto_triage.py
   suggested-project correction now PUTs {"project": suggested} so
   misattribution flags actually retarget the memory.

3. POST /memory/{id}/invalidate uses direct id lookup.
   New get_memory(id) helper. Replaces the old
   _get_memories(status="active", limit=1) lookup, which only saw the
   highest-confidence active row. Active memories outside slot 0 no
   longer 404. Same status-guard structure applied to
   POST /memory/{id}/supersede so candidates can't silently flip to
   superseded.

14 regression tests added (572 -> 586 locally). Reviewed by Codex twice:
verdict GO on tip 9604c3e.

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

View File

@@ -6,26 +6,26 @@
## Orientation ## Orientation
- **live_sha** (Dalidou `/health` build_sha): `d3de9f6` (verified 2026-04-25T01:01Z post trusted-state ranking deploy; status=ok) - **live_sha** (Dalidou `/health` build_sha): `23cdb31` (verified 2026-04-29T17:04:05Z post V1-A deploy; status=ok)
- **last_updated**: 2026-04-25 by Codex (retrieval harness fully green) - **last_updated**: 2026-04-29 by Claude (Wave 1 + Wave 1.5 + V1-A all squash-merged and deployed today)
- **main_tip**: `d3de9f6` - **main_tip**: `23cdb31`
- **test_count**: 572 on `main` - **test_count**: 605 on `main` (572 + 14 Wave 1 + 10 Wave 1.5 + 9 V1-A)
- **harness**: `20/20 PASS` on live Dalidou, 0 blocking failures, 0 known issues - **harness**: `20/20 PASS` on live Dalidou against `23cdb31`, 0 blocking failures, 0 known issues
- **vectors**: 33,253 - **vectors**: 33,253
- **active_memories**: 290 (`/admin/dashboard` 2026-04-24; note integrity panel reports a separate active_memory_count=951 and needs reconciliation) - **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**: 0 (triage queue drained) - **candidate_memories**: 44 (live as of 2026-04-29T02:00Z; queue accumulated since the 2026-04-28 nightly autotriage)
- **interactions**: 951 (`/admin/dashboard` 2026-04-24) - **interactions**: 1054 (claude-code 474, openclaw 576; verified `/admin/dashboard` 2026-04-29)
- **registered_projects**: atocore, p04-gigabit, p05-interferometer, p06-polisher, atomizer-v2, abb-space (aliased p08) - **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.
- **project_state_entries**: 128 across registered projects (`/admin/dashboard` 2026-04-24) - **project_state_entries**: 128 across registered projects (verified 2026-04-29)
- **entities**: 66 (up from 35 — V1-0 backfill + ongoing work; 0 open conflicts) - **entities**: 66 (V1-0 backfill complete; 0 open conflicts)
- **off_host_backup**: `papa@192.168.86.39:/home/papa/atocore-backups/` via cron, verified - **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 (NEW)** → weekly synth/lint Sundays → **retrieval harness (NEW)****pipeline summary (NEW)** - **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) - **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 - **wiki**: http://dalidou:8100/wiki (browse), /wiki/projects/{id}, /wiki/entities/{id}, /wiki/search
- **dashboard**: http://dalidou:8100/admin/dashboard (now shows pipeline health, interaction totals by client, all registered projects) - **dashboard**: http://dalidou:8100/admin/dashboard (now reports SQL-aggregate counts; new fields: `memories.total`, `memories.by_status`)
- **active_track**: Engineering V1 Completion (started 2026-04-22). V1-0 landed (`2712c5d`). V1-A density gate CLEARED (784 active ≫ 100 target as of 2026-04-23). V1-A soak gate at day 5/~7 (F4 first run 2026-04-19; nightly clean 2026-04-19 through 2026-04-23; live harness is now fully green as of 2026-04-25). Plan: `docs/plans/engineering-v1-completion-plan.md`. Resume map: `docs/plans/v1-resume-state.md`. - **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`.
- **last_nightly_pipeline**: `2026-04-23T03:00:20Z` — harness 17/18, triage promoted=3 rejected=7 human=0, dedup 7 clusters (1 tier1 + 6 tier2 auto-merged), graduation 30-skipped 0-graduated 0-errors, auto-triage drained the queue (0 new candidates 2026-04-22T00:52Z run) - **last_nightly_pipeline**: `2026-04-28T03:00:30Z` — harness 20/20, triage promoted=1 rejected=1 human=0
- **open_branches**: `codex/p04-constraints-state-gap` pushed and fast-forwarded into `main` as `d3de9f6`; no active unmerged code branch for this tranche. - **open_branches**: none. Wave 1 (`4c70756`), Wave 1.5 (`b69d2c7`), and V1-A (`23cdb31`) all squash-merged and deployed today; branches cleaned up.
## Active Plan ## Active Plan
@@ -170,6 +170,20 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
## Session Log ## 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. - **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.
- **2026-04-25 Codex (project_id backfill + retrieval stabilization closed)** Merged `codex/project-id-metadata-retrieval` into `main` (`867a1ab`) and deployed to Dalidou. Took Chroma-inclusive backup `/srv/storage/atocore/backups/snapshots/20260424T154358Z`, then ran `scripts/backfill_chunk_project_ids.py` per project; populated projects `p04-gigabit`, `p05-interferometer`, `p06-polisher`, `atomizer-v2`, and `atocore` applied cleanly for 33,253 vectors total, with 0 missing/malformed and an immediate final dry-run showing 33,253 already tagged / 0 updates. Post-backfill harness exposed p06 memory-ranking misses (`Tailscale`, `encoder`), so Codex shipped `4744c69` then `a87d984 fix(memory): widen query-time context candidates`. Full local suite: 571 passed. Live `/health` reports `a87d9845a8c34395a02890f0cf22aa7a46afaf62`, vectors=33,253, sources_ready=true. Live retrieval harness: 19/20, 0 blocking failures, 1 known issue (`p04-constraints` missing `Zerodur` / `1.2`). A repeat backfill dry-run after the code-only stabilization deploy was aborted after the one-off container ran too long; the live service stayed healthy and the earlier post-apply idempotency result remains the migration acceptance record. Dalidou HTTP push credentials are still not configured; this session pushed through the Windows credential path. - **2026-04-25 Codex (project_id backfill + retrieval stabilization closed)** Merged `codex/project-id-metadata-retrieval` into `main` (`867a1ab`) and deployed to Dalidou. Took Chroma-inclusive backup `/srv/storage/atocore/backups/snapshots/20260424T154358Z`, then ran `scripts/backfill_chunk_project_ids.py` per project; populated projects `p04-gigabit`, `p05-interferometer`, `p06-polisher`, `atomizer-v2`, and `atocore` applied cleanly for 33,253 vectors total, with 0 missing/malformed and an immediate final dry-run showing 33,253 already tagged / 0 updates. Post-backfill harness exposed p06 memory-ranking misses (`Tailscale`, `encoder`), so Codex shipped `4744c69` then `a87d984 fix(memory): widen query-time context candidates`. Full local suite: 571 passed. Live `/health` reports `a87d9845a8c34395a02890f0cf22aa7a46afaf62`, vectors=33,253, sources_ready=true. Live retrieval harness: 19/20, 0 blocking failures, 1 known issue (`p04-constraints` missing `Zerodur` / `1.2`). A repeat backfill dry-run after the code-only stabilization deploy was aborted after the one-off container ran too long; the live service stayed healthy and the earlier post-apply idempotency result remains the migration acceptance record. Dalidou HTTP push credentials are still not configured; this session pushed through the Windows credential path.

View File

@@ -198,8 +198,8 @@ where surfaces are disjoint, pauses when they collide.
| Phase | Scope | Status | | 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-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 | 🟡 gated — starts when soak (~2026-04-26) + density (100+ active memories) gates clear | | 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 | pending V1-A | | V1-B | KB-CAD + KB-FEM ingest (`POST /ingest/kb-cad/export`, `POST /ingest/kb-fem/export`) + D-2 schema docs | next |
| 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-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-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) | | 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,19 +404,23 @@ def process_candidate(cand, base_url, active_cache, state_cache, known_projects,
known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S, known_projects, TIER1_MODEL, DEFAULT_TIMEOUT_S,
) )
# Project misattribution fix: suggested_project surfaces from tier 1 # 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.
suggested = (v1.get("suggested_project") or "").strip() suggested = (v1.get("suggested_project") or "").strip()
if suggested and suggested != project and suggested in known_projects: if suggested and suggested != project and suggested in known_projects:
# Try to re-canonicalize the memory's project
if not dry_run: if not dry_run:
try: try:
import urllib.request as _ur import urllib.request as _ur
req = _ur.Request( req = _ur.Request(
f"{base_url}/memory/{mid}", method="PUT", f"{base_url}/memory/{mid}", method="PUT",
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
data=json.dumps({"content": cand["content"]}).encode("utf-8"), data=json.dumps({"project": suggested}).encode("utf-8"),
) )
_ur.urlopen(req, timeout=10).read() # triggers canonicalization via update _ur.urlopen(req, timeout=10).read()
except Exception: except Exception:
pass pass
print(f" ↺ misattribution flagged: {project!r}{suggested!r}") print(f" ↺ misattribution flagged: {project!r}{suggested!r}")

View File

@@ -303,6 +303,7 @@ class MemoryUpdateRequest(BaseModel):
memory_type: str | None = None memory_type: str | None = None
domain_tags: list[str] | None = None domain_tags: list[str] | None = None
valid_until: str | None = None valid_until: str | None = None
project: str | None = None
class ProjectStateSetRequest(BaseModel): class ProjectStateSetRequest(BaseModel):
@@ -473,6 +474,33 @@ def api_register_emerging_project(req: RegisterEmergingRequest) -> dict:
return result 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}") @router.put("/projects/{project_name}")
def api_project_update(project_name: str, req: ProjectUpdateRequest) -> dict: def api_project_update(project_name: str, req: ProjectUpdateRequest) -> dict:
"""Update an existing project registration.""" """Update an existing project registration."""
@@ -636,6 +664,7 @@ def api_update_memory(memory_id: str, req: MemoryUpdateRequest) -> dict:
memory_type=req.memory_type, memory_type=req.memory_type,
domain_tags=req.domain_tags, domain_tags=req.domain_tags,
valid_until=req.valid_until, valid_until=req.valid_until,
project=req.project,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -794,33 +823,25 @@ def api_invalidate_memory(
req: MemoryInvalidateRequest | None = None, req: MemoryInvalidateRequest | None = None,
) -> dict: ) -> dict:
"""Retract an active memory (Issue E — active → invalid).""" """Retract an active memory (Issue E — active → invalid)."""
from atocore.memory.service import get_memories as _get_memories, invalidate_memory from atocore.memory.service import get_memory, invalidate_memory
reason = req.reason if req else "" reason = req.reason if req else ""
# Quick existence/status check for a clean 404 vs 409. # Direct id lookup — earlier code used get_memories(status='active', limit=1)
existing = [ # which only saw the highest-confidence active row, so any other active
m for m in _get_memories(status="active", limit=1) # memory would 404 here even though it existed.
if m.id == memory_id target = get_memory(memory_id)
] if target is None:
if not existing:
# Fall through to generic not-active if the id exists in another status.
all_match = [
m for m in _get_memories(status="candidate", limit=5000)
+ _get_memories(status="invalid", limit=5000)
+ _get_memories(status="superseded", limit=5000)
if m.id == memory_id
]
if all_match:
if all_match[0].status == "invalid":
return {"status": "already_invalid", "id": memory_id}
raise HTTPException(
status_code=409,
detail=(
f"Memory {memory_id} is {all_match[0].status}; "
"use /reject for candidates"
),
)
raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}") raise HTTPException(status_code=404, detail=f"Memory not found: {memory_id}")
if target.status == "invalid":
return {"status": "already_invalid", "id": memory_id}
if target.status != "active":
raise HTTPException(
status_code=409,
detail=(
f"Memory {memory_id} is {target.status}; "
"use /reject for candidates"
),
)
success = invalidate_memory(memory_id, actor="api-http", reason=reason) success = invalidate_memory(memory_id, actor="api-http", reason=reason)
if not success: if not success:
@@ -833,15 +854,33 @@ def api_supersede_memory(
memory_id: str, memory_id: str,
req: MemorySupersedeRequest | None = None, req: MemorySupersedeRequest | None = None,
) -> dict: ) -> dict:
"""Supersede an active memory (Issue E — active → superseded).""" """Supersede an active memory (Issue E — active → superseded).
from atocore.memory.service import supersede_memory
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
reason = req.reason if req else "" 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) success = supersede_memory(memory_id, actor="api-http", reason=reason)
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=404, status_code=409,
detail=f"Memory not found or not active: {memory_id}", detail=f"Memory {memory_id} could not be superseded",
) )
return {"status": "superseded", "id": memory_id} return {"status": "superseded", "id": memory_id}
@@ -1280,16 +1319,20 @@ def api_dashboard() -> dict:
health beyond the basic /health endpoint. health beyond the basic /health endpoint.
""" """
import json as _json import json as _json
from collections import Counter
from datetime import datetime as _dt, timezone as _tz from datetime import datetime as _dt, timezone as _tz
all_memories = get_memories(active_only=False, limit=500) from atocore.memory.service import get_memory_count_summary
active = [m for m in all_memories if m.status == "active"]
candidates = [m for m in all_memories if m.status == "candidate"]
type_counts = dict(Counter(m.memory_type for m in active)) # SQL-backed counts. Earlier code derived these by sampling the top
project_counts = dict(Counter(m.project or "(none)" for m in active)) # 500 rows of get_memories() ordered by confidence — anything past
reinforced = [m for m in active if m.reference_count > 0] # the cap was invisible, so /admin/dashboard silently undercounted
# active memories once the corpus crossed ~500 active rows.
counts = get_memory_count_summary()
active_total = counts["active"]["total"]
candidate_total = counts["by_status"].get("candidate", 0)
type_counts = counts["active"]["by_type"]
project_counts = counts["active"]["by_project"]
reinforced_total = counts["active"]["reinforced"]
# Interaction stats — total + by_client from DB directly # Interaction stats — total + by_client from DB directly
interaction_stats: dict = {"most_recent": None, "total": 0, "by_client": {}} interaction_stats: dict = {"most_recent": None, "total": 0, "by_client": {}}
@@ -1402,13 +1445,13 @@ def api_dashboard() -> dict:
# Triage queue health # Triage queue health
triage: dict = { triage: dict = {
"pending": len(candidates), "pending": candidate_total,
"review_url": "/admin/triage", "review_url": "/admin/triage",
} }
if len(candidates) > 50: if candidate_total > 50:
triage["warning"] = f"High queue: {len(candidates)} candidates pending review." triage["warning"] = f"High queue: {candidate_total} candidates pending review."
elif len(candidates) > 20: elif candidate_total > 20:
triage["notice"] = f"{len(candidates)} candidates awaiting triage." triage["notice"] = f"{candidate_total} candidates awaiting triage."
# Recent audit activity (Phase 4 V1) — last 10 mutations for operator # Recent audit activity (Phase 4 V1) — last 10 mutations for operator
recent_audit: list[dict] = [] recent_audit: list[dict] = []
@@ -1420,11 +1463,13 @@ def api_dashboard() -> dict:
return { return {
"memories": { "memories": {
"active": len(active), "active": active_total,
"candidates": len(candidates), "candidates": candidate_total,
"by_type": type_counts, "by_type": type_counts,
"by_project": project_counts, "by_project": project_counts,
"reinforced": len(reinforced), "reinforced": reinforced_total,
"by_status": counts["by_status"],
"total": counts["total"],
}, },
"project_state": { "project_state": {
"counts": ps_counts, "counts": ps_counts,
@@ -2348,6 +2393,41 @@ def api_entity_audit(entity_id: str, limit: int = 100) -> dict:
return {"entity_id": entity_id, "entries": entries, "count": len(entries)} 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}") @router.get("/entities/{entity_id}")
def api_get_entity(entity_id: str) -> dict: def api_get_entity(entity_id: str) -> dict:
"""Get an entity with its relationships and related entities.""" """Get an entity with its relationships and related entities."""

View File

@@ -118,6 +118,70 @@ def system_map(project: str) -> dict:
return out 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: def decisions_affecting(project: str, subsystem_id: str | None = None) -> dict:
"""Q-008: decisions that affect a subsystem (or whole project). """Q-008: decisions that affect a subsystem (or whole project).

View File

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

View File

@@ -347,6 +347,203 @@ def get_memories(
return [_row_to_memory(r) for r in rows] return [_row_to_memory(r) for r in rows]
def get_memory(memory_id: str) -> Memory | None:
"""Return a single memory by id, or None if missing.
Direct id lookup (no LIMIT, no confidence ordering) — the right
primitive for routes that need to check a specific memory's status
before acting. Avoids the sampling pitfall where ``get_memories``
with a small ``limit`` could hide a target row sorted past the cap.
"""
with get_connection() as conn:
row = conn.execute(
"SELECT * FROM memories WHERE id = ?", (memory_id,)
).fetchone()
return _row_to_memory(row) if row else None
def get_memory_count_summary() -> dict:
"""Aggregate memory counts straight from SQL (no sampling).
Returned shape:
{
"total": int, # all rows
"by_status": {status: int, ...}, # full table
"active": {
"total": int,
"reinforced": int, # active with reference_count > 0
"by_type": {memory_type: int, ...},
"by_project": {project_or_none: int, ...},
},
}
Distinct from ``get_memories(...)``, which is a row-fetcher with a
confidence-sorted LIMIT and is therefore not safe for counting.
"""
summary: dict = {
"total": 0,
"by_status": {},
"active": {
"total": 0,
"reinforced": 0,
"by_type": {},
"by_project": {},
},
}
with get_connection() as conn:
row = conn.execute("SELECT count(*) FROM memories").fetchone()
summary["total"] = row[0] if row else 0
rows = conn.execute(
"SELECT status, count(*) FROM memories GROUP BY status"
).fetchall()
summary["by_status"] = {r[0]: r[1] for r in rows}
active_total = summary["by_status"].get("active", 0)
summary["active"]["total"] = active_total
rows = conn.execute(
"SELECT memory_type, count(*) FROM memories "
"WHERE status = 'active' GROUP BY memory_type"
).fetchall()
summary["active"]["by_type"] = {r[0]: r[1] for r in rows}
rows = conn.execute(
"SELECT COALESCE(NULLIF(project, ''), '(none)') AS project, count(*) "
"FROM memories WHERE status = 'active' GROUP BY project"
).fetchall()
summary["active"]["by_project"] = {r[0]: r[1] for r in rows}
row = conn.execute(
"SELECT count(*) FROM memories "
"WHERE status = 'active' AND reference_count > 0"
).fetchone()
summary["active"]["reinforced"] = row[0] if row else 0
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( def update_memory(
memory_id: str, memory_id: str,
content: str | None = None, content: str | None = None,
@@ -355,6 +552,7 @@ def update_memory(
memory_type: str | None = None, memory_type: str | None = None,
domain_tags: list[str] | None = None, domain_tags: list[str] | None = None,
valid_until: str | None = None, valid_until: str | None = None,
project: str | None = None,
actor: str = "api", actor: str = "api",
note: str = "", note: str = "",
) -> bool: ) -> bool:
@@ -368,6 +566,10 @@ def update_memory(
next_content = content if content is not None else existing["content"] next_content = content if content is not None else existing["content"]
next_status = status if status is not None else existing["status"] next_status = status if status is not None else existing["status"]
next_project = (
resolve_project_name(project) if project is not None
else (existing["project"] or "")
)
if confidence is not None: if confidence is not None:
_validate_confidence(confidence) _validate_confidence(confidence)
@@ -375,7 +577,7 @@ def update_memory(
duplicate = conn.execute( duplicate = conn.execute(
"SELECT id FROM memories " "SELECT id FROM memories "
"WHERE memory_type = ? AND content = ? AND project = ? AND status = 'active' AND id != ?", "WHERE memory_type = ? AND content = ? AND project = ? AND status = 'active' AND id != ?",
(existing["memory_type"], next_content, existing["project"] or "", memory_id), (existing["memory_type"], next_content, next_project, memory_id),
).fetchone() ).fetchone()
if duplicate: if duplicate:
raise ValueError("Update would create a duplicate active memory") raise ValueError("Update would create a duplicate active memory")
@@ -386,6 +588,7 @@ def update_memory(
"status": existing["status"], "status": existing["status"],
"confidence": existing["confidence"], "confidence": existing["confidence"],
"memory_type": existing["memory_type"], "memory_type": existing["memory_type"],
"project": existing["project"] or "",
} }
after_snapshot = dict(before_snapshot) after_snapshot = dict(before_snapshot)
@@ -422,6 +625,10 @@ def update_memory(
updates.append("valid_until = ?") updates.append("valid_until = ?")
params.append(vu) params.append(vu)
after_snapshot["valid_until"] = vu or "" after_snapshot["valid_until"] = vu or ""
if project is not None:
updates.append("project = ?")
params.append(next_project)
after_snapshot["project"] = next_project
if not updates: if not updates:
return False return False

View File

@@ -0,0 +1,217 @@
"""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

@@ -192,3 +192,120 @@ def test_v1_aliases_present(env):
"/v1/memory/{memory_id}/supersede", "/v1/memory/{memory_id}/supersede",
): ):
assert p in paths, f"{p} missing" assert p in paths, f"{p} missing"
# ---------------------------------------------------------------------------
# Wave 1 (2026-04-29) — invalidation route used to do
# `_get_memories(status='active', limit=1)` and look for the target id
# inside that single highest-confidence row, so any active memory
# outside slot 0 fell through as 404. Direct id lookup fixes it.
# ---------------------------------------------------------------------------
def test_api_invalidate_finds_active_memory_outside_top_one(env):
"""An active memory not at the top of the confidence sort must still
be invalidatable via POST /memory/{id}/invalidate."""
high = create_memory(
memory_type="knowledge",
content="high-confidence top row",
confidence=0.99,
)
low = create_memory(
memory_type="knowledge",
content="lower-confidence target",
confidence=0.55,
)
client = TestClient(app)
r = client.post(f"/memory/{low.id}/invalidate", json={"reason": "wave1 regression"})
assert r.status_code == 200, r.text
assert r.json()["status"] == "invalidated"
# And confirm the high-confidence row is untouched
assert _get_memory(high.id).status == "active"
assert _get_memory(low.id).status == "invalid"
def test_api_invalidate_already_invalid_is_idempotent(env):
m = create_memory(memory_type="knowledge", content="already invalid")
client = TestClient(app)
r1 = client.post(f"/memory/{m.id}/invalidate", json={"reason": "first"})
assert r1.status_code == 200
r2 = client.post(f"/memory/{m.id}/invalidate", json={"reason": "again"})
assert r2.status_code == 200
assert r2.json()["status"] == "already_invalid"
def test_api_invalidate_candidate_returns_409(env):
m = create_memory(
memory_type="knowledge", content="candidate route", status="candidate"
)
client = TestClient(app)
r = client.post(f"/memory/{m.id}/invalidate", json={"reason": "wrong route"})
assert r.status_code == 409
def test_api_invalidate_unknown_id_is_404(env):
client = TestClient(app)
r = client.post("/memory/no-such-id/invalidate", json={"reason": "ghost"})
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).
This guards the Codex finding that the dashboard was deriving counts
from a confidence-sorted limit=500 fetch, hiding rows past the cap.
We don't need 500 rows in the test — a small corpus that exercises
the SQL-aggregate path is enough; the integrity-vs-dashboard equality
is the invariant being asserted.
"""
# Mix of statuses to exercise the by_status aggregate
create_memory(memory_type="knowledge", content="a")
create_memory(memory_type="knowledge", content="b", project="p06-polisher")
create_memory(memory_type="project", content="c-cand", status="candidate")
cand = create_memory(memory_type="project", content="d-cand", status="candidate")
# Invalidate one to seed an "invalid" bucket
from atocore.memory.service import invalidate_memory
target_id = cand.id
# Promote it first via direct DB so invalidate does flip a candidate
# to invalid via the service path (mirrors actual API trajectory).
invalidate_memory(target_id)
client = TestClient(app)
dash = client.get("/admin/dashboard").json()
assert dash["memories"]["active"] == 2
assert dash["memories"]["candidates"] == 1
assert dash["memories"]["by_status"]["invalid"] == 1
assert dash["memories"]["total"] == 4
assert dash["memories"]["by_project"].get("p06-polisher") == 1
# "(none)" bucket is the COALESCE label for empty/null project
assert "(none)" in dash["memories"]["by_project"]

View File

@@ -575,3 +575,121 @@ def test_expire_stale_candidates_keeps_reinforced(isolated_db):
assert mid not in expired assert mid not in expired
mem = _get_memory_by_id(mid) mem = _get_memory_by_id(mid)
assert mem["status"] == "candidate" assert mem["status"] == "candidate"
# ---------------------------------------------------------------------------
# Wave 1 (2026-04-29) — counts come from SQL, not from the top-N sample.
# Exposed by Codex audit when prod /admin/dashboard reported 315 active
# while /admin/integrity-check reported 1091. The dashboard was building
# its counts from a confidence-sorted limit=500 fetch.
# ---------------------------------------------------------------------------
def test_get_memory_count_summary_returns_full_table_aggregates(isolated_db):
"""Counts come from SQL aggregates, not a sampled fetch."""
from atocore.memory.service import (
create_memory,
get_memory_count_summary,
invalidate_memory,
)
# Create more rows than any reasonable sampling LIMIT so any
# LIMIT-based counter would visibly disagree with reality.
for i in range(120):
create_memory(
"knowledge",
f"fact-{i}",
project="p04-gigabit",
confidence=0.9,
status="active",
)
for i in range(7):
create_memory("knowledge", f"cand-{i}", status="candidate")
invalid_obj = create_memory("knowledge", "to-invalidate", status="active")
invalidate_memory(invalid_obj.id)
summary = get_memory_count_summary()
assert summary["total"] == 120 + 7 + 1
assert summary["by_status"]["active"] == 120
assert summary["by_status"]["candidate"] == 7
assert summary["by_status"]["invalid"] == 1
assert summary["active"]["total"] == 120
assert summary["active"]["by_type"] == {"knowledge": 120}
assert summary["active"]["by_project"] == {"p04-gigabit": 120}
def test_get_memory_returns_single_row_or_none(isolated_db):
from atocore.memory.service import create_memory, get_memory
mem = create_memory("knowledge", "single-row test")
fetched = get_memory(mem.id)
assert fetched is not None
assert fetched.id == mem.id
assert get_memory("non-existent-id") is None
def test_update_memory_can_change_project_with_canonicalization(
isolated_db, project_registry
):
"""update_memory(project=...) canonicalizes aliases and writes audit."""
project_registry(("p04-gigabit", ("p04", "gigabit")))
from atocore.memory.service import (
create_memory,
get_memory,
get_memory_audit,
update_memory,
)
mem = create_memory("knowledge", "retargetable fact", project="atocore")
ok = update_memory(mem.id, project="p04") # alias
assert ok is True
refreshed = get_memory(mem.id)
assert refreshed.project == "p04-gigabit" # canonical, not "p04"
audit_rows = get_memory_audit(mem.id, limit=10)
update_rows = [r for r in audit_rows if r.get("action") == "updated"]
assert update_rows, f"expected an updated audit row, got {audit_rows}"
head = update_rows[0]
assert head["before"]["project"] == "atocore"
assert head["after"]["project"] == "p04-gigabit"
def test_update_memory_project_unchanged_when_not_passed(isolated_db):
from atocore.memory.service import create_memory, get_memory, update_memory
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

@@ -0,0 +1,278 @@
"""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