From f1c7ff30659a3c9feb554f8f9dbb70b8fcd62fa8 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 9 Jul 2026 00:40:34 +0300 Subject: [PATCH 1/2] Add safe workflow topology repair Proposal-ID: 0208 Spec-Impact: behavior | schema | lifecycle | tooling --- .../ProductWorkspacePilots.md | 10 +- .../0208_safe_workflow_topology_repair.md | 6 + ...duct_workspace_graph_versioning_roadmap.md | 21 + .../0208_safe_workflow_topology_repair.md | 104 +++++ tests/test_idea_to_spec_answer_rerun_input.py | 46 ++ ...est_idea_to_spec_clarification_requests.py | 7 +- ...test_idea_to_spec_rerun_materialization.py | 71 +++ tests/test_idea_to_spec_rerun_preview.py | 156 +++++++ tools/README.md | 6 + tools/idea_to_spec_answer_rerun_input.py | 12 + tools/idea_to_spec_clarification_requests.py | 49 +++ tools/idea_to_spec_rerun_materialization.py | 50 +++ tools/idea_to_spec_rerun_preview.py | 405 ++++++++++++++++++ tools/proposal_promotion_registry.json | 13 + tools/proposal_runtime_registry.json | 68 +++ tools/real_idea_answer_authoring.py | 8 + 16 files changed, 1028 insertions(+), 4 deletions(-) create mode 100644 docs/archive/proposal_sources/0208_safe_workflow_topology_repair.md create mode 100644 docs/proposals/0208_safe_workflow_topology_repair.md diff --git a/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md b/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md index 1cbd0cd5..480483ef 100644 --- a/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md +++ b/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md @@ -713,9 +713,13 @@ structural-depth observations can now become ordinary `idea_to_spec_clarification_requests` with `event_storming_hints.*` targets. Accepted typed `entries[]` answers flow through the existing review-only rerun overlay and can increase actor, command, event, policy, or constraint depth. -Workflow-topology depth remains diagnostic until a patch-capable topology -overlay exists. The loop does not introduce a score, prompt-agent execution, -canonical spec mutation, Ontology writes, or promotion authority. +Proposal `0208` adds the patch-capable workflow-topology overlay for the +previously diagnostic `workflow_edge_count` case. Flat workflow topology can +now ask for typed `event_storming_hints.workflow_relations` answers. Rerun +preview validates relation names and source/target event-storming refs before +materialization copies only review-only, non-dependency workflow edges into the +candidate graph preview. The loop does not introduce a score, prompt-agent +execution, canonical spec mutation, Ontology writes, or promotion authority. The next slices are Platform awareness and a `local-subscription-control` demo pass. Platform may use the report as an explanatory preflight signal before diff --git a/docs/archive/proposal_sources/0208_safe_workflow_topology_repair.md b/docs/archive/proposal_sources/0208_safe_workflow_topology_repair.md new file mode 100644 index 00000000..0ab1b0ff --- /dev/null +++ b/docs/archive/proposal_sources/0208_safe_workflow_topology_repair.md @@ -0,0 +1,6 @@ +# 0208 Safe Workflow Topology Repair + +Source draft for proposal `0208`. + +See `docs/proposals/0208_safe_workflow_topology_repair.md` for the realized +proposal text and acceptance criteria. diff --git a/docs/product_workspace_graph_versioning_roadmap.md b/docs/product_workspace_graph_versioning_roadmap.md index ff9e3ca7..108851bd 100644 --- a/docs/product_workspace_graph_versioning_roadmap.md +++ b/docs/product_workspace_graph_versioning_roadmap.md @@ -1593,6 +1593,27 @@ event-storming entries rather than patching existing command/event relations. The requests are `review_required`, not blocking, and accepted typed answers feed the existing review-only rerun overlay as event-storming hints. +## Safe Workflow Topology Repair + +Status: implemented producer-side in proposal `0208`; SpecSpace can layer typed +relation controls over the same request/answer/rerun contracts. + +Proposal `0208` closes the deferred workflow-topology gap from proposal `0207`. +When `workflow_edge_count` is zero and the current event-storming intake already +has linkable entries, SpecGraph can emit one `workflow_topology_gap` +clarification request targeting `event_storming_hints.workflow_relations`. +Accepted answers contain typed `relations[]` such as command-event, +actor-command, event-policy, event-constraint, policy-command, or +constraint-command links. + +The rerun preview validates relation names and source/target kinds against the +current event-storming preview before patching relation fields. Rerun +materialization copies only validated `review_only` edges with +`materialization_dependency: false` into the candidate graph preview, so +workflow topology remains review evidence and never becomes canonical spec +mutation, Ontology acceptance, Git authority, or YAML implementation +dependency. + ## Real Idea Entry Request Import Status: implemented producer-side in proposal `0202`; downstream UI/Platform diff --git a/docs/proposals/0208_safe_workflow_topology_repair.md b/docs/proposals/0208_safe_workflow_topology_repair.md new file mode 100644 index 00000000..b5997bae --- /dev/null +++ b/docs/proposals/0208_safe_workflow_topology_repair.md @@ -0,0 +1,104 @@ +# 0208 Safe Workflow Topology Repair + +## Status + +Draft / producer-side topology repair slice. + +## Summary + +Proposal `0207` lets shallow `candidate_structure_depth` observations ask for +missing event-storming entries, but it intentionally leaves +`workflow_edge_count = 0` diagnostic-only because the rerun overlay only +appended entries. + +This slice adds a bounded, typed relation overlay for workflow topology repair. +It lets accepted clarification answers connect already-published +event-storming entries through review-only relations such as actor-command, +command-event, event-policy, event-constraint, policy-command, and +constraint-command links. + +## Decision + +When `groups.candidate_structure_depth.workflow_edge_count = 0` and the +maturity report has a loaded event-storming intake source with enough existing +entries to link, `tools/idea_to_spec_clarification_requests.py` may emit: + +- `kind: workflow_topology_gap`; +- `target_ref: event_storming_hints.workflow_relations`; +- `suggested_answer_shape: event_storming_relation[]`; +- `suggested_actions: ["answer_question", "defer_candidate"]`. + +Accepted answers feed `tools/idea_to_spec_answer_rerun_input.py` as: + +```json +{ + "intake_overlay": { + "event_storming_hints": [ + { + "target_ref": "event_storming_hints.workflow_relations", + "value": { + "workflow_relations": [ + { + "relation": "command_emits_event", + "source_ref": "command.record-item", + "target_ref": "event.item-recorded" + } + ] + } + } + ] + } +} +``` + +`tools/idea_to_spec_rerun_preview.py` validates every relation against the +current event-storming preview before applying it: + +- `actor_triggers_command`: actor -> command; +- `command_emits_event`: command -> domain event; +- `event_informs_policy`: domain event -> policy; +- `event_informs_constraint`: domain event -> constraint; +- `constraint_applies_to_command`: constraint -> command; +- `policy_applies_to_command`: policy -> command. + +Unknown, missing, stale, or wrong-kind refs become `review_required` findings. +Valid relation hints patch only event-storming relation fields +(`actor_refs`, `produces_event_refs`, `trigger_event_refs`, `command_refs`) and +emit `workflow_topology_preview.workflow_edges`. + +`tools/idea_to_spec_rerun_materialization.py` may copy validated workflow edges +into the review-only `candidate_graph_preview` when every edge has +`review_only: true` and `materialization_dependency: false`. + +## Authority Boundary + +This slice is review-only. It does not: + +- change Metrics schemas or validator semantics; +- create a structural-depth score; +- turn workflow topology into a promotion, approval, or Pre-SIB gate; +- infer fuzzy topology links; +- mutate event-storming intake source artifacts; +- mutate candidate or canonical specs; +- write Ontology packages or lockfiles; +- accept ontology terms; +- execute prompt agents; +- create Git branches, commits, or pull requests; +- publish read models. + +Workflow topology edges are product-review evidence. They must not become YAML +`depends_on` or implementation dependencies. + +## Acceptance Criteria + +- Zero workflow-edge count can create one review-required topology + clarification request only when existing entries can be linked. +- Accepted `relations[]` answers are preserved in the rerun input overlay. +- Rerun preview rejects unknown relations, missing refs, and wrong source/target + kinds. +- Rerun preview patches relation fields only after validation. +- Rerun preview emits review-only workflow topology edges. +- Rerun materialization copies only review-only, non-dependency workflow edges + into the candidate graph preview. +- Metrics remains objective telemetry; topology repair is producer/consumer + behavior. diff --git a/tests/test_idea_to_spec_answer_rerun_input.py b/tests/test_idea_to_spec_answer_rerun_input.py index 696c6f56..3d16b7ff 100644 --- a/tests/test_idea_to_spec_answer_rerun_input.py +++ b/tests/test_idea_to_spec_answer_rerun_input.py @@ -320,6 +320,52 @@ def test_answer_rerun_input_accepts_array_valued_event_storming_answer() -> None assert report["summary"]["intake_overlay_count"] == 1 +def test_answer_rerun_input_preserves_workflow_relation_hints() -> None: + module = load_module( + RERUN_TOOL_PATH, + "idea_to_spec_answer_rerun_input_workflow_relations_test", + ) + answer_report = ready_answer_report_with_answer( + answer_kind="answer_question", + value={ + "relations": [ + { + "relation": "command_emits_event", + "source_ref": "command.record-pantry-item", + "target_ref": "event.pantry-item-recorded", + } + ] + }, + request_kind="workflow_topology_gap", + target_ref="event_storming_hints.workflow_relations", + ) + + report = module.build_idea_to_spec_answer_rerun_input( + answers_report=answer_report, + ) + + hints = report["rerun_input_overlay"]["intake_overlay"]["event_storming_hints"] + assert hints == [ + { + "answer_kind": "answer_question", + "request_id": "clarification.repair.repair-review-unresolved-gaps", + "request_kind": "workflow_topology_gap", + "target_artifact": "runs/candidate_spec_graph.json", + "target_ref": "event_storming_hints.workflow_relations", + "value": { + "workflow_relations": [ + { + "relation": "command_emits_event", + "source_ref": "command.record-pantry-item", + "target_ref": "event.pantry-item-recorded", + } + ] + }, + } + ] + assert report["summary"]["intake_overlay_count"] == 1 + + def test_answer_rerun_input_routes_non_ontology_reject_to_candidate_hints() -> None: module = load_module( RERUN_TOOL_PATH, diff --git a/tests/test_idea_to_spec_clarification_requests.py b/tests/test_idea_to_spec_clarification_requests.py index ebb3877a..69ee7c6c 100644 --- a/tests/test_idea_to_spec_clarification_requests.py +++ b/tests/test_idea_to_spec_clarification_requests.py @@ -348,7 +348,7 @@ def test_clarification_requests_adds_depth_driven_event_storming_requests() -> N assert report["readiness"]["ready"] is False assert report["readiness"]["review_state"] == "clarification_review_required" assert report["summary"]["blocking_request_count"] == 0 - assert report["summary"]["review_required_request_count"] == 2 + assert report["summary"]["review_required_request_count"] == 3 actor_request = request_by_id(report, "clarification.depth.actors") assert actor_request["kind"] == "event_storming_gap" assert actor_request["severity"] == "review_required" @@ -359,6 +359,11 @@ def test_clarification_requests_adds_depth_driven_event_storming_requests() -> N assert actor_request["source_findings"][0]["evidence"]["source_ref"] == ( "runs/custom/idea_maturity_metrics_report.json#groups.candidate_structure_depth.actor_count" ) + topology_request = request_by_id(report, "clarification.depth.workflow-topology") + assert topology_request["kind"] == "workflow_topology_gap" + assert topology_request["target_ref"] == "event_storming_hints.workflow_relations" + assert topology_request["suggested_answer_shape"] == "event_storming_relation[]" + assert topology_request["source_findings"][0]["evidence"]["proposal_id"] == "0208" def test_clarification_requests_missing_depth_group_is_not_faked() -> None: diff --git a/tests/test_idea_to_spec_rerun_materialization.py b/tests/test_idea_to_spec_rerun_materialization.py index 43f53783..cfa3794e 100644 --- a/tests/test_idea_to_spec_rerun_materialization.py +++ b/tests/test_idea_to_spec_rerun_materialization.py @@ -181,6 +181,54 @@ def candidate_graph_artifact() -> dict[str, object]: } +def candidate_graph_for_workflow_topology() -> dict[str, object]: + graph = candidate_graph_artifact() + graph["nodes"] = [ + { + "id": "candidate-spec.product-boundary", + "kind": "product_boundary", + "source_event_refs": [], + "gaps": [], + }, + { + "id": "candidate-spec.record-pantry-item", + "kind": "behavior_requirement", + "source_event_refs": ["command.record-pantry-item"], + "gaps": [], + }, + ] + graph["edges"] = [] + return graph + + +def rerun_preview_with_workflow_topology() -> dict[str, object]: + preview = rerun_preview_artifact() + preview["rerun_preview"]["workflow_topology_preview"] = { + "workflow_edges": [ + { + "id": "edge.command.record-pantry-item.pantry-item-recorded", + "from": "candidate-spec.record-pantry-item", + "to": "candidate-spec.product-boundary", + "relation": "command_emits_event", + "source_event_refs": [ + "command.record-pantry-item", + "event.pantry-item-recorded", + ], + "command_ref": "command.record-pantry-item", + "event_ref": "event.pantry-item-recorded", + "review_only": True, + "materialization_dependency": False, + } + ], + "workflow_edge_count": 1, + "review_only": True, + "materialization_dependency": False, + } + preview["rerun_preview"]["ontology_gap_preview"]["resolved_ontology_gaps"] = [] + preview["rerun_preview"]["ontology_gap_preview"]["unresolved_ontology_gaps"] = [] + return preview + + def candidate_graph_with_candidate_gaps() -> dict[str, object]: graph = candidate_graph_artifact() graph["nodes"] = [ @@ -280,6 +328,29 @@ def test_rerun_materialization_removes_resolved_candidate_gap_in_preview() -> No assert "private prompt trace" not in json.dumps(report) +def test_rerun_materialization_merges_review_only_workflow_topology_edges() -> None: + module = load_module() + + report = module.build_idea_to_spec_rerun_materialization( + rerun_preview=rerun_preview_with_workflow_topology(), + candidate_graph=candidate_graph_for_workflow_topology(), + ) + + assert report["readiness"]["ready"] is True + preview = report["materialization_preview"]["candidate_graph_preview"] + edges = preview["edges"] + assert len(edges) == 1 + edge = edges[0] + assert edge["relation"] == "command_emits_event" + assert edge["review_only"] is True + assert edge["materialization_dependency"] is False + assert preview["summary"]["edge_count"] == 1 + delta = report["materialization_preview"]["delta"] + assert delta["added_workflow_topology_edge_count"] == 1 + assert delta["added_workflow_topology_edges"][0]["event_ref"] == ("event.pantry-item-recorded") + assert report["summary"]["removed_gap_count"] == 0 + + def test_rerun_materialization_preserves_node_scope_for_duplicate_candidate_gap_ids() -> None: module = load_module() candidate_graph = copy.deepcopy(candidate_graph_artifact()) diff --git a/tests/test_idea_to_spec_rerun_preview.py b/tests/test_idea_to_spec_rerun_preview.py index 9dba5cfd..7ff2c1df 100644 --- a/tests/test_idea_to_spec_rerun_preview.py +++ b/tests/test_idea_to_spec_rerun_preview.py @@ -96,6 +96,88 @@ def candidate_graph_artifact() -> dict[str, object]: } +def workflow_topology_intake() -> dict[str, object]: + intake = intake_artifact() + intake["event_storming"] = { + "actors": [{"id": "actor.household-member", "name": "Household member"}], + "domain_events": [ + {"id": "event.pantry-item-recorded", "statement": "Pantry item recorded"} + ], + "commands": [{"id": "command.record-pantry-item", "name": "Record pantry item"}], + "policies": [{"id": "policy.expiration-reminder", "name": "Expiration reminder"}], + "external_systems": [], + "constraints": [], + "risks": [], + "assumptions": [], + "vocabulary_questions": [], + } + return intake + + +def workflow_topology_candidate_graph() -> dict[str, object]: + graph = candidate_graph_artifact() + graph["nodes"] = [ + { + "id": "candidate-spec.product-boundary", + "kind": "product_boundary", + "source_event_refs": [], + "gaps": [], + }, + { + "id": "candidate-spec.record-pantry-item", + "kind": "behavior_requirement", + "source_event_refs": ["command.record-pantry-item"], + "gaps": [], + }, + { + "id": "candidate-spec.expiration-reminder", + "kind": "policy_constraint", + "source_event_refs": ["policy.expiration-reminder"], + "gaps": [], + }, + ] + return graph + + +def rerun_input_with_workflow_relations(relations: list[dict[str, object]]) -> dict[str, object]: + return { + "artifact_kind": "idea_to_spec_answer_rerun_input", + "schema_version": 1, + "contract_ref": "specgraph.idea-to-spec.answer-rerun-input.v0.1", + "canonical_mutations_allowed": False, + "tracked_artifacts_written": False, + "readiness": {"ready": True, "review_state": "rerun_input_ready"}, + "rerun_input_overlay": { + "intake_overlay": { + "active_frame_hints": [], + "event_storming_hints": [ + { + "request_id": "clarification.depth.workflow-topology", + "request_kind": "workflow_topology_gap", + "answer_kind": "answer_question", + "target_artifact": "runs/idea_event_storming_intake.json", + "target_ref": "event_storming_hints.workflow_relations", + "value": {"workflow_relations": relations}, + } + ], + }, + "ontology_review_hints": { + "term_bindings": [], + "aliases": [], + "project_local_terms": [], + "rejected_terms": [], + "deferred_terms": [], + }, + "candidate_review_hints": { + "acceptance_criteria": [], + "graph_edges": [], + "claim_reviews": [], + "other": [], + }, + }, + } + + def candidate_graph_with_candidate_gaps() -> dict[str, object]: graph = candidate_graph_artifact() graph["nodes"] = [ @@ -711,6 +793,80 @@ def test_rerun_preview_sanitizes_base_event_storming_entries() -> None: assert "private prompt trace" not in json.dumps(report) +def test_rerun_preview_applies_typed_workflow_relation_hints() -> None: + module = load_module( + PREVIEW_TOOL_PATH, + "idea_to_spec_rerun_preview_workflow_relations_test", + ) + rerun_input = rerun_input_with_workflow_relations( + [ + { + "relation": "actor_triggers_command", + "source_ref": "actor.household-member", + "target_ref": "command.record-pantry-item", + }, + { + "relation": "command_emits_event", + "source_ref": "command.record-pantry-item", + "target_ref": "event.pantry-item-recorded", + }, + { + "relation": "event_informs_policy", + "source_ref": "event.pantry-item-recorded", + "target_ref": "policy.expiration-reminder", + }, + ] + ) + + report = module.build_idea_to_spec_rerun_preview( + rerun_input=rerun_input, + intake=workflow_topology_intake(), + candidate_graph=workflow_topology_candidate_graph(), + ) + + assert report["readiness"]["ready"] is True + event_storming = report["rerun_preview"]["event_storming_preview"]["event_storming"] + command = event_storming["commands"][0] + policy = event_storming["policies"][0] + assert command["actor_refs"] == ["actor.household-member"] + assert command["produces_event_refs"] == ["event.pantry-item-recorded"] + assert policy["trigger_event_refs"] == ["event.pantry-item-recorded"] + topology = report["rerun_preview"]["workflow_topology_preview"] + assert topology["workflow_edge_count"] == 3 + assert topology["topology_relation_counts"]["actor_triggers_command"] == 1 + assert topology["topology_relation_counts"]["command_emits_event"] == 1 + assert topology["topology_relation_counts"]["event_informs_policy"] == 1 + assert all(edge["review_only"] is True for edge in topology["workflow_edges"]) + assert report["summary"]["workflow_relation_hint_count"] == 3 + + +def test_rerun_preview_blocks_wrong_kind_workflow_relation_hint() -> None: + module = load_module( + PREVIEW_TOOL_PATH, + "idea_to_spec_rerun_preview_workflow_relation_kind_test", + ) + rerun_input = rerun_input_with_workflow_relations( + [ + { + "relation": "command_emits_event", + "source_ref": "actor.household-member", + "target_ref": "event.pantry-item-recorded", + } + ] + ) + + report = module.build_idea_to_spec_rerun_preview( + rerun_input=rerun_input, + intake=workflow_topology_intake(), + candidate_graph=workflow_topology_candidate_graph(), + ) + + assert report["readiness"]["ready"] is False + assert "workflow_relation_hint_kind_mismatch" in finding_ids(report) + topology = report["rerun_preview"]["workflow_topology_preview"] + assert topology["workflow_edge_count"] == 0 + + def test_rerun_preview_cli_writes_output(tmp_path: Path) -> None: rerun_input_path = tmp_path / "idea_to_spec_answer_rerun_input.json" intake_path = tmp_path / "idea_event_storming_intake.json" diff --git a/tools/README.md b/tools/README.md index 55f92722..253099b9 100644 --- a/tools/README.md +++ b/tools/README.md @@ -407,6 +407,12 @@ Supervisor modes: `event_storming_hints.*` questions. Accepted `entries[]` answers are converted into review-only event-storming rerun hints by `tools/idea_to_spec_answer_rerun_input.py`. +- Proposal 0208 adds safe workflow-topology repair for flat candidates. When + `workflow_edge_count = 0`, clarification requests can target + `event_storming_hints.workflow_relations` with typed `relations[]` answers. + Rerun preview validates source/target refs and relation kinds before emitting + review-only workflow topology edges; materialization copies only + `review_only` / non-dependency edges into candidate graph preview. - `tools/user_idea_intake_source.py`: deterministic generic user-idea source builder introduced by proposal 0158. Use `make user-idea-intake-source USER_IDEA_INTAKE_SOURCE=` to normalize product workspace identity, diff --git a/tools/idea_to_spec_answer_rerun_input.py b/tools/idea_to_spec_answer_rerun_input.py index 62fc0c0f..9ed18c1a 100644 --- a/tools/idea_to_spec_answer_rerun_input.py +++ b/tools/idea_to_spec_answer_rerun_input.py @@ -26,6 +26,15 @@ "policies", "constraints", } +EVENT_STORMING_RELATION_TARGET = "event_storming_hints.workflow_relations" +WORKFLOW_RELATIONS = { + "actor_triggers_command", + "command_emits_event", + "event_informs_policy", + "event_informs_constraint", + "constraint_applies_to_command", + "policy_applies_to_command", +} ACTIVE_FRAME_FIELDS = { "project", "subsystem", @@ -593,6 +602,9 @@ def _append_event_storming( entries = _list(value) if not entries: entries = _list(value_dict.get("entries")) + relations = _list(value_dict.get("relations")) + if target_ref == EVENT_STORMING_RELATION_TARGET and relations: + event_storming = {"workflow_relations": relations} if ( target_ref.startswith("event_storming_hints.") and category in EVENT_STORMING_CATEGORIES diff --git a/tools/idea_to_spec_clarification_requests.py b/tools/idea_to_spec_clarification_requests.py index 1ca5dfe2..4bf25f67 100644 --- a/tools/idea_to_spec_clarification_requests.py +++ b/tools/idea_to_spec_clarification_requests.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] PROPOSAL_ID = "0163" DEPTH_DRIVEN_PROPOSAL_ID = "0207" +WORKFLOW_TOPOLOGY_REPAIR_PROPOSAL_ID = "0208" SCHEMA_VERSION = 1 CONTRACT_REF = "specgraph.idea-to-spec.clarification-requests.v0.1" USER_IDEA_INTAKE_SESSION_CONTRACT_REF = "specgraph.idea-to-spec.user-idea-intake-session.v0.1" @@ -167,6 +168,15 @@ }, } +WORKFLOW_TOPOLOGY_REPAIR_REQUEST = { + "metric": "workflow_edge_count", + "question": ( + "Which typed workflow relations connect the existing actors, commands, " + "domain events, policies, and constraints?" + ), + "block": "candidate_structure_depth.workflow_edge_count", +} + def _now_iso() -> str: return datetime.now(tz=timezone.utc).isoformat() @@ -772,6 +782,45 @@ def _requests_from_structure_depth( ), ) ) + workflow_edge_count = _non_negative_int(depth.get("workflow_edge_count")) + command_count = _non_negative_int(depth.get("command_count")) + relation_context_count = sum( + _non_negative_int(depth.get(metric)) + for metric in ( + "actor_count", + "domain_event_count", + "policy_count", + "constraint_count", + ) + ) + if workflow_edge_count == 0 and command_count > 0 and relation_context_count > 0: + spec = WORKFLOW_TOPOLOGY_REPAIR_REQUEST + requests.append( + _request( + request_id="clarification.depth.workflow-topology", + kind="workflow_topology_gap", + severity="review_required", + question=spec["question"], + target_artifact=intake_target, + target_ref="event_storming_hints.workflow_relations", + blocks=[spec["block"]], + suggested_answer_shape="event_storming_relation[]", + suggested_actions=["answer_question", "defer_candidate"], + source_findings=[ + { + **item, + "evidence": { + **_dict(item.get("evidence")), + "proposal_id": WORKFLOW_TOPOLOGY_REPAIR_PROPOSAL_ID, + }, + } + for item in _depth_source_findings( + metric=spec["metric"], + idea_maturity_target=idea_maturity_target, + ) + ], + ) + ) return requests diff --git a/tools/idea_to_spec_rerun_materialization.py b/tools/idea_to_spec_rerun_materialization.py index 673a73c6..be0a220f 100644 --- a/tools/idea_to_spec_rerun_materialization.py +++ b/tools/idea_to_spec_rerun_materialization.py @@ -306,6 +306,50 @@ def _refresh_candidate_graph_summary(candidate_graph: dict[str, Any]) -> None: candidate_graph["summary"] = summary +def _edge_key(edge: dict[str, Any]) -> tuple[str, str, str]: + return (_text(edge.get("from")), _text(edge.get("to")), _text(edge.get("relation"))) + + +def _workflow_topology_edges_from_preview(rerun_preview: dict[str, Any]) -> list[dict[str, Any]]: + workflow_preview = _dict( + _dict(rerun_preview.get("rerun_preview")).get("workflow_topology_preview") + ) + edges: list[dict[str, Any]] = [] + for raw_edge in _list(workflow_preview.get("workflow_edges")): + edge = _dict(raw_edge) + if edge.get("review_only") is not True: + continue + if edge.get("materialization_dependency") is not False: + continue + relation = _text(edge.get("relation")) + from_node = _text(edge.get("from")) + to_node = _text(edge.get("to")) + if not relation or not from_node or not to_node: + continue + edges.append(_public_safe(edge)) + return edges + + +def _merge_workflow_topology_edges( + candidate_graph: dict[str, Any], + rerun_preview: dict[str, Any], +) -> list[dict[str, Any]]: + existing_edges = [_dict(edge) for edge in _list(candidate_graph.get("edges"))] + seen = {_edge_key(edge) for edge in existing_edges} + added: list[dict[str, Any]] = [] + merged_edges = list(_list(candidate_graph.get("edges"))) + for edge in _workflow_topology_edges_from_preview(rerun_preview): + key = _edge_key(edge) + if not all(key) or key in seen: + continue + seen.add(key) + merged_edges.append(edge) + added.append(edge) + if added: + candidate_graph["edges"] = merged_edges + return added + + def _empty_delta(candidate_graph: dict[str, Any]) -> dict[str, Any]: unresolved_ontology_gap_ids: list[str] = [] unresolved_candidate_gap_ids: list[str] = [] @@ -322,6 +366,8 @@ def _empty_delta(candidate_graph: dict[str, Any]) -> dict[str, Any]: unresolved_candidate_gap_ids.append(gap_id) return { "removed_gap_ids": [], + "added_workflow_topology_edges": [], + "added_workflow_topology_edge_count": 0, "unresolved_ontology_gap_ids": unresolved_ontology_gap_ids, "unresolved_candidate_gap_ids": unresolved_candidate_gap_ids, "resolved_ontology_gap_count": 0, @@ -441,6 +487,7 @@ def _materialize_candidate_graph_preview( if isinstance(item, dict) ] node["candidate_gap_resolutions"] = existing_resolutions + node_candidate_resolutions + added_workflow_edges = _merge_workflow_topology_edges(preview, rerun_preview) preview["source_ref"] = candidate_graph_preview_ref _refresh_candidate_graph_summary(preview) preview["rerun_materialization"] = { @@ -451,9 +498,12 @@ def _materialize_candidate_graph_preview( "resolution_count": len(resolution_records), "ontology_resolution_count": len(ontology_resolution_records), "candidate_resolution_count": len(candidate_resolution_records), + "added_workflow_topology_edge_count": len(added_workflow_edges), } delta = { "removed_gap_ids": removed_gap_ids, + "added_workflow_topology_edges": added_workflow_edges, + "added_workflow_topology_edge_count": len(added_workflow_edges), "unresolved_ontology_gap_ids": unresolved_ontology_gap_ids, "unresolved_candidate_gap_ids": unresolved_candidate_gap_ids, "resolved_ontology_gap_count": len(ontology_resolution_records), diff --git a/tools/idea_to_spec_rerun_preview.py b/tools/idea_to_spec_rerun_preview.py index 8887496e..12db98a3 100644 --- a/tools/idea_to_spec_rerun_preview.py +++ b/tools/idea_to_spec_rerun_preview.py @@ -39,6 +39,52 @@ "assumptions", "vocabulary_questions", ) +EVENT_STORMING_RELATION_TARGET = "event_storming_hints.workflow_relations" +WORKFLOW_RELATION_SPECS = { + "actor_triggers_command": { + "source_category": "actors", + "target_category": "commands", + "patch_category": "commands", + "patch_ref": "target_ref", + "field": "actor_refs", + }, + "command_emits_event": { + "source_category": "commands", + "target_category": "domain_events", + "patch_category": "commands", + "patch_ref": "source_ref", + "field": "produces_event_refs", + }, + "event_informs_policy": { + "source_category": "domain_events", + "target_category": "policies", + "patch_category": "policies", + "patch_ref": "target_ref", + "field": "trigger_event_refs", + }, + "event_informs_constraint": { + "source_category": "domain_events", + "target_category": "constraints", + "patch_category": "constraints", + "patch_ref": "target_ref", + "field": "trigger_event_refs", + }, + "constraint_applies_to_command": { + "source_category": "constraints", + "target_category": "commands", + "patch_category": "constraints", + "patch_ref": "source_ref", + "field": "command_refs", + }, + "policy_applies_to_command": { + "source_category": "policies", + "target_category": "commands", + "patch_category": "policies", + "patch_ref": "source_ref", + "field": "command_refs", + }, +} +WORKFLOW_RELATION_NAMES = set(WORKFLOW_RELATION_SPECS) RAW_TRACE_FIELDS = { "operator_note", "operator_notes", @@ -285,6 +331,151 @@ def _merge_unique(existing: list[Any], additions: list[Any]) -> list[Any]: return merged +def _append_text_unique(entry: dict[str, Any], field: str, ref: str) -> bool: + refs = _text_list(entry.get(field)) + if ref in refs: + entry[field] = refs + return False + refs.append(ref) + entry[field] = refs + return True + + +def _event_storming_index( + event_storming: dict[str, list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + index: dict[str, dict[str, Any]] = {} + for category in EVENT_STORMING_CATEGORIES: + for entry in _list(event_storming.get(category)): + record = _dict(entry) + entry_id = _text(record.get("id")) + if entry_id: + index[entry_id] = {"category": category, "entry": record} + return index + + +def _relation_hint_record( + relation: str, + source_ref: str, + target_ref: str, + hint: dict[str, Any], +) -> dict[str, Any]: + return { + "relation": relation, + "source_ref": source_ref, + "target_ref": target_ref, + "request_id": _text(hint.get("request_id")), + "target_ref_context": _text(hint.get("target_ref")), + "review_only": True, + "materialization_dependency": False, + } + + +def _apply_workflow_relation_hints( + *, + preview: dict[str, list[dict[str, Any]]], + overlay: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + applied: list[dict[str, Any]] = [] + findings: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + for raw_hint in _list(_dict(overlay.get("intake_overlay")).get("event_storming_hints")): + hint = _dict(raw_hint) + value = _dict(hint.get("value")) + relations = _list(value.get("workflow_relations")) + if not relations: + continue + if _text(hint.get("target_ref")) != EVENT_STORMING_RELATION_TARGET: + findings.append( + _finding( + finding_id="workflow_relation_hint_target_mismatch", + severity="review_required", + message=( + "Workflow relation hints must target " + "event_storming_hints.workflow_relations." + ), + evidence={ + "request_id": _text(hint.get("request_id")), + "target_ref": _text(hint.get("target_ref")), + }, + ) + ) + continue + index = _event_storming_index(preview) + for index_in_hint, raw_relation in enumerate(relations): + relation_item = _dict(raw_relation) + relation = _text(relation_item.get("relation")) + source_ref = _text(relation_item.get("source_ref")) + target_ref = _text(relation_item.get("target_ref")) + evidence = { + "request_id": _text(hint.get("request_id")), + "relation_index": index_in_hint, + "relation": relation, + "source_ref": source_ref, + "target_ref": target_ref, + } + spec = WORKFLOW_RELATION_SPECS.get(relation) + if not spec or not source_ref or not target_ref: + findings.append( + _finding( + finding_id="workflow_relation_hint_invalid", + severity="review_required", + message=( + "Workflow relation hint requires an allowed relation, " + "source_ref, and target_ref." + ), + evidence=evidence, + ) + ) + continue + source = index.get(source_ref) + target = index.get(target_ref) + if source is None or target is None: + findings.append( + _finding( + finding_id="workflow_relation_hint_ref_missing", + severity="review_required", + message=( + "Workflow relation hint references an unknown event-storming entry." + ), + evidence=evidence, + ) + ) + continue + if ( + source["category"] != spec["source_category"] + or target["category"] != spec["target_category"] + ): + findings.append( + _finding( + finding_id="workflow_relation_hint_kind_mismatch", + severity="review_required", + message=( + "Workflow relation hint source_ref/target_ref kinds " + "do not match the relation." + ), + evidence={ + **evidence, + "expected_source_category": spec["source_category"], + "actual_source_category": source["category"], + "expected_target_category": spec["target_category"], + "actual_target_category": target["category"], + }, + ) + ) + continue + relation_key = (relation, source_ref, target_ref) + if relation_key in seen: + continue + patch_ref = source_ref if spec["patch_ref"] == "source_ref" else target_ref + patch_entry = index[patch_ref]["entry"] + append_ref = target_ref if spec["patch_ref"] == "source_ref" else source_ref + _append_text_unique(patch_entry, spec["field"], append_ref) + seen.add(relation_key) + applied.append(_relation_hint_record(relation, source_ref, target_ref, hint)) + return applied, findings + + def _merge_active_frame( *, intake: dict[str, Any], @@ -346,10 +537,17 @@ def _merge_event_storming( "categories": applied_categories, } ) + applied_relations, relation_findings = _apply_workflow_relation_hints( + preview=preview, + overlay=overlay, + ) return { "event_storming": preview, "applied_hint_count": len(applied_hints), "applied_hints": applied_hints, + "applied_workflow_relation_count": len(applied_relations), + "applied_workflow_relations": applied_relations, + "findings": relation_findings, } @@ -374,6 +572,203 @@ def _gap_items(candidate_graph: dict[str, Any]) -> list[dict[str, Any]]: return items +def _slug(value: str, fallback: str = "ref") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") + return slug or fallback + + +def _nodes_by_source_ref(candidate_graph: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + by_ref: dict[str, list[dict[str, Any]]] = {} + for raw_node in _list(candidate_graph.get("nodes")): + node = _dict(raw_node) + for ref in _text_list(node.get("source_event_refs")): + by_ref.setdefault(ref, []).append(node) + return by_ref + + +def _first_node_id_for_ref( + nodes_by_ref: dict[str, list[dict[str, Any]]], + source_ref: str, + *, + allowed_kinds: set[str], +) -> str: + for node in nodes_by_ref.get(source_ref, []): + node_id = _text(node.get("id")) + node_kind = _text(node.get("kind")) + if node_id and (not allowed_kinds or node_kind in allowed_kinds): + return node_id + return "" + + +def _command_event_index( + event_storming: dict[str, list[dict[str, Any]]], +) -> dict[str, list[str]]: + by_event: dict[str, list[str]] = {} + for raw_command in _list(event_storming.get("commands")): + command = _dict(raw_command) + command_id = _text(command.get("id")) + if not command_id: + continue + for event_ref in _text_list(command.get("produces_event_refs")): + if command_id not in by_event.setdefault(event_ref, []): + by_event[event_ref].append(command_id) + return by_event + + +def _append_workflow_edge( + edges: list[dict[str, Any]], + seen: set[tuple[str, str, str]], + *, + edge_id: str, + from_node: str, + to_node: str, + relation: str, + source_event_refs: list[str], + extra: dict[str, Any], +) -> None: + if not from_node or not to_node: + return + key = (from_node, to_node, relation) + if key in seen: + return + seen.add(key) + edges.append( + { + "id": edge_id, + "from": from_node, + "to": to_node, + "relation": relation, + "source_event_refs": source_event_refs, + "derivation": { + "kind": "event_storming_workflow_relation_hint", + "source": "idea_to_spec_rerun_preview", + }, + "review_only": True, + "materialization_dependency": False, + **extra, + } + ) + + +def _workflow_topology_preview( + *, + candidate_graph: dict[str, Any], + event_storming_preview: dict[str, Any], +) -> dict[str, Any]: + event_storming = { + category: _list(_dict(event_storming_preview.get("event_storming")).get(category)) + for category in EVENT_STORMING_CATEGORIES + } + boundary_id = "candidate-spec.product-boundary" + nodes_by_ref = _nodes_by_source_ref(candidate_graph) + command_events = _command_event_index(event_storming) + edges: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + for raw_command in _list(event_storming.get("commands")): + command = _dict(raw_command) + command_ref = _text(command.get("id")) + command_node = _first_node_id_for_ref( + nodes_by_ref, + command_ref, + allowed_kinds={"behavior_requirement"}, + ) + if not command_ref or not command_node: + continue + command_slug = command_node.removeprefix("candidate-spec.") + for actor_ref in _text_list(command.get("actor_refs")): + _append_workflow_edge( + edges, + seen, + edge_id=f"edge.actor.{_slug(actor_ref, 'actor')}.{command_slug}", + from_node=boundary_id, + to_node=command_node, + relation="actor_triggers_command", + source_event_refs=[actor_ref, command_ref], + extra={"actor_ref": actor_ref, "command_ref": command_ref}, + ) + for event_ref in _text_list(command.get("produces_event_refs")): + _append_workflow_edge( + edges, + seen, + edge_id=f"edge.command.{command_slug}.{_slug(event_ref, 'event')}", + from_node=command_node, + to_node=boundary_id, + relation="command_emits_event", + source_event_refs=[command_ref, event_ref], + extra={"command_ref": command_ref, "event_ref": event_ref}, + ) + for kind, category in (("constraint", "constraints"), ("policy", "policies")): + for raw_entry in _list(event_storming.get(category)): + entry = _dict(raw_entry) + entry_ref = _text(entry.get("id")) + entry_node = _first_node_id_for_ref( + nodes_by_ref, + entry_ref, + allowed_kinds={f"{kind}_constraint"}, + ) + if not entry_ref or not entry_node: + continue + entry_slug = entry_node.removeprefix("candidate-spec.") + relation = ( + "policy_applies_to_command" if kind == "policy" else "constraint_applies_to_command" + ) + for command_ref in _text_list(entry.get("command_refs")): + command_node = _first_node_id_for_ref( + nodes_by_ref, + command_ref, + allowed_kinds={"behavior_requirement"}, + ) + command_slug = command_node.removeprefix("candidate-spec.") + _append_workflow_edge( + edges, + seen, + edge_id=f"edge.{kind}.{entry_slug}.{command_slug}", + from_node=entry_node, + to_node=command_node, + relation=relation, + source_event_refs=[entry_ref, command_ref], + extra={f"{kind}_ref": entry_ref, "command_ref": command_ref}, + ) + for event_ref in _text_list(entry.get("trigger_event_refs")): + for command_ref in command_events.get(event_ref, []): + command_node = _first_node_id_for_ref( + nodes_by_ref, + command_ref, + allowed_kinds={"behavior_requirement"}, + ) + command_slug = command_node.removeprefix("candidate-spec.") + _append_workflow_edge( + edges, + seen, + edge_id=( + f"edge.event.{_slug(event_ref, 'event')}.{command_slug}.{entry_slug}" + ), + from_node=command_node, + to_node=entry_node, + relation=( + "event_informs_policy" + if kind == "policy" + else "event_informs_constraint" + ), + source_event_refs=[command_ref, event_ref, entry_ref], + extra={ + "command_ref": command_ref, + "event_ref": event_ref, + f"{kind}_ref": entry_ref, + }, + ) + relation_counts = {relation: 0 for relation in WORKFLOW_RELATION_NAMES} + for edge in edges: + relation_counts[edge["relation"]] = relation_counts.get(edge["relation"], 0) + 1 + return { + "workflow_edges": edges, + "workflow_edge_count": len(edges), + "topology_relation_counts": relation_counts, + "review_only": True, + "materialization_dependency": False, + } + + def _decision_records( *, overlay: dict[str, Any], @@ -882,6 +1277,11 @@ def build_idea_to_spec_rerun_preview( overlay = _dict(rerun_input.get("rerun_input_overlay")) if not findings else {} active_frame_preview = _merge_active_frame(intake=intake, overlay=overlay) event_storming_preview = _merge_event_storming(intake=intake, overlay=overlay) + findings.extend(_list(event_storming_preview.get("findings"))) + workflow_topology_preview = _workflow_topology_preview( + candidate_graph=candidate_graph, + event_storming_preview=event_storming_preview, + ) ontology_gap_preview = _ontology_gap_preview( candidate_graph=candidate_graph, overlay=overlay, @@ -936,6 +1336,7 @@ def build_idea_to_spec_rerun_preview( "rerun_preview": { "active_frame_preview": active_frame_preview, "event_storming_preview": event_storming_preview, + "workflow_topology_preview": workflow_topology_preview, "ontology_gap_preview": ontology_gap_preview, "candidate_gap_preview": candidate_gap_preview, "candidate_review_preview": candidate_review_preview, @@ -959,6 +1360,10 @@ def build_idea_to_spec_rerun_preview( "status": "rerun_preview_ready" if ready else "rerun_preview_review_required", "active_frame_hint_count": active_frame_preview["applied_hint_count"], "event_storming_hint_count": event_storming_preview["applied_hint_count"], + "workflow_relation_hint_count": event_storming_preview[ + "applied_workflow_relation_count" + ], + "workflow_edge_preview_count": workflow_topology_preview["workflow_edge_count"], "ontology_decision_count": ontology_gap_preview["decision_count"], "resolved_ontology_gap_count": ontology_gap_preview["resolved_ontology_gap_count"], "unresolved_ontology_gap_count": ontology_gap_preview["unresolved_ontology_gap_count"], diff --git a/tools/proposal_promotion_registry.json b/tools/proposal_promotion_registry.json index 928861fd..624e39f5 100644 --- a/tools/proposal_promotion_registry.json +++ b/tools/proposal_promotion_registry.json @@ -2676,5 +2676,18 @@ "required_provenance_links": [ "source_draft_ref" ] + }, + { + "proposal_id": "0208", + "source_artifact_class": "working_draft", + "source_refs": [ + "docs/archive/proposal_sources/0208_safe_workflow_topology_repair.md" + ], + "motivating_concern": "Depth-driven clarification can collect missing event-storming entries, but flat workflow topology still needs typed relation repair that links existing entries without fuzzy inference or implementation dependency authority.", + "normalized_title": "Safe Workflow Topology Repair", + "bounded_scope": "Emit review-required workflow_topology_gap clarification requests from flat candidate_structure_depth workflow edges, preserve accepted typed relations answers in rerun input, validate relation names and source/target kinds in rerun preview, and materialize only review-only non-dependency workflow edges into candidate graph preview, without changing Metrics schemas, adding scores, changing gates, approving candidates, mutating source intake or canonical specs, writing Ontology packages or lockfiles, accepting ontology terms, creating Git branches or commits, opening pull requests, publishing read models, or turning workflow topology into YAML dependencies.", + "required_provenance_links": [ + "source_draft_ref" + ] } ] diff --git a/tools/proposal_runtime_registry.json b/tools/proposal_runtime_registry.json index 489cc5bb..ee5d2d09 100644 --- a/tools/proposal_runtime_registry.json +++ b/tools/proposal_runtime_registry.json @@ -11840,5 +11840,73 @@ } ], "notes": "SpecGraph can now turn shallow Metrics-owned structural depth observations into review-required clarification requests and convert accepted entries answers into event-storming rerun hints." + }, + { + "proposal_id": "0208", + "posture": "bounded_runtime_followup", + "runtime_surfaces": [ + "docs/proposals/0208_safe_workflow_topology_repair.md", + "docs/archive/proposal_sources/0208_safe_workflow_topology_repair.md", + "tools/idea_to_spec_clarification_requests.py", + "tools/idea_to_spec_answer_rerun_input.py", + "tools/idea_to_spec_rerun_preview.py", + "tools/idea_to_spec_rerun_materialization.py", + "tools/real_idea_answer_authoring.py", + "tests/test_idea_to_spec_clarification_requests.py", + "tests/test_idea_to_spec_answer_rerun_input.py", + "tests/test_idea_to_spec_rerun_preview.py", + "tests/test_idea_to_spec_rerun_materialization.py", + "tests/test_real_idea_answer_authoring.py", + "docs/product_workspace_graph_versioning_roadmap.md", + "Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md", + "tools/README.md" + ], + "runtime_markers": [ + { + "path": "tools/idea_to_spec_clarification_requests.py", + "pattern": "WORKFLOW_TOPOLOGY_REPAIR_PROPOSAL_ID = \"0208\"" + }, + { + "path": "tools/idea_to_spec_rerun_preview.py", + "pattern": "WORKFLOW_RELATION_SPECS = {" + }, + { + "path": "tools/idea_to_spec_rerun_materialization.py", + "pattern": "def _merge_workflow_topology_edges(" + } + ], + "validation_markers": [ + { + "path": "tests/test_idea_to_spec_clarification_requests.py", + "pattern": "clarification.depth.workflow-topology" + }, + { + "path": "tests/test_idea_to_spec_answer_rerun_input.py", + "pattern": "test_answer_rerun_input_preserves_workflow_relation_hints" + }, + { + "path": "tests/test_idea_to_spec_rerun_preview.py", + "pattern": "test_rerun_preview_applies_typed_workflow_relation_hints" + }, + { + "path": "tests/test_idea_to_spec_rerun_materialization.py", + "pattern": "test_rerun_materialization_merges_review_only_workflow_topology_edges" + } + ], + "observation_markers": [ + { + "path": "docs/product_workspace_graph_versioning_roadmap.md", + "pattern": "Safe Workflow Topology Repair" + }, + { + "path": "Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md", + "pattern": "Proposal `0208` adds the patch-capable workflow-topology overlay" + }, + { + "path": "tools/README.md", + "pattern": "Proposal 0208 adds safe workflow-topology repair" + } + ], + "notes": "SpecGraph can now turn flat workflow_edge_count observations into review-required typed workflow relation requests and safely materialize validated review-only topology edges in candidate graph preview." } ] diff --git a/tools/real_idea_answer_authoring.py b/tools/real_idea_answer_authoring.py index 4347e729..b9b5a392 100644 --- a/tools/real_idea_answer_authoring.py +++ b/tools/real_idea_answer_authoring.py @@ -289,6 +289,8 @@ def _value_template(action: str, request: dict[str, Any]) -> Any: "evidence_refs": [], } if action == "answer_question": + if "event_storming_relation[]" in shape or kind == "workflow_topology_gap": + return {"relations": [{"relation": "", "source_ref": "", "target_ref": ""}]} if ( "ontology_ref[]" in shape or "domain_ref[]" in shape @@ -317,6 +319,10 @@ def _required_fields(action: str, request: dict[str, Any]) -> list[str]: if action == "provide_candidate_context": return ["value.context"] if action == "answer_question": + shape = _text(request.get("suggested_answer_shape")) + kind = _text(request.get("kind")) + if "event_storming_relation[]" in shape or kind == "workflow_topology_gap": + return ["value.relations[]"] return ["value"] return ["value"] @@ -343,6 +349,8 @@ def _field_present(value: Any, field: str) -> bool: key = path[:-2] if not isinstance(current, dict): return False + if key == "relations": + return bool(_list(current.get(key))) return bool(_text_list(current.get(key))) if not isinstance(current, dict): return False From 2e255986dcfea9362ff6ca659c39c57c0ee6fff0 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Thu, 9 Jul 2026 11:14:07 +0300 Subject: [PATCH 2/2] Harden workflow topology repair preview --- ...test_idea_to_spec_rerun_materialization.py | 29 ++++- tests/test_idea_to_spec_rerun_preview.py | 104 ++++++++++++++++++ tests/test_real_idea_answer_authoring.py | 89 +++++++++++++++ tools/idea_to_spec_rerun_materialization.py | 16 ++- tools/idea_to_spec_rerun_preview.py | 101 ++++++++++++++++- tools/real_idea_answer_authoring.py | 10 +- 6 files changed, 334 insertions(+), 15 deletions(-) diff --git a/tests/test_idea_to_spec_rerun_materialization.py b/tests/test_idea_to_spec_rerun_materialization.py index cfa3794e..754f7e90 100644 --- a/tests/test_idea_to_spec_rerun_materialization.py +++ b/tests/test_idea_to_spec_rerun_materialization.py @@ -218,9 +218,23 @@ def rerun_preview_with_workflow_topology() -> dict[str, object]: "event_ref": "event.pantry-item-recorded", "review_only": True, "materialization_dependency": False, - } + }, + { + "id": "edge.command.record-pantry-item.pantry-item-reviewed", + "from": "candidate-spec.record-pantry-item", + "to": "candidate-spec.product-boundary", + "relation": "command_emits_event", + "source_event_refs": [ + "command.record-pantry-item", + "event.pantry-item-reviewed", + ], + "command_ref": "command.record-pantry-item", + "event_ref": "event.pantry-item-reviewed", + "review_only": True, + "materialization_dependency": False, + }, ], - "workflow_edge_count": 1, + "workflow_edge_count": 2, "review_only": True, "materialization_dependency": False, } @@ -339,15 +353,18 @@ def test_rerun_materialization_merges_review_only_workflow_topology_edges() -> N assert report["readiness"]["ready"] is True preview = report["materialization_preview"]["candidate_graph_preview"] edges = preview["edges"] - assert len(edges) == 1 + assert len(edges) == 2 edge = edges[0] assert edge["relation"] == "command_emits_event" assert edge["review_only"] is True assert edge["materialization_dependency"] is False - assert preview["summary"]["edge_count"] == 1 + assert preview["summary"]["edge_count"] == 2 delta = report["materialization_preview"]["delta"] - assert delta["added_workflow_topology_edge_count"] == 1 - assert delta["added_workflow_topology_edges"][0]["event_ref"] == ("event.pantry-item-recorded") + assert delta["added_workflow_topology_edge_count"] == 2 + assert {edge["event_ref"] for edge in delta["added_workflow_topology_edges"]} == { + "event.pantry-item-recorded", + "event.pantry-item-reviewed", + } assert report["summary"]["removed_gap_count"] == 0 diff --git a/tests/test_idea_to_spec_rerun_preview.py b/tests/test_idea_to_spec_rerun_preview.py index 7ff2c1df..4b064f45 100644 --- a/tests/test_idea_to_spec_rerun_preview.py +++ b/tests/test_idea_to_spec_rerun_preview.py @@ -840,6 +840,110 @@ def test_rerun_preview_applies_typed_workflow_relation_hints() -> None: assert report["summary"]["workflow_relation_hint_count"] == 3 +def test_rerun_preview_preserves_distinct_workflow_edges_with_same_endpoints() -> None: + module = load_module( + PREVIEW_TOOL_PATH, + "idea_to_spec_rerun_preview_workflow_relation_distinct_edges_test", + ) + intake = workflow_topology_intake() + intake["event_storming"]["actors"].append( + {"id": "actor.shopping-planner", "name": "Shopping planner"} + ) + rerun_input = rerun_input_with_workflow_relations( + [ + { + "relation": "actor_triggers_command", + "source_ref": "actor.household-member", + "target_ref": "command.record-pantry-item", + }, + { + "relation": "actor_triggers_command", + "source_ref": "actor.shopping-planner", + "target_ref": "command.record-pantry-item", + }, + ] + ) + + report = module.build_idea_to_spec_rerun_preview( + rerun_input=rerun_input, + intake=intake, + candidate_graph=workflow_topology_candidate_graph(), + ) + + topology = report["rerun_preview"]["workflow_topology_preview"] + assert report["readiness"]["ready"] is True + assert topology["workflow_edge_count"] == 2 + assert topology["topology_relation_counts"]["actor_triggers_command"] == 2 + assert {tuple(edge["source_event_refs"]) for edge in topology["workflow_edges"]} == { + ("actor.household-member", "command.record-pantry-item"), + ("actor.shopping-planner", "command.record-pantry-item"), + } + + +def test_rerun_preview_blocks_workflow_topology_when_boundary_node_missing() -> None: + module = load_module( + PREVIEW_TOOL_PATH, + "idea_to_spec_rerun_preview_workflow_relation_boundary_test", + ) + candidate_graph = workflow_topology_candidate_graph() + candidate_graph["nodes"] = [ + node for node in candidate_graph["nodes"] if node["id"] != "candidate-spec.product-boundary" + ] + rerun_input = rerun_input_with_workflow_relations( + [ + { + "relation": "actor_triggers_command", + "source_ref": "actor.household-member", + "target_ref": "command.record-pantry-item", + } + ] + ) + + report = module.build_idea_to_spec_rerun_preview( + rerun_input=rerun_input, + intake=workflow_topology_intake(), + candidate_graph=candidate_graph, + ) + + assert report["readiness"]["ready"] is False + assert "workflow_topology_boundary_missing" in finding_ids(report) + topology = report["rerun_preview"]["workflow_topology_preview"] + assert topology["workflow_edge_count"] == 0 + + +def test_rerun_preview_reports_unmapped_workflow_relation_endpoint() -> None: + module = load_module( + PREVIEW_TOOL_PATH, + "idea_to_spec_rerun_preview_workflow_relation_unmapped_test", + ) + candidate_graph = workflow_topology_candidate_graph() + candidate_graph["nodes"] = [ + node + for node in candidate_graph["nodes"] + if node["id"] != "candidate-spec.record-pantry-item" + ] + rerun_input = rerun_input_with_workflow_relations( + [ + { + "relation": "command_emits_event", + "source_ref": "command.record-pantry-item", + "target_ref": "event.pantry-item-recorded", + } + ] + ) + + report = module.build_idea_to_spec_rerun_preview( + rerun_input=rerun_input, + intake=workflow_topology_intake(), + candidate_graph=candidate_graph, + ) + + assert report["readiness"]["ready"] is False + assert "workflow_topology_endpoint_unmapped" in finding_ids(report) + topology = report["rerun_preview"]["workflow_topology_preview"] + assert topology["workflow_edge_count"] == 0 + + def test_rerun_preview_blocks_wrong_kind_workflow_relation_hint() -> None: module = load_module( PREVIEW_TOOL_PATH, diff --git a/tests/test_real_idea_answer_authoring.py b/tests/test_real_idea_answer_authoring.py index 8dfce5e5..95d387ea 100644 --- a/tests/test_real_idea_answer_authoring.py +++ b/tests/test_real_idea_answer_authoring.py @@ -70,6 +70,30 @@ def filled_repair_template(tmp_path: Path) -> Path: return template_path +def workflow_relation_requests() -> dict[str, object]: + return { + "artifact_kind": "idea_to_spec_clarification_requests", + "schema_version": 1, + "contract_ref": "specgraph.idea-to-spec.clarification-requests.v0.1", + "canonical_mutations_allowed": False, + "tracked_artifacts_written": False, + "clarification_requests": [ + { + "id": "clarification.depth.workflow-topology", + "kind": "workflow_topology_gap", + "status": "open", + "severity": "blocking", + "question": "Which workflow relations connect actors, commands, and events?", + "target_artifact": "runs/idea_event_storming_intake.json", + "target_ref": "event_storming_hints.workflow_relations", + "suggested_actions": ["answer_question"], + "suggested_answer_shape": "event_storming_relation[]", + } + ], + "readiness": {"ready": True, "review_state": "clarification_requests_ready"}, + } + + def test_answer_template_exposes_typed_targets(tmp_path: Path) -> None: module = load_module() @@ -138,6 +162,71 @@ def test_answer_authoring_blocks_empty_required_fields(tmp_path: Path) -> None: assert "answer_required_field_empty" in finding_ids(report) +def test_answer_authoring_requires_substantive_workflow_relation(tmp_path: Path) -> None: + module = load_module() + requests = workflow_relation_requests() + requests_path = tmp_path / "workflow_relation_requests.json" + write_json(requests_path, requests) + template = module.build_template( + clarification_requests=requests, + requests_path=requests_path, + stage="repair", + run_dir=tmp_path, + ) + template["operator_answers"][0]["status"] = "accepted_for_candidate" + template_path = tmp_path / "unfilled_workflow_relation_template.json" + write_json(template_path, template) + + _answer_set, _validated_answers, report = module.build_validation( + clarification_requests=requests, + requests_path=requests_path, + answer_input=load_json(template_path), + answers_path=template_path, + stage="repair", + run_dir=tmp_path, + ) + + assert report["readiness"]["ready"] is False + assert "answer_required_field_empty" in finding_ids(report) + + +def test_answer_authoring_accepts_complete_workflow_relation(tmp_path: Path) -> None: + module = load_module() + requests = workflow_relation_requests() + requests_path = tmp_path / "workflow_relation_requests.json" + write_json(requests_path, requests) + template = module.build_template( + clarification_requests=requests, + requests_path=requests_path, + stage="repair", + run_dir=tmp_path, + ) + template["operator_answers"][0]["status"] = "accepted_for_candidate" + template["operator_answers"][0]["value"] = { + "relations": [ + { + "relation": "command_emits_event", + "source_ref": "command.record-pantry-item", + "target_ref": "event.pantry-item-recorded", + } + ] + } + template_path = tmp_path / "filled_workflow_relation_template.json" + write_json(template_path, template) + + _answer_set, validated_answers, report = module.build_validation( + clarification_requests=requests, + requests_path=requests_path, + answer_input=load_json(template_path), + answers_path=template_path, + stage="repair", + run_dir=tmp_path, + ) + + assert validated_answers["readiness"]["ready"] is True + assert report["readiness"]["ready"] is True + + def test_answer_authoring_blocks_authority_expansion(tmp_path: Path) -> None: module = load_module() template_path = filled_repair_template(tmp_path) diff --git a/tools/idea_to_spec_rerun_materialization.py b/tools/idea_to_spec_rerun_materialization.py index be0a220f..564dbcce 100644 --- a/tools/idea_to_spec_rerun_materialization.py +++ b/tools/idea_to_spec_rerun_materialization.py @@ -49,6 +49,10 @@ def _text(value: Any, default: str = "") -> str: return value.strip() if isinstance(value, str) and value.strip() else default +def _text_list(value: Any) -> list[str]: + return [_text(item) for item in _list(value) if _text(item)] + + def _relative_ref(path: Path) -> str: try: return path.resolve().relative_to(ROOT).as_posix() @@ -306,8 +310,13 @@ def _refresh_candidate_graph_summary(candidate_graph: dict[str, Any]) -> None: candidate_graph["summary"] = summary -def _edge_key(edge: dict[str, Any]) -> tuple[str, str, str]: - return (_text(edge.get("from")), _text(edge.get("to")), _text(edge.get("relation"))) +def _edge_key(edge: dict[str, Any]) -> tuple[str, str, str, tuple[str, ...]]: + return ( + _text(edge.get("from")), + _text(edge.get("to")), + _text(edge.get("relation")), + tuple(_text_list(edge.get("source_event_refs"))), + ) def _workflow_topology_edges_from_preview(rerun_preview: dict[str, Any]) -> list[dict[str, Any]]: @@ -340,7 +349,8 @@ def _merge_workflow_topology_edges( merged_edges = list(_list(candidate_graph.get("edges"))) for edge in _workflow_topology_edges_from_preview(rerun_preview): key = _edge_key(edge) - if not all(key) or key in seen: + from_node, to_node, relation, _source_event_refs = key + if not from_node or not to_node or not relation or key in seen: continue seen.add(key) merged_edges.append(edge) diff --git a/tools/idea_to_spec_rerun_preview.py b/tools/idea_to_spec_rerun_preview.py index 12db98a3..e3b85ae0 100644 --- a/tools/idea_to_spec_rerun_preview.py +++ b/tools/idea_to_spec_rerun_preview.py @@ -615,9 +615,17 @@ def _command_event_index( return by_event +def _has_boundary_workflow_inputs(event_storming: dict[str, list[dict[str, Any]]]) -> bool: + for raw_command in _list(event_storming.get("commands")): + command = _dict(raw_command) + if _text_list(command.get("actor_refs")) or _text_list(command.get("produces_event_refs")): + return True + return False + + def _append_workflow_edge( edges: list[dict[str, Any]], - seen: set[tuple[str, str, str]], + seen: set[tuple[str, str, str, tuple[str, ...]]], *, edge_id: str, from_node: str, @@ -628,7 +636,7 @@ def _append_workflow_edge( ) -> None: if not from_node or not to_node: return - key = (from_node, to_node, relation) + key = (from_node, to_node, relation, tuple(source_event_refs)) if key in seen: return seen.add(key) @@ -660,10 +668,25 @@ def _workflow_topology_preview( for category in EVENT_STORMING_CATEGORIES } boundary_id = "candidate-spec.product-boundary" + node_ids = {_text(_dict(node).get("id")) for node in _list(candidate_graph.get("nodes"))} + boundary_available = boundary_id in node_ids nodes_by_ref = _nodes_by_source_ref(candidate_graph) command_events = _command_event_index(event_storming) edges: list[dict[str, Any]] = [] - seen: set[tuple[str, str, str]] = set() + findings: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str, tuple[str, ...]]] = set() + if not boundary_available and _has_boundary_workflow_inputs(event_storming): + findings.append( + _finding( + finding_id="workflow_topology_boundary_missing", + severity="review_required", + message=( + "Workflow topology preview requires the product boundary candidate node " + "before boundary-based workflow edges can be emitted." + ), + evidence={"boundary_id": boundary_id}, + ) + ) for raw_command in _list(event_storming.get("commands")): command = _dict(raw_command) command_ref = _text(command.get("id")) @@ -672,10 +695,25 @@ def _workflow_topology_preview( command_ref, allowed_kinds={"behavior_requirement"}, ) - if not command_ref or not command_node: + if not command_ref: + continue + if not command_node: + findings.append( + _finding( + finding_id="workflow_topology_endpoint_unmapped", + severity="review_required", + message="Workflow relation endpoint does not map to a candidate graph node.", + evidence={ + "source_ref": command_ref, + "expected_kind": "behavior_requirement", + }, + ) + ) continue command_slug = command_node.removeprefix("candidate-spec.") for actor_ref in _text_list(command.get("actor_refs")): + if not boundary_available: + continue _append_workflow_edge( edges, seen, @@ -687,6 +725,8 @@ def _workflow_topology_preview( extra={"actor_ref": actor_ref, "command_ref": command_ref}, ) for event_ref in _text_list(command.get("produces_event_refs")): + if not boundary_available: + continue _append_workflow_edge( edges, seen, @@ -706,7 +746,22 @@ def _workflow_topology_preview( entry_ref, allowed_kinds={f"{kind}_constraint"}, ) - if not entry_ref or not entry_node: + if not entry_ref: + continue + if not entry_node: + findings.append( + _finding( + finding_id="workflow_topology_endpoint_unmapped", + severity="review_required", + message=( + "Workflow relation endpoint does not map to a candidate graph node." + ), + evidence={ + "source_ref": entry_ref, + "expected_kind": f"{kind}_constraint", + }, + ) + ) continue entry_slug = entry_node.removeprefix("candidate-spec.") relation = ( @@ -718,6 +773,22 @@ def _workflow_topology_preview( command_ref, allowed_kinds={"behavior_requirement"}, ) + if not command_node: + findings.append( + _finding( + finding_id="workflow_topology_endpoint_unmapped", + severity="review_required", + message=( + "Workflow relation endpoint does not map to a candidate graph node." + ), + evidence={ + "source_ref": command_ref, + "expected_kind": "behavior_requirement", + f"{kind}_ref": entry_ref, + }, + ) + ) + continue command_slug = command_node.removeprefix("candidate-spec.") _append_workflow_edge( edges, @@ -736,6 +807,24 @@ def _workflow_topology_preview( command_ref, allowed_kinds={"behavior_requirement"}, ) + if not command_node: + findings.append( + _finding( + finding_id="workflow_topology_endpoint_unmapped", + severity="review_required", + message=( + "Workflow relation endpoint does not map to a candidate " + "graph node." + ), + evidence={ + "source_ref": command_ref, + "expected_kind": "behavior_requirement", + "event_ref": event_ref, + f"{kind}_ref": entry_ref, + }, + ) + ) + continue command_slug = command_node.removeprefix("candidate-spec.") _append_workflow_edge( edges, @@ -766,6 +855,7 @@ def _workflow_topology_preview( "topology_relation_counts": relation_counts, "review_only": True, "materialization_dependency": False, + "findings": findings, } @@ -1282,6 +1372,7 @@ def build_idea_to_spec_rerun_preview( candidate_graph=candidate_graph, event_storming_preview=event_storming_preview, ) + findings.extend(_list(workflow_topology_preview.get("findings"))) ontology_gap_preview = _ontology_gap_preview( candidate_graph=candidate_graph, overlay=overlay, diff --git a/tools/real_idea_answer_authoring.py b/tools/real_idea_answer_authoring.py index b9b5a392..50abe926 100644 --- a/tools/real_idea_answer_authoring.py +++ b/tools/real_idea_answer_authoring.py @@ -350,7 +350,15 @@ def _field_present(value: Any, field: str) -> bool: if not isinstance(current, dict): return False if key == "relations": - return bool(_list(current.get(key))) + for raw_relation in _list(current.get(key)): + relation = _dict(raw_relation) + if ( + _text(relation.get("relation")) + and _text(relation.get("source_ref")) + and _text(relation.get("target_ref")) + ): + return True + return False return bool(_text_list(current.get(key))) if not isinstance(current, dict): return False