diff --git a/packages/resource-explorer/docs/Backlog.md b/packages/resource-explorer/docs/Backlog.md index 286f6365f..bd7fdcb57 100644 --- a/packages/resource-explorer/docs/Backlog.md +++ b/packages/resource-explorer/docs/Backlog.md @@ -56,6 +56,24 @@ reconciliation changes one concrete thing in the inside-out plan's Phase C (fron moves from Analysis to a new Curate tab) β read both before implementing, not just one, or ask whoever picks this up next to re-derive current state rather than trust this paragraph's own age. +**Progress, 2026-09-03: Phases A, A.5, B, and now C are done.** Registry + `BlueprintMaterializer` +(`6a76e4b`); the live wire-safety measurement, confirmed `SolutionLinkingWire` is multi-link +(`86c214a`), with the project owner directly deciding to defer wire enqueueing to its own +follow-up rather than ship it with the confirmed risk (`7381782`); the blueprint verdict route +and member/child attachment via the outbox, scoped accordingly (`bce70ca`). **Phase C (frontend) +shipped at the reconciled placement** β a new `π Architecture Verdicts` sub-tab in Curate +(`_curateSubnavHtml`, mirroring `_automateSubnavHtml`), not Analysis's panel: candidate-blueprint +accept/reject with oversized-cluster and unmaterialized-member warnings, plus the existing +per-component verdict controls relocated there from Analysis, which is now genuinely read-only +(new `_archInteractiveMode` module flag gates the accept/reject/change affordances). Backend gap +closed alongside it: `_architecture_recovery_results` now carries `data.blueprints` via a new +`_candidate_blueprints_results` reader (10 unit tests). Live-verified against `sqlglot` through +the actual UI, not just the API β see item 6 below for a real, pre-existing materialization bug +found doing that verification. Full suite: 3652 passed, 92 skipped, 0 failed. **What's left: the +deferred wire-enqueueing follow-up**, whenever that's picked up. Backlog item 5 (below) tracks the +related `AnnotationReview` measurement gap a peer session surfaced reviewing Phase A.5, also not +yet done. + **2. `security_features` should report `skipped_by_design` β DONE, already on `main`.** Picked up 2026-09-03 and found already fully shipped, in two commits from before this Backlog entry was even opened: `a059e01` (2026-08-31, surveyor β emits a confidence-0 `skipped_by_design` annotation @@ -173,6 +191,76 @@ each doc's own heading list before touching anything, left all four alone. Dr.Eg survey-definition/outbox files and `archive/` were out of scope by the existing exclusion convention. +**5. `AnnotationReview` is an unmeasured relationship type queued for the outbox's create-blind +path β raised by a peer session, 2026-09-03, reviewing the Phase A.5 `SolutionLinkingWire` +measurement.** `egeria_outbox.py`'s own `_create_annotation_link` docstring already names it: +`AnnotationExtension` was measured live 2026-09-01 as UNI_LINK (safe to create-blind); the same +docstring says to re-measure before relying on that for *any other* relationship type, and names +`AnnotationReview` (Phase 3, not built) as one that has never been measured. Not fixed here β +nothing currently calls it, so there's no live bug β but it's the next candidate for exactly the +`scripts/arch-spike/measure_wire_multi_link.py` treatment (the return-value test, one call) +**before** anything in Phase 3 adds it to the outbox, not after. + +**6. `BlueprintMaterializer.materialize_blueprint_element` failed Egeria's request-body +validation on a real accept β found 2026-09-03 live-verifying Phase C above against `sqlglot` +through the actual UI. FIXED same day β root cause was a pyegeria gap, not an Egeria-side +rejection.** The `VALIDATION_ERROR_1` ("Request body failed validation") never reached the +network: `SolutionArchitect.create_solution_blueprint`'s own docstring documents a +`NewSolutionElementRequestBody` body (with `initialStatus: "DRAFT"`) for Draft-status creation, +but that class doesn't exist as a real pydantic model in pyegeria β confirmed by direct +inspection of both the installed version here (5.3.4.23) and the canonical egeria-python +checkout (6.1.9-dev; `create_solution_component` carries the identical, separately-confirmed +gap). The client validates every create-blueprint body against a bare +`TypeAdapter(NewElementRequestBody)`, whose `class` field is `Literal["NewElementRequestBody"]` +β any other value is a local pydantic `ValidationError` before any HTTP call, which +`BlueprintMaterializationError`'s message wording made read like a server-side rejection. +Logged as egeria-python's `PYEGERIA_ISSUES.md` ISSUE-84 (not patched there directly, per this +repo's policy β [[feedback_pyegeria_gaps_tracking]]); `egeria-python-65` messaged to review. +Fixed here by matching `ComponentMaterializer`'s existing, working body shape β `class: +"NewElementRequestBody"`, no `initialStatus` β so materialized blueprints are ACTIVE, not Draft, +same pre-existing gap `ComponentMaterializer` already has (its own follow-up, not new). Two new +regression tests added, including one that runs the actual body through pyegeria's real +`TypeAdapter(NewElementRequestBody)` rather than only asserting the mock-call shape β the kind +of test that would have caught this before it reached a live accept. + +**Follow-up, same day: full live materialization confirmed, and Draft-status achieved for real.** +The TLS-handshake timeout blocking live confirmation turned out to be `qs-metadata-store`'s +Postgres connection pool genuinely exhausted (`jdbcMaximumPoolSize` defaulting to 10, HikariPool +timeouts in the container logs) β not transient, and not caused by this work; fixed by +`egeria-workspaces-fs-14` bumping the pool to 50 and restarting `quickstart-egeria-main` +(coordinated first β see the session transcript for the cross-session exchange, including a +correction: an earlier "misattribution" claim about an unrelated wire-test coordination request +was wrong and retracted, the request was real but predated this session's own visible context +after a compaction). Once the platform came back: accepting the `.github` candidate blueprint +against `sqlglot` through the actual Curate UI's API materialized a real `SolutionBlueprint` +(`status: "partial"` β its two members correctly unmaterialized, per Decision 2's governance +boundary). Separately, `egeria-python-65` reviewed ISSUE-84 and found the real mechanism: +`contentStatus` is a plain field on `ReferenceableProperties`, settable inside `properties` on +the existing `NewElementRequestBody` β no separate request-body class was ever needed +(odpi/egeria-python#337). Confirmed live, twice, each independently read back by guid (not just +trusted from the create call's own return): a standalone probe (guid `1c6550a9-77cd-49ac-a791- +ffb9fa56f3fc`, superseded by a second probe `8755a5ac-7941-4880-a735-72010d779a73` to also check +the properties-block shape, both deleted after) confirmed `properties.contentStatus == "DRAFT"` +via `get_solution_blueprint_by_guid`; then a real accept through the actual Curate UI's own API +endpoint (`POST /api/curate/blueprint-verdicts/repo/sqlglot`, not a direct library call) +materialized guid `809025b5-cca9-4e9a-a2f7-3a5104138f67`, independently re-queried and confirmed +the same. Both checks: `properties.contentStatus` round-trips as `"DRAFT"` correctly; +`elementHeader.status` stays `"ACTIVE"` (a different, unrelated instance-status axis). +`BlueprintMaterializer` now sends `contentStatus: "DRAFT"` β architecture-recovery.md Β§10 Phase +2's "All at ContentStatus = Draft" is achieved for blueprints. `ComponentMaterializer`'s +identical gap (still `NewElementRequestBody` with no `contentStatus`) is NOT fixed β separate, +still-open follow-up. One new regression test (`test_content_status_draft`), 21/21 passing in +`test_blueprint_materializer.py`. + +**Also found and fixed in the same window, unrelated to Egeria**: a genuinely stale +`resource-explorer web` process (pid 74923/74921, running 20+ hours, holding no listening port β +invisible to `lsof -i :8810`/curl checks since only the current process had the port) β flagged +by a peer (`dwolfson-be`), confirmed via `ps`, force-killed after a plain `kill` failed to take. +Its background scheduler thread does start independently of the HTTP bind (confirmed by reading +`web/app.py`'s `_lifespan`), so this was briefly a real second outbox drainer β no damage found, +since every kind currently enqueued is convergent under double-processing and wires (the one +non-convergent kind) are deferred out of the outbox entirely. + **Deliberately closed, with a measurement behind each β do not reopen without re-measuring:** the LLM adjudicator (the doc lens reaches Milvus's real components more cheaply where documentation exists); milvus site ingestion (302-loops for every user agent including a diff --git a/packages/resource-explorer/docs/blueprint-materialization-plan.md b/packages/resource-explorer/docs/blueprint-materialization-plan.md index 656e9220c..2fb64a6cb 100644 --- a/packages/resource-explorer/docs/blueprint-materialization-plan.md +++ b/packages/resource-explorer/docs/blueprint-materialization-plan.md @@ -72,7 +72,7 @@ Rejected alternative: a wholly separate `architecture_blueprint_verdicts` table, - **Blueprint element itself**: `BlueprintMaterializer` mirrors `ComponentMaterializer`'s pattern exactly β local-cache check (`get_materialized_blueprint`) β qualifiedName search (`_find_element_guid`, byte-identical copy, same reasoning `ComponentMaterializer._find_element_guid`'s own docstring gives for not sharing one) β create. **Divergence from `ComponentMaterializer` #1**: `architecture-recovery.md` Β§10 Phase 2 says explicitly "**All at `ContentStatus = Draft`**" β so the blueprint create body uses **`class: "NewSolutionElementRequestBody"`**, not `NewElementRequestBody` (which pyegeria's own docstring says defaults to ACTIVE). `ComponentMaterializer` uses `NewElementRequestBody` today, i.e. it is NOT Draft β that's a pre-existing gap against the design doc this plan does not silently inherit; flagged as its own follow-up (see "Out of scope / follow-ups"), and `BlueprintMaterializer` gets this right from the start rather than copying the drift. - **Membership + wires must go through the outbox** (per Β§8.4: "a half-published blueprint is worse than none") rather than being written synchronously like `ComponentMaterializer` does for its single element, because a blueprint write is N+1+M elements (1 blueprint, N `CollectionMembership`, M `SolutionLinkingWire`) and a crash mid-write must leave a resumable, visible state, not a half-formed live blueprint with no record of what's missing. - **Enqueue order, mirroring `_link_evidence_outbox`'s two-phase shape**: `BlueprintMaterializer.materialize()` runs synchronously (find-or-create the blueprint element itself β same as `ComponentMaterializer`, since callers need the GUID back immediately to report success), *then* the route enqueues membership rows via a new `enqueue_blueprint_members(registry, entity_type, entity_slug, blueprint_guid, member_guids, *, run_id="")` (parallel to `enqueue_collection_members`, reusing `_create_collection_membership`'s **existing** `element_kind="collection_membership"` creator unchanged β `CollectionMembership` doesn't care whether the collection is an investigation's Collection or a `SolutionBlueprint`, it's still CollectionβReferenceable). No `depends_on_id` needed for these, since the blueprint GUID is already known synchronously before enqueueing (unlike the annotation-link case, where both ends might still be in-flight). -- **Wires β the real open risk, stated plainly rather than hidden behind a false "same as membership" claim**: a new `element_kind="solution_linking_wire"` creator cannot safely reuse the create-blind pattern, because a crash between "Egeria created the wire" and "the outbox row is marked done" means a retry creates a **second, distinct** wire relationship β silently, since multi-link means no 409. Two options considered: +- **Wires β the real open risk, stated plainly rather than hidden behind a false "same as membership" claim**: a new `element_kind="solution_linking_wire"` creator cannot safely reuse the create-blind pattern, because a crash between "Egeria created the wire" and "the outbox row is marked done" means a retry creates a **second, distinct** wire relationship β silently, since multi-link means no 409. **Sharper framing, from a peer session's review after the Phase A.5 measurement**: this isn't "wires need their own idempotency layer" as an add-on β `apply_element`'s create-blind safety for every other kind in the outbox rests on `qualifiedName` search-then-adopt (why `qualified_name` is `NOT NULL` on the table), and **relationships have no qualifiedName**. Wires would be the first element kind the outbox's central idempotency invariant structurally cannot cover at all, not the first one that merely lacks a layer. Two options considered: - (a) Ship it anyway with create-blind semantics and accept the risk, same posture the codebase already takes toward `claim_due_outbox_elements`'s documented "two drainers is a real hazard for annotation links" β acceptable only if it's written down with the same honesty that docstring uses, not asserted safe. - (b) Do a pre-check before create β but there is no `get_solution_linking_wires`/`get_all_related_elements`-style read exposed for this relationship in the pyegeria surface checked (`solution_architect.py`), so a real pre-check would need a new pyegeria method, which is out of scope for an RE-only plan. **Chosen: (a), with the risk written into the outbox row's own payload and into this plan's Verification section as a required live measurement before the wire creator ships** β exactly the same discipline the outbox module already applied to `CollectionMembership`/`AnnotationExtension` (measure live, then document), which for wires has NOT yet been done. Phase A explicitly does not include wire enqueueing until that measurement exists (see Phased build order, Phase A.5). @@ -276,18 +276,78 @@ def add_blueprint_verdict(entity_type: str, slug: str, body: BlueprintVerdictCre - `blueprint_materializer.py`: `qualified_name_for`, `materialize_blueprint_element` (with a fake/mocked `SolutionArchitect`, same test shape the existing `ComponentMaterializer` tests use β check for that test file before writing new ones, to match its mocking style), `resolve_member_guids`, `resolve_child_blueprint_guids`. - Tests: idempotency (repeat `materialize_blueprint_element` call finds cached/existing), Draft-status body shape (`NewSolutionElementRequestBody`, not `NewElementRequestBody`), member resolution correctly reports unmet members without raising. -**Phase A.5 β the wire-safety measurement, gating whether wire enqueueing ships at all.** -- A one-off live-server script (in `scripts/arch-spike/`, the existing home for this kind of measurement) calling `link_solution_linking_wire` twice with identical arguments against a throwaway pair of components, reading back via whatever pyegeria exposes to confirm duplication. **If it duplicates** (expected, given the docstring): document the risk in `_create_solution_linking_wire`'s docstring as written above, and decide with the project owner whether wire enqueueing ships with that known gap in Phase B or is deferred to its own follow-up entry β this is a project-owner call, not a design call this plan makes silently. - -**Phase B β route.** -- `egeria_outbox.py`: `OutboxClients.solution_architect`, `_create_solution_linking_wire`, `enqueue_blueprint_members`, `enqueue_blueprint_wires`, `_default_clients()` update. -- `curate.py`: `BlueprintVerdictCreate`, `list_blueprint_verdicts`, `add_blueprint_verdict`, `_materialize_blueprint_if_accepted`. -- Tests: route-level, with `BlueprintMaterializer`/outbox mocked β verdict recorded regardless of Egeria reachability (same non-fatal-but-visible contract as the component route); partial-member-materialization case returns the right shape; child-blueprint nesting resolves correctly for a two-level cluster fixture (reuse `tests/test_arch_clustering.py`'s own fixtures rather than inventing new ones). - -**Phase C β frontend, plus the backend reader gap it depends on.** -- `_architecture_recovery_results` gains `data.blueprints` (new backend step, per above). -- `index.html`: `_renderCandidateBlueprints`, blueprint verdict controls, oversized-cluster styling, unmaterialized-member warning state. -- Manual verification against a real survey run with clusters (e.g. `egeria_workspaces_git`, which already has candidate blueprints computed) β confirm the rendered tree matches `detail.children`/`detail.parent` linkage correctly, and that accepting a leaf cluster before its members are individually accepted correctly blocks/warns rather than silently doing nothing. +**Phase A.5 β the wire-safety measurement, gating whether wire enqueueing ships at all. DONE, +2026-09-03 β confirmed multi-link, duplicates on repeat calls.** +- Ran `scripts/arch-spike/measure_wire_multi_link.py` against the live quickstart platform, + coordinated with all 8 live peer sessions first (asked before writing, confirmed the earlier + republish batch's outbox was fully drained β 0 `running` rows β and the platform itself + functionally settled, not mid-restart). Two identical `link_solution_linking_wire()` calls + between the same two throwaway `SolutionComponent`s returned two **different** relationship + GUIDs (`f4179125-...`, `8f18b426-...`) β the definitive test (memory note + `reference_egeria_dedup_and_link_patterns`: "if attach returns an identifier it is multi-link; + if it returns nothing it is uni-link"), not inferred from the docstring. **Confirms the + docstring's own claim** rather than contradicting it β still worth having measured, since the + two relationships this codebase already relies on (`ResourceList`, `CollectionMembership`) were + each independently measured uni-link on 2026-08-25 despite looking like they might not be. +- The confirmatory read-back (`get_component_related_elements`) found 0 relationships β not a + contradiction, a wrong instrument: that method's response only carries component/blueprint/ + supply-chain/actor GUIDs, no generic wire list. The return-value test alone was sufficient and + is the one to trust. A future read-back attempt should try `get_relationships_with_property_value` + instead, un-tried here. +- Both throwaway components deleted via `delete_solution_component(guid, cascade_delete=True)` β + not a by-ends `detach_solution_linking_wire`, which would have removed both wires in one call and + corrupted the evidence before it could be read back. +- **Decided, 2026-09-03, asked directly of the project owner: defer wire enqueueing to its own + follow-up.** Phase B (below) ships blueprint materialization and member/child attachment; it + does NOT include `enqueue_blueprint_wires`/`_create_solution_linking_wire`/ + `OutboxClients.solution_architect`. A materialized blueprint renders without its wire diagram + until wire enqueueing lands separately β either once a real pyegeria pre-check exists + (`get_relationships_with_property_value`, noted above as un-tried but the likely candidate), or + the risk is judged acceptable on its own, later. See "Out of scope / follow-ups" below for the + tracked entry. + +**Phase B β route. Wires excluded per the Phase A.5 decision above β scoped to blueprint + +membership/child attachment only. DONE, 2026-09-03 (`bce70ca`).** +- `egeria_outbox.py`: `enqueue_blueprint_members` β built as planned. `OutboxClients.solution_architect`/`_create_solution_linking_wire`/`enqueue_blueprint_wires` were NOT built β out of scope per the deferred-wires decision, not a gap in this phase. +- `curate.py`: `BlueprintVerdictCreate`, `list_blueprint_verdicts`, `add_blueprint_verdict`, `_materialize_blueprint_if_accepted`, plus two small helpers the plan's prose implied but didn't name as their own functions β `_slug_to_scope_map` (the identity-mismatch fix from the plan's own Context section, factored out rather than inlined) and `_find_candidate_blueprint` (the candidate_blueprint lookup by perspective/cluster_name). +- **A real gap found by the tests, not scoped in the plan**: the *existing* `list_component_verdicts` route never filtered to `verdict_target="component"` β `get_component_verdicts` returns both mixed and leaves filtering to the caller, and only the new blueprint route filtered its own side. Fixed with the mirroring filter; practically harmless (a blueprint verdict's key can't realistically collide with a real `scope_locator`) but the endpoint's name promises component verdicts specifically. +- Tests: 20 route-level tests (`tests/test_web.py::TestCurateBlueprintVerdictsRouter`) with `BlueprintMaterializer` mocked, plus 2 direct unit tests for `enqueue_blueprint_members` (`tests/test_egeria_outbox.py`). Full suite: 3615 passed, 92 skipped, 0 failed. + +**Phase C β frontend, plus the backend reader gap it depends on. DONE, 2026-09-03 β built at +the corrected placement (the amendment above), not the original inside-Analysis one.** +- `_architecture_recovery_results` gains `data.blueprints` β new `_candidate_blueprints_results` + reader in `repo_survey_definition_adapter.py`, reading `kind="architecture_blueprints"` + findings (disambiguated by `(perspective, cluster_name)`, latest survey wins) and merging on + verdict/materialization state via `curate.py`'s own `f"{perspective}::{cluster_name}"` key. + 10 unit tests (`tests/test_candidate_blueprints_reader.py`). +- `index.html`: new Curate sub-tab (`π Architecture Verdicts`, repo-only β `_curateSubnavHtml`/ + `_setCurateSubTab`, mirroring `_automateSubnavHtml`'s pattern) with two sections: candidate + blueprints (accept/reject, oversized-cluster warning, unmaterialized-member/child warning on + a partial materialization) and components still awaiting a verdict (reuses the existing + `_archRow`/`_archVerdictControlsHtml` chain rather than duplicating it). Analysis's own + rendering of the same payload is now genuinely read-only: a new module-level + `_archInteractiveMode` flag (false by default, set true only for the Curate render pass) gates + both the accept/reject controls on an undecided component and the "change" affordance on an + already-decided one β Analysis shows verdicts as facts, Curate shows them as editable state. + **Not built as originally scoped**: a per-perspective nested tree with `detail.children` + indentation (item 2 of the original Frontend section) β built instead as a flat list of + (perspective, cluster_name) rows, since Curate's own candidate set is small enough per repo + that the extra nesting UI wasn't needed to stay scannable; `detail.children`/`detail.parent` + are still both carried in the payload (`bp.children.length`/`bp.parent` rendered inline) if a + future pass wants the tree view. +- Live-verified against `sqlglot` (18 components, 3 candidate blueprints) β accepted a blueprint + and a component through the actual UI (not just the API), confirmed the verdict badge and + "change" control render correctly in Curate, confirmed the same payload renders no accept/ + change controls in Analysis (checked via direct `_renderArchitectureRecoveryResults` calls in + both `_archInteractiveMode` states against the live payload). **Found, not caused by this + phase**: accepting the `.github` blueprint saved its verdict correctly but Egeria rejected the + materialization (`VALIDATION_ERROR_1` on `NewSolutionElementRequestBody`, "Request body failed + validation") β reported non-fatally via the existing toast pattern exactly as designed + (verdict saved regardless). This is a `BlueprintMaterializer.materialize_blueprint_element` + (Phase A) request-body bug, not a Phase C regression; tracked as Backlog item 6 rather than + fixed here, since fixing it means touching Phase A code under Phase C's own late-session + review, not extending Phase C's actual scope. +- Full suite run pending (background at time of writing) β see Backlog.md item 1 for the result. --- diff --git a/packages/resource-explorer/resource_explorer/egeria_outbox.py b/packages/resource-explorer/resource_explorer/egeria_outbox.py index 081d656bd..490d74578 100644 --- a/packages/resource-explorer/resource_explorer/egeria_outbox.py +++ b/packages/resource-explorer/resource_explorer/egeria_outbox.py @@ -529,6 +529,41 @@ def enqueue_collection_members( return row_ids +def enqueue_blueprint_members( + registry, entity_type: str, entity_slug: str, blueprint_guid: str, + member_guids: list[str], *, run_id: str = "", +) -> list[int]: + """One row per member (a `SolutionComponent` or a child `SolutionBlueprint` + β both are ordinary `CollectionMembership` attachments to the blueprint's + Collection) attached to a blueprint. docs/blueprint-materialization-plan.md + Decision 5/Phase B. + + Reuses `element_kind="collection_membership"` unchanged β same creator, + same idempotency basis as `enqueue_collection_members` above + (`CollectionMembership` measured uni-link 2026-08-25; `add_to_collection` + is a safe upsert regardless of which kind of Collection the blueprint + is). Parallel in shape to `enqueue_collection_members`, not a call to it, + because that function hardcodes `entity_type="investigation"` and this + caller's entity is a repo (or whatever `entity_type` the blueprint's own + resource is) β the member's own identity fields that function also + records don't apply here, since a blueprint member is always this same + resource's own component/child-blueprint, not a cross-resource reference. + + No `depends_on_id`: unlike annotation links, the blueprint GUID and every + member GUID passed in are already resolved synchronously before this is + called (Decision 5) β nothing here is still in flight. + """ + row_ids: list[int] = [] + for member_guid in member_guids: + qualified_name = f"CollectionMembership::{blueprint_guid}::{member_guid}" + row_ids.append(registry.enqueue_outbox_element( + entity_type, entity_slug, "collection_membership", qualified_name, + {"collection_guid": blueprint_guid, "member_guid": member_guid}, + run_id=run_id, + )) + return row_ids + + def record_drain_outcome( registry, summary: dict, entity_type: str = "", entity_slug: str = "", troubled_runs: dict[str, str] | None = None, diff --git a/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py b/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py index aa90631cb..61b554cf0 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py +++ b/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py @@ -25,16 +25,36 @@ (plan Decisions 1 and 2: accepting a blueprint does NOT implicitly accept or materialize its members/children). -**Divergence from `ComponentMaterializer` #1 (deliberate, not an oversight):** -`architecture-recovery.md` Β§10 Phase 2 says explicitly "All at -`ContentStatus = Draft`" for this projection. `ComponentMaterializer` uses -`NewElementRequestBody`, which pyegeria's own docstring says defaults the -element to ACTIVE β so despite the design doc, materialized SolutionComponents -are NOT Draft today (a pre-existing gap, flagged as its own follow-up in the -plan, not fixed here since ComponentMaterializer is out of scope for this -module). `BlueprintMaterializer` gets this right from the start: the create -body uses `class: "NewSolutionElementRequestBody"` with `initialStatus: -"DRAFT"`, not `NewElementRequestBody`. +**Draft-status divergence from `ComponentMaterializer` β achieved, on the +second attempt (Backlog.md item 6, 2026-09-03).** `architecture-recovery.md` +Β§10 Phase 2 says explicitly "All at `ContentStatus = Draft`" for this +projection. First attempt: `class: "NewSolutionElementRequestBody"` with a +top-level `initialStatus: "DRAFT"`, per `SolutionArchitect. +create_solution_blueprint`'s own docstring β **failed client-side, before +any HTTP call**: `NewSolutionElementRequestBody` is documented in that +docstring in two separate pyegeria versions (5.3.4.23 here, 6.1.9 in the +canonical egeria-python checkout) but was never a real pydantic model, nor +(per egeria-python's own review, ISSUE-84) ever a real Egeria API surface β +`initialStatus` appears in no `.http` ground-truth file either. The +installed client validates every create-blueprint body against a bare +`TypeAdapter(NewElementRequestBody)`, whose `class` field is a strict +`Literal["NewElementRequestBody"]`; any other value raised a +`PyegeriaInvalidParameterException` ("Request body failed validation") that +read like an Egeria-side rejection but wasn't one. Second attempt, the real +mechanism (egeria-python-65's finding, odpi/egeria-python#337): +`contentStatus` is a plain field on `ReferenceableProperties` (base of +`SolutionBlueprintProperties`), settable inside `properties` on the +existing, real `NewElementRequestBody` β no separate request-body class +needed. Confirmed live against the real platform (post +`jdbcMaximumPoolSize` fix): `properties.contentStatus` round-trips as +`"DRAFT"` on read-back. `elementHeader.status` stays `"ACTIVE"` regardless +β that's OMRS's own instance-status axis (soft-delete/active at the +repository level), a different thing from the content-maturity axis +`contentStatus` represents; the design doc's own wording ("ContentStatus = +Draft") names the field this now sends, not the instance status. +`ComponentMaterializer`'s own equivalent gap (still `NewElementRequestBody` +with no `contentStatus`) is not fixed here β out of scope for this module, +tracked as its own follow-up in the plan. **Idempotency, not a create-blind path.** Same reasoning as `ComponentMaterializer`: a repeat "accepted" call for the same @@ -212,6 +232,25 @@ def materialize_blueprint_element( "class": "SolutionBlueprintProperties", "qualifiedName": qualified_name, "displayName": display_name, + # architecture-recovery.md Β§10 Phase 2's "All at ContentStatus = + # Draft" β achieved 2026-09-03, second attempt. The first + # (class: "NewSolutionElementRequestBody" + top-level + # initialStatus) never validated at all (Backlog.md item 6, + # egeria-python PYEGERIA_ISSUES.md ISSUE-84). egeria-python-65's + # review found the real mechanism: `contentStatus` is a plain + # field on ReferenceableProperties (base of + # SolutionBlueprintProperties), settable at creation like any + # other property. Confirmed live against the real platform + # (qs-view-server, post jdbcMaximumPoolSize fix): a blueprint + # created with contentStatus: "DRAFT" round-trips it correctly + # on read-back (properties.contentStatus == "DRAFT"). Separately + # confirmed: elementHeader.status stays "ACTIVE" regardless β + # that's OMRS's own instance-status axis (soft-delete/active at + # the repository level), a different thing from the content- + # maturity axis contentStatus represents; the design doc's own + # wording ("ContentStatus = Draft") names exactly the field this + # sends, not the instance status. + "contentStatus": "DRAFT", } # Same provenance reasoning as ComponentMaterializer.materialize: # this is evidence ABOUT the proposal, not a typed property of the @@ -222,10 +261,39 @@ def materialize_blueprint_element( additional["oversized"] = "true" properties["additionalProperties"] = additional + # `class: "NewSolutionElementRequestBody"` + `initialStatus` (this + # method's original body, matching SolutionArchitect. + # create_solution_blueprint's own docstring) fails BEFORE any Egeria + # call β confirmed live 2026-09-03 accepting a real candidate + # blueprint (Backlog.md item 6) and by direct inspection: pyegeria + # 5.3.4.23 (this checkout) and 6.1.9 (the canonical egeria-python + # checkout) both document `NewSolutionElementRequestBody` in that + # docstring but never define it as a pydantic model β the installed + # `ServerClient._new_element_request_adapter` is a bare + # `TypeAdapter(NewElementRequestBody)`, whose `class_` field is + # `Literal["NewElementRequestBody"]`. Any other `class` value is a + # client-side `PyegeriaInvalidParameterException` + # ("Request body failed validation") raised locally by pydantic β + # it never reaches the network, so this was never an Egeria-side + # rejection despite how the caught exception reads. Logged as a + # pyegeria gap (egeria-python's PYEGERIA_ISSUES.md) rather than + # patched there directly, per this repo's own policy of routing + # egeria-python fixes through that tracker. + # + # The fix here is the same one ComponentMaterializer already lives + # with: `class: "NewElementRequestBody"`, no `initialStatus` (it + # isn't a real field on that model either β `extra="ignore"` would + # have silently dropped it even if the class name had validated). + # This means materialized blueprints are created ACTIVE, not DRAFT, + # same as materialized components β the module docstring's + # "Divergence from ComponentMaterializer #1" no longer holds; both + # materializers now share the identical gap, tracked as its own + # follow-up (Backlog.md, "Confidence classification"/Draft-status + # note) rather than fixed by inventing a body shape pyegeria can't + # validate. body = { - "class": "NewSolutionElementRequestBody", + "class": "NewElementRequestBody", "isOwnAnchor": True, - "initialStatus": "DRAFT", "properties": properties, } try: diff --git a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py index add57b08e..1d8a90cb7 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py +++ b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py @@ -2709,6 +2709,12 @@ def _architecture_recovery_results( # form. Offering there would re-open a decision the system made # correctly. "documentation": _doc_ingestion_state(registry, slug), + # Phase C (blueprint-materialization-plan.md) β candidate blueprints + # proposed by clustering.py, each with its own accept/reject verdict + # and materialization state. Read once here so the frontend's Curate + # tab doesn't need a second route; Analysis's read-only rendering of + # this same payload simply doesn't render this key. + "blueprints": _candidate_blueprints_results(registry, slug), "raw_component_count": sum(1 for c in all_components if not c.get("structural")), "structural_node_count": sum(1 for c in all_components if c.get("structural")), "run_outcomes": run_outcomes, @@ -2738,6 +2744,71 @@ def _json_or_empty(raw) -> dict: return {} +def _candidate_blueprints_results(registry, slug: str) -> list[dict]: + """The `data.blueprints` key for `_architecture_recovery_results` β + Phase C's frontend-facing read of clustering.py's proposals (Backlog.md + item 1 / blueprint-materialization-plan.md), so Curate can offer an + accept/reject control on a candidate blueprint the same way component + verdicts already work. + + Deliberately its own reader rather than folded into the component loop + above: candidate_blueprint findings share scope_locator="" (persist.py's + `_persist_blueprints`) and are disambiguated by (perspective, label) + within that one scope, not by scope_locator the way components are β a + genuinely different join key, so merging the two loops would mean + threading a second identity scheme through code that already has one. + + One row per (perspective, cluster_name), latest survey wins β same + "latest row for this identity" rule every other reader here uses. + Verdict/materialized state merged on via curate.py's own key shape + (`f"{perspective}::{cluster_name}"`) so the frontend never has to + recompute that join. + """ + from resource_explorer.surveyors.arch_recovery.persist import BLUEPRINT_KIND + + rows = [r for r in registry.query_findings(slug, BLUEPRINT_KIND, "") + if r["check_name"] == "candidate_blueprint"] + by_key: dict[tuple[str, str], dict] = {} + for r in rows: + detail = _json_or_empty(r.get("detail_json")) + perspective = detail.get("perspective", "") + name = detail.get("name", "") + if not perspective or not name: + continue + key = (perspective, name) + existing = by_key.get(key) + if existing is None or r["surveyed_at"] >= existing["row"]["surveyed_at"]: + by_key[key] = {"row": r, "detail": detail} + + verdicts = registry.get_component_verdicts("repo", slug) + materialized = registry.get_materialized_blueprints("repo", slug) + + blueprints = [] + for (perspective, name), entry in sorted(by_key.items()): + r, detail = entry["row"], entry["detail"] + vkey = f"{perspective}::{name}" + blueprints.append({ + "perspective": perspective, + "cluster_name": name, + "summary": r.get("summary", ""), + "signal": detail.get("signal"), + "carrier": detail.get("carrier"), + "composed_into": detail.get("composed_into"), + "size": detail.get("size", 0), + "members": detail.get("members") or [], + "children": detail.get("children") or [], + "parent": detail.get("parent", ""), + "oversized": bool(detail.get("oversized")), + "target_size": detail.get("target_size"), + "run_scope": detail.get("run_scope", ""), + "surveyed_at": r.get("surveyed_at", ""), + "verdict": _verdict_view(verdicts.get(vkey)), + "materialized": _materialized_view(materialized.get(vkey)), + }) + return blueprints + + + def _architecture_recovery_trend(registry, slug: str) -> list[dict]: # Two independent counters (detect/coupling never share a metric_name β # see persist.py's run-summary comment) merged by surveyed_at into one diff --git a/packages/resource-explorer/resource_explorer/web/routes/curate.py b/packages/resource-explorer/resource_explorer/web/routes/curate.py index d999126d5..0b4d64d73 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/curate.py +++ b/packages/resource-explorer/resource_explorer/web/routes/curate.py @@ -276,8 +276,19 @@ class ComponentVerdictCreate(BaseModel): def list_component_verdicts(entity_type: str, slug: str) -> dict[str, dict]: """{scope_locator: latest verdict} for every component this resource has a curator verdict on β the same shape merged onto each component by - _architecture_recovery_results.""" - return _registry().get_component_verdicts(entity_type, slug) + _architecture_recovery_results. + + Filters out verdict_target='blueprint' rows (added 2026-09-03, Phase B): + get_component_verdicts' own docstring says it returns both mixed and + leaves filtering to the caller β this route's name and its one existing + consumer (_architecture_recovery_results, keyed by scope_locator) both + promise component verdicts specifically, so a blueprint verdict's key + (f"{perspective}::{cluster_name}") appearing here would be a surprising, + if practically harmless, leak. Mirrors list_blueprint_verdicts' own + client-side filter in the other direction. + """ + all_verdicts = _registry().get_component_verdicts(entity_type, slug) + return {k: v for k, v in all_verdicts.items() if v.get("verdict_target") != "blueprint"} def _materialize_if_accepted(registry: ProjectRegistry, entity_type: str, slug: str, @@ -350,3 +361,164 @@ def component_verdict_history(entity_type: str, slug: str, scope_locator: str) - a query param (not a path segment) since it's a path prefix and routinely contains slashes.""" return _registry().list_component_verdict_history(entity_type, slug, scope_locator) + + +# ββ Blueprint verdicts ββββββββββββββββββββββββββββββββββββββββββββββββββββββ +# +# docs/blueprint-materialization-plan.md β the same report-then-curate shape +# as component verdicts above, one level up: a candidate_blueprint finding +# (a clustering.py proposal, not yet a real Egeria element) is what a +# curator accepts or rejects. Reuses architecture_component_verdicts (see +# the plan's Decision 3) rather than a parallel table β verdict_target +# distinguishes the two, and scope_locator is repurposed to hold +# f"{perspective}::{cluster_name}" since a blueprint has none of its own. +# +# Wires are deliberately NOT enqueued here (project-owner decision, +# 2026-09-03, after Phase A.5's live measurement confirmed +# SolutionLinkingWire duplicates on retry) β a materialized blueprint +# attaches its member components and child blueprints but renders without +# its wire diagram until that's built as its own follow-up. + +class BlueprintVerdictCreate(BaseModel): + perspective: str + cluster_name: str + verdict: str # "accepted" | "rejected" + note: str = "" + + +@router.get("/blueprint-verdicts/{entity_type}/{slug}") +def list_blueprint_verdicts(entity_type: str, slug: str) -> dict[str, dict]: + """{f"{perspective}::{cluster_name}": latest verdict} β filters + list_component_verdicts' full result to verdict_target='blueprint' + client-side, per the plan's Decision 3 (one table, not a second query + parameter for what is currently one filter).""" + all_verdicts = _registry().get_component_verdicts(entity_type, slug) + return {k: v for k, v in all_verdicts.items() if v.get("verdict_target") == "blueprint"} + + +def _slug_to_scope_map(registry: ProjectRegistry, slug: str) -> dict[str, str]: + """component slug -> scope_locator, for every currently-live + architecture_recovery component finding. Deliberately its own copy of + _architecture_recovery_results' identical loop + (repo_survey_definition_adapter.py) rather than a shared import β same + reasoning ComponentMaterializer._find_element_guid gives for not + sharing with EgeriaPublisher: a small, self-contained piece of logic + duplicated once is safer here than a new cross-module coupling for one + caller. This is THE fix for the plan's own named identity-mismatch + trap: clustering keys a blueprint's members by component slug; verdicts + and materialization are keyed by scope_locator. Looking a slug up + directly in get_materialized_component()/get_component_verdicts() + without going through this map first silently finds nothing for every + member.""" + import json as _json + out: dict[str, str] = {} + for scope in registry.query_finding_scopes(slug, "architecture_recovery", check_name="component"): + rows = [r for r in registry.query_findings_all_runs(slug, "architecture_recovery", scope) + if r["check_name"] == "component"] + if not rows: + continue + latest = max(rows, key=lambda r: r["surveyed_at"]) + detail = _json.loads(latest.get("detail_json") or "{}") if latest.get("detail_json") else {} + if detail.get("slug"): + out[detail["slug"]] = scope + return out + + +def _find_candidate_blueprint(registry: ProjectRegistry, slug: str, + perspective: str, cluster_name: str) -> dict | None: + """The latest candidate_blueprint finding for (perspective, cluster_name), + or None if it's gone (e.g. re-clustered since the review surface last + loaded). clustering.py's findings all share scope_locator="" β every + cluster for a repo is disambiguated by label/detail.name within that one + scope, per the plan's own Context section.""" + import json as _json + from resource_explorer.surveyors.arch_recovery.persist import BLUEPRINT_KIND + rows = [r for r in registry.query_findings(slug, BLUEPRINT_KIND, "") + if r["check_name"] == "candidate_blueprint"] + matches = [] + for r in rows: + detail = _json.loads(r.get("detail_json") or "{}") if r.get("detail_json") else {} + if detail.get("perspective") == perspective and detail.get("name") == cluster_name: + matches.append((r, detail)) + if not matches: + return None + r, detail = max(matches, key=lambda pair: pair[0]["surveyed_at"]) + return detail + + +def _materialize_blueprint_if_accepted(registry: ProjectRegistry, entity_type: str, slug: str, + perspective: str, cluster_name: str, verdict: str) -> dict | None: + """Same non-fatal-but-visible shape as _materialize_if_accepted above β + the verdict itself is already saved and real regardless of whether this + succeeds. Only 'accepted' triggers a write; only entity_type='repo' is + wired (architecture_recovery/clustering's only kind today). + + Partial progress is real progress (Decision 2/step 4 of the plan): an + unmaterialized member or child is reported, not treated as a reason to + enqueue nothing. + """ + if verdict != "accepted" or entity_type != "repo": + return None + cluster = _find_candidate_blueprint(registry, slug, perspective, cluster_name) + if cluster is None: + return {"status": "error", + "error": "no candidate_blueprint finding for this (perspective, cluster_name) " + "to materialize"} + + from resource_explorer.surveyors.arch_recovery.blueprint_materializer import ( + BlueprintMaterializationError, BlueprintMaterializer, + ) + materializer = BlueprintMaterializer(registry=registry) + try: + result = materializer.materialize_blueprint_element( + entity_type, slug, perspective, cluster_name, + display_name=cluster.get("name", cluster_name), + oversized=bool(cluster.get("oversized")), + ) + except BlueprintMaterializationError as exc: + return {"status": "error", "error": str(exc)} + + blueprint_guid = result["guid"] + slug_to_scope = _slug_to_scope_map(registry, slug) + member_guids, unmaterialized_members = materializer.resolve_member_guids( + registry, entity_type, slug, cluster.get("members") or [], slug_to_scope, + ) + child_guids, unmaterialized_children = materializer.resolve_child_blueprint_guids( + registry, entity_type, slug, perspective, cluster.get("children") or [], + ) + + from resource_explorer.egeria_outbox import enqueue_blueprint_members + all_member_guids = list(member_guids.values()) + list(child_guids.values()) + row_ids = enqueue_blueprint_members(registry, entity_type, slug, blueprint_guid, all_member_guids) + + result["enqueued_membership_rows"] = len(row_ids) + if unmaterialized_members: + result["unmaterialized_members"] = unmaterialized_members + if unmaterialized_children: + result["unmaterialized_children"] = unmaterialized_children + if unmaterialized_members or unmaterialized_children: + result["status"] = "partial" + return result + + +@router.post("/blueprint-verdicts/{entity_type}/{slug}") +def add_blueprint_verdict(entity_type: str, slug: str, body: BlueprintVerdictCreate) -> dict: + if not body.perspective.strip() or not body.cluster_name.strip(): + raise HTTPException(status_code=400, detail="perspective and cluster_name must not be empty") + if body.verdict not in ProjectRegistry.BLUEPRINT_VERDICTS: + raise HTTPException( + status_code=400, + detail=f"verdict must be one of {sorted(ProjectRegistry.BLUEPRINT_VERDICTS)}, got {body.verdict!r}", + ) + registry = _registry() + scope_locator = f"{body.perspective}::{body.cluster_name}" + verdict = registry.record_component_verdict( + entity_type, slug, scope_locator, body.verdict, "", body.note, + verdict_target="blueprint", + ) + materialization = _materialize_blueprint_if_accepted( + registry, entity_type, slug, body.perspective, body.cluster_name, body.verdict, + ) + if materialization is not None: + verdict["materialization"] = materialization + return verdict diff --git a/packages/resource-explorer/resource_explorer/web/static/index.html b/packages/resource-explorer/resource_explorer/web/static/index.html index 3c2942792..2fc2b962c 100644 --- a/packages/resource-explorer/resource_explorer/web/static/index.html +++ b/packages/resource-explorer/resource_explorer/web/static/index.html @@ -3695,6 +3695,15 @@ return best; } let _archUidSeq = 0; +// Read-only by default (Analysis's own call site never sets this) β +// per docs/curate-evidence-based-decisions-plan.md Β§D item 10, Analysis stays a +// read-only evidence display; only Curate's own rendering pass (below) turns +// this on so accept/reject/retype controls appear. A module flag rather than +// threading a param through every nested render function in this chain +// (_archPanesHtml/_archRow/_archVerdictBadgeHtml) β same reasoning _archUidSeq +// above already uses for render-scoped state. +let _archInteractiveMode = false; + function _archShowPerspective(uid, persp) { document.querySelectorAll('[data-arch-pane="' + uid + '"]').forEach(el => { el.classList.toggle('hidden', el.dataset.archPersp !== persp); @@ -3765,7 +3774,10 @@ // renders no verdict UI rather than wiring buttons to a broken URL. const verdictArea = !slug ? '' : (c.verdict ? _archVerdictBadgeHtml(rowUid, slug, c.path, c.verdict, c.materialized) - : _archVerdictControlsHtml(rowUid, slug, c.path)); + // No verdict yet, read-only context (Analysis): render nothing rather + // than an inert-looking control set β the "Accept"/"Reject" affordance + // only exists in Curate. + : (_archInteractiveMode ? _archVerdictControlsHtml(rowUid, slug, c.path) : '')); return `