From e2ecfea69b61489b6d3f35576cddf8491515bafe Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 10 Sep 2026 10:06:55 -0700 Subject: [PATCH] Checkpoint source 7 workflows and in-progress diagram authoring redesign Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope. --- .gitattributes | 3 + AGENTS.md | 13 + DEVELOPMENT_SUMMARY.md | 74 + README.md | 4 +- .../oa-core/examples/release_dense_views.rs | 1 + crates/oa-core/src/demo.rs | 1 + crates/oa-core/src/language.rs | 39 +- .../oa-core/src/language/semantic_source.rs | 4 + crates/oa-core/src/lib.rs | 2 + crates/oa-core/src/model.rs | 72 +- crates/oa-core/src/operations.rs | 345 +++++ crates/oa-core/src/persistence.rs | 76 +- crates/oa-core/src/references.rs | 29 + crates/oa-core/src/semantic/catalog.rs | 41 +- crates/oa-core/src/semantic/historical.rs | 25 + crates/oa-core/src/semantic/mutate.rs | 6 +- crates/oa-core/src/semantic/types.rs | 3 + crates/oa-core/src/session.rs | 1 + crates/oa-core/src/source_project.rs | 6 +- crates/oa-core/src/topology_context.rs | 352 +++++ .../oa-core/src/topology_context/analysis.rs | 529 +++++++ .../src/topology_context/analysis_tests.rs | 172 +++ crates/oa-core/src/transitions.rs | 104 +- crates/oa-core/src/validation.rs | 156 +- crates/oa-core/src/view_options.rs | 24 +- crates/oa-core/src/view_options_tests.rs | 1 + .../tests/communication_boundary_scenarios.rs | 1 + crates/oa-core/tests/communication_means.rs | 20 +- crates/oa-core/tests/coverage_diagnostics.rs | 3 + crates/oa-core/tests/language.rs | 54 +- crates/oa-core/tests/port_migration.rs | 2 +- crates/oa-core/tests/r3_constructor_ack.rs | 1 + crates/oa-core/tests/r3_migration.rs | 2 +- crates/oa-core/tests/r3_semantic.rs | 1 + crates/oa-core/tests/r3_source.rs | 2 +- crates/oa-core/tests/r6_data.rs | 1 + crates/oa-core/tests/r6_migration.rs | 2 +- crates/oa-core/tests/r6_source.rs | 4 +- crates/oa-core/tests/release_scenarios.rs | 1 + crates/oa-core/tests/release_topology.rs | 1 + crates/oa-core/tests/source6_view_contract.rs | 3 +- crates/oa-core/tests/source7_contract.rs | 1294 +++++++++++++++++ crates/oa-core/tests/source_project.rs | 4 +- crates/oa-desktop/src/app.rs | 76 +- .../src/app/architecture_authoring.rs | 683 +++++++++ .../src/app/architecture_authoring/palette.rs | 268 ++++ .../src/app/architecture_authoring_tests.rs | 328 +++++ crates/oa-desktop/src/app/checkpoints.rs | 19 +- crates/oa-desktop/src/app/process_canvas.rs | 79 + crates/oa-desktop/src/behavior.rs | 29 +- crates/oa-desktop/src/behavior/scenario.rs | 180 ++- .../src/behavior/scenario_operation_tests.rs | 304 ++++ crates/oa-desktop/src/behavior/topology.rs | 681 ++++++--- .../src/behavior/topology/contextual.rs | 787 ++++++++++ .../behavior/topology/contextual_routing.rs | 417 ++++++ .../src/behavior/topology/direct.rs | 116 +- .../src/behavior/topology/direct_tests.rs | 1 + .../src/behavior/topology/layout_tests.rs | 243 ++++ .../oa-desktop/src/behavior/topology/view.rs | 646 ++++---- crates/oa-desktop/src/data_values.rs | 115 ++ crates/oa-desktop/src/diagram.rs | 51 +- .../oa-desktop/src/diagram/lifecycle_tests.rs | 1 + .../oa-desktop/src/diagram/pointer_tests.rs | 1 + .../oa-desktop/src/diagram/port_hit_tests.rs | 1 + crates/oa-desktop/src/diagram/port_labels.rs | 1 + crates/oa-desktop/src/forms.rs | 37 +- crates/oa-desktop/src/forms/context_fields.rs | 263 ++++ crates/oa-desktop/src/forms/data_fields.rs | 156 +- crates/oa-desktop/src/guide.rs | 79 +- crates/oa-desktop/src/model_menu.rs | 51 +- crates/oa-desktop/src/recovery.rs | 117 ++ .../src/recovery/fixtures/README.md | 7 + .../src/recovery/fixtures/profiles.json | 12 + .../src/recovery/fixtures/source5-bundle.json | 35 + .../src/recovery/fixtures/source5-model.json | 1 + .../src/recovery/fixtures/source6-bundle.json | 35 + .../src/recovery/fixtures/source6-model.json | 1 + crates/oa-desktop/src/recovery/source5.rs | 97 +- .../oa-desktop/src/recovery/source5_tests.rs | 10 +- .../oa-desktop/src/recovery/source7_tests.rs | 444 ++++++ crates/oa-desktop/src/semantic_review.rs | 17 + .../src/semantic_review/attachments.rs | 11 + .../src/semantic_review/attachments_tests.rs | 5 + .../oa-desktop/src/semantic_review/batch.rs | 78 +- .../oa-desktop/src/semantic_review/compose.rs | 68 +- .../src/semantic_review/constructor_tests.rs | 4 + .../src/semantic_review/keyed_rows_tests.rs | 1 + .../src/semantic_review/message_kind_tests.rs | 2 + .../src/semantic_review/recovery.rs | 17 +- crates/oa-desktop/src/semantic_review/row.rs | 22 +- .../oa-desktop/src/semantic_review/scalar.rs | 42 +- .../src/semantic_review/source7_tests.rs | 350 +++++ .../oa-desktop/src/semantic_review/tests.rs | 1 + crates/oa-desktop/src/semantic_review/view.rs | 78 +- crates/oa-desktop/src/views.rs | 205 ++- crates/oa-desktop/src/views/traceability.rs | 215 +++ crates/oa-desktop/src/workflow.rs | 352 ++++- .../src/workflow/transition_tests.rs | 259 ++++ crates/oa-server/src/lib.rs | 3 + .../oa-server/src/preparation_http_tests.rs | 151 ++ crates/oa-server/src/routes.rs | 6 +- crates/oa-server/src/store.rs | 10 + .../oa-server/src/store_preparation_gate.rs | 141 ++ crates/oa-server/tests/http.rs | 87 +- .../tests/http/communication_limits.rs | 73 +- crates/oa-server/tests/http/data_contracts.rs | 9 +- .../tests/http/semantic_transitions.rs | 1 + crates/oa-server/tests/http/source6_views.rs | 2 +- .../oa-server/tests/http/source7_contracts.rs | 326 +++++ crates/oa-server/tests/semantic_schema.py | 79 +- docs/api.html | 122 +- docs/api.rst | 96 +- docs/language.html | 295 +++- docs/language.rst | 202 ++- docs/openapi.json | 436 +++++- examples/release1/README.md | 4 +- examples/release1/model.json | 2 +- examples/release1/model.rs | 3 + examples/release1/project.arcadia | 2 +- examples/release1/transition-before.json | 2 +- harness/DECISIONS.md | 11 + harness/PLAN.md | 28 +- harness/STATE.md | 51 +- harness/TASK_LEDGER.md | 34 +- harness/basic-editing-contract.rs | 514 +++++++ harness/fixtures/basic-editing/README.md | 56 + .../basic-editing/base/expected-baseline.json | 167 +++ .../basic-editing/base/project.arcadia | 108 ++ .../basic-editing/base/rationale.arcadia | 1 + harness/fixtures/basic-editing/manifest.json | 17 + harness/fixtures/source7-native/README.md | 115 ++ .../base/expected-baseline.json | 936 ++++++++++++ .../source7-native/base/project.arcadia | 477 ++++++ .../source7-native/base/rationale.arcadia | 1 + harness/fixtures/source7-native/manifest.json | 14 + harness/native-release1-review-check.rs | 49 +- harness/recovery-profile-fixtures.rs | 176 +++ .../release-basic-editing-reset-20260907.md | 46 + .../release-capella-basic-editing-20260907.md | 88 ++ ...se-contextual-route-native-fix-20260907.md | 78 + ...se-deployability-handoff-audit-20260907.md | 18 + ...p-preparation-phase-regression-20260907.md | 24 + ...elease-linux-cross-build-probe-20260906.md | 96 ++ ...release-linux-gui-runtime-plan-20260907.md | 154 ++ harness/release-linux-validation-options.md | 107 ++ ...process-fit-independent-review-20260907.md | 39 + ...lease-process-fit-layout-audit-20260907.md | 188 +++ ...elease-source7-api-integration-20260906.md | 45 + ...lease-source7-checkpoint-audit-20260907.md | 79 + ...elease-source7-core-checkpoint-20260906.md | 38 + ...ease-source7-desktop-authoring-20260906.md | 70 + ...ce7-desktop-independent-review-20260906.md | 25 + ...e-source7-integration-decision-20260906.md | 4 +- .../release-source7-linux-gate-20260907.md | 85 ++ ...-source7-linux-gui-cross-build-20260907.md | 115 ++ ...e7-linux-server-corrected-gate-20260907.md | 52 + ...ease-source7-native-acceptance-20260907.md | 101 ++ ...ld5-restored-checkpoint-oracle-20260907.md | 11 + ...ing-contextual-recovery-oracle-20260907.md | 111 ++ harness/release-source7-recovery-20260906.md | 28 + harness/release-teaching-native-20260906.md | 19 + harness/source7-native-contract.rs | 515 +++++++ 162 files changed, 17595 insertions(+), 926 deletions(-) create mode 100644 AGENTS.md create mode 100644 DEVELOPMENT_SUMMARY.md create mode 100644 crates/oa-core/src/operations.rs create mode 100644 crates/oa-core/src/topology_context.rs create mode 100644 crates/oa-core/src/topology_context/analysis.rs create mode 100644 crates/oa-core/src/topology_context/analysis_tests.rs create mode 100644 crates/oa-core/tests/source7_contract.rs create mode 100644 crates/oa-desktop/src/app/architecture_authoring.rs create mode 100644 crates/oa-desktop/src/app/architecture_authoring/palette.rs create mode 100644 crates/oa-desktop/src/app/architecture_authoring_tests.rs create mode 100644 crates/oa-desktop/src/behavior/scenario_operation_tests.rs create mode 100644 crates/oa-desktop/src/behavior/topology/contextual.rs create mode 100644 crates/oa-desktop/src/behavior/topology/contextual_routing.rs create mode 100644 crates/oa-desktop/src/forms/context_fields.rs create mode 100644 crates/oa-desktop/src/recovery/fixtures/README.md create mode 100644 crates/oa-desktop/src/recovery/fixtures/profiles.json create mode 100644 crates/oa-desktop/src/recovery/fixtures/source5-bundle.json create mode 100644 crates/oa-desktop/src/recovery/fixtures/source5-model.json create mode 100644 crates/oa-desktop/src/recovery/fixtures/source6-bundle.json create mode 100644 crates/oa-desktop/src/recovery/fixtures/source6-model.json create mode 100644 crates/oa-desktop/src/recovery/source7_tests.rs create mode 100644 crates/oa-desktop/src/semantic_review/source7_tests.rs create mode 100644 crates/oa-desktop/src/views/traceability.rs create mode 100644 crates/oa-desktop/src/workflow/transition_tests.rs create mode 100644 crates/oa-server/src/preparation_http_tests.rs create mode 100644 crates/oa-server/src/store_preparation_gate.rs create mode 100644 crates/oa-server/tests/http/source7_contracts.rs create mode 100644 harness/basic-editing-contract.rs create mode 100644 harness/fixtures/basic-editing/README.md create mode 100644 harness/fixtures/basic-editing/base/expected-baseline.json create mode 100644 harness/fixtures/basic-editing/base/project.arcadia create mode 100644 harness/fixtures/basic-editing/base/rationale.arcadia create mode 100644 harness/fixtures/basic-editing/manifest.json create mode 100644 harness/fixtures/source7-native/README.md create mode 100644 harness/fixtures/source7-native/base/expected-baseline.json create mode 100644 harness/fixtures/source7-native/base/project.arcadia create mode 100644 harness/fixtures/source7-native/base/rationale.arcadia create mode 100644 harness/fixtures/source7-native/manifest.json create mode 100644 harness/recovery-profile-fixtures.rs create mode 100644 harness/release-basic-editing-reset-20260907.md create mode 100644 harness/release-capella-basic-editing-20260907.md create mode 100644 harness/release-contextual-route-native-fix-20260907.md create mode 100644 harness/release-deployability-handoff-audit-20260907.md create mode 100644 harness/release-http-preparation-phase-regression-20260907.md create mode 100644 harness/release-linux-cross-build-probe-20260906.md create mode 100644 harness/release-linux-gui-runtime-plan-20260907.md create mode 100644 harness/release-process-fit-independent-review-20260907.md create mode 100644 harness/release-process-fit-layout-audit-20260907.md create mode 100644 harness/release-source7-api-integration-20260906.md create mode 100644 harness/release-source7-checkpoint-audit-20260907.md create mode 100644 harness/release-source7-core-checkpoint-20260906.md create mode 100644 harness/release-source7-desktop-authoring-20260906.md create mode 100644 harness/release-source7-desktop-independent-review-20260906.md create mode 100644 harness/release-source7-linux-gate-20260907.md create mode 100644 harness/release-source7-linux-gui-cross-build-20260907.md create mode 100644 harness/release-source7-linux-server-corrected-gate-20260907.md create mode 100644 harness/release-source7-native-acceptance-20260907.md create mode 100644 harness/release-source7-old5-restored-checkpoint-oracle-20260907.md create mode 100644 harness/release-source7-pending-contextual-recovery-oracle-20260907.md create mode 100644 harness/release-source7-recovery-20260906.md create mode 100644 harness/source7-native-contract.rs diff --git a/.gitattributes b/.gitattributes index ead553c1..5f9a2625 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,6 @@ harness/fixtures/release1-native/** -text harness/fixtures/interface-operation-probe/** -text harness/history/2026-09-06-ledgers-before-consolidation/** -text +crates/oa-desktop/src/recovery/fixtures/*.json -text +harness/fixtures/source7-native/** -text +harness/fixtures/basic-editing/** -text diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c273b641 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +## Cooperation through Gitea MCP + +Use Gitea MCP to coordinate with other agents working on this project. + +- Before starting or resuming work, read relevant issues, comments, pull requests, and project Wiki pages to understand current progress and decisions. +- Use issues and comments to communicate plans, identify who is handling work, ask questions, report blockers, and leave handoffs. Check existing work before creating duplicates. +- Identify yourself consistently in messages. Do not assume another agent has read a message or is currently running. +- Respect work already in progress. Coordinate overlapping changes and preserve other agents' edits. +- Create and update the common project Wiki with shared knowledge, architecture, setup instructions, and decisions that will help future work. Keep it accurate as the project changes. +- Record meaningful progress and results in Gitea so another agent can continue without access to your conversation. Include relevant commits, validation results, and next steps. +- Keep coordination concise and useful. Use the available MCP tools, and report access failures honestly. + +Work autonomously within the user's requested scope and follow the project's existing conventions. diff --git a/DEVELOPMENT_SUMMARY.md b/DEVELOPMENT_SUMMARY.md new file mode 100644 index 00000000..c6f44559 --- /dev/null +++ b/DEVELOPMENT_SUMMARY.md @@ -0,0 +1,74 @@ +# ARCADIA Studio — development-run summary + +Latest correction: your review identified a misplaced priority in this run. Advanced counterpart review received attention while basic diagram editing remained awkward. The first attempted fix added large creation buttons; you rejected that too. Actual use of the installed Capella 7.1 has now informed a redesign around a compact contextual palette, in-place naming and renaming, relevant existing elements, explicit activity allocation and clear counterpart destinations. Implementation and native verification remain in progress; the downloadable preview described below predates these corrections. [Observed Capella behavior and design decisions](harness/release-capella-basic-editing-20260907.md). + +Updated 7 September 2026. This summarizes the whole continuation run: work integrated from earlier branches, new engineering features, defects found through actual use, and verification on this machine. **The product has advanced substantially, but the complete Windows/Linux release has not yet been accepted.** The latest Windows preview is built, packaged and available for your inspection below. + +## Objective and starting point + +The objective is a usable Operational Analysis (OA) and System Analysis (SA) engineering application, with equivalent graphical, textual and REST workflows, maintained traceability, offline guidance, and working Windows/Linux distributions. The run treated domain semantics, interaction design, recovery, examples and verification as part of the product—not merely as supporting documentation. + +The initial checkout lagged behind other branches. An ancestry, patch-equivalence and code audit found that direct process/scenario/state editing, data contracts, hosted integration and earlier semantic review were already combined in `codex/data-contract-integration`. Work continued from that baseline on `codex/release1-continuation`; duplicate preliminary changes were preserved rather than overwriting integrated work. Source6 work and its evidence were subsequently committed at `4b6b5fc`; source7 and later improvements remain a separately reviewed continuation. No remote publication is implied. [Branch integration](harness/release-branch-integration-20260906.md) + +## Engineering features and editing workflows + +The integrated foundation includes architecture views and ports; typed data, interfaces and values; direct scenario/process gestures; and persisted state-machine views with regions, pseudostates, transitions and maintained action references. The run extended and tested those workflows rather than replacing their shared model. + +Source6 added first-class OA **Communication Means** between participants, with allocated interactions and explicit direction. OA→SA transition requires reviewed component-port choices; it does not silently invent responsibility or substitute a participant for a port. Scenario endpoint resolution handles reverse communication on unoriented channels and correlated Call/Reply behavior. Native testing also corrected transitions that left users on the wrong layer or diagram despite producing the right model. + +Saved views gained exchange categories, hidden-edge choices, relation-kind selection, behavior coverage/context filters and exchange-name/item labels. Filters preserve semantic identities and stable port geometry. Traceability views explicitly support both layers. Advisory checks now locate incomplete behavior coverage, non-leaf work exchanges and mission/capability gaps without turning legitimate unfinished engineering work into structural corruption. [Coverage and implementation map](harness/release-current-coverage-20260906.md) + +Semantic reconciliation became an editable, atomic **multi-source review**. Engineers can review scalar values, constructors, keyed collections, ordered members, child ownership, incident relationships and artifact lifecycle changes. Included rows compose through the shared planner; excluded invalid rows retain their raw input. Conflicting intentions are checked against the actual destination field. Artifact creation, movement, deletion and all seven metadata fields preserve distinctions such as absent versus empty text. Native tests exercised complete Apply/Save/Undo/Redo cycles, not just individual controls. [Artifact evidence](harness/release-artifacts-native-20260906.md), [attachment evidence](harness/release-attachments-native-20260906.md) + +The optional hosted workflow continues to use the same core model and REST service, with conditional ETags, explicit publish/save, conflict handling and retained local drafts. Connection credentials remain in memory. Typed commands, modular source, diagnostics and OpenAPI were kept aligned as representations evolved. Existing hosted acceptance is recorded separately from still-required current-platform transport cases. [Hosted workflow](harness/release-hosted-desktop.md) + +## Source, history, recovery and onboarding + +Home now offers New/Open, examples, the guide, recent local models and recoverable sessions. Missing recent files remain visible with a recovery route. The searchable offline engineering guide has related topics and guarded workspace actions; navigation and scroll behavior were corrected through minimum-window testing. + +Periodic user-local checkpoints preserve the accepted source bundle and raw source, property, behavior, workflow and semantic-review drafts. They do not silently save model files or restore an executable command plan. Recovery creates an unsaved copy, rebuilds previews and requires explicit saving. Write preflight preserves the last good checkpoint if a new snapshot is invalid; close/write races and stale save receipts were covered with regressions. + +Frozen, nonempty old5/old6 fixtures support bounded migration to source7. Accepted headers are upgraded while comments, includes and raw invalid edits are retained. Unsupported newer intent hidden in old structural fields or constructor caches is rejected without destroying the checkpoint; ordinary prose containing those words remains valid raw text. Native old5, old6 and current7 recovery cases compared full bundles, model exports and draft DTOs. [Recovery contract](harness/release-source7-recovery-20260906.md) + +A real Save As defect was fixed: changing the entry filename had created an extra Undo transaction and cleared Redo. Save As now adopts file identity across retained history without an artificial model edit, with conservative filename-collision preflight before disk writes. Journal publication was hardened against Windows scanner/share races and accidental takeover of another writer; Linux uses no-replace publication with fail-closed unsupported behavior. These safeguards do not claim model-file compare-and-swap or universal power-loss guarantees. [Save As](harness/release-save-as-runtime-packaging.md), [journal review](harness/release-journal-publication-review-20260906.md) + +A coherent water-pressure restoration lesson now connects OA responsibilities and communication to explicit SA ports, interfaces, processes, scenarios, state behavior, saved views and evidence. It contains 94 elements, 62 relations, five views, 68 traces and 30 source files, plus deterministic replay inputs and one deliberate warning. Home and the guide open it. Copy the **whole example folder**, including its evidence, before editing. [Walkthrough](examples/release1/README.md) + +## Source7: two substantive semantic gaps closed + +Independent probes established two gaps that existing constructs could not faithfully represent: + +* **Caller-specific process assembly:** ContextualLink addresses include the caller and complete reference-occurrence path. The same leaf reused through two references remains two distinct contributions. Precedence, exchange assembly and common-work purposes have separate meanings; common work does not imply execution fusion. Deletion removes dependent links rather than silently shortening their addresses. +* **Exact interface-operation identity:** Messages can retain unresolved operation intent or select a specific InterfaceItemAllocation. Selection requires proof through actual compatible component ports. Reply derives its operation from the exact Call while validating its own channel and correlation; parameterless operations remain distinguishable. Removing an allocation never silently selects another equivalent-looking allocation, and ordinary channel validity still applies. + +Both landed under one source/schema7 contract across core, parser/updater, REST, graphical forms, semantic review and recovery. Old versions reject new fields even when explicitly null. Reference/path admission and bounded contextual analysis are deterministic and independent of cache size; exhausted analysis reports incompleteness rather than fabricated reachability. [Ratified contract](harness/release-source7-integration-decision-20260906.md) + +Native inspection then exposed obscured contextual routes, endpoint hit-priority conflicts, and a separate process Fit problem. Routing and qualified selection were corrected and independently checked. The latest Fit change measures the actual remaining viewport, adds bounded tools/details disclosures and keeps pending-form actions reachable. Its first six tests caught two real failures: Iced discarded zero-size slots and reset the canvas tree. A narrow slot-preserving fix passed all six unchanged expectations, the existing pending-scroll and branch-fit regressions, and the full desktop suite. [Fit review](harness/release-process-fit-independent-review-20260907.md) + +## What has actually been verified + +Windows checkpoints include core **433 passed/1 ignored**, corrected full server **56/56**, and now full desktop **570/570** after the Fit fix. The latest locked workspace binaries build, formatting and strict workspace/all-target Clippy all passed. Native checks used separately hashed binaries and independent complete-model/source oracles for artifacts, batch review, communication, teaching edits, source7 operation choices, nested links and crash recovery—including Save, Undo, Redo and reopening. These are scoped results, not one blanket final-release pass. [Current evidence ledger](harness/STATE.md), [source7 native report](harness/release-source7-native-acceptance-20260907.md) + +Linux verification ran in a real Ubuntu guest under QEMU using Rust1.90, ext4 and immutable source snapshots. Current core completed **436 passed/2 ignored**. Server compilation passed; unit **2/2** and HTTP **50/51** passed. One timing-dependent test returned the legitimate 30-second deadline response504 instead of412, including an isolated rerun. Its replacement uses a private test-only preparation gate to prove real HTTP overlap and retain exact conflict/model/disk assertions; that correction passed on Windows and still needs Linux execution. [Linux gate](harness/release-source7-linux-gate-20260907.md) + +WSL installation was authorized, but importing the isolated distro remains pending a Windows restart. QEMU resources, shutdown budgets and caches were preserved. A cross-built probe actually executed on Linux. After VMware clock adjustments interrupted one watchdog and another run paused for your Windows build, the **full Linux desktop cross-build passed** with Rust1.90 on a fresh snapshot containing the Fit fix. Its final cached build took2m20s; the ELF dependencies, glibc2.28 link baseline and all37 teaching files were checked. Actual Linux GUI execution remains unverified. The built snapshot precedes the subsequent guide-label wording update. [Cross-build status](harness/release-source7-linux-gui-cross-build-20260907.md) + +The next Linux test session failed during kernel startup on three bounded attempts, before any corrected server tests ran. We preserved the failures and extracted the exact verified Ubuntu boot assets for a temporary timer-check workaround. Automatic approval review rejected its preparation/launch command as “blocked by policy,” so that experiment did not run. All task VM processes are stopped. [Boot evidence and pending gate](harness/release-source7-linux-server-corrected-gate-20260907.md) + +## Latest preview and remaining release work + +**Run the latest Windows preview:** open [arcadia-studio.exe](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm/arcadia-studio.exe). Keep its folder intact, including `libunwind.dll`; no compiler installation or PATH change is needed. To move it elsewhere, extract the whole [preview ZIP](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip). Home opens the guide and examples. This is a development-profile preview, not the final optimized release. + +Or run this in PowerShell: + +```powershell +& 'C:\Users\User\Documents\Git\ARCADIA-Studio\dist\ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm\arcadia-studio.exe' +``` + +The package contains 565 files, including all 37 teaching files; independent integrity verification and source/build provenance are recorded alongside the ZIP. The packaged executable's SHA-256 begins `DC50C887`; the ZIP's is `1fac10b30daf2f8e1dac29e9469485e4bea142e77ff5a13e4c9d1e41ff03abb2`. [Provenance](dist/ArcadiaStudio-preview-source7-20260907-x86_64-pc-windows-gnullvm.zip.provenance.json) + +The packaged app launched with a clean PATH. At **1444×932**, the accepted OA caller graph fitted at **68%** with all four cards visible. A **1102×932** width was also observed; this does **not** establish the 700-pixel minimum-height case. Your Escape stopped Computer Use before the final UI checks; no clean close is claimed. A subsequent read-only oracle confirmed the saved model and both source files were unchanged; this does not establish unobserved in-memory state. Broader native acceptance remains open. + +The subsequent guide update names the actual Tools, Selection details, Restore panels and Reply operation controls. Formatting, independent label review and a fresh Windows workspace-binary build passed; the downloadable preview above retains its original, already-tested bytes. + +Remaining work is to finish native Fit checks, complete outstanding engineering/hosted workflows, execute the corrected Linux server gate, verify actual Linux GUI behavior, and produce optimized, independently reviewed final packages including the updated guide. Export is explicitly **Architecture SVG**; faithful process/scenario/state SVG is deferred. Logical/physical architecture layers and simulation are outside this OA/SA release. The applicable Windows/Linux acceptance obligations remain open. [Release plan](harness/PLAN.md) diff --git a/README.md b/README.md index 6d4e361d..7b8bdb86 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ In the state view, **Select / move** drags vertices and containers without repar **Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example: ```text -arcadia 6; +arcadia 7; project harbor "Harbor assistance" { oa_entity control "Harbor control" {} oa_activity respond "Respond to requests" { @@ -153,7 +153,7 @@ To preserve authored files and comments, use `POST /api/v1/projects/import-sourc ## Development -The 2026-09-06 source-6 verification accounts for **943 passing tests and one existing ignored benchmark** across the full workspace run and complete affected-suite reruns after three compatibility-expectation corrections. The **48 HTTP tests** are included in that coverage, not additional to it. Source-6 OpenAPI validation passes 39 fixtures and checks 197 component schemas. [Current state](harness/STATE.md) identifies the exact logs and outstanding checks; this evidence does not claim one uninterrupted green workspace run or accept subsequent edits. Fresh GUI/server builds, strict checks for the final tree, source-6 native workflows, Linux/MSRV verification and current release packages remain release gates. +Source7 integration adds caller-qualified reusable-process links and explicit interface-operation identity. Its [contract decision](harness/release-source7-integration-decision-20260906.md) and [current state](harness/STATE.md) distinguish prepared code from tested behavior. Current OpenAPI validation passes72structural fixtures across204schemas; source7 core, desktop, HTTP, recovery and native acceptance remain separate gates. Earlier source6 Windows and Linux results retain their exact recorded revision scope. Final full suites, strict checks, optimized/MSRV builds and current release packages are still required. ```sh cargo fmt --all -- --check diff --git a/crates/oa-core/examples/release_dense_views.rs b/crates/oa-core/examples/release_dense_views.rs index fe226389..acc62bac 100644 --- a/crates/oa-core/examples/release_dense_views.rs +++ b/crates/oa-core/examples/release_dense_views.rs @@ -187,6 +187,7 @@ fn fixture() -> (Project, Id, Vec, Vec, Vec) { .shared_mut() .unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: Some(exchanges[index]), sender: None, receiver: None, diff --git a/crates/oa-core/src/demo.rs b/crates/oa-core/src/demo.rs index 1d3ab09a..93cb45e9 100644 --- a/crates/oa-core/src/demo.rs +++ b/crates/oa-core/src/demo.rs @@ -15,6 +15,7 @@ fn occurrence( let id = add(p, Kind::Occurrence, layer, name, Some(owner)); *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender, receiver, diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index dc6aee3b..37d42e7f 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -84,7 +84,20 @@ impl ParseLimits { .elements .values() .map(|e| e.steps().map_or(0, Vec::len)) - .sum(); + .sum::() + .saturating_add( + project + .relations + .values() + .map(|r| match &r.body { + RelationBody::ContextualLink { source, target, .. } => source + .references + .len() + .saturating_add(target.references.len()), + _ => 0, + }) + .sum::(), + ); self.check_counts(graph, records, steps) } } @@ -343,6 +356,10 @@ fn reference_field(key: &str) -> bool { | "local" | "field" | "path" + | "references" + | "occurrence" + | "allocation" + | "work" | "supertype" | "sender" | "receiver" @@ -1386,9 +1403,10 @@ impl Parser<'_> { fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result { let mut header = if entry { self.take("arcadia")?; - if !matches!(self.text(), "3" | "4" | "5" | "6") { - return Err(self - .error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;', 'arcadia 5;' or 'arcadia 6;'.")); + if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") { + return Err( + self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.") + ); } let schema_version = self.text().parse().expect("checked source version"); self.cursor += 1; @@ -1606,6 +1624,7 @@ pub(crate) fn resolve_declarations( }; match d.kind { DeclKind::Element(kind, layer) => { + if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; } if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) { return Err(conversion_error(std::format!("{kind:?} requires schema/source 6."))); } @@ -1683,6 +1702,7 @@ pub(crate) fn resolve_declarations( ); } DeclKind::Relation(kind) => { + if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); } if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) { return Err(conversion_error(std::format!("{kind:?} requires schema/source 6."))); } @@ -1691,7 +1711,9 @@ pub(crate) fn resolve_declarations( } let description = take_string(&mut fields, "description", "").map_err(&conversion_error)?; - let required: &[&str] = if kind == RelationKind::FunctionalExchange { + let required: &[&str] = if kind == RelationKind::ContextualLink { + &["owner", "source", "target", "purpose"] + } else if kind == RelationKind::FunctionalExchange { &["source", "target", "source_port", "target_port"] } else { &["source", "target"] @@ -1722,6 +1744,7 @@ pub(crate) fn resolve_declarations( ); } DeclKind::Diagram => { + if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; } if project.schema_version < 6 && fields.contains_key("view_options") { return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into())); } @@ -1852,11 +1875,13 @@ pub(crate) fn resolve_declarations( } return Ok(migrated); } - if project.schema_version == 4 || project.schema_version == 5 { + if matches!(project.schema_version, 4..=6) { return (if project.schema_version == 4 { crate::persistence::v4::finish(project, limits) - } else { + } else if project.schema_version == 5 { crate::persistence::migrate_v5(project, limits) + } else { + crate::persistence::migrate_v6(project, limits) }) .map_err(|error| { ( diff --git a/crates/oa-core/src/language/semantic_source.rs b/crates/oa-core/src/language/semantic_source.rs index fa183835..fad23ef9 100644 --- a/crates/oa-core/src/language/semantic_source.rs +++ b/crates/oa-core/src/language/semantic_source.rs @@ -109,6 +109,10 @@ pub(super) fn resolve_baseline( continue; } let json = raw(value)?; + if version < 7 { + crate::persistence::reject_source7_fields(&json) + .map_err(|cause| error(value, cause))?; + } let baseline: SemanticBaseline = serde_json::from_value(json.clone()) .map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?; if version < 6 { diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 72339176..7197328c 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod data; pub mod language; pub mod model; +pub mod operations; pub mod persistence; pub mod ports; pub mod projection; @@ -11,6 +12,7 @@ pub mod semantic; pub mod session; pub mod source_files; pub mod source_project; +pub mod topology_context; pub mod transitions; pub mod validation; pub mod view_options; diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index 490f779c..041639a6 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -6,7 +6,7 @@ use std::{ use uuid::Uuid; pub type Id = Uuid; -pub const SCHEMA_VERSION: u32 = 6; +pub const SCHEMA_VERSION: u32 = 7; pub const MAX_COORDINATE: f32 = 1_000_000.; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -523,6 +523,28 @@ pub struct MessageValue { pub field: Id, pub expression: String, } +/// Explicit operation intent. A missing allocation remains an unfinished operation, +/// whereas an absent OperationUse retains ordinary channel-message semantics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OperationUse { + pub allocation: Option, +} +/// A contribution in a caller instance. Paths contain reference occurrence IDs, +/// never sequence definitions; repeated references therefore remain distinct. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OccurrenceEndpoint { + pub references: Vec, + pub occurrence: Id, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum ContextualLinkPurpose { + Precedence { guard: String }, + Exchange { exchange: Id }, + CommonWork { work: Id }, +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum OccurrenceBody { @@ -536,6 +558,8 @@ pub enum OccurrenceBody { reply_to: Option, #[serde(default)] values: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + operation: Option, }, Action { action: Option, @@ -797,6 +821,7 @@ impl ElementBody { message_kind: MessageKind::Asynchronous, reply_to: None, values: vec![], + operation: None, }, precondition: String::new(), postcondition: String::new(), @@ -863,6 +888,7 @@ pub enum RelationKind { CapabilityGeneralization, MissionInvolvement, SequenceLink, + ContextualLink, StateTransition, Availability, ExchangeAllocation, @@ -871,7 +897,7 @@ pub enum RelationKind { StateAction, } impl RelationKind { - pub const ALL: [Self; 24] = [ + pub const ALL: [Self; 25] = [ Self::InterfaceItemAllocation, Self::DataAssociation, Self::Allocation, @@ -890,6 +916,7 @@ impl RelationKind { Self::CapabilityGeneralization, Self::MissionInvolvement, Self::SequenceLink, + Self::ContextualLink, Self::StateTransition, Self::Availability, Self::ExchangeAllocation, @@ -937,7 +964,9 @@ impl RelationKind { (Kind::Entity | Kind::Actor, Kind::OperationalMission) | (Kind::System | Kind::SystemActor, Kind::Mission) ), - Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence, + Self::SequenceLink | Self::ContextualLink => { + from == Kind::Occurrence && to == Kind::Occurrence + } Self::StateTransition => from.is_state() && to.is_state(), Self::ExchangeAllocation => false, Self::InterfaceProvision | Self::InterfaceRequirement => { @@ -980,6 +1009,9 @@ impl RelationKind { Self::CapabilityGeneralization => "Specific capability → general capability", Self::MissionInvolvement => "Participant → mission in the same perspective", Self::SequenceLink => "Preceding → following contribution in the same process or chain", + Self::ContextualLink => { + "Contributions addressed in an explicit process or chain caller" + } Self::StateTransition => "State → state in one machine", Self::ExchangeAllocation => { "Communication means / boundary exchange → operational interaction / functional exchange" @@ -1012,6 +1044,7 @@ impl fmt::Display for RelationKind { Self::CapabilityGeneralization => "Capability generalization", Self::MissionInvolvement => "Mission involvement", Self::SequenceLink => "Sequence link", + Self::ContextualLink => "Contextual link", Self::StateTransition => "State transition", Self::ExchangeAllocation => "Exchange allocation", Self::InterfaceProvision => "Interface provision", @@ -1126,6 +1159,12 @@ pub enum RelationBody { target: Id, guard: String, }, + ContextualLink { + owner: Id, + source: OccurrenceEndpoint, + target: OccurrenceEndpoint, + purpose: ContextualLinkPurpose, + }, StateTransition { source: Id, target: Id, @@ -1162,6 +1201,13 @@ pub enum RelationBody { }, } impl Relation { + /// Caller ownership cannot be inferred from a contextual link's leaf IDs. + pub fn contextual_owner(&self) -> Option { + match self.body { + RelationBody::ContextualLink { owner, .. } => Some(owner), + _ => None, + } + } pub fn kind(&self) -> RelationKind { self.body.kind() } @@ -1193,6 +1239,7 @@ impl RelationBody { Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization, Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement, Self::SequenceLink { .. } => RelationKind::SequenceLink, + Self::ContextualLink { .. } => RelationKind::ContextualLink, Self::StateTransition { .. } => RelationKind::StateTransition, Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation, Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision, @@ -1203,6 +1250,7 @@ impl RelationBody { } pub fn endpoints(&self) -> (Id, Id) { match self { + Self::ContextualLink { source, target, .. } => (source.occurrence, target.occurrence), Self::InterfaceItemAllocation { source, target, .. } | Self::DataAssociation { source, target, .. } | Self::Allocation { source, target } @@ -1231,6 +1279,10 @@ impl RelationBody { } pub fn set_endpoints(&mut self, a: Id, b: Id) { match self { + Self::ContextualLink { source, target, .. } => { + source.occurrence = a; + target.occurrence = b; + } Self::InterfaceItemAllocation { source, target, .. } | Self::DataAssociation { source, target, .. } | Self::Allocation { source, target } @@ -1337,6 +1389,20 @@ impl RelationBody { target, guard: String::new(), }, + RelationKind::ContextualLink => Self::ContextualLink { + owner: Id::nil(), + source: OccurrenceEndpoint { + references: vec![], + occurrence: source, + }, + target: OccurrenceEndpoint { + references: vec![], + occurrence: target, + }, + purpose: ContextualLinkPurpose::Precedence { + guard: String::new(), + }, + }, RelationKind::StateTransition => Self::StateTransition { source, target, diff --git a/crates/oa-core/src/operations.rs b/crates/oa-core/src/operations.rs new file mode 100644 index 00000000..90f4bfc1 --- /dev/null +++ b/crates/oa-core/src/operations.rs @@ -0,0 +1,345 @@ +//! One operation resolver for admission, forms and scenario labels. +use crate::*; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedOperation { + /// The actual Call/Asynchronous declaration, also when resolving its Reply. + pub invocation: Id, + pub allocation: Id, + pub interface: Id, + pub item: Id, + pub caller_port: Id, + pub provider_port: Id, + pub transmission: TransmissionProtocol, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OperationResolution { + Ordinary, + Unresolved { invocation: Id, reason: String }, + Selected(ResolvedOperation), +} +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{message}")] +pub struct OperationError { + pub code: &'static str, + pub message: String, +} +fn error(code: &'static str, message: &str) -> OperationError { + OperationError { + code, + message: message.into(), + } +} + +pub fn resolve(p: &Project, occurrence: Id) -> Result { + let e = p + .elements + .get(&occurrence) + .ok_or_else(|| error("operation_context", "The message no longer exists."))?; + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + operation, + message_kind, + reply_to, + .. + }, + .. + }) = e.shared() + else { + return Ok(OperationResolution::Ordinary); + }; + let invocation = if *message_kind == MessageKind::Reply { + if operation.is_some() { + return Err(error( + "reply_operation", + "A Reply derives its operation from its exact Call; it cannot store a second selection.", + )); + } + let Some(call) = reply_to else { + return Ok(OperationResolution::Ordinary); + }; + if !matches!( + p.elements.get(call).and_then(Element::shared), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + .. + }, + .. + }) + ) { + return Ok(OperationResolution::Ordinary); + } + *call + } else { + occurrence + }; + let Some(SharedBody::Occurrence { + body: + body @ OccurrenceBody::Exchange { + operation: Some(operation), + message_kind, + exchange, + sender, + receiver, + .. + }, + .. + }) = p.elements.get(&invocation).and_then(Element::shared) + else { + return Ok(OperationResolution::Ordinary); + }; + if p.layer_of(invocation) != Some(Layer::Sa) + || !matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous) + || validation::ancestor_of_kind(p, invocation, Kind::Scenario) + .and_then(|id| p.elements.get(&id)) + .and_then(Element::shared) + .is_none_or(|body| { + !matches!( + body, + SharedBody::Scenario { + viewpoint: ScenarioViewpoint::BoundaryInterface, + .. + } + ) + }) + { + return Err(error( + "operation_context", + "Operation use belongs to a Call or Asynchronous message in an SA BoundaryInterface scenario.", + )); + } + let unresolved = |reason: &str| { + Ok(OperationResolution::Unresolved { + invocation, + reason: reason.into(), + }) + }; + if exchange + .and_then(|id| p.relations.get(&id)) + .is_some_and(|r| r.kind() != RelationKind::BoundaryExchange) + { + return Err(error( + "operation_channel", + "Operation use requires an explicit boundary exchange channel.", + )); + } + let Some(allocation) = operation.allocation else { + return unresolved( + "Choose the interface operation allocation; existing value expressions are preserved.", + ); + }; + let Some(relation) = p.relations.get(&allocation) else { + return Err(error( + "operation_allocation", + "The selected interface operation allocation no longer exists.", + )); + }; + let RelationBody::InterfaceItemAllocation { + source: interface, + target: item, + transmission, + .. + } = relation.body + else { + return Err(error( + "operation_allocation", + "Choose an InterfaceItemAllocation, not an item or another relationship.", + )); + }; + if p.layer_of(allocation) != Some(Layer::Sa) + || !matches!( + p.elements.get(&item).and_then(Element::shared), + Some(SharedBody::ExchangeItem { + kind: ExchangeItemKind::Information, + mechanism: ExchangeMechanism::Operation, + .. + }) + ) + { + return Err(error( + "operation_allocation", + "The selected SA allocation must declare an Information item with Operation mechanism.", + )); + } + let Some(channel) = exchange.and_then(|id| p.relations.get(&id)) else { + return Err(error( + "operation_channel", + "A selected operation requires an existing boundary exchange carrying its item; clear the allocation to retain unresolved intent.", + )); + }; + if channel.kind() != RelationKind::BoundaryExchange + || !channel + .body + .items() + .iter() + .any(|carried| data::item_satisfies(p, *carried, item)) + { + return Err(error( + "operation_channel", + "The boundary exchange must carry the selected operation item or an admitted subtype.", + )); + } + if sender.is_none() || receiver.is_none() { + return Err(error( + "operation_lifelines", + "A selected operation requires actual caller and provider lifelines; clear the allocation to retain unresolved intent.", + )); + } + let (caller_port, provider_port) = match validation::resolve_message_endpoints(p, body) { + validation::MessageEndpointResolution::Resolved { source, target } => (source, target), + validation::MessageEndpointResolution::Conflict => { + return Err(error( + "operation_direction", + "The selected lifelines conflict with the boundary channel's actual caller/provider direction.", + )); + } + _ => { + return Err(error( + "operation_direction", + "A selected operation requires a proven actual caller/provider channel direction; clear the allocation to retain unresolved intent.", + )); + } + }; + for port in [caller_port, provider_port] { + if !matches!( + p.elements.get(&port).and_then(ports::port_contract), + Some(PortContract::Component { + kind: ComponentPortKind::Standard, + .. + }) + ) { + return Err(error( + "operation_ports", + "Operation proof requires concrete Standard component ports.", + )); + } + } + let exposes = |interface| { + data::effective_interface_allocations(p, interface) + .iter() + .any(|r| r.id == allocation) + }; + let providers: Vec<_> = p + .relations + .values() + .filter(|r| { + r.kind() == RelationKind::InterfaceProvision + && r.source() == provider_port + && exposes(r.target()) + }) + .map(Relation::target) + .collect(); + let valid = p.relations.values().any(|r| { + r.kind() == RelationKind::InterfaceRequirement + && r.source() == caller_port + && exposes(r.target()) + && providers + .iter() + .any(|provided| data::interface_satisfies(p, *provided, r.target())) + }); + if !valid { + return Err(error( + "operation_roles", + "The actual receiver port must provide, and sender port require, compatible interfaces exposing this exact allocation.", + )); + } + if !matches!( + (message_kind, transmission), + (_, TransmissionProtocol::Unspecified) + | (MessageKind::Call, TransmissionProtocol::Synchronous) + | ( + MessageKind::Asynchronous, + TransmissionProtocol::Asynchronous + ) + ) { + return Err(error( + "operation_transmission", + "Calls require synchronous transmission; asynchronous operation messages require asynchronous transmission.", + )); + } + let selected = ResolvedOperation { + invocation, + allocation, + interface, + item, + caller_port, + provider_port, + transmission, + }; + if occurrence != invocation { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: reply_exchange, + .. + }, + .. + }) = e.shared() + else { + unreachable!() + }; + if let Some(response) = reply_exchange.and_then(|id| p.relations.get(&id)) + && response.id != channel.id + && !response + .body + .items() + .iter() + .any(|carried| data::item_satisfies(p, *carried, item)) + { + return Err(error( + "operation_reply_channel", + "A separate reply channel must carry the Call's selected operation item or an admitted subtype, including parameterless returns.", + )); + } + } + Ok(OperationResolution::Selected(selected)) +} + +/// Valid selections under the proposed message context, without mutating the model. +pub fn eligible_allocations(p: &Project, occurrence: Id) -> Vec { + let mut candidate = p.clone(); + let ids: Vec<_> = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::InterfaceItemAllocation) + .map(|r| r.id) + .collect(); + ids.into_iter() + .filter(|id| { + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = candidate + .elements + .get_mut(&occurrence) + .and_then(Element::shared_mut) + else { + return false; + }; + *operation = Some(OperationUse { + allocation: Some(*id), + }); + matches!( + resolve(&candidate, occurrence), + Ok(OperationResolution::Selected(_)) + ) + }) + .collect() +} + +/// Reuse the existing bounded field-path engine with exactly one selected item. +pub fn signature_relation(operation: &ResolvedOperation) -> Relation { + Relation { + id: operation.allocation, + alias: String::new(), + name: String::new(), + description: String::new(), + body: RelationBody::Interaction { + source: operation.caller_port, + target: operation.provider_port, + items: vec![operation.item], + }, + } +} diff --git a/crates/oa-core/src/persistence.rs b/crates/oa-core/src/persistence.rs index f7bc1ded..39f496d7 100644 --- a/crates/oa-core/src/persistence.rs +++ b/crates/oa-core/src/persistence.rs @@ -139,6 +139,13 @@ pub fn decode_with_report_limited( } let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') { let header: serde_json::Value = serde_json::from_slice(bytes)?; + if header + .get("schema_version") + .and_then(serde_json::Value::as_u64) + .is_some_and(|v| v <= 6) + { + reject_source7_fields(&header).map_err(FileError::Invalid)?; + } if header .get("schema_version") .and_then(serde_json::Value::as_u64) @@ -182,13 +189,14 @@ pub fn decode_with_report_limited( Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?, Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?, Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?, - Some(6) => DecodeResult { + Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?, + Some(7) => DecodeResult { project: serde_json::from_slice(bytes)?, migration: None, }, _ => { return Err(FileError::Invalid( - "Unsupported or missing schema_version; expected 1, 2, 3, 4, 5 or 6.".into(), + "Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(), )); } } @@ -357,6 +365,70 @@ pub(crate) fn migrate_v5( }) } +/// Reject explicit new fields before serde defaults can erase their presence. +/// This is also applied to source's resolved structured values. +pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::Object(fields) => { + if fields.contains_key("ContextualLink") { + return Err("ContextualLink requires schema/source 7.".into()); + } + if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink") + { + return Err("ContextualLink view selectors require schema/source 7.".into()); + } + if fields.keys().any(|key| { + matches!( + key.as_str(), + "Operation" + | "ContextOwner" + | "ContextSource" + | "ContextTarget" + | "ContextPurpose" + ) + }) { + return Err( + "Operation/contextual-link semantic facets require schema/source 7.".into(), + ); + } + if fields + .get("Exchange") + .and_then(serde_json::Value::as_object) + .is_some_and(|body| body.contains_key("operation")) + { + return Err( + "Exchange.operation requires schema/source 7, including explicit null.".into(), + ); + } + for nested in fields.values() { + reject_source7_fields(nested)?; + } + } + serde_json::Value::Array(values) => { + for nested in values { + reject_source7_fields(nested)?; + } + } + _ => {} + } + Ok(()) +} +pub(crate) fn migrate_v6( + mut project: Project, + limits: crate::language::ParseLimits, +) -> Result { + if project.schema_version != 6 { + return Err(FileError::Invalid("Expected schema_version 6.".into())); + } + reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?; + limits.check_model(&project).map_err(FileError::Limit)?; + project.schema_version = SCHEMA_VERSION; + Ok(DecodeResult { + project, + migration: Some(MigrationReport::data_upgrade(6)), + }) +} + pub(crate) fn migrate_v1_with_report_limited( old: v1::Project, limits: crate::language::ParseLimits, diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index 10edde44..470337f6 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -87,8 +87,15 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) { receiver, reply_to, values, + operation, .. } => { + if let Some(id) = operation + .as_mut() + .and_then(|operation| operation.allocation.as_mut()) + { + visit(id); + } for id in [exchange, sender, receiver, reply_to].into_iter().flatten() { visit(id); } @@ -150,6 +157,22 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) { visit(&mut target); r.body.set_endpoints(source, target); match &mut r.body { + RelationBody::ContextualLink { + owner, + source, + target, + purpose, + } => { + visit(owner); + for id in source.references.iter_mut().chain(&mut target.references) { + visit(id); + } + match purpose { + ContextualLinkPurpose::Exchange { exchange } => visit(exchange), + ContextualLinkPurpose::CommonWork { work } => visit(work), + ContextualLinkPurpose::Precedence { .. } => {} + } + } RelationBody::StateTransition { triggers, effects, .. } => { @@ -274,6 +297,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool) }); if removed.contains(&r.source()) || removed.contains(&r.target()) + || (r.kind() == RelationKind::ContextualLink + && relation_references(r).iter().any(|id| removed.contains(id))) || port_removed || work_allocation_removed { @@ -444,8 +469,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet) { receiver, reply_to, values, + operation, .. } => { + if let Some(operation) = operation { + clear(&mut operation.allocation); + } clear(exchange); clear(sender); clear(receiver); diff --git a/crates/oa-core/src/semantic/catalog.rs b/crates/oa-core/src/semantic/catalog.rs index a6151603..1e1f44e4 100644 --- a/crates/oa-core/src/semantic/catalog.rs +++ b/crates/oa-core/src/semantic/catalog.rs @@ -81,6 +81,35 @@ impl Own { } let kind = enum_tag(value)?.to_string(); prefix.push(kind.clone()); + let mut whole = whole; + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call | MessageKind::Asynchronous, + .. + }, + .. + }) = project.elements.get(&id).and_then(Element::shared) + && project.layer_of(id) == Some(Layer::Sa) + && crate::validation::ancestor_of_kind(project, id, Kind::Scenario) + .and_then(|id| project.elements.get(&id)) + .and_then(Element::shared) + .is_some_and(|body| { + matches!( + body, + SharedBody::Scenario { + viewpoint: ScenarioViewpoint::BoundaryInterface, + .. + } + ) + }) + { + let mut operation_path = prefix.clone(); + operation_path.extend(["body".into(), "Exchange".into()]); + if let Some(body) = at_mut(&mut whole, &operation_path).and_then(Value::as_object_mut) { + body.entry("operation").or_insert(Value::Null); + } + } Some(Self { whole, prefix, @@ -163,6 +192,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option> { F::Occurrence(OccurrenceFacet::ReplyTo) => { ("Occurrence", &["body", "Exchange", "reply_to"]) } + F::Occurrence(OccurrenceFacet::Operation) => { + ("Occurrence", &["body", "Exchange", "operation"]) + } F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]), F::Occurrence(OccurrenceFacet::ActionLifeline) => { ("Occurrence", &["body", "Action", "lifeline"]) @@ -192,8 +224,12 @@ fn path(own: &Own, facet: &FacetSelector) -> Option> { F::State(StateFacet::DoActivity) => ("State", &["do_activity"]), F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]), F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]), - F::Relation(RelationFacet::Source) => ("*", &["source"]), - F::Relation(RelationFacet::Target) => ("*", &["target"]), + F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]), + F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]), + F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]), + F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]), + F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]), + F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]), F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]), F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]), F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]), @@ -926,6 +962,7 @@ mod coverage { ] { add(SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: Some(id), sender: Some(id), receiver: Some(id), diff --git a/crates/oa-core/src/semantic/historical.rs b/crates/oa-core/src/semantic/historical.rs index 400b890f..e2283cfe 100644 --- a/crates/oa-core/src/semantic/historical.rs +++ b/crates/oa-core/src/semantic/historical.rs @@ -171,6 +171,31 @@ impl Historical for LifelineBinding { w.at("local", |w| self.local.walk(w)); } } +impl Historical for OperationUse { + fn walk(&mut self, w: &mut Walk<'_>) { + w.at("allocation", |w| self.allocation.walk(w)); + } +} +impl Historical for OccurrenceEndpoint { + fn walk(&mut self, w: &mut Walk<'_>) { + if self.references.len() > crate::topology_context::MAX_REFERENCE_DEPTH { + w.error("historical contextual path depth exceeded"); + } + w.at("references", |w| self.references.walk(w)); + w.at("occurrence", |w| self.occurrence.walk(w)); + } +} +impl Historical for ContextualLinkPurpose { + fn walk(&mut self, w: &mut Walk<'_>) { + match self { + Self::Precedence { guard } => w.at("Precedence", |w| w.at("guard", |w| guard.walk(w))), + Self::Exchange { exchange } => { + w.at("Exchange", |w| w.at("exchange", |w| exchange.walk(w))) + } + Self::CommonWork { work } => w.at("CommonWork", |w| w.at("work", |w| work.walk(w))), + } + } +} impl Historical for DataValue { fn walk(&mut self, w: &mut Walk<'_>) { match self { diff --git a/crates/oa-core/src/semantic/mutate.rs b/crates/oa-core/src/semantic/mutate.rs index dea83d0e..8d80d6f9 100644 --- a/crates/oa-core/src/semantic/mutate.rs +++ b/crates/oa-core/src/semantic/mutate.rs @@ -518,7 +518,11 @@ pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) } F::Occurrence(OccurrenceFacet::MessageKind) => matches!( facet, - F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression) + F::Occurrence( + OccurrenceFacet::MessageKind + | OccurrenceFacet::TimerExpression + | OccurrenceFacet::Operation + ) ), F::Event(EventFacet::Constructor) => matches!( facet, diff --git a/crates/oa-core/src/semantic/types.rs b/crates/oa-core/src/semantic/types.rs index 65e9eb6a..69a3d758 100644 --- a/crates/oa-core/src/semantic/types.rs +++ b/crates/oa-core/src/semantic/types.rs @@ -112,6 +112,7 @@ macro_rules! scalar_families { Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String, Exchange: Option, Sender: Option, Receiver: Option, MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option, + Operation: Option, Action: Option, ActionLifeline: Option, ControlKind: ControlKind, Sequence: Option, Scenario: Option, State: Option, StateLifeline: Option, FragmentOperator: FragmentOperator, BranchGuard: String @@ -129,6 +130,8 @@ macro_rules! scalar_families { SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String, SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String, Guard: String, Effect: String, StateActionPhase: StateActionKind + , ContextOwner: Id, ContextSource: OccurrenceEndpoint, ContextTarget: OccurrenceEndpoint, + ContextPurpose: ContextualLinkPurpose } }}; } diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index 37b42951..0e740810 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -660,6 +660,7 @@ fn apply_command( .unwrap(); *occ.shared_mut().unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: Some(*exchange), sender: None, receiver: None, diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index a8764f6c..4d3e377f 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -1553,7 +1553,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option { return element.parent; } if let Some(relation) = project.relations.get(&id) { - return Some(relation.source()); + return Some( + relation + .contextual_owner() + .unwrap_or_else(|| relation.source()), + ); } if let Some(artifact) = project.artifact_links.get(&id) { return Some(artifact.owner); diff --git a/crates/oa-core/src/topology_context.rs b/crates/oa-core/src/topology_context.rs new file mode 100644 index 00000000..72acf368 --- /dev/null +++ b/crates/oa-core/src/topology_context.rs @@ -0,0 +1,352 @@ +//! Caller-qualified process/chain addresses. Cache residency never affects admission. +use crate::*; +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, +}; + +mod analysis; +pub use analysis::*; +pub const MAX_REFERENCE_DEPTH: usize = 64; +pub const MAX_ANALYSIS_NODES: usize = 50_000; +pub const MAX_ANALYSIS_EDGES: usize = 100_000; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ContextAddress { + pub owner: Id, + pub endpoint: OccurrenceEndpoint, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedOccurrence { + pub address: ContextAddress, + pub defining_sequence: Id, + pub work: Option, + pub exchange: Option, +} +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{message}")] +pub struct ContextError { + pub code: &'static str, + pub message: String, +} +fn failure(code: &'static str, message: &str) -> ContextError { + ContextError { + code, + message: message.into(), + } +} +struct Record { + parent: Option, + layer: Layer, + kind: Kind, + reference: Option, + work: Option, + exchange: Option, + contribution: bool, +} +pub struct TopologyIndex<'a> { + project: &'a Project, + records: BTreeMap, + children: BTreeMap>, + link_definitions: BTreeMap>, + contextual_callers: BTreeSet, + cache: RefCell>, + cache_capacity: usize, +} +impl<'a> TopologyIndex<'a> { + pub fn new(project: &'a Project) -> Self { + Self::with_cache_capacity(project, 4096) + } + pub fn with_cache_capacity(project: &'a Project, cache_capacity: usize) -> Self { + let records: BTreeMap<_, _> = project + .elements + .values() + .map(|e| { + let (reference, work, exchange, contribution) = match e.shared() { + Some(SharedBody::Occurrence { body, .. }) => match body { + OccurrenceBody::SequenceReference { sequence } => { + (*sequence, None, None, true) + } + OccurrenceBody::Action { action, .. } => (None, *action, None, true), + OccurrenceBody::Exchange { exchange, .. } => (None, None, *exchange, true), + OccurrenceBody::Control { .. } => (None, None, None, true), + _ => (None, None, None, false), + }, + _ => (None, None, None, false), + }; + ( + e.id, + Record { + parent: e.parent, + layer: e.layer(), + kind: e.kind(), + reference, + work, + exchange, + contribution, + }, + ) + }) + .collect(); + let mut children: BTreeMap<_, _> = project + .elements + .values() + .filter_map(|e| e.steps().map(|steps| (e.id, steps.clone()))) + .collect(); + let mut child_sets: BTreeMap<_, BTreeSet<_>> = children + .iter() + .map(|(owner, children)| (*owner, children.iter().copied().collect())) + .collect(); + for (id, record) in &records { + if record.contribution + && let Some(parent) = record.parent + { + let list = children.entry(parent).or_default(); + if child_sets.entry(parent).or_default().insert(*id) { + list.push(*id); + } + } + } + for (owner, list) in &mut children { + list.retain(|id| { + records + .get(id) + .is_some_and(|r| r.parent == Some(*owner) && r.contribution) + }); + } + let mut link_definitions: BTreeMap<_, Vec<_>> = BTreeMap::new(); + let mut contextual_callers = BTreeSet::new(); + for relation in project.relations.values() { + let owner = relation.contextual_owner().or_else(|| { + if relation.kind() == RelationKind::SequenceLink { + records.get(&relation.source()).and_then(|r| r.parent) + } else { + None + } + }); + if let Some(owner) = owner { + link_definitions.entry(owner).or_default().push(relation.id); + } + if let Some(owner) = relation.contextual_owner() { + contextual_callers.insert(owner); + } + } + let mut callers: BTreeMap<_, Vec<_>> = BTreeMap::new(); + for record in records.values() { + if let (Some(parent), Some(reference)) = (record.parent, record.reference) { + callers.entry(reference).or_default().push(parent); + } + } + let mut pending: Vec<_> = contextual_callers.iter().copied().collect(); + while let Some(sequence) = pending.pop() { + for caller in callers.get(&sequence).into_iter().flatten() { + if contextual_callers.insert(*caller) { + pending.push(*caller); + } + } + } + Self { + project, + records, + children, + link_definitions, + contextual_callers, + cache: RefCell::new(BTreeMap::new()), + cache_capacity, + } + } + /// Resolve a caller path while an endpoint leaf is still being edited. + pub fn resolve_scope(&self, owner: Id, references: &[Id]) -> Result { + if references.len() > MAX_REFERENCE_DEPTH { + return Err(failure( + "context_depth", + "A contextual path exceeds 64 references.", + )); + } + let root = self.records.get(&owner).ok_or_else(|| { + failure( + "context_owner", + "The caller must be an existing process or functional chain.", + ) + })?; + if !matches!(root.kind, Kind::Process | Kind::FunctionalChain) { + return Err(failure( + "context_owner", + "The caller must be a process or functional chain.", + )); + } + let mut current = owner; + let mut visited = BTreeSet::from([owner]); + for step in references { + let reference = self.records.get(step).ok_or_else(|| { + failure( + "context_path", + "A reference occurrence in the endpoint path no longer exists.", + ) + })?; + if reference.parent != Some(current) || reference.layer != root.layer { + return Err(failure( + "context_path", + "Each reference occurrence must belong directly to the preceding caller context.", + )); + } + let next = reference.reference.ok_or_else(|| { + failure( + "context_path", + "Each path component must be a resolved sequence-reference occurrence.", + ) + })?; + let target = self.records.get(&next).ok_or_else(|| { + failure( + "context_path", + "A referenced process or chain no longer exists.", + ) + })?; + if target.layer != root.layer || target.kind != root.kind { + return Err(failure( + "context_path", + "References must resolve to sequences of the same kind and layer.", + )); + } + if !visited.insert(next) { + return Err(failure( + "context_cycle", + "A contextual endpoint cannot traverse recursive sequence reuse.", + )); + } + current = next; + } + Ok(current) + } + pub fn resolve( + &self, + owner: Id, + endpoint: &OccurrenceEndpoint, + ) -> Result { + if endpoint.references.len() > MAX_REFERENCE_DEPTH { + return Err(failure( + "context_depth", + "A contextual endpoint exceeds the 64-reference depth limit.", + )); + } + let address = ContextAddress { + owner, + endpoint: endpoint.clone(), + }; + if let Some(cached) = self.cache.borrow().get(&address) { + return Ok(cached.clone()); + } + let current = self.resolve_scope(owner, &endpoint.references)?; + let root = &self.records[&owner]; + let leaf = self.records.get(&endpoint.occurrence).ok_or_else(|| { + failure( + "context_leaf", + "The endpoint contribution no longer exists.", + ) + })?; + if leaf.parent != Some(current) || leaf.layer != root.layer || !leaf.contribution { + return Err(failure( + "context_leaf", + "The endpoint must be a direct contribution of its resolved process or chain.", + )); + } + let resolved = ResolvedOccurrence { + address: address.clone(), + defining_sequence: current, + work: leaf.work, + exchange: leaf.exchange, + }; + if self.cache_capacity > 0 { + let mut cache = self.cache.borrow_mut(); + if cache.len() >= self.cache_capacity { + cache.pop_first(); + } + cache.insert(address, resolved.clone()); + } + Ok(resolved) + } + /// Validate a caller-owned semantic connection, independent of graphical expansion. + pub fn validate_link( + &self, + owner: Id, + source: &OccurrenceEndpoint, + target: &OccurrenceEndpoint, + purpose: &ContextualLinkPurpose, + ) -> Result<(ResolvedOccurrence, ResolvedOccurrence), ContextError> { + let a = self.resolve(owner, source)?; + let b = self.resolve(owner, target)?; + if a.address == b.address { + return Err(failure( + "context_self", + "A contextual link needs two distinct occurrence addresses.", + )); + } + match purpose { + ContextualLinkPurpose::Precedence { guard } => { + if source.references.is_empty() && target.references.is_empty() { + return Err(failure( + "context_direct_precedence", + "Use a SequenceLink for precedence between two direct contributions.", + )); + } + if !validation::valid_text(guard) { + return Err(failure( + "invalid_text", + "The precedence guard contains invalid text.", + )); + } + let previous = a.exchange.and_then(|id| self.project.relations.get(&id)); + let next = b.exchange.and_then(|id| self.project.relations.get(&id)); + let mismatch = match (a.work, previous, b.work, next) { + (Some(work), _, _, Some(exchange)) => work != exchange.source(), + (_, Some(exchange), Some(work), _) => exchange.target() != work, + (_, Some(previous), _, Some(next)) => previous.target() != next.source(), + _ => false, + }; + if mismatch { + return Err(failure( + "sequence_exchange_endpoint", + "Adjacent work/exchange contributions must agree with the referenced exchange endpoints.", + )); + } + } + ContextualLinkPurpose::Exchange { exchange } => { + let relation = self.project.relations.get(exchange).ok_or_else(|| { + failure("context_exchange", "Choose an existing work exchange.") + })?; + let expected = if self.project.layer_of(owner) == Some(Layer::Oa) { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }; + if relation.kind() != expected + || self.project.layer_of(*exchange) != self.project.layer_of(owner) + || a.work != Some(relation.source()) + || b.work != Some(relation.target()) + { + return Err(failure( + "context_exchange", + "The selected same-layer exchange must connect the exact work referenced by the source and target Actions.", + )); + } + } + ContextualLinkPurpose::CommonWork { work } => { + if !self.project.elements.get(work).is_some_and(|e| { + e.kind().is_work() && Some(e.layer()) == self.project.layer_of(owner) + }) || a.work != Some(*work) + || b.work != Some(*work) + { + return Err(failure( + "context_common_work", + "Both Actions must reference the exact same existing work in this layer.", + )); + } + } + } + Ok((a, b)) + } + /// Direct child candidates for lazy UI exploration; never expands a Cartesian reuse tree. + pub fn contributions(&self, sequence: Id) -> Vec { + self.children.get(&sequence).cloned().unwrap_or_default() + } +} diff --git a/crates/oa-core/src/topology_context/analysis.rs b/crates/oa-core/src/topology_context/analysis.rs new file mode 100644 index 00000000..f2c7bf9b --- /dev/null +++ b/crates/oa-core/src/topology_context/analysis.rs @@ -0,0 +1,529 @@ +use super::*; + +#[cfg(test)] +#[path = "analysis_tests.rs"] +mod tests; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ReferenceBoundary { + Entry, + Exit, +} +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ContextVertex { + pub address: ContextAddress, + pub boundary: Option, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContextEdgeKind { + Authored, + SyntheticBoundary, + LegacyOrder, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextEdge { + pub relation: Option, + pub instance: Vec, + pub source: ContextVertex, + pub target: ContextVertex, + pub guard: String, + pub kind: ContextEdgeKind, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextAssembly { + pub relation: Id, + pub instance: Vec, + pub source: ContextAddress, + pub target: ContextAddress, + pub purpose: ContextualLinkPurpose, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextNotice { + pub address: ContextAddress, + pub code: &'static str, + pub message: String, + pub relations: Vec, +} +#[derive(Debug, Clone, Copy)] +pub struct AnalysisLimits { + pub nodes: usize, + pub edges: usize, +} +impl Default for AnalysisLimits { + fn default() -> Self { + Self { + nodes: MAX_ANALYSIS_NODES, + edges: MAX_ANALYSIS_EDGES, + } + } +} +#[derive(Debug, Clone, Default)] +pub struct ContextAnalysis { + pub nodes: BTreeMap, + pub precedence: Vec, + pub assembly: Vec, + pub entries: Vec, + pub exits: Vec, + pub notices: Vec, + /// A partial expansion must never be used for complete reachability conclusions. + pub complete: bool, +} +struct Builder<'a, 'p> { + index: &'a TopologyIndex<'p>, + root: Id, + limits: AnalysisLimits, + result: ContextAnalysis, + definitions: BTreeMap>, + examined_edges: usize, + exhausted: bool, +} +impl TopologyIndex<'_> { + pub fn analyze(&self, owner: Id, limits: AnalysisLimits) -> ContextAnalysis { + self.analyze_counted(owner, limits).0 + } + fn analyze_counted(&self, owner: Id, limits: AnalysisLimits) -> (ContextAnalysis, usize) { + let mut definitions: BTreeMap<_, Vec<_>> = BTreeMap::new(); + for (owner, relations) in &self.link_definitions { + definitions.insert( + *owner, + relations + .iter() + .filter_map(|id| self.project.relations.get(id)) + .collect(), + ); + } + let mut b = Builder { + index: self, + root: owner, + limits, + result: ContextAnalysis { + complete: true, + ..ContextAnalysis::default() + }, + definitions, + examined_edges: 0, + exhausted: false, + }; + (b.result.entries, b.result.exits) = b.expand(owner, &[], &mut BTreeSet::new()); + if b.result.complete { + b.cardinality(); + b.connectivity(); + } + (b.result, b.examined_edges) + } + pub fn has_contextual_links(&self, owner: Id) -> bool { + self.contextual_callers.contains(&owner) + } +} +impl Builder<'_, '_> { + fn address(&self, path: &[Id], occurrence: Id) -> ContextAddress { + ContextAddress { + owner: self.root, + endpoint: OccurrenceEndpoint { + references: path.to_vec(), + occurrence, + }, + } + } + fn vertex(&self, address: ContextAddress, source: bool) -> ContextVertex { + let reference = self + .index + .project + .elements + .get(&address.endpoint.occurrence) + .and_then(Element::shared) + .is_some_and(|s| { + matches!( + s, + SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { .. }, + .. + } + ) + }); + ContextVertex { + address, + boundary: reference.then_some(if source { + ReferenceBoundary::Exit + } else { + ReferenceBoundary::Entry + }), + } + } + fn incomplete(&mut self, address: ContextAddress, code: &'static str, message: &str) { + self.result.complete = false; + if !self + .result + .notices + .iter() + .any(|n| n.address == address && n.code == code) + { + self.result.notices.push(ContextNotice { + address, + code, + message: message.into(), + relations: vec![], + }); + } + } + // Reserve work before resolving an authored endpoint, not merely before storing + // its output. Repeated malformed or filtered endpoints consume the same budget. + fn reserve_edge(&mut self, address: ContextAddress) -> bool { + if self.exhausted { + return false; + } + if self.examined_edges >= self.limits.edges { + self.exhausted = true; + self.incomplete( + address, + "context_analysis_limit", + "Contextual edge limit reached; connectivity analysis is incomplete.", + ); + return false; + } + self.examined_edges += 1; + true + } + fn edge(&mut self, edge: ContextEdge) -> bool { + if !self.reserve_edge(edge.source.address.clone()) { + return false; + } + self.result.precedence.push(edge); + true + } + fn expand( + &mut self, + sequence: Id, + prefix: &[Id], + active: &mut BTreeSet, + ) -> (Vec, Vec) { + if self.exhausted { + return (vec![], vec![]); + } + if prefix.len() > MAX_REFERENCE_DEPTH || !active.insert(sequence) { + self.incomplete( + self.address(prefix, sequence), + "context_analysis_incomplete", + "Recursive or over-depth reuse has no proven boundary path.", + ); + return (vec![], vec![]); + } + let children = self.index.contributions(sequence); + if children.is_empty() { + self.incomplete( + self.address(prefix, sequence), + "context_analysis_incomplete", + "An empty or unresolved sequence has no proven boundary path.", + ); + active.remove(&sequence); + return (vec![], vec![]); + } + let start = self.result.precedence.len(); + for id in &children { + let address = self.address(prefix, *id); + if self.result.nodes.len() >= self.limits.nodes { + self.exhausted = true; + self.incomplete( + address, + "context_analysis_limit", + "Contextual node limit reached; connectivity analysis is incomplete.", + ); + break; + } + match self.index.resolve(self.root, &address.endpoint) { + Ok(resolved) => { + self.result.nodes.insert(address.clone(), resolved); + } + Err(e) => { + self.incomplete(address, "context_analysis_incomplete", &e.message); + continue; + } + } + let reference = self.index.project.elements[id] + .shared() + .and_then(|s| match s { + SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence }, + .. + } => Some(*sequence), + _ => None, + }); + if let Some(reference) = reference { + let Some(callee) = reference else { + self.incomplete( + address, + "context_analysis_incomplete", + "An unresolved reference has no proven boundary path.", + ); + continue; + }; + let mut path = prefix.to_vec(); + path.push(*id); + let (entries, exits) = self.expand(callee, &path, active); + if self.exhausted { + active.remove(&sequence); + return (vec![], vec![]); + } + if entries.is_empty() || exits.is_empty() { + self.incomplete( + address.clone(), + "context_analysis_incomplete", + "No referenced entry or exit is proven; no bypass is inferred.", + ); + } + for target in entries { + if !self.edge(ContextEdge { + relation: None, + instance: prefix.to_vec(), + source: self.vertex(address.clone(), false), + target, + guard: String::new(), + kind: ContextEdgeKind::SyntheticBoundary, + }) { + break; + } + } + for source in exits { + if !self.edge(ContextEdge { + relation: None, + instance: prefix.to_vec(), + source, + target: self.vertex(address.clone(), true), + guard: String::new(), + kind: ContextEdgeKind::SyntheticBoundary, + }) { + break; + } + } + } + if self.exhausted { + break; + } + } + if self.exhausted { + active.remove(&sequence); + return (vec![], vec![]); + } + let relations = self.definitions.get(&sequence).cloned().unwrap_or_default(); + for r in &relations { + if !self.reserve_edge(self.address(prefix, r.source())) { + break; + } + let (source, target, purpose) = match &r.body { + RelationBody::SequenceLink { + source, + target, + guard, + } => ( + OccurrenceEndpoint { + references: vec![], + occurrence: *source, + }, + OccurrenceEndpoint { + references: vec![], + occurrence: *target, + }, + ContextualLinkPurpose::Precedence { + guard: guard.clone(), + }, + ), + RelationBody::ContextualLink { + source, + target, + purpose, + .. + } => (source.clone(), target.clone(), purpose.clone()), + _ => continue, + }; + let qualify = |mut endpoint: OccurrenceEndpoint| { + let mut path = prefix.to_vec(); + path.extend(endpoint.references); + endpoint.references = path; + ContextAddress { + owner: self.root, + endpoint, + } + }; + let source = qualify(source); + let target = qualify(target); + if !self.result.nodes.contains_key(&source) || !self.result.nodes.contains_key(&target) + { + self.incomplete( + source, + "context_analysis_incomplete", + "A link endpoint is unresolved in this bounded analysis.", + ); + continue; + } + match purpose { + ContextualLinkPurpose::Precedence { guard } => { + self.result.precedence.push(ContextEdge { + relation: Some(r.id), + instance: prefix.to_vec(), + source: self.vertex(source, true), + target: self.vertex(target, false), + guard, + kind: ContextEdgeKind::Authored, + }) + } + purpose => { + self.result.assembly.push(ContextAssembly { + relation: r.id, + instance: prefix.to_vec(), + source, + target, + purpose, + }); + } + } + } + if self.exhausted { + active.remove(&sequence); + return (vec![], vec![]); + } + if relations.is_empty() + && children.iter().all(|id| { + matches!( + self.index.project.elements[id].shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) + ) + }) + { + for pair in children.windows(2) { + if !self.edge(ContextEdge { + relation: None, + instance: prefix.to_vec(), + source: self.vertex(self.address(prefix, pair[0]), true), + target: self.vertex(self.address(prefix, pair[1]), false), + guard: String::new(), + kind: ContextEdgeKind::LegacyOrder, + }) { + break; + } + } + } + if self.exhausted { + active.remove(&sequence); + return (vec![], vec![]); + } + // Intrinsic boundaries are determined before any enclosing caller links. + let edges = &self.result.precedence[start..]; + let targets: BTreeSet<_> = edges.iter().map(|e| &e.target).collect(); + let sources: BTreeSet<_> = edges.iter().map(|e| &e.source).collect(); + let entries = children + .iter() + .map(|id| self.vertex(self.address(prefix, *id), false)) + .filter(|v| !targets.contains(v)) + .collect(); + let exits = children + .iter() + .map(|id| self.vertex(self.address(prefix, *id), true)) + .filter(|v| !sources.contains(v)) + .collect(); + active.remove(&sequence); + (entries, exits) + } + fn cardinality(&mut self) { + // Index authored incidence once; synthetic boundaries never count as controls. + let mut incoming: BTreeMap<_, Vec<_>> = BTreeMap::new(); + let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new(); + for edge in &self.result.precedence { + if edge.kind == ContextEdgeKind::Authored { + incoming.entry(&edge.target.address).or_default().push(edge); + outgoing.entry(&edge.source.address).or_default().push(edge); + } + } + for address in self.result.nodes.keys() { + let ins = incoming.get(address).cloned().unwrap_or_default(); + let outs = outgoing.get(address).cloned().unwrap_or_default(); + let before = ins.len(); + let after = outs.len(); + let control = match self.index.project.elements[&address.endpoint.occurrence].shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Control { kind }, + .. + }) => Some(*kind), + _ => None, + }; + let issue = match control { + Some(kind) => { let valid = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2 }; (!valid).then_some(("control_cardinality", format!("{kind:?} has {before} incoming and {after} outgoing authored precedence links in this caller context. Review the intended paths."))) } + None => (before > 1 || after > 1).then_some(("ambiguous_sequence_branch", "This contribution has multiple incoming or outgoing precedence links in this caller context. Review whether explicit merge/join or branching control is needed to describe the intended paths.".into())), + }; + if let Some((code, message)) = issue { + self.result.notices.push(ContextNotice { + address: address.clone(), + code, + message, + relations: ins.iter().chain(&outs).filter_map(|e| e.relation).collect(), + }); + } + if matches!(control, Some(ControlKind::Choice | ControlKind::Loop)) { + for edge in outs.iter().filter(|e| e.guard.trim().is_empty()) { + self.result.notices.push(ContextNotice { address: address.clone(), code: "missing_sequence_guard", message: "Describe the choice/loop condition for this path, or use an explicit 'else' guard.".into(), relations: edge.relation.into_iter().collect() }); + } + } + } + } + fn connectivity(&mut self) { + let mut forward: BTreeMap<_, Vec<_>> = BTreeMap::new(); + let mut reverse: BTreeMap<_, Vec<_>> = BTreeMap::new(); + for edge in &self.result.precedence { + forward + .entry(edge.source.clone()) + .or_default() + .push(edge.target.clone()); + reverse + .entry(edge.target.clone()) + .or_default() + .push(edge.source.clone()); + } + let visit = |starts: &[ContextVertex], + graph: &BTreeMap>| { + let mut pending = starts.to_vec(); + let mut seen = BTreeSet::new(); + while let Some(vertex) = pending.pop() { + if seen.insert(vertex.clone()) { + pending.extend(graph.get(&vertex).into_iter().flatten().cloned()); + } + } + seen + }; + let from_start = visit(&self.result.entries, &forward); + let to_end = visit(&self.result.exits, &reverse); + for address in self.result.nodes.keys() { + let entry = self.vertex(address.clone(), false); + let exit = self.vertex(address.clone(), true); + for (missing, code, message) in [ + ( + !from_start.contains(&entry), + "unreachable_contribution", + "No path from a structural caller entry reaches this contribution in this context.", + ), + ( + !to_end.contains(&exit), + "nonterminating_contribution", + "No structural caller exit is reachable from this contribution in this context; review the intended loop.", + ), + ( + self.result.nodes.len() > 1 + && !reverse.contains_key(&entry) + && !forward.contains_key(&exit), + "isolated_contribution", + "This contribution has no incoming or outgoing precedence path in this caller context; assembly incidence is separate.", + ), + ] { + if missing { + self.result.notices.push(ContextNotice { + address: address.clone(), + code, + message: message.into(), + relations: vec![], + }); + } + } + } + } +} diff --git a/crates/oa-core/src/topology_context/analysis_tests.rs b/crates/oa-core/src/topology_context/analysis_tests.rs new file mode 100644 index 00000000..05695d9d --- /dev/null +++ b/crates/oa-core/src/topology_context/analysis_tests.rs @@ -0,0 +1,172 @@ +use super::*; + +fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let id = p + .add_element(Kind::Occurrence, Layer::Sa, name, Some(owner)) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} + +fn repeated(references: usize, links: usize) -> (Project, Id, Id) { + let mut p = Project::empty(); + let root = p + .add_element(Kind::FunctionalChain, Layer::Sa, "Caller", None) + .unwrap(); + let procedure = p + .add_element(Kind::FunctionalChain, Layer::Sa, "Reusable procedure", None) + .unwrap(); + let mut leaves = Vec::new(); + for name in ["A", "B"] { + let action = p + .add_element(Kind::Function, Layer::Sa, name, None) + .unwrap(); + leaves.push(occurrence( + &mut p, + procedure, + name, + OccurrenceBody::Action { + action: Some(action), + lifeline: None, + }, + )); + } + for _ in 0..links { + p.relate( + RelationKind::SequenceLink, + "Authored A to B", + leaves[0], + leaves[1], + ); + } + for _ in 0..references { + occurrence( + &mut p, + root, + "Reuse", + OccurrenceBody::SequenceReference { + sequence: Some(procedure), + }, + ); + } + (p, root, procedure) +} + +#[test] +fn edge_work_budget_stops_repeated_expansion_not_just_output_storage() { + // Only ~3,000 possible contribution nodes, but a million authored edge + // instances. The edge budget must stop actual work before later references. + let (p, root, _) = repeated(1_000, 1_000); + let limits = AnalysisLimits::default(); + let index = TopologyIndex::with_cache_capacity(&p, 1); + let (first, attempts) = index.analyze_counted(root, limits); + assert_eq!(attempts, limits.edges); + assert_eq!(first.precedence.len(), limits.edges); + assert!(!first.complete); + assert!(first.entries.is_empty() && first.exits.is_empty()); + assert!( + first.nodes.len() < 1_000, + "later caller references must not be expanded" + ); + assert_eq!( + first + .notices + .iter() + .filter(|n| n.code == "context_analysis_limit") + .count(), + 1 + ); + assert!( + first + .notices + .iter() + .all(|n| n.code == "context_analysis_limit") + ); + let (warm, warm_attempts) = index.analyze_counted(root, limits); + assert_eq!(warm_attempts, attempts); + assert_eq!(warm.nodes, first.nodes); + assert_eq!(warm.precedence, first.precedence); + assert_eq!(warm.notices, first.notices); + drop(warm); + let (uncached, uncached_attempts) = + TopologyIndex::with_cache_capacity(&p, 0).analyze_counted(root, limits); + assert_eq!(uncached_attempts, attempts); + assert_eq!(uncached.precedence, first.precedence); + assert_eq!(uncached.notices, first.notices); +} + +#[test] +fn unresolved_authored_edges_consume_work_budget_without_stored_output() { + let (mut p, root, _) = repeated(20, 100); + let missing = Id::nil(); + for relation in p.relations.values_mut() { + relation.body.set_endpoints(relation.source(), missing); + } + let (result, attempts) = TopologyIndex::new(&p).analyze_counted( + root, + AnalysisLimits { + nodes: 1_000, + edges: 17, + }, + ); + assert_eq!(attempts, 17); + assert!(result.precedence.is_empty()); + assert_eq!(result.nodes.len(), 3); + assert!(!result.complete); + assert!(result.entries.is_empty() && result.exits.is_empty()); + assert_eq!( + result + .notices + .iter() + .filter(|n| n.code == "context_analysis_limit") + .count(), + 1 + ); +} + +#[test] +fn exact_edge_budget_includes_reference_boundaries_and_node_abort_has_no_bypass() { + let (p, root, _) = repeated(1, 2); + let index = TopologyIndex::new(&p); + let (complete, count) = index.analyze_counted(root, AnalysisLimits { nodes: 3, edges: 4 }); + assert!(complete.complete); + assert_eq!(count, 4); // two authored edges plus entry and exit connectors + assert_eq!( + complete + .precedence + .iter() + .filter(|e| e.kind == ContextEdgeKind::SyntheticBoundary) + .count(), + 2 + ); + for limits in [ + AnalysisLimits { nodes: 3, edges: 3 }, + AnalysisLimits { + nodes: 1, + edges: 10, + }, + ] { + let (partial, count) = index.analyze_counted(root, limits); + assert!(!partial.complete); + assert!(partial.entries.is_empty() && partial.exits.is_empty()); + assert!(count <= limits.edges); + assert_eq!( + partial + .notices + .iter() + .filter(|n| n.code == "context_analysis_limit") + .count(), + 1 + ); + } +} diff --git a/crates/oa-core/src/transitions.rs b/crates/oa-core/src/transitions.rs index a56439f8..2abc00c2 100644 --- a/crates/oa-core/src/transitions.rs +++ b/crates/oa-core/src/transitions.rs @@ -81,6 +81,12 @@ pub struct CommunicationEndpointDecision { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] +pub struct OperationOmission { + pub source: Id, + pub rationale: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TransitionRequest { pub direction: TransitionDirection, pub sources: Vec, @@ -91,6 +97,8 @@ pub struct TransitionRequest { pub destination_diagram: Option, #[serde(default)] pub communication_endpoints: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operation_omissions: Vec, } impl TransitionRequest { pub fn new(direction: TransitionDirection, sources: Vec) -> Self { @@ -103,6 +111,7 @@ impl TransitionRequest { resolutions: vec![], destination_diagram: None, communication_endpoints: vec![], + operation_omissions: vec![], } } } @@ -319,6 +328,7 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option, owner: Id, field: &str, ids: &[Id]) { pub fn validate(p: &Project) -> Vec { let mut out = Vec::new(); let coverage = coverage::Index::new(p); + let topology_index = crate::topology_context::TopologyIndex::new(p); if p.schema_version != SCHEMA_VERSION { error( &mut out, @@ -383,7 +384,7 @@ pub fn validate(p: &Project) -> Vec { "Add ordered occurrences.", ); } - validate_sequence(p, e, steps, &mut out); + validate_sequence(p, e, steps, &topology_index, &mut out); } if let Some(shared) = e.shared() { match shared { @@ -600,7 +601,7 @@ pub fn validate(p: &Project) -> Vec { } text(&mut out, r.id, "name", &r.name); text(&mut out, r.id, "description", &r.description); - validate_relation(p, r, &mut out); + validate_relation(p, r, &topology_index, &mut out); crate::data::validate_relation(p, r, &mut out); crate::ports::validate_boundary_interface_requirements(p, r, &mut out); validate_relation_context(p, r, &mut out); @@ -950,10 +951,57 @@ pub fn validate(p: &Project) -> Vec { } out } -fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec) { +fn validate_sequence( + p: &Project, + e: &Element, + steps: &[Id], + topology: &crate::topology_context::TopologyIndex<'_>, + out: &mut Vec, +) { if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { return; } + if topology.has_contextual_links(e.id) { + let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default()); + for notice in analysis.notices { + let breadcrumb = notice + .address + .endpoint + .references + .iter() + .chain(std::iter::once(¬ice.address.endpoint.occurrence)) + .map(|id| format!("{} ({id})", p.named(*id))) + .collect::>() + .join(" / "); + warning( + out, + e.id, + notice.code, + "steps", + format!("Caller '{}', {breadcrumb}: {}", e.name, notice.message), + ); + } + if analysis.complete { + for (code, label, count) in [ + ("sequence_starts", "start", analysis.entries.len()), + ("sequence_ends", "end", analysis.exits.len()), + ] { + if count != 1 { + warning( + out, + e.id, + code, + "steps", + format!( + "Caller '{}' has {count} structural {label} boundaries; review the intended independent paths.", + e.name + ), + ); + } + } + } + return; + } let links: Vec<_> = p .relations .values() @@ -1554,6 +1602,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V receiver, reply_to, values, + .. } = body else { return; @@ -1766,6 +1815,34 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V } } let relation = exchange.and_then(|id| p.relations.get(&id)); + let operation = crate::operations::resolve(p, e.id); + let signature = match &operation { + Ok(crate::operations::OperationResolution::Selected(selected)) => { + if selected.transmission == TransmissionProtocol::Unspecified { + warning( + out, + e.id, + "operation_transmission_unspecified", + "operation", + "The selected operation transmission protocol is unspecified.", + ); + } + Some(crate::operations::signature_relation(selected)) + } + Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => { + warning(out, e.id, "operation_unresolved", "operation", reason); + None + } + Err(problem) => { + error(out, e.id, problem.code, "operation", &problem.message); + None + } + _ => None, + }; + let value_relation = match &operation { + Ok(crate::operations::OperationResolution::Ordinary) => relation, + _ => signature.as_ref(), + }; if message_kind == &MessageKind::Call && relation.is_some_and(|r| { !r.body @@ -1811,10 +1888,48 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V "The value path exceeds the bounded authoring depth.", ); } - if let Some(r) = relation { + if matches!( + operation, + Ok(crate::operations::OperationResolution::Unresolved { .. }) + ) && let Some(owner) = value + .path + .first() + .or(Some(&value.field)) + .and_then(|id| p.elements.get(id)) + .and_then(|field| field.parent) + { + let path_context = Relation { + id: e.id, + alias: String::new(), + name: String::new(), + description: String::new(), + body: RelationBody::Interaction { + source: e.id, + target: e.id, + items: vec![owner], + }, + }; + if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none() + { + error( + out, + e.id, + "message_value_path", + "values.path", + "Even while the operation is unresolved, a value path must follow existing Value record fields in order.", + ); + } + } + if let Some(r) = value_relation { if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) { if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) { let permitted = match message_kind { + MessageKind::Asynchronous if signature.is_some() => matches!( + path.direction, + ParameterDirection::Unspecified + | ParameterDirection::In + | ParameterDirection::InOut + ), MessageKind::Call => matches!( path.direction, ParameterDirection::Unspecified @@ -2254,7 +2369,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo false } -fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { +fn validate_relation( + p: &Project, + r: &Relation, + topology: &crate::topology_context::TopologyIndex<'_>, + out: &mut Vec, +) { + if let RelationBody::ContextualLink { + owner, + source, + target, + purpose, + } = &r.body + { + if let Err(problem) = topology.validate_link(*owner, source, target, purpose) { + error(out, r.id, problem.code, "body", problem.message); + } + return; + } let (source, target) = r.body.endpoints(); for id in crate::references::relation_references(r) { // Most relation references name elements. Avoid constructing all model @@ -2632,7 +2764,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b *transition_kind = TransitionKind::Local; } let mut issues = vec![]; - validate_relation(p, &candidate, &mut issues); + validate_relation( + p, + &candidate, + &crate::topology_context::TopologyIndex::new(p), + &mut issues, + ); validate_relation_context(p, &candidate, &mut issues); if kind == RelationKind::StateTransition { for endpoint in [source, target] { @@ -2662,7 +2799,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool) }, }; let mut issues = vec![]; - validate_relation(p, &candidate, &mut issues); + validate_relation( + p, + &candidate, + &crate::topology_context::TopologyIndex::new(p), + &mut issues, + ); validate_relation_context(p, &candidate, &mut issues); !issues.iter().any(|i| i.severity == Severity::Error) } diff --git a/crates/oa-core/src/view_options.rs b/crates/oa-core/src/view_options.rs index 481c445e..e57d4f33 100644 --- a/crates/oa-core/src/view_options.rs +++ b/crates/oa-core/src/view_options.rs @@ -1,7 +1,10 @@ //! Persisted view intent and pure visibility decisions over actual model identities. //! Build `ViewIndex` once per accepted model revision. Preparing a view performs //! set unions/reachability once; testing an edge never walks the model. -use crate::{Id, Kind, Layer, OccurrenceBody, Project, RelationKind, SharedBody}; +use crate::{ + ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind, + SharedBody, +}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -401,6 +404,25 @@ impl BehaviorIndex { _ => {} } } + let topology = crate::topology_context::TopologyIndex::new(p); + for relation in p.relations.values() { + if let RelationBody::ContextualLink { + owner, + source, + target, + purpose: purpose @ ContextualLinkPurpose::Exchange { exchange }, + } = &relation.body + && topology + .validate_link(*owner, source, target, purpose) + .is_ok() + { + let facts = index.facts.entry(*owner).or_default(); + facts.exchanges.insert(*exchange); + if let Some(exchange) = p.relations.get(exchange) { + facts.work.extend([exchange.source(), exchange.target()]); + } + } + } index } diff --git a/crates/oa-core/src/view_options_tests.rs b/crates/oa-core/src/view_options_tests.rs index 677b505c..27825385 100644 --- a/crates/oa-core/src/view_options_tests.rs +++ b/crates/oa-core/src/view_options_tests.rs @@ -27,6 +27,7 @@ fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id { fn exchange(id: Id) -> OccurrenceBody { OccurrenceBody::Exchange { + operation: None, exchange: Some(id), sender: None, receiver: None, diff --git a/crates/oa-core/tests/communication_boundary_scenarios.rs b/crates/oa-core/tests/communication_boundary_scenarios.rs index c91393e0..acb6ee8a 100644 --- a/crates/oa-core/tests/communication_boundary_scenarios.rs +++ b/crates/oa-core/tests/communication_boundary_scenarios.rs @@ -22,6 +22,7 @@ fn message( reply: Option, ) -> OccurrenceBody { OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender: Some(sender), receiver: Some(receiver), diff --git a/crates/oa-core/tests/communication_means.rs b/crates/oa-core/tests/communication_means.rs index 3ac80133..fd1eaa04 100644 --- a/crates/oa-core/tests/communication_means.rs +++ b/crates/oa-core/tests/communication_means.rs @@ -71,6 +71,7 @@ fn message( reply_to: Option, ) -> OccurrenceBody { OccurrenceBody::Exchange { + operation: None, exchange: Some(mean), sender: Some(sender), receiver: Some(receiver), @@ -110,7 +111,7 @@ fn communication_means_are_distinct_oa_participant_relations_and_strict_source6_ valid(&p); let text = language::format(&p); assert!(text.contains("communication_mean")); - assert!(text.starts_with("arcadia 6;")); + assert!(text.starts_with(&format!("arcadia {SCHEMA_VERSION};"))); assert_eq!(language::parse(&text).unwrap(), p); let bytes = serde_json::to_vec(&p).unwrap(); assert_eq!(persistence::decode(&bytes).unwrap(), p); @@ -122,8 +123,12 @@ fn communication_means_are_distinct_oa_participant_relations_and_strict_source6_ "schema {version}" ); assert!( - language::parse(&text.replacen("arcadia 6;", &format!("arcadia {version};"), 1)) - .is_err(), + language::parse(&text.replacen( + &format!("arcadia {SCHEMA_VERSION};"), + &format!("arcadia {version};"), + 1 + )) + .is_err(), "source {version}" ); } @@ -158,8 +163,11 @@ fn communication_schema5_upgrade_preserves_legacy_data_and_exact_source_trivia() assert_eq!(result.migration.unwrap().from_schema, 5); let mut without_views = p.clone(); without_views.diagrams.clear(); - let old = - language::format(&without_views).replacen("arcadia 6;", "arcadia // old contract\n5;", 1); + let old = language::format(&without_views).replacen( + &format!("arcadia {SCHEMA_VERSION};"), + "arcadia // old contract\n5;", + 1, + ); let bundle = source_project::SourceBundle { entry: "entry.arcadia".into(), files: BTreeMap::from([("entry.arcadia".into(), old.clone())]), @@ -168,7 +176,7 @@ fn communication_schema5_upgrade_preserves_legacy_data_and_exact_source_trivia() source_project::compile_bundle(&bundle, source_project::ProjectLimits::default()).unwrap(); assert_eq!( compiled.bundle.files["entry.arcadia"], - old.replacen("\n5;", "\n6;", 1) + old.replacen("\n5;", &format!("\n{SCHEMA_VERSION};"), 1) ); assert_eq!(compiled.decoded.project, without_views); } diff --git a/crates/oa-core/tests/coverage_diagnostics.rs b/crates/oa-core/tests/coverage_diagnostics.rs index 6dc1ea18..6d385886 100644 --- a/crates/oa-core/tests/coverage_diagnostics.rs +++ b/crates/oa-core/tests/coverage_diagnostics.rs @@ -56,6 +56,7 @@ fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id { } fn exchange(id: Id) -> OccurrenceBody { OccurrenceBody::Exchange { + operation: None, exchange: Some(id), sender: None, receiver: None, @@ -183,6 +184,7 @@ fn included_process_and_nested_scenario_evidence_is_identity_based_in_both_layer &mut p, branch, OccurrenceBody::Exchange { + operation: None, exchange: Some(e), sender: Some(sender), receiver: Some(receiver), @@ -195,6 +197,7 @@ fn included_process_and_nested_scenario_evidence_is_identity_based_in_both_layer &mut p, branch, OccurrenceBody::Exchange { + operation: None, exchange: Some(e), sender: Some(receiver), receiver: Some(sender), diff --git a/crates/oa-core/tests/language.rs b/crates/oa-core/tests/language.rs index c24e52b3..b598ed8a 100644 --- a/crates/oa-core/tests/language.rs +++ b/crates/oa-core/tests/language.rs @@ -363,6 +363,49 @@ project mission "Harbor safety — 港 🌊" { family: oa_core::view_options::ExchangeFamily::Participant, exchanges: vec![means], }; + let caller = p + .add_element(Kind::Process, Layer::Oa, "Common work caller", None) + .unwrap(); + let work = p + .elements + .values() + .find(|e| e.kind() == Kind::Activity) + .unwrap() + .id; + let mut occurrences = vec![]; + for name in ["First contribution", "Second contribution"] { + let id = p + .add_element(Kind::Occurrence, Layer::Oa, name, Some(caller)) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + precondition: String::new(), + postcondition: String::new(), + }; + occurrences.push(id); + } + *p.elements.get_mut(&caller).unwrap().steps_mut().unwrap() = occurrences.clone(); + let id = p.relate( + RelationKind::ContextualLink, + "Shared work incidence", + occurrences[0], + occurrences[1], + ); + p.relations.get_mut(&id).unwrap().body = RelationBody::ContextualLink { + owner: caller, + source: OccurrenceEndpoint { + references: vec![], + occurrence: occurrences[0], + }, + target: OccurrenceEndpoint { + references: vec![], + occurrence: occurrences[1], + }, + purpose: ContextualLinkPurpose::CommonWork { work }, + }; p } @@ -515,7 +558,7 @@ fn authoring_defaults_and_forward_references_remain_strict() { #[test] fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() { for (source, expected) in [ - ("arcadia 7; project mission \"x\" {}", "Unsupported schema"), + ("arcadia 8; project mission \"x\" {}", "Unsupported schema"), ( "arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }", "Unknown field", @@ -1219,11 +1262,10 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() { break; } } - if code.trim_start().starts_with("arcadia 2;") - || code.trim_start().starts_with("arcadia 3;") - || code.trim_start().starts_with("arcadia 4;") - || code.trim_start().starts_with("arcadia 6;") - { + if (1..=SCHEMA_VERSION).any(|version| { + code.trim_start() + .starts_with(&format!("arcadia {version};")) + }) { read(&code); examples += 1; } diff --git a/crates/oa-core/tests/port_migration.rs b/crates/oa-core/tests/port_migration.rs index e6ef29c3..4a724508 100644 --- a/crates/oa-core/tests/port_migration.rs +++ b/crates/oa-core/tests/port_migration.rs @@ -152,7 +152,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() { } } let canonical = language::format(&first.project); - assert!(canonical.starts_with("arcadia 6;")); + assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};"))); let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap(); assert!(reopened.migration.is_none()); assert_eq!(reopened.project, first.project); diff --git a/crates/oa-core/tests/r3_constructor_ack.rs b/crates/oa-core/tests/r3_constructor_ack.rs index 1bd7c4c2..d5809866 100644 --- a/crates/oa-core/tests/r3_constructor_ack.rs +++ b/crates/oa-core/tests/r3_constructor_ack.rs @@ -102,6 +102,7 @@ fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector id, SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: None, sender: None, receiver: None, diff --git a/crates/oa-core/tests/r3_migration.rs b/crates/oa-core/tests/r3_migration.rs index f7a9e4b6..4956ec9d 100644 --- a/crates/oa-core/tests/r3_migration.rs +++ b/crates/oa-core/tests/r3_migration.rs @@ -120,7 +120,7 @@ fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() { assert_eq!(compiled.bundle.files["contracts.arcadia"], module); assert_eq!( compiled.bundle.files["project.arcadia"], - entry.replacen("arcadia 4;", "arcadia 6;", 1) + entry.replacen("arcadia 4;", &format!("arcadia {SCHEMA_VERSION};"), 1) ); assert_eq!( compiled.index.lexical_owners diff --git a/crates/oa-core/tests/r3_semantic.rs b/crates/oa-core/tests/r3_semantic.rs index dbe6f548..4f330a41 100644 --- a/crates/oa-core/tests/r3_semantic.rs +++ b/crates/oa-core/tests/r3_semantic.rs @@ -515,6 +515,7 @@ fn payload(p: &mut Project, layer: Layer, prefix: &str) -> Payload { scenario, &format!("{prefix} message"), OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender: None, receiver: None, diff --git a/crates/oa-core/tests/r3_source.rs b/crates/oa-core/tests/r3_source.rs index 3f3a015a..e8396d7f 100644 --- a/crates/oa-core/tests/r3_source.rs +++ b/crates/oa-core/tests/r3_source.rs @@ -131,7 +131,7 @@ fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories( ]); assert!(!model.contains(id(90))); let source = language::format(&model); - assert!(source.starts_with("arcadia 6;")); + assert!(source.starts_with(&format!("arcadia {SCHEMA_VERSION};"))); assert!( source.contains(&format!("Named uuid \"{}\"", id(90))), "{source}" diff --git a/crates/oa-core/tests/r6_data.rs b/crates/oa-core/tests/r6_data.rs index bd64e0f0..70191d2b 100644 --- a/crates/oa-core/tests/r6_data.rs +++ b/crates/oa-core/tests/r6_data.rs @@ -91,6 +91,7 @@ fn message( id, SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender: None, receiver: None, diff --git a/crates/oa-core/tests/r6_migration.rs b/crates/oa-core/tests/r6_migration.rs index 8e6ce953..d989913a 100644 --- a/crates/oa-core/tests/r6_migration.rs +++ b/crates/oa-core/tests/r6_migration.rs @@ -74,7 +74,7 @@ fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() .any(|issue| issue.code == "message_value_ambiguous_path") ); let canonical = language::format(project); - assert!(canonical.starts_with("arcadia 6;")); + assert!(canonical.starts_with(&format!("arcadia {SCHEMA_VERSION};"))); let reopened = language::parse_with_report(&canonical).unwrap(); assert_eq!(&reopened.project, project); assert!(reopened.migration.is_none()); diff --git a/crates/oa-core/tests/r6_source.rs b/crates/oa-core/tests/r6_source.rs index 461f86c3..6f67338b 100644 --- a/crates/oa-core/tests/r6_source.rs +++ b/crates/oa-core/tests/r6_source.rs @@ -26,7 +26,9 @@ fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_i }; let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); assert_eq!(compiled.bundle.files.len(), 2); - assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 6;")); + assert!( + compiled.bundle.files["project.arcadia"].contains(&format!("arcadia {SCHEMA_VERSION};")) + ); assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";")); for text in [ "// Contracts stay in their original module.", diff --git a/crates/oa-core/tests/release_scenarios.rs b/crates/oa-core/tests/release_scenarios.rs index 516c076a..7cdb00b1 100644 --- a/crates/oa-core/tests/release_scenarios.rs +++ b/crates/oa-core/tests/release_scenarios.rs @@ -51,6 +51,7 @@ fn message( call: Option, ) -> OccurrenceBody { OccurrenceBody::Exchange { + operation: None, exchange, sender: Some(sender), receiver, diff --git a/crates/oa-core/tests/release_topology.rs b/crates/oa-core/tests/release_topology.rs index f0de15eb..caac7fb4 100644 --- a/crates/oa-core/tests/release_topology.rs +++ b/crates/oa-core/tests/release_topology.rs @@ -271,6 +271,7 @@ fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() { owner, "Contextual message", OccurrenceBody::Exchange { + operation: None, exchange: Some(global), sender: None, receiver: None, diff --git a/crates/oa-core/tests/source6_view_contract.rs b/crates/oa-core/tests/source6_view_contract.rs index c46de242..ac25c8fe 100644 --- a/crates/oa-core/tests/source6_view_contract.rs +++ b/crates/oa-core/tests/source6_view_contract.rs @@ -372,7 +372,8 @@ fn source6_new_historical_facts_are_rejected_under_source5_even_without_live_cat error.contains("category semantic facts require schema/source 6"), "{error}" ); - let old = language::format(&p).replacen("arcadia 6;", "arcadia 5;", 1); + let old = + language::format(&p).replacen(&format!("arcadia {SCHEMA_VERSION};"), "arcadia 5;", 1); assert!(language::parse(&old).unwrap_err().iter().any(|d| { d.message .contains("category semantic facts require schema/source 6") diff --git a/crates/oa-core/tests/source7_contract.rs b/crates/oa-core/tests/source7_contract.rs new file mode 100644 index 00000000..d33a2ea7 --- /dev/null +++ b/crates/oa-core/tests/source7_contract.rs @@ -0,0 +1,1294 @@ +use oa_core::{ + operations::*, + topology_context::*, + transitions::*, + validation::{self, Severity}, + *, +}; + +fn valid(p: &Project) { + let errors: Vec<_> = validation::validate(p) + .into_iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); +} +fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let mut e = p + .make_element( + Kind::Occurrence, + p.elements[&owner].layer(), + name, + Some(owner), + ) + .unwrap(); + *e.shared_mut().unwrap() = SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }; + let id = e.id; + p.elements.insert(id, e); + p.elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} +fn endpoint(path: &[Id], occurrence: Id) -> OccurrenceEndpoint { + OccurrenceEndpoint { + references: path.to_vec(), + occurrence, + } +} +struct Chain { + p: Project, + outer: Id, + procedure: Id, + a: Id, + b: Id, + x: Id, + r1: Id, + r2: Id, + inner: Id, +} +fn chain(layer: Layer) -> Chain { + let mut p = Project::empty(); + let kind = if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }; + let work = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let outer = p.add_element(kind, layer, "Caller", None).unwrap(); + let procedure = p + .add_element(kind, layer, "Shared procedure", None) + .unwrap(); + let nested = p.add_element(kind, layer, "Nested caller", None).unwrap(); + let mut actions = vec![]; + for (owner, name) in [ + (procedure, "A"), + (procedure, "B"), + (procedure, "C"), + (outer, "X"), + ] { + let action = p.add_element(work, layer, name, None).unwrap(); + actions.push(occurrence( + &mut p, + owner, + name, + OccurrenceBody::Action { + action: Some(action), + lifeline: None, + }, + )); + } + let [a, b, c, x] = actions.try_into().unwrap(); + p.relate(RelationKind::SequenceLink, "A then B", a, b); + p.relate(RelationKind::SequenceLink, "B then C", b, c); + let r1 = occurrence( + &mut p, + outer, + "First reuse", + OccurrenceBody::SequenceReference { + sequence: Some(procedure), + }, + ); + let r2 = occurrence( + &mut p, + outer, + "Second reuse", + OccurrenceBody::SequenceReference { + sequence: Some(procedure), + }, + ); + let inner = occurrence( + &mut p, + nested, + "Nested reuse", + OccurrenceBody::SequenceReference { + sequence: Some(procedure), + }, + ); + valid(&p); + Chain { + p, + outer, + procedure, + a, + b, + x, + r1, + r2, + inner, + } +} +fn link(f: &Chain, purpose: ContextualLinkPurpose) -> Relation { + Relation { + id: Id::new_v4(), + alias: "caller_connection".into(), + name: "Caller contribution".into(), + description: String::new(), + body: RelationBody::ContextualLink { + owner: f.outer, + source: endpoint(&[], f.x), + target: endpoint(&[f.r1], f.b), + purpose, + }, + } +} + +#[test] +fn source7_repeated_nested_qualified_precedence_and_cache_are_distinct() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = chain(layer); + let relation = link( + &f, + ContextualLinkPurpose::Precedence { + guard: "authorized".into(), + }, + ); + f.p = execute_batch(&f.p, &[Command::CreateRelation(relation.clone())]).unwrap(); + let index = TopologyIndex::with_cache_capacity(&f.p, 1); + let one = index.resolve(f.outer, &endpoint(&[f.r1], f.b)).unwrap(); + let two = index.resolve(f.outer, &endpoint(&[f.r2], f.b)).unwrap(); + assert_ne!(one.address, two.address); + assert_eq!(index.resolve(f.outer, &one.address.endpoint).unwrap(), one); + let analysis = index.analyze(f.outer, AnalysisLimits::default()); + assert!(analysis.complete); + let warnings: Vec<_> = analysis + .notices + .iter() + .filter(|n| n.code == "ambiguous_sequence_branch") + .collect(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].address, one.address); + assert!( + !analysis + .precedence + .iter() + .any(|e| e.source.address.endpoint.occurrence == f.x + && e.target.address.endpoint == endpoint(&[f.r1], f.a)) + ); + assert!( + analysis + .precedence + .iter() + .any(|e| e.relation == Some(relation.id) && e.target.address == one.address) + ); + assert!( + index + .analyze(f.procedure, AnalysisLimits::default()) + .notices + .is_empty() + ); + let limited = index.analyze(f.outer, AnalysisLimits { nodes: 1, edges: 1 }); + assert!(!limited.complete); + assert!( + limited + .notices + .iter() + .any(|n| n.code == "context_analysis_limit") + ); + assert!(index.analyze(f.outer, AnalysisLimits::default()).complete); + let nested_owner = f.p.elements[&f.inner].parent.unwrap(); + let mut changed = f.p.elements[&f.r1].clone(); + *changed.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { + sequence: Some(nested_owner), + }, + precondition: String::new(), + postcondition: String::new(), + }; + assert!(execute_batch(&f.p, &[Command::EditElement(f.r1, changed.clone())]).is_err()); + let mut repaired = relation.clone(); + if let RelationBody::ContextualLink { target, .. } = &mut repaired.body { + target.references.push(f.inner); + } + let nested = execute_batch( + &f.p, + &[ + Command::EditElement(f.r1, changed), + Command::EditRelation(relation.id, repaired), + ], + ) + .unwrap(); + assert!( + TopologyIndex::new(&nested) + .resolve(f.outer, &endpoint(&[f.r1, f.inner], f.b)) + .is_ok() + ); + assert!( + TopologyIndex::new(&nested) + .resolve(f.outer, &endpoint(&[f.r2, f.inner], f.b)) + .is_err() + ); + } +} +#[test] +fn source7_context_lifecycle_source_history_and_closed_admission() { + let f = chain(Layer::Oa); + let relation = link( + &f, + ContextualLinkPurpose::Precedence { + guard: "ready".into(), + }, + ); + let mut session = Session::new(f.p.clone()); + session + .execute(Command::CreateRelation(relation.clone())) + .unwrap(); + let after = session.project().clone(); + let source = language::format(&after); + assert_eq!(language::parse(&source).unwrap(), after); + assert_eq!( + persistence::decode(&serde_json::to_vec(&after).unwrap()).unwrap(), + after + ); + session.undo(); + assert_eq!(session.project(), &f.p); + session.redo(); + assert_eq!(session.project(), &after); + session.execute(Command::Delete(f.r1)).unwrap(); + assert!(!session.project().relations.contains_key(&relation.id)); + session.undo(); + assert_eq!(session.project(), &after); + let fresh = Id::new_v4(); + let mut reidentified = after.clone(); + references::remap_references(&mut reidentified, f.r1, fresh); + let mut e = reidentified.elements.remove(&f.r1).unwrap(); + e.id = fresh; + reidentified.elements.insert(fresh, e); + valid(&reidentified); + assert!( + TopologyIndex::new(&reidentified) + .resolve(f.outer, &endpoint(&[fresh], f.b)) + .is_ok() + ); + for version in [4, 5, 6] { + let old = source.replacen("arcadia 7;", &format!("arcadia {version};"), 1); + assert!(language::parse(&old).is_err()); + let mut json = serde_json::to_value(&after).unwrap(); + json["schema_version"] = version.into(); + assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err()); + } + let mut direct = relation.clone(); + if let RelationBody::ContextualLink { target, .. } = &mut direct.body { + *target = endpoint(&[], f.x); + } + assert!(execute_batch(&f.p, &[Command::CreateRelation(direct)]).is_err()); +} + +fn operation_id(n: u128) -> Id { + Id::from_u128(0x52060906000040008000000000000000 + n) +} +fn operations_fixture() -> Project { + persistence::decode(include_bytes!( + "../../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + )) + .unwrap() +} + +#[test] +fn source7_operation_reverse_unoriented_call_and_reply_use_actual_binding() { + let mut p = operations_fixture(); + p.relations + .get_mut(&operation_id(34)) + .unwrap() + .body + .set_endpoints(operation_id(13), operation_id(12)); + for (call, allocation) in [ + (operation_id(43), operation_id(30)), + (operation_id(44), operation_id(31)), + ] { + p.elements + .insert(call, operation_edit(&p, call, Some(allocation))); + } + let reply = occurrence( + &mut p, + operation_id(40), + "Exact reset reply", + OccurrenceBody::Exchange { + exchange: Some(operation_id(34)), + sender: Some(operation_id(42)), + receiver: Some(operation_id(41)), + message_kind: MessageKind::Reply, + reply_to: Some(operation_id(43)), + values: vec![], + operation: None, + }, + ); + valid(&p); + for message in [operation_id(43), reply] { + let OperationResolution::Selected(proof) = resolve(&p, message).unwrap() else { + panic!() + }; + assert_eq!(proof.invocation, operation_id(43)); + assert_eq!(proof.allocation, operation_id(30)); + assert_eq!( + (proof.caller_port, proof.provider_port), + (operation_id(12), operation_id(13)) + ); + } + let mut other = p.elements[&reply].clone(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { reply_to, .. }, + .. + }) = other.shared_mut() + { + *reply_to = Some(operation_id(44)); + } + let q = execute_batch(&p, &[Command::EditElement(reply, other)]).unwrap(); + let OperationResolution::Selected(proof) = resolve(&q, reply).unwrap() else { + panic!() + }; + assert_eq!(proof.invocation, operation_id(44)); + assert_eq!(proof.allocation, operation_id(31)); + let mut oriented = p.relations[&operation_id(34)].clone(); + if let RelationBody::BoundaryExchange { oriented, .. } = &mut oriented.body { + *oriented = true; + } + assert!(execute_batch(&p, &[Command::EditRelation(oriented.id, oriented)]).is_err()); + assert_eq!( + persistence::decode(&serde_json::to_vec(&q).unwrap()).unwrap(), + q + ); +} + +#[test] +fn source7_operation_inherited_exact_allocation_is_not_a_same_item_substitute() { + let mut p = operations_fixture(); + let derived = p + .add_element( + Kind::Interface, + Layer::Sa, + "Specialized provider service", + None, + ) + .unwrap(); + *p.elements.get_mut(&derived).unwrap().shared_mut().unwrap() = SharedBody::Interface { + supertype: Some(operation_id(22)), + }; + p.relations + .get_mut(&operation_id(33)) + .unwrap() + .body + .set_endpoints(operation_id(13), derived); + let call = operation_id(43); + p.elements + .insert(call, operation_edit(&p, call, Some(operation_id(30)))); + valid(&p); + let OperationResolution::Selected(proof) = resolve(&p, call).unwrap() else { + panic!() + }; + assert_eq!(proof.allocation, operation_id(30)); + let other = p + .add_element(Kind::Interface, Layer::Sa, "Separate reset contract", None) + .unwrap(); + let mut separate = p.relations[&operation_id(30)].clone(); + separate.id = Id::new_v4(); + separate.alias = "separately_allocated_reset".into(); + separate.body.set_endpoints(other, operation_id(20)); + let separate_id = separate.id; + p.relations.insert(separate.id, separate); + p.relate( + RelationKind::InterfaceProvision, + "Also provides separate reset", + operation_id(13), + other, + ); + valid(&p); + // Same item and actual provider are insufficient: the caller requires only + // the inherited allocation's interface, not this separately allocated one. + let candidate = operation_edit(&p, call, Some(separate_id)); + let mut probe = p.clone(); + probe.elements.insert(call, candidate.clone()); + assert_eq!(resolve(&probe, call).unwrap_err().code, "operation_roles"); + assert!(execute_batch(&p, &[Command::EditElement(call, candidate.clone())]).is_err()); + p.relate( + RelationKind::InterfaceRequirement, + "Explicitly requires separate reset", + operation_id(12), + other, + ); + let q = execute_batch(&p, &[Command::EditElement(call, candidate)]).unwrap(); + let OperationResolution::Selected(proof) = resolve(&q, call).unwrap() else { + panic!() + }; + assert_eq!(proof.allocation, separate_id); + assert_eq!(proof.item, operation_id(20)); +} + +#[test] +fn source7_operation_transmission_matrix_rejects_mismatch_and_warns_unspecified() { + for kind in [MessageKind::Call, MessageKind::Asynchronous] { + for protocol in [ + TransmissionProtocol::Synchronous, + TransmissionProtocol::Asynchronous, + TransmissionProtocol::Unspecified, + ] { + let p = operations_fixture(); + let call = operation_id(43); + let mut candidate = operation_edit(&p, call, Some(operation_id(30))); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { message_kind, .. }, + .. + }) = candidate.shared_mut() + { + *message_kind = kind.clone(); + } + let mut allocation = p.relations[&operation_id(30)].clone(); + if let RelationBody::InterfaceItemAllocation { transmission, .. } = &mut allocation.body + { + *transmission = protocol; + } + let commands = [ + Command::EditElement(call, candidate.clone()), + Command::EditRelation(allocation.id, allocation.clone()), + ]; + let allowed = matches!( + (&kind, protocol), + (_, TransmissionProtocol::Unspecified) + | (MessageKind::Call, TransmissionProtocol::Synchronous) + | ( + MessageKind::Asynchronous, + TransmissionProtocol::Asynchronous + ) + ); + let result = execute_batch(&p, &commands); + if allowed { + let q = result.unwrap(); + assert!(matches!( + resolve(&q, call), + Ok(OperationResolution::Selected(_)) + )); + let warned = validation::validate(&q).iter().any(|i| { + i.code == "operation_transmission_unspecified" + && i.severity == Severity::Warning + }); + assert_eq!(warned, protocol == TransmissionProtocol::Unspecified); + } else { + assert!(result.is_err(), "{kind:?} with {protocol:?}"); + let mut probe = p.clone(); + probe.elements.insert(call, candidate); + probe.relations.insert(allocation.id, allocation); + assert_eq!( + resolve(&probe, call).unwrap_err().code, + "operation_transmission" + ); + } + } + } +} + +#[test] +fn source7_operation_allocation_delete_repairs_both_call_and_separate_reply_channels() { + let mut p = operations_fixture(); + let call = operation_id(43); + let allocation = operation_id(30); + let field = p + .add_element( + Kind::DataField, + Layer::Sa, + "Retained bidirectional value", + Some(operation_id(20)), + ) + .unwrap(); + if let Some(SharedBody::DataField { direction, .. }) = + p.elements.get_mut(&field).unwrap().shared_mut() + { + *direction = ParameterDirection::InOut; + } + let value = MessageValue { + path: vec![], + field, + expression: "unfinished(\n preserve exactly".into(), + }; + let mut invocation = operation_edit(&p, call, Some(allocation)); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = invocation.shared_mut() + { + values.push(value.clone()); + } + p.elements.insert(call, invocation); + let mut response = p.relations[&operation_id(34)].clone(); + response.id = Id::new_v4(); + response.alias = "explicit_reset_response".into(); + response + .body + .set_endpoints(operation_id(13), operation_id(12)); + if let RelationBody::BoundaryExchange { items, .. } = &mut response.body { + *items = vec![operation_id(20)]; + } + let response_id = response.id; + p.relations.insert(response.id, response); + let reply = occurrence( + &mut p, + operation_id(40), + "Explicit reset response", + OccurrenceBody::Exchange { + exchange: Some(response_id), + sender: Some(operation_id(42)), + receiver: Some(operation_id(41)), + message_kind: MessageKind::Reply, + reply_to: Some(call), + values: vec![value.clone()], + operation: None, + }, + ); + valid(&p); + let repair = |id| { + let mut channel = p.relations[&id].clone(); + if let RelationBody::BoundaryExchange { items, .. } = &mut channel.body { + items.retain(|item| *item != operation_id(20)); + } + Command::EditRelation(id, channel) + }; + let mut session = Session::new(p.clone()); + let source = language::format(&p); + assert!( + session + .execute(Command::Batch(vec![ + Command::Delete(allocation), + repair(operation_id(34)) + ])) + .is_err() + ); + assert_eq!(session.project(), &p); + assert_eq!(language::format(session.project()), source); + session + .execute(Command::Batch(vec![ + Command::Delete(allocation), + repair(operation_id(34)), + repair(response_id), + ])) + .unwrap(); + let after = session.project().clone(); + for message in [call, reply] { + assert!( + matches!(resolve(&after, message), Ok(OperationResolution::Unresolved { invocation, .. }) if invocation == call) + ); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = after.elements[&message].shared() + else { + panic!() + }; + assert_eq!(values, &vec![value.clone()]); + } + session.undo(); + assert_eq!(session.project(), &p); + assert_eq!(language::format(session.project()), source); + session.redo(); + assert_eq!(session.project(), &after); +} +fn operation_edit(p: &Project, message: Id, allocation: Option) -> Element { + let mut e = p.elements[&message].clone(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = e.shared_mut() + { + *operation = Some(OperationUse { allocation }); + } + e +} +#[test] +fn source7_parameterless_operations_have_exact_protocol_identity_and_deletion_intent() { + let p = operations_fixture(); + let a = operation_id(43); + let b = operation_id(44); + let reset = operation_id(30); + let status = operation_id(31); + let q = execute_batch( + &p, + &[ + Command::EditElement(a, operation_edit(&p, a, Some(reset))), + Command::EditElement(b, operation_edit(&p, b, Some(status))), + ], + ) + .unwrap(); + let OperationResolution::Selected(first) = resolve(&q, a).unwrap() else { + panic!() + }; + let OperationResolution::Selected(second) = resolve(&q, b).unwrap() else { + panic!() + }; + assert_ne!(first.allocation, second.allocation); + assert_ne!(first.item, second.item); + assert_eq!(language::parse(&language::format(&q)).unwrap(), q); + assert!(execute_batch(&q, &[Command::Delete(reset)]).is_err()); + let mut q = q; + let another = q + .add_element(Kind::Interface, Layer::Sa, "Other protocol", None) + .unwrap(); + q.relate( + RelationKind::InterfaceProvision, + "Other provided protocol", + operation_id(13), + another, + ); + q.relate( + RelationKind::InterfaceRequirement, + "Other required protocol", + operation_id(12), + another, + ); + let mut retained = q.relations[&reset].clone(); + retained.body.set_endpoints(another, operation_id(20)); + retained.id = Id::new_v4(); + retained.alias = "remaining_operation_allocation".into(); + q.relations.insert(retained.id, retained); + valid(&q); + let mut session = Session::new(q.clone()); + session.execute(Command::Delete(reset)).unwrap(); + assert!(matches!( + resolve(session.project(), a), + Ok(OperationResolution::Unresolved { .. }) + )); + assert!(matches!( + resolve(session.project(), b), + Ok(OperationResolution::Selected(_)) + )); + session.undo(); + assert_eq!(session.project(), &q); + session.redo(); + assert!(matches!( + resolve(session.project(), a), + Ok(OperationResolution::Unresolved { .. }) + )); + let mut wrong = q.elements[&a].clone(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = wrong.shared_mut() + { + *operation = Some(OperationUse { + allocation: Some(first.item), + }); + } + assert!(execute_batch(&q, &[Command::EditElement(a, wrong)]).is_err()); + let source = language::format(&q); + for version in [5, 6] { + assert!( + language::parse(&source.replacen("arcadia 7;", &format!("arcadia {version};"), 1)) + .is_err() + ); + } +} +#[test] +fn source7_ordinary_old_calls_keep_bytes_and_explicit_null_is_not_old_admission() { + let bytes = include_bytes!( + "../../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + ); + let old: serde_json::Value = serde_json::from_slice(bytes).unwrap(); + let p = operations_fixture(); + let current = serde_json::to_value(&p).unwrap(); + assert_eq!(old["elements"], current["elements"]); + for version in [5, 6] { + let mut old = old.clone(); + old["schema_version"] = version.into(); + let current = persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(); + assert_eq!( + serde_json::to_value(¤t).unwrap()["elements"], + old["elements"] + ); + let e = &mut old["elements"][operation_id(43).to_string()]["body"]["Sa"]["Shared"]["Occurrence"] + ["body"]["Exchange"]; + e["operation"] = serde_json::Value::Null; + assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err()); + } +} +#[test] +fn source7_allocation_removal_requires_independent_channel_repair_and_keeps_values() { + let mut p = operations_fixture(); + let call = operation_id(43); + let allocation = operation_id(30); + let mut field = p + .make_element( + Kind::DataField, + Layer::Sa, + "Reset reason", + Some(operation_id(20)), + ) + .unwrap(); + if let Some(SharedBody::DataField { direction, .. }) = field.shared_mut() { + *direction = ParameterDirection::In; + } + let field_id = field.id; + p.elements.insert(field.id, field); + let mut selected = operation_edit(&p, call, Some(allocation)); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = selected.shared_mut() + { + values.push(MessageValue { + path: vec![], + field: field_id, + expression: "unfinished expression (\n preserve".into(), + }); + } + p = execute_batch(&p, &[Command::EditElement(call, selected)]).unwrap(); + let before = p.clone(); + assert!(execute_batch(&p, &[Command::Delete(allocation)]).is_err()); + assert_eq!(p, before); + let mut channel = p.relations[&operation_id(34)].clone(); + if let RelationBody::BoundaryExchange { items, .. } = &mut channel.body { + items.retain(|id| *id != operation_id(20)); + } + let after = execute_batch( + &p, + &[ + Command::Delete(allocation), + Command::EditRelation(channel.id, channel), + ], + ) + .unwrap(); + assert!(matches!( + resolve(&after, call), + Ok(OperationResolution::Unresolved { .. }) + )); + let values = |p: &Project| match p.elements[&call].shared().unwrap() { + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + } => values.clone(), + _ => panic!(), + }; + assert_eq!(values(&after), values(&p)); + assert!( + execute_batch( + &after, + &[Command::EditElement( + call, + operation_edit(&after, call, Some(operation_id(31))) + )] + ) + .is_err() + ); +} +#[test] +fn source7_reply_derives_call_signature_and_void_return_channel_still_carries_operation() { + let mut p = operations_fixture(); + let call = operation_id(43); + p = execute_batch( + &p, + &[Command::EditElement( + call, + operation_edit(&p, call, Some(operation_id(30))), + )], + ) + .unwrap(); + let reply = occurrence( + &mut p, + operation_id(40), + "Reset reply", + OccurrenceBody::Exchange { + exchange: Some(operation_id(34)), + sender: Some(operation_id(42)), + receiver: Some(operation_id(41)), + message_kind: MessageKind::Reply, + reply_to: Some(call), + values: vec![], + operation: None, + }, + ); + valid(&p); + let OperationResolution::Selected(resolved) = resolve(&p, reply).unwrap() else { + panic!() + }; + assert_eq!(resolved.invocation, call); + assert_eq!(resolved.provider_port, operation_id(13)); + assert_eq!(resolved.item, operation_id(20)); + assert!( + execute_batch( + &p, + &[Command::EditElement(reply, operation_edit(&p, reply, None))] + ) + .is_err() + ); + let mut response = p.relations[&operation_id(34)].clone(); + response.id = Id::new_v4(); + response.alias = "separate_response".into(); + response + .body + .set_endpoints(operation_id(13), operation_id(12)); + if let RelationBody::BoundaryExchange { items, .. } = &mut response.body { + *items = vec![operation_id(21)]; + } + let response_id = response.id; + p.relations.insert(response.id, response); + let mut changed = p.elements[&reply].clone(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + }) = changed.shared_mut() + { + *exchange = Some(response_id); + } + assert!(execute_batch(&p, &[Command::EditElement(reply, changed.clone())]).is_err()); + if let RelationBody::BoundaryExchange { items, .. } = + &mut p.relations.get_mut(&response_id).unwrap().body + { + *items = vec![operation_id(20)]; + } + assert!(execute_batch(&p, &[Command::EditElement(reply, changed)]).is_ok()); +} +#[test] +fn source7_transition_preserves_qualified_paths_and_requires_reviewed_operation_omission() { + let mut f = chain(Layer::Oa); + let relation = link( + &f, + ContextualLinkPurpose::Precedence { + guard: "ready".into(), + }, + ); + f.p = execute_batch(&f.p, &[Command::CreateRelation(relation.clone())]).unwrap(); + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![f.outer]); + request.scope = DependencyScope::Required; + let plan = plan_transition(&f.p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let q = execute_batch(&f.p, &plan.commands).unwrap(); + let mapped = |id| plan.changes.iter().find(|c| c.source == id).unwrap().target; + let RelationBody::ContextualLink { owner, target, .. } = + &q.relations[&mapped(relation.id)].body + else { + panic!() + }; + assert_eq!(*owner, mapped(f.outer)); + assert_eq!(target.references, vec![mapped(f.r1)]); + assert_eq!(target.occurrence, mapped(f.b)); + let p = operations_fixture(); + let call = operation_id(43); + let q = execute_batch( + &p, + &[Command::EditElement( + call, + operation_edit(&p, call, Some(operation_id(30))), + )], + ) + .unwrap(); + let mut request = TransitionRequest::new(TransitionDirection::SaToOa, vec![operation_id(40)]); + request.scope = DependencyScope::Required; + let missing = plan_transition(&q, &request).unwrap(); + assert!( + missing + .conflicts + .iter() + .any(|c| c.source == Some(call) && c.message.contains("omission")) + ); + request.mappings = vec![ + TransitionMapping { + source: operation_id(10), + target: None, + target_kind: Some(Kind::Actor), + }, + TransitionMapping { + source: operation_id(11), + target: None, + target_kind: Some(Kind::Entity), + }, + ]; + request.operation_omissions.push(OperationOmission { + source: call, + rationale: "Operational conversation abstracts the technical service signature.".into(), + }); + let plan = plan_transition(&q, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let after = execute_batch(&q, &plan.commands).unwrap(); + let target = plan + .changes + .iter() + .find(|c| c.source == call) + .unwrap() + .target; + assert!(matches!( + resolve(&after, target), + Ok(OperationResolution::Ordinary) + )); + assert!(matches!( + resolve(&after, call), + Ok(OperationResolution::Selected(_)) + )); + request + .operation_omissions + .push(request.operation_omissions[0].clone()); + assert!(!plan_transition(&q, &request).unwrap().conflicts.is_empty()); +} +#[test] +fn source7_assembly_purposes_do_not_invent_precedence_or_global_coverage() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = chain(layer); + let index = TopologyIndex::new(&f.p); + let x = index + .resolve(f.outer, &endpoint(&[], f.x)) + .unwrap() + .work + .unwrap(); + let b = index + .resolve(f.outer, &endpoint(&[f.r1], f.b)) + .unwrap() + .work + .unwrap(); + let exchange = Id::new_v4(); + if layer == Layer::Oa { + f.p.relations.insert( + exchange, + Relation { + id: exchange, + alias: "assembly_exchange".into(), + name: "Supply B".into(), + description: String::new(), + body: RelationBody::Interaction { + source: x, + target: b, + items: vec![], + }, + }, + ); + } else { + let request = FunctionalExchangeRequest { + id: exchange, + alias: "assembly_exchange".into(), + name: "Supply B".into(), + description: String::new(), + source: x, + target: b, + source_port: PortChoice::Auto, + target_port: PortChoice::Auto, + items: vec![], + }; + f.p = execute_batch(&f.p, &plan_functional_exchange(&f.p, &request).unwrap()).unwrap(); + } + let relation = link(&f, ContextualLinkPurpose::Exchange { exchange }); + let p = execute_batch(&f.p, &[Command::CreateRelation(relation.clone())]).unwrap(); + let analysis = TopologyIndex::new(&p).analyze(f.outer, AnalysisLimits::default()); + assert!(analysis.complete); + assert_eq!(analysis.assembly.len(), 1); + assert!( + !analysis + .precedence + .iter() + .any(|e| e.relation == Some(relation.id)) + ); + assert!( + !analysis + .notices + .iter() + .any(|n| n.code == "ambiguous_sequence_branch") + ); + assert!( + view_options::behavior_evidence(&p, [f.outer]) + .exchanges + .contains(&exchange) + ); + assert!( + !view_options::behavior_evidence(&p, [f.procedure]) + .exchanges + .contains(&exchange) + ); + let mut reverse = relation.clone(); + if let RelationBody::ContextualLink { source, target, .. } = &mut reverse.body { + std::mem::swap(source, target); + } + assert!(execute_batch(&p, &[Command::EditRelation(relation.id, reverse)]).is_err()); + let mut common = relation.clone(); + common.body = RelationBody::ContextualLink { + owner: f.outer, + source: endpoint(&[f.r1], f.b), + target: endpoint(&[f.r2], f.b), + purpose: ContextualLinkPurpose::CommonWork { work: b }, + }; + let q = execute_batch(&p, &[Command::EditRelation(relation.id, common.clone())]).unwrap(); + let analysis = TopologyIndex::new(&q).analyze(f.outer, AnalysisLimits::default()); + assert_eq!(analysis.assembly.len(), 1); + assert!( + !analysis + .notices + .iter() + .any(|n| n.code == "ambiguous_sequence_branch") + ); + assert!( + !view_options::behavior_evidence(&q, [f.outer]) + .exchanges + .contains(&exchange) + ); + if let RelationBody::ContextualLink { purpose, .. } = &mut common.body { + *purpose = ContextualLinkPurpose::CommonWork { work: x }; + } + assert!(execute_batch(&q, &[Command::EditRelation(relation.id, common)]).is_err()); + let removed = execute_batch(&p, &[Command::Delete(exchange)]).unwrap(); + assert!(!removed.relations.contains_key(&relation.id)); + } +} +#[test] +fn source7_source_bundle_migration_is_token_only_and_new_link_stays_with_caller() { + let f = chain(Layer::Oa); + let mut bundle = source_project::format_bundle(&f.p); + let entry = bundle.files.get_mut(&bundle.entry).unwrap(); + *entry = entry + .replacen("arcadia 7;", "arcadia // preserve this header\r\n 6;", 1) + .replace('\n', "\r\n"); + let old = bundle.clone(); + let compiled = source_project::compile_bundle(&bundle, Default::default()).unwrap(); + assert_eq!(compiled.decoded.project, f.p); + assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 6); + for (path, source) in &old.files { + let expected = if path == &old.entry { + source.replacen(" 6;", " 7;", 1) + } else { + source.clone() + }; + assert_eq!(compiled.bundle.files[path], expected); + } + let relation = link( + &f, + ContextualLinkPurpose::Precedence { + guard: "caller only".into(), + }, + ); + let after = execute_batch(&f.p, &[Command::CreateRelation(relation.clone())]).unwrap(); + let updated = source_project::update_bundle(&compiled, &after, Default::default()).unwrap(); + assert_eq!( + updated.index.declarations[&relation.id].path, + updated.index.declarations[&f.outer].path + ); + let procedure_file = &compiled.index.declarations[&f.procedure].path; + assert_eq!( + compiled.bundle.files[procedure_file], + updated.bundle.files[procedure_file] + ); + assert_eq!( + source_project::compile_bundle(&updated.bundle, Default::default()) + .unwrap() + .decoded + .project, + after + ); +} +#[test] +fn source7_semantic_facets_retain_typed_context_and_operation_references() { + let f = chain(Layer::Oa); + let relation = link( + &f, + ContextualLinkPurpose::Precedence { + guard: "uuid-shaped prose remains prose".into(), + }, + ); + let p = execute_batch(&f.p, &[Command::CreateRelation(relation.clone())]).unwrap(); + let facet = capture_facet( + &p, + relation.id, + relation.id, + &FacetSelector::Relation(RelationFacet::ContextTarget), + ) + .unwrap(); + let mut baseline = SemanticBaseline { + facets: vec![facet], + }; + let new = Id::new_v4(); + visit_semantic_references(&mut baseline, |id| { + if *id == f.r1 { + *id = new + } + }); + let FacetReview::Relation(RelationReview::ContextTarget(review)) = &baseline.facets[0] else { + panic!() + }; + let Observed::Value(target) = &review.oa else { + panic!() + }; + assert_eq!(target.references, vec![new]); + assert_eq!(target.occurrence, f.b); + let source = capture_facet( + &p, + relation.id, + relation.id, + &FacetSelector::Relation(RelationFacet::Source), + ); + assert!( + source + .unwrap_err() + .contains("must apply on at least one side") + ); + let p = operations_fixture(); + let call = operation_id(43); + let facet = capture_facet( + &p, + Id::nil(), + call, + &FacetSelector::Occurrence(OccurrenceFacet::Operation), + ) + .unwrap(); + assert!(matches!( + facet, + FacetReview::Occurrence(OccurrenceReview::Operation(Reviewed { + oa: Observed::NotApplicable, + sa: Observed::Value(None) + })) + )); + let q = execute_batch( + &p, + &[Command::EditElement( + call, + operation_edit(&p, call, Some(operation_id(30))), + )], + ) + .unwrap(); + let facet = capture_facet( + &q, + Id::nil(), + call, + &FacetSelector::Occurrence(OccurrenceFacet::Operation), + ) + .unwrap(); + let mut baseline = SemanticBaseline { + facets: vec![facet], + }; + visit_semantic_references(&mut baseline, |id| { + if *id == operation_id(30) { + *id = new + } + }); + let FacetReview::Occurrence(OccurrenceReview::Operation(review)) = &baseline.facets[0] else { + panic!() + }; + assert_eq!( + review.sa, + Observed::Value(Some(OperationUse { + allocation: Some(new) + })) + ); +} + +#[test] +fn source7_selected_operation_requires_channel_lifelines_and_proven_direction() { + let p = operations_fixture(); + let call = operation_id(43); + for missing in ["channel", "lifeline", "direction"] { + let mut candidate = p.clone(); + let mut message = operation_edit(&candidate, call, Some(operation_id(30))); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange, sender, .. + }, + .. + }) = message.shared_mut() + else { + panic!() + }; + match missing { + "channel" => *exchange = None, + "lifeline" => *sender = None, + _ => { + let Some(SharedBody::Lifeline { represented }) = candidate + .elements + .get_mut(&operation_id(41)) + .unwrap() + .shared_mut() + else { + panic!() + }; + *represented = None; + } + } + candidate.elements.insert(call, message.clone()); + let code = resolve(&candidate, call).unwrap_err().code; + assert_eq!( + code, + match missing { + "channel" => "operation_channel", + "lifeline" => "operation_lifelines", + _ => "operation_direction", + } + ); + assert!(execute_batch(&p, &[Command::Replace(candidate.clone())]).is_err()); + let unresolved = operation_edit(&candidate, call, None); + candidate.elements.insert(call, unresolved); + valid(&candidate); + assert!(matches!( + resolve(&candidate, call), + Ok(OperationResolution::Unresolved { .. }) + )); + } +} + +#[test] +fn source7_context_resource_limits_are_per_request_and_count_authored_paths() { + let mut f = chain(Layer::Oa); + for n in 0..100 { + occurrence( + &mut f.p, + f.outer, + &format!("Reuse {n}"), + OccurrenceBody::SequenceReference { + sequence: Some(f.procedure), + }, + ); + } + let relation = link( + &f, + ContextualLinkPurpose::Precedence { + guard: String::new(), + }, + ); + f.p = execute_batch(&f.p, &[Command::CreateRelation(relation)]).unwrap(); + let old_steps: usize = + f.p.elements + .values() + .map(|e| e.steps().map_or(0, Vec::len)) + .sum(); + let mut limits = language::ParseLimits::UNLIMITED; + limits.max_step_references = old_steps; + assert!(limits.check_model(&f.p).is_err()); + limits.max_step_references += 1; + assert!(limits.check_model(&f.p).is_ok()); + let cold = TopologyIndex::with_cache_capacity(&f.p, 0); + let hot = TopologyIndex::with_cache_capacity(&f.p, 1); + let full = cold.analyze( + f.outer, + AnalysisLimits { + nodes: 1000, + edges: 2000, + }, + ); + assert!(full.complete); + assert_eq!(full.nodes.len(), 409); + assert!( + !hot.analyze( + f.outer, + AnalysisLimits { + nodes: 300, + edges: 2000 + } + ) + .complete + ); + let again = hot.analyze( + f.outer, + AnalysisLimits { + nodes: 1000, + edges: 2000, + }, + ); + assert!(again.complete); + assert_eq!(again.nodes, full.nodes); + assert_eq!(again.precedence, full.precedence); + assert_eq!(again.notices, full.notices); + let bad = endpoint(&vec![f.r1; 65], f.b); + assert_eq!( + hot.resolve(f.outer, &bad).unwrap_err().code, + "context_depth" + ); + assert_eq!(hot.resolve_scope(f.outer, &[f.r1]).unwrap(), f.procedure); +} diff --git a/crates/oa-core/tests/source_project.rs b/crates/oa-core/tests/source_project.rs index 43fa42bd..0171cc85 100644 --- a/crates/oa-core/tests/source_project.rs +++ b/crates/oa-core/tests/source_project.rs @@ -163,7 +163,9 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() { (5, oa_core::SCHEMA_VERSION) ); assert!(input.files[&input.entry].contains("arcadia 5;")); - assert!(result.bundle.files[&result.bundle.entry].contains("arcadia 6;")); + assert!( + result.bundle.files[&result.bundle.entry].contains(&format!("arcadia {SCHEMA_VERSION};")) + ); assert!( again.decoded.migration.is_none(), "stabilized current source must not migrate twice" diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 971d7569..36dc4c6e 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1,3 +1,6 @@ +pub(crate) mod architecture_authoring; +#[cfg(test)] +mod architecture_authoring_tests; mod checkpoints; #[cfg(test)] mod diagram_selection_tests; @@ -314,6 +317,7 @@ pub struct FilePrompt { #[derive(Debug, Clone)] pub enum Message { + Architecture(architecture_authoring::Event), Home(crate::home::Event), CheckpointTick, CheckpointWritten(Id, Result<(), String>), @@ -329,6 +333,7 @@ pub enum Message { ExactStrings(bool), Page(Page), Guide(crate::guide::Event), + Traceability(crate::views::traceability::Event), Behavior(crate::behavior::Event), Layer(Layer), Select(Id), @@ -450,6 +455,7 @@ pub struct App { /// Home resumes this page; explicit Architecture navigation remains separate. last_workspace_page: Page, pub guide: crate::guide::State, + pub traceability: crate::views::traceability::State, pub behavior: crate::behavior::State, pub data_state: crate::data_view::State, pub inspector_tab: crate::navigation::InspectorTab, @@ -460,6 +466,8 @@ pub struct App { pub active_diagram: Option, pub search: String, pub create_kind: Kind, + pub architecture: Option, + pub architecture_palette_expanded: bool, pub status: String, pub busy: bool, pub pending: Option, @@ -548,6 +556,7 @@ impl App { page: Page::Diagram, last_workspace_page: Page::Diagram, guide: crate::guide::State::default(), + traceability: Default::default(), behavior: Default::default(), data_state: Default::default(), inspector_tab: Default::default(), @@ -557,6 +566,8 @@ impl App { draft, active_diagram, search: String::new(), + architecture: None, + architecture_palette_expanded: false, create_kind: if layer == Layer::Sa { Kind::Function } else { @@ -623,6 +634,7 @@ impl App { } pub fn dirty(&self) -> bool { self.document.dirty() + || self.architecture.is_some() || self.workflow.is_some() || self.draft_dirty() || self.source_dirty() @@ -1051,6 +1063,39 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + if matches!(&message, Message::Connection(_)) + && self + .architecture + .as_ref() + .is_some_and(architecture_authoring::State::unnamed_tool) + { + self.architecture = None; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + // Named placement owns input. Passive geometry/checkpoints continue; + // navigation and close retain raw input until explicit completion/cancel. + if self.architecture.is_some() { + if matches!(message, Message::Escape) { + self.architecture = None; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + return Task::none(); + } + let placing = self.architecture.as_ref().is_some_and(|s| s.saved.placing); + if !matches!( + &message, + Message::Architecture(_) + | Message::CheckpointTick + | Message::CheckpointWritten(..) + | Message::Lint(_) + | Message::Linted(..) + | Message::WindowResized(_) + | Message::CanvasResized(_) + ) && !(placing && matches!(&message, Message::Pan(_) | Message::Zoom(_))) + { + self.status = "Finish placing this element or Cancel (Esc) before leaving. Your name is retained.".into(); + return Task::none(); + } + } // Scrollable geometry can change while async work or an overlay blocks // commands. Keep the observed translation current; its passive handler // still validates document, scenario and revision before accepting it. @@ -1303,6 +1348,7 @@ impl App { } fn handle(&mut self, message: Message) -> Task { match message { + Message::Architecture(event) => return self.architecture_event(event), Message::Home(event) => return self.home_event(event), Message::CheckpointTick => return self.checkpoint_tick(), Message::CheckpointWritten(id, result) => { @@ -1718,6 +1764,13 @@ impl App { self.refresh_draft(); } } + Message::Traceability(event) => { + self.traceability.update(event); + return iced::widget::operation::snap_to( + "traceability-list", + iced::widget::scrollable::RelativeOffset::START, + ); + } Message::Search(value) => { self.search = value; self.explorer_page = 0; @@ -1910,6 +1963,10 @@ impl App { if t.request.sources.first().is_some_and(|source| crate::behavior::selected_behavior(self.project(), *source).is_some())); let previous_behavior_context = self.state_view_identity(); + let requested_destination = match &workflow { + crate::workflow::Workflow::Transition(t) => t.request.destination_diagram, + _ => None, + }; match workflow.update(self.project(), event) { Ok(Some(mut candidate)) => { let focus = transition_focus @@ -1932,7 +1989,24 @@ impl App { .and_then(|id| crate::behavior::selected_behavior(&candidate, id)); let destination = focus.and_then(|id| { candidate.layer_of(id).and_then(|layer| { - preferred_diagram(&candidate, self.active_diagram, layer, id) + requested_destination + .filter(|view| { + candidate.diagrams.get(view).is_some_and(|d| { + (d.layer == layer + || d.projection == ProjectionKind::Traceability) + && d.nodes + .values() + .any(|node| node.element == id) + }) + }) + .or_else(|| { + preferred_diagram( + &candidate, + self.active_diagram, + layer, + id, + ) + }) }) }); if let (None, Some(id), Some(diagram)) = diff --git a/crates/oa-desktop/src/app/architecture_authoring.rs b/crates/oa-desktop/src/app/architecture_authoring.rs new file mode 100644 index 00000000..74f93df3 --- /dev/null +++ b/crates/oa-desktop/src/app/architecture_authoring.rs @@ -0,0 +1,683 @@ +//! Named, view-local creation. A draft never enters the accepted model. +use super::*; +use iced::widget::{button, column, pick_list, row, scrollable, text, text_input}; +use serde::{Deserialize, Serialize}; +mod palette; +pub use palette::view as palette; + +#[derive(Debug, Clone)] +pub enum Event { + Start(Kind, Option), + Allocate(Kind, Id, Option), + Rename(crate::route_edit::Context, Id, Position), + TogglePalette, + Existing(Option), + Name(String), + Submit, + Click(crate::route_edit::Context, Position), + Pick(Id), + Locate(Id), + Search(String), + Filter(Filter), + ShowPlaced(bool), + Page(usize), + Edit, + Cancel, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum Filter { + Context, + AllCompatible, + Kind(Kind), +} +impl std::fmt::Display for Filter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Context => f.write_str("Suggested for this view"), + Self::AllCompatible => f.write_str("All compatible types"), + Self::Kind(k) => f.write_str(k.label()), + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum Intent { + Create { + kind: Kind, + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + performer: Option, + }, + Rename { + element: Id, + name: String, + }, + Existing { + selected: Option, + }, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Saved { + pub version: u32, + pub base: String, + pub diagram: Id, + pub intent: Intent, + pub point: Option, + pub placing: bool, + pub query: String, + pub filter: Filter, + pub show_placed: bool, + pub page: usize, +} +#[derive(Debug, Clone)] +pub struct State { + pub saved: Saved, + document: Id, + revision: u64, + pub error: Option, +} +impl State { + pub fn restore(app: &App, saved: Saved) -> Result { + validate_saved(app.project(), &saved)?; + Ok(Self { + saved, + document: app.canvas_document, + revision: app.document.revision(), + error: None, + }) + } + pub fn modal(&self) -> bool { + !self.saved.placing && matches!(self.saved.intent, Intent::Existing { .. }) + } + pub fn inline(&self) -> bool { + !self.saved.placing + && self.saved.point.is_some() + && !matches!(self.saved.intent, Intent::Existing { .. }) + } + pub fn unnamed_tool(&self) -> bool { + self.saved.placing + && self.saved.point.is_none() + && matches!(&self.saved.intent, Intent::Create { name, .. } if name.is_empty()) + } +} +pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> { + if s.version != 1 || s.base != oa_core::transitions::project_fingerprint(p) { + return Err("The architecture draft does not match its accepted model.".into()); + } + let d = p + .diagrams + .get(&s.diagram) + .ok_or("The creation draft's diagram is missing.")?; + if matches!(d.projection, ProjectionKind::StateMachine { .. }) + || s.query.len() > 16384 + || s.page > 1_000_000 + || s.point + .is_some_and(|p| !p.x.is_finite() || !p.y.is_finite()) + { + return Err("Invalid architecture creation draft context.".into()); + } + match &s.intent { + Intent::Create { + kind, + name, + performer, + } if name.len() > 16384 + || !kind.supports(d.layer) + || !d.projection.includes(*kind) + || *kind == Kind::Port + || performer.is_some_and(|id| { + !kind.is_work() + || !p + .elements + .get(&id) + .is_some_and(|e| e.kind().is_performer() && e.layer() == d.layer) + }) => + { + Err("Invalid architecture creation draft type or name.".into()) + } + Intent::Existing { selected: Some(id) } if !compatible(p, d, *id) => { + Err("The creation draft's existing element is incompatible.".into()) + } + Intent::Rename { element, name } + if name.len() > 16384 + || !p.elements.contains_key(element) + || !d.nodes.values().any(|n| n.element == *element) => + { + Err("The rename draft's element is not shown in this view.".into()) + } + _ => Ok(()), + } +} +fn context_kinds(p: &Project, d: &Diagram) -> Vec { + let mut values = match (d.layer, d.projection) { + (_, ProjectionKind::StateMachine { .. }) => vec![], + (Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity], + (Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function], + (Layer::Oa, ProjectionKind::Capabilities) => vec![ + Kind::OperationalMission, + Kind::Capability, + Kind::Entity, + Kind::Actor, + ], + (Layer::Sa, ProjectionKind::Capabilities) => vec![ + Kind::Mission, + Kind::SystemCapability, + Kind::System, + Kind::SystemActor, + ], + (Layer::Oa, _) => vec![Kind::Entity, Kind::Actor, Kind::Activity], + (Layer::Sa, _) => vec![Kind::System, Kind::SystemActor, Kind::Function], + }; + values.retain(|k| *k != Kind::System || !p.elements.values().any(|e| e.kind() == Kind::System)); + values +} +pub fn kinds(app: &App) -> Vec { + app.active_diagram + .and_then(|id| app.project().diagrams.get(&id)) + .map(|d| context_kinds(app.project(), d)) + .unwrap_or_default() +} +fn compatible(p: &Project, d: &Diagram, id: Id) -> bool { + p.elements.get(&id).is_some_and(|e| { + e.kind() != Kind::Port + && d.projection.includes(e.kind()) + && (e.layer() == d.layer || d.projection == ProjectionKind::Traceability) + }) +} +fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool { + // Existing System remains a useful choice even when the singleton exists. + context_kinds(p, d).contains(&kind) + || (kind == Kind::System + && d.layer == Layer::Sa + && d.projection != ProjectionKind::FunctionalDataflow) +} +pub fn candidates(app: &App) -> Vec<(Id, String, bool)> { + let Some(s) = &app.architecture else { + return vec![]; + }; + let p = app.project(); + let Some(d) = p.diagrams.get(&s.saved.diagram) else { + return vec![]; + }; + let query = s.saved.query.to_lowercase(); + let mut entries: Vec<_> = p + .elements + .values() + .filter(|e| compatible(p, d, e.id)) + .filter(|e| match s.saved.filter { + Filter::Context => suggested(p, d, e.kind()), + Filter::AllCompatible => true, + Filter::Kind(k) => e.kind() == k, + }) + .filter_map(|e| { + let shown = d.nodes.values().any(|n| n.element == e.id); + let label = format!( + "{} · @{} · {} · {}", + e.name, + e.alias, + e.kind().label(), + e.parent.map(|id| p.named(id)).unwrap_or("At root") + ); + ((!shown || s.saved.show_placed) && label.to_lowercase().contains(&query)) + .then_some((e.id, label, shown)) + }) + .collect(); + entries.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); + entries +} +impl App { + pub(super) fn architecture_event(&mut self, event: Event) -> Task { + match event { + Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => { + if self.architecture.is_some() { + if self.architecture.as_ref().is_some_and(State::unnamed_tool) { + self.architecture = None; + } else { + self.status = + "Finish or cancel the current name before choosing another tool." + .into(); + return Task::none(); + } + } + if self.page != Page::Diagram + || self.source_dirty() + || self.draft_dirty() + || self.behavior.has_pending() + || self.workflow.is_some() + { + self.status = + "Apply or cancel pending edits before creating diagram content.".into(); + return Task::none(); + } + let Some(diagram) = self.active_diagram else { + return Task::none(); + }; + let intent = match event { + Event::Start(kind, _) if kinds(self).contains(&kind) => Intent::Create { + kind, + name: String::new(), + performer: None, + }, + Event::Allocate(kind, performer, _) + if kinds(self).contains(&kind) && kind.is_work() => + { + Intent::Create { + kind, + name: String::new(), + performer: Some(performer), + } + } + Event::Existing(_) => Intent::Existing { selected: None }, + _ => return Task::none(), + }; + let placing = point.is_none() && matches!(intent, Intent::Create { .. }); + let saved = Saved { + version: 1, + base: oa_core::transitions::project_fingerprint(self.project()), + diagram, + intent, + point, + placing, + query: String::new(), + filter: Filter::Context, + show_placed: false, + page: 0, + }; + if let Ok(state) = State::restore(self, saved) { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.architecture = Some(state); + self.connection = None; + self.fit_to_view = false; + self.status = + "Click in the diagram to position the new element · Esc cancels".into(); + } + return iced::widget::operation::focus("architecture-name"); + } + Event::TogglePalette => { + self.architecture_palette_expanded = !self.architecture_palette_expanded; + self.fit_to_view = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + Event::Rename(context, element, point) => { + if self.architecture.is_some() + || self.route_context() != Some(context) + || self.page != Page::Diagram + || self.draft_dirty() + || self.source_dirty() + || self.behavior.has_pending() + { + return Task::none(); + } + let Some(e) = self.project().elements.get(&element) else { + return Task::none(); + }; + let saved = Saved { + version: 1, + base: oa_core::transitions::project_fingerprint(self.project()), + diagram: context.diagram, + intent: Intent::Rename { + element, + name: e.name.clone(), + }, + point: Some(point), + placing: false, + query: String::new(), + filter: Filter::Context, + show_placed: false, + page: 0, + }; + if let Ok(state) = State::restore(self, saved) { + self.architecture = Some(state); + self.connection = None; + self.fit_to_view = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + return iced::widget::operation::focus("architecture-name"); + } + Event::Cancel => { + self.architecture = None; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + Event::Name(value) => { + if value.len() <= 16384 + && let Some(s) = &mut self.architecture + && let (Intent::Create { name, .. } | Intent::Rename { name, .. }) = + &mut s.saved.intent + { + *name = value; + s.error = None; + } + } + Event::Search(query) => { + if query.len() <= 16384 + && let Some(s) = &mut self.architecture + { + s.saved.query = query; + s.saved.page = 0; + } + } + Event::Filter(filter) => { + if let Some(s) = &mut self.architecture { + s.saved.filter = filter; + s.saved.page = 0; + } + } + Event::ShowPlaced(value) => { + if let Some(s) = &mut self.architecture { + s.saved.show_placed = value; + s.saved.page = 0; + } + } + Event::Page(page) => { + if let Some(s) = &mut self.architecture { + s.saved.page = page.min(1_000_000); + } + } + Event::Edit => { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + if let Some(s) = &mut self.architecture { + s.saved.placing = false; + } + return iced::widget::operation::focus("architecture-name"); + } + Event::Pick(id) => { + if !candidates(self) + .iter() + .any(|(candidate, _, shown)| *candidate == id && !shown) + { + return Task::none(); + } + if let Some(s) = &mut self.architecture { + s.saved.intent = Intent::Existing { selected: Some(id) }; + } + return self.architecture_event(Event::Submit); + } + Event::Locate(id) => { + if candidates(self) + .iter() + .any(|(candidate, _, shown)| *candidate == id && *shown) + && let Some(s) = self.architecture.take() + { + return self.update(Message::RevealInDiagram(s.saved.diagram, id)); + } + } + Event::Submit => { + let Some(s) = &mut self.architecture else { + return Task::none(); + }; + if matches!(&s.saved.intent, Intent::Create { name, .. } | Intent::Rename { name, .. } if name.trim().is_empty()) + { + s.error = Some("Enter an engineering name before placing this element.".into()); + return Task::none(); + } + if matches!(s.saved.intent, Intent::Existing { selected: None }) { + return Task::none(); + } + s.saved.placing = true; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + if let Some(point) = s.saved.point { + self.commit_architecture(point); + } + } + Event::Click(context, point) => { + if self.route_context() != Some(context) { + return Task::none(); + } + if self.architecture.as_ref().is_some_and(|s| s.saved.placing) { + if matches!( + self.architecture.as_ref().unwrap().saved.intent, + Intent::Existing { .. } + ) { + self.commit_architecture(point); + } else { + let s = self.architecture.as_mut().unwrap(); + s.saved.point = Some(point); + s.saved.placing = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + return iced::widget::operation::focus("architecture-name"); + } + } + } + } + Task::none() + } + fn commit_architecture(&mut self, point: Position) { + let Some(s) = self.architecture.as_ref() else { + return; + }; + if s.document != self.canvas_document + || s.revision != self.document.revision() + || Some(s.saved.diagram) != self.active_diagram + || self.page != Page::Diagram + || self.source_dirty() + || self.draft_dirty() + || self.behavior.has_pending() + || !point.x.is_finite() + || !point.y.is_finite() + { + self.status = + "The diagram changed. The name is retained; cancel and restart this placement." + .into(); + return; + } + let diagram = s.saved.diagram; + if let Intent::Rename { element, name } = &s.saved.intent { + let element = *element; + let name = name.clone(); + if name.trim().is_empty() { + return; + } + if self.execute(Command::Rename(element, name)) { + self.architecture = None; + self.selected = element; + self.refresh_draft(); + self.status = "Name updated · Undo restores the previous name".into(); + } else if let Some(s) = &mut self.architecture { + s.error = Some(self.status.clone()); + s.saved.placing = false; + } + return; + } + let mut candidate = self.project().clone(); + let mut commands = vec![]; + let id = match &s.saved.intent { + Intent::Create { + kind, + name, + performer, + } => { + if name.trim().is_empty() || !kinds(self).contains(kind) { + return; + } + let Some(element) = + candidate.make_element(*kind, candidate.diagrams[&diagram].layer, name, None) + else { + return; + }; + let id = element.id; + candidate.elements.insert(id, element.clone()); + commands.push(Command::CreateElement(element)); + if let Some(performer) = performer { + commands.push(Command::Connect( + RelationKind::Allocation, + "Performs".into(), + *performer, + id, + )); + } + id + } + Intent::Existing { selected: Some(id) } + if compatible(&candidate, &candidate.diagrams[&diagram], *id) + && !candidate.diagrams[&diagram] + .nodes + .values() + .any(|n| n.element == *id) => + { + *id + } + _ => return, + }; + let node = candidate.place(diagram, id, point); + commands.push(Command::CreateNode { + diagram, + node: candidate.diagrams[&diagram].nodes[&node].clone(), + }); + if self.execute(Command::Batch(commands)) { + self.architecture = None; + self.selected = id; + self.selected_node = Some(node); + self.selected_bend = None; + self.inspector_tab = crate::navigation::InspectorTab::Properties; + self.refresh_draft(); + self.fit_to_view = false; + self.status = "Element placed · Undo restores the whole change. Drag to move; Fit is available when needed.".into(); + } else if let Some(s) = &mut self.architecture { + s.error = Some(self.status.clone()); + s.saved.placing = false; + } + } +} +pub fn inline_editor(app: &App) -> Option> { + let s = app.architecture.as_ref().filter(|s| s.inline())?; + let point = s.saved.point?; + let size = app.canvas_size.unwrap_or(iced::Size::new(450., 350.)); + let width = (size.width - 16.).clamp(120., 320.); + let x = (point.x * app.zoom + app.pan.x).clamp(8., (size.width - width - 8.).max(8.)); + let y = (point.y * app.zoom + app.pan.y).clamp(8., (size.height - 152.).max(8.)); + Some( + column![ + iced::widget::Space::new().height(y), + row![ + iced::widget::Space::new().width(x), + iced::widget::opaque( + iced::widget::container(name_editor(app)) + .padding(10) + .width(width) + .style(crate::style::card) + ) + ] + ] + .into(), + ) +} +fn name_editor(app: &App) -> Element<'_, Message> { + let s = app.architecture.as_ref().unwrap(); + let (title, name, action) = match &s.saved.intent { + Intent::Create { + kind, + name, + performer, + } => ( + performer + .map(|id| { + format!( + "{} · performed by {}", + kind.label(), + crate::views::short(app.project().named(id), 32) + ) + }) + .unwrap_or_else(|| format!("New {}", kind.label())), + name, + "Create", + ), + Intent::Rename { name, .. } => ("Rename".into(), name, "Apply"), + Intent::Existing { .. } => unreachable!(), + }; + let mut content = column![ + text(title).size(12), + text_input("Engineering name", name) + .id("architecture-name") + .on_input(|v| Message::Architecture(Event::Name(v))) + .on_submit(Message::Architecture(Event::Submit)) + .padding(7) + .size(13), + row![ + button(text(action).size(12)) + .padding([5, 9]) + .style(crate::style::nav) + .on_press(Message::Architecture(Event::Submit)), + button(text("Cancel").size(12)) + .padding([5, 9]) + .style(crate::style::nav) + .on_press(Message::Architecture(Event::Cancel)) + ] + .spacing(6) + ] + .spacing(7); + if let Some(error) = &s.error { + content = content.push(text(crate::views::short(error, 100)).size(11)); + } + content.into() +} +pub fn dialog(app: &App) -> Element<'_, Message> { + let s = app.architecture.as_ref().unwrap(); + let action = |label: String, event| { + button(text(label).size(12)) + .padding(8) + .on_press(Message::Architecture(event)) + }; + let mut content = column![].spacing(12); + match &s.saved.intent { + Intent::Create { .. } | Intent::Rename { .. } => return name_editor(app), + Intent::Existing { .. } => { + let p = app.project(); + let d = &p.diagrams[&s.saved.diagram]; + let mut filters = vec![Filter::Context, Filter::AllCompatible]; + filters.extend( + Kind::ALL + .into_iter() + .filter(|k| { + *k != Kind::Port + && d.projection.includes(*k) + && (k.supports(d.layer) || d.projection == ProjectionKind::Traceability) + }) + .map(Filter::Kind), + ); + let entries = candidates(app); + let page = s.saved.page.min(entries.len().saturating_sub(1) / 30); + let mut rows = column![].spacing(5); + for (id, label, shown) in entries.iter().skip(page * 30).take(30) { + rows = rows.push( + action( + format!("{}{}", if *shown { "Locate · " } else { "Add · " }, label), + if *shown { + Event::Locate(*id) + } else { + Event::Pick(*id) + }, + ) + .width(iced::Fill), + ); + } + content = content.push(text("Add existing to this diagram").size(22)) + .push(text("Add a model element to this view. Its name and properties stay shared across views.").size(13)) + .push(text_input("Optional name, alias or owner filter", &s.saved.query).id("architecture-name").on_input(|v| Message::Architecture(Event::Search(v))).padding(9)) + .push(pick_list(filters, Some(s.saved.filter), |f| Message::Architecture(Event::Filter(f)))) + .push(iced::widget::checkbox(s.saved.show_placed).label("Include already shown (Locate)").on_toggle(|v| Message::Architecture(Event::ShowPlaced(v)))) + .push(text(format!("{} compatible matches · page {}", entries.len(), page + 1)).size(12)); + if entries.is_empty() { + content = content.push(text("No unplaced matches. Try All compatible types or include already shown elements.").size(13)); + } + content = content + .push(scrollable(rows).height((app.window_size.height - 500.).clamp(120., 260.))) + .push( + row![ + action("Previous".into(), Event::Page(page.saturating_sub(1))), + action( + "Next".into(), + Event::Page((page + 1).min(entries.len().saturating_sub(1) / 30)) + ), + action("Cancel".into(), Event::Cancel) + ] + .spacing(8), + ); + } + } + if let Some(error) = &s.error { + content = content.push(text(error).size(13)); + } + content.into() +} diff --git a/crates/oa-desktop/src/app/architecture_authoring/palette.rs b/crates/oa-desktop/src/app/architecture_authoring/palette.rs new file mode 100644 index 00000000..c7e1552f --- /dev/null +++ b/crates/oa-desktop/src/app/architecture_authoring/palette.rs @@ -0,0 +1,268 @@ +//! Compact neutral tool palette. Tool state never changes its allocated width. +use super::*; +use iced::{ + Point, Rectangle, Size, Theme, mouse, + widget::{ + canvas::{self, Frame, Geometry, Path, Stroke}, + container, tooltip, + }, +}; + +#[derive(Clone, Copy)] +enum Icon { + Element(Kind), + Arrow, + Existing, + Select, + Collapse(bool), +} +impl canvas::Program for Icon { + type State = (); + fn draw( + &self, + _: &(), + renderer: &iced::Renderer, + _: &Theme, + bounds: Rectangle, + _: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + let color = match self { + Self::Element(k) => crate::style::kind_color(*k), + _ => crate::style::INK, + }; + let stroke = Stroke::default().with_color(color).with_width(1.4); + let line = |points: &[(f32, f32)]| { + Path::new(|p| { + p.move_to(Point::new(points[0].0, points[0].1)); + for &(x, y) in &points[1..] { + p.line_to(Point::new(x, y)); + } + }) + }; + match self { + Self::Element(Kind::Actor | Kind::SystemActor | Kind::Role) => { + f.stroke(&Path::circle(Point::new(8., 4.), 2.5), stroke); + for points in [ + &[(8., 6.5), (8., 11.)][..], + &[(3., 8.), (13., 8.)], + &[(4., 15.), (8., 11.), (12., 15.)], + ] { + f.stroke(&line(points), stroke); + } + } + Self::Element(Kind::Capability | Kind::SystemCapability) => { + f.stroke(&Path::circle(Point::new(8., 8.), 6.), stroke); + } + Self::Element(Kind::Mission | Kind::OperationalMission) => { + f.stroke( + &line(&[(3., 15.), (3., 2.), (13., 2.), (10., 6.), (3., 6.)]), + stroke, + ); + } + Self::Element(k) => { + f.stroke( + &Path::rounded_rectangle( + Point::new(1., 3.), + Size::new(14., 10.), + if k.is_work() { 3.into() } else { 0.into() }, + ), + stroke, + ); + if !k.is_work() { + f.stroke( + &Path::rectangle(Point::new(4., 6.), Size::new(8., 4.)), + stroke, + ); + } + } + Self::Arrow => { + f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke); + f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke); + } + Self::Existing => { + f.stroke( + &Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)), + stroke, + ); + f.stroke( + &Path::rectangle(Point::new(5., 5.), Size::new(10., 10.)), + stroke, + ); + } + Self::Select => { + f.stroke( + &line(&[ + (3., 1.), + (3., 14.), + (7., 10.), + (11., 15.), + (13., 14.), + (9., 9.), + (15., 8.), + (3., 1.), + ]), + stroke, + ); + } + Self::Collapse(expanded) => { + let (a, b) = if *expanded { (5., 11.) } else { (11., 5.) }; + f.stroke(&line(&[(a, 3.), (b, 8.), (a, 13.)]), stroke); + } + } + vec![f.into_geometry()] + } +} +fn tool<'a>( + app: &App, + icon: Icon, + label: &'a str, + message: Message, + selected: bool, +) -> Element<'a, Message> { + let width = if app.architecture_palette_expanded { + 156. + } else { + 36. + }; + let graphic = iced::widget::canvas(icon).width(16).height(16); + let mut content = row![graphic].spacing(7).align_y(iced::Center); + if app.architecture_palette_expanded { + content = content.push(text(label).size(11).wrapping(text::Wrapping::None)); + } + let mut enabled = app.architecture.as_ref().is_none_or(State::unnamed_tool) + || matches!( + &message, + Message::Architecture(Event::Cancel | Event::TogglePalette) + ); + let mut hint = label.to_owned(); + if let Message::Connection(crate::connection_edit::Event::Choose(Some(connection))) = &message { + let view = oa_core::projection::project_view(app.project(), app.active_diagram.unwrap()); + if !crate::connection_edit::visible(&view).any(|id| connection.can_start(app.project(), id)) + { + enabled = false; + hint.push_str(" — add or show a compatible source first"); + } + } + let control = button(content) + .padding([5, 7]) + .height(28) + .width(width - 4.) + .clip(true) + .style(if selected { + crate::style::selected + } else { + crate::style::nav + }) + .on_press_maybe(enabled.then_some(message)); + tooltip( + control, + container(text(hint).size(12)) + .padding(7) + .style(crate::style::card), + tooltip::Position::Left, + ) + .into() +} +pub fn view(app: &App) -> Element<'_, Message> { + let mut content = column![tool( + app, + Icon::Collapse(app.architecture_palette_expanded), + if app.architecture_palette_expanded { + "Collapse tools" + } else { + "Expand tools" + }, + Message::Architecture(Event::TogglePalette), + false + )] + .spacing(3); + let group = |name: &'static str| -> Element<'_, Message> { + if app.architecture_palette_expanded { + container(text(name).size(10).color(crate::style::MUTED)) + .padding([5, 4]) + .into() + } else { + iced::widget::rule::horizontal(1).into() + } + }; + content = content.push(tool( + app, + Icon::Select, + if app.architecture.is_some() { + "Cancel / select" + } else { + "Select / move" + }, + if app.architecture.is_some() { + Message::Architecture(Event::Cancel) + } else { + Message::Connection(crate::connection_edit::Event::Choose(None)) + }, + app.architecture.is_none() && app.connection.is_none(), + )); + for (heading, work) in [ + ("Participants / capabilities", false), + ("Activities / functions", true), + ] { + let choices: Vec<_> = kinds(app) + .into_iter() + .filter(|k| k.is_work() == work) + .collect(); + if !choices.is_empty() { + content = content.push(group(heading)); + } + for kind in choices { + let active = app + .architecture + .as_ref() + .is_some_and(|s| matches!(s.saved.intent,Intent::Create{kind:k,..} if kind==k)); + content = content.push(tool( + app, + Icon::Element(kind), + kind.label(), + Message::Architecture(Event::Start(kind, None)), + active, + )); + } + } + content = content.push(group("Connections")); + for connection in crate::connection_tool::Tool::for_layer(app.layer) { + let d = &app.project().diagrams[&app.active_diagram.unwrap()]; + let kind = match connection { + crate::connection_tool::Tool::Interaction => Kind::Activity, + crate::connection_tool::Tool::FunctionalExchange => Kind::Function, + crate::connection_tool::Tool::CommunicationMean => Kind::Entity, + crate::connection_tool::Tool::BoundaryExchange => Kind::System, + }; + if !d.projection.includes(kind) { + continue; + } + content = content.push(tool( + app, + Icon::Arrow, + connection.label(), + Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))), + app.connection + .as_ref() + .is_some_and(|s| s.tool == *connection), + )); + } + content = content.push(group("Common")).push(tool( + app, + Icon::Existing, + "Add existing…", + Message::Architecture(Event::Existing(None)), + false, + )); + container(scrollable(content)) + .width(if app.architecture_palette_expanded { + 156 + } else { + 36 + }) + .height(iced::Fill) + .padding(2) + .style(crate::style::panel) + .into() +} diff --git a/crates/oa-desktop/src/app/architecture_authoring_tests.rs b/crates/oa-desktop/src/app/architecture_authoring_tests.rs new file mode 100644 index 00000000..ee49386c --- /dev/null +++ b/crates/oa-desktop/src/app/architecture_authoring_tests.rs @@ -0,0 +1,328 @@ +use super::*; +use crate::app::architecture_authoring::{Event, kinds}; + +fn setup(layer: Layer) -> App { + let mut app = App::with_project(Project::empty()); + app.layer = layer; + app.active_diagram = None; + let _ = app.update(Message::AddDiagram); + app.zoom = 0.75; + app.pan = iced::Vector::new(37., -19.); + app +} +fn send(app: &mut App, event: Event) { + let _ = app.update(Message::Architecture(event)); +} +#[test] +fn architecture_creation_names_before_commit_preserves_camera_and_undoes_once() { + let mut app = setup(Layer::Oa); + let before = app.document.bundle().clone(); + let model = app.project().clone(); + let camera = (app.zoom, app.pan); + send(&mut app, Event::Start(Kind::Entity, None)); + assert_eq!(app.project(), &model); + let context = app.route_context().unwrap(); + send( + &mut app, + Event::Click(context, Position { x: 219., y: 117. }), + ); + assert_eq!(app.project(), &model); + assert!(app.architecture.as_ref().unwrap().inline()); + send(&mut app, Event::Name(" Response team 雨 ".into())); + send(&mut app, Event::Submit); + assert!(app.architecture.is_none()); + assert_eq!((app.zoom, app.pan), camera); + let element = &app.project().elements[&app.selected]; + assert_eq!(element.name, " Response team 雨 "); + assert_eq!(element.parent, None); + let node = + &app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()]; + assert_eq!(node.position, Position { x: 219., y: 117. }); + let accepted = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &accepted); +} +#[test] +fn architecture_context_choices_are_small_and_system_is_singleton() { + let mut app = setup(Layer::Oa); + assert_eq!(kinds(&app), vec![Kind::Entity, Kind::Actor, Kind::Activity]); + app = setup(Layer::Sa); + assert_eq!( + kinds(&app), + vec![Kind::System, Kind::SystemActor, Kind::Function] + ); + send( + &mut app, + Event::Start(Kind::System, Some(Position { x: 60., y: 60. })), + ); + send(&mut app, Event::Name("Pump controller".into())); + send(&mut app, Event::Submit); + assert_eq!(kinds(&app), vec![Kind::SystemActor, Kind::Function]); +} +#[test] +fn architecture_name_survives_rejected_click_resize_navigation_and_escape_is_explicit() { + let mut app = setup(Layer::Oa); + let before = app.document.bundle().clone(); + send(&mut app, Event::Start(Kind::Activity, None)); + send(&mut app, Event::Name("Diagnose pressure".into())); + send(&mut app, Event::Start(Kind::Entity, None)); + assert!( + matches!(&app.architecture.as_ref().unwrap().saved.intent, architecture_authoring::Intent::Create { name, kind: Kind::Activity, .. } if name == "Diagnose pressure") + ); + send(&mut app, Event::Submit); + let mut stale = app.route_context().unwrap(); + stale.revision += 1; + send(&mut app, Event::Click(stale, Position { x: 1., y: 2. })); + let _ = app.update(Message::Page(Page::Home)); + let _ = app.update(Message::WindowResized(iced::Size::new(1100., 700.))); + assert_eq!(app.page, Page::Diagram); + assert!(app.architecture.is_some()); + assert_eq!(app.document.bundle(), &before); + let _ = app.update(Message::Escape); + assert!(app.architecture.is_none()); + assert_eq!(app.document.bundle(), &before); +} + +#[test] +fn architecture_existing_inventory_is_contextual_and_placement_never_implies_allocation() { + let mut app = setup(Layer::Oa); + let entity = app + .project() + .make_element(Kind::Entity, Layer::Oa, "Response team", None) + .unwrap(); + let entity_id = entity.id; + let work = app + .project() + .make_element(Kind::Activity, Layer::Oa, "Report status", None) + .unwrap(); + let work_id = work.id; + let data = app + .project() + .make_element(Kind::DataType, Layer::Oa, "Pressure", None) + .unwrap(); + let data_id = data.id; + assert!(app.execute(Command::Batch(vec![ + Command::CreateElement(entity), + Command::CreateElement(work), + Command::CreateElement(data) + ]))); + send(&mut app, Event::Existing(Some(Position { x: 80., y: 80. }))); + let ids: Vec<_> = architecture_authoring::candidates(&app) + .iter() + .map(|row| row.0) + .collect(); + assert!(ids.contains(&entity_id) && ids.contains(&work_id)); + assert!(!ids.contains(&data_id)); + send(&mut app, Event::Pick(entity_id)); + let semantic = app.project().elements.clone(); + let before = app.document.bundle().clone(); + let camera = (app.zoom, app.pan); + send( + &mut app, + Event::Existing(Some(Position { x: 90., y: 100. })), + ); + assert!( + !architecture_authoring::candidates(&app) + .iter() + .any(|row| row.0 == entity_id) + ); + send(&mut app, Event::ShowPlaced(true)); + assert!( + architecture_authoring::candidates(&app) + .iter() + .any(|row| row.0 == entity_id && row.2) + ); + send(&mut app, Event::Pick(work_id)); + assert_eq!(app.project().elements, semantic); + assert!(app.project().relations.is_empty()); + assert_eq!((app.zoom, app.pan), camera); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before); +} + +#[test] +fn architecture_checkpoint_retains_exact_name_point_and_fresh_binding_without_commands() { + for (name, placing) in [(" Team 雨 ", true), (" ", false)] { + let mut app = setup(Layer::Oa); + send(&mut app, Event::Start(Kind::Entity, None)); + send(&mut app, Event::Name(name.into())); + if placing { + send(&mut app, Event::Submit); + } + app.architecture.as_mut().unwrap().saved.point = Some(Position { x: 127.5, y: 215. }); + app.architecture.as_mut().unwrap().saved.placing = placing; + let snapshot = crate::recovery::Snapshot::capture( + app.canvas_document, + &app.document, + &app.source_workspace, + app.draft.as_ref(), + &app.behavior, + None, + app.selected, + ) + .unwrap() + .with_architecture(app.architecture.as_ref()); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let decoded: crate::recovery::Snapshot = serde_json::from_slice(&bytes).unwrap(); + let accepted = app.document.bundle().clone(); + let mut restored = setup(Layer::Sa); + let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(decoded)))); + assert_eq!(restored.document.bundle(), &accepted); + let draft = &restored.architecture.as_ref().unwrap().saved; + assert!( + matches!(&draft.intent, architecture_authoring::Intent::Create { name: actual, .. } if actual == name) + ); + assert_eq!(draft.placing, placing); + assert_eq!(draft.point, Some(Position { x: 127.5, y: 215. })); + if placing { + let context = restored.route_context().unwrap(); + send( + &mut restored, + Event::Click(context, Position { x: 20., y: 30. }), + ); + assert!(restored.architecture.as_ref().unwrap().inline()); + send(&mut restored, Event::Submit); + assert!(restored.architecture.is_none()); + assert_eq!(restored.project().elements[&restored.selected].name, name); + } else { + send(&mut restored, Event::Submit); + assert!(restored.architecture.is_some()); + assert_eq!(restored.document.bundle(), &accepted); + } + let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + value["architecture"]["base"] = json!("stale"); + assert!( + serde_json::from_value::(value) + .unwrap() + .restore() + .is_err() + ); + } +} + +#[test] +fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() { + use iced::widget::canvas::Program; + let mut app = setup(Layer::Oa); + send(&mut app, Event::Start(Kind::Actor, None)); + send(&mut app, Event::Name("Control centre".into())); + send(&mut app, Event::Submit); + let bounds = iced::Rectangle { + x: 260., + y: 180., + width: 600., + height: 380., + }; + let local = iced::Point::new(190., 100.); + let expected = Position { + x: (local.x - app.pan.x) / app.zoom, + y: (local.y - app.pan.y) / app.zoom, + }; + let canvas = crate::diagram::DiagramCanvas { + project: app.project(), + view_index: None, + diagram: app.active_diagram.unwrap(), + selected: app.selected, + zoom: app.zoom, + pan: app.pan, + context: app.route_context().unwrap(), + selected_bend: None, + editable: true, + placing: true, + connection: None, + }; + let mut state = crate::diagram::State::default(); + let action = canvas + .update( + &mut state, + &iced::widget::canvas::Event::Mouse(iced::mouse::Event::ButtonPressed( + iced::mouse::Button::Left, + )), + bounds, + iced::mouse::Cursor::Available(iced::Point::new( + bounds.x + local.x, + bounds.y + local.y, + )), + ) + .unwrap(); + let message = action + .into_inner() + .0 + .expect("named placement emits a command"); + let _ = app.update(message); + assert_eq!( + app.architecture.as_ref().unwrap().saved.point, + Some(expected) + ); + send(&mut app, Event::Submit); + let node = + &app.project().diagrams[&app.active_diagram.unwrap()].nodes[&app.selected_node.unwrap()]; + assert_eq!(node.position, expected); +} + +#[test] +fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() { + let mut app = setup(Layer::Oa); + send(&mut app, Event::Start(Kind::Entity, None)); + send(&mut app, Event::Name("First".into())); + send(&mut app, Event::Submit); + let stale = app.route_context().unwrap(); + send(&mut app, Event::Cancel); + send(&mut app, Event::Start(Kind::Actor, None)); + send(&mut app, Event::Name("Second".into())); + send(&mut app, Event::Submit); + let before = app.document.bundle().clone(); + send(&mut app, Event::Click(stale, Position { x: 1., y: 1. })); + assert_eq!(app.document.bundle(), &before); + assert!(app.architecture.is_some()); +} + +#[test] +fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() { + use crate::workflow::{Event as W, Workflow}; + let mut p = Project::empty(); + let work = p + .add_element(Kind::Activity, Layer::Oa, "Report status", None) + .unwrap(); + let architecture = p.ensure_diagram(Layer::Sa); + p.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation; + let mut first = Workflow::transition(&p, work).unwrap(); + let Workflow::Transition(t) = &first else { + unreachable!() + }; + let target = t + .plan + .changes + .iter() + .find(|c| c.source == work) + .unwrap() + .target; + let mut p = first.update(&p, W::Apply).unwrap().unwrap(); + let mut overview = p.diagrams[&architecture].clone(); + overview.id = Id::new_v4(); + overview.alias = "chosen_overview".into(); + overview.name = "Chosen system overview".into(); + overview.projection = ProjectionKind::Overview; + overview.nodes.clear(); + let chosen = overview.id; + p.diagrams.insert(chosen, overview); + let placement = p.place(chosen, target, Position { x: 823., y: 417. }); + let mut app = App::with_project(p); + app.selected = work; + app.active_diagram = Some(architecture); + app.refresh_draft(); + let _ = app.update(Message::StartTransition); + let _ = app.update(Message::Workflow(W::Destination(Choice { + id: chosen, + label: "Chosen system overview".into(), + }))); + let before = app.project().diagrams.clone(); + let _ = app.update(Message::Workflow(W::Apply)); + assert!(app.workflow.is_none(), "{}", app.status); + assert_eq!(app.active_diagram, Some(chosen)); + assert_eq!(app.selected, target); + assert_eq!(app.selected_node, Some(placement)); + assert_eq!(app.project().diagrams, before); +} diff --git a/crates/oa-desktop/src/app/checkpoints.rs b/crates/oa-desktop/src/app/checkpoints.rs index 8faf6037..9f5525cd 100644 --- a/crates/oa-desktop/src/app/checkpoints.rs +++ b/crates/oa-desktop/src/app/checkpoints.rs @@ -62,6 +62,7 @@ impl App { self.selected, ) .and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref())) + .map(|snapshot| snapshot.with_architecture(self.architecture.as_ref())) { Ok(snapshot) => snapshot, Err(error) => { @@ -186,11 +187,21 @@ impl App { } else { Page::Diagram }; - self.status = if self.migration.is_some() { - "Recovered source 5 as source 6 in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it." + if let Some(saved) = restored.architecture { + self.active_diagram = Some(saved.diagram); + self.layer = self.project().diagrams[&saved.diagram].layer; + self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); + self.page = Page::Diagram; + self.architecture = architecture_authoring::State::restore(self, saved).ok(); + } + self.status = if let Some(report) = &self.migration { + format!( + "Recovered source {} as source {} in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it.", + report.from_schema, report.to_schema + ) } else { - "Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged." - }.into(); + "Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into() + }; self.request_lint() } } diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs index 39d87d53..211a15bf 100644 --- a/crates/oa-desktop/src/app/process_canvas.rs +++ b/crates/oa-desktop/src/app/process_canvas.rs @@ -59,12 +59,62 @@ impl App { .topology .enter(context.document, context.owner); match event { + CanvasEvent::ExpandQualified(_, endpoint) => { + let mut state = std::mem::take(&mut self.behavior.topology); + let changed = state.expand_context(self.project(), context.owner, endpoint); + self.behavior.topology = state; + if changed { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + } + CanvasEvent::OpenQualified(_, endpoint) => { + if self.behavior.has_pending() || !self.apply() { + return Task::none(); + } + let resolved = oa_core::topology_context::TopologyIndex::new(self.project()) + .resolve(context.owner, &endpoint); + if let Ok(resolved) = resolved { + self.behavior.topology.remember( + context.owner, + endpoint + .references + .first() + .copied() + .unwrap_or(endpoint.occurrence), + ); + self.behavior.active_owner = Some(resolved.defining_sequence); + self.select(endpoint.occurrence); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + } + CanvasEvent::SelectQualified(_, endpoint) => { + if !self.behavior.has_pending() + && !self.draft_dirty() + && !self.source_dirty() + && let Ok(resolved) = + oa_core::topology_context::TopologyIndex::new(self.project()) + .resolve(context.owner, &endpoint) + { + let reference = endpoint + .references + .first() + .copied() + .unwrap_or(endpoint.occurrence); + self.select(reference); + self.behavior.topology.select_context(resolved.address); + } + } + CanvasEvent::Focus(_) => { + self.behavior.topology.focused = !self.behavior.topology.focused; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } CanvasEvent::Select(_, id) => { if !self.behavior.has_pending() && crate::behavior::selected_behavior(self.project(), id) == Some(context.owner) { self.select(id); self.selected_node = None; + self.behavior.topology.clear_context(); } } CanvasEvent::Choose(_, tool) => { @@ -234,6 +284,35 @@ mod tests { } } #[test] + fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() { + let (mut app, f) = setup(); + let original = app.document.snapshot(); + let initial = app.selected; + let event = begin(&app); + let _ = app.update(Message::TopologyCanvas(event)); + form( + &mut app, + Event::Name(" unfinished contextual work ".into()), + ); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context))); + assert!(app.behavior.topology.focused); + let raw = app.behavior.recovery_snapshot().unwrap(); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select( + context, f.nodes[1], + ))); + assert_eq!(app.selected, initial); + form(&mut app, Event::Submit); + assert!(app.behavior.has_pending()); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert!(Arc::ptr_eq(&original, &app.document.snapshot())); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context))); + assert!(!app.behavior.topology.focused); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + } + #[test] fn direct_process_append_empty_first_second_exchange_has_exact_source_history() { for layer in [Layer::Oa, Layer::Sa] { let (f, next) = direct_tests::empty_exchange_fixture(layer); diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index c1806f00..08a99218 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -711,14 +711,21 @@ pub fn view(app: &App) -> Element<'_, Message> { return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into(); }; let e = &p.elements[&selected.id]; + let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain) + && (uses_explicit_topology(p, e.id) || e.steps().is_none_or(Vec::is_empty)); if e.kind() == Kind::StateMachine { out = column![text("States & modes").size(20)].spacing(8); + } else if compact_process { + // The process canvas owns its compact header and remaining-height layout. + out = column![].spacing(6); + } else if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + out = column![text("Processes & functional chains").size(20)].spacing(8); } out = out.push( pick_list(choices, Some(selected), Message::BehaviorOwner) .width(Fill) .text_size(13) - .padding(10), + .padding(if compact_process { 6 } else { 10 }), ); if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { return topology::view(app, e.id, out); @@ -767,6 +774,17 @@ fn uses_explicit_topology(p: &Project, owner: Id) -> bool { }) } pub fn selected_behavior(p: &Project, selected: Id) -> Option { + if let Some(Relation { + body: RelationBody::ContextualLink { owner, .. }, + .. + }) = p.relations.get(&selected) + { + return p + .elements + .get(owner) + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .map(|e| e.id); + } let mut current = Some( p.relations .get(&selected) @@ -1154,7 +1172,7 @@ impl Scene { MessageKind::Timer { .. } => "timer · ", } }; - let message_label = if !scenario { + let mut message_label = if !scenario { r.map(|r| r.name.clone()).unwrap_or_else(|| e.name.clone()) } else if let MessageKind::Timer { expression } = message_kind { format!("{} · {expression}", e.name) @@ -1163,6 +1181,12 @@ impl Scene { } else { e.name.clone() }; + if scenario + && let Some(operation) = crate::data_values::operation_label(p, e) + { + message_label.push_str(" · "); + message_label.push_str(&operation); + } self.lines.push(Line { id: *id, points, @@ -2311,6 +2335,7 @@ mod direct_canvas_tests { f.owner, &format!("Reply {index}"), OccurrenceBody::Exchange { + operation: None, exchange: Some(f.exchange), sender: Some(f.receiver), receiver: Some(f.sender), diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 37e4894d..8559e524 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -5,6 +5,10 @@ use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; +#[cfg(test)] +#[path = "scenario_operation_tests.rs"] +mod operation_tests; + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Tool { Lifeline, @@ -125,6 +129,9 @@ pub enum Event { Receiver(Choice), Call(Choice), Style(MessageStyle), + OperationIntent(bool), + Operation(Choice), + ClearOperation, Parent(Choice), Expression(String), SecondGuard(String), @@ -175,6 +182,10 @@ struct Draft { receiver: Option, call: Option, style: MessageStyle, + #[serde(default)] + operation: Option, + #[serde(default)] + disabled_operation: Option, expression: String, second_guard: String, operator: FragmentOperator, @@ -330,6 +341,8 @@ impl State { receiver: None, call: None, style: MessageStyle::CallReply, + operation: None, + disabled_operation: None, expression: String::new(), second_guard: "else".into(), operator: FragmentOperator::Alternative, @@ -351,6 +364,27 @@ impl State { Event::Name(s) => d.name = s, Event::Expression(s) => d.expression = s, Event::SecondGuard(s) => d.second_guard = s, + Event::OperationIntent(enabled) => { + if enabled { + d.operation = Some( + d.operation + .clone() + .or_else(|| d.disabled_operation.take()) + .unwrap_or(OperationUse { allocation: None }), + ); + } else { + d.disabled_operation = + d.operation.take().or_else(|| d.disabled_operation.take()); + } + } + Event::Operation(c) => { + d.operation = Some(OperationUse { + allocation: Some(c.id), + }) + } + Event::ClearOperation => { + d.operation = Some(OperationUse { allocation: None }) + } Event::Target(c) => { d.target = Some(c.id); d.bindings.clear(); @@ -358,7 +392,9 @@ impl State { Event::Exchange(c) => { if d.exchange != Some(c.id) { d.exchange = Some(c.id); - d.reset_values(); + if !d.has_operation_intent(p) { + d.reset_values(); + } if !d.fixed_endpoints { d.sender = None; d.receiver = None; @@ -394,7 +430,9 @@ impl State { std::mem::swap(&mut d.sender, &mut d.receiver); d.call = None; } - d.retain_value_paths(p); + if !d.has_operation_intent(p) { + d.retain_value_paths(p); + } } } Event::Call(c) => { @@ -410,7 +448,7 @@ impl State { .. }) = p.elements.get(&c.id).and_then(|e| e.shared()) { - if d.exchange != *exchange { + if d.exchange != *exchange && !d.has_operation_intent(p) { d.reset_values(); } d.exchange = *exchange; @@ -426,18 +464,24 @@ impl State { d.values.insert(id, s); } Event::ValueFocus(key) - if d.exchange - .and_then(|id| p.relations.get(&id)) - .and_then(|r| crate::data_values::resolve(p, r, &d.kind(), &key)) + if d.signature(p) + .ok() + .flatten() + .and_then(|r| { + crate::data_values::resolve(p, &r, &d.value_kind(), &key) + }) .is_some() => { d.value_focus = Some(key); } Event::ValueBrowse(key) => { if key.as_ref().is_none_or(|key| { - d.exchange - .and_then(|id| p.relations.get(&id)) - .and_then(|r| crate::data_values::resolve(p, r, &d.kind(), key)) + d.signature(p) + .ok() + .flatten() + .and_then(|r| { + crate::data_values::resolve(p, &r, &d.value_kind(), key) + }) .is_some() }) { d.value_browse = key; @@ -767,6 +811,33 @@ fn calls(p: &Project, d: &Draft) -> Vec { .collect() } impl Draft { + fn has_operation_intent(&self, p: &Project) -> bool { + self.operation.is_some() + || self + .call + .and_then(|id| p.elements.get(&id)) + .is_some_and(|e| { + matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + operation: Some(_), + .. + }, + .. + }) + ) + }) + } + fn proposed_message(&self, p: &Project) -> oa_core::Element { + let mut message = self.append(p, "Operation preview", self.message_body()); + // This is a transient resolver input; no generated identity reaches a command. + message.id = Id::nil(); + message + } + fn signature(&self, p: &Project) -> Result, String> { + crate::data_values::message_signature(p, &self.proposed_message(p)) + } fn reset_values(&mut self) { self.values.clear(); self.value_focus = None; @@ -804,8 +875,8 @@ impl Draft { exchange: &Relation, ) -> Result, String> { match &self.value_browse { - Some(key) => crate::data_values::children(p, exchange, &self.kind(), key), - None => Ok(crate::data_values::roots(p, exchange, &self.kind())), + Some(key) => crate::data_values::children(p, exchange, &self.value_kind(), key), + None => Ok(crate::data_values::roots(p, exchange, &self.value_kind())), } } fn authored_values(&self) -> Vec<(&crate::data_values::FieldPathKey, &str)> { @@ -815,6 +886,9 @@ impl Draft { .map(|(key, value)| (key, value.as_str())) .collect() } + fn value_kind(&self) -> MessageKind { + crate::data_values::operation_value_phase(&self.kind(), self.operation.as_ref()) + } fn kind(&self) -> MessageKind { match self.style { MessageStyle::Asynchronous => MessageKind::Asynchronous, @@ -841,6 +915,14 @@ impl Draft { self.receiver }, message_kind: self.kind(), + operation: if matches!( + self.style, + MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous + ) { + self.operation.clone() + } else { + None + }, reply_to: self.call, values: self .values @@ -974,6 +1056,7 @@ impl Draft { sender: self.receiver, receiver: self.sender, message_kind: MessageKind::Reply, + operation: None, reply_to: Some(call_id), values: vec![], }, @@ -1136,7 +1219,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a use crate::data_values; let mut out = column![text("Message values").size(14)].spacing(7); if let Some(browse) = &d.value_browse { - let title = data_values::resolve(p, exchange, &d.kind(), browse) + let title = data_values::resolve(p, exchange, &d.value_kind(), browse) .map(|choice| format!("Fields inside {}", choice.label)) .unwrap_or_else(|| "This record path is no longer available in this message.".into()); out = out.push(text(title).size(11)).push( @@ -1182,7 +1265,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a match d .value_focus .as_ref() - .map(|key| data_values::resolve(p, exchange, &d.kind(), key)) + .map(|key| data_values::resolve(p, exchange, &d.value_kind(), key)) { Some(Some(focus)) => { let key = focus.key; @@ -1233,7 +1316,7 @@ fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a ValueSection::Authored, )); for (key, expression) in &authored[value_page(authored.len(), d.value_authored_page)] { - let title = data_values::resolve(p, exchange, &d.kind(), key) + let title = data_values::resolve(p, exchange, &d.value_kind(), key) .map(|choice| choice.label) .unwrap_or_else(|| format!("Unavailable field {} · review this value", key.field)); out = out.push( @@ -1579,8 +1662,71 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen .size(12), ); } - if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) { - form = form.push(value_panel(p, d, exchange)); + if p.layer_of(owner) == Some(Layer::Sa) + && viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface + && matches!( + d.style, + MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous + ) + { + form = form.push( + iced::widget::checkbox(d.operation.is_some()) + .label("Invoke a specific interface operation") + .on_toggle(|enabled| event(Event::OperationIntent(enabled))), + ); + if let Some(operation) = &d.operation { + form = form + .push(bounded_picker( + crate::data_values::operation_choices(p, &d.proposed_message(p)), + d, + operation.allocation, + "Choose the exact interface operation allocation…", + Event::Operation, + )) + .push( + button("Keep operation unresolved") + .on_press(event(Event::ClearOperation)), + ); + if let Some(id) = operation.allocation { + form = form.push(label(crate::data_values::operation_identity(p, id))); + } + } + } + if let Some(description) = + crate::data_values::operation_label(p, &d.proposed_message(p)) + { + form = form.push(label(description)); + } + match d.signature(p) { + Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)), + Err(problem) => { + form = form.push(label(problem)); + for (key, expression) in d.authored_values() { + form = form + .push( + text(format!( + "Retained value · {} / {}", + key.path + .iter() + .map(|id| p.named(*id)) + .collect::>() + .join(" → "), + p.named(key.field) + )) + .size(11), + ) + .push( + text_input("Value expression", expression) + .on_input({ + let key = key.clone(); + move |raw| event(Event::Value(key.clone(), raw)) + }) + .padding(8) + .size(12), + ); + } + } + Ok(None) => {} } if d.style == MessageStyle::CallReply { form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation.")); @@ -2072,6 +2218,7 @@ pub(crate) mod direct_tests { f.owner, &format!("Call {index}"), OccurrenceBody::Exchange { + operation: None, exchange: Some(f.exchange), sender: Some(f.sender), receiver: Some(f.receiver), @@ -3173,6 +3320,7 @@ mod data_contract_tests { f.scenario, name, OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender: Some(lifelines[0]), receiver: Some(lifelines[1]), diff --git a/crates/oa-desktop/src/behavior/scenario_operation_tests.rs b/crates/oa-desktop/src/behavior/scenario_operation_tests.rs new file mode 100644 index 00000000..ba137317 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_operation_tests.rs @@ -0,0 +1,304 @@ +use super::*; +use crate::document::Document; +use oa_core::source_project::ProjectLimits; + +fn fixture() -> Project { + persistence::decode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + ))) + .unwrap() +} +fn id(p: &Project, n: u32) -> Id { + let alias = format!("operation_probe_{n}"); + p.elements + .values() + .find(|e| e.alias == alias) + .map(|e| e.id) + .or_else(|| { + p.relations + .values() + .find(|r| r.alias == alias) + .map(|r| r.id) + }) + .unwrap() +} +fn choice(p: &Project, n: u32) -> Choice { + Choice { + id: id(p, n), + label: p.named(id(p, n)).to_owned(), + } +} +fn prepare(p: &Project) -> State { + let mut state = State::default(); + for event in [ + Event::Start(id(p, 40), Tool::Message), + Event::Style(MessageStyle::CallReply), + Event::Exchange(choice(p, 34)), + Event::Sender(choice(p, 41)), + Event::Receiver(choice(p, 42)), + Event::OperationIntent(true), + ] { + state.update(p, event).unwrap(); + } + state +} +#[test] +fn operation_gui_selects_two_parameterless_allocations_without_inventing_refinement_and_reply_derives_call() + { + let p = fixture(); + let mut state = prepare(&p); + let choices = crate::data_values::operation_choices( + &p, + &state.draft.as_ref().unwrap().proposed_message(&p), + ); + assert_eq!( + choices.iter().map(|c| c.id).collect::>(), + BTreeSet::from([id(&p, 30), id(&p, 31)]) + ); + for allocation in [30, 31] { + state + .update(&p, Event::Operation(choice(&p, allocation))) + .unwrap(); + let command = state.prepare_pending(&p).unwrap(); + let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); + let added: Vec<_> = expected + .elements + .values() + .filter(|e| !p.contains(e.id)) + .collect(); + assert_eq!(added.len(), 2); + let call = added + .iter() + .find(|e| { + matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + .. + }, + .. + }) + ) + }) + .unwrap(); + let reply = added + .iter() + .find(|e| { + matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + .. + }, + .. + }) + ) + }) + .unwrap(); + assert!( + matches!(call.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:Some(OperationUse {allocation:Some(selected)}),..},..}) if *selected==id(&p,allocation)) + ); + assert!( + matches!(reply.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:None,reply_to:Some(selected),..},..}) if *selected==call.id) + ); + let label = crate::data_values::operation_label(&expected, reply).unwrap(); + assert!(label.contains("Reply operation from Call")); + assert!(label.contains(if allocation == 30 { + "Reset" + } else { + "ReadStatus" + })); + assert_eq!(expected.relations, p.relations); + let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap(); + let before = doc.bundle().clone(); + doc.execute(command).unwrap(); + let after = doc.bundle().clone(); + assert_eq!(doc.project(), &expected); + assert_eq!( + Document::from_bundle(after.clone(), ProjectLimits::default()) + .unwrap() + .project(), + &expected + ); + assert!(doc.undo()); + assert_eq!(doc.bundle(), &before); + assert!(doc.redo()); + assert_eq!(doc.bundle(), &after); + } +} +#[test] +fn operation_gui_unresolved_and_disabled_selection_recovery_preserves_rejected_raw_values() { + let p = fixture(); + let mut state = prepare(&p); + state.update(&p, Event::Operation(choice(&p, 30))).unwrap(); + state.update(&p, Event::OperationIntent(false)).unwrap(); + let raw = state.recovery_snapshot().unwrap().unwrap(); + let mut state = State::from_recovery(raw.clone()).unwrap(); + assert_eq!(state.recovery_snapshot().unwrap(), Some(raw)); + state.update(&p, Event::OperationIntent(true)).unwrap(); + assert_eq!( + state + .draft + .as_ref() + .unwrap() + .operation + .as_ref() + .unwrap() + .allocation, + Some(id(&p, 30)) + ); + let key = crate::data_values::FieldPathKey { + path: vec![], + field: id(&p, 20), + }; + state + .update(&p, Event::Value(key.clone(), " rejected raw && ".into())) + .unwrap(); + state.update(&p, Event::ClearOperation).unwrap(); + assert!(state.draft.as_ref().unwrap().signature(&p).is_err()); + let raw = state.recovery_snapshot().unwrap().unwrap(); + let mut restored = State::from_recovery(raw.clone()).unwrap(); + assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone())); + assert!(restored.prepare_pending(&p).is_err()); + assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw)); + restored + .update(&p, Event::Operation(choice(&p, 31))) + .unwrap(); + assert_eq!( + restored.draft.as_ref().unwrap().values[&key], + " rejected raw && " + ); + assert!(restored.prepare_pending(&p).is_err()); + restored + .update(&p, Event::Value(key, String::new())) + .unwrap(); + assert!(restored.prepare_pending(&p).is_ok()); +} + +#[test] +fn operation_gui_async_picker_uses_request_root_and_nested_directions_without_erasing_rejected_values() + { + let mut p = fixture(); + let allocation = id(&p, 30); + if let RelationBody::InterfaceItemAllocation { transmission, .. } = + &mut p.relations.get_mut(&allocation).unwrap().body + { + *transmission = TransmissionProtocol::Asynchronous; + } + let record = p + .add_element( + Kind::DataType, + Layer::Sa, + "Operation parameter record", + None, + ) + .unwrap(); + *p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: None }, + default: None, + unit: String::new(), + }; + let leaf = p + .add_element(Kind::DataField, Layer::Sa, "Nested value", Some(record)) + .unwrap(); + let item = id(&p, 20); + let mut fields = vec![]; + for (name, direction) in [ + ("Request input", ParameterDirection::In), + ("Response output", ParameterDirection::Out), + ("Return result", ParameterDirection::Return), + ] { + let field = p + .add_element(Kind::DataField, Layer::Sa, name, Some(item)) + .unwrap(); + *p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField { + type_ref: TypeRef::Named(record), + direction, + minimum: 0, + maximum: Some(1), + unit: String::new(), + storage: FieldStorage::Value, + default: None, + }; + fields.push(field); + } + validate_candidate(&p).unwrap(); + let mut state = prepare(&p); + state + .update(&p, Event::Style(MessageStyle::Asynchronous)) + .unwrap(); + state.update(&p, Event::Operation(choice(&p, 30))).unwrap(); + let signature = state + .draft + .as_ref() + .unwrap() + .signature(&p) + .unwrap() + .unwrap(); + let choices = state + .draft + .as_ref() + .unwrap() + .browsed_values(&p, &signature) + .unwrap(); + assert_eq!( + choices.iter().map(|c| c.key.field).collect::>(), + vec![fields[0]] + ); + state + .update( + &p, + Event::ValueBrowse(Some(crate::data_values::FieldPathKey { + path: vec![], + field: fields[0], + })), + ) + .unwrap(); + let choices = state + .draft + .as_ref() + .unwrap() + .browsed_values(&p, &signature) + .unwrap(); + assert_eq!(choices.len(), 1); + assert_eq!( + choices[0].key, + crate::data_values::FieldPathKey { + path: vec![fields[0]], + field: leaf + } + ); + let rejected = crate::data_values::FieldPathKey { + path: vec![fields[1]], + field: leaf, + }; + state + .update( + &p, + Event::Value(rejected.clone(), " retained output expression ".into()), + ) + .unwrap(); + assert!(state.prepare_pending(&p).is_err()); + let raw = state.recovery_snapshot().unwrap().unwrap(); + let mut restored = State::from_recovery(raw.clone()).unwrap(); + assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw)); + assert_eq!( + restored.draft.as_ref().unwrap().values[&rejected], + " retained output expression " + ); + restored.update(&p, Event::OperationIntent(false)).unwrap(); + restored.update(&p, Event::ValueBrowse(None)).unwrap(); + let d = restored.draft.as_ref().unwrap(); + let ordinary = d.signature(&p).unwrap().unwrap(); + assert!( + d.browsed_values(&p, &ordinary) + .unwrap() + .iter() + .any(|c| c.key.field == fields[1]), + "Ordinary asynchronous channel behavior is preserved" + ); + assert_eq!(d.values[&rejected], " retained output expression "); +} diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index f4cadcbb..c7617b8d 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -3,6 +3,7 @@ use super::*; use iced::widget::{Column, text_input}; use std::collections::BTreeSet; +pub(crate) mod contextual; mod direct; mod view; use direct::Input; @@ -20,6 +21,7 @@ pub enum Tool { Control, Connect, Branch, + Contextual, } impl Tool { fn title(self) -> &'static str { @@ -30,6 +32,7 @@ impl Tool { Self::Control => "Add control", Self::Connect => "Connect contributions", Self::Branch => "Insert two paths", + Self::Contextual => "Connect qualified contributions", } } } @@ -50,6 +53,13 @@ pub enum Event { Control(ControlChoice), Search(usize, String), Page(usize, usize), + Contextual(contextual::Event), + Shelf(Shelf), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shelf { + Tools, + Selection, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ControlChoice(pub ControlKind); @@ -70,11 +80,15 @@ pub struct State { tool: CanvasTool, expanded: BTreeSet, scroll_offsets: [iced::Vector; 2], - return_to: Vec<(Id, Id)>, + return_to: Vec<(Id, Id, Option)>, draft: Option, viewport: Option, zoom: Option, fit: bool, + pub(crate) focused: bool, + contextual_expanded: BTreeSet, + selected_context: Option, + shelf: Option, } #[derive(Default, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -99,6 +113,8 @@ struct Draft { guard: String, alternate_guard: String, control: ControlKind, + #[serde(default)] + contextual: Option, } impl State { pub(super) fn recovery_snapshot(&self) -> Result, String> { @@ -140,6 +156,9 @@ impl State { return Err(PENDING_DRAFT.into()); } match event { + Event::Shelf(shelf) => { + self.shelf = (self.shelf != Some(shelf)).then_some(shelf); + } Event::Start(owner, tool, selected) => { let e = p .elements @@ -178,7 +197,17 @@ impl State { guard: String::new(), alternate_guard: "else".into(), control: ControlKind::Choice, + contextual: (tool == Tool::Contextual) + .then(|| contextual::Draft::new(p, owner, selected)), }); + if let Some(endpoint) = self + .qualified_selection(p, owner) + .map(|address| address.endpoint.clone()) + && let Some(contextual) = + self.draft.as_mut().and_then(|d| d.contextual.as_mut()) + { + contextual.seed_source(endpoint); + } } Event::Cancel => self.acknowledge_commit(), Event::Submit => return self.prepare_pending(p).map(Some), @@ -200,6 +229,11 @@ impl State { d.browsers[index].page = 0; } Event::Page(index, value) if index < 4 => d.browsers[index].page = value, + Event::Contextual(event) => { + if let Some(contextual) = &mut d.contextual { + contextual.update(event); + } + } _ => {} } } @@ -212,11 +246,31 @@ impl State { if !self.fit { return self.zoom.unwrap_or(1.); } - let width = self.viewport.map_or(fallback_width, |s| s.width); - ((width - 24.).max(1.) / graph.width.max(1.)) - .min(450. / graph.height.max(1.)) + let available = self.fit_area(fallback_width); + (available.width / graph.width.max(1.)) + .min(available.height / graph.height.max(1.)) .clamp(0.05, 1.) } + + fn fit_area(&self, fallback_width: f32) -> Size { + // The sensor measures the inner scroller, independent of graph content. + // Leave a small gutter for scrollbars; before first layout use a compact + // provisional viewport and recompute when the sensor reports actual size. + let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.)); + Size::new( + (viewport.width - 24.).max(1.), + (viewport.height - 10.).max(1.), + ) + } + + fn canvas_offset(&self) -> iced::Vector { + self.scroll_offsets[0] + } + + fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool { + let available = self.fit_area(fallback_width); + self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height) + } } mod recovery_insertion { @@ -461,6 +515,12 @@ impl Draft { if !matches!(owner.kind(), Kind::Process | Kind::FunctionalChain) { return Err("Choose a process or chain.".into()); } + if (self.tool == Tool::Contextual) != self.contextual.is_some() { + return Err("The qualified link draft and selected tool do not agree.".into()); + } + if let Some(draft) = &self.contextual { + return draft.command(p, self.owner, &self.name); + } for id in [self.before, self.after].into_iter().flatten() { if !is_contribution(p, self.owner, id) { return Err("Choose contributions from this process or chain.".into()); @@ -638,6 +698,7 @@ impl Draft { lifeline: None, }, Tool::Exchange => OccurrenceBody::Exchange { + operation: None, exchange: target, sender: None, receiver: None, @@ -744,10 +805,27 @@ struct GraphNode { detail: String, control: Option, source: Option, - preview: Vec<(Id, String)>, - preview_links: Vec<(Id, Id)>, + preview: Vec<(OccurrenceEndpoint, String)>, + preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>, preview_total: usize, } +impl GraphNode { + fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle { + self.preview + .iter() + .position(|(candidate, _)| candidate == endpoint) + .map(|i| { + Rectangle::new( + Point::new( + self.rect.x + 12. + (i % 2) as f32 * 125., + self.rect.y + 118. + (i / 2) as f32 * 44., + ), + Size::new(113., 32.), + ) + }) + .unwrap_or(self.rect) + } +} struct GraphEdge { id: Id, source: Id, @@ -755,7 +833,30 @@ struct GraphEdge { guard: String, points: Vec, label: Rectangle, + incidence: bool, + layer: EdgeLayer, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EdgeLayer { + Direct, + Contextual, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaintPass { + DirectEdges, + NodeBackgrounds, + ContextualEdges, + NodeContents, + Labels, +} +const PAINT_PASSES: [PaintPass; 5] = [ + PaintPass::DirectEdges, + PaintPass::NodeBackgrounds, + PaintPass::ContextualEdges, + PaintPass::NodeContents, + PaintPass::Labels, +]; +mod contextual_routing; struct Graph<'a> { project: &'a Project, interaction: Option<(Context, CanvasTool, bool)>, @@ -771,6 +872,16 @@ impl<'a> Graph<'a> { Self::expanded(p, owner, selected, &BTreeSet::new()) } fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet) -> Self { + Self::expanded_qualified(p, owner, selected, expanded, &BTreeSet::new()) + } + fn expanded_qualified( + p: &'a Project, + owner: Id, + selected: Id, + expanded: &BTreeSet, + expanded_paths: &BTreeSet, + ) -> Self { + let context_index = oa_core::topology_context::TopologyIndex::new(p); let choices = contributions(p, owner); let links = sequence_links(p, owner); let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect(); @@ -864,26 +975,51 @@ impl<'a> Graph<'a> { if let Some(source) = sequence { let contributions = contributions(p, *source); node.preview_total = contributions.len(); - node.preview = contributions - .into_iter() - .take(if expanded.contains(&e.id) { - usize::MAX - } else { - 4 - }) - .map(|c| (c.id, p.named(c.id).to_owned())) - .collect(); + node.preview = contextual::preview( + &context_index, + p, + owner, + e.id, + expanded_paths, + if expanded.contains(&e.id) { 512 } else { 4 }, + ) + .into_iter() + .map(|endpoint| { + let name = format!( + "{}{}", + "› ".repeat(endpoint.references.len().saturating_sub(1).min(3)), + p.named(endpoint.occurrence) + ); + (endpoint, name) + }) + .collect(); node.rect.height = (132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.); let preview_ids: BTreeSet<_> = - node.preview.iter().map(|(id, _)| *id).collect(); + node.preview.iter().map(|(id, _)| id.clone()).collect(); node.preview_links = sequence_links(p, *source) .into_iter() .filter(|r| { - preview_ids.contains(&r.source()) - && preview_ids.contains(&r.target()) + preview_ids.contains(&OccurrenceEndpoint { + references: vec![e.id], + occurrence: r.source(), + }) && preview_ids.contains(&OccurrenceEndpoint { + references: vec![e.id], + occurrence: r.target(), + }) + }) + .map(|r| { + ( + OccurrenceEndpoint { + references: vec![e.id], + occurrence: r.source(), + }, + OccurrenceEndpoint { + references: vec![e.id], + occurrence: r.target(), + }, + ) }) - .map(|r| (r.source(), r.target())) .collect(); } } @@ -944,11 +1080,83 @@ impl<'a> Graph<'a> { guard, points, label, + incidence: false, + layer: EdgeLayer::Direct, + }); + } + let mut contextual_pairs = BTreeSet::new(); + for r in p.relations.values() { + let RelationBody::ContextualLink { + owner: caller, + source, + target, + purpose, + } = &r.body + else { + continue; + }; + if *caller != owner { + continue; + } + let source_id = source + .references + .first() + .copied() + .unwrap_or(source.occurrence); + let target_id = target + .references + .first() + .copied() + .unwrap_or(target.occurrence); + let Some(a) = nodes + .iter() + .find(|n| n.id == source_id) + .map(|n| contextual_routing::anchor(n, source, false)) + else { + continue; + }; + let Some(b) = nodes + .iter() + .find(|n| n.id == target_id) + .map(|n| contextual_routing::anchor(n, target, true)) + else { + continue; + }; + let below = size.height + 30. + edges.len() as f32 * 48.; + let first = contextual_pairs.insert((source.clone(), target.clone())); + let (points, label) = contextual_routing::route(a, b, below, first); + let (caption, incidence) = match purpose { + ContextualLinkPurpose::Precedence { guard } => { + (format!("Precedence · {guard}"), false) + } + ContextualLinkPurpose::Exchange { exchange } => { + (format!("Assembly · {}", p.named(*exchange)), true) + } + ContextualLinkPurpose::CommonWork { work } => { + (format!("Common work · {}", p.named(*work)), true) + } + }; + edges.push(GraphEdge { + id: r.id, + source: source_id, + target: target_id, + guard: caption, + points, + label, + incidence, + layer: EdgeLayer::Contextual, }); } for e in &edges { for pt in &e.points { size.height = size.height.max(pt.y + 30.); + if e.layer == EdgeLayer::Contextual { + size.width = size.width.max(pt.x + 24.); + } + } + if e.layer == EdgeLayer::Contextual { + size.width = size.width.max(e.label.x + e.label.width + 24.); + size.height = size.height.max(e.label.y + e.label.height + 24.); } } Self { @@ -1053,155 +1261,196 @@ impl canvas::Program for Graph<'_> { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); f.scale(self.scale); - for e in &self.edges { - let selected = - e.id == self.selected || e.source == self.selected || e.target == self.selected; - draw_arrow( - &mut f, - &e.points, - if selected { style::TEAL } else { style::MUTED }, - if selected { 2.5 } else { 1.5 }, - ); - } - for n in &self.nodes { - let rect = Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()); - f.fill(&rect, Color::from_rgb8(243, 248, 248)); - f.stroke( - &rect, - Stroke::default() - .with_color(if n.id == self.selected { - style::TEAL - } else { - style::LINE - }) - .with_width(if n.id == self.selected { 2.5 } else { 1. }), - ); - let text_x = n.rect.x + if n.control.is_some() { 68. } else { 12. }; - if let Some(kind) = n.control { - let center = Point::new(n.rect.x + 33., n.rect.y + 34.); - let glyph = match kind { - ControlKind::Choice | ControlKind::Merge => Path::new(|p| { - p.move_to(Point::new(center.x, center.y - 22.)); - p.line_to(Point::new(center.x + 23., center.y)); - p.line_to(Point::new(center.x, center.y + 22.)); - p.line_to(Point::new(center.x - 23., center.y)); - p.close(); - }), - ControlKind::Fork | ControlKind::Join => Path::rectangle( - Point::new(center.x - 23., center.y - 5.), - Size::new(46., 10.), - ), - ControlKind::Loop => Path::circle(center, 23.), - }; - f.stroke( - &glyph, - Stroke::default().with_color(style::TEAL).with_width(2.), - ); - if matches!(kind, ControlKind::Fork | ControlKind::Join) { - f.fill(&glyph, style::TEAL); - } else { - draw_text( - &mut f, - if kind == ControlKind::Loop { - "IT" + for pass in PAINT_PASSES { + match pass { + PaintPass::DirectEdges | PaintPass::ContextualEdges => { + for e in self.edges.iter().filter(|edge| { + edge.layer + == if pass == PaintPass::DirectEdges { + EdgeLayer::Direct + } else { + EdgeLayer::Contextual + } + }) { + let selected = e.id == self.selected + || e.source == self.selected + || e.target == self.selected; + if e.incidence { + f.stroke( + &Path::new(|path| { + path.move_to(e.points[0]); + for point in &e.points[1..] { + path.line_to(*point); + } + }), + Stroke::default() + .with_color(if selected { style::TEAL } else { style::MUTED }) + .with_width(if selected { 2.5 } else { 1.5 }), + ); } else { - "OR" + draw_arrow( + &mut f, + &e.points, + if selected { style::TEAL } else { style::MUTED }, + if selected { 2.5 } else { 1.5 }, + ); } - .into(), - Point::new(center.x - 10., center.y - 8.), - 12., - style::TEAL, - ); + } } - } - draw_text( - &mut f, - wrapped(&n.title, if n.control.is_some() { 25 } else { 34 }, 2), - Point::new(text_x, n.rect.y + 12.), - 13., - style::INK, - ); - draw_text( - &mut f, - wrapped(&n.detail, 37, 2), - Point::new(n.rect.x + 12., n.rect.y + 60.), - 11., - style::MUTED, - ); - if n.source.is_some() { - draw_text( - &mut f, - format!("Source preview · {} / {}", n.preview.len(), n.preview_total), - Point::new(n.rect.x + 12., n.rect.y + 94.), - 11., - style::TEAL, - ); - let mini: BTreeMap<_, _> = n - .preview - .iter() - .enumerate() - .map(|(i, (id, _))| { - ( - *id, - Rectangle::new( - Point::new( - n.rect.x + 12. + (i % 2) as f32 * 125., - n.rect.y + 118. + (i / 2) as f32 * 44., + PaintPass::NodeBackgrounds => { + for n in &self.nodes { + let rect = + Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()); + f.fill(&rect, Color::from_rgb8(243, 248, 248)); + f.stroke( + &rect, + Stroke::default() + .with_color(if n.id == self.selected { + style::TEAL + } else { + style::LINE + }) + .with_width(if n.id == self.selected { 2.5 } else { 1. }), + ); + } + } + PaintPass::NodeContents => { + for n in &self.nodes { + let text_x = n.rect.x + if n.control.is_some() { 68. } else { 12. }; + if let Some(kind) = n.control { + let center = Point::new(n.rect.x + 33., n.rect.y + 34.); + let glyph = match kind { + ControlKind::Choice | ControlKind::Merge => Path::new(|p| { + p.move_to(Point::new(center.x, center.y - 22.)); + p.line_to(Point::new(center.x + 23., center.y)); + p.line_to(Point::new(center.x, center.y + 22.)); + p.line_to(Point::new(center.x - 23., center.y)); + p.close(); + }), + ControlKind::Fork | ControlKind::Join => Path::rectangle( + Point::new(center.x - 23., center.y - 5.), + Size::new(46., 10.), ), - Size::new(113., 32.), - ), - ) - }) - .collect(); - for (a, b) in &n.preview_links { - let a = mini[a]; - let b = mini[b]; - draw_arrow( - &mut f, - &[ - Point::new(a.x + a.width, a.center_y()), - Point::new(b.x, b.center_y()), - ], - style::MUTED, - 1., - ); + ControlKind::Loop => Path::circle(center, 23.), + }; + f.stroke( + &glyph, + Stroke::default().with_color(style::TEAL).with_width(2.), + ); + if matches!(kind, ControlKind::Fork | ControlKind::Join) { + f.fill(&glyph, style::TEAL); + } else { + draw_text( + &mut f, + if kind == ControlKind::Loop { + "IT" + } else { + "OR" + } + .into(), + Point::new(center.x - 10., center.y - 8.), + 12., + style::TEAL, + ); + } + } + draw_text( + &mut f, + wrapped(&n.title, if n.control.is_some() { 25 } else { 34 }, 2), + Point::new(text_x, n.rect.y + 12.), + 13., + style::INK, + ); + draw_text( + &mut f, + wrapped(&n.detail, 37, 2), + Point::new(n.rect.x + 12., n.rect.y + 60.), + 11., + style::MUTED, + ); + if n.source.is_some() { + draw_text( + &mut f, + format!( + "{} shown · {} direct contributions", + n.preview.len(), + n.preview_total + ), + Point::new(n.rect.x + 12., n.rect.y + 94.), + 11., + style::TEAL, + ); + let mini: BTreeMap<_, _> = n + .preview + .iter() + .enumerate() + .map(|(i, (id, _))| { + ( + id.clone(), + Rectangle::new( + Point::new( + n.rect.x + 12. + (i % 2) as f32 * 125., + n.rect.y + 118. + (i / 2) as f32 * 44., + ), + Size::new(113., 32.), + ), + ) + }) + .collect(); + for (a, b) in &n.preview_links { + let a = mini[a]; + let b = mini[b]; + draw_arrow( + &mut f, + &[ + Point::new(a.x + a.width, a.center_y()), + Point::new(b.x, b.center_y()), + ], + style::MUTED, + 1., + ); + } + for (id, name) in &n.preview { + let r = mini[id]; + f.fill_rectangle(r.position(), r.size(), Color::WHITE); + f.stroke( + &Path::rectangle(r.position(), r.size()), + Stroke::default().with_color(style::LINE), + ); + draw_text( + &mut f, + wrapped(name, 17, 2), + Point::new(r.x + 4., r.y + 3.), + 9., + style::INK, + ); + } + if n.preview.is_empty() { + draw_text( + &mut f, + "Source has no contributions yet".into(), + Point::new(n.rect.x + 12., n.rect.y + 136.), + 11., + style::MUTED, + ); + } + } + } } - for (id, name) in &n.preview { - let r = mini[id]; - f.fill_rectangle(r.position(), r.size(), Color::WHITE); - f.stroke( - &Path::rectangle(r.position(), r.size()), - Stroke::default().with_color(style::LINE), - ); - draw_text( - &mut f, - wrapped(name, 17, 2), - Point::new(r.x + 4., r.y + 3.), - 9., - style::INK, - ); + PaintPass::Labels => { + for e in &self.edges { + if !e.guard.is_empty() { + f.fill_rectangle(e.label.position(), e.label.size(), Color::WHITE); + draw_text( + &mut f, + wrapped(&format!("[{}]", e.guard), 19, 2), + e.label.position(), + 11., + style::INK, + ); + } + } } - if n.preview.is_empty() { - draw_text( - &mut f, - "Source has no contributions yet".into(), - Point::new(n.rect.x + 12., n.rect.y + 136.), - 11., - style::MUTED, - ); - } - } - } - for e in &self.edges { - if !e.guard.is_empty() { - f.fill_rectangle(e.label.position(), e.label.size(), Color::WHITE); - draw_text( - &mut f, - wrapped(&format!("[{}]", e.guard), 19, 2), - e.label.position(), - 11., - style::INK, - ); } } self.overlay(state, &mut f, cursor, bounds); @@ -1296,6 +1545,7 @@ mod tests { guard: "request accepted".into(), alternate_guard: "else".into(), control: ControlKind::Choice, + contextual: None, } } fn legacy_fixture() -> (Project, Id) { @@ -1339,6 +1589,7 @@ mod tests { owner_id, &format!("Ordered contribution {index}"), OccurrenceBody::Exchange { + operation: None, exchange: Some(exchanges[index % exchanges.len()]), sender: None, receiver: None, @@ -1565,7 +1816,13 @@ mod tests { .iter() .find(|n| n.source == Some(source_id)) .unwrap(); - assert_eq!(reference.preview[0].0, inside_id); + assert_eq!( + reference.preview[0].0, + OccurrenceEndpoint { + references: vec![reference.id], + occurrence: inside_id + } + ); assert_eq!(result.children(source_id).count(), 1); assert!( !targets(&result, source_id, Tool::Reuse) @@ -1687,4 +1944,102 @@ mod tests { state.zoom(None); assert!(state.scale(graph.size, 500.) < 1.); } + + #[test] + fn process_fit_uses_measured_height_and_reports_minimum_without_changing_manual_zoom() { + let mut state = State::default(); + let graph = Size::new(420., 960.); + state.zoom(None); + let mut previous = 0.; + for height in [180., 320., 600.] { + state.viewport(Size::new(1000., height)); + let scale = state.scale(graph, 200.); + assert!(scale > previous); + assert!(graph.height * scale <= height - 10. + 0.01); + assert!(!state.fit_limited(graph, 200.)); + previous = scale; + } + state.viewport(Size::new(500., 180.)); + let huge = Size::new(500., 10_000.); + assert_eq!(state.scale(huge, 200.), 0.05); + assert!(state.fit_limited(huge, 200.)); + state.zoom(Some(1.)); + for height in [180., 600.] { + state.viewport(Size::new(500., height)); + assert_eq!(state.scale(huge, 200.), 1.); + assert!(!state.fit_limited(huge, 200.)); + } + } + + #[test] + fn process_fit_disclosures_and_focus_preserve_rejected_raw_draft_and_accepted_source() { + let (p, owner, _, _, _, _) = fixture(); + let mut app = App::with_project(p); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + for input in [ + Event::Start(owner, Tool::Contextual, None), + Event::Name(" pending 雨 ".into()), + Event::Contextual(contextual::Event::Guard(" unavailable && 雨 ".into())), + ] { + let _ = app.update(event(input)); + } + let before = app.behavior.recovery_snapshot().unwrap(); + let accepted = app.document.snapshot(); + assert!(app.behavior.prepare_pending(app.project()).is_err()); + for (shelf, expected) in [ + (Shelf::Tools, Some(Shelf::Tools)), + (Shelf::Selection, Some(Shelf::Selection)), + (Shelf::Selection, None), + ] { + let focused = app.behavior.topology.focused; + let _ = app.update(event(Event::Shelf(shelf))); + assert_eq!(app.behavior.topology.shelf, expected); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context))); + assert_eq!(app.behavior.topology.focused, !focused); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), before); + assert!(app.behavior.prepare_pending(app.project()).is_err()); + assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted)); + } + } + + #[test] + fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() { + let (p, owner, _, _, _, _) = fixture(); + let mut state = State::default(); + state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.)); + state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.)); + let context = Context { + document: p.id, + owner, + revision: 0, + epoch: 0, + }; + for scale in [0.45, 1.] { + let mut graph = Graph::new(&p, owner, owner); + graph.scale = scale; + graph.scroll_offset = state.canvas_offset(); + graph.interaction = Some((context, CanvasTool::Select, true)); + let node = &graph.nodes[0]; + let id = node.id; + let point = node.rect.center(); + let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.)); + let screen = Point::new( + bounds.x + point.x * scale - 11., + bounds.y + point.y * scale - 19., + ); + let message = graph + .input( + &mut Input::default(), + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(screen), + ) + .and_then(|a| a.into_inner().0); + assert!( + matches!(message,Some(Message::TopologyCanvas(CanvasEvent::Select(c,actual))) if c==context && actual==id) + ); + } + } } diff --git a/crates/oa-desktop/src/behavior/topology/contextual.rs b/crates/oa-desktop/src/behavior/topology/contextual.rs new file mode 100644 index 00000000..ab498e8d --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/contextual.rs @@ -0,0 +1,787 @@ +//! Lazy qualified authoring over the core address resolver. A folded reference +//! never replaces its retained endpoint with the reference or a same-named leaf. +use super::*; +use iced::widget::column; +use oa_core::topology_context::TopologyIndex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub enum Purpose { + #[default] + Precedence, + Exchange, + CommonWork, +} +impl std::fmt::Display for Purpose { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Precedence => "Precedence", + Self::Exchange => "Exchange assembly", + Self::CommonWork => "Common work incidence", + }) + } +} +#[derive(Debug, Clone)] +pub enum Event { + Side(bool), + Pick(OccurrenceEndpoint), + Expand(OccurrenceEndpoint), + Up, + Root, + Purpose(Purpose), + Evidence(Choice), + Guard(String), + Search(String), + Page(usize), +} +#[derive(Default, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Draft { + edit: Option, + source: Option, + target: Option, + target_side: bool, + purpose: Purpose, + evidence: Option, + guard: String, + search: String, + page: usize, + #[serde(default)] + scope: Vec, +} +impl Draft { + pub(super) fn seed_source(&mut self, endpoint: OccurrenceEndpoint) { + if self.edit.is_none() { + self.source = Some(endpoint); + } + } + pub(super) fn new(p: &Project, owner: Id, selected: Option) -> Self { + if let Some(r) = selected.and_then(|id| p.relations.get(&id)) + && let RelationBody::ContextualLink { + owner: caller, + source, + target, + purpose, + } = &r.body + && *caller == owner + { + let (purpose, evidence, guard) = match purpose { + ContextualLinkPurpose::Precedence { guard } => { + (Purpose::Precedence, None, guard.clone()) + } + ContextualLinkPurpose::Exchange { exchange } => { + (Purpose::Exchange, Some(*exchange), String::new()) + } + ContextualLinkPurpose::CommonWork { work } => { + (Purpose::CommonWork, Some(*work), String::new()) + } + }; + return Self { + edit: Some(r.id), + source: Some(source.clone()), + target: Some(target.clone()), + purpose, + evidence, + guard, + ..Self::default() + }; + } + Self { + source: selected + .filter(|id| is_contribution(p, owner, *id)) + .map(|occurrence| OccurrenceEndpoint { + references: vec![], + occurrence, + }), + ..Self::default() + } + } + pub(super) fn update(&mut self, event: Event) { + match event { + Event::Side(target) => self.target_side = target, + Event::Pick(endpoint) => { + if self.target_side { + self.target = Some(endpoint); + } else { + self.source = Some(endpoint); + } + } + Event::Expand(endpoint) => { + self.scope = endpoint.references; + self.scope.push(endpoint.occurrence); + self.search.clear(); + self.page = 0; + } + Event::Up => { + self.scope.pop(); + self.search.clear(); + self.page = 0; + } + Event::Root => { + self.scope.clear(); + self.search.clear(); + self.page = 0; + } + Event::Purpose(purpose) => self.purpose = purpose, + Event::Evidence(choice) => self.evidence = Some(choice.id), + Event::Guard(raw) => self.guard = raw, + Event::Search(raw) => { + self.search = raw; + self.page = 0; + } + Event::Page(page) => self.page = page, + } + } + fn purpose(&self) -> Result { + Ok(match self.purpose { + Purpose::Precedence => ContextualLinkPurpose::Precedence { + guard: self.guard.clone(), + }, + Purpose::Exchange => ContextualLinkPurpose::Exchange { + exchange: self + .evidence + .ok_or("Choose the exact exchange used for assembly.")?, + }, + Purpose::CommonWork => ContextualLinkPurpose::CommonWork { + work: self.evidence.ok_or("Choose the shared work identity.")?, + }, + }) + } + pub(super) fn command(&self, p: &Project, owner: Id, name: &str) -> Result { + let source = self + .source + .as_ref() + .ok_or("Choose the qualified source contribution.")?; + let target = self + .target + .as_ref() + .ok_or("Choose the qualified target contribution.")?; + let purpose = self.purpose()?; + TopologyIndex::new(p) + .validate_link(owner, source, target, &purpose) + .map_err(|e| e.to_string())?; + let mut relation = if let Some(id) = self.edit { + p.relations.get(&id).filter(|r| matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller==owner)).ok_or("This caller-owned link no longer exists.")?.clone() + } else { + let id = Id::new_v4(); + Relation { + id, + alias: p.fresh_alias("contextual_link", "relation", id), + name: self.purpose.to_string(), + description: String::new(), + body: RelationBody::ContextualLink { + owner, + source: source.clone(), + target: target.clone(), + purpose: purpose.clone(), + }, + } + }; + if !name.trim().is_empty() { + relation.name = name.to_owned(); + } + relation.body = RelationBody::ContextualLink { + owner, + source: source.clone(), + target: target.clone(), + purpose, + }; + let command = if self.edit.is_some() { + Command::EditRelation(relation.id, relation) + } else { + Command::CreateRelation(relation) + }; + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + Ok(command) + } +} + +pub(crate) fn address_label(p: &Project, owner: Id, endpoint: &OccurrenceEndpoint) -> String { + std::iter::once(&owner) + .chain(endpoint.references.iter()) + .chain(std::iter::once(&endpoint.occurrence)) + .map(|id| { + p.elements + .get(id) + .map(|e| format!("{} (@{})", e.name, e.alias)) + .unwrap_or_else(|| format!("Unavailable {id}")) + }) + .collect::>() + .join(" → ") +} +struct AddressRow { + endpoint: OccurrenceEndpoint, + reference: bool, +} +fn rows(p: &Project, owner: Id, scope: &[Id], search: &str) -> Result, String> { + let index = TopologyIndex::new(p); + let sequence = index + .resolve_scope(owner, scope) + .map_err(|e| e.to_string())?; + let query = search.trim().to_lowercase(); + Ok(index + .contributions(sequence) + .into_iter() + .filter_map(|occurrence| { + let endpoint = OccurrenceEndpoint { + references: scope.to_vec(), + occurrence, + }; + if !address_label(p, owner, &endpoint) + .to_lowercase() + .contains(&query) + { + return None; + } + let reference = matches!( + p.elements.get(&occurrence).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence: Some(_) }, + .. + }) + ); + Some(AddressRow { + endpoint, + reference, + }) + }) + .collect()) +} +fn event(value: Event) -> Message { + super::event(super::Event::Contextual(value)) +} + +pub(super) fn preview( + index: &TopologyIndex<'_>, + p: &Project, + owner: Id, + reference: Id, + expanded: &BTreeSet, + limit: usize, +) -> Vec { + let children = |endpoint: &OccurrenceEndpoint| { + if index.resolve(owner, endpoint).is_err() { + return vec![]; + } + let sequence = p + .elements + .get(&endpoint.occurrence) + .and_then(|e| e.shared()) + .and_then(|body| match body { + SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence }, + .. + } => *sequence, + _ => None, + }); + let Some(sequence) = sequence else { + return vec![]; + }; + let available: BTreeSet<_> = index.contributions(sequence).into_iter().collect(); + let mut references = endpoint.references.clone(); + references.push(endpoint.occurrence); + p.elements + .get(&sequence) + .and_then(|e| e.steps()) + .into_iter() + .flatten() + .filter(|id| available.contains(id)) + .map(|occurrence| OccurrenceEndpoint { + references: references.clone(), + occurrence: *occurrence, + }) + .collect::>() + }; + let root = OccurrenceEndpoint { + references: vec![], + occurrence: reference, + }; + let mut pending = children(&root); + pending.reverse(); + let mut visible = vec![]; + while let Some(endpoint) = pending.pop() { + if visible.len() == limit { + break; + } + if index.resolve(owner, &endpoint).is_err() { + continue; + } + if expanded.contains(&endpoint) { + pending.extend(children(&endpoint).into_iter().rev()); + } + visible.push(endpoint); + } + visible +} +pub(super) fn view<'a>(p: &'a Project, owner: Id, d: &'a Draft) -> Element<'a, Message> { + let mut out = column![ + text("Choose each endpoint in its caller context. Enter a reference to browse all its contributions; repeated uses stay distinct. Browsing preserves both selected endpoints.").size(12), + row![button("Choose source").style(if !d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(false))), button("Choose target").style(if d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(true)))].spacing(6), + text(format!("Source: {}", d.source.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12), + text(format!("Target: {}", d.target.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12), + pick_list(vec![Purpose::Precedence,Purpose::Exchange,Purpose::CommonWork],Some(d.purpose),|p| event(Event::Purpose(p))).width(Fill).text_size(12).padding(8) + ].spacing(7); + match d.purpose { + Purpose::Precedence => { + out = out.push( + text_input("Precedence guard (optional)", &d.guard) + .on_input(|v| event(Event::Guard(v))) + .padding(8) + .size(12), + ) + } + purpose => { + let choices = if purpose == Purpose::Exchange { + targets(p, owner, Tool::Exchange) + } else { + targets(p, owner, Tool::Work) + }; + let selected = d.evidence.map(|id| Choice { + id, + label: p.named(id).to_owned(), + }); + out = out + .push( + pick_list(choices, selected, |c| event(Event::Evidence(c))) + .placeholder("Choose exact engineering identity…") + .width(Fill) + .text_size(12) + .padding(8), + ) + .push( + text("Assembly incidence does not imply execution order or fuse executions.") + .size(11), + ); + } + } + out = out.push( + text_input("Search all contributions in this scope…", &d.search) + .on_input(|v| event(Event::Search(v))) + .padding(7) + .size(12), + ); + let scope_label = std::iter::once(&owner) + .chain(d.scope.iter()) + .map(|id| { + p.elements + .get(id) + .map(|e| format!("{} (@{})", e.name, e.alias)) + .unwrap_or_else(|| format!("Unavailable {id}")) + }) + .collect::>() + .join(" → "); + out = out + .push(text(format!("Browsing: {scope_label}")).size(12)) + .push( + row![ + button("Root").on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Root))), + button("Up one reference") + .on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Up))) + ] + .spacing(6), + ); + let rows = match rows(p, owner, &d.scope, &d.search) { + Ok(rows) => rows, + Err(error) => return out.push(text(error).size(12)).into(), + }; + let range = super::view::choice_page(rows.len(), d.page); + let page = range.start / 24; + out = out.push( + row![ + button("Previous").on_press_maybe((page > 0).then(|| event(Event::Page(page - 1)))), + text(format!( + "{} matching contributions · page {}", + rows.len(), + page + 1 + )) + .size(11), + button("Next") + .on_press_maybe((range.end < rows.len()).then(|| event(Event::Page(page + 1)))) + ] + .spacing(6), + ); + for r in &rows[range] { + let mut line = row![ + button(text(address_label(p, owner, &r.endpoint)).size(12)) + .width(Fill) + .on_press(event(Event::Pick(r.endpoint.clone()))) + ] + .spacing(5); + if r.reference { + line = line + .push(button("Enter reference").on_press(event(Event::Expand(r.endpoint.clone())))); + } + out = out.push(line); + } + out.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::Document; + use oa_core::source_project::ProjectLimits; + + fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id, Id) { + let f = super::super::direct_tests::fixture(layer); + let mut p = f.p; + let sequence = p + .make_element( + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + "Reusable check", + None, + ) + .unwrap(); + let sequence_id = sequence.id; + p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap(); + let leaf = occurrence( + &p, + sequence_id, + "Verify pressure", + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + ); + let leaf_id = leaf.id; + p = execute_batch(&p, &[Command::CreateElement(leaf)]).unwrap(); + let a = occurrence( + &p, + f.owner, + "First check", + OccurrenceBody::SequenceReference { + sequence: Some(sequence_id), + }, + ); + let b = occurrence( + &p, + f.owner, + "Second check", + OccurrenceBody::SequenceReference { + sequence: Some(sequence_id), + }, + ); + let (a_id, b_id) = (a.id, b.id); + p = execute_batch(&p, &[Command::CreateElement(a), Command::CreateElement(b)]).unwrap(); + (p, f.owner, a_id, b_id, leaf_id, f.work[0]) + } + #[test] + fn qualified_link_gui_repeated_sources_remain_distinct_and_apply_has_exact_source_history() { + for layer in [Layer::Oa, Layer::Sa] { + let (p, owner, a, b, leaf, work) = fixture(layer); + let mut state = State::default(); + state + .update( + &p, + super::super::Event::Start(owner, Tool::Contextual, None), + ) + .unwrap(); + let source = OccurrenceEndpoint { + references: vec![a], + occurrence: leaf, + }; + let target = OccurrenceEndpoint { + references: vec![b], + occurrence: leaf, + }; + for event in [ + Event::Pick(source.clone()), + Event::Side(true), + Event::Pick(target.clone()), + Event::Purpose(Purpose::CommonWork), + Event::Evidence(Choice { + id: work, + label: String::new(), + }), + ] { + state + .update(&p, super::super::Event::Contextual(event)) + .unwrap(); + } + let raw = state.recovery_snapshot().unwrap().unwrap(); + let restored = State::from_recovery(raw.clone()).unwrap(); + assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw)); + let command = restored.prepare_pending(&p).unwrap(); + let Command::CreateRelation(record) = &command else { + panic!("one caller-owned record") + }; + assert_eq!( + record.body, + RelationBody::ContextualLink { + owner, + source: source.clone(), + target: target.clone(), + purpose: ContextualLinkPurpose::CommonWork { work } + } + ); + let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); + let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap(); + let before = doc.bundle().clone(); + doc.execute(command.clone()).unwrap(); + assert_eq!(doc.project(), &expected); + assert_eq!( + doc.project().elements, + p.elements, + "no copied work or source occurrences" + ); + let after = doc.bundle().clone(); + let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), &expected); + assert!(doc.undo()); + assert_eq!(doc.bundle(), &before); + assert!(doc.redo()); + assert_eq!(doc.bundle(), &after); + assert_ne!( + address_label(&p, owner, &source), + address_label(&p, owner, &target) + ); + assert!( + Graph::new(&expected, owner, record.id) + .edges + .iter() + .any(|edge| edge.id == record.id && edge.incidence) + ); + } + } + #[test] + fn qualified_link_gui_rejected_endpoint_keeps_exact_raw_intent_after_recovery() { + let (p, owner, a, _, leaf, _) = fixture(Layer::Oa); + let mut state = State::default(); + state + .update( + &p, + super::super::Event::Start(owner, Tool::Contextual, None), + ) + .unwrap(); + for event in [ + Event::Pick(OccurrenceEndpoint { + references: vec![a], + occurrence: leaf, + }), + Event::Side(true), + Event::Pick(OccurrenceEndpoint { + references: vec![Id::new_v4()], + occurrence: leaf, + }), + Event::Guard(" pressure > && ".into()), + ] { + state + .update(&p, super::super::Event::Contextual(event)) + .unwrap(); + } + let before = state.recovery_snapshot().unwrap(); + assert!(state.prepare_pending(&p).is_err()); + assert_eq!(state.recovery_snapshot().unwrap(), before); + let restored = State::from_recovery(before.clone().unwrap()).unwrap(); + assert!(restored.prepare_pending(&p).is_err()); + assert_eq!(restored.recovery_snapshot().unwrap(), before); + } + #[test] + fn qualified_link_gui_deleted_transient_selection_does_not_seed_or_hide_retained_authored_endpoint() + { + let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa); + let endpoint = OccurrenceEndpoint { + references: vec![reference], + occurrence: leaf, + }; + let mut state = State::default(); + state.select_context(oa_core::topology_context::ContextAddress { + owner, + endpoint: endpoint.clone(), + }); + state + .update( + &p, + super::super::Event::Start(owner, Tool::Contextual, None), + ) + .unwrap(); + let before = state.recovery_snapshot().unwrap(); + let deleted = execute_batch(&p, &[Command::Delete(leaf)]).unwrap(); + assert!(state.qualified_selection(&deleted, owner).is_none()); + assert_eq!( + state.recovery_snapshot().unwrap(), + before, + "Authored endpoint is retained for explicit repair" + ); + assert!(state.prepare_pending(&deleted).is_err()); + state.remember(owner, reference); + assert_eq!(state.back(&deleted), Some((owner, reference))); + assert!(state.selected_context.is_none()); + state.draft = None; + state + .update( + &deleted, + super::super::Event::Start(owner, Tool::Contextual, None), + ) + .unwrap(); + assert!( + state + .draft + .as_ref() + .unwrap() + .contextual + .as_ref() + .unwrap() + .source + .is_none() + ); + } + #[test] + fn qualified_link_gui_scope_paging_and_search_reach_tail_beyond_512_without_losing_endpoints() { + let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa); + let sequence = p.elements[&leaf].parent.unwrap(); + let mut commands = Vec::new(); + let mut tails = Vec::new(); + for (scope_index, scope) in [owner, sequence].into_iter().enumerate() { + for n in 0..520 { + let mut record = occurrence( + &p, + scope, + &format!("Contribution {n:04}"), + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + record.alias = format!("scope_{scope_index}_contribution_{n:04}"); + if n == 519 { + tails.push(record.id); + } + commands.push(Command::CreateElement(record)); + } + } + let p = execute_batch(&p, &commands).unwrap(); + let all = rows(&p, owner, &[], "").unwrap(); + assert!(all.len() > 512); + let last_page = super::super::view::choice_page(all.len(), usize::MAX); + assert!( + all[last_page] + .iter() + .any(|row| row.endpoint.occurrence == tails[0]) + ); + let found = rows(&p, owner, &[], "Contribution 0519").unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].endpoint.occurrence, tails[0]); + let mut draft = Draft::default(); + draft.update(Event::Pick(OccurrenceEndpoint { + references: vec![reference], + occurrence: leaf, + })); + draft.update(Event::Side(true)); + draft.update(Event::Pick(found[0].endpoint.clone())); + let chosen = (draft.source.clone(), draft.target.clone()); + draft.update(Event::Expand(OccurrenceEndpoint { + references: vec![], + occurrence: reference, + })); + draft.update(Event::Search("Contribution 0519".into())); + let nested = rows(&p, owner, &draft.scope, &draft.search).unwrap(); + assert_eq!(nested.len(), 1); + assert_eq!( + nested[0].endpoint, + OccurrenceEndpoint { + references: vec![reference], + occurrence: tails[1] + } + ); + let saved = serde_json::to_value(&draft).unwrap(); + let mut restored: Draft = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), saved); + restored.update(Event::Up); + assert!(restored.scope.is_empty()); + assert_eq!((restored.source.clone(), restored.target.clone()), chosen); + assert!(restored.command(&p, owner, "Tail constraint").is_ok()); + let mut historical = saved; + historical.as_object_mut().unwrap().remove("scope"); + let restored: Draft = serde_json::from_value(historical).unwrap(); + assert!(restored.scope.is_empty()); + assert_eq!((restored.source, restored.target), chosen); + } + #[test] + fn qualified_link_gui_nested_preview_folds_only_the_exact_reference_instance() { + let (mut p, owner, a, b, leaf, _) = fixture(Layer::Oa); + let source = p.elements[&leaf].parent.unwrap(); + let deep = p + .make_element(Kind::Process, Layer::Oa, "Nested definition", None) + .unwrap(); + let deep_id = deep.id; + p = execute_batch(&p, &[Command::CreateElement(deep)]).unwrap(); + let deep_leaf = occurrence( + &p, + deep_id, + "Nested control", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let deep_leaf_id = deep_leaf.id; + let nested = occurrence( + &p, + source, + "Nested use", + OccurrenceBody::SequenceReference { + sequence: Some(deep_id), + }, + ); + let nested_id = nested.id; + p = execute_batch( + &p, + &[ + Command::CreateElement(deep_leaf), + Command::CreateElement(nested), + ], + ) + .unwrap(); + let path = OccurrenceEndpoint { + references: vec![a], + occurrence: nested_id, + }; + let mut state = State::default(); + assert!(state.expand_context(&p, owner, path.clone())); + state.expanded.insert(b); + let graph = Graph::expanded_qualified( + &p, + owner, + owner, + &state.expanded, + &state.contextual_expanded, + ); + assert!( + graph + .nodes + .iter() + .find(|n| n.id == a) + .unwrap() + .preview + .iter() + .any(|(v, _)| v.references == vec![a, nested_id] && v.occurrence == deep_leaf_id) + ); + assert!( + !graph + .nodes + .iter() + .find(|n| n.id == b) + .unwrap() + .preview + .iter() + .any(|(v, _)| v.occurrence == deep_leaf_id) + ); + assert!(state.expand_context(&p, owner, path)); + let graph = Graph::expanded_qualified( + &p, + owner, + owner, + &state.expanded, + &state.contextual_expanded, + ); + assert!( + !graph + .nodes + .iter() + .any(|n| n.preview.iter().any(|(v, _)| v.occurrence == deep_leaf_id)) + ); + } +} diff --git a/crates/oa-desktop/src/behavior/topology/contextual_routing.rs b/crates/oa-desktop/src/behavior/topology/contextual_routing.rs new file mode 100644 index 00000000..f016af3c --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/contextual_routing.rs @@ -0,0 +1,417 @@ +//! Contextual routes use outer-card gutters, never inner preview x coordinates. +use super::*; + +pub(super) struct Anchor { + point: Point, + lane: f32, + right: bool, +} +pub(super) fn anchor(node: &GraphNode, endpoint: &OccurrenceEndpoint, incoming: bool) -> Anchor { + let preview = node + .preview + .iter() + .position(|(address, _)| address == endpoint); + // The outward-facing side of each preview column has no sibling mini-card + // between it and the host boundary. Hidden addresses use the host itself. + let right = preview.map_or(!incoming, |index| index % 2 == 1); + let rect = node.endpoint_rect(endpoint); + Anchor { + point: Point::new( + if right { rect.x + rect.width } else { rect.x }, + rect.center_y(), + ), + lane: if right { + node.rect.x + node.rect.width + 18. + } else { + node.rect.x - 16. + }, + right, + } +} +pub(super) fn route(a: Anchor, mut b: Anchor, below: f32, first: bool) -> (Vec, Rectangle) { + let same_lane = (a.lane - b.lane).abs() < 0.1; + let short = first && same_lane && a.right && b.right; + // A small separate return lane keeps a left-side detour from retracing itself. + // Both lanes remain outside the first card's x=24 boundary. + if same_lane && !short { + b.lane += 4.; + } + let mut points = vec![a.point, Point::new(a.lane, a.point.y)]; + if !short { + points.extend([Point::new(a.lane, below), Point::new(b.lane, below)]); + } + points.extend([Point::new(b.lane, b.point.y), b.point]); + points.dedup(); + let label = Rectangle::new( + if short { + Point::new(a.lane - 6., (a.point.y + b.point.y) / 2. - 18.) + } else { + Point::new(((a.lane + b.lane) / 2. - 66.).max(4.), below - 38.) + }, + Size::new(132., 36.), + ); + (points, label) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn baseline() -> Project { + persistence::decode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../harness/fixtures/source7-native/base/expected-baseline.json" + ))) + .unwrap() + } + fn id(p: &Project, alias: &str) -> Id { + p.elements + .values() + .find(|element| element.alias == alias) + .unwrap() + .id + } + fn add( + p: &Project, + owner: Id, + source: OccurrenceEndpoint, + target: OccurrenceEndpoint, + ) -> (Project, Id) { + let id = Id::new_v4(); + let relation = Relation { + id, + alias: p.fresh_alias("native_gate", "relation", id), + name: "First assessment gate".into(), + description: String::new(), + body: RelationBody::ContextualLink { + owner, + source, + target, + purpose: ContextualLinkPurpose::Precedence { + guard: "Check accepted".into(), + }, + }, + }; + ( + execute_batch(p, &[Command::CreateRelation(relation)]).unwrap(), + id, + ) + } + fn crosses(a: Point, b: Point, rect: Rectangle) -> bool { + if a.x == b.x { + a.x > rect.x + && a.x < rect.x + rect.width + && a.y.min(b.y) < rect.y + rect.height + && a.y.max(b.y) > rect.y + } else { + assert_eq!(a.y, b.y, "Every route segment is orthogonal"); + a.y > rect.y + && a.y < rect.y + rect.height + && a.x.min(b.x) < rect.x + rect.width + && a.x.max(b.x) > rect.x + } + } + fn assert_clear( + graph: &Graph<'_>, + edge: &GraphEdge, + source: &OccurrenceEndpoint, + target: &OccurrenceEndpoint, + ) { + for node in &graph.nodes { + if node.id != edge.source && node.id != edge.target { + assert!( + !edge + .points + .windows(2) + .any(|line| crosses(line[0], line[1], node.rect)), + "Route crosses unrelated {}", + node.title + ); + } + for (endpoint, _) in &node.preview { + if endpoint != source && endpoint != target { + let mini = node.endpoint_rect(endpoint); + assert!( + !edge + .points + .windows(2) + .any(|line| crosses(line[0], line[1], mini)), + "Route crosses another qualified contribution {endpoint:?}" + ); + } + } + } + } + #[test] + fn contextual_route_native_x_to_first_b_keeps_visible_arrow_above_background_and_clear_of_other_references() + { + for prefix in ["oa", "sa"] { + let p = baseline(); + let owner = id(&p, &format!("{prefix}_caller")); + let reference = id(&p, &format!("{prefix}_r1")); + let source = OccurrenceEndpoint { + references: vec![], + occurrence: id(&p, &format!("{prefix}_x")), + }; + let target = OccurrenceEndpoint { + references: vec![reference], + occurrence: id(&p, &format!("{prefix}_b")), + }; + let (q, link) = add(&p, owner, source.clone(), target.clone()); + let graph = Graph::new(&q, owner, link); + let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap(); + let host = graph + .nodes + .iter() + .find(|node| node.id == reference) + .unwrap(); + let mini = host.endpoint_rect(&target); + assert_eq!( + mini, + Rectangle::new(Point::new(161., 282.), Size::new(113., 32.)) + ); + assert_eq!( + edge.points, + vec![ + Point::new(294., 76.), + Point::new(312., 76.), + Point::new(312., 298.), + Point::new(274., 298.) + ] + ); + assert_clear(&graph, edge, &source, &target); + assert_eq!( + graph.size.height, + Graph::new(&p, owner, owner).size.height, + "This adjacent target needs no below-canvas detour" + ); + assert_eq!(edge.layer, EdgeLayer::Contextual); + let position = |pass| { + PAINT_PASSES + .iter() + .position(|actual| *actual == pass) + .unwrap() + }; + assert!(position(PaintPass::NodeBackgrounds) < position(PaintPass::ContextualEdges)); + assert!(position(PaintPass::ContextualEdges) < position(PaintPass::NodeContents)); + assert!(position(PaintPass::DirectEdges) < position(PaintPass::NodeBackgrounds)); + // The arrow points left into B's right border. Its head stays in the + // host's clear right padding and is not covered by any mini-card. + let end = *edge.points.last().unwrap(); + assert_eq!(end, Point::new(mini.x + mini.width, mini.center_y())); + let stub = Point::new(end.x + 10., end.y); + assert!(host.rect.contains(stub)); + let context = Context { + document: q.id, + owner, + revision: 0, + epoch: 0, + }; + for scale in [0.45, 1.] { + let click = |point: Point| { + let mut interactive = Graph::new(&q, owner, link); + interactive.interaction = Some((context, CanvasTool::Select, true)); + interactive.scale = scale; + interactive + .input( + &mut Input::default(), + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Rectangle::new( + Point::ORIGIN, + Size::new( + interactive.size.width * scale, + interactive.size.height * scale, + ), + ), + mouse::Cursor::Available(Point::new(point.x * scale, point.y * scale)), + ) + .and_then(|action| action.into_inner().0) + }; + assert!( + matches!(click(stub), Some(Message::TopologyCanvas(CanvasEvent::Select(_, id))) if id == link) + ); + assert!( + matches!(click(mini.center()), Some(Message::TopologyCanvas(CanvasEvent::SelectQualified(_, endpoint))) if endpoint == target) + ); + } + assert_eq!(q.elements, p.elements); + let (parallel_project, second) = add(&q, owner, source.clone(), target.clone()); + let parallel = Graph::new(¶llel_project, owner, second); + let first_edge = parallel.edges.iter().find(|edge| edge.id == link).unwrap(); + let second_edge = parallel + .edges + .iter() + .find(|edge| edge.id == second) + .unwrap(); + assert_ne!(first_edge.points, second_edge.points); + assert_ne!(first_edge.label, second_edge.label); + assert_clear(¶llel, first_edge, &source, &target); + assert_clear(¶llel, second_edge, &source, &target); + } + } + #[test] + fn contextual_route_shared_anchor_preserves_selected_direct_reconnect_at_fit_and_full_scale() { + for prefix in ["oa", "sa"] { + let p = baseline(); + let owner = id(&p, &format!("{prefix}_caller")); + let x = id(&p, &format!("{prefix}_x")); + let r1 = id(&p, &format!("{prefix}_r1")); + let r2 = id(&p, &format!("{prefix}_r2")); + let (p, contextual) = add( + &p, + owner, + OccurrenceEndpoint { + references: vec![], + occurrence: x, + }, + OccurrenceEndpoint { + references: vec![r1], + occurrence: id(&p, &format!("{prefix}_b")), + }, + ); + let command = link(&p, x, r1, "Direct caller order"); + let Command::CreateRelation(relation) = &command else { + unreachable!() + }; + let direct = relation.id; + let p = execute_batch(&p, &[command]).unwrap(); + let context = Context { + document: p.id, + owner, + revision: 0, + epoch: 0, + }; + for scale in [0.45, 1.] { + let mut graph = Graph::new(&p, owner, direct); + graph.interaction = Some((context, CanvasTool::Select, true)); + graph.scale = scale; + let source = graph + .edges + .iter() + .find(|edge| edge.id == direct) + .unwrap() + .points[0]; + assert_eq!( + source, + graph + .edges + .iter() + .find(|edge| edge.id == contextual) + .unwrap() + .points[0] + ); + let destination = graph + .nodes + .iter() + .find(|node| node.id == r2) + .unwrap() + .rect + .center(); + let screen = |point: Point| Point::new(point.x * scale, point.y * scale); + let bounds = Rectangle::new( + Point::ORIGIN, + Size::new(graph.size.width * scale, graph.size.height * scale), + ); + let mut input = Input::default(); + let press = graph + .input( + &mut input, + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(screen(source)), + ) + .expect("The selected endpoint captures its press") + .into_inner() + .0; + assert!( + press.is_none(), + "A shared contextual anchor must not steal the selected direct handle" + ); + let _ = graph.input( + &mut input, + &canvas::Event::Mouse(mouse::Event::CursorMoved { + position: screen(destination), + }), + bounds, + mouse::Cursor::Available(screen(destination)), + ); + let release = graph + .input( + &mut input, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(screen(destination)), + ) + .and_then(|action| action.into_inner().0); + assert!( + matches!(release, Some(Message::TopologyCanvas(CanvasEvent::Reconnect { + context: actual_context, link, end: direct::End::Source, target, + })) if actual_context == context && link == direct && target == r2) + ); + } + } + } + #[test] + fn contextual_route_nested_visible_hidden_and_parallel_paths_keep_exact_addresses_and_legacy_routes() + { + let p = baseline(); + let owner = id(&p, "oa_caller"); + let r3 = id(&p, "oa_r3"); + let inner = id(&p, "oa_inner"); + let source = OccurrenceEndpoint { + references: vec![], + occurrence: id(&p, "oa_x"), + }; + let target = OccurrenceEndpoint { + references: vec![r3, inner], + occurrence: id(&p, "oa_b"), + }; + let (q, link) = add(&p, owner, source.clone(), target.clone()); + let expanded = BTreeSet::from([r3]); + let paths = BTreeSet::from([OccurrenceEndpoint { + references: vec![r3], + occurrence: inner, + }]); + let graph = Graph::expanded_qualified(&q, owner, link, &expanded, &paths); + let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap(); + assert_clear(&graph, edge, &source, &target); + let host = graph.nodes.iter().find(|node| node.id == r3).unwrap(); + let mini = host.endpoint_rect(&target); + assert_ne!(mini, host.rect); + assert_eq!( + edge.points.last(), + Some(&Point::new(mini.x, mini.center_y())) + ); + let folded = Graph::new(&q, owner, link); + let folded_edge = folded.edges.iter().find(|edge| edge.id == link).unwrap(); + let folded_host = folded.nodes.iter().find(|node| node.id == r3).unwrap(); + assert_eq!( + folded_edge.points.last(), + Some(&Point::new(folded_host.rect.x, folded_host.rect.center_y())) + ); + assert_clear(&folded, folded_edge, &source, &target); + let (q2, other) = add(&q, owner, source.clone(), target.clone()); + let parallel = Graph::expanded_qualified(&q2, owner, link, &expanded, &paths); + let a = parallel.edges.iter().find(|edge| edge.id == link).unwrap(); + let b = parallel.edges.iter().find(|edge| edge.id == other).unwrap(); + assert_ne!(a.points, b.points); + assert_ne!(a.label, b.label); + assert_clear(¶llel, a, &source, &target); + assert_clear(¶llel, b, &source, &target); + let procedure = id(&p, "oa_procedure"); + let old = Graph::new(&p, procedure, procedure); + let new = Graph::new(&q2, procedure, procedure); + assert_eq!(old.size, new.size); + assert_eq!( + old.edges + .iter() + .map(|e| (&e.points, e.label, e.layer)) + .collect::>(), + new.edges + .iter() + .map(|e| (&e.points, e.label, e.layer)) + .collect::>() + ); + assert!(new.edges.iter().all(|edge| edge.layer == EdgeLayer::Direct)); + } +} diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index 8cb730f6..f88e6a43 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -30,6 +30,9 @@ pub enum CanvasEvent { Scrolled(Context, scenario::ScrollArea, iced::Vector), Choose(Context, CanvasTool), Select(Context, Id), + SelectQualified(Context, OccurrenceEndpoint), + ExpandQualified(Context, OccurrenceEndpoint), + OpenQualified(Context, OccurrenceEndpoint), AppendExchange(Context), Begin { context: Context, @@ -57,6 +60,7 @@ pub enum CanvasEvent { Open(Context, Id), Back(Context), Zoom(Context, Option), + Focus(Context), Viewport(Context, Size), } impl CanvasEvent { @@ -65,11 +69,15 @@ impl CanvasEvent { Self::Scrolled(c, ..) | Self::Choose(c, ..) | Self::Select(c, ..) + | Self::SelectQualified(c, ..) + | Self::ExpandQualified(c, ..) + | Self::OpenQualified(c, ..) | Self::Expand(c, ..) | Self::Open(c, ..) | Self::Back(c) | Self::AppendExchange(c) | Self::Zoom(c, ..) + | Self::Focus(c) | Self::Viewport(c, ..) => *c, Self::Begin { context, .. } | Self::Connect { context, .. } @@ -79,6 +87,53 @@ impl CanvasEvent { } } impl State { + pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) { + self.selected_context = Some(address); + } + pub(crate) fn clear_context(&mut self) { + self.selected_context = None; + } + pub(super) fn qualified_selection( + &self, + p: &Project, + owner: Id, + ) -> Option<&oa_core::topology_context::ContextAddress> { + self.selected_context.as_ref().filter(|address| { + address.owner == owner + && oa_core::topology_context::TopologyIndex::new(p) + .resolve(owner, &address.endpoint) + .is_ok() + }) + } + pub(crate) fn expand_context( + &mut self, + p: &Project, + owner: Id, + endpoint: OccurrenceEndpoint, + ) -> bool { + if oa_core::topology_context::TopologyIndex::new(p) + .resolve(owner, &endpoint) + .is_err() + || !matches!( + p.elements + .get(&endpoint.occurrence) + .and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence: Some(_) }, + .. + }) + ) + { + return false; + } + if let Some(root) = endpoint.references.first() { + self.expanded.insert(*root); + } + if !self.contextual_expanded.remove(&endpoint) { + self.contextual_expanded.insert(endpoint); + } + true + } pub(crate) fn enter(&mut self, document: Id, owner: Id) { if self.identity != Some((document, owner)) { self.identity = Some((document, owner)); @@ -90,6 +145,7 @@ impl State { self.scroll_offsets = [iced::Vector::ZERO; 2]; } } + #[cfg(test)] pub(crate) fn scroll_offset(&self) -> iced::Vector { self.scroll_offsets[0] + self.scroll_offsets[1] } @@ -133,11 +189,18 @@ impl State { true } pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) { - self.return_to.push((owner, occurrence)); + self.return_to + .push((owner, occurrence, self.selected_context.clone())); } pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> { - while let Some((owner, occurrence)) = self.return_to.pop() { + while let Some((owner, occurrence, address)) = self.return_to.pop() { if is_contribution(p, owner, occurrence) { + self.selected_context = address.filter(|address| { + address.owner == owner + && oa_core::topology_context::TopologyIndex::new(p) + .resolve(owner, &address.endpoint) + .is_ok() + }); return Some((owner, occurrence)); } } @@ -145,6 +208,11 @@ impl State { } pub(crate) fn created_selection(&self, command: &Command) -> Option { let owner = self.pending_owner()?; + if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command + && matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner) + { + return Some(r.id); + } let Command::Batch(commands) = command else { return None; }; @@ -355,6 +423,20 @@ impl Graph<'_> { .find(|n| n.rect.contains(point)) .map(|n| n.id) } + fn qualified_at(&self, point: Point) -> Option { + self.nodes.iter().find_map(|node| { + node.preview.iter().enumerate().find_map(|(i, (id, _))| { + let rect = Rectangle::new( + Point::new( + node.rect.x + 12. + (i % 2) as f32 * 125., + node.rect.y + 118. + (i / 2) as f32 * 44., + ), + Size::new(113., 32.), + ); + rect.contains(point).then(|| id.clone()) + }) + }) + } fn edge_at(&self, point: Point) -> Option { self.edges .iter() @@ -366,6 +448,19 @@ impl Graph<'_> { }) .map(|e| e.id) } + fn contextual_edge_at(&self, point: Point) -> Option { + self.edges + .iter() + .filter(|edge| edge.layer == EdgeLayer::Contextual) + .find(|edge| { + edge.label.contains(point) + || edge.points.windows(2).any(|segment| { + crate::diagram::distance_to_segment(point, segment[0], segment[1]) + < 8. / self.scale + }) + }) + .map(|edge| edge.id) + } fn handle(&self, id: Id) -> Option { let n = self.nodes.iter().find(|n| n.id == id)?; Some(Rectangle::new( @@ -374,6 +469,9 @@ impl Graph<'_> { )) } fn endpoint_at(&self, point: Point) -> Option<(Id, End)> { + if self.project.relations.get(&self.selected)?.kind() != RelationKind::SequenceLink { + return None; + } let e = self.edges.iter().find(|e| e.id == self.selected)?; [(e.points[0], End::Source), (*e.points.last()?, End::Target)] .into_iter() @@ -497,13 +595,17 @@ impl Graph<'_> { }); } CanvasTool::Select => { - if let Some((link, end)) = self.endpoint_at(point) { + if let Some(endpoint) = self.qualified_at(point) { + return publish(CanvasEvent::SelectQualified(context, endpoint)); + } else if let Some((link, end)) = self.endpoint_at(point) { state.press = Some(Press::Reconnect { link, end, start, moved: false, }); + } else if let Some(link) = self.contextual_edge_at(point) { + return publish(CanvasEvent::Select(context, link)); } else if self .handle(self.selected) .is_some_and(|r| r.contains(point)) @@ -728,13 +830,17 @@ impl State { owner: Id, id: Id, ) -> Option { - let graph = Graph::expanded(p, owner, id, &self.expanded); + let graph = + Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded); let scale = self.scale(graph.size, 500.); let rect = graph .nodes .iter() .find(|n| n.id == id) - .map(|n| n.rect) + .map(|n| { + self.qualified_selection(p, owner) + .map_or(n.rect, |a| n.endpoint_rect(&a.endpoint)) + }) .or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?; Some(scrollable::AbsoluteOffset { x: (rect.x * scale - 12.).max(0.), diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs index 1c687522..dbb3c261 100644 --- a/crates/oa-desktop/src/behavior/topology/direct_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -896,6 +896,7 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha f.owner, "Ordered contribution", OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender: None, receiver: None, diff --git a/crates/oa-desktop/src/behavior/topology/layout_tests.rs b/crates/oa-desktop/src/behavior/topology/layout_tests.rs index 04cc0d0c..276414a2 100644 --- a/crates/oa-desktop/src/behavior/topology/layout_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/layout_tests.rs @@ -136,3 +136,246 @@ fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible assert_eq!(closed.scrolls[0].1.height, size.height); } } + +fn explicit_workspace( + pending: bool, + details: bool, + header_height: f32, +) -> iced::Element<'static, Message, iced::Theme, ()> { + let canvas = container( + scrollable(iced::widget::Space::new().width(1800.).height(2400.)) + .id("process-canvas") + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .width(Fill) + .height(Fill), + ) + .padding(8) + .width(Fill) + .height(Fill); + let graph = explicit_pane( + iced::widget::Space::new().height(header_height).into(), + iced::widget::Space::new().height(1800.).into(), + canvas.into(), + details, + pending, + ); + let form = if pending { + editor_panel( + container(iced::widget::Space::new().height(32)) + .id("test-process-actions") + .into(), + iced::widget::Space::new().height(2000).into(), + ) + } else { + iced::widget::Space::new().height(0).into() + }; + panes(graph, form, pending) +} + +#[test] +fn process_fit_layout_measures_remaining_canvas_with_details_forms_and_window_resize() { + // Minimum/default panes both with side panels and focused; tall content + // deliberately cannot influence the enclosing scrollable allocations. + for size in [ + Size::new(500., 570.), + Size::new(1050., 570.), + Size::new(840., 770.), + Size::new(1394., 802.), + ] { + for pending in [false, true] { + for details in [false, true] { + for header_height in [68., 96.] { + let mut view = explicit_workspace(pending, details, header_height); + let mut tree = widget::Tree::new(view.as_widget()); + let (node, actual) = geometry(&mut view, &mut tree, size); + assert_eq!(node.size(), size); + let canvas = actual + .scrolls + .iter() + .find(|s| s.0 == Some(widget::Id::new("process-canvas"))) + .unwrap() + .1; + let pane_bottom = if pending { + size.height / 2. + } else { + size.height + }; + assert!(canvas.x >= 0. && canvas.y >= 0.); + assert!( + canvas.x + canvas.width <= size.width + && canvas.y + canvas.height <= pane_bottom, + "Canvas must be visible, not merely allocated outside a parent clip: {size:?} {canvas:?}" + ); + assert!(canvas.height >= if pending { 75. } else { 300. }); + let mut state = State::default(); + state.viewport(canvas.size()); + state.zoom(None); + let graph = Size::new(900., 1400.); + let scale = state.scale(graph, 1.); + assert!( + graph.width * scale <= canvas.width + && graph.height * scale <= canvas.height + ); + assert!(!state.fit_limited(graph, 1.)); + if pending { + let form = actual + .scrolls + .iter() + .find(|s| s.0 == Some(widget::Id::new("process-form"))) + .unwrap() + .1; + let actions = actual.actions.unwrap(); + assert!(form.height >= 200. && form.y + form.height <= size.height); + assert!(actions.y >= pane_bottom && actions.y + actions.height <= form.y); + } + } + } + } + } +} + +#[test] +fn process_fit_layout_disclosures_and_pending_editor_keep_real_manual_canvas_scroll() { + let mut view = explicit_workspace(false, false, 68.); + let mut tree = widget::Tree::new(view.as_widget()); + let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.)); + let mut scroll = widget::operation::scrollable::scroll_to::<()>( + widget::Id::new("process-canvas"), + widget::operation::scrollable::AbsoluteOffset { + x: Some(90.), + y: Some(180.), + }, + ); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut scroll); + let (_, before) = geometry(&mut view, &mut tree, Size::new(500., 570.)); + let initial: Vec<_> = before + .scrolls + .iter() + .filter(|s| s.0 == Some(widget::Id::new("process-canvas"))) + .collect(); + assert_eq!(initial.len(), 1, "The exact canvas ID must be unique"); + assert_eq!( + initial[0].2, + Vector::new(90., 180.), + "Establish the actual canvas scroll before any tree diff" + ); + for (pending, details, height, size) in [ + (false, true, 96., Size::new(500., 570.)), + (true, true, 68., Size::new(1050., 570.)), + (true, false, 68., Size::new(1394., 802.)), + (false, false, 96., Size::new(500., 570.)), + ] { + view = explicit_workspace(pending, details, height); + tree.diff(view.as_widget()); + let (node, actual) = geometry(&mut view, &mut tree, size); + let canvas = actual + .scrolls + .iter() + .find(|s| s.0 == Some(widget::Id::new("process-canvas"))) + .unwrap(); + assert_eq!( + canvas.2, + Vector::new(90., 180.), + "Shelf/header/form/focus resize must retain the same scroll state" + ); + if pending { + let actions = actual.actions; + let mut form_scroll = widget::operation::scrollable::scroll_to::<()>( + widget::Id::new("process-form"), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(600.), + }, + ); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut form_scroll); + let (_, scrolled) = geometry(&mut view, &mut tree, size); + assert_eq!(scrolled.actions, actions); + assert!( + scrolled + .scrolls + .iter() + .any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.) + ); + assert!( + scrolled + .scrolls + .iter() + .any(|s| s.0 == Some(widget::Id::new("process-canvas")) + && s.2 == Vector::new(90., 180.)) + ); + } + } +} + +#[test] +fn process_fit_whole_app_focus_and_disclosures_retain_canvas_tree_with_pending_raw_form() { + use iced::advanced::widget::tree; + use std::rc::Rc; + fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option> { + if tree.tag == tag { + return Some(vec![]); + } + tree.children.iter().enumerate().find_map(|(i, child)| { + let mut path = path_to(child, tag)?; + path.insert(0, i); + Some(path) + }) + } + fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree { + for &i in path { + tree = &mut tree.children[i] + } + tree + } + for layer in [Layer::Oa, Layer::Sa] { + let f = super::super::direct_tests::fixture(layer); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.nodes[1])); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let graph: Element<'_, Message> = + canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into(); + let tag = widget::Tree::new(graph.as_widget()).tag; + drop(graph); + let mut tree = widget::Tree::new(crate::views::view(&app).as_widget()); + let mut path = path_to(&tree, tag).unwrap(); + path.pop(); + let marker = Rc::new(()); + at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker)); + let _ = app.update(event(Event::Start(f.owner, Tool::Contextual, None))); + let _ = app.update(event(Event::Name(" preserved raw 雨 ".into()))); + let raw = app.behavior.recovery_snapshot().unwrap(); + let accepted = app.document.snapshot(); + for (shelf, expected) in [ + (Shelf::Tools, Some(Shelf::Tools)), + (Shelf::Selection, Some(Shelf::Selection)), + (Shelf::Selection, None), + ] { + let focused = app.behavior.topology.focused; + let _ = app.update(event(Event::Shelf(shelf))); + assert_eq!(app.behavior.topology.shelf, expected); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context))); + assert_eq!(app.behavior.topology.focused, !focused); + tree.diff(crate::views::view(&app).as_widget()); + let mut current = path_to(&tree, tag).unwrap(); + current.pop(); + assert_eq!( + current, path, + "Main must not shift when explorer/inspector are hidden" + ); + assert_eq!(Rc::strong_count(&marker), 2); + assert!(Rc::ptr_eq( + at(&mut tree, &path).state.downcast_ref::>(), + &marker + )); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted)); + } + } +} diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 96483d04..7e269b48 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -72,9 +72,7 @@ pub(super) fn view<'a>( uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty); let selected = is_contribution(p, owner, app.selected).then_some(app.selected); let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool)); - // Keep the canvas at the same sibling position when its palette becomes a - // pending-form notice, so Iced retains the scrollable's local offsets. - let mut controls = Column::new().spacing(14); + let mut controls = Column::new().spacing(6); if !pending && explicit { controls = controls .push( @@ -162,39 +160,179 @@ pub(super) fn view<'a>( CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column", CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels", CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}})); - out = out.push(controls); - if explicit { - let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded); + // A fixed sibling keeps Iced's canvas scroll state through selection/form changes. + let mut navigation = column![].spacing(6); + if let Some(Relation { + body: + RelationBody::ContextualLink { + owner: caller, + source, + target, + purpose, + }, + .. + }) = p.relations.get(&app.selected) + && *caller == owner + { + navigation=navigation.push(text(format!("From: {}",super::contextual::address_label(p,owner,source))).size(12)) + .push(text(format!("To: {}",super::contextual::address_label(p,owner,target))).size(12)) + .push(text(match purpose {ContextualLinkPurpose::Precedence {..}=>"Precedence · hidden contributions attach visually to their folded reference",_=>"Assembly incidence · no execution order is implied"}).size(11)); + } + if let Some(address) = state.qualified_selection(p, owner) { + navigation=navigation.push(text(super::contextual::address_label(p,owner,&address.endpoint)).size(12)) + .push(text("Qualified contribution · definition properties affect every use of the source.").size(11)) + .push(button("Open contribution definition").on_press(Message::TopologyCanvas(CanvasEvent::OpenQualified(context,address.endpoint.clone())))); + if matches!( + p.elements + .get(&address.endpoint.occurrence) + .and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { .. }, + .. + }) + ) { + navigation = navigation.push( + button(if state.contextual_expanded.contains(&address.endpoint) { + "Fold nested reference" + } else { + "Expand nested reference" + }) + .on_press(Message::TopologyCanvas(CanvasEvent::ExpandQualified( + context, + address.endpoint.clone(), + ))), + ); + } + } + if !pending { + controls = controls.push(button("Qualified link / assembly…").on_press(event( + Event::Start(owner, Tool::Contextual, Some(app.selected)), + ))); + } + if let Some(id) = selected { + let mut actions = + row![button("Contribution properties").on_press(Message::EditProperties(id))] + .spacing(6); + if let Some(target) = direct::referenced(p, owner, id) { + actions = actions.push( + button(text(format!("Open source: {}", short(p.named(target), 28))).size(12)) + .on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))), + ); + if matches!( + p.elements[&id].shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { .. }, + .. + }) + ) { + actions = actions.push( + button(if state.expanded.contains(&id) { + "Collapse" + } else { + "Expand" + }) + .on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))), + ); + } + } + navigation = navigation.push(actions); + } else if sequence_links(p, owner) + .iter() + .any(|r| r.id == app.selected) + { + navigation = navigation.push( + button("Path properties / guard").on_press(Message::EditProperties(app.selected)), + ); + } + if !state.return_to.is_empty() { + navigation = navigation.push( + button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))), + ); + } + let diagram = if explicit { + let mut graph = Graph::expanded_qualified( + p, + owner, + app.selected, + &state.expanded, + &state.contextual_expanded, + ); graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.)); graph.interaction = Some(( context, state.tool, !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(), )); - graph.scroll_offset = state.scroll_offset(); + // Explicit graphs no longer have a scrolling page ancestor. A retained + // offset from an ordered view must not distort their pointer coordinates. + graph.scroll_offset = state.canvas_offset(); let scale = graph.scale; - out = out.push( + let mut header = column![ row![ - button("Fit overview") - .on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))), - button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom( - context, - Some(scale / 1.25) - ))), - label(format!("{:.0}%", scale * 100.)), - button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom( - context, - Some(scale * 1.25) - ))), - button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom( - context, - Some(1.) - ))) + out.width(Fill), + button( + text(if state.focused { + "Restore panels" + } else { + "Focus canvas" + }) + .size(12) + ) + .on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))) + .padding(6) ] - .spacing(6), - ); + .spacing(6) + .align_y(iced::Center), + row![ + button(text("Tools").size(12)) + .padding(6) + .style(if state.shelf == Some(Shelf::Tools) { + button::primary + } else { + style::nav + }) + .on_press(event(Event::Shelf(Shelf::Tools))), + button(text("Selection details").size(12)) + .padding(6) + .style(if state.shelf == Some(Shelf::Selection) { + button::primary + } else { + style::nav + }) + .on_press(event(Event::Shelf(Shelf::Selection))), + button(text("Fit overview").size(12)) + .padding(6) + .on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))), + button(text("−").size(12)) + .padding(6) + .on_press(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(scale / 1.25) + ))), + label(format!("{:.0}%", scale * 100.)), + button(text("+").size(12)) + .padding(6) + .on_press(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(scale * 1.25) + ))), + button(text("100%").size(12)) + .padding(6) + .on_press(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(1.) + ))) + ] + .spacing(6) + .wrap(), + ] + .spacing(6); + if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) { + header = header + .push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11)); + } if graph.nodes.is_empty() { - out = out.push(label( + controls = controls.push(label( "Choose Work, Exchange, Reuse or a control, then click the canvas to start.", )); } @@ -209,26 +347,44 @@ pub(super) fn view<'a>( vertical: scrollable::Scrollbar::default(), horizontal: scrollable::Scrollbar::default(), }) - .height(if pending { - (app.window_size.height - 510.).clamp(160., 240.) - } else { - (app.window_size.height - 420.).clamp(190., 420.) - }); - out = out.push( - iced::widget::sensor( - container(super::super::scenario_scroll::observe_topology( - context, - scenario::ScrollArea::Canvas, - canvas, - )) - .padding(8) - .width(Fill) - .style(style::card), - ) + .width(Fill) + .height(Fill); + let canvas = container( + iced::widget::sensor(super::super::scenario_scroll::observe_topology( + context, + scenario::ScrollArea::Canvas, + canvas, + )) .on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))) .on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))), - ); + ) + .padding(8) + .width(Fill) + .height(Fill) + .style(style::card); + let details: Element<'_, Message> = match state.shelf { + Some(Shelf::Tools) => controls.into(), + _ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(), + }; + explicit_pane( + header.into(), + details, + canvas.into(), + state.shelf.is_some(), + pending, + ) } else { + out = out + .push( + button(if state.focused { + "Restore explorer and properties" + } else { + "Focus canvas" + }) + .on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))), + ) + .push(controls) + .push(navigation); out = out.push(label( "Ordered exchange path · append the next exchange or convert to explicit links.", )); @@ -264,204 +420,180 @@ pub(super) fn view<'a>( }) .height(240.), ); - } - if let Some(id) = selected { - let mut actions = - row![button("Contribution properties").on_press(Message::EditProperties(id))] - .spacing(6); - if let Some(target) = direct::referenced(p, owner, id) { - actions = actions.push( - button(text(format!("Open source: {}", short(p.named(target), 28))).size(12)) - .on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))), - ); - if matches!( - p.elements[&id].shared(), - Some(SharedBody::Occurrence { - body: OccurrenceBody::SequenceReference { .. }, - .. - }) - ) { - actions = actions.push( - button(if state.expanded.contains(&id) { - "Collapse" - } else { - "Expand" - }) - .on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))), - ); - } - } - out = out.push(actions); - } else if sequence_links(p, owner) - .iter() - .any(|r| r.id == app.selected) - { - out = out.push( - button("Path properties / guard").on_press(Message::EditProperties(app.selected)), - ); - } - if !state.return_to.is_empty() { - out = out.push( - button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))), - ); - } + super::super::scenario_scroll::observe_topology( + context, + scenario::ScrollArea::Page, + scrollable(out.padding(18)), + ) + }; let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into(); if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) { let mut form = column![text(d.tool.title()).size(16)].spacing(8); - if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) { - let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body { - guard.as_str() - } else { - "" - }; - form = form.push(label(format!( - "Insert on {} → {} · preserve path identity and condition [{}]", - p.named(link.source()), - p.named(link.target()), - guard - ))); - } else if d.fixed - && d.tool == Tool::Exchange - && let Some(before) = d.before - { - form = form.push(label(format!( - "Append after {} → new exchange · Create adds an explicit sequence link", - p.named(before) - ))); - } else if d.fixed && d.tool != Tool::Connect { - form = form.push(label( - d.insertion - .before - .map_or("Append an unconnected contribution".into(), |id| { - format!("Unconnected contribution before {}", p.named(id)) - }), - )); - } - if !legacy_order(p, owner).is_empty() { - form = form.push(label( - "This edit preserves the ordered path as explicit links in the same undo step.", - )); - } - if matches!( - d.tool, - Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch - ) { - let choices = targets(p, owner, d.tool) - .into_iter() - .filter(|c| compatible_source(p, d, c.id)) - .collect(); - if d.tool == Tool::Branch { - form = form.push(label("First path work")); - } - form = form.push(picker( - choices, - d, - 0, - d.target, - if d.tool == Tool::Branch { - "Choose first path work…" + if let Some(contextual) = &d.contextual { + form = form + .push( + text_input("Link name (retain current when blank)", &d.name) + .on_input(|v| event(Event::Name(v))) + .padding(8) + .size(12), + ) + .push(super::contextual::view(p, owner, contextual)); + } else { + if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) { + let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body { + guard.as_str() } else { - "Choose existing source…" - }, - Event::Target, - )); - } - if d.tool != Tool::Connect { - form = form.push( - text_input("Contribution name (defaults to source)", &d.name) - .on_input(|v| event(Event::Name(v))) + "" + }; + form = form.push(label(format!( + "Insert on {} → {} · preserve path identity and condition [{}]", + p.named(link.source()), + p.named(link.target()), + guard + ))); + } else if d.fixed + && d.tool == Tool::Exchange + && let Some(before) = d.before + { + form = form.push(label(format!( + "Append after {} → new exchange · Create adds an explicit sequence link", + p.named(before) + ))); + } else if d.fixed && d.tool != Tool::Connect { + form = form.push(label( + d.insertion + .before + .map_or("Append an unconnected contribution".into(), |id| { + format!("Unconnected contribution before {}", p.named(id)) + }), + )); + } + if !legacy_order(p, owner).is_empty() { + form = form.push(label( + "This edit preserves the ordered path as explicit links in the same undo step.", + )); + } + if matches!( + d.tool, + Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch + ) { + let choices = targets(p, owner, d.tool) + .into_iter() + .filter(|c| compatible_source(p, d, c.id)) + .collect(); + if d.tool == Tool::Branch { + form = form.push(label("First path work")); + } + form = form.push(picker( + choices, + d, + 0, + d.target, + if d.tool == Tool::Branch { + "Choose first path work…" + } else { + "Choose existing source…" + }, + Event::Target, + )); + } + if d.tool != Tool::Connect { + form = form.push( + text_input("Contribution name (defaults to source)", &d.name) + .on_input(|v| event(Event::Name(v))) + .padding(8) + .size(12), + ); + } + if matches!(d.tool, Tool::Control | Tool::Branch) { + let kinds = if d.tool == Tool::Branch { + vec![ControlKind::Choice, ControlKind::Fork] + } else { + vec![ + ControlKind::Choice, + ControlKind::Merge, + ControlKind::Fork, + ControlKind::Join, + ControlKind::Loop, + ] + }; + form = form.push( + pick_list( + kinds.into_iter().map(ControlChoice).collect::>(), + Some(ControlChoice(d.control)), + |v| event(Event::Control(v)), + ) + .padding(8) + .text_size(12), + ); + } + if d.tool == Tool::Branch { + form = form.push(label("Second path work")).push(picker( + targets(p, owner, Tool::Work), + d, + 1, + d.alternate, + "Second path work…", + Event::Alternate, + )); + if d.control != ControlKind::Fork { + form = form + .push(label("First path guard")) + .push( + text_input("First path guard", &d.guard) + .on_input(|v| event(Event::Guard(v))) + .padding(8), + ) + .push(label("Second path guard")) + .push( + text_input("Second path guard (e.g. else)", &d.alternate_guard) + .on_input(|v| event(Event::AlternateGuard(v))) + .padding(8), + ); + } + } + if d.fixed { + if let (Some(a), Some(b)) = (d.before, d.after) { + form = form.push(label(format!("{} → {}", p.named(a), p.named(b)))); + } + } else { + form = form + .push(label("Preceding contribution")) + .push(picker( + contributions(p, owner), + d, + 2, + d.before, + "After contribution…", + Event::Before, + )) + .push(button("Clear preceding").on_press(event(Event::ClearBefore))) + .push(label("Following contribution")) + .push(picker( + contributions(p, owner), + d, + 3, + d.after, + "Before contribution…", + Event::After, + )) + .push(button("Clear following").on_press(event(Event::ClearAfter))); + } + if d.tool != Tool::Branch + && d.insertion.link.is_none() + && (d.before.is_some() || d.tool == Tool::Connect) + { + form = form.push( + text_input( + "Path condition (empty remains a Choice / Loop warning)", + &d.guard, + ) + .on_input(|v| event(Event::Guard(v))) .padding(8) .size(12), - ); - } - if matches!(d.tool, Tool::Control | Tool::Branch) { - let kinds = if d.tool == Tool::Branch { - vec![ControlKind::Choice, ControlKind::Fork] - } else { - vec![ - ControlKind::Choice, - ControlKind::Merge, - ControlKind::Fork, - ControlKind::Join, - ControlKind::Loop, - ] - }; - form = form.push( - pick_list( - kinds.into_iter().map(ControlChoice).collect::>(), - Some(ControlChoice(d.control)), - |v| event(Event::Control(v)), - ) - .padding(8) - .text_size(12), - ); - } - if d.tool == Tool::Branch { - form = form.push(label("Second path work")).push(picker( - targets(p, owner, Tool::Work), - d, - 1, - d.alternate, - "Second path work…", - Event::Alternate, - )); - if d.control != ControlKind::Fork { - form = form - .push(label("First path guard")) - .push( - text_input("First path guard", &d.guard) - .on_input(|v| event(Event::Guard(v))) - .padding(8), - ) - .push(label("Second path guard")) - .push( - text_input("Second path guard (e.g. else)", &d.alternate_guard) - .on_input(|v| event(Event::AlternateGuard(v))) - .padding(8), - ); + ); } } - if d.fixed { - if let (Some(a), Some(b)) = (d.before, d.after) { - form = form.push(label(format!("{} → {}", p.named(a), p.named(b)))); - } - } else { - form = form - .push(label("Preceding contribution")) - .push(picker( - contributions(p, owner), - d, - 2, - d.before, - "After contribution…", - Event::Before, - )) - .push(button("Clear preceding").on_press(event(Event::ClearBefore))) - .push(label("Following contribution")) - .push(picker( - contributions(p, owner), - d, - 3, - d.after, - "Before contribution…", - Event::After, - )) - .push(button("Clear following").on_press(event(Event::ClearAfter))); - } - if d.tool != Tool::Branch - && d.insertion.link.is_none() - && (d.before.is_some() || d.tool == Tool::Connect) - { - form = form.push( - text_input( - "Path condition (empty remains a Choice / Loop warning)", - &d.guard, - ) - .on_input(|v| event(Event::Guard(v))) - .padding(8) - .size(12), - ); - } let valid = match d.command(p) { Ok(_) => true, Err(problem) => { @@ -470,21 +602,49 @@ pub(super) fn view<'a>( } }; let actions = row![ - button("Create").on_press_maybe(valid.then(|| event(Event::Submit))), + button(if d.contextual.is_some() { + "Apply link" + } else { + "Create" + }) + .on_press_maybe(valid.then(|| event(Event::Submit))), button("Cancel").on_press(event(Event::Cancel)) ] .spacing(7); form_panel = editor_panel(actions.into(), form.into()); } - panes( - super::super::scenario_scroll::observe_topology( - context, - scenario::ScrollArea::Page, - scrollable(out.padding(18)), - ), - form_panel, - pending, - ) + panes(diagram, form_panel, pending) +} + +/// A permanent three-slot tree: variable controls never remount the canvas. +/// The shelf scrolls independently; the canvas receives actual remaining height. +fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>( + header: iced::Element<'a, Message, iced::Theme, R>, + details: iced::Element<'a, Message, iced::Theme, R>, + canvas: iced::Element<'a, Message, iced::Theme, R>, + show_details: bool, + pending: bool, +) -> iced::Element<'a, Message, iced::Theme, R> { + // Column::push drops Fixed(0) children. from_vec retains the closed shelf + // slot so its disclosure cannot move/remount the following canvas tree. + Column::from_vec(vec![ + container(header).width(Fill).into(), + scrollable(details) + .id("process-details") + .width(Fill) + .height(if show_details { + if pending { 64. } else { 112. } + } else { + 0. + }) + .into(), + container(canvas).width(Fill).height(Fill).into(), + ]) + .spacing(6) + .padding(8) + .width(Fill) + .height(Fill) + .into() } /// Keep the diagram mounted while an independent editor uses the remaining diff --git a/crates/oa-desktop/src/data_values.rs b/crates/oa-desktop/src/data_values.rs index 14e10f4f..c94ae000 100644 --- a/crates/oa-desktop/src/data_values.rs +++ b/crates/oa-desktop/src/data_values.rs @@ -1,6 +1,109 @@ //! One address for a carried value, shared by scenario creation and property editing. use oa_core::*; +fn resolve_operation( + project: &Project, + message: &Element, +) -> Result { + if project.elements.get(&message.id) == Some(message) { + return operations::resolve(project, message.id); + } + let active = matches!( + message.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + operation: Some(_), + .. + }, + .. + }) + ) || matches!(message.shared(),Some(SharedBody::Occurrence { body:OccurrenceBody::Exchange { message_kind:MessageKind::Reply,reply_to:Some(call),..},.. }) if matches!(project.elements.get(call).and_then(|e|e.shared()),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:Some(_),..},..}))); + if !active { + return Ok(operations::OperationResolution::Ordinary); + } + let mut candidate = project.clone(); + candidate.elements.insert(message.id, message.clone()); + operations::resolve(&candidate, message.id) +} + +/// The same exact signature used by core admission, evaluated with the authored +/// message in place. Unresolved operation intent never falls back to channel union. +pub(crate) fn message_signature( + project: &Project, + message: &Element, +) -> Result, String> { + match resolve_operation(project, message).map_err(|e| e.to_string())? { + operations::OperationResolution::Selected(operation) => { + Ok(Some(operations::signature_relation(&operation))) + } + operations::OperationResolution::Unresolved { reason, .. } => Err(reason), + operations::OperationResolution::Ordinary => Ok(match message.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + }) => exchange.and_then(|id| project.relations.get(&id)).cloned(), + _ => None, + }), + } +} + +pub(crate) fn operation_choices(project: &Project, message: &Element) -> Vec { + let mut candidate = project.clone(); + candidate.elements.insert(message.id, message.clone()); + let mut choices: Vec<_> = operations::eligible_allocations(&candidate, message.id) + .into_iter() + .map(|id| crate::app::Choice { + id, + label: operation_identity(project, id), + }) + .collect(); + choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id))); + choices +} + +pub(crate) fn operation_identity(project: &Project, allocation: Id) -> String { + if let Some(r) = project.relations.get(&allocation) { + format!( + "{} / {} · {} (@{})", + project.named(r.source()), + project.named(r.target()), + r.name, + r.alias + ) + } else { + format!("Unavailable operation allocation · {allocation}") + } +} + +pub(crate) fn operation_label(project: &Project, message: &Element) -> Option { + let reply = matches!( + message.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + .. + }, + .. + }) + ); + let prefix = if reply { + "Reply operation from Call" + } else { + "Operation" + }; + match resolve_operation(project, message) { + Ok(operations::OperationResolution::Ordinary) => None, + Ok(operations::OperationResolution::Unresolved { reason, .. }) => { + Some(format!("{prefix}: unresolved · {reason}")) + } + Ok(operations::OperationResolution::Selected(operation)) => Some(format!( + "{prefix}: {}", + operation_identity(project, operation.allocation) + )), + Err(error) => Some(format!("{prefix}: {error}")), + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct FieldPathKey { pub path: Vec, @@ -38,6 +141,18 @@ fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &Messa _ => true, } } +/// Selected asynchronous operations carry request parameters. Ordinary channel +/// messages retain their existing phase rules; unresolved intent has no signature. +pub(crate) fn operation_value_phase( + kind: &MessageKind, + operation: Option<&OperationUse>, +) -> MessageKind { + if *kind == MessageKind::Asynchronous && operation.is_some() { + MessageKind::Call + } else { + kind.clone() + } +} fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice { let qualified = entry .path diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 3de1a10b..549b3da1 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -46,6 +46,7 @@ pub struct DiagramCanvas<'a> { pub context: Context, pub selected_bend: Option, pub editable: bool, + pub placing: bool, pub connection: Option<&'a crate::connection_edit::Session>, } #[derive(Default)] @@ -227,6 +228,9 @@ impl DiagramCanvas<'_> { let scene = self.scene(state); let mut request = crate::model_menu::Request::object(self.diagram, bounds.center()); request.canvas = Some(self.context); + request.creation_point = Some(position( + self.world(Point::new(bounds.width / 2., bounds.height / 2.)), + )); let anchor = if let Some(port) = scene.view.ports.iter().find(|p| p.element == self.selected) { request.object = port.element; @@ -258,6 +262,7 @@ impl DiagramCanvas<'_> { None }; if let Some(anchor) = anchor { + request.creation_point = Some(anchor); request.anchor = Point::new( bounds.x + anchor.x * self.zoom + self.pan.x, bounds.y + anchor.y * self.zoom + self.pan.y, @@ -278,6 +283,7 @@ impl DiagramCanvas<'_> { Point::new(bounds.x + local.x, bounds.y + local.y), ); request.canvas = Some(self.context); + request.creation_point = Some(position(world)); if let Some(edge) = scene.routed.edges.get(&self.selected) && !edge.custom_suspended && let Some(index) = edge @@ -612,6 +618,28 @@ impl canvas::Program for DiagramCanvas<'_> { }) if state.keyboard_focus => { use iced::keyboard::{Key, key::Named}; match key.as_ref() { + Key::Named(Named::F2) + if self.editable && self.connection.is_none() && modifiers.is_empty() => + { + let view = self.scene(state).view; + let node = view + .nodes + .iter() + .find(|n| n.element == self.selected || n.id == self.selected)?; + Some( + Action::publish(Message::Architecture( + crate::app::architecture_authoring::Event::Rename( + self.context, + node.element, + Position { + x: node.position.x + 14., + y: node.position.y + 35., + }, + ), + )) + .and_capture(), + ) + } Key::Named(Named::ContextMenu) | Key::Named(Named::F10) if *key == Key::Named(Named::ContextMenu) || modifiers.shift() => { @@ -685,6 +713,17 @@ impl canvas::Program for DiagramCanvas<'_> { let scene = self.scene(state); let view = &scene.view; let world = self.world(p); + if self.placing && self.editable { + return Some( + Action::publish(Message::Architecture( + crate::app::architecture_authoring::Event::Click( + self.context, + position(world), + ), + )) + .and_capture(), + ); + } if self.connection.is_some() && self.editable { let event = if let Some(id) = self.endpoint_at(view, p) { state.connection_press = Some((id, p)); @@ -1301,7 +1340,7 @@ impl canvas::Program for DiagramCanvas<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { - if self.connection.is_some() && cursor.is_over(bounds) { + if (self.connection.is_some() || self.placing) && cursor.is_over(bounds) { mouse::Interaction::Crosshair } else if state.drag.is_some() || state.pan_start.is_some() @@ -1858,6 +1897,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, diagram, selected: p.id, @@ -1931,6 +1971,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, diagram, selected: p.id, @@ -2054,6 +2095,7 @@ mod tests { zoom, pan: Vector::new(20., 30.), editable: true, + placing: false, connection: Some(&session), }; let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.)); @@ -2226,6 +2268,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, }; let mut state = State::default(); @@ -2300,6 +2343,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, }; let mut state = State::default(); @@ -2395,6 +2439,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, }; let mut state = State::default(); @@ -2521,6 +2566,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, }; for end in ends { @@ -2573,6 +2619,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, }; let mut state = State::default(); @@ -2645,6 +2692,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, }; assert!( @@ -2676,6 +2724,7 @@ mod tests { }, selected_bend: None, editable: true, + placing: false, connection: None, }; let span = &routed.edges[&edge].points; diff --git a/crates/oa-desktop/src/diagram/lifecycle_tests.rs b/crates/oa-desktop/src/diagram/lifecycle_tests.rs index 3734a7c5..6f623b5b 100644 --- a/crates/oa-desktop/src/diagram/lifecycle_tests.rs +++ b/crates/oa-desktop/src/diagram/lifecycle_tests.rs @@ -62,6 +62,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> { context: app.route_context().unwrap(), selected_bend: None, editable: true, + placing: false, connection: app.connection.as_ref(), } } diff --git a/crates/oa-desktop/src/diagram/pointer_tests.rs b/crates/oa-desktop/src/diagram/pointer_tests.rs index c674871b..b76c2dbc 100644 --- a/crates/oa-desktop/src/diagram/pointer_tests.rs +++ b/crates/oa-desktop/src/diagram/pointer_tests.rs @@ -70,6 +70,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> { context: app.route_context().unwrap(), selected_bend: None, editable: true, + placing: false, connection: app.connection.as_ref(), } } diff --git a/crates/oa-desktop/src/diagram/port_hit_tests.rs b/crates/oa-desktop/src/diagram/port_hit_tests.rs index a65e02b6..5ffaf542 100644 --- a/crates/oa-desktop/src/diagram/port_hit_tests.rs +++ b/crates/oa-desktop/src/diagram/port_hit_tests.rs @@ -16,6 +16,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> { }, selected_bend: None, editable: true, + placing: false, connection: None, } } diff --git a/crates/oa-desktop/src/diagram/port_labels.rs b/crates/oa-desktop/src/diagram/port_labels.rs index d0d4982d..35326577 100644 --- a/crates/oa-desktop/src/diagram/port_labels.rs +++ b/crates/oa-desktop/src/diagram/port_labels.rs @@ -183,6 +183,7 @@ mod tests { context: app.route_context().unwrap(), selected_bend: None, editable: true, + placing: false, connection: None, }) } diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index 6d466147..4a9473cd 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -14,6 +14,7 @@ use iced::{ use oa_core::*; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; +mod context_fields; mod data_fields; mod port_fields; mod view_fields; @@ -44,6 +45,23 @@ pub struct ReferenceBrowser { pub page: usize, } +pub(crate) fn set_shape_value(record: &mut Value, path: &str, value: Value) -> Result<(), String> { + if let Some(target) = record.pointer_mut(path) { + *target = value; + return Ok(()); + } + // Ordinary messages omit operation=None in source/JSON. Authoring that one + // optional field must not require changing the historic record encoding. + if let Some(parent) = path.strip_suffix("/operation") + && parent.ends_with("/Occurrence/body/Exchange") + && let Some(fields) = record.pointer_mut(parent).and_then(Value::as_object_mut) + { + fields.insert("operation".into(), value); + return Ok(()); + } + Err("Property no longer exists.".into()) +} + pub struct Draft { pub kind: RecordKind, pub original: Id, @@ -323,9 +341,7 @@ impl Draft { } else { value }; - *candidate - .pointer_mut(path) - .ok_or("Property no longer exists.")? = value; + set_shape_value(&mut candidate, path, value)?; self.value = candidate; self.rebuild(); Ok(()) @@ -333,10 +349,7 @@ impl Draft { /// Replace a workflow-authorized subtree without parsing unrelated inputs, /// including an invalid value which this replacement deliberately removes. pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> { - *self - .value - .pointer_mut(path) - .ok_or("Property no longer exists.")? = value; + set_shape_value(&mut self.value, path, value)?; self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned())); Ok(()) } @@ -355,7 +368,8 @@ impl Draft { || path.ends_with("/Port/contract") || path.ends_with("/Occurrence/body") || path.ends_with("/Event/body") - || path.ends_with("/message_kind"); + || path.ends_with("/message_kind") + || path.ends_with("/ContextualLink/purpose"); if !constructor { return self.shape_scoped(path, proposed); } @@ -1697,6 +1711,9 @@ pub fn scoped<'a>( ) -> View<'a, Message> { match draft.value.pointer(path) { Some(value) => property(draft, project, value, path, label, false), + None if path.ends_with("/Occurrence/body/Exchange/operation") => { + property(draft, project, &Value::Null, path, label, false) + } None => text("This property is not applicable to the current target.") .size(12) .into(), @@ -1711,6 +1728,9 @@ fn property<'a>( label: &str, skip_variant: bool, ) -> View<'a, Message> { + if let Some(view) = context_fields::property(draft, project, value, path) { + return view; + } if let Some(view) = view_fields::property(draft, project, value, path) { return view; } @@ -2419,6 +2439,7 @@ mod tests { receiver: Some(receiver), message_kind, reply_to, + operation: None, values: vec![], }, }, diff --git a/crates/oa-desktop/src/forms/context_fields.rs b/crates/oa-desktop/src/forms/context_fields.rs new file mode 100644 index 00000000..6bc3bc2e --- /dev/null +++ b/crates/oa-desktop/src/forms/context_fields.rs @@ -0,0 +1,263 @@ +//! Qualified editors share core's resolver, including incomplete leaf selection. +use super::*; +use iced::widget::column; +use oa_core::topology_context::TopologyIndex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Purpose { + Precedence, + Exchange, + CommonWork, +} +impl std::fmt::Display for Purpose { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Precedence => "Precedence", + Self::Exchange => "Exchange assembly", + Self::CommonWork => "Common work incidence", + }) + } +} +fn choice(p: &Project, id: Id) -> crate::app::Choice { + crate::app::Choice { + id, + label: p + .elements + .get(&id) + .map(|e| format!("{} (@{})", e.name, e.alias)) + .or_else(|| { + p.relations + .get(&id) + .map(|r| format!("{} (@{})", r.name, r.alias)) + }) + .unwrap_or_else(|| format!("Unavailable {id}")), + } +} +pub(super) fn property<'a>( + draft: &'a Draft, + p: &'a Project, + value: &'a Value, + path: &str, +) -> Option> { + if !path.contains("/ContextualLink/") { + return None; + } + let relation: Relation = serde_json::from_value(draft.context_value()) + .ok() + .or_else(|| p.relations.get(&draft.original).cloned())?; + let RelationBody::ContextualLink { owner, .. } = relation.body else { + return None; + }; + if path.ends_with("/source/occurrence") || path.ends_with("/target/occurrence") { + let parent = path.strip_suffix("/occurrence")?; + let endpoint: OccurrenceEndpoint = + serde_json::from_value(draft.context_value().pointer(parent)?.clone()).ok()?; + let index = TopologyIndex::new(p); + let scope = match index.resolve_scope(owner, &endpoint.references) { + Ok(scope) => scope, + Err(error) => return Some(text(error.to_string()).size(12).into()), + }; + let choices = index + .contributions(scope) + .into_iter() + .map(|id| choice(p, id)) + .collect::>(); + let selected = (!endpoint.occurrence.is_nil()).then(|| choice(p, endpoint.occurrence)); + return Some( + column![ + text("Only the leaf changes; the caller and complete reference path remain fixed.") + .size(11), + pick_list(choices, selected, { + let path = path.to_owned(); + move |choice| Message::FieldShape(path.clone(), json!(choice.id)) + }) + .padding(8) + .text_size(12) + .width(Fill) + ] + .spacing(6) + .into(), + ); + } + if path.ends_with("/source") || path.ends_with("/target") { + let endpoint: OccurrenceEndpoint = serde_json::from_value(value.clone()).ok()?; + let index = TopologyIndex::new(p); + let mut out = column![ + text(crate::behavior::topology::contextual::address_label( + p, owner, &endpoint + )) + .size(12) + ] + .spacing(6); + match index.resolve_scope(owner, &endpoint.references) { + Ok(scope) => { + let choices = index + .contributions(scope) + .into_iter() + .map(|id| choice(p, id)) + .collect::>(); + let selected = + (!endpoint.occurrence.is_nil()).then(|| choice(p, endpoint.occurrence)); + out = out.push( + pick_list(choices, selected, { + let path = path.to_owned(); + let references = endpoint.references.clone(); + move |choice| { + Message::FieldShape( + path.clone(), + json!(OccurrenceEndpoint { + references: references.clone(), + occurrence: choice.id + }), + ) + } + }) + .placeholder("Choose a contribution in this context…") + .padding(8) + .text_size(12) + .width(Fill), + ); + if index.resolve(owner, &endpoint).is_ok() + && matches!( + p.elements + .get(&endpoint.occurrence) + .and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence: Some(_) }, + .. + }) + ) + { + let mut references = endpoint.references.clone(); + references.push(endpoint.occurrence); + out = out.push(button("Enter selected reference").on_press( + Message::FieldShape( + path.to_owned(), + json!(OccurrenceEndpoint { + references, + occurrence: Id::nil() + }), + ), + )); + } + } + Err(error) => out = out.push(text(error.to_string()).size(12)), + } + if let Some(reference) = endpoint.references.last() { + let mut references = endpoint.references.clone(); + references.pop(); + out = out.push(button("Up to caller context").on_press(Message::FieldShape( + path.to_owned(), + json!(OccurrenceEndpoint { + references, + occurrence: *reference + }), + ))); + } + return Some(out.push(text("Entering a reference retains an unfinished endpoint until you choose its contribution. Apply checks the complete caller path.").size(11)).into()); + } + if path.ends_with("/owner") { + let mut choices = p + .elements + .values() + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .map(|e| choice(p, e.id)) + .collect::>(); + choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id))); + return Some( + pick_list(choices, Some(choice(p, owner)), { + let path = path.to_owned(); + move |choice| Message::FieldShape(path.clone(), json!(choice.id)) + }) + .padding(8) + .text_size(12) + .width(Fill) + .into(), + ); + } + if path.ends_with("/purpose") { + let purpose: ContextualLinkPurpose = serde_json::from_value(value.clone()).ok()?; + let selected = match purpose { + ContextualLinkPurpose::Precedence { .. } => Purpose::Precedence, + ContextualLinkPurpose::Exchange { .. } => Purpose::Exchange, + ContextualLinkPurpose::CommonWork { .. } => Purpose::CommonWork, + }; + let mut out = column![ + pick_list( + vec![Purpose::Precedence, Purpose::Exchange, Purpose::CommonWork], + Some(selected), + { + let path = path.to_owned(); + move |purpose| { + Message::FieldVariant( + path.clone(), + match purpose { + Purpose::Precedence => json!(ContextualLinkPurpose::Precedence { + guard: String::new() + }), + Purpose::Exchange => json!(ContextualLinkPurpose::Exchange { + exchange: Id::nil() + }), + Purpose::CommonWork => { + json!(ContextualLinkPurpose::CommonWork { work: Id::nil() }) + } + }, + ) + } + } + ) + .padding(8) + .text_size(12) + .width(Fill) + ] + .spacing(7); + match purpose { + ContextualLinkPurpose::Precedence { .. } => { + let path = format!("{path}/Precedence/guard"); + let raw = draft + .inputs + .get(&path) + .map(|input| input.raw.as_str()) + .unwrap_or(""); + out = out.push( + text_input("Precedence guard", raw) + .on_input(move |raw| Message::Field(path.clone(), raw)) + .padding(8) + .size(12), + ); + } + purpose => { + let (selected, choices, leaf) = match purpose { + ContextualLinkPurpose::Exchange { exchange } => ( + exchange, + p.relations + .values() + .filter(|r| { + matches!( + r.kind(), + RelationKind::Interaction | RelationKind::FunctionalExchange + ) && p.layer_of(r.id) == p.layer_of(owner) + }) + .map(|r| choice(p, r.id)) + .collect::>(), + "Exchange/exchange", + ), + ContextualLinkPurpose::CommonWork { work } => ( + work, + p.elements + .values() + .filter(|e| e.kind().is_work() && Some(e.layer()) == p.layer_of(owner)) + .map(|e| choice(p, e.id)) + .collect::>(), + "CommonWork/work", + ), + _ => unreachable!(), + }; + let path = format!("{path}/{leaf}"); + out=out.push(pick_list(choices,(!selected.is_nil()).then(||choice(p,selected)),move|choice|Message::FieldShape(path.clone(),json!(choice.id))).placeholder("Choose exact engineering identity…").padding(8).text_size(12).width(Fill)).push(text("Assembly incidence does not imply execution order or merge executions.").size(11)); + } + } + return Some(out.into()); + } + None +} diff --git a/crates/oa-desktop/src/forms/data_fields.rs b/crates/oa-desktop/src/forms/data_fields.rs index 291739c9..61a9c62f 100644 --- a/crates/oa-desktop/src/forms/data_fields.rs +++ b/crates/oa-desktop/src/forms/data_fields.rs @@ -51,6 +51,146 @@ pub(super) fn property<'a>( return Some(message_value(draft, project, value, path)); } let leaf = path.rsplit('/').next()?; + if leaf == "operation" && path.ends_with("/Occurrence/body/Exchange/operation") { + let message = element(draft, project)?; + let operation = match message.shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + } => operation.as_ref(), + _ => return None, + }; + let choices = crate::data_values::operation_choices(project, &message); + let selected = operation + .and_then(|o| o.allocation) + .map(|id| crate::app::Choice { + id, + label: crate::data_values::operation_identity(project, id), + }); + let path = path.to_owned(); + let mut out = column![ + text("Exact interface operation allocation").size(12), + pick_list(choices, selected, { + let path = path.clone(); + move |choice| Message::FieldShape(path.clone(), json!({"allocation":choice.id})) + }) + .placeholder("Choose an eligible allocation…") + .text_size(12) + .padding(8) + .width(Fill), + button("Keep operation unresolved").on_press(Message::FieldShape( + path.clone(), + json!({"allocation":null}) + )), + button("Use ordinary channel semantics") + .on_press(Message::FieldShape(path, Value::Null)) + ] + .spacing(7); + if let Some(label) = crate::data_values::operation_label(project, &message) { + out = out.push(text(label).size(11)); + } + return Some(out.into()); + } + if leaf == "Exchange" && path.contains("/Occurrence/") && value.is_object() { + let mut out = column![].spacing(7); + if let Some(message) = element(draft, project) { + let operation_path = format!("{path}/operation"); + let body = message.shared(); + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind, + operation, + .. + }, + .. + }) = body + { + let scenario = validation::ancestor_of_kind(project, message.id, Kind::Scenario); + let boundary = scenario + .and_then(|id| project.elements.get(&id)) + .is_some_and(|e| { + matches!( + e.shared(), + Some(SharedBody::Scenario { + viewpoint: ScenarioViewpoint::BoundaryInterface, + .. + }) + ) + }); + if message.layer() == Layer::Sa + && boundary + && matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous) + { + let enabled = operation.is_some(); + out = out.push( + checkbox(enabled) + .label("Invoke a specific interface operation") + .on_toggle({ + let path = operation_path.clone(); + move |enabled| { + Message::FieldShape( + path.clone(), + if enabled { + json!({"allocation":null}) + } else { + Value::Null + }, + ) + } + }), + ); + if let Some(operation) = operation { + let mut choices = crate::data_values::operation_choices(project, &message); + let selected = operation.allocation.map(|id| crate::app::Choice { + id, + label: crate::data_values::operation_identity(project, id), + }); + if let Some(selected) = &selected + && !choices.iter().any(|c| c.id == selected.id) + { + choices.push(selected.clone()); + } + out = out + .push( + pick_list(choices, selected, { + let path = operation_path.clone(); + move |choice| { + Message::FieldShape( + path.clone(), + json!({"allocation":choice.id}), + ) + } + }) + .placeholder("Choose exact interface operation allocation…") + .text_size(12) + .padding(8) + .width(Fill), + ) + .push(button("Keep operation unresolved").on_press( + Message::FieldShape(operation_path, json!({"allocation":null})), + )); + } + } + if let Some(description) = crate::data_values::operation_label(project, &message) { + out = out.push(text(description).size(11)); + } + } + } + for (key, field) in value.as_object()? { + if key != "operation" { + out = out.push(super::property( + draft, + project, + field, + &format!("{path}/{key}"), + &friendly(key), + false, + )); + } + } + return Some(out.into()); + } if leaf == "direction" && path.contains("/DataField/") { let operation = element(draft, project) .and_then(|e| e.parent) @@ -315,21 +455,30 @@ fn message_value<'a>( let row = edited.pointer(path).unwrap_or(value).clone(); let current = serde_json::from_value::(row.clone()).ok(); let owner = element(draft, project); - let context = owner + let signature = owner.as_ref().and_then(|message| { + crate::data_values::message_signature(project, message) + .ok() + .flatten() + }); + let phase = owner .as_ref() .and_then(Element::shared) .and_then(|body| match body { SharedBody::Occurrence { body: OccurrenceBody::Exchange { - exchange: Some(id), message_kind, + operation, .. }, .. - } => project.relations.get(id).map(|r| (r, message_kind)), + } => Some(crate::data_values::operation_value_phase( + message_kind, + operation.as_ref(), + )), _ => None, }); + let context = signature.as_ref().zip(phase.as_ref()); let current_key = current .as_ref() .map(|value| crate::data_values::FieldPathKey { @@ -788,6 +937,7 @@ mod tests { .shared_mut() .unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: Some(f.exchange), sender: None, receiver: None, diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index 049129b7..8cb70a72 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -187,15 +187,16 @@ fn article(topic: Topic) -> Article { &[ "Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.", "On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.", - "Choose Entity under Create element, then + At root. Name it Rescue coordinator and Apply changes. Use Actor for an external participant such as a Vessel in distress.", - "Select Rescue coordinator, choose Activity, then + Allocated. Name the new activity Assess incident. It appears inside its performer; an Allocation relationship records that responsibility.", + "Choose Entity in the diagram's Create toolbar. Enter Rescue coordinator, choose Choose position, then click the diagram. Use Actor for an external participant such as a Vessel in distress. You can also right-click a blank area, choose Create Entity here, enter its name and choose Create here.", + "Choose Activity in the same toolbar, name it Assess incident and place it. Select Rescue coordinator and use + Relationship with Allocation to assign Assess incident to it. The allocated-work view then shows the activity inside its performer.", + "Use Add existing to place an element that is already in the model. The initial choices match this view; no search is required. Include already shown offers Locate for elements already placed here.", "Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.", "Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.", ], ), explain( "Continue into system design", - "Use Create / update counterpart on an operational element to review an OA → SA mapping. In System Analysis, identify the System under study, its external Actors and the Functions for which each is responsible. The transition tutorial explains scope, reuse and review decisions.", + "Use Create System counterpart on an operational element to create a linked SA element. Choose System for the system under study or System actor for an external participant, then choose the result diagram. The original operational element stays in OA. The transition tutorial explains scope and reuse.", ), walkthrough( "Build a reviewable OA → SA baseline", @@ -239,7 +240,7 @@ fn article(topic: Topic) -> Article { ), explain( "Switching perspective does not transition the model", - "The 01 Operational and 02 System controls change the visible perspective and available creation kinds. They do not move or delete records. Create / update counterpart makes a reviewed, traceable correspondence while preserving both perspectives.", + "The 01 Operational and 02 System controls change the visible perspective and creation choices. To represent an element in the other layer, use Create System counterpart or Create Operational counterpart. Both elements remain linked by a trace.", ), explain( "Keep the boundary explicit", @@ -261,8 +262,8 @@ fn article(topic: Topic) -> Article { "Create and allocate work", &[ "Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.", - "Create Activity in OA or Function in SA. Select existing work and use + In selected to decompose it into smaller work units.", - "To create work for a participant, select the participant, choose Activity or Function, then + Allocated. To allocate existing work, use + Relationship with Allocation from the participant to the leaf work.", + "In the diagram's Create toolbar, choose Activity in OA or Function in SA, enter a name and click to place it. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.", + "To assign work to a participant, use + Relationship with Allocation from the participant to the leaf work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.", "Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.", ], ), @@ -438,17 +439,17 @@ fn article(topic: Topic) -> Article { Topic::Chains => { const { Article { - summary: "An operational Process or system Functional chain selects an ordered path through existing exchanges. It explains how work contributes to an outcome.", - keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps", + summary: "An operational Process or system Functional chain connects contributions of existing work and exchanges, including alternatives and reusable procedures. It explains how work contributes to an outcome.", + keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps caller contextual qualified assembly common work focus reuse", sections: &[ walkthrough( "Describe a path through exchanges", &[ "First create the activities/functions and their exchanges. Check the source and target work for every exchange.", "Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.", - "Select that behavior in the page's selector. Choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.", - "Choose Sequence link and click or drag from the preceding contribution to the following contribution. In Select / reorder, drag a selected path endpoint to reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.", - "Select a contribution, then add another after it. Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.", + "Select that behavior in the page's selector. Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.", + "In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.", + "Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.", "Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.", ], ), @@ -458,7 +459,20 @@ fn article(topic: Topic) -> Article { ), explain( "Review the path as an engineer", - "Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.", + "Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Tools → Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.", + ), + walkthrough( + "Connect a particular use of a procedure", + &[ + "Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Selection details to read its complete caller and reference identity.", + "In Selection details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.", + "Open Tools → Qualified link / assembly… and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.", + "Choose Precedence to describe order and its guard, Exchange assembly to identify a work exchange between the contributions, or Common work incidence to state that both use the same work. Select the engineering identity when required, then Apply link.", + ], + ), + explain( + "Keep caller intent separate from the definition", + "A link into the first assessment's second contribution belongs to that caller. It does not change the shared assessment or its second use, and it does not delay the first contribution by targeting the whole procedure. Common work records shared identity without merging executions. Model checks identify the particular caller path and report incomplete analysis when expansion cannot be completed.", ), ], related: &[Topic::Exchanges, Topic::Capabilities, Topic::Scenarios], @@ -470,7 +484,7 @@ fn article(topic: Topic) -> Article { const { Article { summary: "Use a scenario to tell a concrete behavioral story, including repeated messages, actions, state observations and guarded alternatives.", - keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state", + keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved", sections: &[ walkthrough( "Create a scenario", @@ -492,6 +506,15 @@ fn article(topic: Topic) -> Article { "Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.", ], ), + walkthrough( + "Invoke an interface operation", + &[ + "In an SA BoundaryInterface scenario, create or edit a Call or Asynchronous message. Choose its boundary exchange and actual sending and receiving lifelines first. The receiving Standard component port must provide the interface; the sending port must require a compatible interface.", + "Enable Invoke a specific interface operation and choose the exact eligible allocation. Two parameterless operations on one channel remain distinct. Choose Keep operation unresolved when the operation decision is unfinished; existing value expressions remain available.", + "For a Reply, inspect Reply operation from Call. It derives the selection from that particular Call and cannot be selected independently. Enter values for the selected operation's call or reply parameters; incompatible values must be corrected explicitly.", + "When transitioning that invocation to OA, explicitly choose to omit the technical operation and enter the engineering rationale in the preview or semantic review row. The SA source keeps its operation identity.", + ], + ), explain( "Conditions are engineering statements", "Guards, actions and effects document behavior; a drawn scenario does not execute code or prove timing. Add evidence references when a simulation, test or analysis establishes a stronger claim.", @@ -623,20 +646,20 @@ fn article(topic: Topic) -> Article { walkthrough( "Review a counterpart transition", &[ - "Select an operational element and choose Create / update counterpart. Selecting a system element starts the reverse System → Operational workflow.", + "Select an operational element and choose Create System counterpart. For a system element, Create Operational counterpart starts the reverse direction. The source stays in its original layer.", "Choose Include: Selected only, Required references or Whole subtree. Required dependencies must have compatible counterparts before the plan can apply.", - "For each source, choose Create new counterpart or reuse an existing target. Review the target kind and any parent, performer or System-boundary decisions offered by the preview.", + "For each source, choose Create new counterpart or reuse an existing target. For an operational participant, explicitly choose System for the system under study or System actor for an external participant. Use Show result in to choose a compatible diagram; Automatic selects an architecture view for ordinary elements.", "Read the proposed changes and conflicts. For a descriptive conflict, choose the source value, keep the target value, or enter a merged value. Changing the target/kind requires reviewing that target's decisions again.", "Apply only after every blocking decision is resolved. The accepted plan is one undoable model change; Cancel preserves the current model.", ], ), explain( "Review drift deliberately", - "On an existing trace, choose Review semantics… to compare typed content and prior observations. Choose Keep for the destination, Take for the source, or Set final value / Set final kind and payload where offered. Review mapped references and proposed changes, then choose Apply reviewed changes. Independent edits may be intentional; keeping a value records that decision. A manual trace records correspondence without propagating properties.", + "On an existing trace, choose Review counterpart changes… to compare typed content and prior observations. Choose Keep for the destination, Take for the source, or Set final value / Set final kind and payload where offered. Review mapped references and proposed changes, then choose Apply reviewed changes. Independent edits may be intentional; keeping a value records that decision. A manual trace records correspondence without propagating properties.", ), explain( "Know what reconciliation changes", - "Semantic review supports structural and scalar changes, typed constructors, individual collection rows and explicit ordered collections. Several trace rows can contribute to one combined destination proposal. Candidate validation checks the complete proposed model before one undoable transaction. The Review semantic changes topic explains included rows, mapped references and order decisions. Attached-record and artifact reconciliation remain outside this dialog's authoring controls; use their supported editing workflows.", + "Semantic review supports structural and scalar changes, typed constructors, individual collection rows, explicit ordered collections, existing attachments and artifact links. Several trace rows can contribute to one combined destination proposal. Candidate validation checks the complete proposed model before one undoable transaction. The Review semantic changes topic explains included rows, mapped references and each explicit attachment decision.", ), explain( "Avoid duplicate counterparts", @@ -663,20 +686,20 @@ fn article(topic: Topic) -> Article { walkthrough( "Compare the right records and direction", &[ - "From an existing counterpart trace, choose Review semantics…. Confirm the OA → SA or SA → OA direction and select the intended trace / review history. Each correspondence and direction has its own retained row draft; opening another row does not discard the first row's choices.", + "From an existing counterpart trace, choose Review counterpart changes…. Confirm the OA → SA or SA → OA direction and select the intended trace / review history. Each correspondence and direction has its own retained row draft; opening another row does not discard the first row's choices.", "In Properties, select a fact. Compare Previously reviewed with Current on both sides. Unreviewed means there is no prior observation; Both changed means both sides changed since that observation, not that one side is automatically wrong.", "Choose Keep for the destination, Take for the source, or Set final value…. For a kind change, Set final kind and payload… reviews the complete replacement and its affected references. Leave unselected makes no decision for that fact.", - "Read Reference pairs and Additional changes before Apply reviewed changes. A missing or ambiguous correspondence must be resolved explicitly. Apply commits the validated model and the reviewed history together; Undo restores both.", + "Read Reference mapping and Additional changes before Apply reviewed changes. A missing or ambiguous correspondence must be resolved explicitly. Apply commits the validated model and the reviewed history together; Undo restores both.", ], ), explain( "Map references by meaning", - "Reference pairs connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.", + "Reference mapping connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.", ), walkthrough( "Review one message value or lifeline binding", &[ - "Open Members / rows and choose the exact identities on both sides. For a message value, browse the enclosing record fields as well as the final field: origin.latitude and destination.latitude are distinct addresses.", + "Open Contents and order and choose the exact identities on both sides. For a message value, browse the enclosing record fields as well as the final field: origin.latitude and destination.latitude are distinct addresses.", "For a binding, choose the referenced lifeline key and its compatible local lifeline. Check which scenario owns each side. Keep or Take concerns that row, not all bindings in the scenario.", "Use Set final value… to enter the destination expression or local lifeline. Include in destination collection controls that row's presence. Unchecking removes the entry without deleting its referenced engineering object.", "If a row key changed, review the old entry's removal and the new entry's addition separately. A missing source identity is not the same decision as an identified source entry that has been deleted. Read the proposed value before applying.", @@ -685,12 +708,16 @@ fn article(topic: Topic) -> Article { walkthrough( "Review membership and sequence order separately", &[ - "Review which occurrences belong to Steps, or which operands belong to Fragment branches, using Members / rows. This changes membership; it does not silently reparent or create the referenced records.", + "Review which occurrences belong to Steps, or which operands belong to Fragment branches, using Contents and order. This changes membership; it does not silently reparent or create the referenced records.", "Choose Review final order… or the corresponding order fact in Properties. Keep retains destination positions. Take uses mapped source order when it can preserve destination-only members without an ambiguous placement.", "For an explicit result, choose Set final value…, then use Up and Down. If you change membership afterward, choose Reconcile with selected membership: surviving positions are retained, removed members leave the list, and new members append for you to position.", "Every surviving destination member must appear exactly once. Resolve an ambiguous insertion with an explicit final order; do not remove an engineering contribution merely to make the preview pass.", ], ), + explain( + "Review attached records and external evidence", + "In Owned elements and relationships, choose exact OA and SA child or relationship identities, then Use exact attachment pair. Review a child's parent or a relationship endpoint independently; creating a new child or relationship requires its explicit transition mapping. In Evidence links, pair the exact links and choose Review this exact artifact pair. Presence concerns attachment to the reviewed owner. Taking an identified source absence can delete the destination link; a destination-only link is retained. Reserve a new destination counterpart only when one is needed, then explicitly review Presence and all seven metadata facts before the combined Apply. The reservation creates nothing by itself.", + ), explain( "Preserve intentional differences and useful history", "Keep can acknowledge a deliberate difference without overwriting the destination. Reset this exact previous observation clears only the chosen review history; it does not restore an old model value. Overlapping row addresses may require an explicit history reset. Review those messages rather than clearing unrelated observations. Pending review choices and raw values can be recovered after an unexpected exit, but the plan is recomputed against the recovered accepted model.", @@ -698,8 +725,8 @@ fn article(topic: Topic) -> Article { walkthrough( "Review several sources together", &[ - "Use Choose another trace to add or reopen, then Open row. Include this row determines whether its decisions contribute to the shared preview. The trace / review history selector switches the row you are editing; excluded rows retain their drafts.", - "Review each included source's contribution to the destination. Compatible decisions combine; contradictory Keep, Take or final-value decisions must be resolved explicitly. Read the combined conflicts, Reference pairs and Additional changes before Apply reviewed changes. One Apply commits the combined model and review histories; one Undo restores them.", + "Use Add linked object, then Review object. Include this object determines whether its decisions contribute to the shared preview. The object selector switches the object you are editing; excluded rows retain their drafts.", + "Review each included source's contribution to the destination. Compatible decisions combine; contradictory Keep, Take or final-value decisions must be resolved explicitly. Read the combined conflicts, Reference mapping and Additional changes before Apply reviewed changes. One Apply commits the combined model and review histories; one Undo restores them.", "Only one trace history per source can be included. Some reverse correspondences cannot share the core reference mapping; the preview explains the conflict. Exclude a row or choose a distinct correspondence without discarding its draft. Switching direction retains the other direction's choices for later review.", "Recovery retains included and excluded row drafts and raw final values, then recomputes the plan against the recovered accepted model. Refresh preview is not automatic synchronization and does not authorize changes to attached records or artifacts.", ], @@ -730,6 +757,10 @@ fn article(topic: Topic) -> Article { "Choose the relation to the artifact: References, Satisfies, Verifies or Implements. Add a description explaining the evidence and its limits. Apply changes, then inspect the owner and reference in Traceability.", ], ), + explain( + "Find existing evidence", + "In Traceability, choose Development artifacts to show artifact links directly. Search by the link's name or alias, its owner, URI, revision or fragment. Layer traces shows OA/SA correspondences; All shows both lists. Filtering changes only what is shown and preserves the selected record and unfinished property input.", + ), explain( "A reference is not an attachment", "The model preserves the author-provided URI, revision and fragment. Loading or saving a model does not fetch, execute or verify an external artifact. A path on one workstation may not exist on another; choose team-resolvable locations and identifiers.", diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 9b4f5fe3..17608344 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -14,6 +14,7 @@ pub struct Request { pub placement: Option, pub bend: Option, pub route_point: Option, + pub creation_point: Option, pub canvas: Option, pub source_file: Option, } @@ -25,6 +26,7 @@ impl Request { placement: None, bend: None, route_point: None, + creation_point: None, canvas: None, source_file: None, } @@ -205,13 +207,24 @@ pub fn popup(app: &App) -> Option> { ), item("Export SVG…", Message::Export, true), Entry::separator(), - item( - &format!("Create {} at root", app.create_kind.label()), - Message::Add(false), - validation::can_parent(p, app.create_kind, app.layer, None), - ), - item("Browse existing elements…", Message::Explore(None), true), ]); + for kind in crate::app::architecture_authoring::kinds(app) { + entries.push(item( + &format!("Create {} here…", kind.label()), + Message::Architecture(crate::app::architecture_authoring::Event::Start( + kind, + request.creation_point, + )), + true, + )); + } + entries.push(item( + "Add existing here…", + Message::Architecture(crate::app::architecture_authoring::Event::Existing( + request.creation_point, + )), + true, + )); } else { if request.route_point.is_some() || request.bend.is_some() @@ -343,7 +356,23 @@ pub fn popup(app: &App) -> Option> { } else { Kind::Function }; - if e.kind().is_performer() || e.kind().is_work() { + if e.kind().is_performer() && request.canvas.is_some() { + if crate::app::architecture_authoring::kinds(app).contains(&work) { + entries.push(item( + if e.layer() == Layer::Oa { + "Add activity…" + } else { + "Add function…" + }, + Message::Architecture(crate::app::architecture_authoring::Event::Allocate( + work, + id, + request.creation_point, + )), + true, + )); + } + } else if e.kind().is_performer() || e.kind().is_work() { entries.push(item( if e.kind().is_performer() { "Create allocated work" @@ -416,12 +445,16 @@ pub fn popup(app: &App) -> Option> { if p.layer_of(id).is_some() { entries.push(item("Create relationship…", Message::AddRelation, true)); entries.push(item( - "Create counterpart / review prose…", + if p.layer_of(id) == Some(Layer::Oa) { + "Create System counterpart…" + } else { + "Create Operational counterpart…" + }, Message::StartTransition, true, )); entries.push(item( - "Review semantics…", + "Review counterpart changes…", Message::StartSemanticReview(id), p.traces.values().any(|t| t.oa == id || t.sa == id), )); diff --git a/crates/oa-desktop/src/recovery.rs b/crates/oa-desktop/src/recovery.rs index c719ba71..2994a4cf 100644 --- a/crates/oa-desktop/src/recovery.rs +++ b/crates/oa-desktop/src/recovery.rs @@ -17,6 +17,8 @@ const MAX_BYTES: u64 = 256 * 1024 * 1024; mod source5; #[cfg(test)] mod source5_tests; +#[cfg(test)] +mod source7_tests; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Snapshot { @@ -31,9 +33,16 @@ pub struct Snapshot { behavior: Option, #[serde(default)] workflow: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "architecture_draft" + )] + architecture: Option, selected: Id, } pub struct Restored { + pub architecture: Option, pub workflow: Option, pub document: Document, pub workspace: Workspace, @@ -41,6 +50,12 @@ pub struct Restored { pub behavior: crate::behavior::State, pub selected: Id, } +fn architecture_draft<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + // Omission is the legacy shape. An explicit new null field is not erased. + crate::app::architecture_authoring::Saved::deserialize(deserializer).map(Some) +} #[derive(Debug, Clone)] pub struct Entry { pub id: Id, @@ -111,6 +126,7 @@ impl Snapshot { property: property.filter(|d| d.dirty()).map(Draft::recovery_snapshot), behavior: behavior.recovery_snapshot()?, workflow: None, + architecture: None, selected, }) } @@ -123,6 +139,13 @@ impl Snapshot { .transpose()?; Ok(self) } + pub fn with_architecture( + mut self, + state: Option<&crate::app::architecture_authoring::State>, + ) -> Self { + self.architecture = state.map(|s| s.saved.clone()); + self + } pub fn restore(self) -> Result { if self.version != 1 { return Err("This recovery checkpoint uses an unsupported version.".into()); @@ -130,6 +153,23 @@ impl Snapshot { let mut document = Document::from_bundle(self.accepted.clone(), LIMITS).map_err(|e| e.to_string())?; let legacy_base = source5::upgrade_binding(&self.accepted, &document)?; + if legacy_base.is_some() && self.architecture.is_some() { + return Err( + "Architecture authoring recovery requires its original current-source baseline." + .into(), + ); + } + if let Some(saved) = &self.architecture { + crate::app::architecture_authoring::validate_saved(document.project(), saved)?; + } + if legacy_base.is_some() { + if let Some(property) = &self.property { + validate_pre7_intent(&serde_json::to_value(property).map_err(|e| e.to_string())?)?; + } + for value in [&self.behavior, &self.workflow].into_iter().flatten() { + validate_pre7_intent(value)?; + } + } let mut saved_workspace = self.workspace; if legacy_base.is_some() { saved_workspace.upgrade_clean_buffers(&self.accepted, document.bundle()); @@ -156,6 +196,7 @@ impl Snapshot { } document.mark_recovered(); Ok(Restored { + architecture: self.architecture, workflow, document, workspace, @@ -165,6 +206,82 @@ impl Snapshot { }) } } +/// Private draft payloads use closed typed records, not arbitrary JSON documents. +/// Reject new structural intent before serde defaults can erase its presence. +/// Only known structural tag/address positions are checked. Authored source, +/// names, expressions and raw input strings are never scanned. +pub(crate) fn validate_pre7_intent(value: &serde_json::Value) -> Result<(), String> { + use serde_json::Value; + const CONTEXT: [&str; 4] = [ + "ContextOwner", + "ContextSource", + "ContextTarget", + "ContextPurpose", + ]; + match value { + Value::Object(fields) => { + let new_field = fields.keys().any(|key| { + matches!( + key.as_str(), + "operation" + | "disabled_operation" + | "contextual" + | "operation_omissions" + | "operation_omission" + | "ContextualLink" + ) + }); + let new_tag = fields.get("kind").and_then(Value::as_str) == Some("ContextualLink") + || fields.get("tool").and_then(Value::as_str) == Some("Contextual"); + // Constructor caches retain inactive raw branches. Their addresses, + // and input-map keys, are structural paths rather than user prose. + let new_path = fields + .get("inputs") + .and_then(Value::as_object) + .is_some_and(|inputs| inputs.keys().any(|key| source7_draft_path(key))) + || fields + .get("constructors") + .and_then(Value::as_array) + .is_some_and(|rows| { + rows.iter().any(|row| { + row.get(0) + .and_then(Value::as_str) + .is_some_and(source7_draft_path) + || row.get(3).and_then(Value::as_object).is_some_and(|inputs| { + inputs.keys().any(|key| source7_draft_path(key)) + }) + }) + }); + let new_facet = fields + .get("Occurrence") + .is_some_and(|v| v.as_str() == Some("Operation") || v.get("Operation").is_some()) + || fields.get("Relation").is_some_and(|v| { + v.as_str() + .is_some_and(|s| s == "ContextualLink" || CONTEXT.contains(&s)) + || CONTEXT.iter().any(|key| v.get(*key).is_some()) + }); + if new_field || new_tag || new_path || new_facet { + return Err("Legacy checkpoint contains source-7 draft fields; it has been retained without interpreting new intent.".into()); + } + for child in fields.values() { + validate_pre7_intent(child)?; + } + } + Value::Array(children) => { + for child in children { + validate_pre7_intent(child)?; + } + } + _ => {} + } + Ok(()) +} +fn source7_draft_path(path: &str) -> bool { + path.ends_with("/ContextualLink") + || path.contains("/ContextualLink/") + || path.ends_with("/Exchange/operation") + || path.contains("/Exchange/operation/") +} fn path(directory: &Path, id: Id) -> PathBuf { directory.join(format!("{id}.json")) } diff --git a/crates/oa-desktop/src/recovery/fixtures/README.md b/crates/oa-desktop/src/recovery/fixtures/README.md new file mode 100644 index 00000000..ced1c32c --- /dev/null +++ b/crates/oa-desktop/src/recovery/fixtures/README.md @@ -0,0 +1,7 @@ +These are frozen historical source-5/source-6 recovery profiles. Do not update them to the current schema or regenerate them with a newer serializer. + +`harness/recovery-profile-fixtures.rs` produced them using the actual schema-6 core library on 2026-09-06. The generator explicitly refuses any other current schema. The library SHA256 was `E53A68018AB470F3D1E3C024A06FC0B5F1618D1495E6095716E9C4D9105680E0`; generation output is retained in `.local/source7-recovery-frozen-profiles.log`. Model JSON uses exact historical struct field order, not alphabetically reordered JSON. `profiles.json` records SHA256 and independent Python FNV-1a checks of the frozen bytes; tests compare the literal FNV values against both the historical bytes and the new recovery adapter. + +Both profiles retain 30 authored modules, Unicode comments, mixed CRLF/LF, Call/Reply with values, SequenceLinks, and more than 60 semantic traces. Source 6 retains communication means, exchange categories and nondefault view selectors. Source 5 removes those unavailable records, uses activity-viewpoint operational messages and the historical diagram shape without view options; SA typed ports/interface messages remain. Its model was independently admitted and compared with its compiled source using the historical decoder. + +Recovery must alter only the entry version token, preserve all other source bytes, reproduce the model admitted by the current historical decoder, bind authored drafts using the corresponding exact old fingerprint, and replan without restoring executable commands or prior impact approvals. diff --git a/crates/oa-desktop/src/recovery/fixtures/profiles.json b/crates/oa-desktop/src/recovery/fixtures/profiles.json new file mode 100644 index 00000000..2d0120fa --- /dev/null +++ b/crates/oa-desktop/src/recovery/fixtures/profiles.json @@ -0,0 +1,12 @@ +{ + "5": { + "fingerprint": "31b56f1d17c2d9cf", + "model_sha256": "5535a706df9ec21715e8b6a5aa6dbd79636ae461450aee61305559016167612b", + "bundle_sha256": "12715995e6f7f35414aea53b3ad720b9b31873ffdeaf4444231db1a5ac3c463e" + }, + "6": { + "fingerprint": "88c1149e445e8676", + "model_sha256": "5bb8db0b6394276666323483f977ac3586f88554c81a148c6d508fca0ef4576f", + "bundle_sha256": "ea88890a33f47d67dfb06d6dbbcc179058cf28dad76ae7d615c00aaf30dbbbfe" + } +} diff --git a/crates/oa-desktop/src/recovery/fixtures/source5-bundle.json b/crates/oa-desktop/src/recovery/fixtures/source5-bundle.json new file mode 100644 index 00000000..c685e056 --- /dev/null +++ b/crates/oa-desktop/src/recovery/fixtures/source5-bundle.json @@ -0,0 +1,35 @@ +{ + "entry": "project.arcadia", + "files": { + "oa/activities/activity_oa_restore_work.arcadia": "oa_activity oa_restore_work \"Restore safe pressure\" id \"1b91f8a7-0bdb-514e-b1cd-955658e09927\" {\n description \"\";\n parent none;\n}\n\noa_activity oa_assess \"Assess pressure alarm\" id \"61f7bbfb-74a4-5540-9182-2583dfb27a49\" {\n description \"\";\n parent @oa_restore_work;\n}\n\noa_activity oa_confirm \"Confirm safe pressure\" id \"727c0b5c-8db6-5eb7-83d6-735519c5949f\" {\n description \"\";\n parent @oa_restore_work;\n}\n\noa_activity oa_repair \"Repair valve in the field\" id \"a21465af-05f3-5fb5-8983-de26932422fb\" {\n description \"\";\n parent @oa_restore_work;\n}\n\noa_activity oa_reset \"Reset valve remotely\" id \"d0c8be87-d238-503a-a549-7558a210507f\" {\n description \"\";\n parent @oa_restore_work;\n}\n\ncapability_involvement oa_assessment_support \"Capability involvement: Assess an alarm\" id \"ffdbfcd0-d423-5707-a880-0dfbe1400f72\" {\n description \"\";\n source @oa_assess;\n target @oa_triage;\n}\n\ninteraction oa_repair_request \"Request field repair\" id \"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d\" {\n description \"\";\n items [@oa_repair_operation];\n source @oa_assess;\n target @oa_repair;\n}\n\ninteraction oa_repair_report \"Report repair result\" id \"b436d0b3-c824-5a96-af6b-2c269816d1cc\" {\n description \"\";\n items [@oa_repair_operation];\n source @oa_repair;\n target @oa_confirm;\n}\n\n", + "oa/behavior/process_oa_assessment_procedure.arcadia": "oa_process oa_assessment_procedure \"Assess an alarm procedure\" id \"022d3cfa-bbaf-5e13-924a-8e55099b8745\" {\n description \"\";\n parent none;\n steps [@oa_assessment_action];\n}\n\noa_occurrence oa_assessment_action \"Assess pressure alarm\" id \"942975b8-0bef-5ae8-9fcb-c37497833fb0\" {\n body {\n Action {\n action @oa_assess;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_assessment_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nchain_involvement oa_assessment_delivery \"Chain involvement: Assess an alarm\" id \"628cb4fc-fe63-5f6b-b364-6268f26b5740\" {\n description \"\";\n source @oa_assessment_procedure;\n target @oa_triage;\n}\n\n", + "oa/behavior/process_oa_field_procedure.arcadia": "oa_process oa_field_procedure \"Field repair procedure\" id \"5d3af417-244c-54bb-9588-16daedda4e1f\" {\n description \"\";\n parent none;\n steps [@oa_request_step, @oa_repair_step, @oa_report_step];\n}\n\noa_occurrence oa_repair_step \"Repair the valve\" id \"c59432fe-6092-5ee0-908d-9dbf39476471\" {\n body {\n Action {\n action @oa_repair;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_report_step \"Report the measured result\" id \"df2fd3f8-6bc2-5380-a13f-36643ed35ac7\" {\n body {\n Exchange {\n exchange @oa_repair_report;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @oa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_request_step \"Request the field repair\" id \"5ba8079c-2dbe-5e98-ae82-2b60c6020a02\" {\n body {\n Exchange {\n exchange @oa_repair_request;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @oa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link oa_repair_then_report \"Sequence link: Report the measured result\" id \"c8196d35-2168-5fef-8b03-ba0d139bc00f\" {\n description \"\";\n guard \"\";\n source @oa_repair_step;\n target @oa_report_step;\n}\n\nsequence_link oa_request_then_repair \"Sequence link: Repair the valve\" id \"3f54d890-4ca5-5e36-873b-abb5c92d578c\" {\n description \"\";\n guard \"\";\n source @oa_request_step;\n target @oa_repair_step;\n}\n\n", + "oa/behavior/process_oa_response.arcadia": "oa_process oa_response \"Handle a pressure alarm\" id \"58bae2b0-f560-50f2-818d-19091d22d18a\" {\n description \"\";\n parent none;\n steps [@oa_assess_step, @oa_decision, @oa_reset_step, @oa_use_field, @oa_merge, @oa_confirm_step];\n}\n\noa_occurrence oa_assess_step \"Assess the alarm\" id \"0b07b9ed-492d-57f6-b0b1-ce8a709238a7\" {\n body {\n SequenceReference {\n sequence @oa_assessment_procedure;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_confirm_step \"Confirm safe pressure\" id \"e00594a3-6122-5a6b-b3c2-ecb5506c741d\" {\n body {\n Action {\n action @oa_confirm;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_decision \"Can a remote reset safely recover pressure?\" id \"b280c6ca-e5f9-5424-a5c1-5676e01c2371\" {\n body {\n Control {\n kind Choice;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_merge \"Recovery path completed\" id \"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d\" {\n body {\n Control {\n kind Merge;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_reset_step \"Try the authorized remote reset\" id \"d2026fc2-74a8-5e22-9942-ff3eb68a3677\" {\n body {\n Action {\n action @oa_reset;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_use_field \"Use the field repair procedure\" id \"fc6763bf-a534-54bf-afcd-57ab285ff07d\" {\n body {\n SequenceReference {\n sequence @oa_field_procedure;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link oa_assess_then_decide \"Continue\" id \"109e2d6b-7e86-5934-93a5-fab56eced84e\" {\n description \"\";\n guard \"\";\n source @oa_assess_step;\n target @oa_decision;\n}\n\nsequence_link oa_field_path \"else\" id \"c1adbbd0-a762-5ee3-8b9a-5c18af377771\" {\n description \"\";\n guard \"else\";\n source @oa_decision;\n target @oa_use_field;\n}\n\nsequence_link oa_remote_path \"remote reset is authorized\" id \"76218d89-68fd-531d-b35b-90c5b769a5ef\" {\n description \"\";\n guard \"remote reset is authorized\";\n source @oa_decision;\n target @oa_reset_step;\n}\n\nsequence_link oa_confirm_path \"Continue\" id \"229ee94f-d147-50f6-9769-b5bd0c03a084\" {\n description \"\";\n guard \"\";\n source @oa_merge;\n target @oa_confirm_step;\n}\n\nsequence_link oa_remote_merge \"Continue\" id \"bac43d3b-910b-5bdc-be9b-f78cf973e1ed\" {\n description \"\";\n guard \"\";\n source @oa_reset_step;\n target @oa_merge;\n}\n\nchain_involvement oa_response_delivery \"Chain involvement: Restore a safe supply\" id \"c7c248f8-28b8-55be-9023-0991d44021e3\" {\n description \"\";\n source @oa_response;\n target @oa_restore;\n}\n\nsequence_link oa_field_merge \"Continue\" id \"c8593670-8906-530f-b6a2-3ada77a9c219\" {\n description \"\";\n guard \"\";\n source @oa_use_field;\n target @oa_merge;\n}\n\n", + "oa/behavior/scenario_oa_conversation.arcadia": "oa_scenario oa_conversation \"Repair request and measured reply\" id \"20472233-5ce6-5ffe-b4ad-0bdbccf3e423\" {\n context @oa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@oa_call, @oa_reply];\n viewpoint OperationalActivity;\n}\n\noa_occurrence oa_call \"Request valve repair\" id \"b97437c6-cd7a-5a05-b537-b30a17dd936a\" {\n body {\n Exchange {\n exchange @oa_repair_request;\n message_kind Call;\n receiver @oa_responder;\n reply_to none;\n sender @oa_caller;\n values [{\n expression \"420.0\";\n field @oa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @oa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\noa_lifeline oa_caller \"Controller\" id \"41859a65-ded2-547b-ab03-14e2fa740cf9\" {\n description \"\";\n parent @oa_conversation;\n represented @oa_assess;\n}\n\noa_occurrence oa_reply \"Return pressure after repair\" id \"cc2d497a-fd15-5455-9ee5-e70eb95811d8\" {\n body {\n Exchange {\n exchange @oa_repair_request;\n message_kind Reply;\n receiver @oa_caller;\n reply_to @oa_call;\n sender @oa_responder;\n values [{\n expression \"320.0\";\n field @oa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @oa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\noa_lifeline oa_responder \"Technician\" id \"fc81c5f0-0442-5b88-992b-e5b10f351c59\" {\n description \"\";\n parent @oa_conversation;\n represented @oa_repair;\n}\n\n", + "oa/behavior/scenario_oa_full_scenario.arcadia": "oa_scenario oa_full_scenario \"Restore supply through field repair\" id \"d6f881a4-95da-54e9-ad97-1b8965532629\" {\n context @oa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@oa_use_conversation];\n viewpoint OperationalActivity;\n}\n\noa_lifeline oa_outer_controller \"Controller\" id \"2011b3ac-614b-5a47-8891-7ff81965f425\" {\n description \"\";\n parent @oa_full_scenario;\n represented @oa_assess;\n}\n\noa_lifeline oa_outer_technician \"Technician\" id \"fffff882-78fd-5415-928d-c1ef7fac6576\" {\n description \"\";\n parent @oa_full_scenario;\n represented @oa_repair;\n}\n\noa_occurrence oa_use_conversation \"Use the reviewed repair conversation\" id \"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5\" {\n body {\n ScenarioUse {\n bindings [{\n local @oa_outer_controller;\n referenced @oa_caller;\n }, {\n local @oa_outer_technician;\n referenced @oa_responder;\n }];\n scenario @oa_conversation;\n };\n };\n description \"\";\n parent @oa_full_scenario;\n postcondition \"\";\n precondition \"\";\n}\n\n", + "oa/behavior/state_machine_oa_monitor.arcadia": "oa_state_machine oa_monitor \"Pressure supervision\" id \"1970608f-85d1-5234-ac86-5e2b0df27dc3\" {\n description \"\";\n parent @oa_control;\n}\n\noa_region oa_monitor_region \"Supervision cycle\" id \"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7\" {\n description \"\";\n parent @oa_monitor;\n}\n\noa_initial oa_monitor_initial \"Start\" id \"a7ffb869-9dde-5e76-b276-c719c5c143a9\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind Initial;\n parent @oa_monitor_region;\n}\n\noa_state oa_monitoring \"Monitoring\" id \"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @oa_monitor_region;\n}\n\noa_state oa_waiting \"Awaiting repair\" id \"58c15833-6e75-523a-b57f-a88f19f6d672\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @oa_monitor_region;\n}\n\nstate_transition oa_start_monitoring \"State transition: Monitoring\" id \"2b00270c-79a5-52a5-af96-33346bea531b\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @oa_monitor_initial;\n target @oa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\nstate_transition oa_alarm_transition \"State transition: Awaiting repair\" id \"fbde9d5e-c7e9-55db-a2be-497345c491b3\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @oa_monitoring;\n target @oa_waiting;\n transition_kind External;\n trigger \"\";\n triggers [@oa_high_pressure];\n}\n\nstate_action oa_assess_on_entry \"Assess when repair is needed\" id \"7fa37e63-db65-5aa7-88f2-31ff4347a787\" {\n description \"\";\n phase Entry;\n source @oa_waiting;\n target @oa_assess;\n}\n\nstate_transition oa_normal_transition \"State transition: Monitoring\" id \"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"repair confirmed and pressure is safe\";\n source @oa_waiting;\n target @oa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\n", + "oa/capabilities.arcadia": "oa_mission oa_mission \"Maintain safe water supply\" id \"c9c61f50-06e0-5d17-973d-dcc57cd5cc90\" {\n description \"\";\n parent none;\n}\n\noa_capability oa_restore \"Restore a safe supply\" id \"3dfee23a-148b-5cec-ad2d-b774e84ea5bb\" {\n description \"\";\n parent none;\n}\n\noa_capability oa_triage \"Assess an alarm\" id \"6a30b763-cc51-546d-907f-5c63657cfd03\" {\n description \"\";\n parent none;\n}\n\nmission_capability oa_mission_delivery \"Mission capability: Restore a safe supply\" id \"1f8b1898-65f6-5360-99cf-b78a71bd50ec\" {\n description \"\";\n source @oa_mission;\n target @oa_restore;\n}\n\ncapability_include oa_include_triage \"Capability include: Assess an alarm\" id \"e21d9a54-ae8e-5618-a4f8-67ee22b92638\" {\n description \"\";\n source @oa_restore;\n target @oa_triage;\n}\n\n", + "oa/model.arcadia": "oa_event oa_high_pressure \"Pressure alarm detected\" id \"65d6b8d6-271a-5c84-8b42-abbe310c955e\" {\n body {\n Change {\n expression \"measured_pressure > operating_limit\";\n };\n };\n description \"\";\n parent none;\n}\n\n", + "oa/participants.arcadia": "oa_entity oa_control \"Control room\" id \"32aa9940-df6e-5a12-b9bd-6337e2d2ed22\" {\n description \"\";\n parent none;\n}\n\noa_actor oa_technician \"Field technician\" id \"e07c3952-4a1a-5cc7-b7d5-03a01101ce49\" {\n description \"\";\n parent none;\n}\n\nallocation oa_assess_allocation \"Allocation: Assess pressure alarm\" id \"594c61cd-94e0-5c07-890d-edfd520d7df9\" {\n description \"\";\n source @oa_control;\n target @oa_assess;\n}\n\nallocation oa_confirm_allocation \"Allocation: Confirm safe pressure\" id \"bfd4326e-ea65-5269-b5e6-e063a355d1e9\" {\n description \"\";\n source @oa_control;\n target @oa_confirm;\n}\n\nmission_involvement oa_mission_participant \"Mission involvement: Maintain safe water supply\" id \"0c4c9e58-ff32-5c00-b82a-d408a4be0183\" {\n description \"\";\n source @oa_control;\n target @oa_mission;\n}\n\nallocation oa_reset_allocation \"Allocation: Reset valve remotely\" id \"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a\" {\n description \"\";\n source @oa_control;\n target @oa_reset;\n}\n\nallocation oa_repair_allocation \"Allocation: Repair valve in the field\" id \"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2\" {\n description \"\";\n source @oa_technician;\n target @oa_repair;\n}\n\n", + "oa/types.arcadia": "oa_data_type oa_pressure \"Measured pressure\" id \"e7824764-5db9-5384-8198-2fbaea2b10b2\" {\n default none;\n definition {\n Real {\n maximum 600.0;\n minimum 0.0;\n };\n };\n description \"\";\n parent none;\n unit \"kPa\";\n}\n\noa_exchange_item oa_repair_operation \"Valve repair\" id \"d736c7d6-d369-550a-8ba4-dae223e40bb3\" {\n description \"\";\n kind Information;\n mechanism Operation;\n parent none;\n supertype none;\n}\n\noa_data_field oa_pressure_parameter \"Pressure before / after repair\" id \"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7\" {\n default none;\n description \"\";\n direction InOut;\n maximum 1;\n minimum 1;\n parent @oa_repair_operation;\n storage Value;\n type_ref {\n Named @oa_pressure;\n };\n unit \"\";\n}\n\n", + "project.arcadia": "// Frozen legacy recovery input 雨.\r\narcadia // profile\r\n 5;\nproject pressure_restoration \"Restore safe water pressure — release 1 walkthrough\" id \"2786d72f-5763-5f33-a57f-2773a4ca1a6c\" {\n description \"A controller either resets a valve remotely or requests field repair. Compare the operational need with the explicitly chosen coordination-service boundary. Start with README.md beside project.arcadia. Units and guards are descriptive; this model does not simulate a real water network.\";\n include \"oa/activities/activity_oa_restore_work.arcadia\";\n include \"oa/behavior/process_oa_assessment_procedure.arcadia\";\n include \"oa/behavior/process_oa_field_procedure.arcadia\";\n include \"oa/behavior/process_oa_response.arcadia\";\n include \"oa/behavior/scenario_oa_conversation.arcadia\";\n include \"oa/behavior/scenario_oa_full_scenario.arcadia\";\n include \"oa/behavior/state_machine_oa_monitor.arcadia\";\n include \"oa/capabilities.arcadia\";\n include \"oa/model.arcadia\";\n include \"oa/participants.arcadia\";\n include \"oa/types.arcadia\";\n include \"sa/behavior/process_sa_assessment_procedure.arcadia\";\n include \"sa/behavior/process_sa_field_procedure.arcadia\";\n include \"sa/behavior/process_sa_response.arcadia\";\n include \"sa/behavior/scenario_sa_conversation.arcadia\";\n include \"sa/behavior/scenario_sa_full_scenario.arcadia\";\n include \"sa/behavior/state_machine_sa_monitor.arcadia\";\n include \"sa/capabilities.arcadia\";\n include \"sa/functions/function_sa_audit_thresholds.arcadia\";\n include \"sa/functions/function_sa_restore_work.arcadia\";\n include \"sa/model.arcadia\";\n include \"sa/participants.arcadia\";\n include \"sa/types.arcadia\";\n include \"traceability.arcadia\";\n include \"views/diagram_oa_allocation_view.arcadia\";\n include \"views/diagram_sa_allocation_view.arcadia\";\n include \"views/diagram_sa_capability_view.arcadia\";\n include \"views/diagram_sa_service_view.arcadia\";\n include \"views/diagram_sa_state_view.arcadia\";\n}\n", + "sa/behavior/process_sa_assessment_procedure.arcadia": "sa_functional_chain sa_assessment_procedure \"Assess an alarm procedure\" id \"acd5a018-f559-5298-a40d-1cf6e1353e74\" {\n description \"\";\n parent none;\n steps [@sa_assessment_action];\n}\n\nsa_occurrence sa_assessment_action \"Assess pressure alarm\" id \"bae5b192-5b8e-51c6-82ae-2331af0d33bf\" {\n body {\n Action {\n action @sa_assess;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_assessment_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nchain_involvement sa_assessment_delivery \"Chain involvement: Assess an alarm\" id \"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57\" {\n description \"\";\n source @sa_assessment_procedure;\n target @sa_triage;\n}\n\n", + "sa/behavior/process_sa_field_procedure.arcadia": "sa_functional_chain sa_field_procedure \"Field repair procedure\" id \"93eb6479-54e9-59cb-ac7b-16912347bfc0\" {\n description \"\";\n parent none;\n steps [@sa_request_step, @sa_repair_step, @sa_report_step];\n}\n\nsa_occurrence sa_repair_step \"Repair the valve\" id \"0f195fea-3493-5ebd-966d-da9eddeb05d7\" {\n body {\n Action {\n action @sa_repair;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_report_step \"Report the measured result\" id \"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac\" {\n body {\n Exchange {\n exchange @sa_repair_report;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @sa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_request_step \"Request the field repair\" id \"40d94322-baa2-5c81-b261-ff47cdc33782\" {\n body {\n Exchange {\n exchange @sa_repair_request;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @sa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link sa_repair_then_report \"Sequence link: Report the measured result\" id \"7fb30b1d-195c-5f0e-a19c-a536f519a899\" {\n description \"\";\n guard \"\";\n source @sa_repair_step;\n target @sa_report_step;\n}\n\nsequence_link sa_request_then_repair \"Sequence link: Repair the valve\" id \"6a6d968f-26d6-5dbb-b721-5a27d362779a\" {\n description \"\";\n guard \"\";\n source @sa_request_step;\n target @sa_repair_step;\n}\n\n", + "sa/behavior/process_sa_response.arcadia": "sa_functional_chain sa_response \"Handle a pressure alarm\" id \"5139ce73-56fd-5875-ba3a-e569fc890231\" {\n description \"\";\n parent none;\n steps [@sa_assess_step, @sa_decision, @sa_reset_step, @sa_use_field, @sa_merge, @sa_confirm_step];\n}\n\nsa_occurrence sa_assess_step \"Assess the alarm\" id \"9024243c-ccc1-5edb-8477-2aa16e5e9a50\" {\n body {\n SequenceReference {\n sequence @sa_assessment_procedure;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_confirm_step \"Confirm safe pressure\" id \"47bec4f9-d6d0-5ed1-adca-fa0d960dc416\" {\n body {\n Action {\n action @sa_confirm;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_decision \"Can a remote reset safely recover pressure?\" id \"30c8fad9-a49b-5692-9542-3148e76afd7b\" {\n body {\n Control {\n kind Choice;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_merge \"Recovery path completed\" id \"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07\" {\n body {\n Control {\n kind Merge;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_reset_step \"Try the authorized remote reset\" id \"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6\" {\n body {\n Action {\n action @sa_reset;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_use_field \"Use the field repair procedure\" id \"d5e61c5d-510b-5091-a878-da46f3a9e152\" {\n body {\n SequenceReference {\n sequence @sa_field_procedure;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link sa_assess_then_decide \"Continue\" id \"9d4a131f-36ad-5125-b21f-c3dd033b8c82\" {\n description \"\";\n guard \"\";\n source @sa_assess_step;\n target @sa_decision;\n}\n\nsequence_link sa_field_path \"else\" id \"1bbd5957-1173-51d8-97e3-ba4f084227ae\" {\n description \"\";\n guard \"else\";\n source @sa_decision;\n target @sa_use_field;\n}\n\nsequence_link sa_remote_path \"remote reset is authorized\" id \"d8b6710a-e713-5979-934f-e5ca7d155bb4\" {\n description \"\";\n guard \"remote reset is authorized\";\n source @sa_decision;\n target @sa_reset_step;\n}\n\nsequence_link sa_confirm_path \"Continue\" id \"1e738f27-a3a1-526d-b330-6384088b9689\" {\n description \"\";\n guard \"\";\n source @sa_merge;\n target @sa_confirm_step;\n}\n\nsequence_link sa_remote_merge \"Continue\" id \"e5279265-8eac-52e8-98dc-571b20475e1f\" {\n description \"\";\n guard \"\";\n source @sa_reset_step;\n target @sa_merge;\n}\n\nchain_involvement sa_response_delivery \"Chain involvement: Restore a safe supply\" id \"66efc484-53c7-51a3-89e9-422429344b38\" {\n description \"\";\n source @sa_response;\n target @sa_restore;\n}\n\nsequence_link sa_field_merge \"Continue\" id \"0f6ac3fb-4ad6-581e-ad14-336472072d5e\" {\n description \"\";\n guard \"\";\n source @sa_use_field;\n target @sa_merge;\n}\n\n", + "sa/behavior/scenario_sa_conversation.arcadia": "sa_scenario sa_conversation \"Repair request and measured reply\" id \"730b27ca-b039-5a3b-8dc6-03418f9061b3\" {\n context @sa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@sa_call, @sa_reply];\n viewpoint BoundaryInterface;\n}\n\nsa_occurrence sa_call \"Request valve repair\" id \"8bab4783-a098-5984-9e36-493957caf26c\" {\n body {\n Exchange {\n exchange @sa_contact;\n message_kind Call;\n receiver @sa_responder;\n reply_to none;\n sender @sa_caller;\n values [{\n expression \"420.0\";\n field @sa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @sa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_lifeline sa_caller \"Controller\" id \"98c61788-2fee-51f4-8cf9-de48e8c70e9e\" {\n description \"\";\n parent @sa_conversation;\n represented @sa_control;\n}\n\nsa_occurrence sa_reply \"Return pressure after repair\" id \"4e1c0a56-ce80-5f13-a93a-6956de54880c\" {\n body {\n Exchange {\n exchange @sa_contact;\n message_kind Reply;\n receiver @sa_caller;\n reply_to @sa_call;\n sender @sa_responder;\n values [{\n expression \"320.0\";\n field @sa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @sa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_lifeline sa_responder \"Technician\" id \"6249b54a-e396-5338-82b9-89266c55670d\" {\n description \"\";\n parent @sa_conversation;\n represented @sa_technician;\n}\n\nartifact repair_test_evidence \"Repair conversation acceptance notes\" id \"f5922bf7-05d8-590f-86e9-1d09f37f4fa6\" {\n description \"A local teaching artifact, not proof of a real valve's safety.\";\n fragment \"acceptance\";\n owner @sa_conversation;\n relation Verifies;\n revision \"lesson-1\";\n uri \"evidence/repair-check.md\";\n}\n\n", + "sa/behavior/scenario_sa_full_scenario.arcadia": "sa_scenario sa_full_scenario \"Restore supply through field repair\" id \"f746a1cd-ff9f-55c0-b325-01c03d2f4fae\" {\n context @sa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@sa_use_conversation];\n viewpoint BoundaryInterface;\n}\n\nsa_lifeline sa_outer_controller \"Controller\" id \"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023\" {\n description \"\";\n parent @sa_full_scenario;\n represented @sa_control;\n}\n\nsa_lifeline sa_outer_technician \"Technician\" id \"155e4fe4-9e3b-5f4e-b886-28abcc3f086b\" {\n description \"\";\n parent @sa_full_scenario;\n represented @sa_technician;\n}\n\nsa_occurrence sa_use_conversation \"Use the reviewed repair conversation\" id \"1c9da843-e2d3-5591-8e3f-db083a42867e\" {\n body {\n ScenarioUse {\n bindings [{\n local @sa_outer_controller;\n referenced @sa_caller;\n }, {\n local @sa_outer_technician;\n referenced @sa_responder;\n }];\n scenario @sa_conversation;\n };\n };\n description \"\";\n parent @sa_full_scenario;\n postcondition \"\";\n precondition \"\";\n}\n\n", + "sa/behavior/state_machine_sa_monitor.arcadia": "sa_state_machine sa_monitor \"Pressure supervision\" id \"132b3f28-3f88-5327-9868-8c8b645ed412\" {\n description \"\";\n parent @sa_control;\n}\n\nsa_region sa_monitor_region \"Supervision cycle\" id \"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c\" {\n description \"\";\n parent @sa_monitor;\n}\n\nsa_initial sa_monitor_initial \"Start\" id \"57eb5348-14d8-560e-996a-8dbf993243e2\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind Initial;\n parent @sa_monitor_region;\n}\n\nsa_state sa_monitoring \"Monitoring\" id \"8af21f9b-c21e-5aae-af0d-0958840f6655\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @sa_monitor_region;\n}\n\nsa_state sa_waiting \"Awaiting repair\" id \"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @sa_monitor_region;\n}\n\nstate_transition sa_start_monitoring \"State transition: Monitoring\" id \"47730532-0372-5cc5-9d6d-0b8953aebdd8\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @sa_monitor_initial;\n target @sa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\nstate_transition sa_alarm_transition \"State transition: Awaiting repair\" id \"13c3bfa4-9254-5ab2-b96f-f65f402adf81\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @sa_monitoring;\n target @sa_waiting;\n transition_kind External;\n trigger \"\";\n triggers [@sa_high_pressure];\n}\n\nstate_action sa_assess_on_entry \"Assess when repair is needed\" id \"a887f96a-37cb-5891-8070-011fa276e5b5\" {\n description \"\";\n phase Entry;\n source @sa_waiting;\n target @sa_assess;\n}\n\nstate_transition sa_normal_transition \"State transition: Monitoring\" id \"6e2c1b47-764c-577a-b325-ff66dd2a2768\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"repair confirmed and pressure is safe\";\n source @sa_waiting;\n target @sa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\n", + "sa/capabilities.arcadia": "sa_mission sa_mission \"Maintain safe water supply\" id \"5ad034b1-084d-5273-9e70-971671766132\" {\n description \"\";\n parent none;\n}\n\nsa_capability sa_restore \"Restore a safe supply\" id \"81259051-370a-512d-a3b5-6420a6f933b6\" {\n description \"\";\n parent none;\n}\n\nsa_capability sa_triage \"Assess an alarm\" id \"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f\" {\n description \"\";\n parent none;\n}\n\nmission_capability sa_mission_delivery \"Mission capability: Restore a safe supply\" id \"a0902e0f-3d18-576a-9aca-975a136dfdd7\" {\n description \"\";\n source @sa_mission;\n target @sa_restore;\n}\n\ncapability_include sa_include_triage \"Capability include: Assess an alarm\" id \"f3fe7690-734a-5877-8b57-a6599a4033c4\" {\n description \"\";\n source @sa_restore;\n target @sa_triage;\n}\n\n", + "sa/functions/function_sa_audit_thresholds.arcadia": "sa_function sa_audit_thresholds \"Audit pressure thresholds\" id \"4149a872-3440-59de-9879-8981e78d27ab\" {\n description \"Deliberately unfinished: assigned to the service, but no behavior context yet. Model checks should expose this one work_without_behavior_context warning.\";\n parent none;\n}\n\n", + "sa/functions/function_sa_restore_work.arcadia": "sa_function sa_restore_work \"Restore safe pressure\" id \"58867e7d-916e-5ebc-9e4c-8dbed1314be3\" {\n description \"\";\n parent none;\n}\n\nsa_function sa_assess \"Assess pressure alarm\" id \"dddae020-a444-5d48-acc8-139d2696a672\" {\n description \"\";\n parent @sa_restore_work;\n}\n\nsa_port sa_repair_request_out \"Request field repair output\" id \"24a6850e-b819-57e0-af88-ca4ab76581b7\" {\n contract {\n FunctionOutput {\n outgoing_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_assess;\n}\n\nsa_function sa_confirm \"Confirm safe pressure\" id \"d75eb3fa-a27d-52d5-8782-8b342b3865f6\" {\n description \"\";\n parent @sa_restore_work;\n}\n\nsa_port sa_repair_report_in \"Report repair result input\" id \"391af7c4-b07d-53a7-8ddd-5c81461fc4f8\" {\n contract {\n FunctionInput {\n incoming_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_confirm;\n}\n\nsa_function sa_repair \"Repair valve in the field\" id \"9497e10f-04c4-5586-8738-2464a6f00bd4\" {\n description \"\";\n parent @sa_restore_work;\n}\n\nsa_port sa_repair_report_out \"Report repair result output\" id \"994faed7-dbc8-5b96-9d62-7604ac0fbc3a\" {\n contract {\n FunctionOutput {\n outgoing_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_repair;\n}\n\nsa_port sa_repair_request_in \"Request field repair input\" id \"d2113fb0-a531-54ef-9484-13951b00ab8f\" {\n contract {\n FunctionInput {\n incoming_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_repair;\n}\n\nsa_function sa_reset \"Reset valve remotely\" id \"09342ccb-044b-5483-ac58-87dc3cf7831c\" {\n description \"\";\n parent @sa_restore_work;\n}\n\ncapability_involvement sa_assessment_support \"Capability involvement: Assess an alarm\" id \"93eaad8d-83b7-5980-b9bb-86c9e3f31da4\" {\n description \"\";\n source @sa_assess;\n target @sa_triage;\n}\n\nfunctional_exchange sa_repair_request \"Request field repair\" id \"93474111-f23e-56c1-b61b-b7c2396b91bd\" {\n description \"\";\n items [@sa_repair_operation];\n source @sa_assess;\n source_port @sa_repair_request_out;\n target @sa_repair;\n target_port @sa_repair_request_in;\n}\n\nfunctional_exchange sa_repair_report \"Report repair result\" id \"684eadc3-e670-5779-a60f-bbc9a5bfb062\" {\n description \"\";\n items [@sa_repair_operation];\n source @sa_repair;\n source_port @sa_repair_report_out;\n target @sa_confirm;\n target_port @sa_repair_report_in;\n}\n\n", + "sa/model.arcadia": "sa_event sa_high_pressure \"Pressure alarm detected\" id \"01490fa8-2f01-5ac8-9722-d55d45414379\" {\n body {\n Change {\n expression \"measured_pressure > operating_limit\";\n };\n };\n description \"\";\n parent none;\n}\n\n", + "sa/participants.arcadia": "sa_system sa_control \"Pressure coordination service\" id \"435bf87c-73fd-5e4d-978a-4c471bc9eaf0\" {\n description \"\";\n parent none;\n}\n\nsa_port sa_control_port \"Repair service client\" id \"98d411e7-7c65-5c3f-b543-8bf0f2522ea2\" {\n contract {\n Component {\n kind Standard;\n orientation Unset;\n };\n };\n description \"Explicitly chosen Standard component port for the repair service.\";\n legacy none;\n parent @sa_control;\n}\n\nsa_actor sa_technician \"Field technician\" id \"c05b9422-b9da-5de5-b21d-07ad23bd2b8a\" {\n description \"\";\n parent none;\n}\n\nsa_port sa_technician_port \"Repair service provider\" id \"e6d69cbd-99d7-596b-bbe3-dada6118a600\" {\n contract {\n Component {\n kind Standard;\n orientation Unset;\n };\n };\n description \"Explicitly chosen Standard component port for the repair service.\";\n legacy none;\n parent @sa_technician;\n}\n\nallocation sa_assess_allocation \"Allocation: Assess pressure alarm\" id \"9b9356d7-2db2-5a94-a47e-ea892cf02afe\" {\n description \"\";\n source @sa_control;\n target @sa_assess;\n}\n\nallocation sa_audit_allocation \"Allocation: Audit pressure thresholds\" id \"1b245065-7242-5d81-8472-aaeb932054c9\" {\n description \"\";\n source @sa_control;\n target @sa_audit_thresholds;\n}\n\nallocation sa_confirm_allocation \"Allocation: Confirm safe pressure\" id \"b76bf8de-2fa4-563f-a540-dd30f30e6c49\" {\n description \"\";\n source @sa_control;\n target @sa_confirm;\n}\n\ninterface_requirement sa_client_requires \"Interface requirement: Valve repair service\" id \"4f85337b-f6eb-533c-a45a-9a5d1d9567ca\" {\n description \"\";\n source @sa_control_port;\n target @sa_repair_interface;\n}\n\nboundary_exchange sa_contact \"Technician service conversation\" id \"63009b5f-539e-5914-b630-ec6b7fefb4d5\" {\n description \"\";\n items [@sa_repair_operation];\n oriented false;\n source @sa_control_port;\n target @sa_technician_port;\n}\n\nexchange_allocation sa_report_carriage \"Exchange allocation: Report repair result\" id \"ff9897b3-a23a-5994-a5b3-99a5b4d34ceb\" {\n description \"\";\n source @sa_contact;\n target @sa_repair_report;\n}\n\nexchange_allocation sa_request_carriage \"Exchange allocation: Request field repair\" id \"4c4c4851-5a3d-5bb9-94df-b19f9cf39819\" {\n description \"\";\n source @sa_contact;\n target @sa_repair_request;\n}\n\nport_allocation sa_port_report_in \"Port allocation: Report repair result input\" id \"568c5c01-de21-52a2-83bd-afdddc158085\" {\n description \"\";\n source @sa_control_port;\n target @sa_repair_report_in;\n}\n\nport_allocation sa_port_request_out \"Port allocation: Request field repair output\" id \"55ec4dbf-bd17-5b0c-b3a3-95b2194aa8b9\" {\n description \"\";\n source @sa_control_port;\n target @sa_repair_request_out;\n}\n\nmission_involvement sa_mission_participant \"Mission involvement: Maintain safe water supply\" id \"1c1e5634-ba8c-591f-b143-90a27cdc6cd2\" {\n description \"\";\n source @sa_control;\n target @sa_mission;\n}\n\nallocation sa_reset_allocation \"Allocation: Reset valve remotely\" id \"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3\" {\n description \"\";\n source @sa_control;\n target @sa_reset;\n}\n\nallocation sa_repair_allocation \"Allocation: Repair valve in the field\" id \"725f2e1b-aa2c-5615-9b1f-28da178168fa\" {\n description \"\";\n source @sa_technician;\n target @sa_repair;\n}\n\nport_allocation sa_port_report_out \"Port allocation: Report repair result output\" id \"e53b7a40-e8a3-5b3f-826d-c2b5c164a84a\" {\n description \"\";\n source @sa_technician_port;\n target @sa_repair_report_out;\n}\n\nport_allocation sa_port_request_in \"Port allocation: Request field repair input\" id \"09eaa992-4cdf-5ea3-b748-f10fd74b8cba\" {\n description \"\";\n source @sa_technician_port;\n target @sa_repair_request_in;\n}\n\ninterface_provision sa_provider_provides \"Interface provision: Valve repair service\" id \"55f89fbf-aa3c-5ae1-95ef-ac676f9c1ada\" {\n description \"\";\n source @sa_technician_port;\n target @sa_repair_interface;\n}\n\n", + "sa/types.arcadia": "sa_data_type sa_pressure \"Measured pressure\" id \"87d0a95b-af1b-5352-b753-e4c6533671a5\" {\n default none;\n definition {\n Real {\n maximum 600.0;\n minimum 0.0;\n };\n };\n description \"\";\n parent none;\n unit \"kPa\";\n}\n\nsa_interface sa_repair_interface \"Valve repair service\" id \"a4b8942e-a975-5f88-ad14-8f38c345baac\" {\n description \"\";\n parent none;\n supertype none;\n}\n\nsa_exchange_item sa_repair_operation \"Valve repair\" id \"a962fc5e-3ab0-5296-8b0d-fd84633f9218\" {\n description \"\";\n kind Information;\n mechanism Operation;\n parent none;\n supertype none;\n}\n\nsa_data_field sa_pressure_parameter \"Pressure before / after repair\" id \"bb12ba5d-aab5-5a1b-99de-c767f5365b57\" {\n default none;\n description \"\";\n direction InOut;\n maximum 1;\n minimum 1;\n parent @sa_repair_operation;\n storage Value;\n type_ref {\n Named @sa_pressure;\n };\n unit \"\";\n}\n\ninterface_item_allocation sa_repair_interface_item \"Repair operation contract\" id \"f074e041-23cf-54ad-af9c-f171e30621e7\" {\n acquisition Accept;\n description \"\";\n order 0;\n source @sa_repair_interface;\n target @sa_repair_operation;\n transmission Synchronous;\n}\n\n", + "traceability.arcadia": "trace trace_oa_alarm_transition \"State transition: Awaiting repair ↔ State transition: Awaiting repair\" id \"0c7c8a06-163e-5b3e-8a98-e66e2ce8963b\" {\n baseline {\n oa {\n description \"\";\n name \"State transition: Awaiting repair\";\n };\n sa {\n description \"\";\n name \"State transition: Awaiting repair\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_alarm_transition;\n sa @sa_alarm_transition;\n}\n\ntrace trace_oa_assess \"Assess pressure alarm ↔ Assess pressure alarm\" id \"5309f955-53fd-5ff4-9c0a-91737e947341\" {\n baseline {\n oa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n sa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_assess;\n sa @sa_assess;\n}\n\ntrace trace_oa_assess_allocation \"Allocation: Assess pressure alarm ↔ Allocation: Assess pressure alarm\" id \"acdbee9c-5017-5154-804e-50801f0376ec\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Assess pressure alarm\";\n };\n sa {\n description \"\";\n name \"Allocation: Assess pressure alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assess_allocation;\n sa @sa_assess_allocation;\n}\n\ntrace trace_oa_assess_on_entry \"Assess when repair is needed ↔ Assess when repair is needed\" id \"fb02420d-b066-5773-87c1-198a056e0731\" {\n baseline {\n oa {\n description \"\";\n name \"Assess when repair is needed\";\n };\n sa {\n description \"\";\n name \"Assess when repair is needed\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assess_on_entry;\n sa @sa_assess_on_entry;\n}\n\ntrace trace_oa_assess_step \"Assess the alarm ↔ Assess the alarm\" id \"e848da41-445e-5e3c-8b4e-ef53fdddf1e1\" {\n baseline {\n oa {\n description \"\";\n name \"Assess the alarm\";\n };\n sa {\n description \"\";\n name \"Assess the alarm\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_assess_step;\n sa @sa_assess_step;\n}\n\ntrace trace_oa_assess_then_decide \"Continue ↔ Continue\" id \"a5c1eb01-a95f-5f7a-9742-7f201493d7aa\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assess_then_decide;\n sa @sa_assess_then_decide;\n}\n\ntrace trace_oa_assessment_action \"Assess pressure alarm ↔ Assess pressure alarm\" id \"3a33de40-20a3-5252-899c-c12dc35e9783\" {\n baseline {\n oa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n sa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_assessment_action;\n sa @sa_assessment_action;\n}\n\ntrace trace_oa_assessment_delivery \"Chain involvement: Assess an alarm ↔ Chain involvement: Assess an alarm\" id \"5b96bfed-63e2-5d7f-b882-91e8b81f8efe\" {\n baseline {\n oa {\n description \"\";\n name \"Chain involvement: Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Chain involvement: Assess an alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assessment_delivery;\n sa @sa_assessment_delivery;\n}\n\ntrace trace_oa_assessment_procedure \"Assess an alarm procedure ↔ Assess an alarm procedure\" id \"6ca4c78a-a5f2-5470-b401-732f5a8825d0\" {\n baseline {\n oa {\n description \"\";\n name \"Assess an alarm procedure\";\n };\n sa {\n description \"\";\n name \"Assess an alarm procedure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_assessment_procedure;\n sa @sa_assessment_procedure;\n}\n\ntrace trace_oa_assessment_support \"Capability involvement: Assess an alarm ↔ Capability involvement: Assess an alarm\" id \"6e13cf86-7242-5e33-8912-b037cf6e8c87\" {\n baseline {\n oa {\n description \"\";\n name \"Capability involvement: Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Capability involvement: Assess an alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assessment_support;\n sa @sa_assessment_support;\n}\n\ntrace trace_oa_call \"Request valve repair ↔ Request valve repair\" id \"f0cc86e5-f8fd-5091-a40d-dbfb6515072f\" {\n baseline {\n oa {\n description \"\";\n name \"Request valve repair\";\n };\n sa {\n description \"\";\n name \"Request valve repair\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_call;\n sa @sa_call;\n}\n\ntrace trace_oa_caller \"Controller ↔ Controller\" id \"241dfdac-2450-5704-a4cb-9f1047db1b6d\" {\n baseline {\n oa {\n description \"\";\n name \"Controller\";\n };\n sa {\n description \"\";\n name \"Controller\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_caller;\n sa @sa_caller;\n}\n\ntrace trace_oa_confirm \"Confirm safe pressure ↔ Confirm safe pressure\" id \"d7773ff5-cef5-50c1-8f9d-c5659115e1f0\" {\n baseline {\n oa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n sa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_confirm;\n sa @sa_confirm;\n}\n\ntrace trace_oa_confirm_allocation \"Allocation: Confirm safe pressure ↔ Allocation: Confirm safe pressure\" id \"2fdcbc6f-526e-5a4e-bde5-314cf0cf3ef3\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Confirm safe pressure\";\n };\n sa {\n description \"\";\n name \"Allocation: Confirm safe pressure\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_confirm_allocation;\n sa @sa_confirm_allocation;\n}\n\ntrace trace_oa_confirm_path \"Continue ↔ Continue\" id \"a106d183-c239-5c9f-b1f1-141af19fc778\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_confirm_path;\n sa @sa_confirm_path;\n}\n\ntrace trace_oa_confirm_step \"Confirm safe pressure ↔ Confirm safe pressure\" id \"154abfce-c311-52d1-85d1-3f4c85203b26\" {\n baseline {\n oa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n sa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_confirm_step;\n sa @sa_confirm_step;\n}\n\ntrace trace_oa_control \"Control room ↔ Pressure coordination service\" id \"aba528ea-e396-582a-bfba-f6e2212d391a\" {\n baseline {\n oa {\n description \"\";\n name \"Control room\";\n };\n sa {\n description \"\";\n name \"Pressure coordination service\";\n };\n };\n description \"\";\n kind Participant;\n oa @oa_control;\n sa @sa_control;\n}\n\ntrace trace_oa_conversation \"Repair request and measured reply ↔ Repair request and measured reply\" id \"3694c752-ba80-5702-a7d7-9cc66b6b01d8\" {\n baseline {\n oa {\n description \"\";\n name \"Repair request and measured reply\";\n };\n sa {\n description \"\";\n name \"Repair request and measured reply\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_conversation;\n sa @sa_conversation;\n}\n\ntrace trace_oa_decision \"Can a remote reset safely recover pressure? ↔ Can a remote reset safely recover pressure?\" id \"bbf6f1a1-cb2b-5632-8ec0-bf4b8ef1a941\" {\n baseline {\n oa {\n description \"\";\n name \"Can a remote reset safely recover pressure?\";\n };\n sa {\n description \"\";\n name \"Can a remote reset safely recover pressure?\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_decision;\n sa @sa_decision;\n}\n\ntrace trace_oa_field_merge \"Continue ↔ Continue\" id \"d32dd433-9c9e-5b57-9355-2970cc44a5f2\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_field_merge;\n sa @sa_field_merge;\n}\n\ntrace trace_oa_field_path \"else ↔ else\" id \"2e849e31-35aa-5aef-a989-26948038e52b\" {\n baseline {\n oa {\n description \"\";\n name \"else\";\n };\n sa {\n description \"\";\n name \"else\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_field_path;\n sa @sa_field_path;\n}\n\ntrace trace_oa_field_procedure \"Field repair procedure ↔ Field repair procedure\" id \"bb72b2c2-6e32-5f65-9d8e-b33c1a65da37\" {\n baseline {\n oa {\n description \"\";\n name \"Field repair procedure\";\n };\n sa {\n description \"\";\n name \"Field repair procedure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_field_procedure;\n sa @sa_field_procedure;\n}\n\ntrace trace_oa_full_scenario \"Restore supply through field repair ↔ Restore supply through field repair\" id \"435bae4c-cf6d-59f3-9eb5-d660ebeec079\" {\n baseline {\n oa {\n description \"\";\n name \"Restore supply through field repair\";\n };\n sa {\n description \"\";\n name \"Restore supply through field repair\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_full_scenario;\n sa @sa_full_scenario;\n}\n\ntrace trace_oa_high_pressure \"Pressure alarm detected ↔ Pressure alarm detected\" id \"b97749d4-f9ad-5fb7-9d78-bc60a74e98eb\" {\n baseline {\n oa {\n description \"\";\n name \"Pressure alarm detected\";\n };\n sa {\n description \"\";\n name \"Pressure alarm detected\";\n };\n };\n description \"\";\n kind State;\n oa @oa_high_pressure;\n sa @sa_high_pressure;\n}\n\ntrace trace_oa_include_triage \"Capability include: Assess an alarm ↔ Capability include: Assess an alarm\" id \"77073fcb-53aa-5f06-987a-0317cbb6971d\" {\n baseline {\n oa {\n description \"\";\n name \"Capability include: Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Capability include: Assess an alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_include_triage;\n sa @sa_include_triage;\n}\n\ntrace trace_oa_merge \"Recovery path completed ↔ Recovery path completed\" id \"0c9de959-d366-5765-98eb-450ea27bd316\" {\n baseline {\n oa {\n description \"\";\n name \"Recovery path completed\";\n };\n sa {\n description \"\";\n name \"Recovery path completed\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_merge;\n sa @sa_merge;\n}\n\ntrace trace_oa_mission \"Maintain safe water supply ↔ Maintain safe water supply\" id \"c987bd0f-87f6-5550-945d-2a42450fe1c5\" {\n baseline {\n oa {\n description \"\";\n name \"Maintain safe water supply\";\n };\n sa {\n description \"\";\n name \"Maintain safe water supply\";\n };\n };\n description \"\";\n kind Mission;\n oa @oa_mission;\n sa @sa_mission;\n}\n\ntrace trace_oa_mission_delivery \"Mission capability: Restore a safe supply ↔ Mission capability: Restore a safe supply\" id \"aa164208-394d-5bc7-8462-b0043f682aad\" {\n baseline {\n oa {\n description \"\";\n name \"Mission capability: Restore a safe supply\";\n };\n sa {\n description \"\";\n name \"Mission capability: Restore a safe supply\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_mission_delivery;\n sa @sa_mission_delivery;\n}\n\ntrace trace_oa_mission_participant \"Mission involvement: Maintain safe water supply ↔ Mission involvement: Maintain safe water supply\" id \"8d693d90-5017-56af-b056-d29d3cb46baa\" {\n baseline {\n oa {\n description \"\";\n name \"Mission involvement: Maintain safe water supply\";\n };\n sa {\n description \"\";\n name \"Mission involvement: Maintain safe water supply\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_mission_participant;\n sa @sa_mission_participant;\n}\n\ntrace trace_oa_monitor \"Pressure supervision ↔ Pressure supervision\" id \"b53863a2-ed80-5f98-ad3c-50e1b821066d\" {\n baseline {\n oa {\n description \"\";\n name \"Pressure supervision\";\n };\n sa {\n description \"\";\n name \"Pressure supervision\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitor;\n sa @sa_monitor;\n}\n\ntrace trace_oa_monitor_initial \"Start ↔ Start\" id \"48d243f0-553f-507e-acb0-9474ae156b63\" {\n baseline {\n oa {\n description \"\";\n name \"Start\";\n };\n sa {\n description \"\";\n name \"Start\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitor_initial;\n sa @sa_monitor_initial;\n}\n\ntrace trace_oa_monitor_region \"Supervision cycle ↔ Supervision cycle\" id \"208753e0-9151-50d8-86ca-29fc28eca80b\" {\n baseline {\n oa {\n description \"\";\n name \"Supervision cycle\";\n };\n sa {\n description \"\";\n name \"Supervision cycle\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitor_region;\n sa @sa_monitor_region;\n}\n\ntrace trace_oa_monitoring \"Monitoring ↔ Monitoring\" id \"12ec320f-2e14-5a5a-8c6b-bd2b6471dae9\" {\n baseline {\n oa {\n description \"\";\n name \"Monitoring\";\n };\n sa {\n description \"\";\n name \"Monitoring\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitoring;\n sa @sa_monitoring;\n}\n\ntrace trace_oa_normal_transition \"State transition: Monitoring ↔ State transition: Monitoring\" id \"c47cf0b4-fe15-5175-bd62-5292e43adc39\" {\n baseline {\n oa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n sa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_normal_transition;\n sa @sa_normal_transition;\n}\n\ntrace trace_oa_outer_controller \"Controller ↔ Controller\" id \"fd195498-dbba-523b-94d0-49f3b9531a3e\" {\n baseline {\n oa {\n description \"\";\n name \"Controller\";\n };\n sa {\n description \"\";\n name \"Controller\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_outer_controller;\n sa @sa_outer_controller;\n}\n\ntrace trace_oa_outer_technician \"Technician ↔ Technician\" id \"c132a0df-1270-55a8-ac4e-6e5075823dc1\" {\n baseline {\n oa {\n description \"\";\n name \"Technician\";\n };\n sa {\n description \"\";\n name \"Technician\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_outer_technician;\n sa @sa_outer_technician;\n}\n\ntrace trace_oa_pressure \"Measured pressure ↔ Measured pressure\" id \"d9d47371-e98d-5d29-9df7-4de2d28a5c73\" {\n baseline {\n oa {\n description \"\";\n name \"Measured pressure\";\n };\n sa {\n description \"\";\n name \"Measured pressure\";\n };\n };\n description \"\";\n kind Item;\n oa @oa_pressure;\n sa @sa_pressure;\n}\n\ntrace trace_oa_pressure_parameter \"Pressure before / after repair ↔ Pressure before / after repair\" id \"0d905fbb-5b3c-56f0-9f11-a27091901eb0\" {\n baseline {\n oa {\n description \"\";\n name \"Pressure before / after repair\";\n };\n sa {\n description \"\";\n name \"Pressure before / after repair\";\n };\n };\n description \"\";\n kind Item;\n oa @oa_pressure_parameter;\n sa @sa_pressure_parameter;\n}\n\ntrace trace_oa_remote_merge \"Continue ↔ Continue\" id \"1b788e74-36b2-5c17-a936-642dbe51ca3c\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_remote_merge;\n sa @sa_remote_merge;\n}\n\ntrace trace_oa_remote_path \"remote reset is authorized ↔ remote reset is authorized\" id \"eee79ec1-41d7-5374-986e-995a56b9026f\" {\n baseline {\n oa {\n description \"\";\n name \"remote reset is authorized\";\n };\n sa {\n description \"\";\n name \"remote reset is authorized\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_remote_path;\n sa @sa_remote_path;\n}\n\ntrace trace_oa_repair \"Repair valve in the field ↔ Repair valve in the field\" id \"b13452bb-1a55-5ad6-b6d9-a37fb34e8d13\" {\n baseline {\n oa {\n description \"\";\n name \"Repair valve in the field\";\n };\n sa {\n description \"\";\n name \"Repair valve in the field\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_repair;\n sa @sa_repair;\n}\n\ntrace trace_oa_repair_allocation \"Allocation: Repair valve in the field ↔ Allocation: Repair valve in the field\" id \"3714d547-006f-5c3a-97f8-175ccc23d994\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Repair valve in the field\";\n };\n sa {\n description \"\";\n name \"Allocation: Repair valve in the field\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_repair_allocation;\n sa @sa_repair_allocation;\n}\n\ntrace trace_oa_repair_operation \"Valve repair ↔ Valve repair\" id \"cf92cedf-ea1a-510c-9bc5-89c16d60e8d2\" {\n baseline {\n oa {\n description \"\";\n name \"Valve repair\";\n };\n sa {\n description \"\";\n name \"Valve repair\";\n };\n };\n description \"\";\n kind Item;\n oa @oa_repair_operation;\n sa @sa_repair_operation;\n}\n\ntrace trace_oa_repair_report \"Report repair result ↔ Report repair result\" id \"424eca39-1c29-5a2f-b9ee-33dd018926aa\" {\n baseline {\n oa {\n description \"\";\n name \"Report repair result\";\n };\n sa {\n description \"\";\n name \"Report repair result\";\n };\n };\n description \"\";\n kind Exchange;\n oa @oa_repair_report;\n sa @sa_repair_report;\n}\n\ntrace trace_oa_repair_request \"Request field repair ↔ Request field repair\" id \"da8efc3c-78df-5b10-b2e5-1e28d6d99e86\" {\n baseline {\n oa {\n description \"\";\n name \"Request field repair\";\n };\n sa {\n description \"\";\n name \"Request field repair\";\n };\n };\n description \"\";\n kind Exchange;\n oa @oa_repair_request;\n sa @sa_repair_request;\n}\n\ntrace trace_oa_repair_step \"Repair the valve ↔ Repair the valve\" id \"ac3aa6b0-568e-5a3f-963a-172aa3cfc7a4\" {\n baseline {\n oa {\n description \"\";\n name \"Repair the valve\";\n };\n sa {\n description \"\";\n name \"Repair the valve\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_repair_step;\n sa @sa_repair_step;\n}\n\ntrace trace_oa_repair_then_report \"Sequence link: Report the measured result ↔ Sequence link: Report the measured result\" id \"f8d34715-1dc8-504b-acdf-78b770aad1e8\" {\n baseline {\n oa {\n description \"\";\n name \"Sequence link: Report the measured result\";\n };\n sa {\n description \"\";\n name \"Sequence link: Report the measured result\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_repair_then_report;\n sa @sa_repair_then_report;\n}\n\ntrace trace_oa_reply \"Return pressure after repair ↔ Return pressure after repair\" id \"595b5729-ccd9-5291-b5d9-30d8c8bd9dfa\" {\n baseline {\n oa {\n description \"\";\n name \"Return pressure after repair\";\n };\n sa {\n description \"\";\n name \"Return pressure after repair\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_reply;\n sa @sa_reply;\n}\n\ntrace trace_oa_report_step \"Report the measured result ↔ Report the measured result\" id \"e63b14d2-f408-56a2-aec4-be20cf4ace73\" {\n baseline {\n oa {\n description \"\";\n name \"Report the measured result\";\n };\n sa {\n description \"\";\n name \"Report the measured result\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_report_step;\n sa @sa_report_step;\n}\n\ntrace trace_oa_request_step \"Request the field repair ↔ Request the field repair\" id \"ad697c00-07d9-5799-89b3-2c78da8dff4d\" {\n baseline {\n oa {\n description \"\";\n name \"Request the field repair\";\n };\n sa {\n description \"\";\n name \"Request the field repair\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_request_step;\n sa @sa_request_step;\n}\n\ntrace trace_oa_request_then_repair \"Sequence link: Repair the valve ↔ Sequence link: Repair the valve\" id \"f2b7050c-629d-533f-b873-bffc33e072f7\" {\n baseline {\n oa {\n description \"\";\n name \"Sequence link: Repair the valve\";\n };\n sa {\n description \"\";\n name \"Sequence link: Repair the valve\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_request_then_repair;\n sa @sa_request_then_repair;\n}\n\ntrace trace_oa_reset \"Reset valve remotely ↔ Reset valve remotely\" id \"82a5be1a-92a8-578d-9448-086062a20d8f\" {\n baseline {\n oa {\n description \"\";\n name \"Reset valve remotely\";\n };\n sa {\n description \"\";\n name \"Reset valve remotely\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_reset;\n sa @sa_reset;\n}\n\ntrace trace_oa_reset_allocation \"Allocation: Reset valve remotely ↔ Allocation: Reset valve remotely\" id \"98ef05b5-5381-5d35-a433-ebab55e9f382\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Reset valve remotely\";\n };\n sa {\n description \"\";\n name \"Allocation: Reset valve remotely\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_reset_allocation;\n sa @sa_reset_allocation;\n}\n\ntrace trace_oa_reset_step \"Try the authorized remote reset ↔ Try the authorized remote reset\" id \"ff84fc06-6f99-5cf3-b73d-236e9eff98a3\" {\n baseline {\n oa {\n description \"\";\n name \"Try the authorized remote reset\";\n };\n sa {\n description \"\";\n name \"Try the authorized remote reset\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_reset_step;\n sa @sa_reset_step;\n}\n\ntrace trace_oa_responder \"Technician ↔ Technician\" id \"c5051628-b7af-5fb1-a014-bb60a0b46e03\" {\n baseline {\n oa {\n description \"\";\n name \"Technician\";\n };\n sa {\n description \"\";\n name \"Technician\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_responder;\n sa @sa_responder;\n}\n\ntrace trace_oa_response \"Handle a pressure alarm ↔ Handle a pressure alarm\" id \"d3626818-4846-5893-9c02-3407cdba7f3c\" {\n baseline {\n oa {\n description \"\";\n name \"Handle a pressure alarm\";\n };\n sa {\n description \"\";\n name \"Handle a pressure alarm\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_response;\n sa @sa_response;\n}\n\ntrace trace_oa_response_delivery \"Chain involvement: Restore a safe supply ↔ Chain involvement: Restore a safe supply\" id \"70c90b0c-85ce-574a-a217-f0d19fad30e1\" {\n baseline {\n oa {\n description \"\";\n name \"Chain involvement: Restore a safe supply\";\n };\n sa {\n description \"\";\n name \"Chain involvement: Restore a safe supply\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_response_delivery;\n sa @sa_response_delivery;\n}\n\ntrace trace_oa_restore \"Restore a safe supply ↔ Restore a safe supply\" id \"0fbef164-b5d3-58ba-ba41-54d0e63709ab\" {\n baseline {\n oa {\n description \"\";\n name \"Restore a safe supply\";\n };\n sa {\n description \"\";\n name \"Restore a safe supply\";\n };\n };\n description \"\";\n kind Capability;\n oa @oa_restore;\n sa @sa_restore;\n}\n\ntrace trace_oa_restore_work \"Restore safe pressure ↔ Restore safe pressure\" id \"82cca563-9d5d-5479-aa1a-9db03e9674d1\" {\n baseline {\n oa {\n description \"\";\n name \"Restore safe pressure\";\n };\n sa {\n description \"\";\n name \"Restore safe pressure\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_restore_work;\n sa @sa_restore_work;\n}\n\ntrace trace_oa_start_monitoring \"State transition: Monitoring ↔ State transition: Monitoring\" id \"07a7b82a-b5a7-5737-abfa-09e422f7420e\" {\n baseline {\n oa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n sa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_start_monitoring;\n sa @sa_start_monitoring;\n}\n\ntrace trace_oa_technician \"Field technician ↔ Field technician\" id \"d47d3c36-50fb-5189-b801-d6fd1694bd49\" {\n baseline {\n oa {\n description \"\";\n name \"Field technician\";\n };\n sa {\n description \"\";\n name \"Field technician\";\n };\n };\n description \"\";\n kind Participant;\n oa @oa_technician;\n sa @sa_technician;\n}\n\ntrace trace_oa_triage \"Assess an alarm ↔ Assess an alarm\" id \"6acd072d-431e-5881-ab20-1813b4fda0c5\" {\n baseline {\n oa {\n description \"\";\n name \"Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Assess an alarm\";\n };\n };\n description \"\";\n kind Capability;\n oa @oa_triage;\n sa @sa_triage;\n}\n\ntrace trace_oa_use_conversation \"Use the reviewed repair conversation ↔ Use the reviewed repair conversation\" id \"f5733d3f-eee3-5cf7-a422-5edd2054d6a6\" {\n baseline {\n oa {\n description \"\";\n name \"Use the reviewed repair conversation\";\n };\n sa {\n description \"\";\n name \"Use the reviewed repair conversation\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_use_conversation;\n sa @sa_use_conversation;\n}\n\ntrace trace_oa_use_field \"Use the field repair procedure ↔ Use the field repair procedure\" id \"7a6b4b9c-4220-546a-a752-1587c2881fe1\" {\n baseline {\n oa {\n description \"\";\n name \"Use the field repair procedure\";\n };\n sa {\n description \"\";\n name \"Use the field repair procedure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_use_field;\n sa @sa_use_field;\n}\n\ntrace trace_oa_waiting \"Awaiting repair ↔ Awaiting repair\" id \"0754be23-e4b8-53bc-b03c-89c2dbd80361\" {\n baseline {\n oa {\n description \"\";\n name \"Awaiting repair\";\n };\n sa {\n description \"\";\n name \"Awaiting repair\";\n };\n };\n description \"\";\n kind State;\n oa @oa_waiting;\n sa @sa_waiting;\n}\n\n", + "views/diagram_oa_allocation_view.arcadia": "diagram oa_allocation_view \"1 · OA responsibility and repair traffic\" id \"103ea62c-ac56-5827-94e7-cf09bca96ccc\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Oa;\n projection Allocation;\n routes [];\n}\n\nnode oa_allocation_view_oa_assess id \"5ae1c46d-59a3-50fd-8cc3-b292df21f951\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_assess;\n position {\n x 100.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_confirm id \"edd74060-ac61-5450-a57c-a6577daa5d50\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_confirm;\n position {\n x 100.0;\n y 490.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_control id \"3191e05c-1c7f-5f0e-beb0-5f3bded54c48\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_control;\n position {\n x 60.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_repair id \"202ebe37-ddb4-5099-8ce9-ec3e895ab9e9\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_repair;\n position {\n x 890.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_reset id \"7066f78c-9736-5349-b94a-0587331b980e\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_reset;\n position {\n x 100.0;\n y 330.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_technician id \"d27f28ef-f10a-5355-9a05-6f2fd39b7de6\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_technician;\n position {\n x 850.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_allocation_view.arcadia": "diagram sa_allocation_view \"2 · SA service boundary and functions\" id \"35d3c24b-289e-59e5-a610-8333e2c6a4bb\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection Allocation;\n routes [];\n}\n\nnode sa_allocation_view_sa_assess id \"60f6ad74-b69e-5aef-b832-f1bd60b903ae\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_assess;\n position {\n x 100.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_confirm id \"7e3ba36f-bc68-5520-bdb9-77083bb7bba2\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_confirm;\n position {\n x 100.0;\n y 490.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_control id \"8345c76a-8d85-5c31-902e-ec1cd33ed4a3\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_control;\n position {\n x 60.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_repair id \"34630a18-41b0-5fdd-bff4-ee86e93e8651\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_repair;\n position {\n x 890.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_reset id \"73406244-ebd7-5995-86c7-e17c73c50b32\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_reset;\n position {\n x 100.0;\n y 330.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_technician id \"f7225d1c-e506-505d-bb20-916950269d4b\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_technician;\n position {\n x 850.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_capability_view.arcadia": "diagram sa_capability_view \"4 · SA mission and included capability\" id \"3e6ad194-61b5-584c-b0f0-a78a20af5781\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection Capabilities;\n routes [];\n}\n\nnode sa_capability_view_sa_control id \"41ddb68b-bd2b-5f91-82eb-efde01157828\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_control;\n position {\n x 60.0;\n y 60.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_capability_view_sa_mission id \"4b814335-bf98-5b68-85d5-ea549766517a\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_mission;\n position {\n x 420.0;\n y 60.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_capability_view_sa_restore id \"d3bc13f9-7726-5280-83fe-63f391c45afb\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_restore;\n position {\n x 420.0;\n y 260.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_capability_view_sa_triage id \"069a3fb3-077c-59d8-91da-27b0665143c7\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_triage;\n position {\n x 800.0;\n y 260.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_service_view.arcadia": "diagram sa_service_view \"3 · SA service conversation only\" id \"50af4c9b-93ce-5ff5-a1f8-7a81eb97e779\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection Allocation;\n routes [];\n}\n\nnode sa_service_view_sa_assess id \"439f1e45-e5ed-567c-978b-4c93ef267db1\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_assess;\n position {\n x 100.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_confirm id \"6f138672-149b-5f88-ab07-7fc413a42262\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_confirm;\n position {\n x 100.0;\n y 490.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_control id \"f53d4352-b042-56a6-8b3e-c51cd27254f9\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_control;\n position {\n x 60.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_repair id \"ae36489d-6ec8-5f1c-b603-8b497227efba\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_repair;\n position {\n x 890.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_reset id \"d52da4e6-40b7-5dbc-84fb-80c443711c69\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_reset;\n position {\n x 100.0;\n y 330.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_technician id \"40210589-69f7-5209-b329-10bb83cf615c\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_technician;\n position {\n x 850.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_state_view.arcadia": "diagram sa_state_view \"5 · SA pressure supervision\" id \"30d5baf9-6581-5cb6-846a-bcaa1e6cce63\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection {\n StateMachine {\n machine @sa_monitor;\n };\n };\n routes [];\n}\n\nnode sa_state_view_sa_monitor id \"a676fbb9-0833-59a8-98cf-a10d8f4b7434\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitor;\n position {\n x 30.0;\n y 30.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_monitor_initial id \"5e2326a0-0f6c-5f8d-8a21-fdded18a9281\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitor_initial;\n position {\n x 100.0;\n y 200.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_monitor_region id \"55a74f91-2025-5001-8db8-9566b1dbe783\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitor_region;\n position {\n x 70.0;\n y 95.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_monitoring id \"b3af7b04-5a45-5b82-ab69-7eada4b876f0\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitoring;\n position {\n x 220.0;\n y 200.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_waiting id \"57acbb71-ca86-57fc-ae90-42dad195f0e0\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_waiting;\n position {\n x 650.0;\n y 200.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n" + } +} diff --git a/crates/oa-desktop/src/recovery/fixtures/source5-model.json b/crates/oa-desktop/src/recovery/fixtures/source5-model.json new file mode 100644 index 00000000..0c351064 --- /dev/null +++ b/crates/oa-desktop/src/recovery/fixtures/source5-model.json @@ -0,0 +1 @@ +{"schema_version":5,"id":"2786d72f-5763-5f33-a57f-2773a4ca1a6c","alias":"pressure_restoration","name":"Restore safe water pressure — release 1 walkthrough","description":"A controller either resets a valve remotely or requests field repair. Compare the operational need with the explicitly chosen coordination-service boundary. Start with README.md beside project.arcadia. Units and guards are descriptive; this model does not simulate a real water network.","elements":{"01490fa8-2f01-5ac8-9722-d55d45414379":{"id":"01490fa8-2f01-5ac8-9722-d55d45414379","alias":"sa_high_pressure","name":"Pressure alarm detected","description":"","parent":null,"body":{"Sa":{"Shared":{"Event":{"body":{"Change":{"expression":"measured_pressure > operating_limit"}}}}}}},"022d3cfa-bbaf-5e13-924a-8e55099b8745":{"id":"022d3cfa-bbaf-5e13-924a-8e55099b8745","alias":"oa_assessment_procedure","name":"Assess an alarm procedure","description":"","parent":null,"body":{"Oa":{"Process":{"steps":["942975b8-0bef-5ae8-9fcb-c37497833fb0"]}}}},"09342ccb-044b-5483-ac58-87dc3cf7831c":{"id":"09342ccb-044b-5483-ac58-87dc3cf7831c","alias":"sa_reset","name":"Reset valve remotely","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"0b07b9ed-492d-57f6-b0b1-ce8a709238a7":{"id":"0b07b9ed-492d-57f6-b0b1-ce8a709238a7","alias":"oa_assess_step","name":"Assess the alarm","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"022d3cfa-bbaf-5e13-924a-8e55099b8745"}},"precondition":"","postcondition":""}}}}},"0f195fea-3493-5ebd-966d-da9eddeb05d7":{"id":"0f195fea-3493-5ebd-966d-da9eddeb05d7","alias":"sa_repair_step","name":"Repair the valve","description":"","parent":"93eb6479-54e9-59cb-ac7b-16912347bfc0","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"9497e10f-04c4-5586-8738-2464a6f00bd4","lifeline":null}},"precondition":"","postcondition":""}}}}},"132b3f28-3f88-5327-9868-8c8b645ed412":{"id":"132b3f28-3f88-5327-9868-8c8b645ed412","alias":"sa_monitor","name":"Pressure supervision","description":"","parent":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","body":{"Sa":{"Shared":"StateMachine"}}},"155e4fe4-9e3b-5f4e-b886-28abcc3f086b":{"id":"155e4fe4-9e3b-5f4e-b886-28abcc3f086b","alias":"sa_outer_technician","name":"Technician","description":"","parent":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","body":{"Sa":{"Shared":{"Lifeline":{"represented":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a"}}}}},"1970608f-85d1-5234-ac86-5e2b0df27dc3":{"id":"1970608f-85d1-5234-ac86-5e2b0df27dc3","alias":"oa_monitor","name":"Pressure supervision","description":"","parent":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","body":{"Oa":{"Shared":"StateMachine"}}},"1b91f8a7-0bdb-514e-b1cd-955658e09927":{"id":"1b91f8a7-0bdb-514e-b1cd-955658e09927","alias":"oa_restore_work","name":"Restore safe pressure","description":"","parent":null,"body":{"Oa":"Activity"}},"1c9da843-e2d3-5591-8e3f-db083a42867e":{"id":"1c9da843-e2d3-5591-8e3f-db083a42867e","alias":"sa_use_conversation","name":"Use the reviewed repair conversation","description":"","parent":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","body":{"Sa":{"Shared":{"Occurrence":{"body":{"ScenarioUse":{"scenario":"730b27ca-b039-5a3b-8dc6-03418f9061b3","bindings":[{"referenced":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","local":"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023"},{"referenced":"6249b54a-e396-5338-82b9-89266c55670d","local":"155e4fe4-9e3b-5f4e-b886-28abcc3f086b"}]}},"precondition":"","postcondition":""}}}}},"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023":{"id":"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023","alias":"sa_outer_controller","name":"Controller","description":"","parent":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","body":{"Sa":{"Shared":{"Lifeline":{"represented":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0"}}}}},"2011b3ac-614b-5a47-8891-7ff81965f425":{"id":"2011b3ac-614b-5a47-8891-7ff81965f425","alias":"oa_outer_controller","name":"Controller","description":"","parent":"d6f881a4-95da-54e9-ad97-1b8965532629","body":{"Oa":{"Shared":{"Lifeline":{"represented":"61f7bbfb-74a4-5540-9182-2583dfb27a49"}}}}},"20472233-5ce6-5ffe-b4ad-0bdbccf3e423":{"id":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","alias":"oa_conversation","name":"Repair request and measured reply","description":"","parent":null,"body":{"Oa":{"Shared":{"Scenario":{"viewpoint":"OperationalActivity","context":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["b97437c6-cd7a-5a05-b537-b30a17dd936a","cc2d497a-fd15-5455-9ee5-e70eb95811d8"]}}}}},"24a6850e-b819-57e0-af88-ca4ab76581b7":{"id":"24a6850e-b819-57e0-af88-ca4ab76581b7","alias":"sa_repair_request_out","name":"Request field repair output","description":"","parent":"dddae020-a444-5d48-acc8-139d2696a672","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionOutput":{"outgoing_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"30c8fad9-a49b-5692-9542-3148e76afd7b":{"id":"30c8fad9-a49b-5692-9542-3148e76afd7b","alias":"sa_decision","name":"Can a remote reset safely recover pressure?","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Choice"}},"precondition":"","postcondition":""}}}}},"32aa9940-df6e-5a12-b9bd-6337e2d2ed22":{"id":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","alias":"oa_control","name":"Control room","description":"","parent":null,"body":{"Oa":"Entity"}},"391af7c4-b07d-53a7-8ddd-5c81461fc4f8":{"id":"391af7c4-b07d-53a7-8ddd-5c81461fc4f8","alias":"sa_repair_report_in","name":"Report repair result input","description":"","parent":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionInput":{"incoming_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c":{"id":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","alias":"sa_monitor_region","name":"Supervision cycle","description":"","parent":"132b3f28-3f88-5327-9868-8c8b645ed412","body":{"Sa":{"Shared":"Region"}}},"3dfee23a-148b-5cec-ad2d-b774e84ea5bb":{"id":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","alias":"oa_restore","name":"Restore a safe supply","description":"","parent":null,"body":{"Oa":"Capability"}},"40d94322-baa2-5c81-b261-ff47cdc33782":{"id":"40d94322-baa2-5c81-b261-ff47cdc33782","alias":"sa_request_step","name":"Request the field repair","description":"","parent":"93eb6479-54e9-59cb-ac7b-16912347bfc0","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"93474111-f23e-56c1-b61b-b7c2396b91bd","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"4149a872-3440-59de-9879-8981e78d27ab":{"id":"4149a872-3440-59de-9879-8981e78d27ab","alias":"sa_audit_thresholds","name":"Audit pressure thresholds","description":"Deliberately unfinished: assigned to the service, but no behavior context yet. Model checks should expose this one work_without_behavior_context warning.","parent":null,"body":{"Sa":"Function"}},"41859a65-ded2-547b-ab03-14e2fa740cf9":{"id":"41859a65-ded2-547b-ab03-14e2fa740cf9","alias":"oa_caller","name":"Controller","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Lifeline":{"represented":"61f7bbfb-74a4-5540-9182-2583dfb27a49"}}}}},"435bf87c-73fd-5e4d-978a-4c471bc9eaf0":{"id":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","alias":"sa_control","name":"Pressure coordination service","description":"","parent":null,"body":{"Sa":"System"}},"47bec4f9-d6d0-5ed1-adca-fa0d960dc416":{"id":"47bec4f9-d6d0-5ed1-adca-fa0d960dc416","alias":"sa_confirm_step","name":"Confirm safe pressure","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","lifeline":null}},"precondition":"","postcondition":""}}}}},"4e1c0a56-ce80-5f13-a93a-6956de54880c":{"id":"4e1c0a56-ce80-5f13-a93a-6956de54880c","alias":"sa_reply","name":"Return pressure after repair","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"63009b5f-539e-5914-b630-ec6b7fefb4d5","sender":"6249b54a-e396-5338-82b9-89266c55670d","receiver":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","message_kind":"Reply","reply_to":"8bab4783-a098-5984-9e36-493957caf26c","values":[{"path":[],"field":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","expression":"320.0"}]}},"precondition":"","postcondition":""}}}}},"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07":{"id":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","alias":"sa_merge","name":"Recovery path completed","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Merge"}},"precondition":"","postcondition":""}}}}},"5139ce73-56fd-5875-ba3a-e569fc890231":{"id":"5139ce73-56fd-5875-ba3a-e569fc890231","alias":"sa_response","name":"Handle a pressure alarm","description":"","parent":null,"body":{"Sa":{"FunctionalChain":{"steps":["9024243c-ccc1-5edb-8477-2aa16e5e9a50","30c8fad9-a49b-5692-9542-3148e76afd7b","bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","d5e61c5d-510b-5091-a878-da46f3a9e152","4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","47bec4f9-d6d0-5ed1-adca-fa0d960dc416"]}}}},"57eb5348-14d8-560e-996a-8dbf993243e2":{"id":"57eb5348-14d8-560e-996a-8dbf993243e2","alias":"sa_monitor_initial","name":"Start","description":"","parent":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","body":{"Sa":{"Shared":{"State":{"kind":"Initial","entry":"","exit":"","do_activity":""}}}}},"58867e7d-916e-5ebc-9e4c-8dbed1314be3":{"id":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","alias":"sa_restore_work","name":"Restore safe pressure","description":"","parent":null,"body":{"Sa":"Function"}},"58bae2b0-f560-50f2-818d-19091d22d18a":{"id":"58bae2b0-f560-50f2-818d-19091d22d18a","alias":"oa_response","name":"Handle a pressure alarm","description":"","parent":null,"body":{"Oa":{"Process":{"steps":["0b07b9ed-492d-57f6-b0b1-ce8a709238a7","b280c6ca-e5f9-5424-a5c1-5676e01c2371","d2026fc2-74a8-5e22-9942-ff3eb68a3677","fc6763bf-a534-54bf-afcd-57ab285ff07d","6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","e00594a3-6122-5a6b-b3c2-ecb5506c741d"]}}}},"58c15833-6e75-523a-b57f-a88f19f6d672":{"id":"58c15833-6e75-523a-b57f-a88f19f6d672","alias":"oa_waiting","name":"Awaiting repair","description":"","parent":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","body":{"Oa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"5ad034b1-084d-5273-9e70-971671766132":{"id":"5ad034b1-084d-5273-9e70-971671766132","alias":"sa_mission","name":"Maintain safe water supply","description":"","parent":null,"body":{"Sa":"Mission"}},"5ba8079c-2dbe-5e98-ae82-2b60c6020a02":{"id":"5ba8079c-2dbe-5e98-ae82-2b60c6020a02","alias":"oa_request_step","name":"Request the field repair","description":"","parent":"5d3af417-244c-54bb-9588-16daedda4e1f","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"5d3af417-244c-54bb-9588-16daedda4e1f":{"id":"5d3af417-244c-54bb-9588-16daedda4e1f","alias":"oa_field_procedure","name":"Field repair procedure","description":"","parent":null,"body":{"Oa":{"Process":{"steps":["5ba8079c-2dbe-5e98-ae82-2b60c6020a02","c59432fe-6092-5ee0-908d-9dbf39476471","df2fd3f8-6bc2-5380-a13f-36643ed35ac7"]}}}},"61f7bbfb-74a4-5540-9182-2583dfb27a49":{"id":"61f7bbfb-74a4-5540-9182-2583dfb27a49","alias":"oa_assess","name":"Assess pressure alarm","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"6249b54a-e396-5338-82b9-89266c55670d":{"id":"6249b54a-e396-5338-82b9-89266c55670d","alias":"sa_responder","name":"Technician","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Lifeline":{"represented":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a"}}}}},"65d6b8d6-271a-5c84-8b42-abbe310c955e":{"id":"65d6b8d6-271a-5c84-8b42-abbe310c955e","alias":"oa_high_pressure","name":"Pressure alarm detected","description":"","parent":null,"body":{"Oa":{"Shared":{"Event":{"body":{"Change":{"expression":"measured_pressure > operating_limit"}}}}}}},"6a30b763-cc51-546d-907f-5c63657cfd03":{"id":"6a30b763-cc51-546d-907f-5c63657cfd03","alias":"oa_triage","name":"Assess an alarm","description":"","parent":null,"body":{"Oa":"Capability"}},"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d":{"id":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","alias":"oa_merge","name":"Recovery path completed","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Merge"}},"precondition":"","postcondition":""}}}}},"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae":{"id":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","alias":"oa_monitoring","name":"Monitoring","description":"","parent":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","body":{"Oa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"727c0b5c-8db6-5eb7-83d6-735519c5949f":{"id":"727c0b5c-8db6-5eb7-83d6-735519c5949f","alias":"oa_confirm","name":"Confirm safe pressure","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"730b27ca-b039-5a3b-8dc6-03418f9061b3":{"id":"730b27ca-b039-5a3b-8dc6-03418f9061b3","alias":"sa_conversation","name":"Repair request and measured reply","description":"","parent":null,"body":{"Sa":{"Shared":{"Scenario":{"viewpoint":"BoundaryInterface","context":"81259051-370a-512d-a3b5-6420a6f933b6","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["8bab4783-a098-5984-9e36-493957caf26c","4e1c0a56-ce80-5f13-a93a-6956de54880c"]}}}}},"81259051-370a-512d-a3b5-6420a6f933b6":{"id":"81259051-370a-512d-a3b5-6420a6f933b6","alias":"sa_restore","name":"Restore a safe supply","description":"","parent":null,"body":{"Sa":"Capability"}},"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7":{"id":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","alias":"oa_monitor_region","name":"Supervision cycle","description":"","parent":"1970608f-85d1-5234-ac86-5e2b0df27dc3","body":{"Oa":{"Shared":"Region"}}},"87d0a95b-af1b-5352-b753-e4c6533671a5":{"id":"87d0a95b-af1b-5352-b753-e4c6533671a5","alias":"sa_pressure","name":"Measured pressure","description":"","parent":null,"body":{"Sa":{"Shared":{"DataType":{"definition":{"Real":{"minimum":0.0,"maximum":600.0}},"default":null,"unit":"kPa"}}}}},"8af21f9b-c21e-5aae-af0d-0958840f6655":{"id":"8af21f9b-c21e-5aae-af0d-0958840f6655","alias":"sa_monitoring","name":"Monitoring","description":"","parent":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","body":{"Sa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"8bab4783-a098-5984-9e36-493957caf26c":{"id":"8bab4783-a098-5984-9e36-493957caf26c","alias":"sa_call","name":"Request valve repair","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"63009b5f-539e-5914-b630-ec6b7fefb4d5","sender":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","receiver":"6249b54a-e396-5338-82b9-89266c55670d","message_kind":"Call","reply_to":null,"values":[{"path":[],"field":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","expression":"420.0"}]}},"precondition":"","postcondition":""}}}}},"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7":{"id":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","alias":"oa_pressure_parameter","name":"Pressure before / after repair","description":"","parent":"d736c7d6-d369-550a-8ba4-dae223e40bb3","body":{"Oa":{"Shared":{"DataField":{"type_ref":{"Named":"e7824764-5db9-5384-8198-2fbaea2b10b2"},"minimum":1,"maximum":1,"unit":"","direction":"InOut","storage":"Value","default":null}}}}},"9024243c-ccc1-5edb-8477-2aa16e5e9a50":{"id":"9024243c-ccc1-5edb-8477-2aa16e5e9a50","alias":"sa_assess_step","name":"Assess the alarm","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"acd5a018-f559-5298-a40d-1cf6e1353e74"}},"precondition":"","postcondition":""}}}}},"93eb6479-54e9-59cb-ac7b-16912347bfc0":{"id":"93eb6479-54e9-59cb-ac7b-16912347bfc0","alias":"sa_field_procedure","name":"Field repair procedure","description":"","parent":null,"body":{"Sa":{"FunctionalChain":{"steps":["40d94322-baa2-5c81-b261-ff47cdc33782","0f195fea-3493-5ebd-966d-da9eddeb05d7","ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac"]}}}},"942975b8-0bef-5ae8-9fcb-c37497833fb0":{"id":"942975b8-0bef-5ae8-9fcb-c37497833fb0","alias":"oa_assessment_action","name":"Assess pressure alarm","description":"","parent":"022d3cfa-bbaf-5e13-924a-8e55099b8745","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"61f7bbfb-74a4-5540-9182-2583dfb27a49","lifeline":null}},"precondition":"","postcondition":""}}}}},"9497e10f-04c4-5586-8738-2464a6f00bd4":{"id":"9497e10f-04c4-5586-8738-2464a6f00bd4","alias":"sa_repair","name":"Repair valve in the field","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"98c61788-2fee-51f4-8cf9-de48e8c70e9e":{"id":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","alias":"sa_caller","name":"Controller","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Lifeline":{"represented":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0"}}}}},"98d411e7-7c65-5c3f-b543-8bf0f2522ea2":{"id":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","alias":"sa_control_port","name":"Repair service client","description":"Explicitly chosen Standard component port for the repair service.","parent":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","body":{"Sa":{"Shared":{"Port":{"contract":{"Component":{"kind":"Standard","orientation":"Unset"}},"legacy":null}}}}},"994faed7-dbc8-5b96-9d62-7604ac0fbc3a":{"id":"994faed7-dbc8-5b96-9d62-7604ac0fbc3a","alias":"sa_repair_report_out","name":"Report repair result output","description":"","parent":"9497e10f-04c4-5586-8738-2464a6f00bd4","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionOutput":{"outgoing_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5":{"id":"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5","alias":"oa_use_conversation","name":"Use the reviewed repair conversation","description":"","parent":"d6f881a4-95da-54e9-ad97-1b8965532629","body":{"Oa":{"Shared":{"Occurrence":{"body":{"ScenarioUse":{"scenario":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","bindings":[{"referenced":"41859a65-ded2-547b-ab03-14e2fa740cf9","local":"2011b3ac-614b-5a47-8891-7ff81965f425"},{"referenced":"fc81c5f0-0442-5b88-992b-e5b10f351c59","local":"fffff882-78fd-5415-928d-c1ef7fac6576"}]}},"precondition":"","postcondition":""}}}}},"a21465af-05f3-5fb5-8983-de26932422fb":{"id":"a21465af-05f3-5fb5-8983-de26932422fb","alias":"oa_repair","name":"Repair valve in the field","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"a4b8942e-a975-5f88-ad14-8f38c345baac":{"id":"a4b8942e-a975-5f88-ad14-8f38c345baac","alias":"sa_repair_interface","name":"Valve repair service","description":"","parent":null,"body":{"Sa":{"Shared":{"Interface":{"supertype":null}}}}},"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37":{"id":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","alias":"sa_waiting","name":"Awaiting repair","description":"","parent":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","body":{"Sa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"a7ffb869-9dde-5e76-b276-c719c5c143a9":{"id":"a7ffb869-9dde-5e76-b276-c719c5c143a9","alias":"oa_monitor_initial","name":"Start","description":"","parent":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","body":{"Oa":{"Shared":{"State":{"kind":"Initial","entry":"","exit":"","do_activity":""}}}}},"a962fc5e-3ab0-5296-8b0d-fd84633f9218":{"id":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","alias":"sa_repair_operation","name":"Valve repair","description":"","parent":null,"body":{"Sa":{"Shared":{"ExchangeItem":{"kind":"Information","mechanism":"Operation","supertype":null}}}}},"acd5a018-f559-5298-a40d-1cf6e1353e74":{"id":"acd5a018-f559-5298-a40d-1cf6e1353e74","alias":"sa_assessment_procedure","name":"Assess an alarm procedure","description":"","parent":null,"body":{"Sa":{"FunctionalChain":{"steps":["bae5b192-5b8e-51c6-82ae-2331af0d33bf"]}}}},"b280c6ca-e5f9-5424-a5c1-5676e01c2371":{"id":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","alias":"oa_decision","name":"Can a remote reset safely recover pressure?","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Choice"}},"precondition":"","postcondition":""}}}}},"b97437c6-cd7a-5a05-b537-b30a17dd936a":{"id":"b97437c6-cd7a-5a05-b537-b30a17dd936a","alias":"oa_call","name":"Request valve repair","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","sender":"41859a65-ded2-547b-ab03-14e2fa740cf9","receiver":"fc81c5f0-0442-5b88-992b-e5b10f351c59","message_kind":"Call","reply_to":null,"values":[{"path":[],"field":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","expression":"420.0"}]}},"precondition":"","postcondition":""}}}}},"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f":{"id":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f","alias":"sa_triage","name":"Assess an alarm","description":"","parent":null,"body":{"Sa":"Capability"}},"bae5b192-5b8e-51c6-82ae-2331af0d33bf":{"id":"bae5b192-5b8e-51c6-82ae-2331af0d33bf","alias":"sa_assessment_action","name":"Assess pressure alarm","description":"","parent":"acd5a018-f559-5298-a40d-1cf6e1353e74","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"dddae020-a444-5d48-acc8-139d2696a672","lifeline":null}},"precondition":"","postcondition":""}}}}},"bb12ba5d-aab5-5a1b-99de-c767f5365b57":{"id":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","alias":"sa_pressure_parameter","name":"Pressure before / after repair","description":"","parent":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":"87d0a95b-af1b-5352-b753-e4c6533671a5"},"minimum":1,"maximum":1,"unit":"","direction":"InOut","storage":"Value","default":null}}}}},"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6":{"id":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","alias":"sa_reset_step","name":"Try the authorized remote reset","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"09342ccb-044b-5483-ac58-87dc3cf7831c","lifeline":null}},"precondition":"","postcondition":""}}}}},"c05b9422-b9da-5de5-b21d-07ad23bd2b8a":{"id":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","alias":"sa_technician","name":"Field technician","description":"","parent":null,"body":{"Sa":"Actor"}},"c59432fe-6092-5ee0-908d-9dbf39476471":{"id":"c59432fe-6092-5ee0-908d-9dbf39476471","alias":"oa_repair_step","name":"Repair the valve","description":"","parent":"5d3af417-244c-54bb-9588-16daedda4e1f","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"a21465af-05f3-5fb5-8983-de26932422fb","lifeline":null}},"precondition":"","postcondition":""}}}}},"c9c61f50-06e0-5d17-973d-dcc57cd5cc90":{"id":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90","alias":"oa_mission","name":"Maintain safe water supply","description":"","parent":null,"body":{"Oa":"Mission"}},"cc2d497a-fd15-5455-9ee5-e70eb95811d8":{"id":"cc2d497a-fd15-5455-9ee5-e70eb95811d8","alias":"oa_reply","name":"Return pressure after repair","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","sender":"fc81c5f0-0442-5b88-992b-e5b10f351c59","receiver":"41859a65-ded2-547b-ab03-14e2fa740cf9","message_kind":"Reply","reply_to":"b97437c6-cd7a-5a05-b537-b30a17dd936a","values":[{"path":[],"field":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","expression":"320.0"}]}},"precondition":"","postcondition":""}}}}},"d0c8be87-d238-503a-a549-7558a210507f":{"id":"d0c8be87-d238-503a-a549-7558a210507f","alias":"oa_reset","name":"Reset valve remotely","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"d2026fc2-74a8-5e22-9942-ff3eb68a3677":{"id":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","alias":"oa_reset_step","name":"Try the authorized remote reset","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"d0c8be87-d238-503a-a549-7558a210507f","lifeline":null}},"precondition":"","postcondition":""}}}}},"d2113fb0-a531-54ef-9484-13951b00ab8f":{"id":"d2113fb0-a531-54ef-9484-13951b00ab8f","alias":"sa_repair_request_in","name":"Request field repair input","description":"","parent":"9497e10f-04c4-5586-8738-2464a6f00bd4","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionInput":{"incoming_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"d5e61c5d-510b-5091-a878-da46f3a9e152":{"id":"d5e61c5d-510b-5091-a878-da46f3a9e152","alias":"sa_use_field","name":"Use the field repair procedure","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"93eb6479-54e9-59cb-ac7b-16912347bfc0"}},"precondition":"","postcondition":""}}}}},"d6f881a4-95da-54e9-ad97-1b8965532629":{"id":"d6f881a4-95da-54e9-ad97-1b8965532629","alias":"oa_full_scenario","name":"Restore supply through field repair","description":"","parent":null,"body":{"Oa":{"Shared":{"Scenario":{"viewpoint":"OperationalActivity","context":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["9ff94014-afa1-50ab-a4d3-8e9dd0565cc5"]}}}}},"d736c7d6-d369-550a-8ba4-dae223e40bb3":{"id":"d736c7d6-d369-550a-8ba4-dae223e40bb3","alias":"oa_repair_operation","name":"Valve repair","description":"","parent":null,"body":{"Oa":{"Shared":{"ExchangeItem":{"kind":"Information","mechanism":"Operation","supertype":null}}}}},"d75eb3fa-a27d-52d5-8782-8b342b3865f6":{"id":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","alias":"sa_confirm","name":"Confirm safe pressure","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"dddae020-a444-5d48-acc8-139d2696a672":{"id":"dddae020-a444-5d48-acc8-139d2696a672","alias":"sa_assess","name":"Assess pressure alarm","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"df2fd3f8-6bc2-5380-a13f-36643ed35ac7":{"id":"df2fd3f8-6bc2-5380-a13f-36643ed35ac7","alias":"oa_report_step","name":"Report the measured result","description":"","parent":"5d3af417-244c-54bb-9588-16daedda4e1f","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"b436d0b3-c824-5a96-af6b-2c269816d1cc","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"e00594a3-6122-5a6b-b3c2-ecb5506c741d":{"id":"e00594a3-6122-5a6b-b3c2-ecb5506c741d","alias":"oa_confirm_step","name":"Confirm safe pressure","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"727c0b5c-8db6-5eb7-83d6-735519c5949f","lifeline":null}},"precondition":"","postcondition":""}}}}},"e07c3952-4a1a-5cc7-b7d5-03a01101ce49":{"id":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","alias":"oa_technician","name":"Field technician","description":"","parent":null,"body":{"Oa":"Actor"}},"e6d69cbd-99d7-596b-bbe3-dada6118a600":{"id":"e6d69cbd-99d7-596b-bbe3-dada6118a600","alias":"sa_technician_port","name":"Repair service provider","description":"Explicitly chosen Standard component port for the repair service.","parent":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","body":{"Sa":{"Shared":{"Port":{"contract":{"Component":{"kind":"Standard","orientation":"Unset"}},"legacy":null}}}}},"e7824764-5db9-5384-8198-2fbaea2b10b2":{"id":"e7824764-5db9-5384-8198-2fbaea2b10b2","alias":"oa_pressure","name":"Measured pressure","description":"","parent":null,"body":{"Oa":{"Shared":{"DataType":{"definition":{"Real":{"minimum":0.0,"maximum":600.0}},"default":null,"unit":"kPa"}}}}},"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac":{"id":"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac","alias":"sa_report_step","name":"Report the measured result","description":"","parent":"93eb6479-54e9-59cb-ac7b-16912347bfc0","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"684eadc3-e670-5779-a60f-bbc9a5bfb062","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"f746a1cd-ff9f-55c0-b325-01c03d2f4fae":{"id":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","alias":"sa_full_scenario","name":"Restore supply through field repair","description":"","parent":null,"body":{"Sa":{"Shared":{"Scenario":{"viewpoint":"BoundaryInterface","context":"81259051-370a-512d-a3b5-6420a6f933b6","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["1c9da843-e2d3-5591-8e3f-db083a42867e"]}}}}},"fc6763bf-a534-54bf-afcd-57ab285ff07d":{"id":"fc6763bf-a534-54bf-afcd-57ab285ff07d","alias":"oa_use_field","name":"Use the field repair procedure","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"5d3af417-244c-54bb-9588-16daedda4e1f"}},"precondition":"","postcondition":""}}}}},"fc81c5f0-0442-5b88-992b-e5b10f351c59":{"id":"fc81c5f0-0442-5b88-992b-e5b10f351c59","alias":"oa_responder","name":"Technician","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Lifeline":{"represented":"a21465af-05f3-5fb5-8983-de26932422fb"}}}}},"fffff882-78fd-5415-928d-c1ef7fac6576":{"id":"fffff882-78fd-5415-928d-c1ef7fac6576","alias":"oa_outer_technician","name":"Technician","description":"","parent":"d6f881a4-95da-54e9-ad97-1b8965532629","body":{"Oa":{"Shared":{"Lifeline":{"represented":"a21465af-05f3-5fb5-8983-de26932422fb"}}}}}},"relations":{"09eaa992-4cdf-5ea3-b748-f10fd74b8cba":{"id":"09eaa992-4cdf-5ea3-b748-f10fd74b8cba","alias":"sa_port_request_in","name":"Port allocation: Request field repair input","description":"","body":{"PortAllocation":{"source":"e6d69cbd-99d7-596b-bbe3-dada6118a600","target":"d2113fb0-a531-54ef-9484-13951b00ab8f"}}},"0c4c9e58-ff32-5c00-b82a-d408a4be0183":{"id":"0c4c9e58-ff32-5c00-b82a-d408a4be0183","alias":"oa_mission_participant","name":"Mission involvement: Maintain safe water supply","description":"","body":{"MissionInvolvement":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90"}}},"0f6ac3fb-4ad6-581e-ad14-336472072d5e":{"id":"0f6ac3fb-4ad6-581e-ad14-336472072d5e","alias":"sa_field_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"d5e61c5d-510b-5091-a878-da46f3a9e152","target":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","guard":""}}},"109e2d6b-7e86-5934-93a5-fab56eced84e":{"id":"109e2d6b-7e86-5934-93a5-fab56eced84e","alias":"oa_assess_then_decide","name":"Continue","description":"","body":{"SequenceLink":{"source":"0b07b9ed-492d-57f6-b0b1-ce8a709238a7","target":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","guard":""}}},"13c3bfa4-9254-5ab2-b96f-f65f402adf81":{"id":"13c3bfa4-9254-5ab2-b96f-f65f402adf81","alias":"sa_alarm_transition","name":"State transition: Awaiting repair","description":"","body":{"StateTransition":{"source":"8af21f9b-c21e-5aae-af0d-0958840f6655","target":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","transition_kind":"External","triggers":["01490fa8-2f01-5ac8-9722-d55d45414379"],"effects":[],"trigger":"","guard":"","effect":""}}},"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a":{"id":"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a","alias":"oa_reset_allocation","name":"Allocation: Reset valve remotely","description":"","body":{"Allocation":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"d0c8be87-d238-503a-a549-7558a210507f"}}},"1b245065-7242-5d81-8472-aaeb932054c9":{"id":"1b245065-7242-5d81-8472-aaeb932054c9","alias":"sa_audit_allocation","name":"Allocation: Audit pressure thresholds","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"4149a872-3440-59de-9879-8981e78d27ab"}}},"1bbd5957-1173-51d8-97e3-ba4f084227ae":{"id":"1bbd5957-1173-51d8-97e3-ba4f084227ae","alias":"sa_field_path","name":"else","description":"","body":{"SequenceLink":{"source":"30c8fad9-a49b-5692-9542-3148e76afd7b","target":"d5e61c5d-510b-5091-a878-da46f3a9e152","guard":"else"}}},"1c1e5634-ba8c-591f-b143-90a27cdc6cd2":{"id":"1c1e5634-ba8c-591f-b143-90a27cdc6cd2","alias":"sa_mission_participant","name":"Mission involvement: Maintain safe water supply","description":"","body":{"MissionInvolvement":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"5ad034b1-084d-5273-9e70-971671766132"}}},"1e738f27-a3a1-526d-b330-6384088b9689":{"id":"1e738f27-a3a1-526d-b330-6384088b9689","alias":"sa_confirm_path","name":"Continue","description":"","body":{"SequenceLink":{"source":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","target":"47bec4f9-d6d0-5ed1-adca-fa0d960dc416","guard":""}}},"1f8b1898-65f6-5360-99cf-b78a71bd50ec":{"id":"1f8b1898-65f6-5360-99cf-b78a71bd50ec","alias":"oa_mission_delivery","name":"Mission capability: Restore a safe supply","description":"","body":{"MissionCapability":{"source":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90","target":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb"}}},"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97":{"id":"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97","alias":"oa_normal_transition","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"58c15833-6e75-523a-b57f-a88f19f6d672","target":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"repair confirmed and pressure is safe","effect":""}}},"229ee94f-d147-50f6-9769-b5bd0c03a084":{"id":"229ee94f-d147-50f6-9769-b5bd0c03a084","alias":"oa_confirm_path","name":"Continue","description":"","body":{"SequenceLink":{"source":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","target":"e00594a3-6122-5a6b-b3c2-ecb5506c741d","guard":""}}},"2b00270c-79a5-52a5-af96-33346bea531b":{"id":"2b00270c-79a5-52a5-af96-33346bea531b","alias":"oa_start_monitoring","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"a7ffb869-9dde-5e76-b276-c719c5c143a9","target":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"","effect":""}}},"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3":{"id":"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3","alias":"sa_reset_allocation","name":"Allocation: Reset valve remotely","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"09342ccb-044b-5483-ac58-87dc3cf7831c"}}},"3f54d890-4ca5-5e36-873b-abb5c92d578c":{"id":"3f54d890-4ca5-5e36-873b-abb5c92d578c","alias":"oa_request_then_repair","name":"Sequence link: Repair the valve","description":"","body":{"SequenceLink":{"source":"5ba8079c-2dbe-5e98-ae82-2b60c6020a02","target":"c59432fe-6092-5ee0-908d-9dbf39476471","guard":""}}},"47730532-0372-5cc5-9d6d-0b8953aebdd8":{"id":"47730532-0372-5cc5-9d6d-0b8953aebdd8","alias":"sa_start_monitoring","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"57eb5348-14d8-560e-996a-8dbf993243e2","target":"8af21f9b-c21e-5aae-af0d-0958840f6655","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"","effect":""}}},"4c4c4851-5a3d-5bb9-94df-b19f9cf39819":{"id":"4c4c4851-5a3d-5bb9-94df-b19f9cf39819","alias":"sa_request_carriage","name":"Exchange allocation: Request field repair","description":"","body":{"ExchangeAllocation":{"source":"63009b5f-539e-5914-b630-ec6b7fefb4d5","target":"93474111-f23e-56c1-b61b-b7c2396b91bd"}}},"4f85337b-f6eb-533c-a45a-9a5d1d9567ca":{"id":"4f85337b-f6eb-533c-a45a-9a5d1d9567ca","alias":"sa_client_requires","name":"Interface requirement: Valve repair service","description":"","body":{"InterfaceRequirement":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"a4b8942e-a975-5f88-ad14-8f38c345baac"}}},"55ec4dbf-bd17-5b0c-b3a3-95b2194aa8b9":{"id":"55ec4dbf-bd17-5b0c-b3a3-95b2194aa8b9","alias":"sa_port_request_out","name":"Port allocation: Request field repair output","description":"","body":{"PortAllocation":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"24a6850e-b819-57e0-af88-ca4ab76581b7"}}},"55f89fbf-aa3c-5ae1-95ef-ac676f9c1ada":{"id":"55f89fbf-aa3c-5ae1-95ef-ac676f9c1ada","alias":"sa_provider_provides","name":"Interface provision: Valve repair service","description":"","body":{"InterfaceProvision":{"source":"e6d69cbd-99d7-596b-bbe3-dada6118a600","target":"a4b8942e-a975-5f88-ad14-8f38c345baac"}}},"568c5c01-de21-52a2-83bd-afdddc158085":{"id":"568c5c01-de21-52a2-83bd-afdddc158085","alias":"sa_port_report_in","name":"Port allocation: Report repair result input","description":"","body":{"PortAllocation":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"391af7c4-b07d-53a7-8ddd-5c81461fc4f8"}}},"594c61cd-94e0-5c07-890d-edfd520d7df9":{"id":"594c61cd-94e0-5c07-890d-edfd520d7df9","alias":"oa_assess_allocation","name":"Allocation: Assess pressure alarm","description":"","body":{"Allocation":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"61f7bbfb-74a4-5540-9182-2583dfb27a49"}}},"628cb4fc-fe63-5f6b-b364-6268f26b5740":{"id":"628cb4fc-fe63-5f6b-b364-6268f26b5740","alias":"oa_assessment_delivery","name":"Chain involvement: Assess an alarm","description":"","body":{"ChainInvolvement":{"source":"022d3cfa-bbaf-5e13-924a-8e55099b8745","target":"6a30b763-cc51-546d-907f-5c63657cfd03"}}},"63009b5f-539e-5914-b630-ec6b7fefb4d5":{"id":"63009b5f-539e-5914-b630-ec6b7fefb4d5","alias":"sa_contact","name":"Technician service conversation","description":"","body":{"BoundaryExchange":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"e6d69cbd-99d7-596b-bbe3-dada6118a600","oriented":false,"items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}}},"66efc484-53c7-51a3-89e9-422429344b38":{"id":"66efc484-53c7-51a3-89e9-422429344b38","alias":"sa_response_delivery","name":"Chain involvement: Restore a safe supply","description":"","body":{"ChainInvolvement":{"source":"5139ce73-56fd-5875-ba3a-e569fc890231","target":"81259051-370a-512d-a3b5-6420a6f933b6"}}},"684eadc3-e670-5779-a60f-bbc9a5bfb062":{"id":"684eadc3-e670-5779-a60f-bbc9a5bfb062","alias":"sa_repair_report","name":"Report repair result","description":"","body":{"FunctionalExchange":{"source":"9497e10f-04c4-5586-8738-2464a6f00bd4","target":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","source_port":"994faed7-dbc8-5b96-9d62-7604ac0fbc3a","target_port":"391af7c4-b07d-53a7-8ddd-5c81461fc4f8","items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}}},"6a6d968f-26d6-5dbb-b721-5a27d362779a":{"id":"6a6d968f-26d6-5dbb-b721-5a27d362779a","alias":"sa_request_then_repair","name":"Sequence link: Repair the valve","description":"","body":{"SequenceLink":{"source":"40d94322-baa2-5c81-b261-ff47cdc33782","target":"0f195fea-3493-5ebd-966d-da9eddeb05d7","guard":""}}},"6e2c1b47-764c-577a-b325-ff66dd2a2768":{"id":"6e2c1b47-764c-577a-b325-ff66dd2a2768","alias":"sa_normal_transition","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","target":"8af21f9b-c21e-5aae-af0d-0958840f6655","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"repair confirmed and pressure is safe","effect":""}}},"725f2e1b-aa2c-5615-9b1f-28da178168fa":{"id":"725f2e1b-aa2c-5615-9b1f-28da178168fa","alias":"sa_repair_allocation","name":"Allocation: Repair valve in the field","description":"","body":{"Allocation":{"source":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","target":"9497e10f-04c4-5586-8738-2464a6f00bd4"}}},"76218d89-68fd-531d-b35b-90c5b769a5ef":{"id":"76218d89-68fd-531d-b35b-90c5b769a5ef","alias":"oa_remote_path","name":"remote reset is authorized","description":"","body":{"SequenceLink":{"source":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","target":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","guard":"remote reset is authorized"}}},"7fa37e63-db65-5aa7-88f2-31ff4347a787":{"id":"7fa37e63-db65-5aa7-88f2-31ff4347a787","alias":"oa_assess_on_entry","name":"Assess when repair is needed","description":"","body":{"StateAction":{"source":"58c15833-6e75-523a-b57f-a88f19f6d672","target":"61f7bbfb-74a4-5540-9182-2583dfb27a49","phase":"Entry"}}},"7fb30b1d-195c-5f0e-a19c-a536f519a899":{"id":"7fb30b1d-195c-5f0e-a19c-a536f519a899","alias":"sa_repair_then_report","name":"Sequence link: Report the measured result","description":"","body":{"SequenceLink":{"source":"0f195fea-3493-5ebd-966d-da9eddeb05d7","target":"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac","guard":""}}},"93474111-f23e-56c1-b61b-b7c2396b91bd":{"id":"93474111-f23e-56c1-b61b-b7c2396b91bd","alias":"sa_repair_request","name":"Request field repair","description":"","body":{"FunctionalExchange":{"source":"dddae020-a444-5d48-acc8-139d2696a672","target":"9497e10f-04c4-5586-8738-2464a6f00bd4","source_port":"24a6850e-b819-57e0-af88-ca4ab76581b7","target_port":"d2113fb0-a531-54ef-9484-13951b00ab8f","items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}}},"93eaad8d-83b7-5980-b9bb-86c9e3f31da4":{"id":"93eaad8d-83b7-5980-b9bb-86c9e3f31da4","alias":"sa_assessment_support","name":"Capability involvement: Assess an alarm","description":"","body":{"CapabilityInvolvement":{"source":"dddae020-a444-5d48-acc8-139d2696a672","target":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f"}}},"9b9356d7-2db2-5a94-a47e-ea892cf02afe":{"id":"9b9356d7-2db2-5a94-a47e-ea892cf02afe","alias":"sa_assess_allocation","name":"Allocation: Assess pressure alarm","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"dddae020-a444-5d48-acc8-139d2696a672"}}},"9d4a131f-36ad-5125-b21f-c3dd033b8c82":{"id":"9d4a131f-36ad-5125-b21f-c3dd033b8c82","alias":"sa_assess_then_decide","name":"Continue","description":"","body":{"SequenceLink":{"source":"9024243c-ccc1-5edb-8477-2aa16e5e9a50","target":"30c8fad9-a49b-5692-9542-3148e76afd7b","guard":""}}},"a0902e0f-3d18-576a-9aca-975a136dfdd7":{"id":"a0902e0f-3d18-576a-9aca-975a136dfdd7","alias":"sa_mission_delivery","name":"Mission capability: Restore a safe supply","description":"","body":{"MissionCapability":{"source":"5ad034b1-084d-5273-9e70-971671766132","target":"81259051-370a-512d-a3b5-6420a6f933b6"}}},"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57":{"id":"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57","alias":"sa_assessment_delivery","name":"Chain involvement: Assess an alarm","description":"","body":{"ChainInvolvement":{"source":"acd5a018-f559-5298-a40d-1cf6e1353e74","target":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f"}}},"a887f96a-37cb-5891-8070-011fa276e5b5":{"id":"a887f96a-37cb-5891-8070-011fa276e5b5","alias":"sa_assess_on_entry","name":"Assess when repair is needed","description":"","body":{"StateAction":{"source":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","target":"dddae020-a444-5d48-acc8-139d2696a672","phase":"Entry"}}},"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2":{"id":"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2","alias":"oa_repair_allocation","name":"Allocation: Repair valve in the field","description":"","body":{"Allocation":{"source":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","target":"a21465af-05f3-5fb5-8983-de26932422fb"}}},"b436d0b3-c824-5a96-af6b-2c269816d1cc":{"id":"b436d0b3-c824-5a96-af6b-2c269816d1cc","alias":"oa_repair_report","name":"Report repair result","description":"","body":{"Interaction":{"source":"a21465af-05f3-5fb5-8983-de26932422fb","target":"727c0b5c-8db6-5eb7-83d6-735519c5949f","items":["d736c7d6-d369-550a-8ba4-dae223e40bb3"]}}},"b76bf8de-2fa4-563f-a540-dd30f30e6c49":{"id":"b76bf8de-2fa4-563f-a540-dd30f30e6c49","alias":"sa_confirm_allocation","name":"Allocation: Confirm safe pressure","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"d75eb3fa-a27d-52d5-8782-8b342b3865f6"}}},"bac43d3b-910b-5bdc-be9b-f78cf973e1ed":{"id":"bac43d3b-910b-5bdc-be9b-f78cf973e1ed","alias":"oa_remote_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","target":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","guard":""}}},"bfd4326e-ea65-5269-b5e6-e063a355d1e9":{"id":"bfd4326e-ea65-5269-b5e6-e063a355d1e9","alias":"oa_confirm_allocation","name":"Allocation: Confirm safe pressure","description":"","body":{"Allocation":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"727c0b5c-8db6-5eb7-83d6-735519c5949f"}}},"c1adbbd0-a762-5ee3-8b9a-5c18af377771":{"id":"c1adbbd0-a762-5ee3-8b9a-5c18af377771","alias":"oa_field_path","name":"else","description":"","body":{"SequenceLink":{"source":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","target":"fc6763bf-a534-54bf-afcd-57ab285ff07d","guard":"else"}}},"c7c248f8-28b8-55be-9023-0991d44021e3":{"id":"c7c248f8-28b8-55be-9023-0991d44021e3","alias":"oa_response_delivery","name":"Chain involvement: Restore a safe supply","description":"","body":{"ChainInvolvement":{"source":"58bae2b0-f560-50f2-818d-19091d22d18a","target":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb"}}},"c8196d35-2168-5fef-8b03-ba0d139bc00f":{"id":"c8196d35-2168-5fef-8b03-ba0d139bc00f","alias":"oa_repair_then_report","name":"Sequence link: Report the measured result","description":"","body":{"SequenceLink":{"source":"c59432fe-6092-5ee0-908d-9dbf39476471","target":"df2fd3f8-6bc2-5380-a13f-36643ed35ac7","guard":""}}},"c8593670-8906-530f-b6a2-3ada77a9c219":{"id":"c8593670-8906-530f-b6a2-3ada77a9c219","alias":"oa_field_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"fc6763bf-a534-54bf-afcd-57ab285ff07d","target":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","guard":""}}},"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d":{"id":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","alias":"oa_repair_request","name":"Request field repair","description":"","body":{"Interaction":{"source":"61f7bbfb-74a4-5540-9182-2583dfb27a49","target":"a21465af-05f3-5fb5-8983-de26932422fb","items":["d736c7d6-d369-550a-8ba4-dae223e40bb3"]}}},"d8b6710a-e713-5979-934f-e5ca7d155bb4":{"id":"d8b6710a-e713-5979-934f-e5ca7d155bb4","alias":"sa_remote_path","name":"remote reset is authorized","description":"","body":{"SequenceLink":{"source":"30c8fad9-a49b-5692-9542-3148e76afd7b","target":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","guard":"remote reset is authorized"}}},"e21d9a54-ae8e-5618-a4f8-67ee22b92638":{"id":"e21d9a54-ae8e-5618-a4f8-67ee22b92638","alias":"oa_include_triage","name":"Capability include: Assess an alarm","description":"","body":{"CapabilityInclude":{"source":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","target":"6a30b763-cc51-546d-907f-5c63657cfd03"}}},"e5279265-8eac-52e8-98dc-571b20475e1f":{"id":"e5279265-8eac-52e8-98dc-571b20475e1f","alias":"sa_remote_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","target":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","guard":""}}},"e53b7a40-e8a3-5b3f-826d-c2b5c164a84a":{"id":"e53b7a40-e8a3-5b3f-826d-c2b5c164a84a","alias":"sa_port_report_out","name":"Port allocation: Report repair result output","description":"","body":{"PortAllocation":{"source":"e6d69cbd-99d7-596b-bbe3-dada6118a600","target":"994faed7-dbc8-5b96-9d62-7604ac0fbc3a"}}},"f074e041-23cf-54ad-af9c-f171e30621e7":{"id":"f074e041-23cf-54ad-af9c-f171e30621e7","alias":"sa_repair_interface_item","name":"Repair operation contract","description":"","body":{"InterfaceItemAllocation":{"source":"a4b8942e-a975-5f88-ad14-8f38c345baac","target":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","order":0,"transmission":"Synchronous","acquisition":"Accept"}}},"f3fe7690-734a-5877-8b57-a6599a4033c4":{"id":"f3fe7690-734a-5877-8b57-a6599a4033c4","alias":"sa_include_triage","name":"Capability include: Assess an alarm","description":"","body":{"CapabilityInclude":{"source":"81259051-370a-512d-a3b5-6420a6f933b6","target":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f"}}},"fbde9d5e-c7e9-55db-a2be-497345c491b3":{"id":"fbde9d5e-c7e9-55db-a2be-497345c491b3","alias":"oa_alarm_transition","name":"State transition: Awaiting repair","description":"","body":{"StateTransition":{"source":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","target":"58c15833-6e75-523a-b57f-a88f19f6d672","transition_kind":"External","triggers":["65d6b8d6-271a-5c84-8b42-abbe310c955e"],"effects":[],"trigger":"","guard":"","effect":""}}},"ff9897b3-a23a-5994-a5b3-99a5b4d34ceb":{"id":"ff9897b3-a23a-5994-a5b3-99a5b4d34ceb","alias":"sa_report_carriage","name":"Exchange allocation: Report repair result","description":"","body":{"ExchangeAllocation":{"source":"63009b5f-539e-5914-b630-ec6b7fefb4d5","target":"684eadc3-e670-5779-a60f-bbc9a5bfb062"}}},"ffdbfcd0-d423-5707-a880-0dfbe1400f72":{"id":"ffdbfcd0-d423-5707-a880-0dfbe1400f72","alias":"oa_assessment_support","name":"Capability involvement: Assess an alarm","description":"","body":{"CapabilityInvolvement":{"source":"61f7bbfb-74a4-5540-9182-2583dfb27a49","target":"6a30b763-cc51-546d-907f-5c63657cfd03"}}}},"diagrams":{"103ea62c-ac56-5827-94e7-cf09bca96ccc":{"id":"103ea62c-ac56-5827-94e7-cf09bca96ccc","alias":"oa_allocation_view","name":"1 · OA responsibility and repair traffic","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Oa","projection":"Allocation","nodes":{"202ebe37-ddb4-5099-8ce9-ec3e895ab9e9":{"id":"202ebe37-ddb4-5099-8ce9-ec3e895ab9e9","alias":"oa_allocation_view_oa_repair","element":"a21465af-05f3-5fb5-8983-de26932422fb","position":{"x":890.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"3191e05c-1c7f-5f0e-beb0-5f3bded54c48":{"id":"3191e05c-1c7f-5f0e-beb0-5f3bded54c48","alias":"oa_allocation_view_oa_control","element":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","position":{"x":60.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"5ae1c46d-59a3-50fd-8cc3-b292df21f951":{"id":"5ae1c46d-59a3-50fd-8cc3-b292df21f951","alias":"oa_allocation_view_oa_assess","element":"61f7bbfb-74a4-5540-9182-2583dfb27a49","position":{"x":100.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"7066f78c-9736-5349-b94a-0587331b980e":{"id":"7066f78c-9736-5349-b94a-0587331b980e","alias":"oa_allocation_view_oa_reset","element":"d0c8be87-d238-503a-a549-7558a210507f","position":{"x":100.0,"y":330.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"d27f28ef-f10a-5355-9a05-6f2fd39b7de6":{"id":"d27f28ef-f10a-5355-9a05-6f2fd39b7de6","alias":"oa_allocation_view_oa_technician","element":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","position":{"x":850.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"edd74060-ac61-5450-a57c-a6577daa5d50":{"id":"edd74060-ac61-5450-a57c-a6577daa5d50","alias":"oa_allocation_view_oa_confirm","element":"727c0b5c-8db6-5eb7-83d6-735519c5949f","position":{"x":100.0,"y":490.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"30d5baf9-6581-5cb6-846a-bcaa1e6cce63":{"id":"30d5baf9-6581-5cb6-846a-bcaa1e6cce63","alias":"sa_state_view","name":"5 · SA pressure supervision","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":{"StateMachine":{"machine":"132b3f28-3f88-5327-9868-8c8b645ed412"}},"nodes":{"55a74f91-2025-5001-8db8-9566b1dbe783":{"id":"55a74f91-2025-5001-8db8-9566b1dbe783","alias":"sa_state_view_sa_monitor_region","element":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","position":{"x":70.0,"y":95.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"57acbb71-ca86-57fc-ae90-42dad195f0e0":{"id":"57acbb71-ca86-57fc-ae90-42dad195f0e0","alias":"sa_state_view_sa_waiting","element":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","position":{"x":650.0,"y":200.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"5e2326a0-0f6c-5f8d-8a21-fdded18a9281":{"id":"5e2326a0-0f6c-5f8d-8a21-fdded18a9281","alias":"sa_state_view_sa_monitor_initial","element":"57eb5348-14d8-560e-996a-8dbf993243e2","position":{"x":100.0,"y":200.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"a676fbb9-0833-59a8-98cf-a10d8f4b7434":{"id":"a676fbb9-0833-59a8-98cf-a10d8f4b7434","alias":"sa_state_view_sa_monitor","element":"132b3f28-3f88-5327-9868-8c8b645ed412","position":{"x":30.0,"y":30.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"b3af7b04-5a45-5b82-ab69-7eada4b876f0":{"id":"b3af7b04-5a45-5b82-ab69-7eada4b876f0","alias":"sa_state_view_sa_monitoring","element":"8af21f9b-c21e-5aae-af0d-0958840f6655","position":{"x":220.0,"y":200.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"35d3c24b-289e-59e5-a610-8333e2c6a4bb":{"id":"35d3c24b-289e-59e5-a610-8333e2c6a4bb","alias":"sa_allocation_view","name":"2 · SA service boundary and functions","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":"Allocation","nodes":{"34630a18-41b0-5fdd-bff4-ee86e93e8651":{"id":"34630a18-41b0-5fdd-bff4-ee86e93e8651","alias":"sa_allocation_view_sa_repair","element":"9497e10f-04c4-5586-8738-2464a6f00bd4","position":{"x":890.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"60f6ad74-b69e-5aef-b832-f1bd60b903ae":{"id":"60f6ad74-b69e-5aef-b832-f1bd60b903ae","alias":"sa_allocation_view_sa_assess","element":"dddae020-a444-5d48-acc8-139d2696a672","position":{"x":100.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"73406244-ebd7-5995-86c7-e17c73c50b32":{"id":"73406244-ebd7-5995-86c7-e17c73c50b32","alias":"sa_allocation_view_sa_reset","element":"09342ccb-044b-5483-ac58-87dc3cf7831c","position":{"x":100.0,"y":330.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"7e3ba36f-bc68-5520-bdb9-77083bb7bba2":{"id":"7e3ba36f-bc68-5520-bdb9-77083bb7bba2","alias":"sa_allocation_view_sa_confirm","element":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","position":{"x":100.0,"y":490.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"8345c76a-8d85-5c31-902e-ec1cd33ed4a3":{"id":"8345c76a-8d85-5c31-902e-ec1cd33ed4a3","alias":"sa_allocation_view_sa_control","element":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","position":{"x":60.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"f7225d1c-e506-505d-bb20-916950269d4b":{"id":"f7225d1c-e506-505d-bb20-916950269d4b","alias":"sa_allocation_view_sa_technician","element":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","position":{"x":850.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"3e6ad194-61b5-584c-b0f0-a78a20af5781":{"id":"3e6ad194-61b5-584c-b0f0-a78a20af5781","alias":"sa_capability_view","name":"4 · SA mission and included capability","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":"Capabilities","nodes":{"069a3fb3-077c-59d8-91da-27b0665143c7":{"id":"069a3fb3-077c-59d8-91da-27b0665143c7","alias":"sa_capability_view_sa_triage","element":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f","position":{"x":800.0,"y":260.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"41ddb68b-bd2b-5f91-82eb-efde01157828":{"id":"41ddb68b-bd2b-5f91-82eb-efde01157828","alias":"sa_capability_view_sa_control","element":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","position":{"x":60.0,"y":60.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"4b814335-bf98-5b68-85d5-ea549766517a":{"id":"4b814335-bf98-5b68-85d5-ea549766517a","alias":"sa_capability_view_sa_mission","element":"5ad034b1-084d-5273-9e70-971671766132","position":{"x":420.0,"y":60.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"d3bc13f9-7726-5280-83fe-63f391c45afb":{"id":"d3bc13f9-7726-5280-83fe-63f391c45afb","alias":"sa_capability_view_sa_restore","element":"81259051-370a-512d-a3b5-6420a6f933b6","position":{"x":420.0,"y":260.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"50af4c9b-93ce-5ff5-a1f8-7a81eb97e779":{"id":"50af4c9b-93ce-5ff5-a1f8-7a81eb97e779","alias":"sa_service_view","name":"3 · SA service conversation only","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":"Allocation","nodes":{"40210589-69f7-5209-b329-10bb83cf615c":{"id":"40210589-69f7-5209-b329-10bb83cf615c","alias":"sa_service_view_sa_technician","element":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","position":{"x":850.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"439f1e45-e5ed-567c-978b-4c93ef267db1":{"id":"439f1e45-e5ed-567c-978b-4c93ef267db1","alias":"sa_service_view_sa_assess","element":"dddae020-a444-5d48-acc8-139d2696a672","position":{"x":100.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"6f138672-149b-5f88-ab07-7fc413a42262":{"id":"6f138672-149b-5f88-ab07-7fc413a42262","alias":"sa_service_view_sa_confirm","element":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","position":{"x":100.0,"y":490.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"ae36489d-6ec8-5f1c-b603-8b497227efba":{"id":"ae36489d-6ec8-5f1c-b603-8b497227efba","alias":"sa_service_view_sa_repair","element":"9497e10f-04c4-5586-8738-2464a6f00bd4","position":{"x":890.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"d52da4e6-40b7-5dbc-84fb-80c443711c69":{"id":"d52da4e6-40b7-5dbc-84fb-80c443711c69","alias":"sa_service_view_sa_reset","element":"09342ccb-044b-5483-ac58-87dc3cf7831c","position":{"x":100.0,"y":330.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"f53d4352-b042-56a6-8b3e-c51cd27254f9":{"id":"f53d4352-b042-56a6-8b3e-c51cd27254f9","alias":"sa_service_view_sa_control","element":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","position":{"x":60.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]}},"artifact_links":{"f5922bf7-05d8-590f-86e9-1d09f37f4fa6":{"id":"f5922bf7-05d8-590f-86e9-1d09f37f4fa6","alias":"repair_test_evidence","name":"Repair conversation acceptance notes","description":"A local teaching artifact, not proof of a real valve's safety.","owner":"730b27ca-b039-5a3b-8dc6-03418f9061b3","relation":"Verifies","uri":"evidence/repair-check.md","revision":"lesson-1","fragment":"acceptance"}},"traces":{"0754be23-e4b8-53bc-b03c-89c2dbd80361":{"id":"0754be23-e4b8-53bc-b03c-89c2dbd80361","alias":"trace_oa_waiting","name":"Awaiting repair ↔ Awaiting repair","description":"","oa":"58c15833-6e75-523a-b57f-a88f19f6d672","sa":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","kind":"State","baseline":{"oa":{"name":"Awaiting repair","description":""},"sa":{"name":"Awaiting repair","description":""}}},"07a7b82a-b5a7-5737-abfa-09e422f7420e":{"id":"07a7b82a-b5a7-5737-abfa-09e422f7420e","alias":"trace_oa_start_monitoring","name":"State transition: Monitoring ↔ State transition: Monitoring","description":"","oa":"2b00270c-79a5-52a5-af96-33346bea531b","sa":"47730532-0372-5cc5-9d6d-0b8953aebdd8","kind":"Manual","baseline":{"oa":{"name":"State transition: Monitoring","description":""},"sa":{"name":"State transition: Monitoring","description":""}}},"0c7c8a06-163e-5b3e-8a98-e66e2ce8963b":{"id":"0c7c8a06-163e-5b3e-8a98-e66e2ce8963b","alias":"trace_oa_alarm_transition","name":"State transition: Awaiting repair ↔ State transition: Awaiting repair","description":"","oa":"fbde9d5e-c7e9-55db-a2be-497345c491b3","sa":"13c3bfa4-9254-5ab2-b96f-f65f402adf81","kind":"Manual","baseline":{"oa":{"name":"State transition: Awaiting repair","description":""},"sa":{"name":"State transition: Awaiting repair","description":""}}},"0c9de959-d366-5765-98eb-450ea27bd316":{"id":"0c9de959-d366-5765-98eb-450ea27bd316","alias":"trace_oa_merge","name":"Recovery path completed ↔ Recovery path completed","description":"","oa":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","sa":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","kind":"Sequence","baseline":{"oa":{"name":"Recovery path completed","description":""},"sa":{"name":"Recovery path completed","description":""}}},"0d905fbb-5b3c-56f0-9f11-a27091901eb0":{"id":"0d905fbb-5b3c-56f0-9f11-a27091901eb0","alias":"trace_oa_pressure_parameter","name":"Pressure before / after repair ↔ Pressure before / after repair","description":"","oa":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","sa":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","kind":"Item","baseline":{"oa":{"name":"Pressure before / after repair","description":""},"sa":{"name":"Pressure before / after repair","description":""}}},"0fbef164-b5d3-58ba-ba41-54d0e63709ab":{"id":"0fbef164-b5d3-58ba-ba41-54d0e63709ab","alias":"trace_oa_restore","name":"Restore a safe supply ↔ Restore a safe supply","description":"","oa":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","sa":"81259051-370a-512d-a3b5-6420a6f933b6","kind":"Capability","baseline":{"oa":{"name":"Restore a safe supply","description":""},"sa":{"name":"Restore a safe supply","description":""}}},"12ec320f-2e14-5a5a-8c6b-bd2b6471dae9":{"id":"12ec320f-2e14-5a5a-8c6b-bd2b6471dae9","alias":"trace_oa_monitoring","name":"Monitoring ↔ Monitoring","description":"","oa":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","sa":"8af21f9b-c21e-5aae-af0d-0958840f6655","kind":"State","baseline":{"oa":{"name":"Monitoring","description":""},"sa":{"name":"Monitoring","description":""}}},"154abfce-c311-52d1-85d1-3f4c85203b26":{"id":"154abfce-c311-52d1-85d1-3f4c85203b26","alias":"trace_oa_confirm_step","name":"Confirm safe pressure ↔ Confirm safe pressure","description":"","oa":"e00594a3-6122-5a6b-b3c2-ecb5506c741d","sa":"47bec4f9-d6d0-5ed1-adca-fa0d960dc416","kind":"Sequence","baseline":{"oa":{"name":"Confirm safe pressure","description":""},"sa":{"name":"Confirm safe pressure","description":""}}},"1b788e74-36b2-5c17-a936-642dbe51ca3c":{"id":"1b788e74-36b2-5c17-a936-642dbe51ca3c","alias":"trace_oa_remote_merge","name":"Continue ↔ Continue","description":"","oa":"bac43d3b-910b-5bdc-be9b-f78cf973e1ed","sa":"e5279265-8eac-52e8-98dc-571b20475e1f","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"208753e0-9151-50d8-86ca-29fc28eca80b":{"id":"208753e0-9151-50d8-86ca-29fc28eca80b","alias":"trace_oa_monitor_region","name":"Supervision cycle ↔ Supervision cycle","description":"","oa":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","sa":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","kind":"State","baseline":{"oa":{"name":"Supervision cycle","description":""},"sa":{"name":"Supervision cycle","description":""}}},"241dfdac-2450-5704-a4cb-9f1047db1b6d":{"id":"241dfdac-2450-5704-a4cb-9f1047db1b6d","alias":"trace_oa_caller","name":"Controller ↔ Controller","description":"","oa":"41859a65-ded2-547b-ab03-14e2fa740cf9","sa":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","kind":"Scenario","baseline":{"oa":{"name":"Controller","description":""},"sa":{"name":"Controller","description":""}}},"2e849e31-35aa-5aef-a989-26948038e52b":{"id":"2e849e31-35aa-5aef-a989-26948038e52b","alias":"trace_oa_field_path","name":"else ↔ else","description":"","oa":"c1adbbd0-a762-5ee3-8b9a-5c18af377771","sa":"1bbd5957-1173-51d8-97e3-ba4f084227ae","kind":"Manual","baseline":{"oa":{"name":"else","description":""},"sa":{"name":"else","description":""}}},"2fdcbc6f-526e-5a4e-bde5-314cf0cf3ef3":{"id":"2fdcbc6f-526e-5a4e-bde5-314cf0cf3ef3","alias":"trace_oa_confirm_allocation","name":"Allocation: Confirm safe pressure ↔ Allocation: Confirm safe pressure","description":"","oa":"bfd4326e-ea65-5269-b5e6-e063a355d1e9","sa":"b76bf8de-2fa4-563f-a540-dd30f30e6c49","kind":"Manual","baseline":{"oa":{"name":"Allocation: Confirm safe pressure","description":""},"sa":{"name":"Allocation: Confirm safe pressure","description":""}}},"3694c752-ba80-5702-a7d7-9cc66b6b01d8":{"id":"3694c752-ba80-5702-a7d7-9cc66b6b01d8","alias":"trace_oa_conversation","name":"Repair request and measured reply ↔ Repair request and measured reply","description":"","oa":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","sa":"730b27ca-b039-5a3b-8dc6-03418f9061b3","kind":"Scenario","baseline":{"oa":{"name":"Repair request and measured reply","description":""},"sa":{"name":"Repair request and measured reply","description":""}}},"3714d547-006f-5c3a-97f8-175ccc23d994":{"id":"3714d547-006f-5c3a-97f8-175ccc23d994","alias":"trace_oa_repair_allocation","name":"Allocation: Repair valve in the field ↔ Allocation: Repair valve in the field","description":"","oa":"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2","sa":"725f2e1b-aa2c-5615-9b1f-28da178168fa","kind":"Manual","baseline":{"oa":{"name":"Allocation: Repair valve in the field","description":""},"sa":{"name":"Allocation: Repair valve in the field","description":""}}},"3a33de40-20a3-5252-899c-c12dc35e9783":{"id":"3a33de40-20a3-5252-899c-c12dc35e9783","alias":"trace_oa_assessment_action","name":"Assess pressure alarm ↔ Assess pressure alarm","description":"","oa":"942975b8-0bef-5ae8-9fcb-c37497833fb0","sa":"bae5b192-5b8e-51c6-82ae-2331af0d33bf","kind":"Sequence","baseline":{"oa":{"name":"Assess pressure alarm","description":""},"sa":{"name":"Assess pressure alarm","description":""}}},"424eca39-1c29-5a2f-b9ee-33dd018926aa":{"id":"424eca39-1c29-5a2f-b9ee-33dd018926aa","alias":"trace_oa_repair_report","name":"Report repair result ↔ Report repair result","description":"","oa":"b436d0b3-c824-5a96-af6b-2c269816d1cc","sa":"684eadc3-e670-5779-a60f-bbc9a5bfb062","kind":"Exchange","baseline":{"oa":{"name":"Report repair result","description":""},"sa":{"name":"Report repair result","description":""}}},"435bae4c-cf6d-59f3-9eb5-d660ebeec079":{"id":"435bae4c-cf6d-59f3-9eb5-d660ebeec079","alias":"trace_oa_full_scenario","name":"Restore supply through field repair ↔ Restore supply through field repair","description":"","oa":"d6f881a4-95da-54e9-ad97-1b8965532629","sa":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","kind":"Scenario","baseline":{"oa":{"name":"Restore supply through field repair","description":""},"sa":{"name":"Restore supply through field repair","description":""}}},"48d243f0-553f-507e-acb0-9474ae156b63":{"id":"48d243f0-553f-507e-acb0-9474ae156b63","alias":"trace_oa_monitor_initial","name":"Start ↔ Start","description":"","oa":"a7ffb869-9dde-5e76-b276-c719c5c143a9","sa":"57eb5348-14d8-560e-996a-8dbf993243e2","kind":"State","baseline":{"oa":{"name":"Start","description":""},"sa":{"name":"Start","description":""}}},"5309f955-53fd-5ff4-9c0a-91737e947341":{"id":"5309f955-53fd-5ff4-9c0a-91737e947341","alias":"trace_oa_assess","name":"Assess pressure alarm ↔ Assess pressure alarm","description":"","oa":"61f7bbfb-74a4-5540-9182-2583dfb27a49","sa":"dddae020-a444-5d48-acc8-139d2696a672","kind":"Refinement","baseline":{"oa":{"name":"Assess pressure alarm","description":""},"sa":{"name":"Assess pressure alarm","description":""}}},"595b5729-ccd9-5291-b5d9-30d8c8bd9dfa":{"id":"595b5729-ccd9-5291-b5d9-30d8c8bd9dfa","alias":"trace_oa_reply","name":"Return pressure after repair ↔ Return pressure after repair","description":"","oa":"cc2d497a-fd15-5455-9ee5-e70eb95811d8","sa":"4e1c0a56-ce80-5f13-a93a-6956de54880c","kind":"Scenario","baseline":{"oa":{"name":"Return pressure after repair","description":""},"sa":{"name":"Return pressure after repair","description":""}}},"5b96bfed-63e2-5d7f-b882-91e8b81f8efe":{"id":"5b96bfed-63e2-5d7f-b882-91e8b81f8efe","alias":"trace_oa_assessment_delivery","name":"Chain involvement: Assess an alarm ↔ Chain involvement: Assess an alarm","description":"","oa":"628cb4fc-fe63-5f6b-b364-6268f26b5740","sa":"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57","kind":"Manual","baseline":{"oa":{"name":"Chain involvement: Assess an alarm","description":""},"sa":{"name":"Chain involvement: Assess an alarm","description":""}}},"6acd072d-431e-5881-ab20-1813b4fda0c5":{"id":"6acd072d-431e-5881-ab20-1813b4fda0c5","alias":"trace_oa_triage","name":"Assess an alarm ↔ Assess an alarm","description":"","oa":"6a30b763-cc51-546d-907f-5c63657cfd03","sa":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f","kind":"Capability","baseline":{"oa":{"name":"Assess an alarm","description":""},"sa":{"name":"Assess an alarm","description":""}}},"6ca4c78a-a5f2-5470-b401-732f5a8825d0":{"id":"6ca4c78a-a5f2-5470-b401-732f5a8825d0","alias":"trace_oa_assessment_procedure","name":"Assess an alarm procedure ↔ Assess an alarm procedure","description":"","oa":"022d3cfa-bbaf-5e13-924a-8e55099b8745","sa":"acd5a018-f559-5298-a40d-1cf6e1353e74","kind":"Sequence","baseline":{"oa":{"name":"Assess an alarm procedure","description":""},"sa":{"name":"Assess an alarm procedure","description":""}}},"6e13cf86-7242-5e33-8912-b037cf6e8c87":{"id":"6e13cf86-7242-5e33-8912-b037cf6e8c87","alias":"trace_oa_assessment_support","name":"Capability involvement: Assess an alarm ↔ Capability involvement: Assess an alarm","description":"","oa":"ffdbfcd0-d423-5707-a880-0dfbe1400f72","sa":"93eaad8d-83b7-5980-b9bb-86c9e3f31da4","kind":"Manual","baseline":{"oa":{"name":"Capability involvement: Assess an alarm","description":""},"sa":{"name":"Capability involvement: Assess an alarm","description":""}}},"70c90b0c-85ce-574a-a217-f0d19fad30e1":{"id":"70c90b0c-85ce-574a-a217-f0d19fad30e1","alias":"trace_oa_response_delivery","name":"Chain involvement: Restore a safe supply ↔ Chain involvement: Restore a safe supply","description":"","oa":"c7c248f8-28b8-55be-9023-0991d44021e3","sa":"66efc484-53c7-51a3-89e9-422429344b38","kind":"Manual","baseline":{"oa":{"name":"Chain involvement: Restore a safe supply","description":""},"sa":{"name":"Chain involvement: Restore a safe supply","description":""}}},"77073fcb-53aa-5f06-987a-0317cbb6971d":{"id":"77073fcb-53aa-5f06-987a-0317cbb6971d","alias":"trace_oa_include_triage","name":"Capability include: Assess an alarm ↔ Capability include: Assess an alarm","description":"","oa":"e21d9a54-ae8e-5618-a4f8-67ee22b92638","sa":"f3fe7690-734a-5877-8b57-a6599a4033c4","kind":"Manual","baseline":{"oa":{"name":"Capability include: Assess an alarm","description":""},"sa":{"name":"Capability include: Assess an alarm","description":""}}},"7a6b4b9c-4220-546a-a752-1587c2881fe1":{"id":"7a6b4b9c-4220-546a-a752-1587c2881fe1","alias":"trace_oa_use_field","name":"Use the field repair procedure ↔ Use the field repair procedure","description":"","oa":"fc6763bf-a534-54bf-afcd-57ab285ff07d","sa":"d5e61c5d-510b-5091-a878-da46f3a9e152","kind":"Sequence","baseline":{"oa":{"name":"Use the field repair procedure","description":""},"sa":{"name":"Use the field repair procedure","description":""}}},"82a5be1a-92a8-578d-9448-086062a20d8f":{"id":"82a5be1a-92a8-578d-9448-086062a20d8f","alias":"trace_oa_reset","name":"Reset valve remotely ↔ Reset valve remotely","description":"","oa":"d0c8be87-d238-503a-a549-7558a210507f","sa":"09342ccb-044b-5483-ac58-87dc3cf7831c","kind":"Refinement","baseline":{"oa":{"name":"Reset valve remotely","description":""},"sa":{"name":"Reset valve remotely","description":""}}},"82cca563-9d5d-5479-aa1a-9db03e9674d1":{"id":"82cca563-9d5d-5479-aa1a-9db03e9674d1","alias":"trace_oa_restore_work","name":"Restore safe pressure ↔ Restore safe pressure","description":"","oa":"1b91f8a7-0bdb-514e-b1cd-955658e09927","sa":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","kind":"Refinement","baseline":{"oa":{"name":"Restore safe pressure","description":""},"sa":{"name":"Restore safe pressure","description":""}}},"8d693d90-5017-56af-b056-d29d3cb46baa":{"id":"8d693d90-5017-56af-b056-d29d3cb46baa","alias":"trace_oa_mission_participant","name":"Mission involvement: Maintain safe water supply ↔ Mission involvement: Maintain safe water supply","description":"","oa":"0c4c9e58-ff32-5c00-b82a-d408a4be0183","sa":"1c1e5634-ba8c-591f-b143-90a27cdc6cd2","kind":"Manual","baseline":{"oa":{"name":"Mission involvement: Maintain safe water supply","description":""},"sa":{"name":"Mission involvement: Maintain safe water supply","description":""}}},"98ef05b5-5381-5d35-a433-ebab55e9f382":{"id":"98ef05b5-5381-5d35-a433-ebab55e9f382","alias":"trace_oa_reset_allocation","name":"Allocation: Reset valve remotely ↔ Allocation: Reset valve remotely","description":"","oa":"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a","sa":"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3","kind":"Manual","baseline":{"oa":{"name":"Allocation: Reset valve remotely","description":""},"sa":{"name":"Allocation: Reset valve remotely","description":""}}},"a106d183-c239-5c9f-b1f1-141af19fc778":{"id":"a106d183-c239-5c9f-b1f1-141af19fc778","alias":"trace_oa_confirm_path","name":"Continue ↔ Continue","description":"","oa":"229ee94f-d147-50f6-9769-b5bd0c03a084","sa":"1e738f27-a3a1-526d-b330-6384088b9689","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"a5c1eb01-a95f-5f7a-9742-7f201493d7aa":{"id":"a5c1eb01-a95f-5f7a-9742-7f201493d7aa","alias":"trace_oa_assess_then_decide","name":"Continue ↔ Continue","description":"","oa":"109e2d6b-7e86-5934-93a5-fab56eced84e","sa":"9d4a131f-36ad-5125-b21f-c3dd033b8c82","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"aa164208-394d-5bc7-8462-b0043f682aad":{"id":"aa164208-394d-5bc7-8462-b0043f682aad","alias":"trace_oa_mission_delivery","name":"Mission capability: Restore a safe supply ↔ Mission capability: Restore a safe supply","description":"","oa":"1f8b1898-65f6-5360-99cf-b78a71bd50ec","sa":"a0902e0f-3d18-576a-9aca-975a136dfdd7","kind":"Manual","baseline":{"oa":{"name":"Mission capability: Restore a safe supply","description":""},"sa":{"name":"Mission capability: Restore a safe supply","description":""}}},"aba528ea-e396-582a-bfba-f6e2212d391a":{"id":"aba528ea-e396-582a-bfba-f6e2212d391a","alias":"trace_oa_control","name":"Control room ↔ Pressure coordination service","description":"","oa":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","sa":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","kind":"Participant","baseline":{"oa":{"name":"Control room","description":""},"sa":{"name":"Pressure coordination service","description":""}}},"ac3aa6b0-568e-5a3f-963a-172aa3cfc7a4":{"id":"ac3aa6b0-568e-5a3f-963a-172aa3cfc7a4","alias":"trace_oa_repair_step","name":"Repair the valve ↔ Repair the valve","description":"","oa":"c59432fe-6092-5ee0-908d-9dbf39476471","sa":"0f195fea-3493-5ebd-966d-da9eddeb05d7","kind":"Sequence","baseline":{"oa":{"name":"Repair the valve","description":""},"sa":{"name":"Repair the valve","description":""}}},"acdbee9c-5017-5154-804e-50801f0376ec":{"id":"acdbee9c-5017-5154-804e-50801f0376ec","alias":"trace_oa_assess_allocation","name":"Allocation: Assess pressure alarm ↔ Allocation: Assess pressure alarm","description":"","oa":"594c61cd-94e0-5c07-890d-edfd520d7df9","sa":"9b9356d7-2db2-5a94-a47e-ea892cf02afe","kind":"Manual","baseline":{"oa":{"name":"Allocation: Assess pressure alarm","description":""},"sa":{"name":"Allocation: Assess pressure alarm","description":""}}},"ad697c00-07d9-5799-89b3-2c78da8dff4d":{"id":"ad697c00-07d9-5799-89b3-2c78da8dff4d","alias":"trace_oa_request_step","name":"Request the field repair ↔ Request the field repair","description":"","oa":"5ba8079c-2dbe-5e98-ae82-2b60c6020a02","sa":"40d94322-baa2-5c81-b261-ff47cdc33782","kind":"Sequence","baseline":{"oa":{"name":"Request the field repair","description":""},"sa":{"name":"Request the field repair","description":""}}},"b13452bb-1a55-5ad6-b6d9-a37fb34e8d13":{"id":"b13452bb-1a55-5ad6-b6d9-a37fb34e8d13","alias":"trace_oa_repair","name":"Repair valve in the field ↔ Repair valve in the field","description":"","oa":"a21465af-05f3-5fb5-8983-de26932422fb","sa":"9497e10f-04c4-5586-8738-2464a6f00bd4","kind":"Refinement","baseline":{"oa":{"name":"Repair valve in the field","description":""},"sa":{"name":"Repair valve in the field","description":""}}},"b53863a2-ed80-5f98-ad3c-50e1b821066d":{"id":"b53863a2-ed80-5f98-ad3c-50e1b821066d","alias":"trace_oa_monitor","name":"Pressure supervision ↔ Pressure supervision","description":"","oa":"1970608f-85d1-5234-ac86-5e2b0df27dc3","sa":"132b3f28-3f88-5327-9868-8c8b645ed412","kind":"State","baseline":{"oa":{"name":"Pressure supervision","description":""},"sa":{"name":"Pressure supervision","description":""}}},"b97749d4-f9ad-5fb7-9d78-bc60a74e98eb":{"id":"b97749d4-f9ad-5fb7-9d78-bc60a74e98eb","alias":"trace_oa_high_pressure","name":"Pressure alarm detected ↔ Pressure alarm detected","description":"","oa":"65d6b8d6-271a-5c84-8b42-abbe310c955e","sa":"01490fa8-2f01-5ac8-9722-d55d45414379","kind":"State","baseline":{"oa":{"name":"Pressure alarm detected","description":""},"sa":{"name":"Pressure alarm detected","description":""}}},"bb72b2c2-6e32-5f65-9d8e-b33c1a65da37":{"id":"bb72b2c2-6e32-5f65-9d8e-b33c1a65da37","alias":"trace_oa_field_procedure","name":"Field repair procedure ↔ Field repair procedure","description":"","oa":"5d3af417-244c-54bb-9588-16daedda4e1f","sa":"93eb6479-54e9-59cb-ac7b-16912347bfc0","kind":"Sequence","baseline":{"oa":{"name":"Field repair procedure","description":""},"sa":{"name":"Field repair procedure","description":""}}},"bbf6f1a1-cb2b-5632-8ec0-bf4b8ef1a941":{"id":"bbf6f1a1-cb2b-5632-8ec0-bf4b8ef1a941","alias":"trace_oa_decision","name":"Can a remote reset safely recover pressure? ↔ Can a remote reset safely recover pressure?","description":"","oa":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","sa":"30c8fad9-a49b-5692-9542-3148e76afd7b","kind":"Sequence","baseline":{"oa":{"name":"Can a remote reset safely recover pressure?","description":""},"sa":{"name":"Can a remote reset safely recover pressure?","description":""}}},"c132a0df-1270-55a8-ac4e-6e5075823dc1":{"id":"c132a0df-1270-55a8-ac4e-6e5075823dc1","alias":"trace_oa_outer_technician","name":"Technician ↔ Technician","description":"","oa":"fffff882-78fd-5415-928d-c1ef7fac6576","sa":"155e4fe4-9e3b-5f4e-b886-28abcc3f086b","kind":"Scenario","baseline":{"oa":{"name":"Technician","description":""},"sa":{"name":"Technician","description":""}}},"c47cf0b4-fe15-5175-bd62-5292e43adc39":{"id":"c47cf0b4-fe15-5175-bd62-5292e43adc39","alias":"trace_oa_normal_transition","name":"State transition: Monitoring ↔ State transition: Monitoring","description":"","oa":"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97","sa":"6e2c1b47-764c-577a-b325-ff66dd2a2768","kind":"Manual","baseline":{"oa":{"name":"State transition: Monitoring","description":""},"sa":{"name":"State transition: Monitoring","description":""}}},"c5051628-b7af-5fb1-a014-bb60a0b46e03":{"id":"c5051628-b7af-5fb1-a014-bb60a0b46e03","alias":"trace_oa_responder","name":"Technician ↔ Technician","description":"","oa":"fc81c5f0-0442-5b88-992b-e5b10f351c59","sa":"6249b54a-e396-5338-82b9-89266c55670d","kind":"Scenario","baseline":{"oa":{"name":"Technician","description":""},"sa":{"name":"Technician","description":""}}},"c987bd0f-87f6-5550-945d-2a42450fe1c5":{"id":"c987bd0f-87f6-5550-945d-2a42450fe1c5","alias":"trace_oa_mission","name":"Maintain safe water supply ↔ Maintain safe water supply","description":"","oa":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90","sa":"5ad034b1-084d-5273-9e70-971671766132","kind":"Mission","baseline":{"oa":{"name":"Maintain safe water supply","description":""},"sa":{"name":"Maintain safe water supply","description":""}}},"cf92cedf-ea1a-510c-9bc5-89c16d60e8d2":{"id":"cf92cedf-ea1a-510c-9bc5-89c16d60e8d2","alias":"trace_oa_repair_operation","name":"Valve repair ↔ Valve repair","description":"","oa":"d736c7d6-d369-550a-8ba4-dae223e40bb3","sa":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","kind":"Item","baseline":{"oa":{"name":"Valve repair","description":""},"sa":{"name":"Valve repair","description":""}}},"d32dd433-9c9e-5b57-9355-2970cc44a5f2":{"id":"d32dd433-9c9e-5b57-9355-2970cc44a5f2","alias":"trace_oa_field_merge","name":"Continue ↔ Continue","description":"","oa":"c8593670-8906-530f-b6a2-3ada77a9c219","sa":"0f6ac3fb-4ad6-581e-ad14-336472072d5e","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"d3626818-4846-5893-9c02-3407cdba7f3c":{"id":"d3626818-4846-5893-9c02-3407cdba7f3c","alias":"trace_oa_response","name":"Handle a pressure alarm ↔ Handle a pressure alarm","description":"","oa":"58bae2b0-f560-50f2-818d-19091d22d18a","sa":"5139ce73-56fd-5875-ba3a-e569fc890231","kind":"Sequence","baseline":{"oa":{"name":"Handle a pressure alarm","description":""},"sa":{"name":"Handle a pressure alarm","description":""}}},"d47d3c36-50fb-5189-b801-d6fd1694bd49":{"id":"d47d3c36-50fb-5189-b801-d6fd1694bd49","alias":"trace_oa_technician","name":"Field technician ↔ Field technician","description":"","oa":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","sa":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","kind":"Participant","baseline":{"oa":{"name":"Field technician","description":""},"sa":{"name":"Field technician","description":""}}},"d7773ff5-cef5-50c1-8f9d-c5659115e1f0":{"id":"d7773ff5-cef5-50c1-8f9d-c5659115e1f0","alias":"trace_oa_confirm","name":"Confirm safe pressure ↔ Confirm safe pressure","description":"","oa":"727c0b5c-8db6-5eb7-83d6-735519c5949f","sa":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","kind":"Refinement","baseline":{"oa":{"name":"Confirm safe pressure","description":""},"sa":{"name":"Confirm safe pressure","description":""}}},"d9d47371-e98d-5d29-9df7-4de2d28a5c73":{"id":"d9d47371-e98d-5d29-9df7-4de2d28a5c73","alias":"trace_oa_pressure","name":"Measured pressure ↔ Measured pressure","description":"","oa":"e7824764-5db9-5384-8198-2fbaea2b10b2","sa":"87d0a95b-af1b-5352-b753-e4c6533671a5","kind":"Item","baseline":{"oa":{"name":"Measured pressure","description":""},"sa":{"name":"Measured pressure","description":""}}},"da8efc3c-78df-5b10-b2e5-1e28d6d99e86":{"id":"da8efc3c-78df-5b10-b2e5-1e28d6d99e86","alias":"trace_oa_repair_request","name":"Request field repair ↔ Request field repair","description":"","oa":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","sa":"93474111-f23e-56c1-b61b-b7c2396b91bd","kind":"Exchange","baseline":{"oa":{"name":"Request field repair","description":""},"sa":{"name":"Request field repair","description":""}}},"e63b14d2-f408-56a2-aec4-be20cf4ace73":{"id":"e63b14d2-f408-56a2-aec4-be20cf4ace73","alias":"trace_oa_report_step","name":"Report the measured result ↔ Report the measured result","description":"","oa":"df2fd3f8-6bc2-5380-a13f-36643ed35ac7","sa":"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac","kind":"Sequence","baseline":{"oa":{"name":"Report the measured result","description":""},"sa":{"name":"Report the measured result","description":""}}},"e848da41-445e-5e3c-8b4e-ef53fdddf1e1":{"id":"e848da41-445e-5e3c-8b4e-ef53fdddf1e1","alias":"trace_oa_assess_step","name":"Assess the alarm ↔ Assess the alarm","description":"","oa":"0b07b9ed-492d-57f6-b0b1-ce8a709238a7","sa":"9024243c-ccc1-5edb-8477-2aa16e5e9a50","kind":"Sequence","baseline":{"oa":{"name":"Assess the alarm","description":""},"sa":{"name":"Assess the alarm","description":""}}},"eee79ec1-41d7-5374-986e-995a56b9026f":{"id":"eee79ec1-41d7-5374-986e-995a56b9026f","alias":"trace_oa_remote_path","name":"remote reset is authorized ↔ remote reset is authorized","description":"","oa":"76218d89-68fd-531d-b35b-90c5b769a5ef","sa":"d8b6710a-e713-5979-934f-e5ca7d155bb4","kind":"Manual","baseline":{"oa":{"name":"remote reset is authorized","description":""},"sa":{"name":"remote reset is authorized","description":""}}},"f0cc86e5-f8fd-5091-a40d-dbfb6515072f":{"id":"f0cc86e5-f8fd-5091-a40d-dbfb6515072f","alias":"trace_oa_call","name":"Request valve repair ↔ Request valve repair","description":"","oa":"b97437c6-cd7a-5a05-b537-b30a17dd936a","sa":"8bab4783-a098-5984-9e36-493957caf26c","kind":"Scenario","baseline":{"oa":{"name":"Request valve repair","description":""},"sa":{"name":"Request valve repair","description":""}}},"f2b7050c-629d-533f-b873-bffc33e072f7":{"id":"f2b7050c-629d-533f-b873-bffc33e072f7","alias":"trace_oa_request_then_repair","name":"Sequence link: Repair the valve ↔ Sequence link: Repair the valve","description":"","oa":"3f54d890-4ca5-5e36-873b-abb5c92d578c","sa":"6a6d968f-26d6-5dbb-b721-5a27d362779a","kind":"Manual","baseline":{"oa":{"name":"Sequence link: Repair the valve","description":""},"sa":{"name":"Sequence link: Repair the valve","description":""}}},"f5733d3f-eee3-5cf7-a422-5edd2054d6a6":{"id":"f5733d3f-eee3-5cf7-a422-5edd2054d6a6","alias":"trace_oa_use_conversation","name":"Use the reviewed repair conversation ↔ Use the reviewed repair conversation","description":"","oa":"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5","sa":"1c9da843-e2d3-5591-8e3f-db083a42867e","kind":"Scenario","baseline":{"oa":{"name":"Use the reviewed repair conversation","description":""},"sa":{"name":"Use the reviewed repair conversation","description":""}}},"f8d34715-1dc8-504b-acdf-78b770aad1e8":{"id":"f8d34715-1dc8-504b-acdf-78b770aad1e8","alias":"trace_oa_repair_then_report","name":"Sequence link: Report the measured result ↔ Sequence link: Report the measured result","description":"","oa":"c8196d35-2168-5fef-8b03-ba0d139bc00f","sa":"7fb30b1d-195c-5f0e-a19c-a536f519a899","kind":"Manual","baseline":{"oa":{"name":"Sequence link: Report the measured result","description":""},"sa":{"name":"Sequence link: Report the measured result","description":""}}},"fb02420d-b066-5773-87c1-198a056e0731":{"id":"fb02420d-b066-5773-87c1-198a056e0731","alias":"trace_oa_assess_on_entry","name":"Assess when repair is needed ↔ Assess when repair is needed","description":"","oa":"7fa37e63-db65-5aa7-88f2-31ff4347a787","sa":"a887f96a-37cb-5891-8070-011fa276e5b5","kind":"Manual","baseline":{"oa":{"name":"Assess when repair is needed","description":""},"sa":{"name":"Assess when repair is needed","description":""}}},"fd195498-dbba-523b-94d0-49f3b9531a3e":{"id":"fd195498-dbba-523b-94d0-49f3b9531a3e","alias":"trace_oa_outer_controller","name":"Controller ↔ Controller","description":"","oa":"2011b3ac-614b-5a47-8891-7ff81965f425","sa":"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023","kind":"Scenario","baseline":{"oa":{"name":"Controller","description":""},"sa":{"name":"Controller","description":""}}},"ff84fc06-6f99-5cf3-b73d-236e9eff98a3":{"id":"ff84fc06-6f99-5cf3-b73d-236e9eff98a3","alias":"trace_oa_reset_step","name":"Try the authorized remote reset ↔ Try the authorized remote reset","description":"","oa":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","sa":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","kind":"Sequence","baseline":{"oa":{"name":"Try the authorized remote reset","description":""},"sa":{"name":"Try the authorized remote reset","description":""}}}}} \ No newline at end of file diff --git a/crates/oa-desktop/src/recovery/fixtures/source6-bundle.json b/crates/oa-desktop/src/recovery/fixtures/source6-bundle.json new file mode 100644 index 00000000..dbfeffe8 --- /dev/null +++ b/crates/oa-desktop/src/recovery/fixtures/source6-bundle.json @@ -0,0 +1,35 @@ +{ + "entry": "project.arcadia", + "files": { + "oa/activities/activity_oa_restore_work.arcadia": "oa_activity oa_restore_work \"Restore safe pressure\" id \"1b91f8a7-0bdb-514e-b1cd-955658e09927\" {\n description \"\";\n parent none;\n}\n\noa_activity oa_assess \"Assess pressure alarm\" id \"61f7bbfb-74a4-5540-9182-2583dfb27a49\" {\n description \"\";\n parent @oa_restore_work;\n}\n\noa_activity oa_confirm \"Confirm safe pressure\" id \"727c0b5c-8db6-5eb7-83d6-735519c5949f\" {\n description \"\";\n parent @oa_restore_work;\n}\n\noa_activity oa_repair \"Repair valve in the field\" id \"a21465af-05f3-5fb5-8983-de26932422fb\" {\n description \"\";\n parent @oa_restore_work;\n}\n\noa_activity oa_reset \"Reset valve remotely\" id \"d0c8be87-d238-503a-a549-7558a210507f\" {\n description \"\";\n parent @oa_restore_work;\n}\n\ncapability_involvement oa_assessment_support \"Capability involvement: Assess an alarm\" id \"ffdbfcd0-d423-5707-a880-0dfbe1400f72\" {\n description \"\";\n source @oa_assess;\n target @oa_triage;\n}\n\ninteraction oa_repair_request \"Request field repair\" id \"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d\" {\n description \"\";\n items [@oa_repair_operation];\n source @oa_assess;\n target @oa_repair;\n}\n\ninteraction oa_repair_report \"Report repair result\" id \"b436d0b3-c824-5a96-af6b-2c269816d1cc\" {\n description \"\";\n items [@oa_repair_operation];\n source @oa_repair;\n target @oa_confirm;\n}\n\n", + "oa/behavior/process_oa_assessment_procedure.arcadia": "oa_process oa_assessment_procedure \"Assess an alarm procedure\" id \"022d3cfa-bbaf-5e13-924a-8e55099b8745\" {\n description \"\";\n parent none;\n steps [@oa_assessment_action];\n}\n\noa_occurrence oa_assessment_action \"Assess pressure alarm\" id \"942975b8-0bef-5ae8-9fcb-c37497833fb0\" {\n body {\n Action {\n action @oa_assess;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_assessment_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nchain_involvement oa_assessment_delivery \"Chain involvement: Assess an alarm\" id \"628cb4fc-fe63-5f6b-b364-6268f26b5740\" {\n description \"\";\n source @oa_assessment_procedure;\n target @oa_triage;\n}\n\n", + "oa/behavior/process_oa_field_procedure.arcadia": "oa_process oa_field_procedure \"Field repair procedure\" id \"5d3af417-244c-54bb-9588-16daedda4e1f\" {\n description \"\";\n parent none;\n steps [@oa_request_step, @oa_repair_step, @oa_report_step];\n}\n\noa_occurrence oa_repair_step \"Repair the valve\" id \"c59432fe-6092-5ee0-908d-9dbf39476471\" {\n body {\n Action {\n action @oa_repair;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_report_step \"Report the measured result\" id \"df2fd3f8-6bc2-5380-a13f-36643ed35ac7\" {\n body {\n Exchange {\n exchange @oa_repair_report;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @oa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_request_step \"Request the field repair\" id \"5ba8079c-2dbe-5e98-ae82-2b60c6020a02\" {\n body {\n Exchange {\n exchange @oa_repair_request;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @oa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link oa_repair_then_report \"Sequence link: Report the measured result\" id \"c8196d35-2168-5fef-8b03-ba0d139bc00f\" {\n description \"\";\n guard \"\";\n source @oa_repair_step;\n target @oa_report_step;\n}\n\nsequence_link oa_request_then_repair \"Sequence link: Repair the valve\" id \"3f54d890-4ca5-5e36-873b-abb5c92d578c\" {\n description \"\";\n guard \"\";\n source @oa_request_step;\n target @oa_repair_step;\n}\n\n", + "oa/behavior/process_oa_response.arcadia": "oa_process oa_response \"Handle a pressure alarm\" id \"58bae2b0-f560-50f2-818d-19091d22d18a\" {\n description \"\";\n parent none;\n steps [@oa_assess_step, @oa_decision, @oa_reset_step, @oa_use_field, @oa_merge, @oa_confirm_step];\n}\n\noa_occurrence oa_assess_step \"Assess the alarm\" id \"0b07b9ed-492d-57f6-b0b1-ce8a709238a7\" {\n body {\n SequenceReference {\n sequence @oa_assessment_procedure;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_confirm_step \"Confirm safe pressure\" id \"e00594a3-6122-5a6b-b3c2-ecb5506c741d\" {\n body {\n Action {\n action @oa_confirm;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_decision \"Can a remote reset safely recover pressure?\" id \"b280c6ca-e5f9-5424-a5c1-5676e01c2371\" {\n body {\n Control {\n kind Choice;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_merge \"Recovery path completed\" id \"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d\" {\n body {\n Control {\n kind Merge;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_reset_step \"Try the authorized remote reset\" id \"d2026fc2-74a8-5e22-9942-ff3eb68a3677\" {\n body {\n Action {\n action @oa_reset;\n lifeline none;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\noa_occurrence oa_use_field \"Use the field repair procedure\" id \"fc6763bf-a534-54bf-afcd-57ab285ff07d\" {\n body {\n SequenceReference {\n sequence @oa_field_procedure;\n };\n };\n description \"\";\n parent @oa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link oa_assess_then_decide \"Continue\" id \"109e2d6b-7e86-5934-93a5-fab56eced84e\" {\n description \"\";\n guard \"\";\n source @oa_assess_step;\n target @oa_decision;\n}\n\nsequence_link oa_field_path \"else\" id \"c1adbbd0-a762-5ee3-8b9a-5c18af377771\" {\n description \"\";\n guard \"else\";\n source @oa_decision;\n target @oa_use_field;\n}\n\nsequence_link oa_remote_path \"remote reset is authorized\" id \"76218d89-68fd-531d-b35b-90c5b769a5ef\" {\n description \"\";\n guard \"remote reset is authorized\";\n source @oa_decision;\n target @oa_reset_step;\n}\n\nsequence_link oa_confirm_path \"Continue\" id \"229ee94f-d147-50f6-9769-b5bd0c03a084\" {\n description \"\";\n guard \"\";\n source @oa_merge;\n target @oa_confirm_step;\n}\n\nsequence_link oa_remote_merge \"Continue\" id \"bac43d3b-910b-5bdc-be9b-f78cf973e1ed\" {\n description \"\";\n guard \"\";\n source @oa_reset_step;\n target @oa_merge;\n}\n\nchain_involvement oa_response_delivery \"Chain involvement: Restore a safe supply\" id \"c7c248f8-28b8-55be-9023-0991d44021e3\" {\n description \"\";\n source @oa_response;\n target @oa_restore;\n}\n\nsequence_link oa_field_merge \"Continue\" id \"c8593670-8906-530f-b6a2-3ada77a9c219\" {\n description \"\";\n guard \"\";\n source @oa_use_field;\n target @oa_merge;\n}\n\n", + "oa/behavior/scenario_oa_conversation.arcadia": "oa_scenario oa_conversation \"Repair request and measured reply\" id \"20472233-5ce6-5ffe-b4ad-0bdbccf3e423\" {\n context @oa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@oa_call, @oa_reply];\n viewpoint ParticipantInteraction;\n}\n\noa_occurrence oa_call \"Request valve repair\" id \"b97437c6-cd7a-5a05-b537-b30a17dd936a\" {\n body {\n Exchange {\n exchange @oa_contact;\n message_kind Call;\n receiver @oa_responder;\n reply_to none;\n sender @oa_caller;\n values [{\n expression \"420.0\";\n field @oa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @oa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\noa_lifeline oa_caller \"Controller\" id \"41859a65-ded2-547b-ab03-14e2fa740cf9\" {\n description \"\";\n parent @oa_conversation;\n represented @oa_control;\n}\n\noa_occurrence oa_reply \"Return pressure after repair\" id \"cc2d497a-fd15-5455-9ee5-e70eb95811d8\" {\n body {\n Exchange {\n exchange @oa_contact;\n message_kind Reply;\n receiver @oa_caller;\n reply_to @oa_call;\n sender @oa_responder;\n values [{\n expression \"320.0\";\n field @oa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @oa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\noa_lifeline oa_responder \"Technician\" id \"fc81c5f0-0442-5b88-992b-e5b10f351c59\" {\n description \"\";\n parent @oa_conversation;\n represented @oa_technician;\n}\n\n", + "oa/behavior/scenario_oa_full_scenario.arcadia": "oa_scenario oa_full_scenario \"Restore supply through field repair\" id \"d6f881a4-95da-54e9-ad97-1b8965532629\" {\n context @oa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@oa_use_conversation];\n viewpoint ParticipantInteraction;\n}\n\noa_lifeline oa_outer_controller \"Controller\" id \"2011b3ac-614b-5a47-8891-7ff81965f425\" {\n description \"\";\n parent @oa_full_scenario;\n represented @oa_control;\n}\n\noa_lifeline oa_outer_technician \"Technician\" id \"fffff882-78fd-5415-928d-c1ef7fac6576\" {\n description \"\";\n parent @oa_full_scenario;\n represented @oa_technician;\n}\n\noa_occurrence oa_use_conversation \"Use the reviewed repair conversation\" id \"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5\" {\n body {\n ScenarioUse {\n bindings [{\n local @oa_outer_controller;\n referenced @oa_caller;\n }, {\n local @oa_outer_technician;\n referenced @oa_responder;\n }];\n scenario @oa_conversation;\n };\n };\n description \"\";\n parent @oa_full_scenario;\n postcondition \"\";\n precondition \"\";\n}\n\n", + "oa/behavior/state_machine_oa_monitor.arcadia": "oa_state_machine oa_monitor \"Pressure supervision\" id \"1970608f-85d1-5234-ac86-5e2b0df27dc3\" {\n description \"\";\n parent @oa_control;\n}\n\noa_region oa_monitor_region \"Supervision cycle\" id \"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7\" {\n description \"\";\n parent @oa_monitor;\n}\n\noa_initial oa_monitor_initial \"Start\" id \"a7ffb869-9dde-5e76-b276-c719c5c143a9\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind Initial;\n parent @oa_monitor_region;\n}\n\noa_state oa_monitoring \"Monitoring\" id \"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @oa_monitor_region;\n}\n\noa_state oa_waiting \"Awaiting repair\" id \"58c15833-6e75-523a-b57f-a88f19f6d672\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @oa_monitor_region;\n}\n\nstate_transition oa_start_monitoring \"State transition: Monitoring\" id \"2b00270c-79a5-52a5-af96-33346bea531b\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @oa_monitor_initial;\n target @oa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\nstate_transition oa_alarm_transition \"State transition: Awaiting repair\" id \"fbde9d5e-c7e9-55db-a2be-497345c491b3\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @oa_monitoring;\n target @oa_waiting;\n transition_kind External;\n trigger \"\";\n triggers [@oa_high_pressure];\n}\n\nstate_action oa_assess_on_entry \"Assess when repair is needed\" id \"7fa37e63-db65-5aa7-88f2-31ff4347a787\" {\n description \"\";\n phase Entry;\n source @oa_waiting;\n target @oa_assess;\n}\n\nstate_transition oa_normal_transition \"State transition: Monitoring\" id \"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"repair confirmed and pressure is safe\";\n source @oa_waiting;\n target @oa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\n", + "oa/capabilities.arcadia": "oa_mission oa_mission \"Maintain safe water supply\" id \"c9c61f50-06e0-5d17-973d-dcc57cd5cc90\" {\n description \"\";\n parent none;\n}\n\noa_capability oa_restore \"Restore a safe supply\" id \"3dfee23a-148b-5cec-ad2d-b774e84ea5bb\" {\n description \"\";\n parent none;\n}\n\noa_capability oa_triage \"Assess an alarm\" id \"6a30b763-cc51-546d-907f-5c63657cfd03\" {\n description \"\";\n parent none;\n}\n\nmission_capability oa_mission_delivery \"Mission capability: Restore a safe supply\" id \"1f8b1898-65f6-5360-99cf-b78a71bd50ec\" {\n description \"\";\n source @oa_mission;\n target @oa_restore;\n}\n\ncapability_include oa_include_triage \"Capability include: Assess an alarm\" id \"e21d9a54-ae8e-5618-a4f8-67ee22b92638\" {\n description \"\";\n source @oa_restore;\n target @oa_triage;\n}\n\n", + "oa/model.arcadia": "oa_exchange_category oa_contact_category \"Field contact\" id \"19bd3a3a-4d0c-56d5-bf50-a6b43fd81771\" {\n description \"\";\n exchanges [@oa_contact];\n family \"Participant\";\n parent none;\n}\n\noa_event oa_high_pressure \"Pressure alarm detected\" id \"65d6b8d6-271a-5c84-8b42-abbe310c955e\" {\n body {\n Change {\n expression \"measured_pressure > operating_limit\";\n };\n };\n description \"\";\n parent none;\n}\n\noa_exchange_category oa_repair_traffic \"Repair work exchanges\" id \"9349b353-53f2-56f8-b571-a05b09335cdf\" {\n description \"\";\n exchanges [@oa_repair_request, @oa_repair_report];\n family \"Work\";\n parent none;\n}\n\n", + "oa/participants.arcadia": "oa_entity oa_control \"Control room\" id \"32aa9940-df6e-5a12-b9bd-6337e2d2ed22\" {\n description \"\";\n parent none;\n}\n\noa_actor oa_technician \"Field technician\" id \"e07c3952-4a1a-5cc7-b7d5-03a01101ce49\" {\n description \"\";\n parent none;\n}\n\nallocation oa_assess_allocation \"Allocation: Assess pressure alarm\" id \"594c61cd-94e0-5c07-890d-edfd520d7df9\" {\n description \"\";\n source @oa_control;\n target @oa_assess;\n}\n\nallocation oa_confirm_allocation \"Allocation: Confirm safe pressure\" id \"bfd4326e-ea65-5269-b5e6-e063a355d1e9\" {\n description \"\";\n source @oa_control;\n target @oa_confirm;\n}\n\ncommunication_mean oa_contact \"Technician service conversation\" id \"27b47887-baf0-518b-bf87-f5acbf7d0b93\" {\n description \"\";\n items [@oa_repair_operation];\n oriented false;\n source @oa_control;\n target @oa_technician;\n}\n\nexchange_allocation oa_report_carriage \"Exchange allocation: Report repair result\" id \"4549d3fa-5ab9-52a9-b4f9-3a78fc9d625a\" {\n description \"\";\n source @oa_contact;\n target @oa_repair_report;\n}\n\nexchange_allocation oa_request_carriage \"Exchange allocation: Request field repair\" id \"094b888c-450c-5c4f-971a-7d75db059574\" {\n description \"\";\n source @oa_contact;\n target @oa_repair_request;\n}\n\nmission_involvement oa_mission_participant \"Mission involvement: Maintain safe water supply\" id \"0c4c9e58-ff32-5c00-b82a-d408a4be0183\" {\n description \"\";\n source @oa_control;\n target @oa_mission;\n}\n\nallocation oa_reset_allocation \"Allocation: Reset valve remotely\" id \"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a\" {\n description \"\";\n source @oa_control;\n target @oa_reset;\n}\n\nallocation oa_repair_allocation \"Allocation: Repair valve in the field\" id \"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2\" {\n description \"\";\n source @oa_technician;\n target @oa_repair;\n}\n\n", + "oa/types.arcadia": "oa_data_type oa_pressure \"Measured pressure\" id \"e7824764-5db9-5384-8198-2fbaea2b10b2\" {\n default none;\n definition {\n Real {\n maximum 600.0;\n minimum 0.0;\n };\n };\n description \"\";\n parent none;\n unit \"kPa\";\n}\n\noa_exchange_item oa_repair_operation \"Valve repair\" id \"d736c7d6-d369-550a-8ba4-dae223e40bb3\" {\n description \"\";\n kind Information;\n mechanism Operation;\n parent none;\n supertype none;\n}\n\noa_data_field oa_pressure_parameter \"Pressure before / after repair\" id \"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7\" {\n default none;\n description \"\";\n direction InOut;\n maximum 1;\n minimum 1;\n parent @oa_repair_operation;\n storage Value;\n type_ref {\n Named @oa_pressure;\n };\n unit \"\";\n}\n\n", + "project.arcadia": "// Frozen legacy recovery input 雨.\r\narcadia // profile\r\n 6;\nproject pressure_restoration \"Restore safe water pressure — release 1 walkthrough\" id \"2786d72f-5763-5f33-a57f-2773a4ca1a6c\" {\n description \"A controller either resets a valve remotely or requests field repair. Compare the operational need with the explicitly chosen coordination-service boundary. Start with README.md beside project.arcadia. Units and guards are descriptive; this model does not simulate a real water network.\";\n include \"oa/activities/activity_oa_restore_work.arcadia\";\n include \"oa/behavior/process_oa_assessment_procedure.arcadia\";\n include \"oa/behavior/process_oa_field_procedure.arcadia\";\n include \"oa/behavior/process_oa_response.arcadia\";\n include \"oa/behavior/scenario_oa_conversation.arcadia\";\n include \"oa/behavior/scenario_oa_full_scenario.arcadia\";\n include \"oa/behavior/state_machine_oa_monitor.arcadia\";\n include \"oa/capabilities.arcadia\";\n include \"oa/model.arcadia\";\n include \"oa/participants.arcadia\";\n include \"oa/types.arcadia\";\n include \"sa/behavior/process_sa_assessment_procedure.arcadia\";\n include \"sa/behavior/process_sa_field_procedure.arcadia\";\n include \"sa/behavior/process_sa_response.arcadia\";\n include \"sa/behavior/scenario_sa_conversation.arcadia\";\n include \"sa/behavior/scenario_sa_full_scenario.arcadia\";\n include \"sa/behavior/state_machine_sa_monitor.arcadia\";\n include \"sa/capabilities.arcadia\";\n include \"sa/functions/function_sa_audit_thresholds.arcadia\";\n include \"sa/functions/function_sa_restore_work.arcadia\";\n include \"sa/model.arcadia\";\n include \"sa/participants.arcadia\";\n include \"sa/types.arcadia\";\n include \"traceability.arcadia\";\n include \"views/diagram_oa_allocation_view.arcadia\";\n include \"views/diagram_sa_allocation_view.arcadia\";\n include \"views/diagram_sa_capability_view.arcadia\";\n include \"views/diagram_sa_service_view.arcadia\";\n include \"views/diagram_sa_state_view.arcadia\";\n}\n", + "sa/behavior/process_sa_assessment_procedure.arcadia": "sa_functional_chain sa_assessment_procedure \"Assess an alarm procedure\" id \"acd5a018-f559-5298-a40d-1cf6e1353e74\" {\n description \"\";\n parent none;\n steps [@sa_assessment_action];\n}\n\nsa_occurrence sa_assessment_action \"Assess pressure alarm\" id \"bae5b192-5b8e-51c6-82ae-2331af0d33bf\" {\n body {\n Action {\n action @sa_assess;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_assessment_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nchain_involvement sa_assessment_delivery \"Chain involvement: Assess an alarm\" id \"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57\" {\n description \"\";\n source @sa_assessment_procedure;\n target @sa_triage;\n}\n\n", + "sa/behavior/process_sa_field_procedure.arcadia": "sa_functional_chain sa_field_procedure \"Field repair procedure\" id \"93eb6479-54e9-59cb-ac7b-16912347bfc0\" {\n description \"\";\n parent none;\n steps [@sa_request_step, @sa_repair_step, @sa_report_step];\n}\n\nsa_occurrence sa_repair_step \"Repair the valve\" id \"0f195fea-3493-5ebd-966d-da9eddeb05d7\" {\n body {\n Action {\n action @sa_repair;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_report_step \"Report the measured result\" id \"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac\" {\n body {\n Exchange {\n exchange @sa_repair_report;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @sa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_request_step \"Request the field repair\" id \"40d94322-baa2-5c81-b261-ff47cdc33782\" {\n body {\n Exchange {\n exchange @sa_repair_request;\n message_kind Asynchronous;\n receiver none;\n reply_to none;\n sender none;\n values [];\n };\n };\n description \"\";\n parent @sa_field_procedure;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link sa_repair_then_report \"Sequence link: Report the measured result\" id \"7fb30b1d-195c-5f0e-a19c-a536f519a899\" {\n description \"\";\n guard \"\";\n source @sa_repair_step;\n target @sa_report_step;\n}\n\nsequence_link sa_request_then_repair \"Sequence link: Repair the valve\" id \"6a6d968f-26d6-5dbb-b721-5a27d362779a\" {\n description \"\";\n guard \"\";\n source @sa_request_step;\n target @sa_repair_step;\n}\n\n", + "sa/behavior/process_sa_response.arcadia": "sa_functional_chain sa_response \"Handle a pressure alarm\" id \"5139ce73-56fd-5875-ba3a-e569fc890231\" {\n description \"\";\n parent none;\n steps [@sa_assess_step, @sa_decision, @sa_reset_step, @sa_use_field, @sa_merge, @sa_confirm_step];\n}\n\nsa_occurrence sa_assess_step \"Assess the alarm\" id \"9024243c-ccc1-5edb-8477-2aa16e5e9a50\" {\n body {\n SequenceReference {\n sequence @sa_assessment_procedure;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_confirm_step \"Confirm safe pressure\" id \"47bec4f9-d6d0-5ed1-adca-fa0d960dc416\" {\n body {\n Action {\n action @sa_confirm;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_decision \"Can a remote reset safely recover pressure?\" id \"30c8fad9-a49b-5692-9542-3148e76afd7b\" {\n body {\n Control {\n kind Choice;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_merge \"Recovery path completed\" id \"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07\" {\n body {\n Control {\n kind Merge;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_reset_step \"Try the authorized remote reset\" id \"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6\" {\n body {\n Action {\n action @sa_reset;\n lifeline none;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_occurrence sa_use_field \"Use the field repair procedure\" id \"d5e61c5d-510b-5091-a878-da46f3a9e152\" {\n body {\n SequenceReference {\n sequence @sa_field_procedure;\n };\n };\n description \"\";\n parent @sa_response;\n postcondition \"\";\n precondition \"\";\n}\n\nsequence_link sa_assess_then_decide \"Continue\" id \"9d4a131f-36ad-5125-b21f-c3dd033b8c82\" {\n description \"\";\n guard \"\";\n source @sa_assess_step;\n target @sa_decision;\n}\n\nsequence_link sa_field_path \"else\" id \"1bbd5957-1173-51d8-97e3-ba4f084227ae\" {\n description \"\";\n guard \"else\";\n source @sa_decision;\n target @sa_use_field;\n}\n\nsequence_link sa_remote_path \"remote reset is authorized\" id \"d8b6710a-e713-5979-934f-e5ca7d155bb4\" {\n description \"\";\n guard \"remote reset is authorized\";\n source @sa_decision;\n target @sa_reset_step;\n}\n\nsequence_link sa_confirm_path \"Continue\" id \"1e738f27-a3a1-526d-b330-6384088b9689\" {\n description \"\";\n guard \"\";\n source @sa_merge;\n target @sa_confirm_step;\n}\n\nsequence_link sa_remote_merge \"Continue\" id \"e5279265-8eac-52e8-98dc-571b20475e1f\" {\n description \"\";\n guard \"\";\n source @sa_reset_step;\n target @sa_merge;\n}\n\nchain_involvement sa_response_delivery \"Chain involvement: Restore a safe supply\" id \"66efc484-53c7-51a3-89e9-422429344b38\" {\n description \"\";\n source @sa_response;\n target @sa_restore;\n}\n\nsequence_link sa_field_merge \"Continue\" id \"0f6ac3fb-4ad6-581e-ad14-336472072d5e\" {\n description \"\";\n guard \"\";\n source @sa_use_field;\n target @sa_merge;\n}\n\n", + "sa/behavior/scenario_sa_conversation.arcadia": "sa_scenario sa_conversation \"Repair request and measured reply\" id \"730b27ca-b039-5a3b-8dc6-03418f9061b3\" {\n context @sa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@sa_call, @sa_reply];\n viewpoint BoundaryInterface;\n}\n\nsa_occurrence sa_call \"Request valve repair\" id \"8bab4783-a098-5984-9e36-493957caf26c\" {\n body {\n Exchange {\n exchange @sa_contact;\n message_kind Call;\n receiver @sa_responder;\n reply_to none;\n sender @sa_caller;\n values [{\n expression \"420.0\";\n field @sa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @sa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_lifeline sa_caller \"Controller\" id \"98c61788-2fee-51f4-8cf9-de48e8c70e9e\" {\n description \"\";\n parent @sa_conversation;\n represented @sa_control;\n}\n\nsa_occurrence sa_reply \"Return pressure after repair\" id \"4e1c0a56-ce80-5f13-a93a-6956de54880c\" {\n body {\n Exchange {\n exchange @sa_contact;\n message_kind Reply;\n receiver @sa_caller;\n reply_to @sa_call;\n sender @sa_responder;\n values [{\n expression \"320.0\";\n field @sa_pressure_parameter;\n path [];\n }];\n };\n };\n description \"\";\n parent @sa_conversation;\n postcondition \"\";\n precondition \"\";\n}\n\nsa_lifeline sa_responder \"Technician\" id \"6249b54a-e396-5338-82b9-89266c55670d\" {\n description \"\";\n parent @sa_conversation;\n represented @sa_technician;\n}\n\nartifact repair_test_evidence \"Repair conversation acceptance notes\" id \"f5922bf7-05d8-590f-86e9-1d09f37f4fa6\" {\n description \"A local teaching artifact, not proof of a real valve's safety.\";\n fragment \"acceptance\";\n owner @sa_conversation;\n relation Verifies;\n revision \"lesson-1\";\n uri \"evidence/repair-check.md\";\n}\n\n", + "sa/behavior/scenario_sa_full_scenario.arcadia": "sa_scenario sa_full_scenario \"Restore supply through field repair\" id \"f746a1cd-ff9f-55c0-b325-01c03d2f4fae\" {\n context @sa_restore;\n description \"\";\n parent none;\n postcondition \"The controller has received the measured pressure after repair\";\n precondition \"A pressure alarm has been assessed\";\n steps [@sa_use_conversation];\n viewpoint BoundaryInterface;\n}\n\nsa_lifeline sa_outer_controller \"Controller\" id \"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023\" {\n description \"\";\n parent @sa_full_scenario;\n represented @sa_control;\n}\n\nsa_lifeline sa_outer_technician \"Technician\" id \"155e4fe4-9e3b-5f4e-b886-28abcc3f086b\" {\n description \"\";\n parent @sa_full_scenario;\n represented @sa_technician;\n}\n\nsa_occurrence sa_use_conversation \"Use the reviewed repair conversation\" id \"1c9da843-e2d3-5591-8e3f-db083a42867e\" {\n body {\n ScenarioUse {\n bindings [{\n local @sa_outer_controller;\n referenced @sa_caller;\n }, {\n local @sa_outer_technician;\n referenced @sa_responder;\n }];\n scenario @sa_conversation;\n };\n };\n description \"\";\n parent @sa_full_scenario;\n postcondition \"\";\n precondition \"\";\n}\n\n", + "sa/behavior/state_machine_sa_monitor.arcadia": "sa_state_machine sa_monitor \"Pressure supervision\" id \"132b3f28-3f88-5327-9868-8c8b645ed412\" {\n description \"\";\n parent @sa_control;\n}\n\nsa_region sa_monitor_region \"Supervision cycle\" id \"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c\" {\n description \"\";\n parent @sa_monitor;\n}\n\nsa_initial sa_monitor_initial \"Start\" id \"57eb5348-14d8-560e-996a-8dbf993243e2\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind Initial;\n parent @sa_monitor_region;\n}\n\nsa_state sa_monitoring \"Monitoring\" id \"8af21f9b-c21e-5aae-af0d-0958840f6655\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @sa_monitor_region;\n}\n\nsa_state sa_waiting \"Awaiting repair\" id \"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37\" {\n description \"\";\n do_activity \"\";\n entry \"\";\n exit \"\";\n kind State;\n parent @sa_monitor_region;\n}\n\nstate_transition sa_start_monitoring \"State transition: Monitoring\" id \"47730532-0372-5cc5-9d6d-0b8953aebdd8\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @sa_monitor_initial;\n target @sa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\nstate_transition sa_alarm_transition \"State transition: Awaiting repair\" id \"13c3bfa4-9254-5ab2-b96f-f65f402adf81\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"\";\n source @sa_monitoring;\n target @sa_waiting;\n transition_kind External;\n trigger \"\";\n triggers [@sa_high_pressure];\n}\n\nstate_action sa_assess_on_entry \"Assess when repair is needed\" id \"a887f96a-37cb-5891-8070-011fa276e5b5\" {\n description \"\";\n phase Entry;\n source @sa_waiting;\n target @sa_assess;\n}\n\nstate_transition sa_normal_transition \"State transition: Monitoring\" id \"6e2c1b47-764c-577a-b325-ff66dd2a2768\" {\n description \"\";\n effect \"\";\n effects [];\n guard \"repair confirmed and pressure is safe\";\n source @sa_waiting;\n target @sa_monitoring;\n transition_kind External;\n trigger \"\";\n triggers [];\n}\n\n", + "sa/capabilities.arcadia": "sa_mission sa_mission \"Maintain safe water supply\" id \"5ad034b1-084d-5273-9e70-971671766132\" {\n description \"\";\n parent none;\n}\n\nsa_capability sa_restore \"Restore a safe supply\" id \"81259051-370a-512d-a3b5-6420a6f933b6\" {\n description \"\";\n parent none;\n}\n\nsa_capability sa_triage \"Assess an alarm\" id \"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f\" {\n description \"\";\n parent none;\n}\n\nmission_capability sa_mission_delivery \"Mission capability: Restore a safe supply\" id \"a0902e0f-3d18-576a-9aca-975a136dfdd7\" {\n description \"\";\n source @sa_mission;\n target @sa_restore;\n}\n\ncapability_include sa_include_triage \"Capability include: Assess an alarm\" id \"f3fe7690-734a-5877-8b57-a6599a4033c4\" {\n description \"\";\n source @sa_restore;\n target @sa_triage;\n}\n\n", + "sa/functions/function_sa_audit_thresholds.arcadia": "sa_function sa_audit_thresholds \"Audit pressure thresholds\" id \"4149a872-3440-59de-9879-8981e78d27ab\" {\n description \"Deliberately unfinished: assigned to the service, but no behavior context yet. Model checks should expose this one work_without_behavior_context warning.\";\n parent none;\n}\n\n", + "sa/functions/function_sa_restore_work.arcadia": "sa_function sa_restore_work \"Restore safe pressure\" id \"58867e7d-916e-5ebc-9e4c-8dbed1314be3\" {\n description \"\";\n parent none;\n}\n\nsa_function sa_assess \"Assess pressure alarm\" id \"dddae020-a444-5d48-acc8-139d2696a672\" {\n description \"\";\n parent @sa_restore_work;\n}\n\nsa_port sa_repair_request_out \"Request field repair output\" id \"24a6850e-b819-57e0-af88-ca4ab76581b7\" {\n contract {\n FunctionOutput {\n outgoing_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_assess;\n}\n\nsa_function sa_confirm \"Confirm safe pressure\" id \"d75eb3fa-a27d-52d5-8782-8b342b3865f6\" {\n description \"\";\n parent @sa_restore_work;\n}\n\nsa_port sa_repair_report_in \"Report repair result input\" id \"391af7c4-b07d-53a7-8ddd-5c81461fc4f8\" {\n contract {\n FunctionInput {\n incoming_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_confirm;\n}\n\nsa_function sa_repair \"Repair valve in the field\" id \"9497e10f-04c4-5586-8738-2464a6f00bd4\" {\n description \"\";\n parent @sa_restore_work;\n}\n\nsa_port sa_repair_report_out \"Report repair result output\" id \"994faed7-dbc8-5b96-9d62-7604ac0fbc3a\" {\n contract {\n FunctionOutput {\n outgoing_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_repair;\n}\n\nsa_port sa_repair_request_in \"Request field repair input\" id \"d2113fb0-a531-54ef-9484-13951b00ab8f\" {\n contract {\n FunctionInput {\n incoming_items [@sa_repair_operation];\n };\n };\n description \"\";\n legacy none;\n parent @sa_repair;\n}\n\nsa_function sa_reset \"Reset valve remotely\" id \"09342ccb-044b-5483-ac58-87dc3cf7831c\" {\n description \"\";\n parent @sa_restore_work;\n}\n\ncapability_involvement sa_assessment_support \"Capability involvement: Assess an alarm\" id \"93eaad8d-83b7-5980-b9bb-86c9e3f31da4\" {\n description \"\";\n source @sa_assess;\n target @sa_triage;\n}\n\nfunctional_exchange sa_repair_request \"Request field repair\" id \"93474111-f23e-56c1-b61b-b7c2396b91bd\" {\n description \"\";\n items [@sa_repair_operation];\n source @sa_assess;\n source_port @sa_repair_request_out;\n target @sa_repair;\n target_port @sa_repair_request_in;\n}\n\nfunctional_exchange sa_repair_report \"Report repair result\" id \"684eadc3-e670-5779-a60f-bbc9a5bfb062\" {\n description \"\";\n items [@sa_repair_operation];\n source @sa_repair;\n source_port @sa_repair_report_out;\n target @sa_confirm;\n target_port @sa_repair_report_in;\n}\n\n", + "sa/model.arcadia": "sa_exchange_category sa_contact_category \"Field contact\" id \"781cbe75-410f-5cc5-aeb8-a43491f77b99\" {\n description \"\";\n exchanges [@sa_contact];\n family \"Participant\";\n parent none;\n}\n\nsa_event sa_high_pressure \"Pressure alarm detected\" id \"01490fa8-2f01-5ac8-9722-d55d45414379\" {\n body {\n Change {\n expression \"measured_pressure > operating_limit\";\n };\n };\n description \"\";\n parent none;\n}\n\nsa_exchange_category sa_repair_traffic \"Repair work exchanges\" id \"a9a47e48-100c-57a8-95f0-7fe24da94f94\" {\n description \"\";\n exchanges [@sa_repair_request, @sa_repair_report];\n family \"Work\";\n parent none;\n}\n\n", + "sa/participants.arcadia": "sa_system sa_control \"Pressure coordination service\" id \"435bf87c-73fd-5e4d-978a-4c471bc9eaf0\" {\n description \"\";\n parent none;\n}\n\nsa_port sa_control_port \"Repair service client\" id \"98d411e7-7c65-5c3f-b543-8bf0f2522ea2\" {\n contract {\n Component {\n kind Standard;\n orientation Unset;\n };\n };\n description \"Explicitly chosen Standard component port for the repair service.\";\n legacy none;\n parent @sa_control;\n}\n\nsa_actor sa_technician \"Field technician\" id \"c05b9422-b9da-5de5-b21d-07ad23bd2b8a\" {\n description \"\";\n parent none;\n}\n\nsa_port sa_technician_port \"Repair service provider\" id \"e6d69cbd-99d7-596b-bbe3-dada6118a600\" {\n contract {\n Component {\n kind Standard;\n orientation Unset;\n };\n };\n description \"Explicitly chosen Standard component port for the repair service.\";\n legacy none;\n parent @sa_technician;\n}\n\nallocation sa_assess_allocation \"Allocation: Assess pressure alarm\" id \"9b9356d7-2db2-5a94-a47e-ea892cf02afe\" {\n description \"\";\n source @sa_control;\n target @sa_assess;\n}\n\nallocation sa_audit_allocation \"Allocation: Audit pressure thresholds\" id \"1b245065-7242-5d81-8472-aaeb932054c9\" {\n description \"\";\n source @sa_control;\n target @sa_audit_thresholds;\n}\n\nallocation sa_confirm_allocation \"Allocation: Confirm safe pressure\" id \"b76bf8de-2fa4-563f-a540-dd30f30e6c49\" {\n description \"\";\n source @sa_control;\n target @sa_confirm;\n}\n\ninterface_requirement sa_client_requires \"Interface requirement: Valve repair service\" id \"4f85337b-f6eb-533c-a45a-9a5d1d9567ca\" {\n description \"\";\n source @sa_control_port;\n target @sa_repair_interface;\n}\n\nboundary_exchange sa_contact \"Technician service conversation\" id \"63009b5f-539e-5914-b630-ec6b7fefb4d5\" {\n description \"\";\n items [@sa_repair_operation];\n oriented false;\n source @sa_control_port;\n target @sa_technician_port;\n}\n\nexchange_allocation sa_report_carriage \"Exchange allocation: Report repair result\" id \"ff9897b3-a23a-5994-a5b3-99a5b4d34ceb\" {\n description \"\";\n source @sa_contact;\n target @sa_repair_report;\n}\n\nexchange_allocation sa_request_carriage \"Exchange allocation: Request field repair\" id \"4c4c4851-5a3d-5bb9-94df-b19f9cf39819\" {\n description \"\";\n source @sa_contact;\n target @sa_repair_request;\n}\n\nport_allocation sa_port_report_in \"Port allocation: Report repair result input\" id \"568c5c01-de21-52a2-83bd-afdddc158085\" {\n description \"\";\n source @sa_control_port;\n target @sa_repair_report_in;\n}\n\nport_allocation sa_port_request_out \"Port allocation: Request field repair output\" id \"55ec4dbf-bd17-5b0c-b3a3-95b2194aa8b9\" {\n description \"\";\n source @sa_control_port;\n target @sa_repair_request_out;\n}\n\nmission_involvement sa_mission_participant \"Mission involvement: Maintain safe water supply\" id \"1c1e5634-ba8c-591f-b143-90a27cdc6cd2\" {\n description \"\";\n source @sa_control;\n target @sa_mission;\n}\n\nallocation sa_reset_allocation \"Allocation: Reset valve remotely\" id \"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3\" {\n description \"\";\n source @sa_control;\n target @sa_reset;\n}\n\nallocation sa_repair_allocation \"Allocation: Repair valve in the field\" id \"725f2e1b-aa2c-5615-9b1f-28da178168fa\" {\n description \"\";\n source @sa_technician;\n target @sa_repair;\n}\n\nport_allocation sa_port_report_out \"Port allocation: Report repair result output\" id \"e53b7a40-e8a3-5b3f-826d-c2b5c164a84a\" {\n description \"\";\n source @sa_technician_port;\n target @sa_repair_report_out;\n}\n\nport_allocation sa_port_request_in \"Port allocation: Request field repair input\" id \"09eaa992-4cdf-5ea3-b748-f10fd74b8cba\" {\n description \"\";\n source @sa_technician_port;\n target @sa_repair_request_in;\n}\n\ninterface_provision sa_provider_provides \"Interface provision: Valve repair service\" id \"55f89fbf-aa3c-5ae1-95ef-ac676f9c1ada\" {\n description \"\";\n source @sa_technician_port;\n target @sa_repair_interface;\n}\n\n", + "sa/types.arcadia": "sa_data_type sa_pressure \"Measured pressure\" id \"87d0a95b-af1b-5352-b753-e4c6533671a5\" {\n default none;\n definition {\n Real {\n maximum 600.0;\n minimum 0.0;\n };\n };\n description \"\";\n parent none;\n unit \"kPa\";\n}\n\nsa_interface sa_repair_interface \"Valve repair service\" id \"a4b8942e-a975-5f88-ad14-8f38c345baac\" {\n description \"\";\n parent none;\n supertype none;\n}\n\nsa_exchange_item sa_repair_operation \"Valve repair\" id \"a962fc5e-3ab0-5296-8b0d-fd84633f9218\" {\n description \"\";\n kind Information;\n mechanism Operation;\n parent none;\n supertype none;\n}\n\nsa_data_field sa_pressure_parameter \"Pressure before / after repair\" id \"bb12ba5d-aab5-5a1b-99de-c767f5365b57\" {\n default none;\n description \"\";\n direction InOut;\n maximum 1;\n minimum 1;\n parent @sa_repair_operation;\n storage Value;\n type_ref {\n Named @sa_pressure;\n };\n unit \"\";\n}\n\ninterface_item_allocation sa_repair_interface_item \"Repair operation contract\" id \"f074e041-23cf-54ad-af9c-f171e30621e7\" {\n acquisition Accept;\n description \"\";\n order 0;\n source @sa_repair_interface;\n target @sa_repair_operation;\n transmission Synchronous;\n}\n\n", + "traceability.arcadia": "trace trace_oa_alarm_transition \"State transition: Awaiting repair ↔ State transition: Awaiting repair\" id \"0c7c8a06-163e-5b3e-8a98-e66e2ce8963b\" {\n baseline {\n oa {\n description \"\";\n name \"State transition: Awaiting repair\";\n };\n sa {\n description \"\";\n name \"State transition: Awaiting repair\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_alarm_transition;\n sa @sa_alarm_transition;\n}\n\ntrace trace_oa_assess \"Assess pressure alarm ↔ Assess pressure alarm\" id \"5309f955-53fd-5ff4-9c0a-91737e947341\" {\n baseline {\n oa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n sa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_assess;\n sa @sa_assess;\n}\n\ntrace trace_oa_assess_allocation \"Allocation: Assess pressure alarm ↔ Allocation: Assess pressure alarm\" id \"acdbee9c-5017-5154-804e-50801f0376ec\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Assess pressure alarm\";\n };\n sa {\n description \"\";\n name \"Allocation: Assess pressure alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assess_allocation;\n sa @sa_assess_allocation;\n}\n\ntrace trace_oa_assess_on_entry \"Assess when repair is needed ↔ Assess when repair is needed\" id \"fb02420d-b066-5773-87c1-198a056e0731\" {\n baseline {\n oa {\n description \"\";\n name \"Assess when repair is needed\";\n };\n sa {\n description \"\";\n name \"Assess when repair is needed\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assess_on_entry;\n sa @sa_assess_on_entry;\n}\n\ntrace trace_oa_assess_step \"Assess the alarm ↔ Assess the alarm\" id \"e848da41-445e-5e3c-8b4e-ef53fdddf1e1\" {\n baseline {\n oa {\n description \"\";\n name \"Assess the alarm\";\n };\n sa {\n description \"\";\n name \"Assess the alarm\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_assess_step;\n sa @sa_assess_step;\n}\n\ntrace trace_oa_assess_then_decide \"Continue ↔ Continue\" id \"a5c1eb01-a95f-5f7a-9742-7f201493d7aa\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assess_then_decide;\n sa @sa_assess_then_decide;\n}\n\ntrace trace_oa_assessment_action \"Assess pressure alarm ↔ Assess pressure alarm\" id \"3a33de40-20a3-5252-899c-c12dc35e9783\" {\n baseline {\n oa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n sa {\n description \"\";\n name \"Assess pressure alarm\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_assessment_action;\n sa @sa_assessment_action;\n}\n\ntrace trace_oa_assessment_delivery \"Chain involvement: Assess an alarm ↔ Chain involvement: Assess an alarm\" id \"5b96bfed-63e2-5d7f-b882-91e8b81f8efe\" {\n baseline {\n oa {\n description \"\";\n name \"Chain involvement: Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Chain involvement: Assess an alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assessment_delivery;\n sa @sa_assessment_delivery;\n}\n\ntrace trace_oa_assessment_procedure \"Assess an alarm procedure ↔ Assess an alarm procedure\" id \"6ca4c78a-a5f2-5470-b401-732f5a8825d0\" {\n baseline {\n oa {\n description \"\";\n name \"Assess an alarm procedure\";\n };\n sa {\n description \"\";\n name \"Assess an alarm procedure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_assessment_procedure;\n sa @sa_assessment_procedure;\n}\n\ntrace trace_oa_assessment_support \"Capability involvement: Assess an alarm ↔ Capability involvement: Assess an alarm\" id \"6e13cf86-7242-5e33-8912-b037cf6e8c87\" {\n baseline {\n oa {\n description \"\";\n name \"Capability involvement: Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Capability involvement: Assess an alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_assessment_support;\n sa @sa_assessment_support;\n}\n\ntrace trace_oa_call \"Request valve repair ↔ Request valve repair\" id \"f0cc86e5-f8fd-5091-a40d-dbfb6515072f\" {\n baseline {\n oa {\n description \"\";\n name \"Request valve repair\";\n };\n sa {\n description \"\";\n name \"Request valve repair\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_call;\n sa @sa_call;\n}\n\ntrace trace_oa_caller \"Controller ↔ Controller\" id \"241dfdac-2450-5704-a4cb-9f1047db1b6d\" {\n baseline {\n oa {\n description \"\";\n name \"Controller\";\n };\n sa {\n description \"\";\n name \"Controller\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_caller;\n sa @sa_caller;\n}\n\ntrace trace_oa_confirm \"Confirm safe pressure ↔ Confirm safe pressure\" id \"d7773ff5-cef5-50c1-8f9d-c5659115e1f0\" {\n baseline {\n oa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n sa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_confirm;\n sa @sa_confirm;\n}\n\ntrace trace_oa_confirm_allocation \"Allocation: Confirm safe pressure ↔ Allocation: Confirm safe pressure\" id \"2fdcbc6f-526e-5a4e-bde5-314cf0cf3ef3\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Confirm safe pressure\";\n };\n sa {\n description \"\";\n name \"Allocation: Confirm safe pressure\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_confirm_allocation;\n sa @sa_confirm_allocation;\n}\n\ntrace trace_oa_confirm_path \"Continue ↔ Continue\" id \"a106d183-c239-5c9f-b1f1-141af19fc778\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_confirm_path;\n sa @sa_confirm_path;\n}\n\ntrace trace_oa_confirm_step \"Confirm safe pressure ↔ Confirm safe pressure\" id \"154abfce-c311-52d1-85d1-3f4c85203b26\" {\n baseline {\n oa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n sa {\n description \"\";\n name \"Confirm safe pressure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_confirm_step;\n sa @sa_confirm_step;\n}\n\ntrace trace_oa_contact \"Technician service conversation ↔ Technician service conversation\" id \"c9d65349-3400-58eb-8a55-57fa02de4b67\" {\n baseline {\n oa {\n description \"\";\n name \"Technician service conversation\";\n };\n sa {\n description \"\";\n name \"Technician service conversation\";\n };\n };\n description \"\";\n kind Exchange;\n oa @oa_contact;\n sa @sa_contact;\n}\n\ntrace trace_oa_contact_category \"Field contact ↔ Field contact\" id \"e96ff119-bb4d-5fd9-9ab7-2ede0d3fd418\" {\n baseline {\n oa {\n description \"\";\n name \"Field contact\";\n };\n sa {\n description \"\";\n name \"Field contact\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_contact_category;\n sa @sa_contact_category;\n}\n\ntrace trace_oa_control \"Control room ↔ Pressure coordination service\" id \"aba528ea-e396-582a-bfba-f6e2212d391a\" {\n baseline {\n oa {\n description \"\";\n name \"Control room\";\n };\n sa {\n description \"\";\n name \"Pressure coordination service\";\n };\n };\n description \"\";\n kind Participant;\n oa @oa_control;\n sa @sa_control;\n}\n\ntrace trace_oa_conversation \"Repair request and measured reply ↔ Repair request and measured reply\" id \"3694c752-ba80-5702-a7d7-9cc66b6b01d8\" {\n baseline {\n oa {\n description \"\";\n name \"Repair request and measured reply\";\n };\n sa {\n description \"\";\n name \"Repair request and measured reply\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_conversation;\n sa @sa_conversation;\n}\n\ntrace trace_oa_decision \"Can a remote reset safely recover pressure? ↔ Can a remote reset safely recover pressure?\" id \"bbf6f1a1-cb2b-5632-8ec0-bf4b8ef1a941\" {\n baseline {\n oa {\n description \"\";\n name \"Can a remote reset safely recover pressure?\";\n };\n sa {\n description \"\";\n name \"Can a remote reset safely recover pressure?\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_decision;\n sa @sa_decision;\n}\n\ntrace trace_oa_field_merge \"Continue ↔ Continue\" id \"d32dd433-9c9e-5b57-9355-2970cc44a5f2\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_field_merge;\n sa @sa_field_merge;\n}\n\ntrace trace_oa_field_path \"else ↔ else\" id \"2e849e31-35aa-5aef-a989-26948038e52b\" {\n baseline {\n oa {\n description \"\";\n name \"else\";\n };\n sa {\n description \"\";\n name \"else\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_field_path;\n sa @sa_field_path;\n}\n\ntrace trace_oa_field_procedure \"Field repair procedure ↔ Field repair procedure\" id \"bb72b2c2-6e32-5f65-9d8e-b33c1a65da37\" {\n baseline {\n oa {\n description \"\";\n name \"Field repair procedure\";\n };\n sa {\n description \"\";\n name \"Field repair procedure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_field_procedure;\n sa @sa_field_procedure;\n}\n\ntrace trace_oa_full_scenario \"Restore supply through field repair ↔ Restore supply through field repair\" id \"435bae4c-cf6d-59f3-9eb5-d660ebeec079\" {\n baseline {\n oa {\n description \"\";\n name \"Restore supply through field repair\";\n };\n sa {\n description \"\";\n name \"Restore supply through field repair\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_full_scenario;\n sa @sa_full_scenario;\n}\n\ntrace trace_oa_high_pressure \"Pressure alarm detected ↔ Pressure alarm detected\" id \"b97749d4-f9ad-5fb7-9d78-bc60a74e98eb\" {\n baseline {\n oa {\n description \"\";\n name \"Pressure alarm detected\";\n };\n sa {\n description \"\";\n name \"Pressure alarm detected\";\n };\n };\n description \"\";\n kind State;\n oa @oa_high_pressure;\n sa @sa_high_pressure;\n}\n\ntrace trace_oa_include_triage \"Capability include: Assess an alarm ↔ Capability include: Assess an alarm\" id \"77073fcb-53aa-5f06-987a-0317cbb6971d\" {\n baseline {\n oa {\n description \"\";\n name \"Capability include: Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Capability include: Assess an alarm\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_include_triage;\n sa @sa_include_triage;\n}\n\ntrace trace_oa_merge \"Recovery path completed ↔ Recovery path completed\" id \"0c9de959-d366-5765-98eb-450ea27bd316\" {\n baseline {\n oa {\n description \"\";\n name \"Recovery path completed\";\n };\n sa {\n description \"\";\n name \"Recovery path completed\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_merge;\n sa @sa_merge;\n}\n\ntrace trace_oa_mission \"Maintain safe water supply ↔ Maintain safe water supply\" id \"c987bd0f-87f6-5550-945d-2a42450fe1c5\" {\n baseline {\n oa {\n description \"\";\n name \"Maintain safe water supply\";\n };\n sa {\n description \"\";\n name \"Maintain safe water supply\";\n };\n };\n description \"\";\n kind Mission;\n oa @oa_mission;\n sa @sa_mission;\n}\n\ntrace trace_oa_mission_delivery \"Mission capability: Restore a safe supply ↔ Mission capability: Restore a safe supply\" id \"aa164208-394d-5bc7-8462-b0043f682aad\" {\n baseline {\n oa {\n description \"\";\n name \"Mission capability: Restore a safe supply\";\n };\n sa {\n description \"\";\n name \"Mission capability: Restore a safe supply\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_mission_delivery;\n sa @sa_mission_delivery;\n}\n\ntrace trace_oa_mission_participant \"Mission involvement: Maintain safe water supply ↔ Mission involvement: Maintain safe water supply\" id \"8d693d90-5017-56af-b056-d29d3cb46baa\" {\n baseline {\n oa {\n description \"\";\n name \"Mission involvement: Maintain safe water supply\";\n };\n sa {\n description \"\";\n name \"Mission involvement: Maintain safe water supply\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_mission_participant;\n sa @sa_mission_participant;\n}\n\ntrace trace_oa_monitor \"Pressure supervision ↔ Pressure supervision\" id \"b53863a2-ed80-5f98-ad3c-50e1b821066d\" {\n baseline {\n oa {\n description \"\";\n name \"Pressure supervision\";\n };\n sa {\n description \"\";\n name \"Pressure supervision\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitor;\n sa @sa_monitor;\n}\n\ntrace trace_oa_monitor_initial \"Start ↔ Start\" id \"48d243f0-553f-507e-acb0-9474ae156b63\" {\n baseline {\n oa {\n description \"\";\n name \"Start\";\n };\n sa {\n description \"\";\n name \"Start\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitor_initial;\n sa @sa_monitor_initial;\n}\n\ntrace trace_oa_monitor_region \"Supervision cycle ↔ Supervision cycle\" id \"208753e0-9151-50d8-86ca-29fc28eca80b\" {\n baseline {\n oa {\n description \"\";\n name \"Supervision cycle\";\n };\n sa {\n description \"\";\n name \"Supervision cycle\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitor_region;\n sa @sa_monitor_region;\n}\n\ntrace trace_oa_monitoring \"Monitoring ↔ Monitoring\" id \"12ec320f-2e14-5a5a-8c6b-bd2b6471dae9\" {\n baseline {\n oa {\n description \"\";\n name \"Monitoring\";\n };\n sa {\n description \"\";\n name \"Monitoring\";\n };\n };\n description \"\";\n kind State;\n oa @oa_monitoring;\n sa @sa_monitoring;\n}\n\ntrace trace_oa_normal_transition \"State transition: Monitoring ↔ State transition: Monitoring\" id \"c47cf0b4-fe15-5175-bd62-5292e43adc39\" {\n baseline {\n oa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n sa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_normal_transition;\n sa @sa_normal_transition;\n}\n\ntrace trace_oa_outer_controller \"Controller ↔ Controller\" id \"fd195498-dbba-523b-94d0-49f3b9531a3e\" {\n baseline {\n oa {\n description \"\";\n name \"Controller\";\n };\n sa {\n description \"\";\n name \"Controller\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_outer_controller;\n sa @sa_outer_controller;\n}\n\ntrace trace_oa_outer_technician \"Technician ↔ Technician\" id \"c132a0df-1270-55a8-ac4e-6e5075823dc1\" {\n baseline {\n oa {\n description \"\";\n name \"Technician\";\n };\n sa {\n description \"\";\n name \"Technician\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_outer_technician;\n sa @sa_outer_technician;\n}\n\ntrace trace_oa_pressure \"Measured pressure ↔ Measured pressure\" id \"d9d47371-e98d-5d29-9df7-4de2d28a5c73\" {\n baseline {\n oa {\n description \"\";\n name \"Measured pressure\";\n };\n sa {\n description \"\";\n name \"Measured pressure\";\n };\n };\n description \"\";\n kind Item;\n oa @oa_pressure;\n sa @sa_pressure;\n}\n\ntrace trace_oa_pressure_parameter \"Pressure before / after repair ↔ Pressure before / after repair\" id \"0d905fbb-5b3c-56f0-9f11-a27091901eb0\" {\n baseline {\n oa {\n description \"\";\n name \"Pressure before / after repair\";\n };\n sa {\n description \"\";\n name \"Pressure before / after repair\";\n };\n };\n description \"\";\n kind Item;\n oa @oa_pressure_parameter;\n sa @sa_pressure_parameter;\n}\n\ntrace trace_oa_remote_merge \"Continue ↔ Continue\" id \"1b788e74-36b2-5c17-a936-642dbe51ca3c\" {\n baseline {\n oa {\n description \"\";\n name \"Continue\";\n };\n sa {\n description \"\";\n name \"Continue\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_remote_merge;\n sa @sa_remote_merge;\n}\n\ntrace trace_oa_remote_path \"remote reset is authorized ↔ remote reset is authorized\" id \"eee79ec1-41d7-5374-986e-995a56b9026f\" {\n baseline {\n oa {\n description \"\";\n name \"remote reset is authorized\";\n };\n sa {\n description \"\";\n name \"remote reset is authorized\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_remote_path;\n sa @sa_remote_path;\n}\n\ntrace trace_oa_repair \"Repair valve in the field ↔ Repair valve in the field\" id \"b13452bb-1a55-5ad6-b6d9-a37fb34e8d13\" {\n baseline {\n oa {\n description \"\";\n name \"Repair valve in the field\";\n };\n sa {\n description \"\";\n name \"Repair valve in the field\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_repair;\n sa @sa_repair;\n}\n\ntrace trace_oa_repair_allocation \"Allocation: Repair valve in the field ↔ Allocation: Repair valve in the field\" id \"3714d547-006f-5c3a-97f8-175ccc23d994\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Repair valve in the field\";\n };\n sa {\n description \"\";\n name \"Allocation: Repair valve in the field\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_repair_allocation;\n sa @sa_repair_allocation;\n}\n\ntrace trace_oa_repair_operation \"Valve repair ↔ Valve repair\" id \"cf92cedf-ea1a-510c-9bc5-89c16d60e8d2\" {\n baseline {\n oa {\n description \"\";\n name \"Valve repair\";\n };\n sa {\n description \"\";\n name \"Valve repair\";\n };\n };\n description \"\";\n kind Item;\n oa @oa_repair_operation;\n sa @sa_repair_operation;\n}\n\ntrace trace_oa_repair_report \"Report repair result ↔ Report repair result\" id \"424eca39-1c29-5a2f-b9ee-33dd018926aa\" {\n baseline {\n oa {\n description \"\";\n name \"Report repair result\";\n };\n sa {\n description \"\";\n name \"Report repair result\";\n };\n };\n description \"\";\n kind Exchange;\n oa @oa_repair_report;\n sa @sa_repair_report;\n}\n\ntrace trace_oa_repair_request \"Request field repair ↔ Request field repair\" id \"da8efc3c-78df-5b10-b2e5-1e28d6d99e86\" {\n baseline {\n oa {\n description \"\";\n name \"Request field repair\";\n };\n sa {\n description \"\";\n name \"Request field repair\";\n };\n };\n description \"\";\n kind Exchange;\n oa @oa_repair_request;\n sa @sa_repair_request;\n}\n\ntrace trace_oa_repair_step \"Repair the valve ↔ Repair the valve\" id \"ac3aa6b0-568e-5a3f-963a-172aa3cfc7a4\" {\n baseline {\n oa {\n description \"\";\n name \"Repair the valve\";\n };\n sa {\n description \"\";\n name \"Repair the valve\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_repair_step;\n sa @sa_repair_step;\n}\n\ntrace trace_oa_repair_then_report \"Sequence link: Report the measured result ↔ Sequence link: Report the measured result\" id \"f8d34715-1dc8-504b-acdf-78b770aad1e8\" {\n baseline {\n oa {\n description \"\";\n name \"Sequence link: Report the measured result\";\n };\n sa {\n description \"\";\n name \"Sequence link: Report the measured result\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_repair_then_report;\n sa @sa_repair_then_report;\n}\n\ntrace trace_oa_repair_traffic \"Repair work exchanges ↔ Repair work exchanges\" id \"3e7bde54-f9c1-549f-9e5b-2ce1ecb685a7\" {\n baseline {\n oa {\n description \"\";\n name \"Repair work exchanges\";\n };\n sa {\n description \"\";\n name \"Repair work exchanges\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_repair_traffic;\n sa @sa_repair_traffic;\n}\n\ntrace trace_oa_reply \"Return pressure after repair ↔ Return pressure after repair\" id \"595b5729-ccd9-5291-b5d9-30d8c8bd9dfa\" {\n baseline {\n oa {\n description \"\";\n name \"Return pressure after repair\";\n };\n sa {\n description \"\";\n name \"Return pressure after repair\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_reply;\n sa @sa_reply;\n}\n\ntrace trace_oa_report_step \"Report the measured result ↔ Report the measured result\" id \"e63b14d2-f408-56a2-aec4-be20cf4ace73\" {\n baseline {\n oa {\n description \"\";\n name \"Report the measured result\";\n };\n sa {\n description \"\";\n name \"Report the measured result\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_report_step;\n sa @sa_report_step;\n}\n\ntrace trace_oa_request_step \"Request the field repair ↔ Request the field repair\" id \"ad697c00-07d9-5799-89b3-2c78da8dff4d\" {\n baseline {\n oa {\n description \"\";\n name \"Request the field repair\";\n };\n sa {\n description \"\";\n name \"Request the field repair\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_request_step;\n sa @sa_request_step;\n}\n\ntrace trace_oa_request_then_repair \"Sequence link: Repair the valve ↔ Sequence link: Repair the valve\" id \"f2b7050c-629d-533f-b873-bffc33e072f7\" {\n baseline {\n oa {\n description \"\";\n name \"Sequence link: Repair the valve\";\n };\n sa {\n description \"\";\n name \"Sequence link: Repair the valve\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_request_then_repair;\n sa @sa_request_then_repair;\n}\n\ntrace trace_oa_reset \"Reset valve remotely ↔ Reset valve remotely\" id \"82a5be1a-92a8-578d-9448-086062a20d8f\" {\n baseline {\n oa {\n description \"\";\n name \"Reset valve remotely\";\n };\n sa {\n description \"\";\n name \"Reset valve remotely\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_reset;\n sa @sa_reset;\n}\n\ntrace trace_oa_reset_allocation \"Allocation: Reset valve remotely ↔ Allocation: Reset valve remotely\" id \"98ef05b5-5381-5d35-a433-ebab55e9f382\" {\n baseline {\n oa {\n description \"\";\n name \"Allocation: Reset valve remotely\";\n };\n sa {\n description \"\";\n name \"Allocation: Reset valve remotely\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_reset_allocation;\n sa @sa_reset_allocation;\n}\n\ntrace trace_oa_reset_step \"Try the authorized remote reset ↔ Try the authorized remote reset\" id \"ff84fc06-6f99-5cf3-b73d-236e9eff98a3\" {\n baseline {\n oa {\n description \"\";\n name \"Try the authorized remote reset\";\n };\n sa {\n description \"\";\n name \"Try the authorized remote reset\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_reset_step;\n sa @sa_reset_step;\n}\n\ntrace trace_oa_responder \"Technician ↔ Technician\" id \"c5051628-b7af-5fb1-a014-bb60a0b46e03\" {\n baseline {\n oa {\n description \"\";\n name \"Technician\";\n };\n sa {\n description \"\";\n name \"Technician\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_responder;\n sa @sa_responder;\n}\n\ntrace trace_oa_response \"Handle a pressure alarm ↔ Handle a pressure alarm\" id \"d3626818-4846-5893-9c02-3407cdba7f3c\" {\n baseline {\n oa {\n description \"\";\n name \"Handle a pressure alarm\";\n };\n sa {\n description \"\";\n name \"Handle a pressure alarm\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_response;\n sa @sa_response;\n}\n\ntrace trace_oa_response_delivery \"Chain involvement: Restore a safe supply ↔ Chain involvement: Restore a safe supply\" id \"70c90b0c-85ce-574a-a217-f0d19fad30e1\" {\n baseline {\n oa {\n description \"\";\n name \"Chain involvement: Restore a safe supply\";\n };\n sa {\n description \"\";\n name \"Chain involvement: Restore a safe supply\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_response_delivery;\n sa @sa_response_delivery;\n}\n\ntrace trace_oa_restore \"Restore a safe supply ↔ Restore a safe supply\" id \"0fbef164-b5d3-58ba-ba41-54d0e63709ab\" {\n baseline {\n oa {\n description \"\";\n name \"Restore a safe supply\";\n };\n sa {\n description \"\";\n name \"Restore a safe supply\";\n };\n };\n description \"\";\n kind Capability;\n oa @oa_restore;\n sa @sa_restore;\n}\n\ntrace trace_oa_restore_work \"Restore safe pressure ↔ Restore safe pressure\" id \"82cca563-9d5d-5479-aa1a-9db03e9674d1\" {\n baseline {\n oa {\n description \"\";\n name \"Restore safe pressure\";\n };\n sa {\n description \"\";\n name \"Restore safe pressure\";\n };\n };\n description \"\";\n kind Refinement;\n oa @oa_restore_work;\n sa @sa_restore_work;\n}\n\ntrace trace_oa_start_monitoring \"State transition: Monitoring ↔ State transition: Monitoring\" id \"07a7b82a-b5a7-5737-abfa-09e422f7420e\" {\n baseline {\n oa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n sa {\n description \"\";\n name \"State transition: Monitoring\";\n };\n };\n description \"\";\n kind Manual;\n oa @oa_start_monitoring;\n sa @sa_start_monitoring;\n}\n\ntrace trace_oa_technician \"Field technician ↔ Field technician\" id \"d47d3c36-50fb-5189-b801-d6fd1694bd49\" {\n baseline {\n oa {\n description \"\";\n name \"Field technician\";\n };\n sa {\n description \"\";\n name \"Field technician\";\n };\n };\n description \"\";\n kind Participant;\n oa @oa_technician;\n sa @sa_technician;\n}\n\ntrace trace_oa_triage \"Assess an alarm ↔ Assess an alarm\" id \"6acd072d-431e-5881-ab20-1813b4fda0c5\" {\n baseline {\n oa {\n description \"\";\n name \"Assess an alarm\";\n };\n sa {\n description \"\";\n name \"Assess an alarm\";\n };\n };\n description \"\";\n kind Capability;\n oa @oa_triage;\n sa @sa_triage;\n}\n\ntrace trace_oa_use_conversation \"Use the reviewed repair conversation ↔ Use the reviewed repair conversation\" id \"f5733d3f-eee3-5cf7-a422-5edd2054d6a6\" {\n baseline {\n oa {\n description \"\";\n name \"Use the reviewed repair conversation\";\n };\n sa {\n description \"\";\n name \"Use the reviewed repair conversation\";\n };\n };\n description \"\";\n kind Scenario;\n oa @oa_use_conversation;\n sa @sa_use_conversation;\n}\n\ntrace trace_oa_use_field \"Use the field repair procedure ↔ Use the field repair procedure\" id \"7a6b4b9c-4220-546a-a752-1587c2881fe1\" {\n baseline {\n oa {\n description \"\";\n name \"Use the field repair procedure\";\n };\n sa {\n description \"\";\n name \"Use the field repair procedure\";\n };\n };\n description \"\";\n kind Sequence;\n oa @oa_use_field;\n sa @sa_use_field;\n}\n\ntrace trace_oa_waiting \"Awaiting repair ↔ Awaiting repair\" id \"0754be23-e4b8-53bc-b03c-89c2dbd80361\" {\n baseline {\n oa {\n description \"\";\n name \"Awaiting repair\";\n };\n sa {\n description \"\";\n name \"Awaiting repair\";\n };\n };\n description \"\";\n kind State;\n oa @oa_waiting;\n sa @sa_waiting;\n}\n\n", + "views/diagram_oa_allocation_view.arcadia": "diagram oa_allocation_view \"1 · OA responsibility and repair traffic\" id \"103ea62c-ac56-5827-94e7-cf09bca96ccc\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Oa;\n projection Allocation;\n routes [];\n view_options {\n coverage \"All\";\n exchange_labels \"Name\";\n hidden_edges [];\n participant_categories \"All\";\n relation_kinds \"All\";\n work_categories \"All\";\n work_contexts \"All\";\n };\n}\n\nnode oa_allocation_view_oa_assess id \"5ae1c46d-59a3-50fd-8cc3-b292df21f951\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_assess;\n position {\n x 100.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_confirm id \"edd74060-ac61-5450-a57c-a6577daa5d50\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_confirm;\n position {\n x 100.0;\n y 490.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_control id \"3191e05c-1c7f-5f0e-beb0-5f3bded54c48\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_control;\n position {\n x 60.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_repair id \"202ebe37-ddb4-5099-8ce9-ec3e895ab9e9\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_repair;\n position {\n x 890.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_reset id \"7066f78c-9736-5349-b94a-0587331b980e\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_reset;\n position {\n x 100.0;\n y 330.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode oa_allocation_view_oa_technician id \"d27f28ef-f10a-5355-9a05-6f2fd39b7de6\" {\n collapsed false;\n diagram @oa_allocation_view;\n element @oa_technician;\n position {\n x 850.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_allocation_view.arcadia": "diagram sa_allocation_view \"2 · SA service boundary and functions\" id \"35d3c24b-289e-59e5-a610-8333e2c6a4bb\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection Allocation;\n routes [];\n view_options {\n coverage \"All\";\n exchange_labels \"Name\";\n hidden_edges [];\n participant_categories \"All\";\n relation_kinds \"All\";\n work_categories \"All\";\n work_contexts \"All\";\n };\n}\n\nnode sa_allocation_view_sa_assess id \"60f6ad74-b69e-5aef-b832-f1bd60b903ae\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_assess;\n position {\n x 100.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_confirm id \"7e3ba36f-bc68-5520-bdb9-77083bb7bba2\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_confirm;\n position {\n x 100.0;\n y 490.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_control id \"8345c76a-8d85-5c31-902e-ec1cd33ed4a3\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_control;\n position {\n x 60.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_repair id \"34630a18-41b0-5fdd-bff4-ee86e93e8651\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_repair;\n position {\n x 890.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_reset id \"73406244-ebd7-5995-86c7-e17c73c50b32\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_reset;\n position {\n x 100.0;\n y 330.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_allocation_view_sa_technician id \"f7225d1c-e506-505d-bb20-916950269d4b\" {\n collapsed false;\n diagram @sa_allocation_view;\n element @sa_technician;\n position {\n x 850.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_capability_view.arcadia": "diagram sa_capability_view \"4 · SA mission and included capability\" id \"3e6ad194-61b5-584c-b0f0-a78a20af5781\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection Capabilities;\n routes [];\n view_options {\n coverage \"All\";\n exchange_labels \"Name\";\n hidden_edges [];\n participant_categories \"All\";\n relation_kinds \"All\";\n work_categories \"All\";\n work_contexts \"All\";\n };\n}\n\nnode sa_capability_view_sa_control id \"41ddb68b-bd2b-5f91-82eb-efde01157828\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_control;\n position {\n x 60.0;\n y 60.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_capability_view_sa_mission id \"4b814335-bf98-5b68-85d5-ea549766517a\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_mission;\n position {\n x 420.0;\n y 60.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_capability_view_sa_restore id \"d3bc13f9-7726-5280-83fe-63f391c45afb\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_restore;\n position {\n x 420.0;\n y 260.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_capability_view_sa_triage id \"069a3fb3-077c-59d8-91da-27b0665143c7\" {\n collapsed false;\n diagram @sa_capability_view;\n element @sa_triage;\n position {\n x 800.0;\n y 260.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_service_view.arcadia": "diagram sa_service_view \"3 · SA service conversation only\" id \"50af4c9b-93ce-5ff5-a1f8-7a81eb97e779\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection Allocation;\n routes [];\n view_options {\n coverage \"All\";\n exchange_labels \"NameAndItems\";\n hidden_edges [];\n participant_categories {\n Any [@sa_contact_category];\n };\n relation_kinds \"All\";\n work_categories {\n Any [];\n };\n work_contexts \"All\";\n };\n}\n\nnode sa_service_view_sa_assess id \"439f1e45-e5ed-567c-978b-4c93ef267db1\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_assess;\n position {\n x 100.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_confirm id \"6f138672-149b-5f88-ab07-7fc413a42262\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_confirm;\n position {\n x 100.0;\n y 490.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_control id \"f53d4352-b042-56a6-8b3e-c51cd27254f9\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_control;\n position {\n x 60.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_repair id \"ae36489d-6ec8-5f1c-b603-8b497227efba\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_repair;\n position {\n x 890.0;\n y 170.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_reset id \"d52da4e6-40b7-5dbc-84fb-80c443711c69\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_reset;\n position {\n x 100.0;\n y 330.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_service_view_sa_technician id \"40210589-69f7-5209-b329-10bb83cf615c\" {\n collapsed false;\n diagram @sa_service_view;\n element @sa_technician;\n position {\n x 850.0;\n y 80.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n", + "views/diagram_sa_state_view.arcadia": "diagram sa_state_view \"5 · SA pressure supervision\" id \"30d5baf9-6581-5cb6-846a-bcaa1e6cce63\" {\n description \"Teaching view: positions and filters do not change ownership or exchange identities.\";\n layer Sa;\n projection {\n StateMachine {\n machine @sa_monitor;\n };\n };\n routes [];\n view_options {\n coverage \"All\";\n exchange_labels \"Name\";\n hidden_edges [];\n participant_categories \"All\";\n relation_kinds \"All\";\n work_categories \"All\";\n work_contexts \"All\";\n };\n}\n\nnode sa_state_view_sa_monitor id \"a676fbb9-0833-59a8-98cf-a10d8f4b7434\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitor;\n position {\n x 30.0;\n y 30.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_monitor_initial id \"5e2326a0-0f6c-5f8d-8a21-fdded18a9281\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitor_initial;\n position {\n x 100.0;\n y 200.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_monitor_region id \"55a74f91-2025-5001-8db8-9566b1dbe783\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitor_region;\n position {\n x 70.0;\n y 95.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_monitoring id \"b3af7b04-5a45-5b82-ab69-7eada4b876f0\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_monitoring;\n position {\n x 220.0;\n y 200.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\nnode sa_state_view_sa_waiting id \"57acbb71-ca86-57fc-ae90-42dad195f0e0\" {\n collapsed false;\n diagram @sa_state_view;\n element @sa_waiting;\n position {\n x 650.0;\n y 200.0;\n };\n size {\n height 110.0;\n width 245.0;\n };\n}\n\n" + } +} diff --git a/crates/oa-desktop/src/recovery/fixtures/source6-model.json b/crates/oa-desktop/src/recovery/fixtures/source6-model.json new file mode 100644 index 00000000..f8abfce4 --- /dev/null +++ b/crates/oa-desktop/src/recovery/fixtures/source6-model.json @@ -0,0 +1 @@ +{"schema_version":6,"id":"2786d72f-5763-5f33-a57f-2773a4ca1a6c","alias":"pressure_restoration","name":"Restore safe water pressure — release 1 walkthrough","description":"A controller either resets a valve remotely or requests field repair. Compare the operational need with the explicitly chosen coordination-service boundary. Start with README.md beside project.arcadia. Units and guards are descriptive; this model does not simulate a real water network.","elements":{"01490fa8-2f01-5ac8-9722-d55d45414379":{"id":"01490fa8-2f01-5ac8-9722-d55d45414379","alias":"sa_high_pressure","name":"Pressure alarm detected","description":"","parent":null,"body":{"Sa":{"Shared":{"Event":{"body":{"Change":{"expression":"measured_pressure > operating_limit"}}}}}}},"022d3cfa-bbaf-5e13-924a-8e55099b8745":{"id":"022d3cfa-bbaf-5e13-924a-8e55099b8745","alias":"oa_assessment_procedure","name":"Assess an alarm procedure","description":"","parent":null,"body":{"Oa":{"Process":{"steps":["942975b8-0bef-5ae8-9fcb-c37497833fb0"]}}}},"09342ccb-044b-5483-ac58-87dc3cf7831c":{"id":"09342ccb-044b-5483-ac58-87dc3cf7831c","alias":"sa_reset","name":"Reset valve remotely","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"0b07b9ed-492d-57f6-b0b1-ce8a709238a7":{"id":"0b07b9ed-492d-57f6-b0b1-ce8a709238a7","alias":"oa_assess_step","name":"Assess the alarm","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"022d3cfa-bbaf-5e13-924a-8e55099b8745"}},"precondition":"","postcondition":""}}}}},"0f195fea-3493-5ebd-966d-da9eddeb05d7":{"id":"0f195fea-3493-5ebd-966d-da9eddeb05d7","alias":"sa_repair_step","name":"Repair the valve","description":"","parent":"93eb6479-54e9-59cb-ac7b-16912347bfc0","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"9497e10f-04c4-5586-8738-2464a6f00bd4","lifeline":null}},"precondition":"","postcondition":""}}}}},"132b3f28-3f88-5327-9868-8c8b645ed412":{"id":"132b3f28-3f88-5327-9868-8c8b645ed412","alias":"sa_monitor","name":"Pressure supervision","description":"","parent":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","body":{"Sa":{"Shared":"StateMachine"}}},"155e4fe4-9e3b-5f4e-b886-28abcc3f086b":{"id":"155e4fe4-9e3b-5f4e-b886-28abcc3f086b","alias":"sa_outer_technician","name":"Technician","description":"","parent":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","body":{"Sa":{"Shared":{"Lifeline":{"represented":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a"}}}}},"1970608f-85d1-5234-ac86-5e2b0df27dc3":{"id":"1970608f-85d1-5234-ac86-5e2b0df27dc3","alias":"oa_monitor","name":"Pressure supervision","description":"","parent":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","body":{"Oa":{"Shared":"StateMachine"}}},"19bd3a3a-4d0c-56d5-bf50-a6b43fd81771":{"id":"19bd3a3a-4d0c-56d5-bf50-a6b43fd81771","alias":"oa_contact_category","name":"Field contact","description":"","parent":null,"body":{"Oa":{"Shared":{"ExchangeCategory":{"family":"Participant","exchanges":["27b47887-baf0-518b-bf87-f5acbf7d0b93"]}}}}},"1b91f8a7-0bdb-514e-b1cd-955658e09927":{"id":"1b91f8a7-0bdb-514e-b1cd-955658e09927","alias":"oa_restore_work","name":"Restore safe pressure","description":"","parent":null,"body":{"Oa":"Activity"}},"1c9da843-e2d3-5591-8e3f-db083a42867e":{"id":"1c9da843-e2d3-5591-8e3f-db083a42867e","alias":"sa_use_conversation","name":"Use the reviewed repair conversation","description":"","parent":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","body":{"Sa":{"Shared":{"Occurrence":{"body":{"ScenarioUse":{"scenario":"730b27ca-b039-5a3b-8dc6-03418f9061b3","bindings":[{"referenced":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","local":"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023"},{"referenced":"6249b54a-e396-5338-82b9-89266c55670d","local":"155e4fe4-9e3b-5f4e-b886-28abcc3f086b"}]}},"precondition":"","postcondition":""}}}}},"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023":{"id":"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023","alias":"sa_outer_controller","name":"Controller","description":"","parent":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","body":{"Sa":{"Shared":{"Lifeline":{"represented":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0"}}}}},"2011b3ac-614b-5a47-8891-7ff81965f425":{"id":"2011b3ac-614b-5a47-8891-7ff81965f425","alias":"oa_outer_controller","name":"Controller","description":"","parent":"d6f881a4-95da-54e9-ad97-1b8965532629","body":{"Oa":{"Shared":{"Lifeline":{"represented":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22"}}}}},"20472233-5ce6-5ffe-b4ad-0bdbccf3e423":{"id":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","alias":"oa_conversation","name":"Repair request and measured reply","description":"","parent":null,"body":{"Oa":{"Shared":{"Scenario":{"viewpoint":"ParticipantInteraction","context":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["b97437c6-cd7a-5a05-b537-b30a17dd936a","cc2d497a-fd15-5455-9ee5-e70eb95811d8"]}}}}},"24a6850e-b819-57e0-af88-ca4ab76581b7":{"id":"24a6850e-b819-57e0-af88-ca4ab76581b7","alias":"sa_repair_request_out","name":"Request field repair output","description":"","parent":"dddae020-a444-5d48-acc8-139d2696a672","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionOutput":{"outgoing_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"30c8fad9-a49b-5692-9542-3148e76afd7b":{"id":"30c8fad9-a49b-5692-9542-3148e76afd7b","alias":"sa_decision","name":"Can a remote reset safely recover pressure?","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Choice"}},"precondition":"","postcondition":""}}}}},"32aa9940-df6e-5a12-b9bd-6337e2d2ed22":{"id":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","alias":"oa_control","name":"Control room","description":"","parent":null,"body":{"Oa":"Entity"}},"391af7c4-b07d-53a7-8ddd-5c81461fc4f8":{"id":"391af7c4-b07d-53a7-8ddd-5c81461fc4f8","alias":"sa_repair_report_in","name":"Report repair result input","description":"","parent":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionInput":{"incoming_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c":{"id":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","alias":"sa_monitor_region","name":"Supervision cycle","description":"","parent":"132b3f28-3f88-5327-9868-8c8b645ed412","body":{"Sa":{"Shared":"Region"}}},"3dfee23a-148b-5cec-ad2d-b774e84ea5bb":{"id":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","alias":"oa_restore","name":"Restore a safe supply","description":"","parent":null,"body":{"Oa":"Capability"}},"40d94322-baa2-5c81-b261-ff47cdc33782":{"id":"40d94322-baa2-5c81-b261-ff47cdc33782","alias":"sa_request_step","name":"Request the field repair","description":"","parent":"93eb6479-54e9-59cb-ac7b-16912347bfc0","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"93474111-f23e-56c1-b61b-b7c2396b91bd","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"4149a872-3440-59de-9879-8981e78d27ab":{"id":"4149a872-3440-59de-9879-8981e78d27ab","alias":"sa_audit_thresholds","name":"Audit pressure thresholds","description":"Deliberately unfinished: assigned to the service, but no behavior context yet. Model checks should expose this one work_without_behavior_context warning.","parent":null,"body":{"Sa":"Function"}},"41859a65-ded2-547b-ab03-14e2fa740cf9":{"id":"41859a65-ded2-547b-ab03-14e2fa740cf9","alias":"oa_caller","name":"Controller","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Lifeline":{"represented":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22"}}}}},"435bf87c-73fd-5e4d-978a-4c471bc9eaf0":{"id":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","alias":"sa_control","name":"Pressure coordination service","description":"","parent":null,"body":{"Sa":"System"}},"47bec4f9-d6d0-5ed1-adca-fa0d960dc416":{"id":"47bec4f9-d6d0-5ed1-adca-fa0d960dc416","alias":"sa_confirm_step","name":"Confirm safe pressure","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","lifeline":null}},"precondition":"","postcondition":""}}}}},"4e1c0a56-ce80-5f13-a93a-6956de54880c":{"id":"4e1c0a56-ce80-5f13-a93a-6956de54880c","alias":"sa_reply","name":"Return pressure after repair","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"63009b5f-539e-5914-b630-ec6b7fefb4d5","sender":"6249b54a-e396-5338-82b9-89266c55670d","receiver":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","message_kind":"Reply","reply_to":"8bab4783-a098-5984-9e36-493957caf26c","values":[{"path":[],"field":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","expression":"320.0"}]}},"precondition":"","postcondition":""}}}}},"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07":{"id":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","alias":"sa_merge","name":"Recovery path completed","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Merge"}},"precondition":"","postcondition":""}}}}},"5139ce73-56fd-5875-ba3a-e569fc890231":{"id":"5139ce73-56fd-5875-ba3a-e569fc890231","alias":"sa_response","name":"Handle a pressure alarm","description":"","parent":null,"body":{"Sa":{"FunctionalChain":{"steps":["9024243c-ccc1-5edb-8477-2aa16e5e9a50","30c8fad9-a49b-5692-9542-3148e76afd7b","bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","d5e61c5d-510b-5091-a878-da46f3a9e152","4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","47bec4f9-d6d0-5ed1-adca-fa0d960dc416"]}}}},"57eb5348-14d8-560e-996a-8dbf993243e2":{"id":"57eb5348-14d8-560e-996a-8dbf993243e2","alias":"sa_monitor_initial","name":"Start","description":"","parent":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","body":{"Sa":{"Shared":{"State":{"kind":"Initial","entry":"","exit":"","do_activity":""}}}}},"58867e7d-916e-5ebc-9e4c-8dbed1314be3":{"id":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","alias":"sa_restore_work","name":"Restore safe pressure","description":"","parent":null,"body":{"Sa":"Function"}},"58bae2b0-f560-50f2-818d-19091d22d18a":{"id":"58bae2b0-f560-50f2-818d-19091d22d18a","alias":"oa_response","name":"Handle a pressure alarm","description":"","parent":null,"body":{"Oa":{"Process":{"steps":["0b07b9ed-492d-57f6-b0b1-ce8a709238a7","b280c6ca-e5f9-5424-a5c1-5676e01c2371","d2026fc2-74a8-5e22-9942-ff3eb68a3677","fc6763bf-a534-54bf-afcd-57ab285ff07d","6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","e00594a3-6122-5a6b-b3c2-ecb5506c741d"]}}}},"58c15833-6e75-523a-b57f-a88f19f6d672":{"id":"58c15833-6e75-523a-b57f-a88f19f6d672","alias":"oa_waiting","name":"Awaiting repair","description":"","parent":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","body":{"Oa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"5ad034b1-084d-5273-9e70-971671766132":{"id":"5ad034b1-084d-5273-9e70-971671766132","alias":"sa_mission","name":"Maintain safe water supply","description":"","parent":null,"body":{"Sa":"Mission"}},"5ba8079c-2dbe-5e98-ae82-2b60c6020a02":{"id":"5ba8079c-2dbe-5e98-ae82-2b60c6020a02","alias":"oa_request_step","name":"Request the field repair","description":"","parent":"5d3af417-244c-54bb-9588-16daedda4e1f","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"5d3af417-244c-54bb-9588-16daedda4e1f":{"id":"5d3af417-244c-54bb-9588-16daedda4e1f","alias":"oa_field_procedure","name":"Field repair procedure","description":"","parent":null,"body":{"Oa":{"Process":{"steps":["5ba8079c-2dbe-5e98-ae82-2b60c6020a02","c59432fe-6092-5ee0-908d-9dbf39476471","df2fd3f8-6bc2-5380-a13f-36643ed35ac7"]}}}},"61f7bbfb-74a4-5540-9182-2583dfb27a49":{"id":"61f7bbfb-74a4-5540-9182-2583dfb27a49","alias":"oa_assess","name":"Assess pressure alarm","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"6249b54a-e396-5338-82b9-89266c55670d":{"id":"6249b54a-e396-5338-82b9-89266c55670d","alias":"sa_responder","name":"Technician","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Lifeline":{"represented":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a"}}}}},"65d6b8d6-271a-5c84-8b42-abbe310c955e":{"id":"65d6b8d6-271a-5c84-8b42-abbe310c955e","alias":"oa_high_pressure","name":"Pressure alarm detected","description":"","parent":null,"body":{"Oa":{"Shared":{"Event":{"body":{"Change":{"expression":"measured_pressure > operating_limit"}}}}}}},"6a30b763-cc51-546d-907f-5c63657cfd03":{"id":"6a30b763-cc51-546d-907f-5c63657cfd03","alias":"oa_triage","name":"Assess an alarm","description":"","parent":null,"body":{"Oa":"Capability"}},"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d":{"id":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","alias":"oa_merge","name":"Recovery path completed","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Merge"}},"precondition":"","postcondition":""}}}}},"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae":{"id":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","alias":"oa_monitoring","name":"Monitoring","description":"","parent":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","body":{"Oa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"727c0b5c-8db6-5eb7-83d6-735519c5949f":{"id":"727c0b5c-8db6-5eb7-83d6-735519c5949f","alias":"oa_confirm","name":"Confirm safe pressure","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"730b27ca-b039-5a3b-8dc6-03418f9061b3":{"id":"730b27ca-b039-5a3b-8dc6-03418f9061b3","alias":"sa_conversation","name":"Repair request and measured reply","description":"","parent":null,"body":{"Sa":{"Shared":{"Scenario":{"viewpoint":"BoundaryInterface","context":"81259051-370a-512d-a3b5-6420a6f933b6","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["8bab4783-a098-5984-9e36-493957caf26c","4e1c0a56-ce80-5f13-a93a-6956de54880c"]}}}}},"781cbe75-410f-5cc5-aeb8-a43491f77b99":{"id":"781cbe75-410f-5cc5-aeb8-a43491f77b99","alias":"sa_contact_category","name":"Field contact","description":"","parent":null,"body":{"Sa":{"Shared":{"ExchangeCategory":{"family":"Participant","exchanges":["63009b5f-539e-5914-b630-ec6b7fefb4d5"]}}}}},"81259051-370a-512d-a3b5-6420a6f933b6":{"id":"81259051-370a-512d-a3b5-6420a6f933b6","alias":"sa_restore","name":"Restore a safe supply","description":"","parent":null,"body":{"Sa":"Capability"}},"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7":{"id":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","alias":"oa_monitor_region","name":"Supervision cycle","description":"","parent":"1970608f-85d1-5234-ac86-5e2b0df27dc3","body":{"Oa":{"Shared":"Region"}}},"87d0a95b-af1b-5352-b753-e4c6533671a5":{"id":"87d0a95b-af1b-5352-b753-e4c6533671a5","alias":"sa_pressure","name":"Measured pressure","description":"","parent":null,"body":{"Sa":{"Shared":{"DataType":{"definition":{"Real":{"minimum":0.0,"maximum":600.0}},"default":null,"unit":"kPa"}}}}},"8af21f9b-c21e-5aae-af0d-0958840f6655":{"id":"8af21f9b-c21e-5aae-af0d-0958840f6655","alias":"sa_monitoring","name":"Monitoring","description":"","parent":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","body":{"Sa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"8bab4783-a098-5984-9e36-493957caf26c":{"id":"8bab4783-a098-5984-9e36-493957caf26c","alias":"sa_call","name":"Request valve repair","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"63009b5f-539e-5914-b630-ec6b7fefb4d5","sender":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","receiver":"6249b54a-e396-5338-82b9-89266c55670d","message_kind":"Call","reply_to":null,"values":[{"path":[],"field":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","expression":"420.0"}]}},"precondition":"","postcondition":""}}}}},"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7":{"id":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","alias":"oa_pressure_parameter","name":"Pressure before / after repair","description":"","parent":"d736c7d6-d369-550a-8ba4-dae223e40bb3","body":{"Oa":{"Shared":{"DataField":{"type_ref":{"Named":"e7824764-5db9-5384-8198-2fbaea2b10b2"},"minimum":1,"maximum":1,"unit":"","direction":"InOut","storage":"Value","default":null}}}}},"9024243c-ccc1-5edb-8477-2aa16e5e9a50":{"id":"9024243c-ccc1-5edb-8477-2aa16e5e9a50","alias":"sa_assess_step","name":"Assess the alarm","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"acd5a018-f559-5298-a40d-1cf6e1353e74"}},"precondition":"","postcondition":""}}}}},"9349b353-53f2-56f8-b571-a05b09335cdf":{"id":"9349b353-53f2-56f8-b571-a05b09335cdf","alias":"oa_repair_traffic","name":"Repair work exchanges","description":"","parent":null,"body":{"Oa":{"Shared":{"ExchangeCategory":{"family":"Work","exchanges":["cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","b436d0b3-c824-5a96-af6b-2c269816d1cc"]}}}}},"93eb6479-54e9-59cb-ac7b-16912347bfc0":{"id":"93eb6479-54e9-59cb-ac7b-16912347bfc0","alias":"sa_field_procedure","name":"Field repair procedure","description":"","parent":null,"body":{"Sa":{"FunctionalChain":{"steps":["40d94322-baa2-5c81-b261-ff47cdc33782","0f195fea-3493-5ebd-966d-da9eddeb05d7","ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac"]}}}},"942975b8-0bef-5ae8-9fcb-c37497833fb0":{"id":"942975b8-0bef-5ae8-9fcb-c37497833fb0","alias":"oa_assessment_action","name":"Assess pressure alarm","description":"","parent":"022d3cfa-bbaf-5e13-924a-8e55099b8745","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"61f7bbfb-74a4-5540-9182-2583dfb27a49","lifeline":null}},"precondition":"","postcondition":""}}}}},"9497e10f-04c4-5586-8738-2464a6f00bd4":{"id":"9497e10f-04c4-5586-8738-2464a6f00bd4","alias":"sa_repair","name":"Repair valve in the field","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"98c61788-2fee-51f4-8cf9-de48e8c70e9e":{"id":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","alias":"sa_caller","name":"Controller","description":"","parent":"730b27ca-b039-5a3b-8dc6-03418f9061b3","body":{"Sa":{"Shared":{"Lifeline":{"represented":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0"}}}}},"98d411e7-7c65-5c3f-b543-8bf0f2522ea2":{"id":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","alias":"sa_control_port","name":"Repair service client","description":"Explicitly chosen Standard component port for the repair service.","parent":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","body":{"Sa":{"Shared":{"Port":{"contract":{"Component":{"kind":"Standard","orientation":"Unset"}},"legacy":null}}}}},"994faed7-dbc8-5b96-9d62-7604ac0fbc3a":{"id":"994faed7-dbc8-5b96-9d62-7604ac0fbc3a","alias":"sa_repair_report_out","name":"Report repair result output","description":"","parent":"9497e10f-04c4-5586-8738-2464a6f00bd4","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionOutput":{"outgoing_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5":{"id":"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5","alias":"oa_use_conversation","name":"Use the reviewed repair conversation","description":"","parent":"d6f881a4-95da-54e9-ad97-1b8965532629","body":{"Oa":{"Shared":{"Occurrence":{"body":{"ScenarioUse":{"scenario":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","bindings":[{"referenced":"41859a65-ded2-547b-ab03-14e2fa740cf9","local":"2011b3ac-614b-5a47-8891-7ff81965f425"},{"referenced":"fc81c5f0-0442-5b88-992b-e5b10f351c59","local":"fffff882-78fd-5415-928d-c1ef7fac6576"}]}},"precondition":"","postcondition":""}}}}},"a21465af-05f3-5fb5-8983-de26932422fb":{"id":"a21465af-05f3-5fb5-8983-de26932422fb","alias":"oa_repair","name":"Repair valve in the field","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"a4b8942e-a975-5f88-ad14-8f38c345baac":{"id":"a4b8942e-a975-5f88-ad14-8f38c345baac","alias":"sa_repair_interface","name":"Valve repair service","description":"","parent":null,"body":{"Sa":{"Shared":{"Interface":{"supertype":null}}}}},"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37":{"id":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","alias":"sa_waiting","name":"Awaiting repair","description":"","parent":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","body":{"Sa":{"Shared":{"State":{"kind":"State","entry":"","exit":"","do_activity":""}}}}},"a7ffb869-9dde-5e76-b276-c719c5c143a9":{"id":"a7ffb869-9dde-5e76-b276-c719c5c143a9","alias":"oa_monitor_initial","name":"Start","description":"","parent":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","body":{"Oa":{"Shared":{"State":{"kind":"Initial","entry":"","exit":"","do_activity":""}}}}},"a962fc5e-3ab0-5296-8b0d-fd84633f9218":{"id":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","alias":"sa_repair_operation","name":"Valve repair","description":"","parent":null,"body":{"Sa":{"Shared":{"ExchangeItem":{"kind":"Information","mechanism":"Operation","supertype":null}}}}},"a9a47e48-100c-57a8-95f0-7fe24da94f94":{"id":"a9a47e48-100c-57a8-95f0-7fe24da94f94","alias":"sa_repair_traffic","name":"Repair work exchanges","description":"","parent":null,"body":{"Sa":{"Shared":{"ExchangeCategory":{"family":"Work","exchanges":["93474111-f23e-56c1-b61b-b7c2396b91bd","684eadc3-e670-5779-a60f-bbc9a5bfb062"]}}}}},"acd5a018-f559-5298-a40d-1cf6e1353e74":{"id":"acd5a018-f559-5298-a40d-1cf6e1353e74","alias":"sa_assessment_procedure","name":"Assess an alarm procedure","description":"","parent":null,"body":{"Sa":{"FunctionalChain":{"steps":["bae5b192-5b8e-51c6-82ae-2331af0d33bf"]}}}},"b280c6ca-e5f9-5424-a5c1-5676e01c2371":{"id":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","alias":"oa_decision","name":"Can a remote reset safely recover pressure?","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Control":{"kind":"Choice"}},"precondition":"","postcondition":""}}}}},"b97437c6-cd7a-5a05-b537-b30a17dd936a":{"id":"b97437c6-cd7a-5a05-b537-b30a17dd936a","alias":"oa_call","name":"Request valve repair","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"27b47887-baf0-518b-bf87-f5acbf7d0b93","sender":"41859a65-ded2-547b-ab03-14e2fa740cf9","receiver":"fc81c5f0-0442-5b88-992b-e5b10f351c59","message_kind":"Call","reply_to":null,"values":[{"path":[],"field":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","expression":"420.0"}]}},"precondition":"","postcondition":""}}}}},"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f":{"id":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f","alias":"sa_triage","name":"Assess an alarm","description":"","parent":null,"body":{"Sa":"Capability"}},"bae5b192-5b8e-51c6-82ae-2331af0d33bf":{"id":"bae5b192-5b8e-51c6-82ae-2331af0d33bf","alias":"sa_assessment_action","name":"Assess pressure alarm","description":"","parent":"acd5a018-f559-5298-a40d-1cf6e1353e74","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"dddae020-a444-5d48-acc8-139d2696a672","lifeline":null}},"precondition":"","postcondition":""}}}}},"bb12ba5d-aab5-5a1b-99de-c767f5365b57":{"id":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","alias":"sa_pressure_parameter","name":"Pressure before / after repair","description":"","parent":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":"87d0a95b-af1b-5352-b753-e4c6533671a5"},"minimum":1,"maximum":1,"unit":"","direction":"InOut","storage":"Value","default":null}}}}},"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6":{"id":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","alias":"sa_reset_step","name":"Try the authorized remote reset","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"09342ccb-044b-5483-ac58-87dc3cf7831c","lifeline":null}},"precondition":"","postcondition":""}}}}},"c05b9422-b9da-5de5-b21d-07ad23bd2b8a":{"id":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","alias":"sa_technician","name":"Field technician","description":"","parent":null,"body":{"Sa":"Actor"}},"c59432fe-6092-5ee0-908d-9dbf39476471":{"id":"c59432fe-6092-5ee0-908d-9dbf39476471","alias":"oa_repair_step","name":"Repair the valve","description":"","parent":"5d3af417-244c-54bb-9588-16daedda4e1f","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"a21465af-05f3-5fb5-8983-de26932422fb","lifeline":null}},"precondition":"","postcondition":""}}}}},"c9c61f50-06e0-5d17-973d-dcc57cd5cc90":{"id":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90","alias":"oa_mission","name":"Maintain safe water supply","description":"","parent":null,"body":{"Oa":"Mission"}},"cc2d497a-fd15-5455-9ee5-e70eb95811d8":{"id":"cc2d497a-fd15-5455-9ee5-e70eb95811d8","alias":"oa_reply","name":"Return pressure after repair","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"27b47887-baf0-518b-bf87-f5acbf7d0b93","sender":"fc81c5f0-0442-5b88-992b-e5b10f351c59","receiver":"41859a65-ded2-547b-ab03-14e2fa740cf9","message_kind":"Reply","reply_to":"b97437c6-cd7a-5a05-b537-b30a17dd936a","values":[{"path":[],"field":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","expression":"320.0"}]}},"precondition":"","postcondition":""}}}}},"d0c8be87-d238-503a-a549-7558a210507f":{"id":"d0c8be87-d238-503a-a549-7558a210507f","alias":"oa_reset","name":"Reset valve remotely","description":"","parent":"1b91f8a7-0bdb-514e-b1cd-955658e09927","body":{"Oa":"Activity"}},"d2026fc2-74a8-5e22-9942-ff3eb68a3677":{"id":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","alias":"oa_reset_step","name":"Try the authorized remote reset","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"d0c8be87-d238-503a-a549-7558a210507f","lifeline":null}},"precondition":"","postcondition":""}}}}},"d2113fb0-a531-54ef-9484-13951b00ab8f":{"id":"d2113fb0-a531-54ef-9484-13951b00ab8f","alias":"sa_repair_request_in","name":"Request field repair input","description":"","parent":"9497e10f-04c4-5586-8738-2464a6f00bd4","body":{"Sa":{"Shared":{"Port":{"contract":{"FunctionInput":{"incoming_items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}},"legacy":null}}}}},"d5e61c5d-510b-5091-a878-da46f3a9e152":{"id":"d5e61c5d-510b-5091-a878-da46f3a9e152","alias":"sa_use_field","name":"Use the field repair procedure","description":"","parent":"5139ce73-56fd-5875-ba3a-e569fc890231","body":{"Sa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"93eb6479-54e9-59cb-ac7b-16912347bfc0"}},"precondition":"","postcondition":""}}}}},"d6f881a4-95da-54e9-ad97-1b8965532629":{"id":"d6f881a4-95da-54e9-ad97-1b8965532629","alias":"oa_full_scenario","name":"Restore supply through field repair","description":"","parent":null,"body":{"Oa":{"Shared":{"Scenario":{"viewpoint":"ParticipantInteraction","context":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["9ff94014-afa1-50ab-a4d3-8e9dd0565cc5"]}}}}},"d736c7d6-d369-550a-8ba4-dae223e40bb3":{"id":"d736c7d6-d369-550a-8ba4-dae223e40bb3","alias":"oa_repair_operation","name":"Valve repair","description":"","parent":null,"body":{"Oa":{"Shared":{"ExchangeItem":{"kind":"Information","mechanism":"Operation","supertype":null}}}}},"d75eb3fa-a27d-52d5-8782-8b342b3865f6":{"id":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","alias":"sa_confirm","name":"Confirm safe pressure","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"dddae020-a444-5d48-acc8-139d2696a672":{"id":"dddae020-a444-5d48-acc8-139d2696a672","alias":"sa_assess","name":"Assess pressure alarm","description":"","parent":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","body":{"Sa":"Function"}},"df2fd3f8-6bc2-5380-a13f-36643ed35ac7":{"id":"df2fd3f8-6bc2-5380-a13f-36643ed35ac7","alias":"oa_report_step","name":"Report the measured result","description":"","parent":"5d3af417-244c-54bb-9588-16daedda4e1f","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"b436d0b3-c824-5a96-af6b-2c269816d1cc","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"e00594a3-6122-5a6b-b3c2-ecb5506c741d":{"id":"e00594a3-6122-5a6b-b3c2-ecb5506c741d","alias":"oa_confirm_step","name":"Confirm safe pressure","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"Action":{"action":"727c0b5c-8db6-5eb7-83d6-735519c5949f","lifeline":null}},"precondition":"","postcondition":""}}}}},"e07c3952-4a1a-5cc7-b7d5-03a01101ce49":{"id":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","alias":"oa_technician","name":"Field technician","description":"","parent":null,"body":{"Oa":"Actor"}},"e6d69cbd-99d7-596b-bbe3-dada6118a600":{"id":"e6d69cbd-99d7-596b-bbe3-dada6118a600","alias":"sa_technician_port","name":"Repair service provider","description":"Explicitly chosen Standard component port for the repair service.","parent":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","body":{"Sa":{"Shared":{"Port":{"contract":{"Component":{"kind":"Standard","orientation":"Unset"}},"legacy":null}}}}},"e7824764-5db9-5384-8198-2fbaea2b10b2":{"id":"e7824764-5db9-5384-8198-2fbaea2b10b2","alias":"oa_pressure","name":"Measured pressure","description":"","parent":null,"body":{"Oa":{"Shared":{"DataType":{"definition":{"Real":{"minimum":0.0,"maximum":600.0}},"default":null,"unit":"kPa"}}}}},"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac":{"id":"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac","alias":"sa_report_step","name":"Report the measured result","description":"","parent":"93eb6479-54e9-59cb-ac7b-16912347bfc0","body":{"Sa":{"Shared":{"Occurrence":{"body":{"Exchange":{"exchange":"684eadc3-e670-5779-a60f-bbc9a5bfb062","sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}}}}},"f746a1cd-ff9f-55c0-b325-01c03d2f4fae":{"id":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","alias":"sa_full_scenario","name":"Restore supply through field repair","description":"","parent":null,"body":{"Sa":{"Shared":{"Scenario":{"viewpoint":"BoundaryInterface","context":"81259051-370a-512d-a3b5-6420a6f933b6","precondition":"A pressure alarm has been assessed","postcondition":"The controller has received the measured pressure after repair","steps":["1c9da843-e2d3-5591-8e3f-db083a42867e"]}}}}},"fc6763bf-a534-54bf-afcd-57ab285ff07d":{"id":"fc6763bf-a534-54bf-afcd-57ab285ff07d","alias":"oa_use_field","name":"Use the field repair procedure","description":"","parent":"58bae2b0-f560-50f2-818d-19091d22d18a","body":{"Oa":{"Shared":{"Occurrence":{"body":{"SequenceReference":{"sequence":"5d3af417-244c-54bb-9588-16daedda4e1f"}},"precondition":"","postcondition":""}}}}},"fc81c5f0-0442-5b88-992b-e5b10f351c59":{"id":"fc81c5f0-0442-5b88-992b-e5b10f351c59","alias":"oa_responder","name":"Technician","description":"","parent":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","body":{"Oa":{"Shared":{"Lifeline":{"represented":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49"}}}}},"fffff882-78fd-5415-928d-c1ef7fac6576":{"id":"fffff882-78fd-5415-928d-c1ef7fac6576","alias":"oa_outer_technician","name":"Technician","description":"","parent":"d6f881a4-95da-54e9-ad97-1b8965532629","body":{"Oa":{"Shared":{"Lifeline":{"represented":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49"}}}}}},"relations":{"094b888c-450c-5c4f-971a-7d75db059574":{"id":"094b888c-450c-5c4f-971a-7d75db059574","alias":"oa_request_carriage","name":"Exchange allocation: Request field repair","description":"","body":{"ExchangeAllocation":{"source":"27b47887-baf0-518b-bf87-f5acbf7d0b93","target":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d"}}},"09eaa992-4cdf-5ea3-b748-f10fd74b8cba":{"id":"09eaa992-4cdf-5ea3-b748-f10fd74b8cba","alias":"sa_port_request_in","name":"Port allocation: Request field repair input","description":"","body":{"PortAllocation":{"source":"e6d69cbd-99d7-596b-bbe3-dada6118a600","target":"d2113fb0-a531-54ef-9484-13951b00ab8f"}}},"0c4c9e58-ff32-5c00-b82a-d408a4be0183":{"id":"0c4c9e58-ff32-5c00-b82a-d408a4be0183","alias":"oa_mission_participant","name":"Mission involvement: Maintain safe water supply","description":"","body":{"MissionInvolvement":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90"}}},"0f6ac3fb-4ad6-581e-ad14-336472072d5e":{"id":"0f6ac3fb-4ad6-581e-ad14-336472072d5e","alias":"sa_field_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"d5e61c5d-510b-5091-a878-da46f3a9e152","target":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","guard":""}}},"109e2d6b-7e86-5934-93a5-fab56eced84e":{"id":"109e2d6b-7e86-5934-93a5-fab56eced84e","alias":"oa_assess_then_decide","name":"Continue","description":"","body":{"SequenceLink":{"source":"0b07b9ed-492d-57f6-b0b1-ce8a709238a7","target":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","guard":""}}},"13c3bfa4-9254-5ab2-b96f-f65f402adf81":{"id":"13c3bfa4-9254-5ab2-b96f-f65f402adf81","alias":"sa_alarm_transition","name":"State transition: Awaiting repair","description":"","body":{"StateTransition":{"source":"8af21f9b-c21e-5aae-af0d-0958840f6655","target":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","transition_kind":"External","triggers":["01490fa8-2f01-5ac8-9722-d55d45414379"],"effects":[],"trigger":"","guard":"","effect":""}}},"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a":{"id":"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a","alias":"oa_reset_allocation","name":"Allocation: Reset valve remotely","description":"","body":{"Allocation":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"d0c8be87-d238-503a-a549-7558a210507f"}}},"1b245065-7242-5d81-8472-aaeb932054c9":{"id":"1b245065-7242-5d81-8472-aaeb932054c9","alias":"sa_audit_allocation","name":"Allocation: Audit pressure thresholds","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"4149a872-3440-59de-9879-8981e78d27ab"}}},"1bbd5957-1173-51d8-97e3-ba4f084227ae":{"id":"1bbd5957-1173-51d8-97e3-ba4f084227ae","alias":"sa_field_path","name":"else","description":"","body":{"SequenceLink":{"source":"30c8fad9-a49b-5692-9542-3148e76afd7b","target":"d5e61c5d-510b-5091-a878-da46f3a9e152","guard":"else"}}},"1c1e5634-ba8c-591f-b143-90a27cdc6cd2":{"id":"1c1e5634-ba8c-591f-b143-90a27cdc6cd2","alias":"sa_mission_participant","name":"Mission involvement: Maintain safe water supply","description":"","body":{"MissionInvolvement":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"5ad034b1-084d-5273-9e70-971671766132"}}},"1e738f27-a3a1-526d-b330-6384088b9689":{"id":"1e738f27-a3a1-526d-b330-6384088b9689","alias":"sa_confirm_path","name":"Continue","description":"","body":{"SequenceLink":{"source":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","target":"47bec4f9-d6d0-5ed1-adca-fa0d960dc416","guard":""}}},"1f8b1898-65f6-5360-99cf-b78a71bd50ec":{"id":"1f8b1898-65f6-5360-99cf-b78a71bd50ec","alias":"oa_mission_delivery","name":"Mission capability: Restore a safe supply","description":"","body":{"MissionCapability":{"source":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90","target":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb"}}},"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97":{"id":"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97","alias":"oa_normal_transition","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"58c15833-6e75-523a-b57f-a88f19f6d672","target":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"repair confirmed and pressure is safe","effect":""}}},"229ee94f-d147-50f6-9769-b5bd0c03a084":{"id":"229ee94f-d147-50f6-9769-b5bd0c03a084","alias":"oa_confirm_path","name":"Continue","description":"","body":{"SequenceLink":{"source":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","target":"e00594a3-6122-5a6b-b3c2-ecb5506c741d","guard":""}}},"27b47887-baf0-518b-bf87-f5acbf7d0b93":{"id":"27b47887-baf0-518b-bf87-f5acbf7d0b93","alias":"oa_contact","name":"Technician service conversation","description":"","body":{"CommunicationMean":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","oriented":false,"items":["d736c7d6-d369-550a-8ba4-dae223e40bb3"]}}},"2b00270c-79a5-52a5-af96-33346bea531b":{"id":"2b00270c-79a5-52a5-af96-33346bea531b","alias":"oa_start_monitoring","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"a7ffb869-9dde-5e76-b276-c719c5c143a9","target":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"","effect":""}}},"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3":{"id":"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3","alias":"sa_reset_allocation","name":"Allocation: Reset valve remotely","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"09342ccb-044b-5483-ac58-87dc3cf7831c"}}},"3f54d890-4ca5-5e36-873b-abb5c92d578c":{"id":"3f54d890-4ca5-5e36-873b-abb5c92d578c","alias":"oa_request_then_repair","name":"Sequence link: Repair the valve","description":"","body":{"SequenceLink":{"source":"5ba8079c-2dbe-5e98-ae82-2b60c6020a02","target":"c59432fe-6092-5ee0-908d-9dbf39476471","guard":""}}},"4549d3fa-5ab9-52a9-b4f9-3a78fc9d625a":{"id":"4549d3fa-5ab9-52a9-b4f9-3a78fc9d625a","alias":"oa_report_carriage","name":"Exchange allocation: Report repair result","description":"","body":{"ExchangeAllocation":{"source":"27b47887-baf0-518b-bf87-f5acbf7d0b93","target":"b436d0b3-c824-5a96-af6b-2c269816d1cc"}}},"47730532-0372-5cc5-9d6d-0b8953aebdd8":{"id":"47730532-0372-5cc5-9d6d-0b8953aebdd8","alias":"sa_start_monitoring","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"57eb5348-14d8-560e-996a-8dbf993243e2","target":"8af21f9b-c21e-5aae-af0d-0958840f6655","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"","effect":""}}},"4c4c4851-5a3d-5bb9-94df-b19f9cf39819":{"id":"4c4c4851-5a3d-5bb9-94df-b19f9cf39819","alias":"sa_request_carriage","name":"Exchange allocation: Request field repair","description":"","body":{"ExchangeAllocation":{"source":"63009b5f-539e-5914-b630-ec6b7fefb4d5","target":"93474111-f23e-56c1-b61b-b7c2396b91bd"}}},"4f85337b-f6eb-533c-a45a-9a5d1d9567ca":{"id":"4f85337b-f6eb-533c-a45a-9a5d1d9567ca","alias":"sa_client_requires","name":"Interface requirement: Valve repair service","description":"","body":{"InterfaceRequirement":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"a4b8942e-a975-5f88-ad14-8f38c345baac"}}},"55ec4dbf-bd17-5b0c-b3a3-95b2194aa8b9":{"id":"55ec4dbf-bd17-5b0c-b3a3-95b2194aa8b9","alias":"sa_port_request_out","name":"Port allocation: Request field repair output","description":"","body":{"PortAllocation":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"24a6850e-b819-57e0-af88-ca4ab76581b7"}}},"55f89fbf-aa3c-5ae1-95ef-ac676f9c1ada":{"id":"55f89fbf-aa3c-5ae1-95ef-ac676f9c1ada","alias":"sa_provider_provides","name":"Interface provision: Valve repair service","description":"","body":{"InterfaceProvision":{"source":"e6d69cbd-99d7-596b-bbe3-dada6118a600","target":"a4b8942e-a975-5f88-ad14-8f38c345baac"}}},"568c5c01-de21-52a2-83bd-afdddc158085":{"id":"568c5c01-de21-52a2-83bd-afdddc158085","alias":"sa_port_report_in","name":"Port allocation: Report repair result input","description":"","body":{"PortAllocation":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"391af7c4-b07d-53a7-8ddd-5c81461fc4f8"}}},"594c61cd-94e0-5c07-890d-edfd520d7df9":{"id":"594c61cd-94e0-5c07-890d-edfd520d7df9","alias":"oa_assess_allocation","name":"Allocation: Assess pressure alarm","description":"","body":{"Allocation":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"61f7bbfb-74a4-5540-9182-2583dfb27a49"}}},"628cb4fc-fe63-5f6b-b364-6268f26b5740":{"id":"628cb4fc-fe63-5f6b-b364-6268f26b5740","alias":"oa_assessment_delivery","name":"Chain involvement: Assess an alarm","description":"","body":{"ChainInvolvement":{"source":"022d3cfa-bbaf-5e13-924a-8e55099b8745","target":"6a30b763-cc51-546d-907f-5c63657cfd03"}}},"63009b5f-539e-5914-b630-ec6b7fefb4d5":{"id":"63009b5f-539e-5914-b630-ec6b7fefb4d5","alias":"sa_contact","name":"Technician service conversation","description":"","body":{"BoundaryExchange":{"source":"98d411e7-7c65-5c3f-b543-8bf0f2522ea2","target":"e6d69cbd-99d7-596b-bbe3-dada6118a600","oriented":false,"items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}}},"66efc484-53c7-51a3-89e9-422429344b38":{"id":"66efc484-53c7-51a3-89e9-422429344b38","alias":"sa_response_delivery","name":"Chain involvement: Restore a safe supply","description":"","body":{"ChainInvolvement":{"source":"5139ce73-56fd-5875-ba3a-e569fc890231","target":"81259051-370a-512d-a3b5-6420a6f933b6"}}},"684eadc3-e670-5779-a60f-bbc9a5bfb062":{"id":"684eadc3-e670-5779-a60f-bbc9a5bfb062","alias":"sa_repair_report","name":"Report repair result","description":"","body":{"FunctionalExchange":{"source":"9497e10f-04c4-5586-8738-2464a6f00bd4","target":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","source_port":"994faed7-dbc8-5b96-9d62-7604ac0fbc3a","target_port":"391af7c4-b07d-53a7-8ddd-5c81461fc4f8","items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}}},"6a6d968f-26d6-5dbb-b721-5a27d362779a":{"id":"6a6d968f-26d6-5dbb-b721-5a27d362779a","alias":"sa_request_then_repair","name":"Sequence link: Repair the valve","description":"","body":{"SequenceLink":{"source":"40d94322-baa2-5c81-b261-ff47cdc33782","target":"0f195fea-3493-5ebd-966d-da9eddeb05d7","guard":""}}},"6e2c1b47-764c-577a-b325-ff66dd2a2768":{"id":"6e2c1b47-764c-577a-b325-ff66dd2a2768","alias":"sa_normal_transition","name":"State transition: Monitoring","description":"","body":{"StateTransition":{"source":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","target":"8af21f9b-c21e-5aae-af0d-0958840f6655","transition_kind":"External","triggers":[],"effects":[],"trigger":"","guard":"repair confirmed and pressure is safe","effect":""}}},"725f2e1b-aa2c-5615-9b1f-28da178168fa":{"id":"725f2e1b-aa2c-5615-9b1f-28da178168fa","alias":"sa_repair_allocation","name":"Allocation: Repair valve in the field","description":"","body":{"Allocation":{"source":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","target":"9497e10f-04c4-5586-8738-2464a6f00bd4"}}},"76218d89-68fd-531d-b35b-90c5b769a5ef":{"id":"76218d89-68fd-531d-b35b-90c5b769a5ef","alias":"oa_remote_path","name":"remote reset is authorized","description":"","body":{"SequenceLink":{"source":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","target":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","guard":"remote reset is authorized"}}},"7fa37e63-db65-5aa7-88f2-31ff4347a787":{"id":"7fa37e63-db65-5aa7-88f2-31ff4347a787","alias":"oa_assess_on_entry","name":"Assess when repair is needed","description":"","body":{"StateAction":{"source":"58c15833-6e75-523a-b57f-a88f19f6d672","target":"61f7bbfb-74a4-5540-9182-2583dfb27a49","phase":"Entry"}}},"7fb30b1d-195c-5f0e-a19c-a536f519a899":{"id":"7fb30b1d-195c-5f0e-a19c-a536f519a899","alias":"sa_repair_then_report","name":"Sequence link: Report the measured result","description":"","body":{"SequenceLink":{"source":"0f195fea-3493-5ebd-966d-da9eddeb05d7","target":"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac","guard":""}}},"93474111-f23e-56c1-b61b-b7c2396b91bd":{"id":"93474111-f23e-56c1-b61b-b7c2396b91bd","alias":"sa_repair_request","name":"Request field repair","description":"","body":{"FunctionalExchange":{"source":"dddae020-a444-5d48-acc8-139d2696a672","target":"9497e10f-04c4-5586-8738-2464a6f00bd4","source_port":"24a6850e-b819-57e0-af88-ca4ab76581b7","target_port":"d2113fb0-a531-54ef-9484-13951b00ab8f","items":["a962fc5e-3ab0-5296-8b0d-fd84633f9218"]}}},"93eaad8d-83b7-5980-b9bb-86c9e3f31da4":{"id":"93eaad8d-83b7-5980-b9bb-86c9e3f31da4","alias":"sa_assessment_support","name":"Capability involvement: Assess an alarm","description":"","body":{"CapabilityInvolvement":{"source":"dddae020-a444-5d48-acc8-139d2696a672","target":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f"}}},"9b9356d7-2db2-5a94-a47e-ea892cf02afe":{"id":"9b9356d7-2db2-5a94-a47e-ea892cf02afe","alias":"sa_assess_allocation","name":"Allocation: Assess pressure alarm","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"dddae020-a444-5d48-acc8-139d2696a672"}}},"9d4a131f-36ad-5125-b21f-c3dd033b8c82":{"id":"9d4a131f-36ad-5125-b21f-c3dd033b8c82","alias":"sa_assess_then_decide","name":"Continue","description":"","body":{"SequenceLink":{"source":"9024243c-ccc1-5edb-8477-2aa16e5e9a50","target":"30c8fad9-a49b-5692-9542-3148e76afd7b","guard":""}}},"a0902e0f-3d18-576a-9aca-975a136dfdd7":{"id":"a0902e0f-3d18-576a-9aca-975a136dfdd7","alias":"sa_mission_delivery","name":"Mission capability: Restore a safe supply","description":"","body":{"MissionCapability":{"source":"5ad034b1-084d-5273-9e70-971671766132","target":"81259051-370a-512d-a3b5-6420a6f933b6"}}},"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57":{"id":"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57","alias":"sa_assessment_delivery","name":"Chain involvement: Assess an alarm","description":"","body":{"ChainInvolvement":{"source":"acd5a018-f559-5298-a40d-1cf6e1353e74","target":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f"}}},"a887f96a-37cb-5891-8070-011fa276e5b5":{"id":"a887f96a-37cb-5891-8070-011fa276e5b5","alias":"sa_assess_on_entry","name":"Assess when repair is needed","description":"","body":{"StateAction":{"source":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","target":"dddae020-a444-5d48-acc8-139d2696a672","phase":"Entry"}}},"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2":{"id":"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2","alias":"oa_repair_allocation","name":"Allocation: Repair valve in the field","description":"","body":{"Allocation":{"source":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","target":"a21465af-05f3-5fb5-8983-de26932422fb"}}},"b436d0b3-c824-5a96-af6b-2c269816d1cc":{"id":"b436d0b3-c824-5a96-af6b-2c269816d1cc","alias":"oa_repair_report","name":"Report repair result","description":"","body":{"Interaction":{"source":"a21465af-05f3-5fb5-8983-de26932422fb","target":"727c0b5c-8db6-5eb7-83d6-735519c5949f","items":["d736c7d6-d369-550a-8ba4-dae223e40bb3"]}}},"b76bf8de-2fa4-563f-a540-dd30f30e6c49":{"id":"b76bf8de-2fa4-563f-a540-dd30f30e6c49","alias":"sa_confirm_allocation","name":"Allocation: Confirm safe pressure","description":"","body":{"Allocation":{"source":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","target":"d75eb3fa-a27d-52d5-8782-8b342b3865f6"}}},"bac43d3b-910b-5bdc-be9b-f78cf973e1ed":{"id":"bac43d3b-910b-5bdc-be9b-f78cf973e1ed","alias":"oa_remote_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","target":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","guard":""}}},"bfd4326e-ea65-5269-b5e6-e063a355d1e9":{"id":"bfd4326e-ea65-5269-b5e6-e063a355d1e9","alias":"oa_confirm_allocation","name":"Allocation: Confirm safe pressure","description":"","body":{"Allocation":{"source":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","target":"727c0b5c-8db6-5eb7-83d6-735519c5949f"}}},"c1adbbd0-a762-5ee3-8b9a-5c18af377771":{"id":"c1adbbd0-a762-5ee3-8b9a-5c18af377771","alias":"oa_field_path","name":"else","description":"","body":{"SequenceLink":{"source":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","target":"fc6763bf-a534-54bf-afcd-57ab285ff07d","guard":"else"}}},"c7c248f8-28b8-55be-9023-0991d44021e3":{"id":"c7c248f8-28b8-55be-9023-0991d44021e3","alias":"oa_response_delivery","name":"Chain involvement: Restore a safe supply","description":"","body":{"ChainInvolvement":{"source":"58bae2b0-f560-50f2-818d-19091d22d18a","target":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb"}}},"c8196d35-2168-5fef-8b03-ba0d139bc00f":{"id":"c8196d35-2168-5fef-8b03-ba0d139bc00f","alias":"oa_repair_then_report","name":"Sequence link: Report the measured result","description":"","body":{"SequenceLink":{"source":"c59432fe-6092-5ee0-908d-9dbf39476471","target":"df2fd3f8-6bc2-5380-a13f-36643ed35ac7","guard":""}}},"c8593670-8906-530f-b6a2-3ada77a9c219":{"id":"c8593670-8906-530f-b6a2-3ada77a9c219","alias":"oa_field_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"fc6763bf-a534-54bf-afcd-57ab285ff07d","target":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","guard":""}}},"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d":{"id":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","alias":"oa_repair_request","name":"Request field repair","description":"","body":{"Interaction":{"source":"61f7bbfb-74a4-5540-9182-2583dfb27a49","target":"a21465af-05f3-5fb5-8983-de26932422fb","items":["d736c7d6-d369-550a-8ba4-dae223e40bb3"]}}},"d8b6710a-e713-5979-934f-e5ca7d155bb4":{"id":"d8b6710a-e713-5979-934f-e5ca7d155bb4","alias":"sa_remote_path","name":"remote reset is authorized","description":"","body":{"SequenceLink":{"source":"30c8fad9-a49b-5692-9542-3148e76afd7b","target":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","guard":"remote reset is authorized"}}},"e21d9a54-ae8e-5618-a4f8-67ee22b92638":{"id":"e21d9a54-ae8e-5618-a4f8-67ee22b92638","alias":"oa_include_triage","name":"Capability include: Assess an alarm","description":"","body":{"CapabilityInclude":{"source":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","target":"6a30b763-cc51-546d-907f-5c63657cfd03"}}},"e5279265-8eac-52e8-98dc-571b20475e1f":{"id":"e5279265-8eac-52e8-98dc-571b20475e1f","alias":"sa_remote_merge","name":"Continue","description":"","body":{"SequenceLink":{"source":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","target":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","guard":""}}},"e53b7a40-e8a3-5b3f-826d-c2b5c164a84a":{"id":"e53b7a40-e8a3-5b3f-826d-c2b5c164a84a","alias":"sa_port_report_out","name":"Port allocation: Report repair result output","description":"","body":{"PortAllocation":{"source":"e6d69cbd-99d7-596b-bbe3-dada6118a600","target":"994faed7-dbc8-5b96-9d62-7604ac0fbc3a"}}},"f074e041-23cf-54ad-af9c-f171e30621e7":{"id":"f074e041-23cf-54ad-af9c-f171e30621e7","alias":"sa_repair_interface_item","name":"Repair operation contract","description":"","body":{"InterfaceItemAllocation":{"source":"a4b8942e-a975-5f88-ad14-8f38c345baac","target":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","order":0,"transmission":"Synchronous","acquisition":"Accept"}}},"f3fe7690-734a-5877-8b57-a6599a4033c4":{"id":"f3fe7690-734a-5877-8b57-a6599a4033c4","alias":"sa_include_triage","name":"Capability include: Assess an alarm","description":"","body":{"CapabilityInclude":{"source":"81259051-370a-512d-a3b5-6420a6f933b6","target":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f"}}},"fbde9d5e-c7e9-55db-a2be-497345c491b3":{"id":"fbde9d5e-c7e9-55db-a2be-497345c491b3","alias":"oa_alarm_transition","name":"State transition: Awaiting repair","description":"","body":{"StateTransition":{"source":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","target":"58c15833-6e75-523a-b57f-a88f19f6d672","transition_kind":"External","triggers":["65d6b8d6-271a-5c84-8b42-abbe310c955e"],"effects":[],"trigger":"","guard":"","effect":""}}},"ff9897b3-a23a-5994-a5b3-99a5b4d34ceb":{"id":"ff9897b3-a23a-5994-a5b3-99a5b4d34ceb","alias":"sa_report_carriage","name":"Exchange allocation: Report repair result","description":"","body":{"ExchangeAllocation":{"source":"63009b5f-539e-5914-b630-ec6b7fefb4d5","target":"684eadc3-e670-5779-a60f-bbc9a5bfb062"}}},"ffdbfcd0-d423-5707-a880-0dfbe1400f72":{"id":"ffdbfcd0-d423-5707-a880-0dfbe1400f72","alias":"oa_assessment_support","name":"Capability involvement: Assess an alarm","description":"","body":{"CapabilityInvolvement":{"source":"61f7bbfb-74a4-5540-9182-2583dfb27a49","target":"6a30b763-cc51-546d-907f-5c63657cfd03"}}}},"diagrams":{"103ea62c-ac56-5827-94e7-cf09bca96ccc":{"id":"103ea62c-ac56-5827-94e7-cf09bca96ccc","alias":"oa_allocation_view","name":"1 · OA responsibility and repair traffic","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Oa","projection":"Allocation","view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"},"nodes":{"202ebe37-ddb4-5099-8ce9-ec3e895ab9e9":{"id":"202ebe37-ddb4-5099-8ce9-ec3e895ab9e9","alias":"oa_allocation_view_oa_repair","element":"a21465af-05f3-5fb5-8983-de26932422fb","position":{"x":890.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"3191e05c-1c7f-5f0e-beb0-5f3bded54c48":{"id":"3191e05c-1c7f-5f0e-beb0-5f3bded54c48","alias":"oa_allocation_view_oa_control","element":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","position":{"x":60.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"5ae1c46d-59a3-50fd-8cc3-b292df21f951":{"id":"5ae1c46d-59a3-50fd-8cc3-b292df21f951","alias":"oa_allocation_view_oa_assess","element":"61f7bbfb-74a4-5540-9182-2583dfb27a49","position":{"x":100.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"7066f78c-9736-5349-b94a-0587331b980e":{"id":"7066f78c-9736-5349-b94a-0587331b980e","alias":"oa_allocation_view_oa_reset","element":"d0c8be87-d238-503a-a549-7558a210507f","position":{"x":100.0,"y":330.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"d27f28ef-f10a-5355-9a05-6f2fd39b7de6":{"id":"d27f28ef-f10a-5355-9a05-6f2fd39b7de6","alias":"oa_allocation_view_oa_technician","element":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","position":{"x":850.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"edd74060-ac61-5450-a57c-a6577daa5d50":{"id":"edd74060-ac61-5450-a57c-a6577daa5d50","alias":"oa_allocation_view_oa_confirm","element":"727c0b5c-8db6-5eb7-83d6-735519c5949f","position":{"x":100.0,"y":490.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"30d5baf9-6581-5cb6-846a-bcaa1e6cce63":{"id":"30d5baf9-6581-5cb6-846a-bcaa1e6cce63","alias":"sa_state_view","name":"5 · SA pressure supervision","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":{"StateMachine":{"machine":"132b3f28-3f88-5327-9868-8c8b645ed412"}},"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"},"nodes":{"55a74f91-2025-5001-8db8-9566b1dbe783":{"id":"55a74f91-2025-5001-8db8-9566b1dbe783","alias":"sa_state_view_sa_monitor_region","element":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","position":{"x":70.0,"y":95.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"57acbb71-ca86-57fc-ae90-42dad195f0e0":{"id":"57acbb71-ca86-57fc-ae90-42dad195f0e0","alias":"sa_state_view_sa_waiting","element":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","position":{"x":650.0,"y":200.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"5e2326a0-0f6c-5f8d-8a21-fdded18a9281":{"id":"5e2326a0-0f6c-5f8d-8a21-fdded18a9281","alias":"sa_state_view_sa_monitor_initial","element":"57eb5348-14d8-560e-996a-8dbf993243e2","position":{"x":100.0,"y":200.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"a676fbb9-0833-59a8-98cf-a10d8f4b7434":{"id":"a676fbb9-0833-59a8-98cf-a10d8f4b7434","alias":"sa_state_view_sa_monitor","element":"132b3f28-3f88-5327-9868-8c8b645ed412","position":{"x":30.0,"y":30.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"b3af7b04-5a45-5b82-ab69-7eada4b876f0":{"id":"b3af7b04-5a45-5b82-ab69-7eada4b876f0","alias":"sa_state_view_sa_monitoring","element":"8af21f9b-c21e-5aae-af0d-0958840f6655","position":{"x":220.0,"y":200.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"35d3c24b-289e-59e5-a610-8333e2c6a4bb":{"id":"35d3c24b-289e-59e5-a610-8333e2c6a4bb","alias":"sa_allocation_view","name":"2 · SA service boundary and functions","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":"Allocation","view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"},"nodes":{"34630a18-41b0-5fdd-bff4-ee86e93e8651":{"id":"34630a18-41b0-5fdd-bff4-ee86e93e8651","alias":"sa_allocation_view_sa_repair","element":"9497e10f-04c4-5586-8738-2464a6f00bd4","position":{"x":890.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"60f6ad74-b69e-5aef-b832-f1bd60b903ae":{"id":"60f6ad74-b69e-5aef-b832-f1bd60b903ae","alias":"sa_allocation_view_sa_assess","element":"dddae020-a444-5d48-acc8-139d2696a672","position":{"x":100.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"73406244-ebd7-5995-86c7-e17c73c50b32":{"id":"73406244-ebd7-5995-86c7-e17c73c50b32","alias":"sa_allocation_view_sa_reset","element":"09342ccb-044b-5483-ac58-87dc3cf7831c","position":{"x":100.0,"y":330.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"7e3ba36f-bc68-5520-bdb9-77083bb7bba2":{"id":"7e3ba36f-bc68-5520-bdb9-77083bb7bba2","alias":"sa_allocation_view_sa_confirm","element":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","position":{"x":100.0,"y":490.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"8345c76a-8d85-5c31-902e-ec1cd33ed4a3":{"id":"8345c76a-8d85-5c31-902e-ec1cd33ed4a3","alias":"sa_allocation_view_sa_control","element":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","position":{"x":60.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"f7225d1c-e506-505d-bb20-916950269d4b":{"id":"f7225d1c-e506-505d-bb20-916950269d4b","alias":"sa_allocation_view_sa_technician","element":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","position":{"x":850.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"3e6ad194-61b5-584c-b0f0-a78a20af5781":{"id":"3e6ad194-61b5-584c-b0f0-a78a20af5781","alias":"sa_capability_view","name":"4 · SA mission and included capability","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":"Capabilities","view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"},"nodes":{"069a3fb3-077c-59d8-91da-27b0665143c7":{"id":"069a3fb3-077c-59d8-91da-27b0665143c7","alias":"sa_capability_view_sa_triage","element":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f","position":{"x":800.0,"y":260.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"41ddb68b-bd2b-5f91-82eb-efde01157828":{"id":"41ddb68b-bd2b-5f91-82eb-efde01157828","alias":"sa_capability_view_sa_control","element":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","position":{"x":60.0,"y":60.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"4b814335-bf98-5b68-85d5-ea549766517a":{"id":"4b814335-bf98-5b68-85d5-ea549766517a","alias":"sa_capability_view_sa_mission","element":"5ad034b1-084d-5273-9e70-971671766132","position":{"x":420.0,"y":60.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"d3bc13f9-7726-5280-83fe-63f391c45afb":{"id":"d3bc13f9-7726-5280-83fe-63f391c45afb","alias":"sa_capability_view_sa_restore","element":"81259051-370a-512d-a3b5-6420a6f933b6","position":{"x":420.0,"y":260.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]},"50af4c9b-93ce-5ff5-a1f8-7a81eb97e779":{"id":"50af4c9b-93ce-5ff5-a1f8-7a81eb97e779","alias":"sa_service_view","name":"3 · SA service conversation only","description":"Teaching view: positions and filters do not change ownership or exchange identities.","layer":"Sa","projection":"Allocation","view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":{"Any":[]},"participant_categories":{"Any":["781cbe75-410f-5cc5-aeb8-a43491f77b99"]},"coverage":"All","work_contexts":"All","exchange_labels":"NameAndItems"},"nodes":{"40210589-69f7-5209-b329-10bb83cf615c":{"id":"40210589-69f7-5209-b329-10bb83cf615c","alias":"sa_service_view_sa_technician","element":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","position":{"x":850.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"439f1e45-e5ed-567c-978b-4c93ef267db1":{"id":"439f1e45-e5ed-567c-978b-4c93ef267db1","alias":"sa_service_view_sa_assess","element":"dddae020-a444-5d48-acc8-139d2696a672","position":{"x":100.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"6f138672-149b-5f88-ab07-7fc413a42262":{"id":"6f138672-149b-5f88-ab07-7fc413a42262","alias":"sa_service_view_sa_confirm","element":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","position":{"x":100.0,"y":490.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"ae36489d-6ec8-5f1c-b603-8b497227efba":{"id":"ae36489d-6ec8-5f1c-b603-8b497227efba","alias":"sa_service_view_sa_repair","element":"9497e10f-04c4-5586-8738-2464a6f00bd4","position":{"x":890.0,"y":170.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"d52da4e6-40b7-5dbc-84fb-80c443711c69":{"id":"d52da4e6-40b7-5dbc-84fb-80c443711c69","alias":"sa_service_view_sa_reset","element":"09342ccb-044b-5483-ac58-87dc3cf7831c","position":{"x":100.0,"y":330.0},"size":{"width":245.0,"height":110.0},"collapsed":false},"f53d4352-b042-56a6-8b3e-c51cd27254f9":{"id":"f53d4352-b042-56a6-8b3e-c51cd27254f9","alias":"sa_service_view_sa_control","element":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","position":{"x":60.0,"y":80.0},"size":{"width":245.0,"height":110.0},"collapsed":false}},"routes":[]}},"artifact_links":{"f5922bf7-05d8-590f-86e9-1d09f37f4fa6":{"id":"f5922bf7-05d8-590f-86e9-1d09f37f4fa6","alias":"repair_test_evidence","name":"Repair conversation acceptance notes","description":"A local teaching artifact, not proof of a real valve's safety.","owner":"730b27ca-b039-5a3b-8dc6-03418f9061b3","relation":"Verifies","uri":"evidence/repair-check.md","revision":"lesson-1","fragment":"acceptance"}},"traces":{"0754be23-e4b8-53bc-b03c-89c2dbd80361":{"id":"0754be23-e4b8-53bc-b03c-89c2dbd80361","alias":"trace_oa_waiting","name":"Awaiting repair ↔ Awaiting repair","description":"","oa":"58c15833-6e75-523a-b57f-a88f19f6d672","sa":"a6bb10b1-f1e5-5b31-8afd-5bad5a7b6c37","kind":"State","baseline":{"oa":{"name":"Awaiting repair","description":""},"sa":{"name":"Awaiting repair","description":""}}},"07a7b82a-b5a7-5737-abfa-09e422f7420e":{"id":"07a7b82a-b5a7-5737-abfa-09e422f7420e","alias":"trace_oa_start_monitoring","name":"State transition: Monitoring ↔ State transition: Monitoring","description":"","oa":"2b00270c-79a5-52a5-af96-33346bea531b","sa":"47730532-0372-5cc5-9d6d-0b8953aebdd8","kind":"Manual","baseline":{"oa":{"name":"State transition: Monitoring","description":""},"sa":{"name":"State transition: Monitoring","description":""}}},"0c7c8a06-163e-5b3e-8a98-e66e2ce8963b":{"id":"0c7c8a06-163e-5b3e-8a98-e66e2ce8963b","alias":"trace_oa_alarm_transition","name":"State transition: Awaiting repair ↔ State transition: Awaiting repair","description":"","oa":"fbde9d5e-c7e9-55db-a2be-497345c491b3","sa":"13c3bfa4-9254-5ab2-b96f-f65f402adf81","kind":"Manual","baseline":{"oa":{"name":"State transition: Awaiting repair","description":""},"sa":{"name":"State transition: Awaiting repair","description":""}}},"0c9de959-d366-5765-98eb-450ea27bd316":{"id":"0c9de959-d366-5765-98eb-450ea27bd316","alias":"trace_oa_merge","name":"Recovery path completed ↔ Recovery path completed","description":"","oa":"6b945a6d-6aa0-5dd3-96e6-2e8d757d167d","sa":"4ed0dd9d-c9e0-5652-aedf-5f0ab4328a07","kind":"Sequence","baseline":{"oa":{"name":"Recovery path completed","description":""},"sa":{"name":"Recovery path completed","description":""}}},"0d905fbb-5b3c-56f0-9f11-a27091901eb0":{"id":"0d905fbb-5b3c-56f0-9f11-a27091901eb0","alias":"trace_oa_pressure_parameter","name":"Pressure before / after repair ↔ Pressure before / after repair","description":"","oa":"8bc6d0a4-3e87-559b-8bd7-1b679a2db5b7","sa":"bb12ba5d-aab5-5a1b-99de-c767f5365b57","kind":"Item","baseline":{"oa":{"name":"Pressure before / after repair","description":""},"sa":{"name":"Pressure before / after repair","description":""}}},"0fbef164-b5d3-58ba-ba41-54d0e63709ab":{"id":"0fbef164-b5d3-58ba-ba41-54d0e63709ab","alias":"trace_oa_restore","name":"Restore a safe supply ↔ Restore a safe supply","description":"","oa":"3dfee23a-148b-5cec-ad2d-b774e84ea5bb","sa":"81259051-370a-512d-a3b5-6420a6f933b6","kind":"Capability","baseline":{"oa":{"name":"Restore a safe supply","description":""},"sa":{"name":"Restore a safe supply","description":""}}},"12ec320f-2e14-5a5a-8c6b-bd2b6471dae9":{"id":"12ec320f-2e14-5a5a-8c6b-bd2b6471dae9","alias":"trace_oa_monitoring","name":"Monitoring ↔ Monitoring","description":"","oa":"6cbde0d8-9f5f-510a-a8c1-e29cb99ad4ae","sa":"8af21f9b-c21e-5aae-af0d-0958840f6655","kind":"State","baseline":{"oa":{"name":"Monitoring","description":""},"sa":{"name":"Monitoring","description":""}}},"154abfce-c311-52d1-85d1-3f4c85203b26":{"id":"154abfce-c311-52d1-85d1-3f4c85203b26","alias":"trace_oa_confirm_step","name":"Confirm safe pressure ↔ Confirm safe pressure","description":"","oa":"e00594a3-6122-5a6b-b3c2-ecb5506c741d","sa":"47bec4f9-d6d0-5ed1-adca-fa0d960dc416","kind":"Sequence","baseline":{"oa":{"name":"Confirm safe pressure","description":""},"sa":{"name":"Confirm safe pressure","description":""}}},"1b788e74-36b2-5c17-a936-642dbe51ca3c":{"id":"1b788e74-36b2-5c17-a936-642dbe51ca3c","alias":"trace_oa_remote_merge","name":"Continue ↔ Continue","description":"","oa":"bac43d3b-910b-5bdc-be9b-f78cf973e1ed","sa":"e5279265-8eac-52e8-98dc-571b20475e1f","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"208753e0-9151-50d8-86ca-29fc28eca80b":{"id":"208753e0-9151-50d8-86ca-29fc28eca80b","alias":"trace_oa_monitor_region","name":"Supervision cycle ↔ Supervision cycle","description":"","oa":"8412e9c6-96fe-5ed6-a903-1d37ec3c3bf7","sa":"3ab11e24-4140-58fc-b3fa-da5f51c7fe2c","kind":"State","baseline":{"oa":{"name":"Supervision cycle","description":""},"sa":{"name":"Supervision cycle","description":""}}},"241dfdac-2450-5704-a4cb-9f1047db1b6d":{"id":"241dfdac-2450-5704-a4cb-9f1047db1b6d","alias":"trace_oa_caller","name":"Controller ↔ Controller","description":"","oa":"41859a65-ded2-547b-ab03-14e2fa740cf9","sa":"98c61788-2fee-51f4-8cf9-de48e8c70e9e","kind":"Scenario","baseline":{"oa":{"name":"Controller","description":""},"sa":{"name":"Controller","description":""}}},"2e849e31-35aa-5aef-a989-26948038e52b":{"id":"2e849e31-35aa-5aef-a989-26948038e52b","alias":"trace_oa_field_path","name":"else ↔ else","description":"","oa":"c1adbbd0-a762-5ee3-8b9a-5c18af377771","sa":"1bbd5957-1173-51d8-97e3-ba4f084227ae","kind":"Manual","baseline":{"oa":{"name":"else","description":""},"sa":{"name":"else","description":""}}},"2fdcbc6f-526e-5a4e-bde5-314cf0cf3ef3":{"id":"2fdcbc6f-526e-5a4e-bde5-314cf0cf3ef3","alias":"trace_oa_confirm_allocation","name":"Allocation: Confirm safe pressure ↔ Allocation: Confirm safe pressure","description":"","oa":"bfd4326e-ea65-5269-b5e6-e063a355d1e9","sa":"b76bf8de-2fa4-563f-a540-dd30f30e6c49","kind":"Manual","baseline":{"oa":{"name":"Allocation: Confirm safe pressure","description":""},"sa":{"name":"Allocation: Confirm safe pressure","description":""}}},"3694c752-ba80-5702-a7d7-9cc66b6b01d8":{"id":"3694c752-ba80-5702-a7d7-9cc66b6b01d8","alias":"trace_oa_conversation","name":"Repair request and measured reply ↔ Repair request and measured reply","description":"","oa":"20472233-5ce6-5ffe-b4ad-0bdbccf3e423","sa":"730b27ca-b039-5a3b-8dc6-03418f9061b3","kind":"Scenario","baseline":{"oa":{"name":"Repair request and measured reply","description":""},"sa":{"name":"Repair request and measured reply","description":""}}},"3714d547-006f-5c3a-97f8-175ccc23d994":{"id":"3714d547-006f-5c3a-97f8-175ccc23d994","alias":"trace_oa_repair_allocation","name":"Allocation: Repair valve in the field ↔ Allocation: Repair valve in the field","description":"","oa":"ae53f363-0dec-5a7c-9afd-8fcd1b778ab2","sa":"725f2e1b-aa2c-5615-9b1f-28da178168fa","kind":"Manual","baseline":{"oa":{"name":"Allocation: Repair valve in the field","description":""},"sa":{"name":"Allocation: Repair valve in the field","description":""}}},"3a33de40-20a3-5252-899c-c12dc35e9783":{"id":"3a33de40-20a3-5252-899c-c12dc35e9783","alias":"trace_oa_assessment_action","name":"Assess pressure alarm ↔ Assess pressure alarm","description":"","oa":"942975b8-0bef-5ae8-9fcb-c37497833fb0","sa":"bae5b192-5b8e-51c6-82ae-2331af0d33bf","kind":"Sequence","baseline":{"oa":{"name":"Assess pressure alarm","description":""},"sa":{"name":"Assess pressure alarm","description":""}}},"3e7bde54-f9c1-549f-9e5b-2ce1ecb685a7":{"id":"3e7bde54-f9c1-549f-9e5b-2ce1ecb685a7","alias":"trace_oa_repair_traffic","name":"Repair work exchanges ↔ Repair work exchanges","description":"","oa":"9349b353-53f2-56f8-b571-a05b09335cdf","sa":"a9a47e48-100c-57a8-95f0-7fe24da94f94","kind":"Manual","baseline":{"oa":{"name":"Repair work exchanges","description":""},"sa":{"name":"Repair work exchanges","description":""}}},"424eca39-1c29-5a2f-b9ee-33dd018926aa":{"id":"424eca39-1c29-5a2f-b9ee-33dd018926aa","alias":"trace_oa_repair_report","name":"Report repair result ↔ Report repair result","description":"","oa":"b436d0b3-c824-5a96-af6b-2c269816d1cc","sa":"684eadc3-e670-5779-a60f-bbc9a5bfb062","kind":"Exchange","baseline":{"oa":{"name":"Report repair result","description":""},"sa":{"name":"Report repair result","description":""}}},"435bae4c-cf6d-59f3-9eb5-d660ebeec079":{"id":"435bae4c-cf6d-59f3-9eb5-d660ebeec079","alias":"trace_oa_full_scenario","name":"Restore supply through field repair ↔ Restore supply through field repair","description":"","oa":"d6f881a4-95da-54e9-ad97-1b8965532629","sa":"f746a1cd-ff9f-55c0-b325-01c03d2f4fae","kind":"Scenario","baseline":{"oa":{"name":"Restore supply through field repair","description":""},"sa":{"name":"Restore supply through field repair","description":""}}},"48d243f0-553f-507e-acb0-9474ae156b63":{"id":"48d243f0-553f-507e-acb0-9474ae156b63","alias":"trace_oa_monitor_initial","name":"Start ↔ Start","description":"","oa":"a7ffb869-9dde-5e76-b276-c719c5c143a9","sa":"57eb5348-14d8-560e-996a-8dbf993243e2","kind":"State","baseline":{"oa":{"name":"Start","description":""},"sa":{"name":"Start","description":""}}},"5309f955-53fd-5ff4-9c0a-91737e947341":{"id":"5309f955-53fd-5ff4-9c0a-91737e947341","alias":"trace_oa_assess","name":"Assess pressure alarm ↔ Assess pressure alarm","description":"","oa":"61f7bbfb-74a4-5540-9182-2583dfb27a49","sa":"dddae020-a444-5d48-acc8-139d2696a672","kind":"Refinement","baseline":{"oa":{"name":"Assess pressure alarm","description":""},"sa":{"name":"Assess pressure alarm","description":""}}},"595b5729-ccd9-5291-b5d9-30d8c8bd9dfa":{"id":"595b5729-ccd9-5291-b5d9-30d8c8bd9dfa","alias":"trace_oa_reply","name":"Return pressure after repair ↔ Return pressure after repair","description":"","oa":"cc2d497a-fd15-5455-9ee5-e70eb95811d8","sa":"4e1c0a56-ce80-5f13-a93a-6956de54880c","kind":"Scenario","baseline":{"oa":{"name":"Return pressure after repair","description":""},"sa":{"name":"Return pressure after repair","description":""}}},"5b96bfed-63e2-5d7f-b882-91e8b81f8efe":{"id":"5b96bfed-63e2-5d7f-b882-91e8b81f8efe","alias":"trace_oa_assessment_delivery","name":"Chain involvement: Assess an alarm ↔ Chain involvement: Assess an alarm","description":"","oa":"628cb4fc-fe63-5f6b-b364-6268f26b5740","sa":"a6d5a6be-fa6e-5fa0-a309-08e87ba40c57","kind":"Manual","baseline":{"oa":{"name":"Chain involvement: Assess an alarm","description":""},"sa":{"name":"Chain involvement: Assess an alarm","description":""}}},"6acd072d-431e-5881-ab20-1813b4fda0c5":{"id":"6acd072d-431e-5881-ab20-1813b4fda0c5","alias":"trace_oa_triage","name":"Assess an alarm ↔ Assess an alarm","description":"","oa":"6a30b763-cc51-546d-907f-5c63657cfd03","sa":"bad8fa7c-f634-55fd-98d3-e1eb2da05d8f","kind":"Capability","baseline":{"oa":{"name":"Assess an alarm","description":""},"sa":{"name":"Assess an alarm","description":""}}},"6ca4c78a-a5f2-5470-b401-732f5a8825d0":{"id":"6ca4c78a-a5f2-5470-b401-732f5a8825d0","alias":"trace_oa_assessment_procedure","name":"Assess an alarm procedure ↔ Assess an alarm procedure","description":"","oa":"022d3cfa-bbaf-5e13-924a-8e55099b8745","sa":"acd5a018-f559-5298-a40d-1cf6e1353e74","kind":"Sequence","baseline":{"oa":{"name":"Assess an alarm procedure","description":""},"sa":{"name":"Assess an alarm procedure","description":""}}},"6e13cf86-7242-5e33-8912-b037cf6e8c87":{"id":"6e13cf86-7242-5e33-8912-b037cf6e8c87","alias":"trace_oa_assessment_support","name":"Capability involvement: Assess an alarm ↔ Capability involvement: Assess an alarm","description":"","oa":"ffdbfcd0-d423-5707-a880-0dfbe1400f72","sa":"93eaad8d-83b7-5980-b9bb-86c9e3f31da4","kind":"Manual","baseline":{"oa":{"name":"Capability involvement: Assess an alarm","description":""},"sa":{"name":"Capability involvement: Assess an alarm","description":""}}},"70c90b0c-85ce-574a-a217-f0d19fad30e1":{"id":"70c90b0c-85ce-574a-a217-f0d19fad30e1","alias":"trace_oa_response_delivery","name":"Chain involvement: Restore a safe supply ↔ Chain involvement: Restore a safe supply","description":"","oa":"c7c248f8-28b8-55be-9023-0991d44021e3","sa":"66efc484-53c7-51a3-89e9-422429344b38","kind":"Manual","baseline":{"oa":{"name":"Chain involvement: Restore a safe supply","description":""},"sa":{"name":"Chain involvement: Restore a safe supply","description":""}}},"77073fcb-53aa-5f06-987a-0317cbb6971d":{"id":"77073fcb-53aa-5f06-987a-0317cbb6971d","alias":"trace_oa_include_triage","name":"Capability include: Assess an alarm ↔ Capability include: Assess an alarm","description":"","oa":"e21d9a54-ae8e-5618-a4f8-67ee22b92638","sa":"f3fe7690-734a-5877-8b57-a6599a4033c4","kind":"Manual","baseline":{"oa":{"name":"Capability include: Assess an alarm","description":""},"sa":{"name":"Capability include: Assess an alarm","description":""}}},"7a6b4b9c-4220-546a-a752-1587c2881fe1":{"id":"7a6b4b9c-4220-546a-a752-1587c2881fe1","alias":"trace_oa_use_field","name":"Use the field repair procedure ↔ Use the field repair procedure","description":"","oa":"fc6763bf-a534-54bf-afcd-57ab285ff07d","sa":"d5e61c5d-510b-5091-a878-da46f3a9e152","kind":"Sequence","baseline":{"oa":{"name":"Use the field repair procedure","description":""},"sa":{"name":"Use the field repair procedure","description":""}}},"82a5be1a-92a8-578d-9448-086062a20d8f":{"id":"82a5be1a-92a8-578d-9448-086062a20d8f","alias":"trace_oa_reset","name":"Reset valve remotely ↔ Reset valve remotely","description":"","oa":"d0c8be87-d238-503a-a549-7558a210507f","sa":"09342ccb-044b-5483-ac58-87dc3cf7831c","kind":"Refinement","baseline":{"oa":{"name":"Reset valve remotely","description":""},"sa":{"name":"Reset valve remotely","description":""}}},"82cca563-9d5d-5479-aa1a-9db03e9674d1":{"id":"82cca563-9d5d-5479-aa1a-9db03e9674d1","alias":"trace_oa_restore_work","name":"Restore safe pressure ↔ Restore safe pressure","description":"","oa":"1b91f8a7-0bdb-514e-b1cd-955658e09927","sa":"58867e7d-916e-5ebc-9e4c-8dbed1314be3","kind":"Refinement","baseline":{"oa":{"name":"Restore safe pressure","description":""},"sa":{"name":"Restore safe pressure","description":""}}},"8d693d90-5017-56af-b056-d29d3cb46baa":{"id":"8d693d90-5017-56af-b056-d29d3cb46baa","alias":"trace_oa_mission_participant","name":"Mission involvement: Maintain safe water supply ↔ Mission involvement: Maintain safe water supply","description":"","oa":"0c4c9e58-ff32-5c00-b82a-d408a4be0183","sa":"1c1e5634-ba8c-591f-b143-90a27cdc6cd2","kind":"Manual","baseline":{"oa":{"name":"Mission involvement: Maintain safe water supply","description":""},"sa":{"name":"Mission involvement: Maintain safe water supply","description":""}}},"98ef05b5-5381-5d35-a433-ebab55e9f382":{"id":"98ef05b5-5381-5d35-a433-ebab55e9f382","alias":"trace_oa_reset_allocation","name":"Allocation: Reset valve remotely ↔ Allocation: Reset valve remotely","description":"","oa":"1853df1f-30f4-5b1a-baac-2dcaf73c7b3a","sa":"3cc9ab52-2b00-57fd-8709-aabc75e5d3e3","kind":"Manual","baseline":{"oa":{"name":"Allocation: Reset valve remotely","description":""},"sa":{"name":"Allocation: Reset valve remotely","description":""}}},"a106d183-c239-5c9f-b1f1-141af19fc778":{"id":"a106d183-c239-5c9f-b1f1-141af19fc778","alias":"trace_oa_confirm_path","name":"Continue ↔ Continue","description":"","oa":"229ee94f-d147-50f6-9769-b5bd0c03a084","sa":"1e738f27-a3a1-526d-b330-6384088b9689","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"a5c1eb01-a95f-5f7a-9742-7f201493d7aa":{"id":"a5c1eb01-a95f-5f7a-9742-7f201493d7aa","alias":"trace_oa_assess_then_decide","name":"Continue ↔ Continue","description":"","oa":"109e2d6b-7e86-5934-93a5-fab56eced84e","sa":"9d4a131f-36ad-5125-b21f-c3dd033b8c82","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"aa164208-394d-5bc7-8462-b0043f682aad":{"id":"aa164208-394d-5bc7-8462-b0043f682aad","alias":"trace_oa_mission_delivery","name":"Mission capability: Restore a safe supply ↔ Mission capability: Restore a safe supply","description":"","oa":"1f8b1898-65f6-5360-99cf-b78a71bd50ec","sa":"a0902e0f-3d18-576a-9aca-975a136dfdd7","kind":"Manual","baseline":{"oa":{"name":"Mission capability: Restore a safe supply","description":""},"sa":{"name":"Mission capability: Restore a safe supply","description":""}}},"aba528ea-e396-582a-bfba-f6e2212d391a":{"id":"aba528ea-e396-582a-bfba-f6e2212d391a","alias":"trace_oa_control","name":"Control room ↔ Pressure coordination service","description":"","oa":"32aa9940-df6e-5a12-b9bd-6337e2d2ed22","sa":"435bf87c-73fd-5e4d-978a-4c471bc9eaf0","kind":"Participant","baseline":{"oa":{"name":"Control room","description":""},"sa":{"name":"Pressure coordination service","description":""}}},"ac3aa6b0-568e-5a3f-963a-172aa3cfc7a4":{"id":"ac3aa6b0-568e-5a3f-963a-172aa3cfc7a4","alias":"trace_oa_repair_step","name":"Repair the valve ↔ Repair the valve","description":"","oa":"c59432fe-6092-5ee0-908d-9dbf39476471","sa":"0f195fea-3493-5ebd-966d-da9eddeb05d7","kind":"Sequence","baseline":{"oa":{"name":"Repair the valve","description":""},"sa":{"name":"Repair the valve","description":""}}},"acdbee9c-5017-5154-804e-50801f0376ec":{"id":"acdbee9c-5017-5154-804e-50801f0376ec","alias":"trace_oa_assess_allocation","name":"Allocation: Assess pressure alarm ↔ Allocation: Assess pressure alarm","description":"","oa":"594c61cd-94e0-5c07-890d-edfd520d7df9","sa":"9b9356d7-2db2-5a94-a47e-ea892cf02afe","kind":"Manual","baseline":{"oa":{"name":"Allocation: Assess pressure alarm","description":""},"sa":{"name":"Allocation: Assess pressure alarm","description":""}}},"ad697c00-07d9-5799-89b3-2c78da8dff4d":{"id":"ad697c00-07d9-5799-89b3-2c78da8dff4d","alias":"trace_oa_request_step","name":"Request the field repair ↔ Request the field repair","description":"","oa":"5ba8079c-2dbe-5e98-ae82-2b60c6020a02","sa":"40d94322-baa2-5c81-b261-ff47cdc33782","kind":"Sequence","baseline":{"oa":{"name":"Request the field repair","description":""},"sa":{"name":"Request the field repair","description":""}}},"b13452bb-1a55-5ad6-b6d9-a37fb34e8d13":{"id":"b13452bb-1a55-5ad6-b6d9-a37fb34e8d13","alias":"trace_oa_repair","name":"Repair valve in the field ↔ Repair valve in the field","description":"","oa":"a21465af-05f3-5fb5-8983-de26932422fb","sa":"9497e10f-04c4-5586-8738-2464a6f00bd4","kind":"Refinement","baseline":{"oa":{"name":"Repair valve in the field","description":""},"sa":{"name":"Repair valve in the field","description":""}}},"b53863a2-ed80-5f98-ad3c-50e1b821066d":{"id":"b53863a2-ed80-5f98-ad3c-50e1b821066d","alias":"trace_oa_monitor","name":"Pressure supervision ↔ Pressure supervision","description":"","oa":"1970608f-85d1-5234-ac86-5e2b0df27dc3","sa":"132b3f28-3f88-5327-9868-8c8b645ed412","kind":"State","baseline":{"oa":{"name":"Pressure supervision","description":""},"sa":{"name":"Pressure supervision","description":""}}},"b97749d4-f9ad-5fb7-9d78-bc60a74e98eb":{"id":"b97749d4-f9ad-5fb7-9d78-bc60a74e98eb","alias":"trace_oa_high_pressure","name":"Pressure alarm detected ↔ Pressure alarm detected","description":"","oa":"65d6b8d6-271a-5c84-8b42-abbe310c955e","sa":"01490fa8-2f01-5ac8-9722-d55d45414379","kind":"State","baseline":{"oa":{"name":"Pressure alarm detected","description":""},"sa":{"name":"Pressure alarm detected","description":""}}},"bb72b2c2-6e32-5f65-9d8e-b33c1a65da37":{"id":"bb72b2c2-6e32-5f65-9d8e-b33c1a65da37","alias":"trace_oa_field_procedure","name":"Field repair procedure ↔ Field repair procedure","description":"","oa":"5d3af417-244c-54bb-9588-16daedda4e1f","sa":"93eb6479-54e9-59cb-ac7b-16912347bfc0","kind":"Sequence","baseline":{"oa":{"name":"Field repair procedure","description":""},"sa":{"name":"Field repair procedure","description":""}}},"bbf6f1a1-cb2b-5632-8ec0-bf4b8ef1a941":{"id":"bbf6f1a1-cb2b-5632-8ec0-bf4b8ef1a941","alias":"trace_oa_decision","name":"Can a remote reset safely recover pressure? ↔ Can a remote reset safely recover pressure?","description":"","oa":"b280c6ca-e5f9-5424-a5c1-5676e01c2371","sa":"30c8fad9-a49b-5692-9542-3148e76afd7b","kind":"Sequence","baseline":{"oa":{"name":"Can a remote reset safely recover pressure?","description":""},"sa":{"name":"Can a remote reset safely recover pressure?","description":""}}},"c132a0df-1270-55a8-ac4e-6e5075823dc1":{"id":"c132a0df-1270-55a8-ac4e-6e5075823dc1","alias":"trace_oa_outer_technician","name":"Technician ↔ Technician","description":"","oa":"fffff882-78fd-5415-928d-c1ef7fac6576","sa":"155e4fe4-9e3b-5f4e-b886-28abcc3f086b","kind":"Scenario","baseline":{"oa":{"name":"Technician","description":""},"sa":{"name":"Technician","description":""}}},"c47cf0b4-fe15-5175-bd62-5292e43adc39":{"id":"c47cf0b4-fe15-5175-bd62-5292e43adc39","alias":"trace_oa_normal_transition","name":"State transition: Monitoring ↔ State transition: Monitoring","description":"","oa":"21fe5e8e-cbc2-5dad-b3e8-1df46e167d97","sa":"6e2c1b47-764c-577a-b325-ff66dd2a2768","kind":"Manual","baseline":{"oa":{"name":"State transition: Monitoring","description":""},"sa":{"name":"State transition: Monitoring","description":""}}},"c5051628-b7af-5fb1-a014-bb60a0b46e03":{"id":"c5051628-b7af-5fb1-a014-bb60a0b46e03","alias":"trace_oa_responder","name":"Technician ↔ Technician","description":"","oa":"fc81c5f0-0442-5b88-992b-e5b10f351c59","sa":"6249b54a-e396-5338-82b9-89266c55670d","kind":"Scenario","baseline":{"oa":{"name":"Technician","description":""},"sa":{"name":"Technician","description":""}}},"c987bd0f-87f6-5550-945d-2a42450fe1c5":{"id":"c987bd0f-87f6-5550-945d-2a42450fe1c5","alias":"trace_oa_mission","name":"Maintain safe water supply ↔ Maintain safe water supply","description":"","oa":"c9c61f50-06e0-5d17-973d-dcc57cd5cc90","sa":"5ad034b1-084d-5273-9e70-971671766132","kind":"Mission","baseline":{"oa":{"name":"Maintain safe water supply","description":""},"sa":{"name":"Maintain safe water supply","description":""}}},"c9d65349-3400-58eb-8a55-57fa02de4b67":{"id":"c9d65349-3400-58eb-8a55-57fa02de4b67","alias":"trace_oa_contact","name":"Technician service conversation ↔ Technician service conversation","description":"","oa":"27b47887-baf0-518b-bf87-f5acbf7d0b93","sa":"63009b5f-539e-5914-b630-ec6b7fefb4d5","kind":"Exchange","baseline":{"oa":{"name":"Technician service conversation","description":""},"sa":{"name":"Technician service conversation","description":""}}},"cf92cedf-ea1a-510c-9bc5-89c16d60e8d2":{"id":"cf92cedf-ea1a-510c-9bc5-89c16d60e8d2","alias":"trace_oa_repair_operation","name":"Valve repair ↔ Valve repair","description":"","oa":"d736c7d6-d369-550a-8ba4-dae223e40bb3","sa":"a962fc5e-3ab0-5296-8b0d-fd84633f9218","kind":"Item","baseline":{"oa":{"name":"Valve repair","description":""},"sa":{"name":"Valve repair","description":""}}},"d32dd433-9c9e-5b57-9355-2970cc44a5f2":{"id":"d32dd433-9c9e-5b57-9355-2970cc44a5f2","alias":"trace_oa_field_merge","name":"Continue ↔ Continue","description":"","oa":"c8593670-8906-530f-b6a2-3ada77a9c219","sa":"0f6ac3fb-4ad6-581e-ad14-336472072d5e","kind":"Manual","baseline":{"oa":{"name":"Continue","description":""},"sa":{"name":"Continue","description":""}}},"d3626818-4846-5893-9c02-3407cdba7f3c":{"id":"d3626818-4846-5893-9c02-3407cdba7f3c","alias":"trace_oa_response","name":"Handle a pressure alarm ↔ Handle a pressure alarm","description":"","oa":"58bae2b0-f560-50f2-818d-19091d22d18a","sa":"5139ce73-56fd-5875-ba3a-e569fc890231","kind":"Sequence","baseline":{"oa":{"name":"Handle a pressure alarm","description":""},"sa":{"name":"Handle a pressure alarm","description":""}}},"d47d3c36-50fb-5189-b801-d6fd1694bd49":{"id":"d47d3c36-50fb-5189-b801-d6fd1694bd49","alias":"trace_oa_technician","name":"Field technician ↔ Field technician","description":"","oa":"e07c3952-4a1a-5cc7-b7d5-03a01101ce49","sa":"c05b9422-b9da-5de5-b21d-07ad23bd2b8a","kind":"Participant","baseline":{"oa":{"name":"Field technician","description":""},"sa":{"name":"Field technician","description":""}}},"d7773ff5-cef5-50c1-8f9d-c5659115e1f0":{"id":"d7773ff5-cef5-50c1-8f9d-c5659115e1f0","alias":"trace_oa_confirm","name":"Confirm safe pressure ↔ Confirm safe pressure","description":"","oa":"727c0b5c-8db6-5eb7-83d6-735519c5949f","sa":"d75eb3fa-a27d-52d5-8782-8b342b3865f6","kind":"Refinement","baseline":{"oa":{"name":"Confirm safe pressure","description":""},"sa":{"name":"Confirm safe pressure","description":""}}},"d9d47371-e98d-5d29-9df7-4de2d28a5c73":{"id":"d9d47371-e98d-5d29-9df7-4de2d28a5c73","alias":"trace_oa_pressure","name":"Measured pressure ↔ Measured pressure","description":"","oa":"e7824764-5db9-5384-8198-2fbaea2b10b2","sa":"87d0a95b-af1b-5352-b753-e4c6533671a5","kind":"Item","baseline":{"oa":{"name":"Measured pressure","description":""},"sa":{"name":"Measured pressure","description":""}}},"da8efc3c-78df-5b10-b2e5-1e28d6d99e86":{"id":"da8efc3c-78df-5b10-b2e5-1e28d6d99e86","alias":"trace_oa_repair_request","name":"Request field repair ↔ Request field repair","description":"","oa":"cf423ea2-8b2e-5a27-93d4-5e8542d9db8d","sa":"93474111-f23e-56c1-b61b-b7c2396b91bd","kind":"Exchange","baseline":{"oa":{"name":"Request field repair","description":""},"sa":{"name":"Request field repair","description":""}}},"e63b14d2-f408-56a2-aec4-be20cf4ace73":{"id":"e63b14d2-f408-56a2-aec4-be20cf4ace73","alias":"trace_oa_report_step","name":"Report the measured result ↔ Report the measured result","description":"","oa":"df2fd3f8-6bc2-5380-a13f-36643ed35ac7","sa":"ea32e4ec-8bdc-57ea-8a90-7b09ba4f0eac","kind":"Sequence","baseline":{"oa":{"name":"Report the measured result","description":""},"sa":{"name":"Report the measured result","description":""}}},"e848da41-445e-5e3c-8b4e-ef53fdddf1e1":{"id":"e848da41-445e-5e3c-8b4e-ef53fdddf1e1","alias":"trace_oa_assess_step","name":"Assess the alarm ↔ Assess the alarm","description":"","oa":"0b07b9ed-492d-57f6-b0b1-ce8a709238a7","sa":"9024243c-ccc1-5edb-8477-2aa16e5e9a50","kind":"Sequence","baseline":{"oa":{"name":"Assess the alarm","description":""},"sa":{"name":"Assess the alarm","description":""}}},"e96ff119-bb4d-5fd9-9ab7-2ede0d3fd418":{"id":"e96ff119-bb4d-5fd9-9ab7-2ede0d3fd418","alias":"trace_oa_contact_category","name":"Field contact ↔ Field contact","description":"","oa":"19bd3a3a-4d0c-56d5-bf50-a6b43fd81771","sa":"781cbe75-410f-5cc5-aeb8-a43491f77b99","kind":"Manual","baseline":{"oa":{"name":"Field contact","description":""},"sa":{"name":"Field contact","description":""}}},"eee79ec1-41d7-5374-986e-995a56b9026f":{"id":"eee79ec1-41d7-5374-986e-995a56b9026f","alias":"trace_oa_remote_path","name":"remote reset is authorized ↔ remote reset is authorized","description":"","oa":"76218d89-68fd-531d-b35b-90c5b769a5ef","sa":"d8b6710a-e713-5979-934f-e5ca7d155bb4","kind":"Manual","baseline":{"oa":{"name":"remote reset is authorized","description":""},"sa":{"name":"remote reset is authorized","description":""}}},"f0cc86e5-f8fd-5091-a40d-dbfb6515072f":{"id":"f0cc86e5-f8fd-5091-a40d-dbfb6515072f","alias":"trace_oa_call","name":"Request valve repair ↔ Request valve repair","description":"","oa":"b97437c6-cd7a-5a05-b537-b30a17dd936a","sa":"8bab4783-a098-5984-9e36-493957caf26c","kind":"Scenario","baseline":{"oa":{"name":"Request valve repair","description":""},"sa":{"name":"Request valve repair","description":""}}},"f2b7050c-629d-533f-b873-bffc33e072f7":{"id":"f2b7050c-629d-533f-b873-bffc33e072f7","alias":"trace_oa_request_then_repair","name":"Sequence link: Repair the valve ↔ Sequence link: Repair the valve","description":"","oa":"3f54d890-4ca5-5e36-873b-abb5c92d578c","sa":"6a6d968f-26d6-5dbb-b721-5a27d362779a","kind":"Manual","baseline":{"oa":{"name":"Sequence link: Repair the valve","description":""},"sa":{"name":"Sequence link: Repair the valve","description":""}}},"f5733d3f-eee3-5cf7-a422-5edd2054d6a6":{"id":"f5733d3f-eee3-5cf7-a422-5edd2054d6a6","alias":"trace_oa_use_conversation","name":"Use the reviewed repair conversation ↔ Use the reviewed repair conversation","description":"","oa":"9ff94014-afa1-50ab-a4d3-8e9dd0565cc5","sa":"1c9da843-e2d3-5591-8e3f-db083a42867e","kind":"Scenario","baseline":{"oa":{"name":"Use the reviewed repair conversation","description":""},"sa":{"name":"Use the reviewed repair conversation","description":""}}},"f8d34715-1dc8-504b-acdf-78b770aad1e8":{"id":"f8d34715-1dc8-504b-acdf-78b770aad1e8","alias":"trace_oa_repair_then_report","name":"Sequence link: Report the measured result ↔ Sequence link: Report the measured result","description":"","oa":"c8196d35-2168-5fef-8b03-ba0d139bc00f","sa":"7fb30b1d-195c-5f0e-a19c-a536f519a899","kind":"Manual","baseline":{"oa":{"name":"Sequence link: Report the measured result","description":""},"sa":{"name":"Sequence link: Report the measured result","description":""}}},"fb02420d-b066-5773-87c1-198a056e0731":{"id":"fb02420d-b066-5773-87c1-198a056e0731","alias":"trace_oa_assess_on_entry","name":"Assess when repair is needed ↔ Assess when repair is needed","description":"","oa":"7fa37e63-db65-5aa7-88f2-31ff4347a787","sa":"a887f96a-37cb-5891-8070-011fa276e5b5","kind":"Manual","baseline":{"oa":{"name":"Assess when repair is needed","description":""},"sa":{"name":"Assess when repair is needed","description":""}}},"fd195498-dbba-523b-94d0-49f3b9531a3e":{"id":"fd195498-dbba-523b-94d0-49f3b9531a3e","alias":"trace_oa_outer_controller","name":"Controller ↔ Controller","description":"","oa":"2011b3ac-614b-5a47-8891-7ff81965f425","sa":"1edfcf9e-9bfa-51f1-80d5-62b8fd8bb023","kind":"Scenario","baseline":{"oa":{"name":"Controller","description":""},"sa":{"name":"Controller","description":""}}},"ff84fc06-6f99-5cf3-b73d-236e9eff98a3":{"id":"ff84fc06-6f99-5cf3-b73d-236e9eff98a3","alias":"trace_oa_reset_step","name":"Try the authorized remote reset ↔ Try the authorized remote reset","description":"","oa":"d2026fc2-74a8-5e22-9942-ff3eb68a3677","sa":"bfa2ef0b-ce68-55c2-a9e5-31ea8a74fce6","kind":"Sequence","baseline":{"oa":{"name":"Try the authorized remote reset","description":""},"sa":{"name":"Try the authorized remote reset","description":""}}}}} \ No newline at end of file diff --git a/crates/oa-desktop/src/recovery/source5.rs b/crates/oa-desktop/src/recovery/source5.rs index 53b920d8..08f73291 100644 --- a/crates/oa-desktop/src/recovery/source5.rs +++ b/crates/oa-desktop/src/recovery/source5.rs @@ -1,7 +1,7 @@ -//! Narrow compatibility for private checkpoints written by the source-5 desktop. +//! Lossless compatibility for private checkpoints written by source-5/6 desktops. use super::*; use oa_core::{ - Diagram, Project, + Diagram, Project, SCHEMA_VERSION, language::{self, TokenClass}, }; use serde::ser::{SerializeStruct, Serializer}; @@ -16,7 +16,9 @@ pub(super) fn upgrade_binding( return Ok(None); } let unsupported = || { - "Checkpoint accepted source cannot be recovered exactly; only a lossless source 5 to 6 upgrade is supported. The checkpoint has been retained.".to_owned() + format!( + "Checkpoint accepted source cannot be recovered exactly; only a lossless source 5 or 6 to {SCHEMA_VERSION} upgrade is supported. The checkpoint has been retained." + ) }; let report = document .compiled() @@ -24,7 +26,7 @@ pub(super) fn upgrade_binding( .migration .as_ref() .ok_or_else(unsupported)?; - if (report.from_schema, report.to_schema) != (5, 6) { + if !matches!(report.from_schema, 5 | 6) || report.to_schema != SCHEMA_VERSION { return Err(unsupported()); } let source = accepted @@ -39,7 +41,7 @@ pub(super) fn upgrade_binding( if tokens.len() != 3 || &source[tokens[0].span.start..tokens[0].span.end] != "arcadia" || tokens[1].class != TokenClass::Number - || &source[tokens[1].span.start..tokens[1].span.end] != "5" + || source[tokens[1].span.start..tokens[1].span.end] != report.from_schema.to_string() || &source[tokens[2].span.start..tokens[2].span.end] != ";" { return Err(unsupported()); @@ -49,24 +51,51 @@ pub(super) fn upgrade_binding( .files .get_mut(&accepted.entry) .ok_or_else(unsupported)? - .replace_range(tokens[1].span.start..tokens[1].span.end, "6"); + .replace_range( + tokens[1].span.start..tokens[1].span.end, + &SCHEMA_VERSION.to_string(), + ); if document.bundle() != &expected { return Err(unsupported()); } - // The old source admission rejects all source-6 constructs. Exact token-only - // migration establishes that borrowed records below retain their old shape. - legacy_fingerprint(document.project()).map(Some) + // Source admission proves authored-version eligibility; historical JSON + // admission below independently proves that omitted fields lose no facts. + legacy_fingerprint(document.project(), report.from_schema).map(Some) } -fn legacy_fingerprint(p: &Project) -> Result { - if p.schema_version != 6 - || p.diagrams - .values() - .any(|d| d.view_options != Default::default()) +fn legacy_fingerprint(p: &Project, schema: u32) -> Result { + if p.schema_version != SCHEMA_VERSION + || !matches!(schema, 5 | 6) + || (schema == 5 + && p.diagrams + .values() + .any(|d| d.view_options != Default::default())) { - return Err("Checkpoint migration does not have a source-5 model shape.".into()); + return Err(format!( + "Checkpoint migration does not have a source-{schema} model shape." + )); + } + let bytes = + serde_json::to_vec(&HistoricalProject { project: p, schema }).map_err(|e| e.to_string())?; + // This path runs only during explicit recovery, never a periodic write. + // The model already passed source limits. Its internal JSON expansion may + // be larger than authored text, so bound records/references as before while + // admitting exactly the internally serialized byte length. + let old = oa_core::persistence::decode_with_report_limited( + &bytes, + language::ParseLimits { + max_source_bytes: bytes.len(), + ..LIMITS.model + }, + ) + .map_err(|error| { + format!("Checkpoint source-{schema} fingerprint shape is not valid: {error}") + })?; + if &old.project != p { + return Err(format!( + "Checkpoint source-{schema} fingerprint would discard or insert model facts." + )); } - let bytes = serde_json::to_vec(&Project5(p)).map_err(|e| e.to_string())?; let hash = bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| { (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) }); @@ -75,21 +104,28 @@ fn legacy_fingerprint(p: &Project) -> Result { // Preserve the exact historical serde field order (2b2d2bc), including empty // maps and routes. Serializing through Value would reorder struct fields. -struct Project5<'a>(&'a Project); -impl Serialize for Project5<'_> { +struct HistoricalProject<'a> { + project: &'a Project, + schema: u32, +} +impl Serialize for HistoricalProject<'_> { fn serialize(&self, serializer: S) -> Result { - let p = self.0; - let diagrams: std::collections::BTreeMap<_, _> = - p.diagrams.iter().map(|(id, d)| (id, Diagram5(d))).collect(); + let p = self.project; let mut s = serializer.serialize_struct("Project", 10)?; - s.serialize_field("schema_version", &5u32)?; + s.serialize_field("schema_version", &self.schema)?; s.serialize_field("id", &p.id)?; s.serialize_field("alias", &p.alias)?; s.serialize_field("name", &p.name)?; s.serialize_field("description", &p.description)?; s.serialize_field("elements", &p.elements)?; s.serialize_field("relations", &p.relations)?; - s.serialize_field("diagrams", &diagrams)?; + if self.schema == 5 { + let diagrams: std::collections::BTreeMap<_, _> = + p.diagrams.iter().map(|(id, d)| (id, Diagram5(d))).collect(); + s.serialize_field("diagrams", &diagrams)?; + } else { + s.serialize_field("diagrams", &p.diagrams)?; + } s.serialize_field("artifact_links", &p.artifact_links)?; s.serialize_field("traces", &p.traces)?; s.end() @@ -122,11 +158,18 @@ mod tests { // value was independently calculated from these UTF-8 bytes with Python. let bytes = r#"{"schema_version":5,"id":"00000000-0000-0000-0000-000000000001","alias":"p","name":"Legacy","description":"","elements":{},"relations":{},"diagrams":{"00000000-0000-0000-0000-000000000002":{"id":"00000000-0000-0000-0000-000000000002","alias":"v","name":"View","description":"","layer":"Oa","projection":"Overview","nodes":{},"routes":[]}},"artifact_links":{},"traces":{}}"#; let mut p: Project = serde_json::from_str(bytes).unwrap(); - p.schema_version = 6; - assert_eq!(serde_json::to_string(&Project5(&p)).unwrap(), bytes); - assert_eq!(legacy_fingerprint(&p).unwrap(), "4140e158d5091161"); + p.schema_version = SCHEMA_VERSION; + assert_eq!( + serde_json::to_string(&HistoricalProject { + project: &p, + schema: 5 + }) + .unwrap(), + bytes + ); + assert_eq!(legacy_fingerprint(&p, 5).unwrap(), "4140e158d5091161"); assert_ne!( - legacy_fingerprint(&p).unwrap(), + legacy_fingerprint(&p, 5).unwrap(), oa_core::transitions::project_fingerprint(&p) ); } diff --git a/crates/oa-desktop/src/recovery/source5_tests.rs b/crates/oa-desktop/src/recovery/source5_tests.rs index 8004b447..de003b28 100644 --- a/crates/oa-desktop/src/recovery/source5_tests.rs +++ b/crates/oa-desktop/src/recovery/source5_tests.rs @@ -26,6 +26,7 @@ fn source5_snapshot() -> Snapshot { property: None, behavior: None, workflow: None, + architecture: None, selected: Id::from_u128(0x40008000000000000001), } } @@ -39,7 +40,7 @@ fn source5_checkpoint_upgrades_only_header_and_clean_editor_baseline() { expected.entry.clone(), saved.accepted.files[&saved.accepted.entry].replacen( "arcadia // header\r\n 5;", - "arcadia // header\r\n 6;", + "arcadia // header\r\n 7;", 1, ), ); @@ -52,7 +53,10 @@ fn source5_checkpoint_upgrades_only_header_and_clean_editor_baseline() { .migration .as_ref() .unwrap(); - assert_eq!((report.from_schema, report.to_schema), (5, 6)); + assert_eq!( + (report.from_schema, report.to_schema), + (5, oa_core::SCHEMA_VERSION) + ); assert_eq!(restored.workspace.text(), expected.files[&expected.entry]); assert!(!restored.workspace.dirty()); assert!(restored.document.dirty()); @@ -76,7 +80,7 @@ fn source5_checkpoint_preserves_dirty_invalid_text_and_baseline_exactly() { ); assert_eq!(restored.workspace.text(), text); assert!(restored.workspace.dirty()); - assert!(restored.document.bundle().files[&entry].contains("arcadia // header\r\n 6;")); + assert!(restored.document.bundle().files[&entry].contains("arcadia // header\r\n 7;")); } #[test] diff --git a/crates/oa-desktop/src/recovery/source7_tests.rs b/crates/oa-desktop/src/recovery/source7_tests.rs new file mode 100644 index 00000000..f309eb54 --- /dev/null +++ b/crates/oa-desktop/src/recovery/source7_tests.rs @@ -0,0 +1,444 @@ +//! Frozen, nonempty historical profiles: never synthesize their fingerprint +//! from the serializer whose compatibility this test is meant to verify. +use super::*; +use oa_core::{Project, SCHEMA_VERSION}; +use serde_json::{Value, json}; + +fn profile(version: u32) -> (Snapshot, &'static [u8], &'static str) { + let (bundle, model, fingerprint): (&[u8], &[u8], &str) = match version { + 5 => ( + include_bytes!("fixtures/source5-bundle.json"), + include_bytes!("fixtures/source5-model.json"), + "31b56f1d17c2d9cf", + ), + 6 => ( + include_bytes!("fixtures/source6-bundle.json"), + include_bytes!("fixtures/source6-model.json"), + "88c1149e445e8676", + ), + _ => unreachable!(), + }; + let accepted: SourceBundle = serde_json::from_slice(bundle).unwrap(); + let saved = Snapshot { + version: 1, + id: Id::from_u128(700 + u128::from(version)), + name: "Historical pressure restoration".into(), + origin: None, + modified: 1, + selected: Id::nil(), + workspace: Workspace::new(&accepted, true).recovery_snapshot(), + accepted, + property: None, + behavior: None, + workflow: None, + architecture: None, + }; + (saved, model, fingerprint) +} +fn alias(p: &Project, name: &str) -> Id { + p.elements.values().find(|e| e.alias == name).unwrap().id +} +fn expected_bundle(saved: &Snapshot, version: u32) -> SourceBundle { + let mut bundle = saved.accepted.clone(); + let text = bundle.files.get_mut(&bundle.entry).unwrap(); + let marker = format!("arcadia // profile\r\n {version};"); + assert_eq!(text.matches(&marker).count(), 1); + *text = text.replacen( + &marker, + &format!("arcadia // profile\r\n {SCHEMA_VERSION};"), + 1, + ); + bundle +} + +#[test] +fn source7_recovers_nonempty_frozen_5_and_6_profiles_with_exact_historical_binding() { + for version in [5, 6] { + let (saved, bytes, fingerprint) = profile(version); + let independently_hashed = bytes.iter().fold(0xcbf29ce484222325u64, |h, b| { + (h ^ u64::from(*b)).wrapping_mul(0x100000001b3) + }); + assert_eq!(format!("{independently_hashed:016x}"), fingerprint); + let historical: Value = serde_json::from_slice(bytes).unwrap(); + let serialized = String::from_utf8_lossy(bytes); + for required in ["\"Call\"", "\"Reply\"", "\"SequenceLink\"", "\"values\""] { + assert!(serialized.contains(required), "missing {required}"); + } + assert!(historical["traces"].as_object().unwrap().len() > 60); + assert_eq!(saved.accepted.files.len(), 30); + let expected = expected_bundle(&saved, version); + let doc = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap(); + assert_eq!( + source5::upgrade_binding(&saved.accepted, &doc) + .unwrap() + .as_deref(), + Some(fingerprint) + ); + let restored = saved.restore().unwrap(); + assert_eq!( + restored.document.project(), + &oa_core::persistence::decode(bytes).unwrap() + ); + assert_eq!(restored.document.bundle(), &expected); + let report = restored + .document + .compiled() + .decoded + .migration + .as_ref() + .unwrap(); + assert_eq!( + (report.from_schema, report.to_schema), + (version, SCHEMA_VERSION) + ); + assert_eq!(restored.workspace.text(), expected.files[&expected.entry]); + assert!(!restored.workspace.dirty()); + assert!(restored.document.dirty()); + assert!(!restored.document.can_undo()); + assert_eq!( + restored + .document + .project() + .diagrams + .values() + .any(|d| d.view_options != Default::default()), + version == 6 + ); + } +} + +#[test] +fn source7_legacy_profiles_preserve_dirty_source_property_and_behavior_raw_fields() { + use crate::behavior::{Event, scenario}; + for version in [5, 6] { + let (mut saved, _, _) = profile(version); + let doc = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap(); + let mut workspace = Workspace::new(&saved.accepted, true); + let cursor = workspace.files[&workspace.entry].cursor; + workspace.edit(format!("{}\r\nunfinished {{ 雨", workspace.text()), cursor); + workspace.new_path = "raw/not-created.arcadia".into(); + saved.workspace = workspace.recovery_snapshot(); + let workspace_before = serde_json::to_value(&saved.workspace).unwrap(); + let call = alias(doc.project(), "oa_call"); + let mut property = Draft::from_project(doc.project(), call).unwrap(); + property.input("/name", " Unfinished operation:null text 雨 ".into()); + saved.property = Some(property.recovery_snapshot()); + let property_before = serde_json::to_value(&saved.property).unwrap(); + let owner = alias(doc.project(), "oa_conversation"); + let mut behavior = crate::behavior::State::default(); + for event in [ + scenario::Event::Start(owner, scenario::Tool::Message), + scenario::Event::Expression("- 雨".into()), + ] { + behavior + .update(doc.project(), Event::Scenario(event)) + .unwrap(); + } + let mut raw = behavior.recovery_snapshot().unwrap().unwrap(); + // Genuine old payloads had no operation key, even when its value is None. + raw["draft"].as_object_mut().unwrap().remove("operation"); + raw["draft"] + .as_object_mut() + .unwrap() + .remove("disabled_operation"); + saved.behavior = Some(raw.clone()); + let restored = saved.restore().unwrap(); + assert_eq!( + serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(), + workspace_before + ); + assert_eq!( + serde_json::to_value(restored.property.unwrap().recovery_snapshot()).unwrap(), + property_before + ); + let mut after = restored.behavior.recovery_snapshot().unwrap().unwrap(); + assert!(after["draft"]["operation"].is_null()); + after["draft"].as_object_mut().unwrap().remove("operation"); + assert!(after["draft"]["disabled_operation"].is_null()); + after["draft"] + .as_object_mut() + .unwrap() + .remove("disabled_operation"); + assert_eq!(after, raw); + assert!( + restored + .behavior + .prepare_pending(restored.document.project()) + .is_err() + ); + } +} + +#[test] +fn source7_legacy_profile_workflow_and_semantic_drafts_replan_with_exact_base() { + use crate::{ + semantic_review::{RowEvent, State as Review}, + workflow::Workflow, + }; + use oa_core::semantic::{FacetSelector, HeaderFacet, SemanticDecision}; + for version in [5, 6] { + let (saved, _, fingerprint) = profile(version); + let doc = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap(); + let work = alias(doc.project(), "oa_confirm"); + let mut transition = Workflow::transition(doc.project(), work) + .unwrap() + .recovery_snapshot() + .unwrap(); + transition["Transition"]["base"] = fingerprint.into(); + let facet = FacetSelector::Header(HeaderFacet::Name); + let mut semantic = Review::new(doc.project(), work).unwrap(); + for event in [ + RowEvent::Select(facet.clone()), + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + RowEvent::Input("/name".into(), " preserved 雨 ".into()), + ] { + semantic.update(doc.project(), event); + } + let mut semantic = semantic.recovery_snapshot().unwrap(); + // The new source-7 raw omission control did not exist in old rows. + for row in semantic["rows"].as_array_mut().unwrap() { + row["draft"] + .as_object_mut() + .unwrap() + .remove("operation_omission"); + } + if let Some(pending) = semantic.get_mut("pending").and_then(Value::as_object_mut) { + pending.remove("operation_omission"); + } + semantic["base_fingerprint"] = fingerprint.into(); + for workflow in [transition, json!({"Semantic": semantic})] { + let mut snapshot = saved.clone(); + snapshot.workflow = Some(workflow.clone()); + let restored = snapshot.restore().unwrap(); + let current = restored.workflow.unwrap().recovery_snapshot().unwrap(); + let base = oa_core::transitions::project_fingerprint(restored.document.project()); + if current.get("Semantic").is_some() { + assert_eq!(current["Semantic"]["base_fingerprint"], base); + assert_eq!(current["Semantic"]["accepted_impacts"], json!([])); + assert!(current.to_string().contains(" preserved 雨 ")); + } else { + assert_eq!(current["Transition"]["base"], base); + } + let mut bad = saved.clone(); + let mut workflow = workflow; + if workflow.get("Semantic").is_some() { + workflow["Semantic"]["base_fingerprint"] = "wrong base".into(); + } else { + workflow["Transition"]["base"] = "wrong base".into(); + } + bad.workflow = Some(workflow); + assert!(bad.restore().is_err()); + } + } +} + +#[test] +fn source7_legacy_structural_intent_rejects_even_null_but_never_scans_raw_strings() { + for value in [ + json!({"operation":null}), + json!({"contextual":null}), + json!({"operation_omissions":[]}), + json!({"ContextualLink":{}}), + json!({"Relation":"ContextualLink"}), + json!({"kind":"ContextualLink"}), + json!({"tool":"Contextual"}), + json!({"Occurrence":"Operation"}), + json!({"Relation":"ContextOwner"}), + json!({"Relation":{"ContextPurpose":null}}), + ] { + assert!(validate_pre7_intent(&value).is_err(), "{value}"); + } + validate_pre7_intent(&json!({"expression":"{\"operation\":null}", "name":"ContextOwner", "inputs":{"/name":"ContextualLink"}})).unwrap(); + for version in [5, 6] { + let (mut saved, _, _) = profile(version); + saved.behavior = Some(json!({"version":1,"kind":"Scenario","draft":{"operation":null}})); + let directory = tempfile::tempdir().unwrap(); + write(directory.path(), &saved).unwrap(); + let before = fs::read(path(directory.path(), saved.id)).unwrap(); + let error = read(directory.path(), saved.id) + .unwrap() + .restore() + .err() + .unwrap(); + assert!(error.contains("source-7 draft fields")); + assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before); + } +} + +#[test] +fn source7_legacy_inactive_constructor_path_is_structural_but_raw_prose_is_not() { + let (mut saved, _, _) = profile(6); + let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap(); + let call = alias(document.project(), "sa_call"); + let mut draft = Draft::from_project(document.project(), call).unwrap(); + let prose = " /body/ContextualLink/purpose and tool Contextual are literal prose 雨 "; + draft.input("/name", prose.into()); + let mut raw = serde_json::to_value(draft.recovery_snapshot()).unwrap(); + validate_pre7_intent(&raw).unwrap(); + raw["constructors"] = json!([["/body/ContextualLink/purpose", "Precedence", {"Precedence":{"guard":"unfinished &&"}}, {}]]); + let property: RecoveryDraft = serde_json::from_value(raw).unwrap(); + // Current recovery intentionally retains incomplete inactive branches; + // compatibility must reject an impossible historical address beforehand. + assert!(Draft::from_recovery(document.project(), property.clone()).is_ok()); + saved.property = Some(property); + let directory = tempfile::tempdir().unwrap(); + write(directory.path(), &saved).unwrap(); + let before = fs::read(path(directory.path(), saved.id)).unwrap(); + let error = read(directory.path(), saved.id) + .unwrap() + .restore() + .err() + .unwrap(); + assert!(error.contains("source-7 draft fields"), "{error}"); + assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before); + saved.property = Some(draft.recovery_snapshot()); + assert_eq!( + saved.restore().unwrap().property.unwrap().inputs["/name"].raw, + prose + ); +} + +#[test] +fn source7_explicit_new_fields_cannot_enter_frozen_old_model_profiles() { + for version in [5, 6] { + let (_, bytes, _) = profile(version); + let mut model: Value = serde_json::from_slice(bytes).unwrap(); + let call = model["elements"] + .as_object_mut() + .unwrap() + .values_mut() + .find(|e| e["alias"] == "oa_call") + .unwrap(); + let body = &mut call["body"]["Oa"]["Shared"]["Occurrence"]["body"]["Exchange"]; + assert!(body.is_object()); + body["operation"] = Value::Null; + assert!(oa_core::persistence::decode(&serde_json::to_vec(&model).unwrap()).is_err()); + } +} + +#[test] +fn source7_legacy_view_selector_refuses_new_relation_kind_without_rewriting_checkpoint() { + let (mut saved, _, _) = profile(6); + let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap(); + let diagram = *document.project().diagrams.keys().next().unwrap(); + let mut draft = Draft::from_project(document.project(), diagram).unwrap(); + draft.input( + "/name", + " ContextualLink remains ordinary name text 雨 ".into(), + ); + let ordinary = draft.recovery_snapshot(); + validate_pre7_intent(&serde_json::to_value(&ordinary).unwrap()).unwrap(); + draft + .shape( + "/view_options/relation_kinds", + json!({"Only":[{"Relation":"ContextualLink"}]}), + ) + .unwrap(); + let raw = draft.recovery_snapshot(); + // This is a valid current draft: only its claimed historical origin makes + // it impossible. Serde/defaults alone cannot enforce the old enum catalog. + assert!(Draft::from_recovery(document.project(), raw.clone()).is_ok()); + saved.property = Some(raw); + let directory = tempfile::tempdir().unwrap(); + write(directory.path(), &saved).unwrap(); + let before = fs::read(path(directory.path(), saved.id)).unwrap(); + let error = read(directory.path(), saved.id) + .unwrap() + .restore() + .err() + .unwrap(); + assert!(error.contains("source-7 draft fields"), "{error}"); + assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before); + saved.property = Some(ordinary); + let restored = saved.restore().unwrap(); + assert_eq!( + restored.property.unwrap().inputs["/name"].raw, + " ContextualLink remains ordinary name text 雨 " + ); +} + +#[test] +fn source7_current_combined_contextual_and_operation_drafts_survive_without_execution() { + use crate::behavior::{ + Event, + topology::{self, contextual}, + }; + use oa_core::OccurrenceEndpoint; + let (old, _, _) = profile(6); + let document = Document::from_bundle(expected_bundle(&old, 6), LIMITS).unwrap(); + let project = document.project(); + let mut property = Draft::from_project(project, alias(project, "sa_call")).unwrap(); + property + .shape( + "/body/Sa/Shared/Occurrence/body/Exchange/operation", + json!({"allocation":null}), + ) + .unwrap(); + property.input("/name", " pending operation 雨 ".into()); + let mut behavior = crate::behavior::State::default(); + let source = OccurrenceEndpoint { + references: vec![alias(project, "oa_use_field")], + occurrence: alias(project, "oa_repair_step"), + }; + let target = OccurrenceEndpoint { + references: vec![], + occurrence: alias(project, "oa_confirm_step"), + }; + for event in [ + topology::Event::Start( + alias(project, "oa_response"), + topology::Tool::Contextual, + None, + ), + topology::Event::Contextual(contextual::Event::Pick(source.clone())), + topology::Event::Contextual(contextual::Event::Side(true)), + topology::Event::Contextual(contextual::Event::Pick(target.clone())), + topology::Event::Contextual(contextual::Event::Guard("unfinished && 雨".into())), + ] { + behavior.update(project, Event::Topology(event)).unwrap(); + } + let workspace = Workspace::new(document.bundle(), true); + let snapshot = Snapshot::capture( + Id::from_u128(707), + &document, + &workspace, + Some(&property), + &behavior, + None, + project.id, + ) + .unwrap(); + let property_before = serde_json::to_value(&snapshot.property).unwrap(); + let behavior_before = snapshot.behavior.clone(); + let directory = tempfile::tempdir().unwrap(); + write(directory.path(), &snapshot).unwrap(); + let before = fs::read(path(directory.path(), snapshot.id)).unwrap(); + let restored = read(directory.path(), snapshot.id) + .unwrap() + .restore() + .unwrap(); + assert_eq!(restored.document.project(), project); + assert_eq!(restored.document.bundle(), document.bundle()); + assert!(restored.document.compiled().decoded.migration.is_none()); + assert!(!restored.document.can_undo()); + assert_eq!( + serde_json::to_value(restored.property.unwrap().recovery_snapshot()).unwrap(), + property_before + ); + assert_eq!( + restored.behavior.recovery_snapshot().unwrap(), + behavior_before + ); + assert_eq!( + behavior_before.as_ref().unwrap()["draft"]["contextual"]["source"], + json!(source) + ); + assert_eq!( + behavior_before.as_ref().unwrap()["draft"]["contextual"]["target"], + json!(target) + ); + assert_eq!( + fs::read(path(directory.path(), snapshot.id)).unwrap(), + before + ); +} diff --git a/crates/oa-desktop/src/semantic_review.rs b/crates/oa-desktop/src/semantic_review.rs index 1f5ce1b3..414f3ccd 100644 --- a/crates/oa-desktop/src/semantic_review.rs +++ b/crates/oa-desktop/src/semantic_review.rs @@ -29,6 +29,8 @@ mod ordered; mod recovery; mod row; mod scalar; +#[cfg(test)] +mod source7_tests; mod view; pub(crate) use view::summary; #[cfg(test)] @@ -54,6 +56,19 @@ pub enum Tab { History, Impacts, } +impl std::fmt::Display for Tab { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Facts => "Properties", + Self::References => "Reference mapping", + Self::Members => "Contents and order", + Self::Attachments => "Owned elements and relationships", + Self::Artifacts => "Evidence links", + Self::History => "Review history", + Self::Impacts => "Additional changes", + }) + } +} #[derive(Debug, Clone)] pub enum RowEvent { @@ -72,6 +87,8 @@ pub enum RowEvent { ValuePath(String, crate::data_values::FieldPathKey), ValueBrowse(String, Option, usize), ReferenceBrowse(String, String, usize), + OperationOmission(bool), + OperationRationale(String), Tab(Tab), ShowUnchanged(bool), ReferenceOa(Choice), diff --git a/crates/oa-desktop/src/semantic_review/attachments.rs b/crates/oa-desktop/src/semantic_review/attachments.rs index 119d81cc..dd9e9ea1 100644 --- a/crates/oa-desktop/src/semantic_review/attachments.rs +++ b/crates/oa-desktop/src/semantic_review/attachments.rs @@ -104,6 +104,17 @@ pub(super) fn path(project: &Project, facet: &FacetSelector, layer: Layer) -> Op Family::Source | Family::Target if !project.relations.contains_key(&id) => return None, _ => {} } + if project + .relations + .get(&id) + .is_some_and(|r| r.kind() == RelationKind::ContextualLink) + { + return match family(facet)? { + Family::Source => Some("/body/ContextualLink/source/occurrence".into()), + Family::Target => Some("/body/ContextualLink/target/occurrence".into()), + _ => None, + }; + } scalar::binding(project, id, &slot) } pub(super) fn label(facet: &FacetSelector) -> &'static str { diff --git a/crates/oa-desktop/src/semantic_review/attachments_tests.rs b/crates/oa-desktop/src/semantic_review/attachments_tests.rs index c8adb9de..26baf25b 100644 --- a/crates/oa-desktop/src/semantic_review/attachments_tests.rs +++ b/crates/oa-desktop/src/semantic_review/attachments_tests.rs @@ -562,9 +562,14 @@ fn absent_attachment_payloads_migrate_v1_and_v2_and_source5_upgrade_requires_exa ] { if value["version"] == 1 { value.as_object_mut().unwrap().remove("attachments"); + value.as_object_mut().unwrap().remove("operation_omission"); } else { for row in value["rows"].as_array_mut().unwrap() { row["draft"].as_object_mut().unwrap().remove("attachments"); + row["draft"] + .as_object_mut() + .unwrap() + .remove("operation_omission"); } } let restored = State::from_recovery(&f.p, value.clone()).unwrap(); diff --git a/crates/oa-desktop/src/semantic_review/batch.rs b/crates/oa-desktop/src/semantic_review/batch.rs index 0a69684f..cf30f56e 100644 --- a/crates/oa-desktop/src/semantic_review/batch.rs +++ b/crates/oa-desktop/src/semantic_review/batch.rs @@ -668,12 +668,82 @@ impl State { .cloned(); let direction = self.direction; let base = &self.base; + let (source_layer, target_layer) = if direction == TransitionDirection::OaToSa { + ("Operational", "System") + } else { + ("System", "Operational") + }; let mut header = column![ - text(format!("Review semantics · {} included rows", self.included_addresses().len())).size(22), - row![pick_list(available, self.add_trace.clone(), move |choice| message(Event::ChooseTrace(choice))).placeholder("Choose another trace to add or reopen").width(Fill).text_size(12).padding(7), button(text("Open row").size(12)).padding(7).style(crate::style::nav).on_press(message(Event::OpenTrace)), button(text(if direction == TransitionDirection::OaToSa { "OA → SA" } else { "SA → OA" }).size(12)).padding(7).style(crate::style::nav).on_press(message(Event::Direction(if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })))].spacing(7), - row![pick_list(choices, selected, move |choice| message(Event::Focus(RowAddress::from_trace(&base.traces[&choice.id], direction)))).placeholder("Choose a trace / review history").width(Fill).text_size(12).padding(7), checkbox(self.active_address.is_some_and(|address| self.included.contains(&address))).label("Include this row").on_toggle_maybe(self.active_address.map(|address| move |included| message(Event::Include(address, included))))].spacing(10), - text("Switching rows retains every draft. Only included rows contribute to the one combined preview.").size(10).color(crate::style::MUTED), + text("Review counterpart changes").size(22), + text("Compare existing linked OA and SA objects, then choose which values to copy. This is for updating counterparts after editing; creating a counterpart is a separate action.").size(12), + row![ + text(format!("Copy selected changes: {source_layer} → {target_layer}")).size(13), + button(text("Reverse direction").size(12)).padding(7).style(crate::style::nav).on_press(message(Event::Direction(if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa }))), + ].spacing(10), ].spacing(6); + if available.len() > 1 || self.active_address.is_none() { + header = header.push( + row![ + pick_list(available, self.add_trace.clone(), move |choice| message( + Event::ChooseTrace(choice) + )) + .placeholder("Choose a linked object to review…") + .width(Fill) + .text_size(12) + .padding(7), + button(text("Review object").size(12)) + .padding(7) + .style(crate::style::nav) + .on_press(message(Event::OpenTrace)), + ] + .spacing(7), + ); + } + let active: iced::Element<'a, Message> = if choices.len() > 1 { + pick_list(choices, selected, move |choice| { + message(Event::Focus(RowAddress::from_trace( + &base.traces[&choice.id], + direction, + ))) + }) + .placeholder("Choose an object already in this review") + .width(Fill) + .text_size(12) + .padding(7) + .into() + } else if let Some(address) = self.active_address { + text(format!( + "{} → {}", + super::view::choice(base, address.source).label, + super::view::choice(base, address.target).label + )) + .size(13) + .width(Fill) + .into() + } else { + text("Select a linked object above to compare its values.") + .size(13) + .width(Fill) + .into() + }; + header = header.push( + row![ + active, + checkbox( + self.active_address + .is_some_and(|address| self.included.contains(&address)) + ) + .label("Include this object") + .on_toggle_maybe( + self.active_address + .map(|address| move |included| message(Event::Include(address, included))) + ), + ] + .spacing(10), + ); + if self.included_addresses().len() > 1 { + header = header.push(text(format!("{} objects included. Apply saves their selected decisions together; switching objects keeps your drafts.", self.included_addresses().len())).size(11).color(crate::style::MUTED)); + } if let Some(notice) = &self.notice { header = header.push(text(notice).size(11)); } diff --git a/crates/oa-desktop/src/semantic_review/compose.rs b/crates/oa-desktop/src/semantic_review/compose.rs index cb79783d..999afb11 100644 --- a/crates/oa-desktop/src/semantic_review/compose.rs +++ b/crates/oa-desktop/src/semantic_review/compose.rs @@ -6,6 +6,20 @@ use super::*; mod diagnostic_tests; type Slot = (Id, FacetSelector); +fn context_leaf_path(project: &Project, slot: &Slot) -> Option<&'static str> { + if project.relations.get(&slot.0)?.kind() != RelationKind::ContextualLink { + return None; + } + match slot.1 { + FacetSelector::Relation(RelationFacet::Source) => { + Some("/body/ContextualLink/source/occurrence") + } + FacetSelector::Relation(RelationFacet::Target) => { + Some("/body/ContextualLink/target/occurrence") + } + _ => None, + } +} struct Intent { value: Value, row: RowAddress, @@ -47,6 +61,13 @@ fn canonical(facet: &FacetSelector, layer: Layer) -> Option { }) } fn observe(project: &Project, slot: &Slot) -> Result { + if let Some(path) = context_leaf_path(project, slot) { + let value = record_value(project, slot.0)? + .pointer(path) + .cloned() + .ok_or("Qualified endpoint leaf is absent.")?; + return Ok(serde_json::json!({"Value":value})); + } if let Some((_, facet)) = artifacts::parts(&slot.1) { return Ok(artifacts::observation( project.artifact_links.get(&slot.0), @@ -297,10 +318,9 @@ fn write_slot(project: &mut Project, slot: &Slot, observation: &Value) -> Result serde_json::to_value(order).expect("Order"); } else { let path = scalar::binding(project, slot.0, &slot.1) + .or_else(|| context_leaf_path(project, slot).map(str::to_owned)) .ok_or("A selected property was retired by another row's constructor proposal.")?; - *value - .pointer_mut(&path) - .ok_or("Selected property is no longer available.")? = supplied; + crate::forms::set_shape_value(&mut value, &path, supplied)?; } install(project, slot.0, value) } @@ -325,6 +345,22 @@ pub(super) fn request(state: &State) -> Result Result occurrence(OccurrenceBody::Exchange { + operation: None, exchange: None, sender: None, receiver: None, @@ -725,6 +727,7 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl #[test] fn outer_and_nested_constructor_candidates_require_explicit_resolution_and_retain_both_drafts() { let original = occurrence(OccurrenceBody::Exchange { + operation: None, exchange: None, sender: None, receiver: None, @@ -1319,6 +1322,7 @@ fn closed_data_and_event_take_composition_matches_core_across_every_kind_pair_an fn exchange_constructor_takes_qualified_annotation_addresses_without_remapping_expression_text() { for reverse in [false, true] { let message = occurrence(OccurrenceBody::Exchange { + operation: None, exchange: None, sender: None, receiver: None, diff --git a/crates/oa-desktop/src/semantic_review/keyed_rows_tests.rs b/crates/oa-desktop/src/semantic_review/keyed_rows_tests.rs index 47c98054..998cda55 100644 --- a/crates/oa-desktop/src/semantic_review/keyed_rows_tests.rs +++ b/crates/oa-desktop/src/semantic_review/keyed_rows_tests.rs @@ -137,6 +137,7 @@ fn fixture(binding: bool) -> Fixture { }); locals.push(leaf); OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender: None, receiver: None, diff --git a/crates/oa-desktop/src/semantic_review/message_kind_tests.rs b/crates/oa-desktop/src/semantic_review/message_kind_tests.rs index 8664508e..aea518cb 100644 --- a/crates/oa-desktop/src/semantic_review/message_kind_tests.rs +++ b/crates/oa-desktop/src/semantic_review/message_kind_tests.rs @@ -14,6 +14,7 @@ fn fixture(oa_kind: MessageKind, sa_kind: MessageKind) -> (Project, Id, Id, Id) .unwrap(); *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: None, sender: None, receiver: None, @@ -358,6 +359,7 @@ fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_dir .unwrap(); *p.elements.get_mut(&call).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: None, sender: None, receiver: None, diff --git a/crates/oa-desktop/src/semantic_review/recovery.rs b/crates/oa-desktop/src/semantic_review/recovery.rs index d449ef16..12336af7 100644 --- a/crates/oa-desktop/src/semantic_review/recovery.rs +++ b/crates/oa-desktop/src/semantic_review/recovery.rs @@ -35,6 +35,8 @@ pub(super) struct Saved { attachments: attachments::Saved, #[serde(default)] artifacts: artifacts::Saved, + #[serde(default)] + operation_omission: Option, reference_oa: Option, reference_sa: Option, reference_search: String, @@ -68,6 +70,8 @@ struct RowPayload { attachments: attachments::Saved, #[serde(default)] artifacts: artifacts::Saved, + #[serde(default)] + operation_omission: Option, reference_oa: Option, reference_sa: Option, reference_search: String, @@ -120,6 +124,7 @@ impl RowPayload { orders: saved.orders, attachments: saved.attachments, artifacts: saved.artifacts, + operation_omission: saved.operation_omission, reference_oa: saved.reference_oa, reference_sa: saved.reference_sa, reference_search: saved.reference_search, @@ -160,6 +165,7 @@ impl RowPayload { orders: self.orders, attachments: self.attachments, artifacts: self.artifacts, + operation_omission: self.operation_omission, reference_oa: self.reference_oa, reference_sa: self.reference_sa, reference_search: self.reference_search, @@ -234,6 +240,7 @@ impl ReviewRow { .collect(), attachments: self.attachments.recovery_snapshot(), artifacts: self.artifacts.recovery_snapshot(), + operation_omission: self.operation_omission.clone(), reference_oa: self.reference_oa.as_ref().map(|c| c.id), orders: self .order_drafts @@ -378,6 +385,7 @@ impl ReviewRow { return Err("Recovered artifact drafts need an exact owner trace.".into()); } state.reference_sa = saved.reference_sa.map(|id| view::choice(project, id)); + state.operation_omission = saved.operation_omission; state.reference_search = saved.reference_search; state.accepted_impacts = saved.accepted_impacts; // Invalid numbers and incomplete mappings are recoverable authored input. @@ -414,7 +422,7 @@ fn batch_limits(value: &Value) -> Result<(), String> { } impl State { - /// A source-5 upgrade may reconstruct the same accepted records with a new + /// A source-5/6 upgrade may reconstruct the same accepted records with a new /// schema fingerprint. The caller supplies the independently verified legacy /// base; every saved row is still decoded and replanned through strict restore. pub(crate) fn from_recovery_after_source5_upgrade( @@ -423,13 +431,13 @@ impl State { value: Value, ) -> Result { batch_limits(&value)?; + crate::recovery::validate_pre7_intent(&value)?; let value = match value.get("version").and_then(Value::as_u64) { Some(1) => { let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?; if saved.base_identity != project.id || saved.base_fingerprint != legacy_base { return Err( - "Semantic source-5 recovery does not match the verified legacy base." - .into(), + "Semantic legacy recovery does not match the verified legacy base.".into(), ); } saved.base_fingerprint = project_fingerprint(project); @@ -441,8 +449,7 @@ impl State { serde_json::from_value(value).map_err(|e| e.to_string())?; if saved.base_identity != project.id || saved.base_fingerprint != legacy_base { return Err( - "Semantic source-5 recovery does not match the verified legacy base." - .into(), + "Semantic legacy recovery does not match the verified legacy base.".into(), ); } saved.base_fingerprint = project_fingerprint(project); diff --git a/crates/oa-desktop/src/semantic_review/row.rs b/crates/oa-desktop/src/semantic_review/row.rs index 8b874549..092922cf 100644 --- a/crates/oa-desktop/src/semantic_review/row.rs +++ b/crates/oa-desktop/src/semantic_review/row.rs @@ -11,6 +11,7 @@ pub struct ReviewRow { pub selected: Option, pub decisions: BTreeMap, pub references: BTreeMap, + pub operation_omission: Option, pub resets: BTreeSet, pub staged: BTreeSet, pub presence: BTreeMap, @@ -73,6 +74,7 @@ impl ReviewRow { selected: None, decisions: BTreeMap::new(), references: BTreeMap::new(), + operation_omission: None, resets: BTreeSet::new(), staged: BTreeSet::new(), presence: BTreeMap::new(), @@ -133,6 +135,7 @@ impl ReviewRow { self.selected = None; self.accepted_impacts.clear(); self.references.clear(); + self.operation_omission = None; self.draft = self .target(project) .and_then(|id| Draft::from_project(project, id)); @@ -163,6 +166,15 @@ impl ReviewRow { pub fn update(&mut self, project: &Project, event: RowEvent) { let mut replan = true; let result = match event { + RowEvent::OperationOmission(enabled) => { + self.operation_omission = + enabled.then(|| self.operation_omission.clone().unwrap_or_default()); + Ok(()) + } + RowEvent::OperationRationale(raw) => { + self.operation_omission = Some(raw); + Ok(()) + } #[cfg(test)] RowEvent::Trace(choice) => { if !project @@ -734,9 +746,7 @@ impl ReviewRow { let fact = capture_facet(project, trace.oa, trace.sa, facet)?; let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?; let mut supplied = original.clone(); - *supplied - .pointer_mut(&roots[0]) - .ok_or("Source property is unavailable.")? = value; + crate::forms::set_shape_value(&mut supplied, &roots[0], value)?; supplied } }; @@ -744,11 +754,15 @@ impl ReviewRow { let value = supplied .pointer(&path) .cloned() + .or_else(|| { + (matches!(facet, FacetSelector::Occurrence(OccurrenceFacet::Operation))) + .then_some(Value::Null) + }) .ok_or("Candidate property is no longer available.")?; if scalar::grouped(facet) && *decision == SemanticDecision::Candidate { group_candidates.push((path.clone(), value.clone())); } - *final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value; + crate::forms::set_shape_value(&mut final_value, &path, value)?; } } if !form_context { diff --git a/crates/oa-desktop/src/semantic_review/scalar.rs b/crates/oa-desktop/src/semantic_review/scalar.rs index a35b01af..54bec26d 100644 --- a/crates/oa-desktop/src/semantic_review/scalar.rs +++ b/crates/oa-desktop/src/semantic_review/scalar.rs @@ -16,7 +16,7 @@ fn enum_tag(value: &Value) -> Option<&str> { } impl Own { fn get(project: &Project, id: Id) -> Option { - let (whole, element) = if let Some(e) = project.elements.get(&id) { + let (mut whole, element) = if let Some(e) = project.elements.get(&id) { (serde_json::to_value(e).ok()?, true) } else { ( @@ -24,6 +24,35 @@ impl Own { false, ) }; + if project.elements.get(&id).is_some_and(|e| { + e.layer() == Layer::Sa + && matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + message_kind: MessageKind::Call | MessageKind::Asynchronous, + .. + }, + .. + }) + ) + }) && validation::ancestor_of_kind(project, id, Kind::Scenario) + .and_then(|id| project.elements.get(&id)) + .is_some_and(|e| { + matches!( + e.shared(), + Some(SharedBody::Scenario { + viewpoint: ScenarioViewpoint::BoundaryInterface, + .. + }) + ) + }) + && let Some(fields) = whole + .pointer_mut("/body/Sa/Shared/Occurrence/body/Exchange") + .and_then(Value::as_object_mut) + { + fields.entry("operation").or_insert(Value::Null); + } let mut prefix = vec!["body".to_string()]; let mut value = &whole["body"]; if element { @@ -114,6 +143,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option> { F::Occurrence(OccurrenceFacet::ReplyTo) => { ("Occurrence", &["body", "Exchange", "reply_to"]) } + F::Occurrence(OccurrenceFacet::Operation) => { + ("Occurrence", &["body", "Exchange", "operation"]) + } F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]), F::Occurrence(OccurrenceFacet::ActionLifeline) => { ("Occurrence", &["body", "Action", "lifeline"]) @@ -142,8 +174,12 @@ fn path(own: &Own, facet: &FacetSelector) -> Option> { F::State(StateFacet::Exit) => ("State", &["exit"]), F::State(StateFacet::DoActivity) => ("State", &["do_activity"]), F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]), - F::Relation(RelationFacet::Source) => ("*", &["source"]), - F::Relation(RelationFacet::Target) => ("*", &["target"]), + F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]), + F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]), + F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]), + F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]), + F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]), + F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]), F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]), F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]), F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]), diff --git a/crates/oa-desktop/src/semantic_review/source7_tests.rs b/crates/oa-desktop/src/semantic_review/source7_tests.rs new file mode 100644 index 00000000..e75a5d2d --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/source7_tests.rs @@ -0,0 +1,350 @@ +//! Source7 desktop composition falsifiers, using the public planner as final oracle. +use super::*; + +fn operation_fixture() -> Project { + persistence::decode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + ))) + .unwrap() +} +fn operation_id(p: &Project, n: u32) -> Id { + let alias = format!("operation_probe_{n}"); + p.elements + .values() + .find(|e| e.alias == alias) + .map(|e| e.id) + .or_else(|| { + p.relations + .values() + .find(|r| r.alias == alias) + .map(|r| r.id) + }) + .unwrap() +} + +fn send(state: &mut State, p: &Project, event: RowEvent) { + state.update(p, state.address_event(event)); +} + +fn occurrence(p: &mut Project, layer: Layer, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let mut element = p + .make_element(Kind::Occurrence, layer, name, Some(owner)) + .unwrap(); + *element.shared_mut().unwrap() = SharedBody::Occurrence { + precondition: String::new(), + postcondition: String::new(), + body, + }; + let id = element.id; + *p = execute_batch(p, &[Command::CreateElement(element)]).unwrap(); + id +} + +#[test] +fn contextual_incident_leaf_keep_allows_path_change_but_conflicts_with_changed_leaf_both_directions() + { + let mut p = Project::empty(); + let mut layer_ids = Vec::new(); + for layer in [Layer::Oa, Layer::Sa] { + let kind = if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }; + let owner = p.add_element(kind, layer, "Caller", None).unwrap(); + let definition = p.add_element(kind, layer, "Reusable checks", None).unwrap(); + let a = occurrence( + &mut p, + layer, + definition, + "First check", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let b = occurrence( + &mut p, + layer, + definition, + "Second check", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let r1 = occurrence( + &mut p, + layer, + owner, + "First use", + OccurrenceBody::SequenceReference { + sequence: Some(definition), + }, + ); + let r2 = occurrence( + &mut p, + layer, + owner, + "Second use", + OccurrenceBody::SequenceReference { + sequence: Some(definition), + }, + ); + let id = Id::new_v4(); + let relation = Relation { + id, + alias: p.fresh_alias("caller_constraint", "relation", id), + name: "Caller constraint".into(), + description: String::new(), + body: RelationBody::ContextualLink { + owner, + source: OccurrenceEndpoint { + references: vec![r1], + occurrence: a, + }, + target: OccurrenceEndpoint { + references: vec![r2], + occurrence: b, + }, + purpose: ContextualLinkPurpose::Precedence { + guard: String::new(), + }, + }, + }; + p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap(); + layer_ids.push([owner, definition, a, b, r1, r2, id]); + } + let attached_trace = tests::add_trace( + &mut p, + layer_ids[0][2], + layer_ids[1][2], + "Incident leaf review", + ); + let direct_trace = tests::add_trace( + &mut p, + layer_ids[0][6], + layer_ids[1][6], + "Qualified path review", + ); + let incident = FacetSelector::Incident { + keys: KeyPair { + oa: Some(layer_ids[0][6]), + sa: Some(layer_ids[1][6]), + }, + end: IncidentEnd::Source, + }; + let direct = FacetSelector::Relation(RelationFacet::ContextSource); + for reverse in [false, true] { + let target = usize::from(!reverse); + let mut state = State::new(&p, attached_trace).unwrap(); + if reverse { + state.update(&p, Event::Direction(TransitionDirection::SaToOa)); + } + send(&mut state, &p, RowEvent::EditPair(incident.clone())); + send( + &mut state, + &p, + RowEvent::Attachment(attachments::Event::Stage), + ); + send( + &mut state, + &p, + RowEvent::Decide(incident.clone(), Some(SemanticDecision::KeepTarget)), + ); + state.update(&p, Event::ChooseTrace(view::choice(&p, direct_trace))); + state.update(&p, Event::OpenTrace); + for (&oa, &sa) in layer_ids[0].iter().zip(&layer_ids[1]).take(7) { + state.references.insert(oa, sa); + } + state.refresh(&p); + send( + &mut state, + &p, + RowEvent::Decide(direct.clone(), Some(SemanticDecision::Candidate)), + ); + send(&mut state, &p, RowEvent::Select(direct.clone())); + let endpoint = OccurrenceEndpoint { + references: vec![layer_ids[target][5]], + occurrence: layer_ids[target][2], + }; + send( + &mut state, + &p, + RowEvent::Shape( + "/body/ContextualLink/source".into(), + serde_json::to_value(&endpoint).unwrap(), + ), + ); + assert!(state.can_apply(), "{:?}", state.error); + let expected = state.proposed.as_ref().unwrap().clone(); + assert!( + matches!(&expected.relations[&layer_ids[target][6]].body, RelationBody::ContextualLink { source, .. } if *source == endpoint) + ); + assert_eq!( + execute_batch(&p, &[state.command(&p).unwrap()]).unwrap(), + expected + ); + let saved = state.recovery_snapshot().unwrap(); + let mut restored = State::from_recovery(&p, saved.clone()).unwrap(); + assert_eq!(restored.recovery_snapshot().unwrap(), saved); + assert!(restored.can_apply()); + let conflicting = OccurrenceEndpoint { + references: vec![layer_ids[target][4]], + occurrence: layer_ids[target][3], + }; + send( + &mut restored, + &p, + RowEvent::Shape( + "/body/ContextualLink/source".into(), + serde_json::to_value(conflicting).unwrap(), + ), + ); + assert!(!restored.can_apply()); + assert!( + restored + .error + .as_deref() + .unwrap_or_default() + .contains("Independent row proposals disagree"), + "{:?}", + restored.error + ); + assert!(restored.command(&p).is_err()); + } +} + +#[test] +fn semantic_operation_virtual_none_candidate_uses_exact_allocation_and_preserves_accepted_bytes() { + let mut p = operation_fixture(); + let call = p + .elements + .values() + .find(|e| e.alias == "operation_probe_43") + .unwrap() + .id; + let allocation = p + .relations + .values() + .find(|r| r.alias == "operation_probe_30") + .unwrap() + .id; + let owner = p + .add_element(Kind::Scenario, Layer::Oa, "Operational conversation", None) + .unwrap(); + let oa = occurrence( + &mut p, + Layer::Oa, + owner, + "Operational request", + OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Call, + reply_to: None, + operation: None, + values: vec![], + }, + ); + let trace = tests::add_trace(&mut p, oa, call, "Service rationale"); + let before = serde_json::to_value(&p).unwrap(); + let facet = FacetSelector::Occurrence(OccurrenceFacet::Operation); + let path = scalar::binding(&p, call, &facet).unwrap(); + assert!( + serde_json::to_value(&p.elements[&call]) + .unwrap() + .pointer(&path) + .is_none() + ); + let mut state = State::new(&p, trace).unwrap(); + send( + &mut state, + &p, + RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)), + ); + send(&mut state, &p, RowEvent::Select(facet)); + send( + &mut state, + &p, + RowEvent::Shape(path, serde_json::json!({"allocation":allocation})), + ); + assert!(state.can_apply(), "{:?}", state.error); + let actual = state.proposed.as_ref().unwrap(); + assert!( + matches!(oa_core::operations::resolve(actual, call).unwrap(), oa_core::operations::OperationResolution::Selected(value) if value.allocation == allocation) + ); + assert_eq!(serde_json::to_value(&p).unwrap(), before); + let saved = state.recovery_snapshot().unwrap(); + let restored = State::from_recovery(&p, saved.clone()).unwrap(); + assert_eq!(restored.recovery_snapshot().unwrap(), saved); + assert_eq!(restored.proposed, state.proposed); +} + +#[test] +fn reverse_operation_omission_gui_requires_raw_rationale_and_matches_one_core_batch_after_recovery() +{ + use crate::workflow::{Event as W, Workflow}; + let p = operation_fixture(); + let call = operation_id(&p, 43); + let mut message = p.elements[&call].clone(); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = message.shared_mut() + else { + panic!() + }; + *operation = Some(OperationUse { + allocation: Some(operation_id(&p, 30)), + }); + let p = execute_batch(&p, &[Command::EditElement(call, message)]).unwrap(); + let mut workflow = Workflow::transition(&p, operation_id(&p, 40)).unwrap(); + assert!(workflow.update(&p, W::Apply).is_err()); + workflow + .update(&p, W::OperationOmission(call, true)) + .unwrap(); + workflow + .update(&p, W::OperationRationale(call, " \n ".into())) + .unwrap(); + let saved = workflow.recovery_snapshot().unwrap(); + let mut workflow = Workflow::from_recovery(&p, saved.clone()).unwrap(); + assert_eq!(workflow.recovery_snapshot().unwrap(), saved); + assert!(workflow.update(&p, W::Apply).is_err()); + for (n, kind) in [(10, Kind::Actor), (11, Kind::Entity)] { + workflow + .update(&p, W::MappingKind(operation_id(&p, n), kind)) + .unwrap(); + } + let rationale = " The operational conversation abstracts the technical service signature. "; + workflow + .update(&p, W::OperationRationale(call, rationale.into())) + .unwrap(); + let Workflow::Transition(draft) = &workflow else { + panic!() + }; + assert_eq!(draft.request.operation_omissions[0].rationale, rationale); + assert!( + draft.plan.conflicts.is_empty(), + "{:?}", + draft.plan.conflicts + ); + let expected = execute_batch(&p, &draft.plan.commands).unwrap(); + let target = draft + .plan + .changes + .iter() + .find(|c| c.source == call) + .unwrap() + .target; + let actual = workflow.update(&p, W::Apply).unwrap().unwrap(); + assert_eq!( + actual, expected, + "Behavior root adds no implicit architectural placements" + ); + assert!(matches!( + oa_core::operations::resolve(&actual, target).unwrap(), + oa_core::operations::OperationResolution::Ordinary + )); + assert_eq!(actual.elements[&call], p.elements[&call]); +} diff --git a/crates/oa-desktop/src/semantic_review/tests.rs b/crates/oa-desktop/src/semantic_review/tests.rs index ed28d9f8..7439376e 100644 --- a/crates/oa-desktop/src/semantic_review/tests.rs +++ b/crates/oa-desktop/src/semantic_review/tests.rs @@ -919,6 +919,7 @@ fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approva .unwrap(); *p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { + operation: None, exchange: Some(exchange), sender: None, receiver: None, diff --git a/crates/oa-desktop/src/semantic_review/view.rs b/crates/oa-desktop/src/semantic_review/view.rs index 70b8c43e..19962619 100644 --- a/crates/oa-desktop/src/semantic_review/view.rs +++ b/crates/oa-desktop/src/semantic_review/view.rs @@ -278,32 +278,22 @@ impl ReviewRow { let mut content = column![ header, row![ - action("Properties", RowEvent::Tab(Tab::Facts)), - action("Members / rows", RowEvent::Tab(Tab::Members)), - action("Attachments", RowEvent::Tab(Tab::Attachments)), - action("Artifact links", RowEvent::Tab(Tab::Artifacts)), - action( - format!( - "History ({}) · {} resets", - trace - .and_then(|t| t.baseline.as_ref()) - .and_then(|b| b.semantic.as_ref()) - .map_or(0, |b| b.facets.len()), - self.resets.len() - ), - RowEvent::Tab(Tab::History) - ), - action( - format!("Reference pairs ({})", self.references.len()), - RowEvent::Tab(Tab::References) - ), - action( - format!( - "Additional changes ({})", - self.plan.as_ref().map_or(0, |p| p.impacts.len()) - ), - RowEvent::Tab(Tab::Impacts) - ), + text("Review area").size(12), + pick_list( + vec![ + Tab::Facts, + Tab::Members, + Tab::Attachments, + Tab::Artifacts, + Tab::History, + Tab::References, + Tab::Impacts, + ], + Some(self.tab), + |tab| event(RowEvent::Tab(tab)), + ) + .text_size(12) + .padding(7), Space::new().width(Fill), action("Refresh preview", RowEvent::Preview) ] @@ -311,6 +301,42 @@ impl ReviewRow { .wrap(), ] .spacing(8); + let impacts = self.plan.as_ref().map_or(0, |plan| plan.impacts.len()); + if impacts > 0 && self.tab != Tab::Impacts { + content = content.push(action( + format!("View {impacts} additional changes"), + RowEvent::Tab(Tab::Impacts), + )); + } + if self.direction == TransitionDirection::SaToOa + && trace.is_some_and(|trace| { + matches!( + project.elements.get(&trace.sa).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + operation: Some(_), + message_kind: MessageKind::Call | MessageKind::Asynchronous, + .. + }, + .. + }) + ) + }) + { + content = content.push( + checkbox(self.operation_omission.is_some()) + .label("Explicitly omit the technical operation in the OA counterpart") + .on_toggle(|enabled| event(RowEvent::OperationOmission(enabled))), + ); + if let Some(raw) = &self.operation_omission { + content = content.push( + text_input("Required engineering rationale", raw) + .on_input(|raw| event(RowEvent::OperationRationale(raw))) + .padding(8) + .size(12), + ); + } + } let body = match self.tab { Tab::Facts => self.facts_view(project), Tab::References => self.references_view(project), diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index e15d6179..ff6b762e 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -1,3 +1,4 @@ +pub(crate) mod traceability; use crate::{ app::{App, Choice, FileOperation, Message, Page, Pending}, diagram::DiagramCanvas, @@ -192,9 +193,11 @@ pub fn view(app: &App) -> View<'_, Message> { ] .spacing(3), ) - .push(crate::explorer_view::sidebar(app)) - .push(creation) - .push(action("Project properties", Message::SelectProject).width(Fill)); + .push(crate::explorer_view::sidebar(app)); + if app.page != Page::Diagram { + nav = nav.push(creation); + } + nav = nav.push(action("Project properties", Message::SelectProject).width(Fill)); let sidebar = container(if app.page == Page::Text { source_files(app) } else { @@ -224,12 +227,33 @@ pub fn view(app: &App) -> View<'_, Message> { Page::Validation => checks(app), Page::Guide => crate::guide::view(&app.guide), }; + let process_layout = app.topology_context().is_some(); + let focus_process = process_layout && app.behavior.topology.focused; let mut body = if app.page == Page::Home { row![container(main).width(Fill).height(Fill)].height(Fill) + } else if process_layout { + // Keep main at the same widget-tree path across Focus toggles. The + // collapsed slot has no controls, so it cannot paint over or hit the canvas. + let process_sidebar: View<'_, Message> = if focus_process { + Space::new().width(0).into() + } else { + sidebar.into() + }; + // Row::push would discard the zero-width slot and shift main again. + widget::Row::from_vec(vec![ + container(process_sidebar) + .width(if focus_process { 0 } else { 260 }) + .height(Fill) + .clip(true) + .into(), + container(main).width(Fill).height(Fill).into(), + ]) + .width(Fill) + .height(Fill) } else { row![sidebar, container(main).width(Fill).height(Fill)].height(Fill) }; - if !matches!(app.page, Page::Home | Page::Text | Page::Guide) { + if !focus_process && !matches!(app.page, Page::Home | Page::Text | Page::Guide) { body = body.push(inspector(app)); } let footer = container( @@ -259,6 +283,13 @@ pub fn view(app: &App) -> View<'_, Message> { .padding([4, 16]) .style(style::panel); let root: View<'_, Message> = column![header, task_nav, body, footer].into(); + if app.architecture.as_ref().is_some_and(|s| s.modal()) { + return modal_width( + root, + crate::app::architecture_authoring::dialog(app), + (app.window_size.width - 50.).clamp(500., 820.), + ); + } if let Some(workflow) = &app.workflow { if matches!(workflow, crate::workflow::Workflow::Semantic(_)) { return modal_width( @@ -345,23 +376,17 @@ fn modal_width<'a>( .into() } fn diagram(app: &App) -> View<'_, Message> { - let diagrams = app + let mut diagrams = app .project() .diagrams .values() .filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability) .map(|d| Choice { id: d.id, - label: short( - &d.name, - if app.window_size.width < 1250. { - 26 - } else { - 48 - }, - ), + label: d.name.clone(), }) .collect::>(); + diagrams.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id))); let selected = diagrams .iter() .find(|c| Some(c.id) == app.active_diagram) @@ -427,16 +452,26 @@ fn diagram(app: &App) -> View<'_, Message> { .filter(|(edge, _)| *edge == app.selected) .map(|(_, i)| i), editable: !app.draft_dirty() && !app.source_dirty(), + placing: app.architecture.as_ref().is_some_and(|s| s.saved.placing), connection: app.connection.as_ref(), }; - widget::sensor(widget::stack![ + let mut scene = widget::stack![ widget::canvas(program).width(Fill).height(Fill), widget::canvas(crate::diagram::PortLabels(program)) .width(Fill) .height(Fill), - ]) - .on_show(Message::CanvasResized) - .on_resize(Message::CanvasResized) + ]; + if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) { + scene = scene.push(editor); + } + row![ + widget::sensor(scene) + .on_show(Message::CanvasResized) + .on_resize(Message::CanvasResized), + crate::app::architecture_authoring::palette(app) + ] + .height(Fill) + .width(Fill) .into() } else { container( @@ -459,20 +494,10 @@ fn diagram(app: &App) -> View<'_, Message> { .center(Fill) .into() }; - let choose = |tool: Option| { - let active = app.connection.as_ref().map(|s| s.tool) == tool; - button(text(tool.map_or("Select / move", |t| t.label())).size(12)) - .padding([8, 10]) - .on_press(Message::Connection(crate::connection_edit::Event::Choose( - tool, - ))) - .style(if active { button::primary } else { style::nav }) - }; - let mut palette = row![choose(None)].spacing(4); - for tool in crate::connection_tool::Tool::for_layer(app.layer) { - palette = palette.push(choose(Some(*tool))); + let mut tools = column![toolbar].spacing(8); + if app.active_diagram.is_some() { + tools = tools.push(modes); } - let mut tools = column![toolbar, modes, palette].spacing(8); if let Some(diagram) = app.active_diagram { let view = oa_core::projection::project_view(app.project(), diagram); let edge = view.edges.iter().find(|edge| edge.id == app.selected); @@ -726,12 +751,22 @@ fn inspector(app: &App) -> View<'_, Message> { ); } if let Some(r) = p.relations.get(&app.selected) { - body = body.push(text(r.kind().to_string()).size(17)).push( - pick_list(RelationKind::ALL, Some(r.kind()), Message::RelationKind) + body = body.push(text(r.kind().to_string()).size(17)); + if r.kind() != RelationKind::ContextualLink { + body = body.push( + pick_list( + RelationKind::ALL + .into_iter() + .filter(|kind| *kind != RelationKind::ContextualLink) + .collect::>(), + Some(r.kind()), + Message::RelationKind, + ) .text_size(12) .padding(8) .width(Fill), - ); + ); + } } body = body.push( row![ @@ -771,27 +806,81 @@ fn data_catalog(app: &App) -> View<'_, Message> { fn traces(app: &App) -> View<'_, Message> { let p = app.project(); - let mut out = column![ + use traceability::{Event as E, Filter}; + let (traces, artifacts) = app.traceability.matching(p); + let mut tabs = row![].spacing(6); + for (filter, label) in [ + ( + Filter::All, + format!("All ({})", p.traces.len() + p.artifact_links.len()), + ), + (Filter::Traces, format!("Layer traces ({})", p.traces.len())), + ( + Filter::Artifacts, + format!("Development artifacts ({})", p.artifact_links.len()), + ), + ] { + tabs = tabs.push( + button(text(label).size(12)) + .style(if app.traceability.filter == filter { + button::primary + } else { + style::nav + }) + .on_press(Message::Traceability(E::Filter(filter))), + ); + } + let header = column![ heading( "Traceability", "Connect operational needs, system responsibilities and development evidence." ), row![ - button(text("Create counterpart / review prose…").size(12)) - .padding(9) - .on_press(Message::StartTransition), + button( + text( + if p.layer_of(app.selected).unwrap_or(app.layer) == Layer::Oa { + "Create System counterpart…" + } else { + "Create Operational counterpart…" + } + ) + .size(12) + ) + .padding(9) + .on_press(Message::StartTransition), action("+ Manual trace", Message::AddTrace), action( - "Review semantics…", + "Review counterpart changes…", Message::StartSemanticReview(app.selected) ), action("+ Artifact", Message::AddArtifact) ] .spacing(8) - .wrap() + .wrap(), + tabs.wrap(), + row![ + text_input( + "Search names, aliases, owners or artifact URI…", + &app.traceability.query + ) + .on_input(|raw| Message::Traceability(E::Search(raw))) + .padding(8) + .size(12) + .width(Fill), + button("Clear search").on_press(Message::Traceability(E::Search(String::new()))) + ] + .spacing(6), + label(format!( + "{} matching traces · {} matching artifacts", + traces.len(), + artifacts.len() + )) ] - .spacing(16); - for t in p.traces.values() { + .spacing(10) + .padding(24); + let mut out = column![].spacing(16); + for id in &traces { + let t = &p.traces[id]; let current = match (&t.baseline, p.properties(t.oa), p.properties(t.sa)) { (Some(b), Some(oa), Some(sa)) if b.oa == oa && b.sa == sa => "Reviewed", (Some(_), _, _) => "Review needed", @@ -810,7 +899,10 @@ fn traces(app: &App) -> View<'_, Message> { row![ label(format!("{:?} · Prose: {current}", t.kind)), Space::new().width(Fill), - action("Review semantics…", Message::StartSemanticReview(t.id)), + action( + "Review counterpart changes…", + Message::StartSemanticReview(t.id) + ), action("Edit link", Message::Select(t.id)) ], text(crate::semantic_review::summary(p, t)) @@ -824,17 +916,12 @@ fn traces(app: &App) -> View<'_, Message> { .style(style::card), ); } - out = out.push(text("Development artifacts").size(18)); - for a in p.artifact_links.values() { - let owner = p - .diagrams - .values() - .find_map(|d| { - d.nodes - .get(&a.owner) - .map(|n| format!("Placement of {} · {}", p.named(n.element), d.name)) - }) - .unwrap_or_else(|| p.named(a.owner).to_owned()); + if !artifacts.is_empty() { + out = out.push(text("Development artifacts").size(18)); + } + for id in &artifacts { + let a = &p.artifact_links[id]; + let owner = traceability::owner_label(p, a.owner); out = out.push( container( column![ @@ -860,8 +947,18 @@ fn traces(app: &App) -> View<'_, Message> { } if p.traces.is_empty() && p.artifact_links.is_empty() { out=out.push(text("Select a model element, then connect it to the other perspective or attach a code, CAD, schematic or document reference.").size(14).color(style::MUTED)); + } else if traces.is_empty() && artifacts.is_empty() { + out = out + .push(text("No matching records. Clear the search or choose another filter.").size(13)); } - scrollable(out.padding(24)).into() + column![ + header, + scrollable(out.padding(iced::Padding::from(24).right(42))) + .id("traceability-list") + .height(Fill) + ] + .height(Fill) + .into() } fn source_files(app: &App) -> View<'_, Message> { diff --git a/crates/oa-desktop/src/views/traceability.rs b/crates/oa-desktop/src/views/traceability.rs new file mode 100644 index 00000000..713dd7ef --- /dev/null +++ b/crates/oa-desktop/src/views/traceability.rs @@ -0,0 +1,215 @@ +//! Transient browsing over the complete trace/artifact inventory. +use oa_core::{Id, Project}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum Filter { + #[default] + All, + Traces, + Artifacts, +} +#[derive(Debug, Clone)] +pub(crate) enum Event { + Filter(Filter), + Search(String), +} +#[derive(Default)] +pub(crate) struct State { + pub filter: Filter, + pub query: String, +} +impl State { + pub fn update(&mut self, event: Event) { + match event { + Event::Filter(filter) => self.filter = filter, + Event::Search(query) => self.query = query, + } + } + pub fn matching(&self, p: &Project) -> (Vec, Vec) { + let query = self.query.trim().to_lowercase(); + let aliases: std::collections::BTreeMap<_, _> = p.identities().into_iter().collect(); + let identity = |id| { + format!( + "{} {} {id}", + p.named(id), + aliases.get(&id).copied().unwrap_or_default() + ) + }; + let mut traces = if self.filter == Filter::Artifacts { + vec![] + } else { + p.traces + .values() + .filter(|t| { + format!( + "{} {} {} {} {}", + t.name, + t.alias, + t.description, + identity(t.oa), + identity(t.sa) + ) + .to_lowercase() + .contains(&query) + }) + .map(|t| t.id) + .collect::>() + }; + let mut artifacts = if self.filter == Filter::Traces { + vec![] + } else { + p.artifact_links + .values() + .filter(|a| { + format!( + "{} {} {} {} {} {} {} {} {:?}", + a.name, + a.alias, + a.description, + a.uri, + a.revision.as_deref().unwrap_or_default(), + a.fragment.as_deref().unwrap_or_default(), + identity(a.owner), + owner_label(p, a.owner), + a.relation + ) + .to_lowercase() + .contains(&query) + }) + .map(|a| a.id) + .collect::>() + }; + traces.sort_by(|a, b| p.traces[a].name.cmp(&p.traces[b].name).then(a.cmp(b))); + artifacts.sort_by(|a, b| { + p.artifact_links[a] + .name + .cmp(&p.artifact_links[b].name) + .then(a.cmp(b)) + }); + (traces, artifacts) + } +} +pub(super) fn owner_label(p: &Project, owner: Id) -> String { + p.diagrams + .values() + .find_map(|d| { + d.nodes + .get(&owner) + .map(|node| format!("Placement of {} · {}", p.named(node.element), d.name)) + }) + .unwrap_or_else(|| p.named(owner).to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{App, Message}; + use oa_core::*; + + fn fixture() -> (Project, Id, [Id; 3]) { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::Activity, Layer::Oa, "Rescue dispatch", None) + .unwrap(); + let sa = p + .add_element(Kind::Function, Layer::Sa, "Dispatch controller", None) + .unwrap(); + for n in 0..68 { + let target = if n == 0 { + sa + } else { + p.add_element( + Kind::Function, + Layer::Sa, + &format!("Dispatch function {n:02}"), + None, + ) + .unwrap() + }; + let id = Id::new_v4(); + p.traces.insert( + id, + Trace { + id, + alias: format!("review_{n}"), + name: format!("Review {n:02}"), + description: String::new(), + oa, + sa: target, + kind: TraceKind::Manual, + baseline: None, + }, + ); + } + let ids = [Id::from_u128(502), Id::from_u128(501), Id::from_u128(503)]; + for (index, id) in ids.iter().copied().enumerate() { + p.artifact_links.insert( + id, + ArtifactLink { + id, + alias: format!("evidence_{index}"), + name: if index == 2 { + "Zulu schematic" + } else { + "Acceptance evidence" + } + .into(), + description: "Engineering evidence".into(), + owner: oa, + relation: ArtifactRelation::References, + uri: format!("./evidence/document_{index}.md"), + revision: Some(format!("revision-{index}")), + fragment: None, + }, + ); + } + validate_candidate(&p).unwrap(); + (p, oa, ids) + } + #[test] + fn traceability_filter_reaches_artifacts_after_68_traces_and_searches_all_metadata() { + let (p, _, ids) = fixture(); + let mut state = State::default(); + assert_eq!(state.matching(&p).0.len(), 68); + state.update(Event::Filter(Filter::Artifacts)); + assert_eq!(state.matching(&p), (vec![], vec![ids[1], ids[0], ids[2]])); + for query in ["document_2", "evidence_2", "revision-2", "Zulu schematic"] { + state.update(Event::Search(query.into())); + assert_eq!(state.matching(&p), (vec![], vec![ids[2]])); + } + state.update(Event::Search("Rescue dispatch".into())); + assert_eq!(state.matching(&p).1.len(), 3); + state.update(Event::Filter(Filter::Traces)); + assert_eq!(state.matching(&p).0.len(), 68); + assert!(state.matching(&p).1.is_empty()); + } + #[test] + fn traceability_browsing_retains_invalid_property_draft_and_existing_select_guard() { + let (p, owner, ids) = fixture(); + let mut app = App::with_project(p); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Field("/alias".into(), "invalid alias".into())); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + let before = app.document.snapshot(); + for event in [ + Event::Filter(Filter::Artifacts), + Event::Search("document_2".into()), + Event::Filter(Filter::All), + ] { + let _ = app.update(Message::Traceability(event)); + assert_eq!(app.selected, owner); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &before)); + } + let _ = app.update(Message::Select(ids[2])); + assert_eq!(app.selected, owner); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &before)); + } +} diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs index 8fb6b8f4..e4f7626f 100644 --- a/crates/oa-desktop/src/workflow.rs +++ b/crates/oa-desktop/src/workflow.rs @@ -14,6 +14,8 @@ use oa_core::{transitions::*, *}; use std::collections::BTreeSet; mod communication; pub(crate) mod recovery; +#[cfg(test)] +mod transition_tests; #[derive(Debug, Clone)] pub enum Event { @@ -30,9 +32,12 @@ pub enum Event { Source(Choice), Target(Choice), Scope(String), + Destination(Choice), MappingTarget(Id, Choice), MappingKind(Id, Kind), CommunicationPort(Id, bool, communication::PortMenuChoice), + OperationOmission(Id, bool), + OperationRationale(Id, String), Resolve(Id, TransitionField, FieldDecision), Preview, } @@ -265,6 +270,157 @@ pub struct Transition { pub plan: TransitionPlan, communication_ports: std::collections::BTreeMap, } +impl Transition { + fn presentation(&self, p: &Project) -> (Project, Vec) { + let mut candidate = p.clone(); + for command in &self.plan.commands { + match command { + Command::CreateElement(e) | Command::EditElement(_, e) => { + candidate.elements.insert(e.id, e.clone()); + } + _ => {} + } + } + let targets = self + .plan + .changes + .iter() + .map(|c| c.target) + .filter(|id| candidate.elements.contains_key(id)) + .collect::>() + .into_iter() + .collect(); + (candidate, targets) + } + fn automatic_behavior(&self, p: &Project) -> bool { + self.request + .sources + .first() + .is_some_and(|id| crate::behavior::selected_behavior(p, *id).is_some()) + } + fn new_counterpart(&self, p: &Project) -> bool { + self.request.sources.first().is_some_and(|source| { + self.plan + .changes + .iter() + .find(|c| c.source == *source) + .map_or_else( + || { + !p.traces + .values() + .any(|t| t.oa == *source || t.sa == *source) + && !self + .request + .mappings + .iter() + .any(|m| m.source == *source && m.target.is_some()) + }, + |c| c.action == TransitionAction::Create, + ) + }) + } + fn boundary_choice(&self, p: &Project, conflict: &TransitionConflict) -> bool { + conflict.field.is_none() + && conflict.target.is_none() + && conflict.message + == "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred." + && conflict + .source + .and_then(|id| p.elements.get(&id)) + .is_some_and(|e| { + matches!( + e.kind(), + Kind::Entity | Kind::Actor | Kind::System | Kind::SystemActor + ) + }) + } + fn destination_error(&self, p: &Project) -> Option { + let id = self.request.destination_diagram?; + let (candidate, targets) = self.presentation(p); + match candidate.diagrams.get(&id) { + None => Some("The chosen result diagram is missing. Your choice is retained; choose another diagram.".into()), + Some(view) if !result_view_compatible(&candidate, view, &targets, self.request.direction.target_layer()) => + Some("The chosen result diagram cannot show every transitioned element. Choose another diagram; no model changes have been applied.".into()), + _ => None, + } + } +} + +fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool { + (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability) + && (diagram.projection.state_machine().is_none() || !targets.is_empty()) + && targets.iter().all(|id| { + p.elements.get(id).is_some_and(|e| { + diagram.projection.includes(e.kind()) + && diagram.projection.state_machine().is_none_or(|machine| { + oa_core::projection::state_machine_contains(p, machine, *id) + }) + }) + }) +} +fn automatic_result_view(p: &Project, targets: &[Id], layer: Layer) -> Option { + p.diagrams + .values() + .filter(|d| d.layer == layer && result_view_compatible(p, d, targets, layer)) + .filter_map(|d| { + Some(( + match d.projection { + ProjectionKind::Allocation => 0, + ProjectionKind::Overview => 1, + ProjectionKind::Decomposition => 2, + _ => return None, + }, + d.id, + )) + }) + .min() + .map(|(_, id)| id) +} + +fn place_transition_results( + before: &Project, + candidate: &mut Project, + diagram: Id, + targets: &[Id], +) { + // Keep every accepted placement and route. Only newly introduced placements + // receive space below the accepted projected bounds, including tall/nested nodes. + let accepted_nodes = before.diagrams.get(&diagram).map(|d| &d.nodes); + let mut occupied = candidate.diagrams[&diagram].clone(); + occupied + .nodes + .retain(|id, _| accepted_nodes.is_some_and(|nodes| nodes.contains_key(id))); + let projected = oa_core::projection::project_diagram(candidate, &occupied); + let mut bottom = projected + .nodes + .iter() + .map(|n| n.position.y + n.size.height) + .chain( + occupied + .nodes + .values() + .map(|n| n.position.y + n.size.height), + ) + .fold(20.0_f32, f32::max) + + 40.; + for target in targets { + if candidate.diagrams[&diagram].nodes.values().any(|n| { + n.element == *target && accepted_nodes.is_some_and(|nodes| nodes.contains_key(&n.id)) + }) { + continue; + } + let node = candidate.place(diagram, *target, Position { x: 60., y: bottom }); + let placement = candidate + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap(); + placement.position = Position { x: 60., y: bottom }; + bottom += placement.size.height + 40.; + } +} impl Workflow { pub fn manual_trace(p: &Project, selected: Id) -> Result { if p.layer_of(selected).is_none() @@ -458,7 +614,34 @@ impl Workflow { _ => {} }, Self::Transition(d) => { + match &event { + Event::OperationOmission(source, enabled) => { + d.request + .operation_omissions + .retain(|v| v.source != *source); + if *enabled { + d.request.operation_omissions.push(OperationOmission { + source: *source, + rationale: String::new(), + }); + } + } + Event::OperationRationale(source, raw) => { + if let Some(decision) = d + .request + .operation_omissions + .iter_mut() + .find(|v| v.source == *source) + { + decision.rationale = raw.clone(); + } + } + _ => {} + } match event { + Event::Destination(choice) => { + d.request.destination_diagram = (!choice.id.is_nil()).then_some(choice.id); + } Event::CommunicationPort(exchange, source, value) => { d.choose_communication_port(p, exchange, source, value)?; } @@ -580,29 +763,27 @@ impl Workflow { return Ok(Some(candidate)); } let layer = d.request.direction.target_layer(); - let diagram = d - .request - .destination_diagram - .or_else(|| { - candidate - .diagrams - .values() - .find(|d| d.layer == layer) - .map(|d| d.id) - }) - .unwrap_or_else(|| candidate.ensure_diagram(layer)); - for change in &d.plan.changes { - if candidate.elements.contains_key(&change.target) { - let index = candidate.diagrams[&diagram].nodes.len(); - candidate.place( - diagram, - change.target, - Position { - x: 60. + (index % 3) as f32 * 280., - y: 60. + (index / 3) as f32 * 150., - }, - ); + let targets: Vec<_> = d + .plan + .changes + .iter() + .map(|c| c.target) + .filter(|id| candidate.elements.contains_key(id)) + .collect(); + let diagram = if let Some(id) = d.request.destination_diagram { + let view = candidate.diagrams.get(&id).ok_or("The chosen result diagram no longer exists. Choose another result diagram.")?; + if !result_view_compatible(&candidate, view, &targets, layer) { + return Err("The chosen result diagram cannot show every transitioned element. Choose a compatible result diagram.".into()); } + id + } else { + automatic_result_view(&candidate, &targets, layer) + .unwrap_or_else(|| candidate.ensure_diagram(layer)) + }; + // Explicit behavior destinations retain the core plan's + // placements; free-space placement is an architecture aid. + if !d.automatic_behavior(p) { + place_transition_results(p, &mut candidate, diagram, &targets); } return execute_batch(&candidate, &[]) .map(Some) @@ -671,6 +852,9 @@ impl Workflow { let kinds = RelationKind::ALL .into_iter() .filter(|kind| { + if *kind == RelationKind::ContextualLink { + return false; + } *kind == RelationKind::ExchangeAllocation || Kind::ALL.into_iter().any(|source| { source.supports(d.layer) @@ -827,7 +1011,11 @@ impl Workflow { } } Self::Transition(d) => { - content=content.push(text("Create or reconcile traced counterparts. Parent and performer remain independent. Descriptive updates are reviewed here; structural changes are created only for new counterparts.").size(12).color(style::MUTED)); + content=content.push(text(if d.new_counterpart(p) { + "Create a linked counterpart in the other layer. The source element stays in its current layer." + } else { + "Review names and descriptions of existing traced counterparts. For structural changes, use Review counterpart changes…" + }).size(12).color(style::MUTED)); let scope = match d.request.scope { DependencyScope::SelectedOnly => "Selected only", DependencyScope::Required => "Required references", @@ -856,6 +1044,63 @@ impl Workflow { .spacing(10) .align_y(iced::Center), ); + let (presentation, targets) = d.presentation(p); + let target_layer = d.request.direction.target_layer(); + let automatic = if d.automatic_behavior(p) { + "Automatic · open the counterpart in Behavior".into() + } else if let Some(id) = + automatic_result_view(&presentation, &targets, target_layer) + { + format!("Automatic · {}", p.named(id)) + } else { + format!("Automatic · create {target_layer} overview") + }; + let mut diagrams = vec![Choice { + id: Id::nil(), + label: automatic, + }]; + diagrams.extend( + presentation + .diagrams + .values() + .filter(|view| { + result_view_compatible(&presentation, view, &targets, target_layer) + }) + .map(|view| Choice { + id: view.id, + label: format!("{} · {}", view.name, view.projection), + }), + ); + let selected = d.request.destination_diagram.unwrap_or(Id::nil()); + if !diagrams.iter().any(|choice| choice.id == selected) { + diagrams.push(Choice { + id: selected, + label: format!("Unavailable result diagram · {}", p.named(selected)), + }); + } + let selected = diagrams + .iter() + .find(|choice| choice.id == selected) + .cloned(); + content = content.push( + column![ + text("Show result in").size(12), + pick_list(diagrams, selected, |choice| Message::Workflow( + Event::Destination(choice) + )) + .width(Fill) + .text_size(12) + .padding(8) + ] + .spacing(5), + ); + if let Some(error) = d.destination_error(p) { + content = content.push( + text(error) + .size(12) + .color(iced::Color::from_rgb8(178, 54, 58)), + ); + } let sources = d .request .sources @@ -865,6 +1110,38 @@ impl Workflow { .chain(d.plan.conflicts.iter().filter_map(|c| c.source)) .collect::>(); for source in sources { + if d.request.direction == TransitionDirection::SaToOa + && matches!( + p.elements.get(&source).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + operation: Some(_), + message_kind: MessageKind::Call | MessageKind::Asynchronous, + .. + }, + .. + }) + ) + { + let omission = d + .request + .operation_omissions + .iter() + .find(|v| v.source == source); + content=content.push(text(format!("Technical operation · {}",p.named(source))).size(13)) + .push(checkbox(omission.is_some()).label("Explicitly omit the interface operation in the OA counterpart").on_toggle(move|enabled|Message::Workflow(Event::OperationOmission(source,enabled)))); + if let Some(omission) = omission { + content = content.push( + text_input("Required engineering rationale", &omission.rationale) + .on_input(move |raw| { + Message::Workflow(Event::OperationRationale(source, raw)) + }) + .padding(8) + .size(12), + ); + } + content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11)); + } let mapping = d.request.mappings.iter().find(|m| m.source == source); let existing = p .traces @@ -947,6 +1224,11 @@ impl Workflow { .padding(8) .width(Fill), ); + if matches!(e.kind(), Kind::Entity | Kind::Actor) + && d.request.direction == TransitionDirection::OaToSa + { + item = item.push(text("System is the system of interest being designed. System actor remains outside that boundary. Choose the intended role; it is never inferred from the name.").size(11)); + } } } if let Some(ports) = d.communication_view(p, source) { @@ -995,7 +1277,7 @@ impl Workflow { fields.sort_by_key(|(source, field)| (*source, *field as u8)); let mut general_conflicts = column![].spacing(8); for conflict in &d.plan.conflicts { - if conflict.field.is_none() { + if conflict.field.is_none() && !d.boundary_choice(p, conflict) { general_conflicts = general_conflicts.push( text(&conflict.message) .size(12) @@ -1128,9 +1410,10 @@ impl Workflow { } content = content.push( text(format!( - "{} changes · {} unresolved conflicts · {} modeling warnings", + "{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings", d.plan.changes.len(), - d.plan.conflicts.len(), + d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(), + d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(), d.plan.warnings.len() )) .size(12), @@ -1142,7 +1425,7 @@ impl Workflow { Self::Ports(_) => false, Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(), Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok), - Self::Transition(d) => d.plan.conflicts.is_empty(), + Self::Transition(d) => d.plan.conflicts.is_empty() && d.destination_error(p).is_none(), }; if let Some(Err(error)) = relation_plan { content = content.push( @@ -1163,9 +1446,18 @@ impl Workflow { .padding(10) .style(style::nav) .on_press(Message::Workflow(Event::Cancel)), - button(text("Apply reviewed change").size(12)) - .padding(10) - .on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply))) + button( + text( + if matches!(self, Self::Transition(d) if d.new_counterpart(p)) { + "Create counterpart" + } else { + "Apply reviewed change" + } + ) + .size(12) + ) + .padding(10) + .on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply))) ] .spacing(10) ] diff --git a/crates/oa-desktop/src/workflow/transition_tests.rs b/crates/oa-desktop/src/workflow/transition_tests.rs new file mode 100644 index 00000000..b73c341d --- /dev/null +++ b/crates/oa-desktop/src/workflow/transition_tests.rs @@ -0,0 +1,259 @@ +use super::*; + +fn view(p: &mut Project, number: u128, projection: ProjectionKind) -> Id { + let id = Id::from_u128(number); + p.diagrams.insert( + id, + Diagram { + id, + alias: format!("view_{number}"), + name: format!("View {number}"), + description: String::new(), + layer: Layer::Sa, + projection, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + }, + ); + id +} +fn fixture() -> (Project, Id, Id) { + let mut p = Project::empty(); + let source = p + .add_element(Kind::Entity, Layer::Oa, "Water service", None) + .unwrap(); + let machine = p + .add_element(Kind::StateMachine, Layer::Sa, "Existing behavior", None) + .unwrap(); + let state = view(&mut p, 1, ProjectionKind::StateMachine { machine }); + p.place(state, machine, Position { x: 60., y: 60. }); + (p, source, state) +} +fn choose_system(p: &Project, source: Id) -> Workflow { + let mut w = Workflow::transition(p, source).unwrap(); + w.update(p, Event::MappingKind(source, Kind::System)) + .unwrap(); + w +} +fn target(w: &Workflow, source: Id) -> Id { + let Workflow::Transition(t) = w else { panic!() }; + t.plan + .changes + .iter() + .find(|c| c.source == source) + .unwrap() + .target +} + +#[test] +fn basic_transition_skips_lowest_uuid_state_view_and_preserves_oa() { + let (mut p, source, state) = fixture(); + let architecture = view(&mut p, 20, ProjectionKind::Allocation); + let mut w = choose_system(&p, source); + let result = target(&w, source); + let accepted = w.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(accepted.elements[&source], p.elements[&source]); + assert_eq!(accepted.diagrams[&state], p.diagrams[&state]); + assert!( + accepted.diagrams[&architecture] + .nodes + .values() + .any(|n| n.element == result) + ); + assert!( + accepted + .traces + .values() + .any(|t| t.oa == source && t.sa == result) + ); +} + +#[test] +fn basic_transition_creates_overview_when_existing_views_cannot_show_result() { + let (mut p, source, state) = fixture(); + let dataflow = view(&mut p, 2, ProjectionKind::FunctionalDataflow); + let mut w = choose_system(&p, source); + let result = target(&w, source); + let accepted = w.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(accepted.diagrams[&state], p.diagrams[&state]); + assert_eq!(accepted.diagrams[&dataflow], p.diagrams[&dataflow]); + let added: Vec<_> = accepted + .diagrams + .values() + .filter(|d| !p.diagrams.contains_key(&d.id)) + .collect(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].projection, ProjectionKind::Overview); + assert!(added[0].nodes.values().any(|n| n.element == result)); +} + +#[test] +fn basic_transition_explicit_destination_appends_below_existing_bounds_and_is_one_source_edit() { + let (mut p, source, _) = fixture(); + let automatic = view(&mut p, 20, ProjectionKind::Allocation); + let explicit = view(&mut p, 30, ProjectionKind::Overview); + let old = p + .add_element( + Kind::SystemActor, + Layer::Sa, + "Existing external operator", + None, + ) + .unwrap(); + let node = p.place(explicit, old, Position { x: 60., y: 600. }); + p.diagrams + .get_mut(&explicit) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .size + .height = 450.; + let mut w = choose_system(&p, source); + w.update( + &p, + Event::Destination(Choice { + id: explicit, + label: "Chosen overview".into(), + }), + ) + .unwrap(); + let result = target(&w, source); + let accepted = w.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(accepted.diagrams[&automatic], p.diagrams[&automatic]); + assert_eq!( + accepted.diagrams[&explicit].nodes[&node], + p.diagrams[&explicit].nodes[&node] + ); + assert_eq!( + accepted.diagrams[&explicit].routes, + p.diagrams[&explicit].routes + ); + let new = accepted.diagrams[&explicit] + .nodes + .values() + .find(|n| n.element == result) + .unwrap(); + assert!( + new.position.y >= 1090., + "New placement overlaps the existing tall node: {new:?}" + ); + let mut document = + crate::document::Document::from_project(p.clone(), Default::default()).unwrap(); + let before = document.bundle().clone(); + document + .execute(Command::Replace(accepted.clone())) + .unwrap(); + let after = document.bundle().clone(); + assert_eq!( + oa_core::source_project::compile_bundle(&after, Default::default()) + .unwrap() + .decoded + .project, + accepted + ); + assert!(document.undo()); + assert_eq!(document.project(), &p); + assert_eq!(document.bundle(), &before); + assert!(!document.undo()); + assert!(document.redo()); + assert_eq!(document.bundle(), &after); +} + +#[test] +fn basic_transition_invalid_explicit_destination_retains_choice_and_refuses() { + let (p, source, state) = fixture(); + for destination in [state, Id::from_u128(999)] { + let mut w = choose_system(&p, source); + w.update( + &p, + Event::Destination(Choice { + id: destination, + label: "Unavailable result view".into(), + }), + ) + .unwrap(); + assert!(w.update(&p, Event::Apply).is_err()); + let restored = Workflow::from_recovery(&p, w.recovery_snapshot().unwrap()).unwrap(); + let Workflow::Transition(restored) = restored else { + panic!() + }; + assert_eq!(restored.request.destination_diagram, Some(destination)); + let Workflow::Transition(t) = w else { panic!() }; + assert_eq!(t.request.destination_diagram, Some(destination)); + assert_eq!(t.request.mappings[0].target_kind, Some(Kind::System)); + } +} + +#[test] +fn basic_transition_boundary_choice_is_required_without_an_inferred_system() { + let (p, source, _) = fixture(); + let mut w = Workflow::transition(&p, source).unwrap(); + let Workflow::Transition(t) = &w else { + panic!() + }; + assert!(t.new_counterpart(&p)); + assert!(t.plan.conflicts.iter().any(|c| t.boundary_choice(&p, c))); + assert!(t.request.mappings.is_empty()); + assert!(w.update(&p, Event::Apply).is_err()); + w.update(&p, Event::MappingKind(source, Kind::SystemActor)) + .unwrap(); + let result = target(&w, source); + let accepted = w.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(accepted.elements[&result].kind(), Kind::SystemActor); + assert_eq!(accepted.elements[&source], p.elements[&source]); + assert!(!accepted.elements.values().any(|e| e.kind() == Kind::System)); +} + +#[test] +fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_exact() { + let (mut p, source, _) = fixture(); + view(&mut p, 20, ProjectionKind::Allocation); + let mut first = choose_system(&p, source); + let result = target(&first, source); + let mut p = first.update(&p, Event::Apply).unwrap().unwrap(); + let explicit = view(&mut p, 30, ProjectionKind::Overview); + let node = p.place(explicit, result, Position { x: 820., y: 912. }); + p.diagrams + .get_mut(&explicit) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .collapsed = true; + let trace = p + .traces + .values() + .find(|t| t.oa == source && t.sa == result) + .unwrap() + .id; + p.diagrams + .get_mut(&explicit) + .unwrap() + .routes + .push(EdgeRoute { + edge: trace, + waypoints: vec![Position { x: 301., y: 507. }], + label: Some(EdgeLabel { + fraction: 0.35, + offset: Position { x: 19., y: -14. }, + }), + }); + let mut w = Workflow::transition(&p, source).unwrap(); + let Workflow::Transition(t) = &w else { + panic!() + }; + assert!(!t.new_counterpart(&p)); + w.update( + &p, + Event::Destination(Choice { + id: explicit, + label: "Existing chosen view".into(), + }), + ) + .unwrap(); + let accepted = w.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(accepted.diagrams, p.diagrams); + assert_eq!(accepted.elements, p.elements); +} diff --git a/crates/oa-server/src/lib.rs b/crates/oa-server/src/lib.rs index f2e99754..16150bf1 100644 --- a/crates/oa-server/src/lib.rs +++ b/crates/oa-server/src/lib.rs @@ -3,6 +3,9 @@ pub mod error; mod routes; pub mod store; +#[cfg(test)] +mod preparation_http_tests; + use axum::{ Router, extract::{DefaultBodyLimit, Request, State}, diff --git a/crates/oa-server/src/preparation_http_tests.rs b/crates/oa-server/src/preparation_http_tests.rs new file mode 100644 index 00000000..93c2cc4c --- /dev/null +++ b/crates/oa-server/src/preparation_http_tests.rs @@ -0,0 +1,151 @@ +use crate::{ + app, + store::{Store, StoredProject}, +}; +use oa_core::{Id, Project}; +use reqwest::{Client, StatusCode}; +use serde_json::{Value, json}; +use std::{sync::Arc, time::Duration}; +use tokio::{sync::oneshot, time::timeout}; + +#[tokio::test] +async fn prepared_source_http_rechecks_revision_without_holding_catalog() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_owned(); + let store = Arc::new( + tokio::task::spawn_blocking(move || Store::open(path)) + .await + .unwrap() + .unwrap(), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}/api/v1", listener.local_addr().unwrap()); + let (stop, done) = oneshot::channel(); + let router = app(store.clone(), None); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = done.await; + }) + .await + .unwrap(); + }); + let client = Client::new(); + let created = client + .post(format!("{base}/projects")) + .json(&json!({"name":"Initial review"})) + .send() + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + let etag = created.headers()["etag"].to_str().unwrap().to_owned(); + let summary: Value = created.json().await.unwrap(); + let id: Id = summary["resource_id"].as_str().unwrap().parse().unwrap(); + let project_url = format!("{base}/projects/{id}"); + let initial: StoredProject = client + .get(&project_url) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let (entered, release) = store.pause_next_replacement(id, etag.clone()); + // Real parsing, legacy admission and source preparation all run. Runtime + // cost is irrelevant: the gate proves the exact pre-commit phase instead. + let source = "arcadia 2; project prepared \"Prepared stale head\" { oa_activity root \"Root\" {} oa_activity child \"Child\" { parent @root; } }"; + let proposed_client = client.clone(); + let proposed_url = format!("{project_url}/source"); + let proposed_etag = etag.clone(); + let proposed = tokio::spawn(async move { + proposed_client + .put(proposed_url) + .header("if-match", proposed_etag) + .header("content-type", "text/plain") + .body(source) + .send() + .await + .unwrap() + }); + let evidence = timeout(Duration::from_secs(10), entered) + .await + .expect("source did not reach validated pre-commit phase") + .unwrap(); + assert_eq!(evidence.model.name, "Prepared stale head"); + assert_eq!(evidence.model.elements.len(), 2); + let compiled = + oa_core::source_project::compile_bundle(&evidence.bundle, crate::store::SOURCE_LIMITS) + .unwrap(); + assert_eq!(compiled.decoded.project, evidence.model); + assert!( + !proposed.is_finished(), + "prepared request must still overlap the winner" + ); + let catalog = timeout( + Duration::from_secs(2), + client.get(format!("{base}/projects")).send(), + ) + .await + .expect("catalog locked during admitted candidate lifetime") + .unwrap(); + assert_eq!(catalog.status(), StatusCode::OK); + let winner = timeout( + Duration::from_secs(2), + client + .patch(&project_url) + .header("if-match", &etag) + .json(&json!({"name":"Concurrent accepted review"})) + .send(), + ) + .await + .expect("prepared worker held the commit lock") + .unwrap(); + assert_eq!(winner.status(), StatusCode::OK); + let winner_etag = winner.headers()["etag"].to_str().unwrap().to_owned(); + assert!( + !proposed.is_finished(), + "stale candidate must await explicit release" + ); + release.release(); + let loser = timeout(Duration::from_secs(10), proposed) + .await + .expect("released candidate did not finish") + .unwrap(); + assert_eq!(loser.status(), StatusCode::PRECONDITION_FAILED); + let failure: Value = loser.json().await.unwrap(); + assert_eq!(failure["code"], "revision_conflict"); + assert_eq!(failure["current_revision"], 2); + let committed_response = client.get(&project_url).send().await.unwrap(); + assert_eq!( + committed_response.headers()["etag"].to_str().unwrap(), + winner_etag + ); + let committed: StoredProject = committed_response + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let mut expected_model: Project = initial.model; + expected_model.name = "Concurrent accepted review".into(); + assert_eq!(committed.model, expected_model); + assert_eq!(committed.revision, 2); + assert!(committed.model.elements.is_empty()); + let installed: StoredProject = serde_json::from_slice( + &std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(), + ) + .unwrap(); + assert_eq!(installed.model, committed.model); + assert_eq!(installed.source_project, committed.source_project); + assert_eq!(installed.revision, 2); + assert_eq!(installed.resource_id, committed.resource_id); + assert_eq!(installed.migration, committed.migration); + let _ = stop.send(()); + drop(client); + timeout(Duration::from_secs(5), task) + .await + .unwrap() + .unwrap(); +} diff --git a/crates/oa-server/src/routes.rs b/crates/oa-server/src/routes.rs index e3db50c7..775b7b13 100644 --- a/crates/oa-server/src/routes.rs +++ b/crates/oa-server/src/routes.rs @@ -1069,10 +1069,11 @@ fn check_transition_request(request: &oa_core::transitions::TransitionRequest) - if request.sources.len() > 100 || request.mappings.len() > 200 || request.communication_endpoints.len() > 200 + || request.operation_omissions.len() > 200 || request.resolutions.len() > 400 { return Err(ApiError::bad_request( - "Transition requests allow at most 100 sources, 200 mappings or communication endpoint decisions and 400 field resolutions.", + "Transition requests allow at most 100 sources, 200 mappings, communication endpoint decisions or operation omissions and 400 field resolutions.", )); } Ok(()) @@ -1195,6 +1196,7 @@ fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest if transition.sources.len() > 100 || transition.mappings.len() > 200 || transition.communication_endpoints.len() > 200 + || transition.operation_omissions.len() > 200 || transition.resolutions.len() > 400 || request.traces.len() > 100 || request.references.len() > 5_000 @@ -1204,7 +1206,7 @@ fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest || request.accepted_impacts.len() > 200 { return Err(ApiError::bad_request( - "Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/communication endpoint decisions/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.", + "Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/communication endpoint decisions/operation omissions/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.", )); } Ok(()) diff --git a/crates/oa-server/src/store.rs b/crates/oa-server/src/store.rs index 4f10e1f9..577a0321 100644 --- a/crates/oa-server/src/store.rs +++ b/crates/oa-server/src/store.rs @@ -13,6 +13,10 @@ use std::{ sync::{Mutex, MutexGuard}, }; +#[cfg(test)] +#[path = "store_preparation_gate.rs"] +pub(crate) mod preparation_gate; + pub const MAX_MODEL_BYTES: usize = 8 * 1024 * 1024; pub const MAX_SOURCE_PROJECT_BYTES: usize = 8 * 1024 * 1024; pub const MAX_OBJECTS: usize = 5_000; @@ -89,6 +93,8 @@ pub enum StoreError { pub struct Store { inner: Mutex, + #[cfg(test)] + preparation_gate: Mutex>, // Keep the OS lock alive for the store's entire lifetime, including requests. _directory_lock: File, } @@ -242,6 +248,8 @@ impl Store { ) -> ApiResult<(StoredProject, bool)> { let current = self.checked(id, expected)?; let prepared = PreparedProject::replacement(¤t, decoded)?; + #[cfg(test)] + self.wait_after_preparation(id, expected, &prepared)?; self.lock()?.replace(id, expected, prepared) } @@ -381,6 +389,8 @@ impl Store { } } Ok(Self { + #[cfg(test)] + preparation_gate: Mutex::new(None), inner: Mutex::new(StoreInner { directory, projects, diff --git a/crates/oa-server/src/store_preparation_gate.rs b/crates/oa-server/src/store_preparation_gate.rs new file mode 100644 index 00000000..977de2fa --- /dev/null +++ b/crates/oa-server/src/store_preparation_gate.rs @@ -0,0 +1,141 @@ +//! Private synchronization for real-HTTP tests; absent from production builds. +use super::{ApiError, ApiResult, Id, PreparedProject, Project, SourceBundle, Store}; +use std::{sync::mpsc, time::Duration}; +use tokio::sync::oneshot; + +pub(crate) struct PreparedEvidence { + pub model: Project, + pub bundle: SourceBundle, +} + +pub(crate) struct Armed { + id: Id, + expected: String, + entered: oneshot::Sender, + resume: mpsc::Receiver, +} + +/// Dropping an unfinished test cancels the worker instead of stranding it. +pub(crate) struct ReleaseOnDrop(Option>); + +impl ReleaseOnDrop { + pub(crate) fn release(mut self) { + self.0.take().unwrap().send(true).unwrap(); + } +} + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(false); + } + } +} + +impl Store { + pub(crate) fn pause_next_replacement( + &self, + id: Id, + expected: String, + ) -> (oneshot::Receiver, ReleaseOnDrop) { + let (entered, receiver) = oneshot::channel(); + let (resume, wait) = mpsc::channel(); + let mut slot = self.preparation_gate.lock().unwrap(); + assert!(slot.is_none(), "one gate per store instance"); + *slot = Some(Armed { + id, + expected, + entered, + resume: wait, + }); + (receiver, ReleaseOnDrop(Some(resume))) + } + + pub(super) fn wait_after_preparation( + &self, + id: Id, + expected: &str, + prepared: &PreparedProject, + ) -> ApiResult<()> { + let armed = { + let mut slot = self.preparation_gate.lock().unwrap(); + if slot + .as_ref() + .is_some_and(|gate| gate.id == id && gate.expected == expected) + { + slot.take() + } else { + None + } + }; + let Some(armed) = armed else { + return Ok(()); + }; + // Neither the catalog nor the gate mutex is held while the real model + // worker waits. Evidence comes from the already validated candidate. + armed + .entered + .send(PreparedEvidence { + model: prepared.model().clone(), + bundle: prepared.bundle().clone(), + }) + .map_err(|_| ApiError::internal("Preparation test observer disappeared."))?; + match armed.resume.recv_timeout(Duration::from_secs(15)) { + Ok(true) => Ok(()), + _ => Err(ApiError::internal( + "Preparation test cancelled or timed out.", + )), + } + } +} + +#[tokio::test] +async fn preparation_gate_drop_cancels_without_installing_candidate() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_owned(); + let (store, initial) = tokio::task::spawn_blocking(move || { + let store = std::sync::Arc::new(Store::open(path).unwrap()); + let initial = store.create(Project::empty()).unwrap(); + (store, initial) + }) + .await + .unwrap(); + let original_bytes = std::fs::read( + directory + .path() + .join(format!("{}.json", initial.resource_id)), + ) + .unwrap(); + let (entered, release) = store.pause_next_replacement(initial.resource_id, initial.etag()); + let mut model = initial.model.clone(); + model.name = "Must not be installed".into(); + let worker_store = store.clone(); + let id = initial.resource_id; + let etag = initial.etag(); + let worker = tokio::task::spawn_blocking(move || { + worker_store.replace_decoded( + id, + &etag, + oa_core::persistence::DecodeResult { + project: model, + migration: None, + }, + ) + }); + tokio::time::timeout(Duration::from_secs(10), entered) + .await + .unwrap() + .unwrap(); + drop(release); + let error = tokio::time::timeout(Duration::from_secs(2), worker) + .await + .expect("dropping the test guard must release its worker") + .unwrap() + .unwrap_err(); + assert_eq!(error.detail, "Preparation test cancelled or timed out."); + assert_eq!(store.get(id).unwrap().model, initial.model); + assert_eq!( + std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(), + original_bytes + ); +} diff --git a/crates/oa-server/tests/http.rs b/crates/oa-server/tests/http.rs index 3b8477a7..c4a15248 100644 --- a/crates/oa-server/tests/http.rs +++ b/crates/oa-server/tests/http.rs @@ -18,6 +18,8 @@ mod port_anchors; mod semantic_transitions; #[path = "http/source6_views.rs"] mod source6_views; +#[path = "http/source7_contracts.rs"] +mod source7_contracts; #[path = "http/source_projects.rs"] mod source_projects; @@ -567,89 +569,6 @@ async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() { server.stop().await; } -#[tokio::test] -async fn accepted_slow_source_does_not_lock_catalog_and_loses_to_a_newer_http_revision() { - let directory = tempfile::tempdir().unwrap(); - let server = TestServer::start(directory.path(), None).await; - let (id, etag) = server.create().await; - let mut source = String::from( - "arcadia 2; project prepared \"Slow proposed head\" { oa_activity a0 \"Root\" {}\n", - ); - // This is within all admission budgets. A nested engineering graph forces - // real validation work after the source route's initial revision snapshot. - // Keep this concurrency fixture below the service's 30-second deadline: - // 2,000 nested activities returned a legitimate 504 on the two-core runner. - // The overlap assertion below prevents shrinking it into a sequential test. - for i in 1..1_000 { - source.push_str(&format!( - "oa_activity a{i} \"Activity {i}\" {{ parent @a{}; }}\n", - i - 1 - )); - } - source.push('}'); - let client = server.client.clone(); - let url = server.url(&format!("/projects/{id}/source")); - let stale_etag = etag.clone(); - let proposed = tokio::spawn(async move { - client - .put(url) - .header("if-match", stale_etag) - .header("content-type", "text/plain") - .body(source) - .send() - .await - .unwrap() - }); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - assert!( - !proposed.is_finished(), - "fixture must overlap actual candidate preparation" - ); - let catalog = tokio::time::timeout( - std::time::Duration::from_secs(2), - server.client.get(server.url("/projects")).send(), - ) - .await - .expect("catalog locked during valid candidate preparation") - .unwrap(); - assert_eq!(catalog.status(), StatusCode::OK); - let winner = tokio::time::timeout( - std::time::Duration::from_secs(2), - server - .client - .patch(server.url(&format!("/projects/{id}"))) - .header("if-match", &etag) - .json(&json!({"name":"Concurrent accepted review"})) - .send(), - ) - .await - .expect("unrelated preparation held the commit lock") - .unwrap(); - assert_eq!(winner.status(), StatusCode::OK); - // The two-second read/PATCH bounds above test responsiveness and lock - // ownership. Finishing the deliberately expensive admitted candidate is - // a separate watchdog, not a latency promise under parallel CPU contention. - let loser = tokio::time::timeout(std::time::Duration::from_secs(45), proposed) - .await - .expect("admitted candidate did not complete within the test watchdog") - .unwrap(); - assert_eq!(loser.status(), StatusCode::PRECONDITION_FAILED); - let failure: Value = loser.json().await.unwrap(); - assert_eq!(failure["code"], "revision_conflict"); - assert_eq!(failure["current_revision"], 2); - let committed = server.get(id).await; - assert_eq!(committed.revision, 2); - assert_eq!(committed.model.name, "Concurrent accepted review"); - assert!(committed.model.elements.is_empty()); - let installed: StoredProject = serde_json::from_slice( - &std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(), - ) - .unwrap(); - assert_eq!(installed.model, committed.model); - assert_eq!(installed.revision, 2); - server.stop().await; -} - #[tokio::test] async fn mission_and_contextual_chain_records_round_trip_and_edit_through_http() { let directory = tempfile::tempdir().unwrap(); @@ -2019,7 +1938,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s .text() .await .unwrap(); - assert!(source.starts_with("arcadia 6;")); + assert!(source.starts_with("arcadia 7;")); assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model); let no_op = server .client diff --git a/crates/oa-server/tests/http/communication_limits.rs b/crates/oa-server/tests/http/communication_limits.rs index c06f15bb..3871d6c3 100644 --- a/crates/oa-server/tests/http/communication_limits.rs +++ b/crates/oa-server/tests/http/communication_limits.rs @@ -1,4 +1,4 @@ -//! HTTP admission limits must reject endpoint decisions before transition planning. +//! HTTP admission limits reject bounded explicit decisions before planning. use super::*; use oa_core::{semantic::SemanticTransitionRequest, transitions::*}; @@ -28,7 +28,7 @@ async fn unchanged(server: &TestServer, before: &StoredProject, disk: &Path, byt assert_eq!(std::fs::read(disk).unwrap(), bytes); } -async fn endpoint_limit(semantic: bool) { +async fn decision_limit(semantic: bool, operations: bool) { let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let mut model = Project::empty(); @@ -40,19 +40,24 @@ async fn endpoint_limit(semantic: bool) { let disk = directory.path().join(format!("{id}.json")); let bytes = std::fs::read(&disk).unwrap(); let transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]); - let (route, mut request, decision_path, applied_decision_path) = if semantic { + let decision_name = if operations { + "operation_omissions" + } else { + "communication_endpoints" + }; + let (route, mut request, decision_parent, applied_decision_parent) = if semantic { ( "semantic-transitions", serde_json::to_value(SemanticTransitionRequest::new(transition)).unwrap(), - "/transition/communication_endpoints", - "/plan/request/transition/communication_endpoints", + "/transition", + "/plan/request/transition", ) } else { ( "transitions", serde_json::to_value(transition).unwrap(), - "/communication_endpoints", - "/plan/request/communication_endpoints", + "", + "/plan/request", ) }; // Obtain a real prepared envelope first: apply must enforce the same bound, @@ -67,12 +72,24 @@ async fn endpoint_limit(semantic: bool) { // Syntactically typed but semantically inapplicable decisions deliberately // distinguish capacity admission (400) from core semantic rejection (422 or // a preview containing conflicts). The bound counts entries, not unique IDs. - let decision = CommunicationEndpointDecision { - source, - source_port: CommunicationPortChoice::Reuse { port: source }, - target_port: CommunicationPortChoice::Reuse { port: source }, + let decision = if operations { + json!(OperationOmission { + source, + rationale: "Explicitly abstract technical operation".into() + }) + } else { + json!(CommunicationEndpointDecision { + source, + source_port: CommunicationPortChoice::Reuse { port: source }, + target_port: CommunicationPortChoice::Reuse { port: source }, + }) }; - *request.pointer_mut(decision_path).unwrap() = json!(vec![decision.clone(); 200]); + request + .pointer_mut(decision_parent) + .unwrap() + .as_object_mut() + .unwrap() + .insert(decision_name.into(), json!(vec![decision.clone(); 200])); let response = post(&server, id, &format!("{route}/preview"), &etag, &request).await; let status = response.status(); let body: Value = response.json().await.unwrap(); @@ -83,8 +100,17 @@ async fn endpoint_limit(semantic: bool) { unchanged(&server, &before, &disk, &bytes).await; let over_limit = json!(vec![decision; 201]); - *request.pointer_mut(decision_path).unwrap() = over_limit.clone(); - *plan.pointer_mut(applied_decision_path).unwrap() = over_limit; + request + .pointer_mut(decision_parent) + .unwrap() + .as_object_mut() + .unwrap() + .insert(decision_name.into(), over_limit.clone()); + plan.pointer_mut(applied_decision_parent) + .unwrap() + .as_object_mut() + .unwrap() + .insert(decision_name.into(), over_limit); for (operation, body) in [("preview", request), ("apply", plan)] { let response = post(&server, id, &format!("{route}/{operation}"), &etag, &body).await; let status = response.status(); @@ -97,8 +123,9 @@ async fn endpoint_limit(semantic: bool) { assert_eq!(error["code"], "invalid_request", "{error}"); let detail = error["detail"].as_str().unwrap().to_lowercase(); assert!( - detail.contains("200") && detail.contains("endpoint"), - "Capacity rejection must identify endpoint decisions: {error}" + detail.contains("200") + && detail.contains(if operations { "omission" } else { "endpoint" }), + "Capacity rejection must identify the bounded decisions: {error}" ); unchanged(&server, &before, &disk, &bytes).await; } @@ -110,10 +137,20 @@ async fn endpoint_limit(semantic: bool) { #[tokio::test] async fn ordinary_transition_endpoint_decision_limit_rejects_201_without_mutation() { - endpoint_limit(false).await; + decision_limit(false, false).await; } #[tokio::test] async fn semantic_transition_endpoint_decision_limit_rejects_201_without_mutation() { - endpoint_limit(true).await; + decision_limit(true, false).await; +} + +#[tokio::test] +async fn ordinary_transition_operation_omission_limit_rejects_201_without_mutation() { + decision_limit(false, true).await; +} + +#[tokio::test] +async fn semantic_transition_operation_omission_limit_rejects_201_without_mutation() { + decision_limit(true, true).await; } diff --git a/crates/oa-server/tests/http/data_contracts.rs b/crates/oa-server/tests/http/data_contracts.rs index 030e649b..f47a7fc3 100644 --- a/crates/oa-server/tests/http/data_contracts.rs +++ b/crates/oa-server/tests/http/data_contracts.rs @@ -185,7 +185,7 @@ async fn published_data_contract_queries_atomic_edits_identity_and_restart() { .text() .await .unwrap(); - assert!(source.starts_with("arcadia 6;")); + assert!(source.starts_with("arcadia 7;")); assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model); server.stop().await; let server = TestServer::start(directory.path(), None).await; @@ -244,7 +244,7 @@ async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_fil 1 ); assert_eq!(stored.source_project.files.len(), 2); - assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 6;")); + assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 7;")); for text in [ "// Event rationale", "// Interface rationale", @@ -288,7 +288,7 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav assert_eq!(opened.revision, 17); assert_eq!(opened.model.schema_version, oa_core::SCHEMA_VERSION); assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3); - assert!(opened.source_project.files["project.arcadia"].contains("arcadia 6;")); + assert!(opened.source_project.files["project.arcadia"].contains("arcadia 7;")); assert_pair(&opened); assert_eq!(std::fs::read(&filename).unwrap(), original); assert_eq!( @@ -475,6 +475,7 @@ async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_s receiver: Some(to), message_kind, reply_to, + operation: None, values: vec![], }, precondition: String::new(), @@ -720,7 +721,7 @@ async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_s .text() .await .unwrap(); - assert!(source.starts_with("arcadia 6;")); + assert!(source.starts_with("arcadia 7;")); assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model); let saved = std::fs::read(&filename).unwrap(); server.stop().await; diff --git a/crates/oa-server/tests/http/semantic_transitions.rs b/crates/oa-server/tests/http/semantic_transitions.rs index cd1d8365..a16e9ab8 100644 --- a/crates/oa-server/tests/http/semantic_transitions.rs +++ b/crates/oa-server/tests/http/semantic_transitions.rs @@ -576,6 +576,7 @@ fn cleanup_fixture() -> (Project, Id, Id, Id) { receiver: None, message_kind: MessageKind::Asynchronous, reply_to: None, + operation: None, values: vec![MessageValue { path: vec![field], field: leaf, diff --git a/crates/oa-server/tests/http/source6_views.rs b/crates/oa-server/tests/http/source6_views.rs index 59fafcf2..5ece6f56 100644 --- a/crates/oa-server/tests/http/source6_views.rs +++ b/crates/oa-server/tests/http/source6_views.rs @@ -113,7 +113,7 @@ fn assert_pair(stored: &StoredProject) { let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap(); assert_eq!(compiled.bundle, stored.source_project); assert_eq!(compiled.decoded.project, stored.model); - assert_eq!(stored.model.schema_version, 6); + assert_eq!(stored.model.schema_version, 7); } async fn transact( diff --git a/crates/oa-server/tests/http/source7_contracts.rs b/crates/oa-server/tests/http/source7_contracts.rs new file mode 100644 index 00000000..d2d7ac45 --- /dev/null +++ b/crates/oa-server/tests/http/source7_contracts.rs @@ -0,0 +1,326 @@ +//! Real HTTP checks for caller-instance identity and operation allocation identity. +use super::*; +use oa_core::{ + ContextualLinkPurpose, Element, OccurrenceBody, OccurrenceEndpoint, OperationUse, Relation, + RelationBody, SharedBody, language, persistence, + source_project::{SourceBundle, compile_bundle}, +}; +use std::collections::BTreeMap; + +fn operation_id(n: u128) -> Id { + Id::from_u128(0x52060906000040008000000000000000 + n) +} + +fn occurrence(model: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let mut element = model + .make_element(Kind::Occurrence, Layer::Oa, name, Some(owner)) + .unwrap(); + *element.shared_mut().unwrap() = SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }; + let id = element.id; + model.elements.insert(id, element); + model + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} + +fn select_operation(model: &Project, allocation: Id) -> Element { + let mut element = model.elements[&operation_id(43)].clone(); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = element.shared_mut() + else { + panic!("fixture Call"); + }; + *operation = Some(OperationUse { + allocation: Some(allocation), + }); + element +} + +struct Fixture { + model: Project, + bundle: SourceBundle, + definition: Id, + r1: Id, + r2: Id, + link: Id, +} + +fn fixture() -> Fixture { + // Frozen source6 absence probe contains two parameterless operations on one + // concrete boundary. Its old bytes remain compatibility inputs. + let mut model = persistence::decode(include_bytes!( + "../../../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + )) + .unwrap(); + let selected = select_operation(&model, operation_id(30)); + model.elements.insert(selected.id, selected); + let caller = model + .add_element(Kind::Process, Layer::Oa, "Caller", None) + .unwrap(); + let definition = model + .add_element(Kind::Process, Layer::Oa, "Reusable assessment", None) + .unwrap(); + let work = model + .add_element(Kind::Activity, Layer::Oa, "Assess", None) + .unwrap(); + let leaf = occurrence( + &mut model, + definition, + "Assessment contribution", + OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + ); + let x = occurrence( + &mut model, + caller, + "Caller check", + OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + ); + let r1 = occurrence( + &mut model, + caller, + "First assessment", + OccurrenceBody::SequenceReference { + sequence: Some(definition), + }, + ); + let r2 = occurrence( + &mut model, + caller, + "Second assessment", + OccurrenceBody::SequenceReference { + sequence: Some(definition), + }, + ); + let link = Id::new_v4(); + model.relations.insert( + link, + Relation { + id: link, + alias: "qualified_assessment".into(), + name: "Check the first assessment".into(), + description: "Caller intent survives source and REST editing.".into(), + body: RelationBody::ContextualLink { + owner: caller, + source: OccurrenceEndpoint { + references: vec![], + occurrence: x, + }, + target: OccurrenceEndpoint { + references: vec![r1], + occurrence: leaf, + }, + purpose: ContextualLinkPurpose::Precedence { + guard: "check accepted".into(), + }, + }, + }, + ); + let source = + language::format(&model).replacen("{\n", "{\n include \"rationale.arcadia\";\n", 1); + let bundle = SourceBundle { + entry: "assembly.arcadia".into(), + files: BTreeMap::from([ + ("assembly.arcadia".into(), source), + ( + "rationale.arcadia".into(), + "// Authored rationale: retain 雨 and CRLF.\r\n".into(), + ), + ]), + }; + assert_eq!( + compile_bundle(&bundle, Default::default()) + .unwrap() + .decoded + .project, + model + ); + Fixture { + model, + bundle, + definition, + r1, + r2, + link, + } +} + +async fn transact( + server: &TestServer, + stored: &StoredProject, + commands: Vec, +) -> reqwest::Response { + server + .client + .post(server.url(&format!("/projects/{}/transactions", stored.resource_id))) + .header("if-match", stored.etag()) + .json(&json!({"commands": commands})) + .send() + .await + .unwrap() +} + +async fn unchanged(server: &TestServer, before: &StoredProject, disk: &Path, bytes: &[u8]) { + let after = server.get(before.resource_id).await; + assert_eq!(after.model, before.model); + assert_eq!(after.source_project, before.source_project); + assert_eq!(after.revision, before.revision); + assert_eq!(after.etag(), before.etag()); + assert_eq!(std::fs::read(disk).unwrap(), bytes); +} + +#[tokio::test] +async fn source7_http_instance_and_operation_edits_preserve_identity_source_and_restart() { + let f = fixture(); + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let response = server + .client + .post(server.url("/projects/import-source-project")) + .json(&f.bundle) + .send() + .await + .unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::CREATED, "{body}"); + let id: Id = body["resource_id"].as_str().unwrap().parse().unwrap(); + let before = server.get(id).await; + assert_eq!(before.model, f.model); + assert_eq!(before.source_project, f.bundle); + let queried: Value = server + .client + .get(server.url(&format!("/projects/{id}/relations?kind=ContextualLink"))) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(queried["items"].as_array().unwrap().len(), 1); + assert_eq!(queried["items"][0]["id"], f.link.to_string()); + + // Only two explicit records change: caller-instance address and allocation. + // The shared procedure, both reference instances and operation items persist. + let mut relation = before.model.relations[&f.link].clone(); + if let RelationBody::ContextualLink { target, .. } = &mut relation.body { + target.references = vec![f.r2]; + } + let selected = select_operation(&before.model, operation_id(31)); + let response = transact( + &server, + &before, + vec![ + Command::EditRelation(f.link, relation.clone()), + Command::EditElement(selected.id, selected.clone()), + ], + ) + .await; + let status = response.status(); + let body: Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::OK, "{body}"); + let after = server.get(id).await; + let mut expected = before.model.clone(); + expected.relations.insert(f.link, relation); + expected.elements.insert(selected.id, selected); + assert_eq!(after.model, expected); + assert_eq!(after.revision, before.revision + 1); + assert_eq!( + after.source_project.files["rationale.arcadia"], + f.bundle.files["rationale.arcadia"] + ); + assert_eq!( + compile_bundle(&after.source_project, Default::default()) + .unwrap() + .decoded + .project, + expected + ); + assert_eq!( + after.model.elements[&f.definition], + before.model.elements[&f.definition] + ); + assert_eq!(after.model.elements[&f.r1], before.model.elements[&f.r1]); + + let disk = directory.path().join(format!("{id}.json")); + let bytes = std::fs::read(&disk).unwrap(); + let mut bad_link = after.model.relations[&f.link].clone(); + if let RelationBody::ContextualLink { target, .. } = &mut bad_link.body { + target.references = vec![f.definition]; // Definition is not an instance. + } + let wrong_operation = select_operation(&after.model, operation_id(20)); // Item is not an allocation. + for commands in [ + vec![Command::EditRelation(f.link, bad_link)], + vec![Command::EditElement(wrong_operation.id, wrong_operation)], + ] { + let response = transact(&server, &after, commands).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + unchanged(&server, &after, &disk, &bytes).await; + } + let mut old_json = serde_json::to_value(&after.model).unwrap(); + old_json["schema_version"] = json!(6); + // Isolate admission gates: one new feature must not mask another. + let mut context_only = old_json.clone(); + for element in context_only["elements"] + .as_object_mut() + .unwrap() + .values_mut() + { + if let Some(body) = element.pointer_mut("/body/Sa/Shared/Occurrence/body/Exchange") { + body.as_object_mut().unwrap().remove("operation"); + } + } + let mut operation_null_only: Value = serde_json::from_slice(include_bytes!( + "../../../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + )) + .unwrap(); + operation_null_only["elements"][operation_id(43).to_string()]["body"]["Sa"]["Shared"]["Occurrence"] + ["body"]["Exchange"]["operation"] = Value::Null; + for candidate in [old_json, context_only, operation_null_only] { + let response = server + .client + .put(server.url(&format!("/projects/{id}/model"))) + .header("if-match", after.etag()) + .json(&candidate) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + unchanged(&server, &after, &disk, &bytes).await; + } + let mut old_source = after.source_project.clone(); + let entry = old_source.files.get_mut(&old_source.entry).unwrap(); + *entry = entry.replacen("arcadia 7;", "arcadia 6;", 1); + let response = server + .client + .put(server.url(&format!("/projects/{id}/source-project"))) + .header("if-match", after.etag()) + .json(&old_source) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + unchanged(&server, &after, &disk, &bytes).await; + server.stop().await; + let server = TestServer::start(directory.path(), None).await; + unchanged(&server, &after, &disk, &bytes).await; + server.stop().await; +} diff --git a/crates/oa-server/tests/semantic_schema.py b/crates/oa-server/tests/semantic_schema.py index d5e5b56f..18087f00 100644 --- a/crates/oa-server/tests/semantic_schema.py +++ b/crates/oa-server/tests/semantic_schema.py @@ -1,7 +1,7 @@ """Refresh the closed semantic OpenAPI catalog from the frozen Rust leaf table. Run from any directory with Python 3: semantic_schema.py [--check] [--validate]. ---validate additionally runs source-6 structural fixtures with jsonschema. +--validate additionally runs source-7 structural fixtures with jsonschema. This generates documentation only, never runtime selectors or model mutations. """ import copy @@ -99,7 +99,7 @@ for family, selector, review, body in families: schemas[review] = {"oneOf": [variant(leaf, reviewed(value)) for leaf, value in leaves]} selectors.append(variant(family, ref(selector))) reviews.append(variant(family, ref(review))) -assert leaf_count == 85 +assert leaf_count == 90 schemas["AnnotationKey"] = obj({"path": array(ref("Id")), "field": ref("Id")}) id_keys, text_keys, annotation_keys = keypair("Id"), keypair("String"), keypair("AnnotationKey") artifact_types = {"Presence": "bool", "Name": "String", "Description": "String", "Owner": "Id", @@ -124,13 +124,13 @@ schemas["FacetReview"] = {"oneOf": reviews} schemas["SemanticBaseline"] = obj({"facets": array(ref("FacetReview"))}) schemas["TraceBaseline"]["properties"]["semantic"] = {**optional(ref("SemanticBaseline")), "default": None, "description": "Absent/null means Unreviewed. Historical typed facts never become live graph references."} -schemas["Project"]["properties"]["schema_version"]["const"] = 6 -schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4, 5] -schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 6 -schemas["MigrationReport"]["description"] = re.sub(r"V1(?:/V[2-5])+ to V[4-6]", "V1/V2/V3/V4/V5 to V6", schemas["MigrationReport"]["description"]) -spec["info"]["description"] = re.sub(r"schema-[45]", "schema-6", spec["info"]["description"]) -spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-6 native JSON is described below. Schema-1/2/3/4/5 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Earlier versions reject source-6 constructors and saved view fields. Prior prose and typed reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 6 / arcadia 6. See docs/language.rst." -schemas["SourceBundle"]["description"] = re.sub(r"source[45] workspace \(source[^)]+\)", "source6 workspace (source3/4/5 migrate explicitly)", schemas["SourceBundle"]["description"]) +schemas["Project"]["properties"]["schema_version"]["const"] = 7 +schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4, 5, 6] +schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 7 +schemas["MigrationReport"]["description"] = re.sub(r"V1(?:/V[2-6])+ to V[4-7]", "V1/V2/V3/V4/V5/V6 to V7", schemas["MigrationReport"]["description"]) +spec["info"]["description"] = re.sub(r"schema-[4-6]", "schema-7", spec["info"]["description"]) +spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-7 native JSON is described below. Schema-1/2/3/4/5/6 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Earlier versions reject later constructors, fields and semantic facets, including explicit null. Prior prose and typed reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 7 / arcadia 7. See docs/language.rst." +schemas["SourceBundle"]["description"] = re.sub(r"source[4-6] workspace \(source[^)]+\)", "source7 workspace (source3/4/5/6 migrate explicitly)", schemas["SourceBundle"]["description"]) # Source-6 additions are closed serde records; selectors with an empty payload # remain explicit selections rather than reverting to their omitted defaults. @@ -155,6 +155,28 @@ def upsert_variant(variants, name, value): upsert_variant(schemas["RelationBody"]["oneOf"], "CommunicationMean", obj({ "source": ref("Id"), "target": ref("Id"), "oriented": typed("bool"), "items": array(ref("Id"))})) + +# Source-7 adds exact caller-instance addresses and explicit operation intent. +# Structural schemas do not replace core owner/path/port/signature validation. +schemas["OccurrenceEndpoint"] = obj({"references": array(ref("Id"), 64), "occurrence": ref("Id")}) +schemas["OccurrenceEndpoint"]["description"] = "Ordered reference-occurrence IDs in an explicit caller owner, followed by the leaf contribution ID. Empty references means a direct contribution; sequence definition IDs are not path components." +schemas["ContextualLinkPurpose"] = {"oneOf": [ + variant("Precedence", obj({"guard": typed("String")})), + variant("Exchange", obj({"exchange": ref("Id")})), + variant("CommonWork", obj({"work": ref("Id")}))]} +upsert_variant(schemas["RelationBody"]["oneOf"], "ContextualLink", obj({ + "owner": ref("Id"), "source": ref("OccurrenceEndpoint"), + "target": ref("OccurrenceEndpoint"), "purpose": ref("ContextualLinkPurpose")})) +schemas["OperationUse"] = obj({"allocation": optional(ref("Id"))}, []) +schemas["OperationUse"]["description"] = "Present with absent/null allocation retains unresolved operation intent. A selected UUID identifies exactly an InterfaceItemAllocation, never an item, port, or name. Reply derives operation from its exact correlated Call." +exchange_body = next(v["properties"]["Exchange"] for v in schemas["OccurrenceBody"]["oneOf"] + if "Exchange" in v.get("properties", {})) +exchange_body["properties"]["operation"] = {**optional(ref("OperationUse")), "default": None} +exchange_body["description"] = "Absent/null operation retains ordinary channel semantics and is omitted on output. Present operation is allowed only on SA BoundaryInterface Call/Asynchronous messages. Reply stores no operation and derives its exact Call's selection. Existing message defaults remain unchanged." +schemas["OperationOmission"] = obj({"source": ref("Id"), "rationale": typed("String")}) +schemas["TransitionRequest"]["properties"]["operation_omissions"] = { + **array(ref("OperationOmission"), 200), "default": [], + "description": "Explicit SA-to-OA abstraction per selected invocation with operation intent. Nonempty rationale required by core; duplicates, irrelevant sources, and other directions are refused. Replies derive the Call abstraction. Source operation identities and values are preserved."} upsert_variant(schemas["SharedBody"]["oneOf"][1]["oneOf"], "ExchangeCategory", obj({ "family": ref("ExchangeFamily"), "exchanges": array(ref("Id"))})) schemas["CommunicationPortChoice"] = {"oneOf": [ @@ -298,9 +320,42 @@ if "--validate" in sys.argv: accepts("FacetSelector", {"Category": "Unknown"}, False) accepts("FacetReview", {"Category": {"Family": {"oa": {"Value": "Work"}, "sa": {"Value": "Participant"}}}}) accepts("FacetSelector", {"Member": {"collection": "CategoryExchanges", "keys": {"oa": a, "sa": b}}}) - assert schemas["Project"]["properties"]["schema_version"]["const"] == 6 - assert schemas["MigrationReport"]["properties"]["from_schema"]["enum"] == [1, 2, 3, 4, 5] - print(f"Source-6 schema fixtures: {fixture_count} passed; all {len(schemas)} component schemas well-formed") + endpoint = {"references": [a], "occurrence": b} + accepts("OccurrenceEndpoint", endpoint) + accepts("OccurrenceEndpoint", {**endpoint, "references": []}) + accepts("OccurrenceEndpoint", {**endpoint, "references": [a] * 64}) + accepts("OccurrenceEndpoint", {**endpoint, "references": [a] * 65}, False) + accepts("OccurrenceEndpoint", {**endpoint, "typo": a}, False) + accepts("OccurrenceEndpoint", {"occurrence": a}, False) + for purpose in [{"Precedence": {"guard": "ready"}}, {"Exchange": {"exchange": a}}, + {"CommonWork": {"work": b}}]: + link = {"owner": a, "source": endpoint, "target": {"references": [], "occurrence": a}, + "purpose": purpose} + accepts("RelationBody", {"ContextualLink": link}) + accepts("RelationBody", {"ContextualLink": {**link, "extra": None}}, False) + accepts("ContextualLinkPurpose", {"CommonWork": {"work": a, "guard": ""}}, False) + accepts("OperationUse", {}) + accepts("OperationUse", {"allocation": None}) + accepts("OperationUse", {"allocation": a}) + accepts("OperationUse", {"allocation": a, "item": b}, False) + accepts("OperationUse", {"allocation": "wrong"}, False) + message = {"exchange": a, "sender": a, "receiver": b, "message_kind": "Call"} + for operation in [None, {}, {"allocation": None}, {"allocation": a}]: + accepts("OccurrenceBody", {"Exchange": {**message, "operation": operation}}) + accepts("OccurrenceBody", {"Exchange": {**message, "operation": a}}, False) + omission = {"source": a, "rationale": "Operational abstraction retains the request intent."} + accepts("OperationOmission", omission) + accepts("OperationOmission", {"source": a}, False) + accepts("TransitionRequest", {**request, "operation_omissions": [omission] * 200}) + accepts("TransitionRequest", {**request, "operation_omissions": [omission] * 201}, False) + accepts("FacetSelector", {"Occurrence": "Operation"}) + for facet in ["ContextOwner", "ContextSource", "ContextTarget", "ContextPurpose"]: + accepts("FacetSelector", {"Relation": facet}) + accepts("FacetReview", {"Occurrence": {"Operation": { + "oa": "NotApplicable", "sa": {"Value": {"allocation": a}}}}}) + assert schemas["Project"]["properties"]["schema_version"]["const"] == 7 + assert schemas["MigrationReport"]["properties"]["from_schema"]["enum"] == [1, 2, 3, 4, 5, 6] + print(f"Source-7 schema fixtures: {fixture_count} passed; all {len(schemas)} component schemas well-formed") rendered = json.dumps(spec, indent=2, ensure_ascii=False) + "\n" if "--check" in sys.argv: diff --git a/docs/api.html b/docs/api.html index f43c3e8f..80433d9b 100644 --- a/docs/api.html +++ b/docs/api.html @@ -2,7 +2,7 @@ - + Arcadia Studio model REST API