commit 93ba83d321cae57a40c3f7fd8e894dd33fba07d5 Author: sorokin.oleg.edu Date: Sat Sep 5 17:04:16 2026 +0300 Establish OA/SA prototype baseline and first-release work diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..cff6285e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto eol=lf +*.rs text eol=lf +*.toml text eol=lf +*.lock text eol=lf +*.md text eol=lf +*.rst text eol=lf +*.json text eol=lf +*.arcadia text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf +*.png binary +*.jpg binary +*.zip binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8ee8e182 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: Rust checks +on: [push, pull_request] +jobs: + check: + strategy: + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Linux libraries + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + - run: cargo test --workspace --locked + - run: cargo build --release --workspace --locked + - uses: actions/upload-artifact@v4 + with: + name: arcadia-studio-${{ runner.os }} + path: | + target/release/arcadia-studio.exe + target/release/arcadia-studio + target/release/arcadia-server.exe + target/release/arcadia-server diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..46704baa --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target/ +/dist/ +*.oa.json.bak +*.recovery.json +/.env +/.env.* +!/.env.example +*.log +*.pdb +*.exe +/model-store/ +/.local/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..bca46d35 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4852 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.20", + "x11rb", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 2.0.20", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "lyon_path", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "lyon", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "lilt" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabea159dc6eea9171a541b458134b70ca95e3162d068132b2b3b9bc01aad06e" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "oa-core" +version = "0.2.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "uuid", +] + +[[package]] +name = "oa-desktop" +version = "0.2.0" +dependencies = [ + "iced", + "oa-core", + "rfd", + "serde_json", + "tokio", +] + +[[package]] +name = "oa-server" +version = "0.2.0" +dependencies = [ + "axum", + "oa-core", + "reqwest", + "serde", + "serde_json", + "subtle", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tower-http", + "uuid", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.20", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 2.0.20", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..0abfad4c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server"] +resolver = "2" + +[workspace.package] +version = "0.2.0" +edition = "2024" +license = "MIT" +rust-version = "1.90" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "v5", "serde"] } +thiserror = "2" + +[profile.dev] +debug = 0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..982e1c9a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Arcadia Studio contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..f422382c --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Arcadia Studio + +Arcadia Studio is a native Rust/Iced modeling workbench for system engineers. V2 covers Operational Analysis (OA) and System Analysis (SA), with graphical and textual editing of one typed model and a deployable Rust REST service. It is an independent implementation inspired by Arcadia and Eclipse Capella, not a complete Capella or SysML implementation. + +The normative [language and editing contract](docs/language.rst) defines supported semantics, both representations, validation and migration. [Scope](docs/scope.md) states the boundaries. The [acceptance](docs/acceptance.md), [validation](docs/validation.md) and [current state](harness/STATE.md) records document V2 tests and native Windows/Linux Computer Use verification. + +## Build and run + +Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are: + +```sh +sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev +``` + +From the repository root: + +```sh +cargo run --locked -p oa-desktop --bin arcadia-studio +``` + +Build both desktop and server release executables: + +```sh +cargo build --release --workspace --locked +``` + +Run `target/release/arcadia-studio.exe` on Windows or `./target/release/arcadia-studio` on Linux. No task-specific cache directory is required. A graphical desktop and compatible graphics driver are required; the application uses Iced 0.14 with WGPU and X11/Wayland support. It does not require Java, Eclipse, a browser, an account or a running server. + +On the tested Ubuntu 24.04/WSLg environment, Mesa's Vulkan software adapter is required: + +```sh +sudo apt-get install mesa-vulkan-drivers +WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json ./target/release/arcadia-studio +``` + +A compatible native Linux graphics adapter can use the ordinary command. This software-adapter workaround requires the indicated driver file. See the [platform verification record](docs/validation.md) for tested environments. + +V2 release bundles are `dist/ArcadiaStudio-Windows.zip` and `dist/ArcadiaStudio-Linux.tar.gz`, with archive checksums in `dist/SHA256SUMS.txt`. Extract the bundle and run its `arcadia-studio.exe` or `./arcadia-studio`; Rust is not needed to run a packaged executable. Both bundles include the headless server, examples, specifications and deployment instructions. Linux bundles target the documented Ubuntu 24.04 runtime. Packaged executable and archive evidence is recorded in [package verification](harness/verification-v2-packages.md). + +## Model operational needs and system responsibilities + +The coastal rescue example combines people, an organization, a System of Interest, information and physical exchange items, normal/degraded behavior and code/CAD evidence. Choose **New** to start an empty project. + +1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility. +2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version. +3. Use **Create relationship** for allocations, interactions/functional exchanges, capability involvement, mission links, state transitions and interface relationships. Add exchange items, typed data, ports and provided/required interfaces in **Data & interfaces**. +4. Create multiple architecture views. **Decomposition** nests semantic children; **Allocation** nests work within performers, including computed summaries. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values. +5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. State machines contain regions and states/modes with guarded transitions, availability and state-action links. Conditions are descriptive, not executable simulation code. Creating records in Behavior or Data preserves your architecture layouts; **Show in this diagram** adds an explicit placement when desired. +6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata. +7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft. + +The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Model history retains up to 100 edits. + +Drag cards to move on a 10-unit grid, drag empty space to pan, and scroll to zoom. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file. + +## Edit the same model as text + +**Textual model** has syntax highlighting, live linting and explicit **Apply to model**. For example: + +```text +arcadia 2; +project harbor "Harbor assistance" { + oa_entity control "Harbor control" {} + oa_activity assess "Assess assistance" {} + allocation performs "Performs" { source @control; target @assess; } + sa_system service "Harbor assistance system" {} + sa_function evaluate "Evaluate assistance request" {} + trace assessment_trace "Operational realization" { + oa @assess; sa @evaluate; kind Refinement; + } +} +``` + +New declarations may omit IDs; Apply writes assigned UUIDs into canonical source. Keep existing identity clauses to preserve objects. Stable `@aliases` survive display-name changes and support forward references. The [normative specification](docs/language.rst) covers every supported declaration, typed constructor, relationship and persisted diagram field. + +Applying valid source commits the whole candidate as one model transaction. Graphical edits regenerate canonical source. Invalid source remains a draft with located diagnostics. **Undo text** and **Undo model** are distinct histories. Apply canonicalizes formatting and removes comments; comments and whitespace are not retained model data. Names, aliases, exact strings, typed values, references, occurrence identity and placement data are retained. Version 1 source/JSON is migrated explicitly without inventing SA content. + +## Save and reopen + +Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement. + +Save validates a snapshot and atomically replaces the destination. Normal saves compare the previously read file bytes and reject a detected external change; preserve your draft and use **Save as** or reopen deliberately. This is a conflict check, not distributed locking. Dirty New/Open/Close operations offer save/discard/cancel. There is no autosave or crash recovery. + +## REST service and external tools + +Run the server without GUI dependencies: + +```sh +cargo run --locked -p oa-server --bin arcadia-server +``` + +The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-dir PATH`, `--bind IP:PORT` and `--token-file PATH` configure deployment; binding outside loopback requires a bearer token. See [server setup](docs/server.md), the [REST contract](docs/api.rst), [OpenAPI](docs/openapi.json) and [deployment examples](deployment). + +The API queries and edits complete models, canonical source, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation as the desktop. Writes require the returned ETag in `If-Match`. The server retains the current revision only. + +Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Save a model as `system.arcadia`, import it, then edit using the API: + +```sh +curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \ + -H 'Content-Type: text/plain' --data-binary @system.arcadia +``` + +The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects//source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them. + +## Development + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +cargo build --release --workspace --locked +``` + +- `crates/oa-core`: typed model, semantics, commands/history, references, projections, transition planner, language and persistence. +- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows. +- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store. + +The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype. diff --git a/crates/oa-core/Cargo.toml b/crates/oa-core/Cargo.toml new file mode 100644 index 00000000..47f41272 --- /dev/null +++ b/crates/oa-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "oa-core" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +thiserror.workspace = true +tempfile = "3" diff --git a/crates/oa-core/src/demo.rs b/crates/oa-core/src/demo.rs new file mode 100644 index 00000000..9db2bfb3 --- /dev/null +++ b/crates/oa-core/src/demo.rs @@ -0,0 +1,710 @@ +use crate::*; +use std::collections::BTreeMap; +fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option) -> Id { + p.add_element(kind, layer, name, parent).unwrap() +} +fn occurrence( + p: &mut Project, + owner: Id, + layer: Layer, + name: &str, + exchange: Id, + sender: Option, + receiver: Option, +) -> Id { + let id = add(p, Kind::Occurrence, layer, name, Some(owner)); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(exchange), + sender, + receiver, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} +fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id { + let id = add(p, Kind::Port, Layer::Sa, name, Some(owner)); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port { + direction, + interface: Some(interface), + }; + id +} +fn diagram( + p: &mut Project, + layer: Layer, + projection: ProjectionKind, + name: &str, + elements: &[Id], +) -> Id { + let id = Id::new_v4(); + let alias = p.fresh_alias(name, "diagram", id); + p.diagrams.insert( + id, + Diagram { + id, + alias, + name: name.into(), + description: "A focused projection of the shared engineering model.".into(), + layer, + projection, + nodes: BTreeMap::new(), + }, + ); + for (index, e) in elements.iter().enumerate() { + p.place( + id, + *e, + Position { + x: 60. + (index % 4) as f32 * 280., + y: 70. + (index / 4) as f32 * 170., + }, + ); + } + id +} +fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) { + let id = Id::new_v4(); + let name = format!("{} ↔ {}", p.named(oa), p.named(sa)); + let alias = p.fresh_alias(&format!("trace_{}", p.named(oa)), "trace", id); + p.traces.insert( + id, + Trace { + id, + alias, + name, + description: "Reviewed OA/SA correspondence in the coastal rescue example.".into(), + oa, + sa, + kind, + baseline: Some(TraceBaseline { + oa: p.properties(oa).unwrap(), + sa: p.properties(sa).unwrap(), + }), + }, + ); +} + +pub fn coastal_rescue() -> Project { + let mut p = Project::empty(); + p.name = "Coastal rescue — operational need to system behavior".into(); + p.description="A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts.".into(); + let coordination = add(&mut p, Kind::Entity, Layer::Oa, "Rescue coordination", None); + let vessel = add(&mut p, Kind::Actor, Layer::Oa, "Vessel in distress", None); + let role = add(&mut p, Kind::Role, Layer::Oa, "Incident coordinator", None); + let oa_parent = add( + &mut p, + Kind::Activity, + Layer::Oa, + "Respond to distress", + None, + ); + let report = add( + &mut p, + Kind::Activity, + Layer::Oa, + "Report distress", + Some(oa_parent), + ); + let assess = add( + &mut p, + Kind::Activity, + Layer::Oa, + "Assess incident", + Some(oa_parent), + ); + let oa_cap = add( + &mut p, + Kind::Capability, + Layer::Oa, + "Protect life at sea", + None, + ); + let process = add( + &mut p, + Kind::Process, + Layer::Oa, + "Distress to response", + None, + ); + let oa_item = add( + &mut p, + Kind::ExchangeItem, + Layer::Oa, + "Distress report", + None, + ); + p.relate(RelationKind::Allocation, "Reports distress", vessel, report); + p.relate( + RelationKind::Allocation, + "Assesses incident", + coordination, + assess, + ); + p.relate( + RelationKind::RoleRealization, + "Fulfils coordinator role", + coordination, + role, + ); + p.relate( + RelationKind::RoleAssignment, + "Coordinates assessment", + role, + assess, + ); + let interaction = p.relate(RelationKind::Interaction, "Distress signal", report, assess); + if let RelationBody::Interaction { items, .. } = + &mut p.relations.get_mut(&interaction).unwrap().body + { + items.push(oa_item); + } + p.relate( + RelationKind::CapabilityInvolvement, + "Operational contribution", + assess, + oa_cap, + ); + p.relate( + RelationKind::ChainInvolvement, + "Operational realization", + process, + oa_cap, + ); + let oa_occurrence = occurrence( + &mut p, + process, + Layer::Oa, + "Distress received", + interaction, + None, + None, + ); + + let system = add( + &mut p, + Kind::System, + Layer::Sa, + "Rescue coordination system", + None, + ); + let actor = add( + &mut p, + Kind::SystemActor, + Layer::Sa, + "Vessel communication service", + None, + ); + let sa_parent = add( + &mut p, + Kind::Function, + Layer::Sa, + "Manage distress response", + None, + ); + let transmit = add( + &mut p, + Kind::Function, + Layer::Sa, + "Transmit distress report", + Some(sa_parent), + ); + let coordinate = add( + &mut p, + Kind::Function, + Layer::Sa, + "Classify incident and alert operator", + Some(sa_parent), + ); + let sa_cap = add( + &mut p, + Kind::SystemCapability, + Layer::Sa, + "Coordinate rescue response", + None, + ); + let chain = add( + &mut p, + Kind::FunctionalChain, + Layer::Sa, + "Digital distress handling", + None, + ); + let mission = add( + &mut p, + Kind::Mission, + Layer::Sa, + "Support timely rescue decisions", + None, + ); + p.relate( + RelationKind::Allocation, + "Vessel transmission", + actor, + transmit, + ); + p.relate( + RelationKind::Allocation, + "System assessment", + system, + coordinate, + ); + p.relate( + RelationKind::CapabilityInvolvement, + "System capability support", + coordinate, + sa_cap, + ); + p.relate( + RelationKind::ChainInvolvement, + "Functional chain support", + chain, + sa_cap, + ); + p.relate( + RelationKind::MissionCapability, + "Mission capability", + mission, + sa_cap, + ); + let information = add( + &mut p, + Kind::ExchangeItem, + Layer::Sa, + "Digital distress report", + None, + ); + let material = add( + &mut p, + Kind::ExchangeItem, + Layer::Sa, + "Rescue supply package", + None, + ); + *p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { + kind: ExchangeItemKind::Material, + }; + let energy = add( + &mut p, + Kind::ExchangeItem, + Layer::Sa, + "Backup electrical energy", + None, + ); + *p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { + kind: ExchangeItemKind::Energy, + }; + let field = add( + &mut p, + Kind::DataField, + Layer::Sa, + "Reported latitude", + Some(information), + ); + *p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField { + type_ref: TypeRef::Real, + minimum: 1, + maximum: Some(1), + unit: "degrees north".into(), + }; + let interface = add( + &mut p, + Kind::Interface, + Layer::Sa, + "Distress reporting interface", + None, + ); + *p.elements + .get_mut(&interface) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Interface { + items: vec![information], + }; + let vessel_port = port( + &mut p, + actor, + "Vessel reporting output", + PortDirection::Out, + interface, + ); + let system_port = port( + &mut p, + system, + "System reporting input", + PortDirection::In, + interface, + ); + let function_out = port( + &mut p, + transmit, + "Report function output", + PortDirection::Out, + interface, + ); + let function_in = port( + &mut p, + coordinate, + "Assessment function input", + PortDirection::In, + interface, + ); + let exchange = p.relate( + RelationKind::FunctionalExchange, + "Validated distress report", + transmit, + coordinate, + ); + p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange { + source: transmit, + target: coordinate, + source_port: Some(function_out), + target_port: Some(function_in), + items: vec![information], + }; + let boundary = p.relate( + RelationKind::BoundaryExchange, + "Distress reporting connection", + vessel_port, + system_port, + ); + if let RelationBody::BoundaryExchange { items, .. } = + &mut p.relations.get_mut(&boundary).unwrap().body + { + items.push(information); + } + p.relate( + RelationKind::ExchangeAllocation, + "Report carried by connection", + boundary, + exchange, + ); + p.relate( + RelationKind::InterfaceProvision, + "Reporting service provided", + actor, + interface, + ); + p.relate( + RelationKind::InterfaceRequirement, + "Reporting service required", + system, + interface, + ); + let sa_occurrence = occurrence( + &mut p, + chain, + Layer::Sa, + "Report handled", + exchange, + None, + None, + ); + + let scenario = add( + &mut p, + Kind::Scenario, + Layer::Sa, + "Repeated distress reports", + None, + ); + *p.elements.get_mut(&scenario).unwrap().shared_mut().unwrap() = SharedBody::Scenario { + viewpoint: ScenarioViewpoint::BoundaryInterface, + context: Some(sa_cap), + precondition: "A vessel has detected an emergency and the reporting service is available." + .into(), + postcondition: "The operator has a classified incident with the latest vessel report." + .into(), + steps: vec![], + }; + let vessel_line = add( + &mut p, + Kind::Lifeline, + Layer::Sa, + "Vessel service lifeline", + Some(scenario), + ); + *p.elements + .get_mut(&vessel_line) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { + represented: Some(actor), + }; + let system_line = add( + &mut p, + Kind::Lifeline, + Layer::Sa, + "Coordination system lifeline", + Some(scenario), + ); + *p.elements + .get_mut(&system_line) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { + represented: Some(system), + }; + occurrence( + &mut p, + scenario, + Layer::Sa, + "Initial distress report", + exchange, + Some(vessel_line), + Some(system_line), + ); + let update = occurrence( + &mut p, + scenario, + Layer::Sa, + "Updated distress report", + exchange, + Some(vessel_line), + Some(system_line), + ); + if let Some(SharedBody::Occurrence { + precondition, + postcondition, + .. + }) = p.elements.get_mut(&update).unwrap().shared_mut() + { + *precondition = "The vessel has more recent position information.".into(); + *postcondition = + "The incident position is refreshed without creating a second incident.".into(); + } + + let machine = add( + &mut p, + Kind::StateMachine, + Layer::Sa, + "Reporting operation modes", + Some(system), + ); + let region = add( + &mut p, + Kind::Region, + Layer::Sa, + "Reporting quality region", + Some(machine), + ); + let initial = add( + &mut p, + Kind::Initial, + Layer::Sa, + "Start reporting", + Some(region), + ); + let normal = add( + &mut p, + Kind::Mode, + Layer::Sa, + "Normal reporting", + Some(region), + ); + let degraded = add( + &mut p, + Kind::Mode, + Layer::Sa, + "Degraded reporting", + Some(region), + ); + p.relate( + RelationKind::StateTransition, + "Initialize reporting", + initial, + normal, + ); + let degrade = p.relate( + RelationKind::StateTransition, + "Signal degraded", + normal, + degraded, + ); + let timeout = add(&mut p, Kind::Event, Layer::Sa, "Report timeout", None); + *p.elements.get_mut(&timeout).unwrap().shared_mut().unwrap() = SharedBody::Event { + body: EventBody::Time { + kind: TimeEventKind::After, + expression: "30 s".into(), + }, + }; + p.relations.get_mut(°rade).unwrap().body = RelationBody::StateTransition { + source: normal, + target: degraded, + transition_kind: TransitionKind::External, + triggers: vec![timeout], + effects: vec![], + trigger: "report_timeout".into(), + guard: "seconds_since_last_report > 30".into(), + effect: "Flag the incident and request an operator review.".into(), + }; + let restore = p.relate( + RelationKind::StateTransition, + "Signal restored", + degraded, + normal, + ); + let report_received = add( + &mut p, + Kind::Event, + Layer::Sa, + "Validated report received", + None, + ); + *p.elements + .get_mut(&report_received) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Event { + body: EventBody::Signal { + items: vec![information], + }, + }; + p.relations.get_mut(&restore).unwrap().body = RelationBody::StateTransition { + source: degraded, + target: normal, + transition_kind: TransitionKind::External, + triggers: vec![report_received], + effects: vec![], + trigger: "validated_report_received".into(), + guard: "position_is_current".into(), + effect: "Clear the degraded-reporting alert.".into(), + }; + p.relate( + RelationKind::Availability, + "Assessment in normal mode", + coordinate, + normal, + ); + p.relate( + RelationKind::Availability, + "Assessment in degraded mode", + coordinate, + degraded, + ); + let action = p.relate( + RelationKind::StateAction, + "Review on degraded entry", + degraded, + coordinate, + ); + p.relations.get_mut(&action).unwrap().body = RelationBody::StateAction { + source: degraded, + target: coordinate, + phase: StateActionKind::Entry, + }; + let constraint = add( + &mut p, + Kind::Constraint, + Layer::Sa, + "Response time budget", + None, + ); + *p.elements + .get_mut(&constraint) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Constraint { + expression: "incident_alert_latency <= 120 s".into(), + constrained: vec![coordinate, exchange], + }; + for (owner, name, uri, revision, relation) in [ + ( + coordinate, + "Response implementation", + "git+https://example.org/rescue/control?path=src/response.rs", + "design-v2", + ArtifactRelation::Implements, + ), + ( + system, + "Console enclosure CAD", + "cad://rescue-console/enclosure.step", + "B.3", + ArtifactRelation::References, + ), + ] { + let id = Id::new_v4(); + let alias = p.fresh_alias(name, "artifact", id); + p.artifact_links.insert(id,ArtifactLink{id,alias,name:name.into(),description:"Illustrative external engineering reference; opening a model does not fetch it.".into(),owner,relation,uri:uri.into(),revision:Some(revision.into()),fragment:None}); + } + for (oa, sa, kind) in [ + (coordination, system, TraceKind::Participant), + (vessel, actor, TraceKind::Participant), + (oa_parent, sa_parent, TraceKind::Refinement), + (report, transmit, TraceKind::Refinement), + (assess, coordinate, TraceKind::Refinement), + (oa_cap, sa_cap, TraceKind::Capability), + (process, chain, TraceKind::Sequence), + (oa_occurrence, sa_occurrence, TraceKind::Scenario), + (interaction, exchange, TraceKind::Exchange), + (oa_item, information, TraceKind::Item), + ] { + trace(&mut p, oa, sa, kind); + } + let oa_view = diagram( + &mut p, + Layer::Oa, + ProjectionKind::Allocation, + "OA participants and activities", + &[coordination, vessel, report, assess], + ); + let sa_view = diagram( + &mut p, + Layer::Sa, + ProjectionKind::Allocation, + "SA system boundary and functions", + &[system, actor, transmit, coordinate], + ); + let functions = diagram( + &mut p, + Layer::Sa, + ProjectionKind::FunctionalDataflow, + "SA functional decomposition", + &[sa_parent, transmit, coordinate], + ); + let oa_capabilities = diagram( + &mut p, + Layer::Oa, + ProjectionKind::Capabilities, + "OA capabilities and participants", + &[coordination, vessel, role, oa_cap], + ); + let sa_capabilities = diagram( + &mut p, + Layer::Sa, + ProjectionKind::Capabilities, + "SA missions and capabilities", + &[system, actor, mission, sa_cap], + ); + let activities = diagram( + &mut p, + Layer::Oa, + ProjectionKind::FunctionalDataflow, + "OA activity dataflow", + &[oa_parent, report, assess], + ); + execute_batch( + &p, + &[ + Command::Layout(oa_view), + Command::Layout(sa_view), + Command::Layout(functions), + Command::Layout(oa_capabilities), + Command::Layout(sa_capabilities), + Command::Layout(activities), + ], + ) + .expect("The bundled OA/SA example is structurally valid") +} diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs new file mode 100644 index 00000000..ebb2d3ee --- /dev/null +++ b/crates/oa-core/src/language.rs @@ -0,0 +1,2024 @@ +//! Typed OA/SA language, tolerant source analysis, and explicit V1 migration. +use crate::*; +use serde_json::{Map, Value}; +use std::{collections::BTreeMap, fmt}; +/// A located syntax or engineering diagnostic. Positions are one-based and +/// columns count Unicode scalars. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + pub severity: validation::Severity, + pub code: String, + pub object_id: Option, + pub field: Option, + pub line: usize, + pub column: usize, + pub message: String, + pub span: Span, +} + +/// Admission budgets applied before structural validation. Limits are caller +/// policy, not a restriction on the engineering language's semantics. +#[derive(Debug, Clone, Copy)] +pub struct ParseLimits { + pub max_source_bytes: usize, + pub max_graph_objects: usize, + pub max_records: usize, + pub max_step_references: usize, +} + +impl ParseLimits { + pub const UNLIMITED: Self = Self { + max_source_bytes: usize::MAX, + max_graph_objects: usize::MAX, + max_records: usize::MAX, + max_step_references: usize::MAX, + }; + + pub(crate) fn check_counts( + self, + graph: usize, + records: usize, + steps: usize, + ) -> Result<(), String> { + if graph > self.max_graph_objects + || records > self.max_records + || steps > self.max_step_references + { + return Err(format!( + "Model exceeds admission limits ({} graph objects, {} total records, {} ordered references).", + self.max_graph_objects, self.max_records, self.max_step_references + )); + } + Ok(()) + } + + pub fn check_model(self, project: &Project) -> Result<(), String> { + let graph = project + .elements + .len() + .saturating_add(project.relations.len()); + let records = graph + .saturating_add(1) + .saturating_add(project.diagrams.len()) + .saturating_add( + project + .diagrams + .values() + .map(|d| d.nodes.len()) + .sum::(), + ) + .saturating_add(project.artifact_links.len()) + .saturating_add(project.traces.len()); + let steps = project + .elements + .values() + .map(|e| e.steps().map_or(0, Vec::len)) + .sum(); + self.check_counts(graph, records, steps) + } +} + +/// Half-open UTF-8 byte offsets into the exact analyzed source buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenClass { + Keyword, + Identifier, + String, + Number, + Comment, + Punctuation, + Whitespace, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenSpan { + pub class: TokenClass, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclarationSpan { + pub alias: String, + pub id: Option, + pub kind: String, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Analysis { + pub tokens: Vec, + pub diagnostics: Vec, + pub declarations: Vec, +} + +/// Tolerant highlighting tokens cover the whole input, including incomplete +/// strings and unexpected characters. No model is created or modified. +pub fn tokenize(source: &str) -> Vec { + let mut tokens = Vec::new(); + let mut cursor = 0; + while cursor < source.len() { + let start = cursor; + let ch = source[cursor..].chars().next().expect("in bounds"); + let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') { + cursor += 1; + while source + .as_bytes() + .get(cursor) + .is_some_and(|next| matches!(*next, b' ' | b'\t' | b'\r' | b'\n')) + { + cursor += 1; + } + TokenClass::Whitespace + } else if source[cursor..].starts_with("//") { + cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor); + TokenClass::Comment + } else if ch == '"' { + cursor += 1; + let mut escaped = false; + let mut closed = false; + while cursor < source.len() { + let next = source[cursor..].chars().next().expect("in bounds"); + cursor += next.len_utf8(); + if escaped { + escaped = false; + } else if next == '\\' { + escaped = true; + } else if next == '"' { + closed = true; + break; + } + } + if closed && serde_json::from_str::(&source[start..cursor]).is_ok() { + TokenClass::String + } else { + TokenClass::Error + } + } else if ch.is_ascii_alphabetic() || ch == '_' { + cursor += 1; + while source + .as_bytes() + .get(cursor) + .is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_') + { + cursor += 1; + } + if reserved(&source[start..cursor]) + || matches!(&source[start..cursor], "true" | "false" | "none") + { + TokenClass::Keyword + } else { + TokenClass::Identifier + } + } else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') { + cursor += 1; + while source.as_bytes().get(cursor).is_some_and(|next| { + next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_') + }) { + cursor += 1; + } + if serde_json::from_str::(&source[start..cursor]).is_ok() { + TokenClass::Number + } else { + TokenClass::Error + } + } else if "{}();,[]:=@".contains(ch) { + cursor += ch.len_utf8(); + TokenClass::Punctuation + } else { + cursor += ch.len_utf8(); + TokenClass::Error + }; + tokens.push(TokenSpan { + class, + span: Span { start, end: cursor }, + }); + } + tokens +} + +pub fn lint(source: &str) -> Vec { + match parse_document(source, ParseLimits::UNLIMITED) { + Ok(parsed) => parsed.diagnostics(source), + Err(diagnostics) => diagnostics, + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}: {}", self.line, self.column, self.message) + } +} + +#[derive(Debug)] +struct Error { + code: &'static str, + offset: usize, + message: String, +} +fn diagnostic(source: &str, error: Error) -> Diagnostic { + let start = error.offset.min(source.len()); + let prefix = &source[..start]; + Diagnostic { + severity: validation::Severity::Error, + code: error.code.into(), + object_id: None, + field: None, + line: prefix.bytes().filter(|b| *b == b'\n').count() + 1, + column: prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1, + message: error.message, + span: Span { + start, + end: start + source[start..].chars().next().map_or(0, char::len_utf8), + }, + } +} +fn reserved(word: &str) -> bool { + crate::reserved_alias(word) + || declaration_kind(word).is_some() + || matches!(word, "none" | "node") +} + +#[derive(Debug, Clone, Copy)] +enum DeclKind { + Element(Kind, Layer), + Relation(RelationKind), + Diagram, + Node, + Artifact, + Trace, +} +fn snake(value: &str) -> String { + let mut out = String::new(); + for (i, c) in value.chars().enumerate() { + if c.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(c.to_ascii_lowercase()); + } else { + out.push(c) + } + } + out +} +fn element_word(kind: Kind, layer: Layer) -> String { + let stem = match kind { + Kind::SystemActor => "actor".into(), + Kind::SystemCapability => "capability".into(), + Kind::OperationalMission => "mission".into(), + _ => snake(serde_json::to_value(kind).unwrap().as_str().unwrap()), + }; + std::format!("{}_{stem}", if layer == Layer::Oa { "oa" } else { "sa" }) +} +fn relation_word(kind: RelationKind) -> String { + snake(serde_json::to_value(kind).unwrap().as_str().unwrap()) +} +fn declaration_kind(word: &str) -> Option { + static KINDS: std::sync::OnceLock> = std::sync::OnceLock::new(); + KINDS + .get_or_init(|| { + let mut kinds = BTreeMap::from([ + ("diagram".into(), DeclKind::Diagram), + ("node".into(), DeclKind::Node), + ("artifact".into(), DeclKind::Artifact), + ("trace".into(), DeclKind::Trace), + ]); + for layer in [Layer::Oa, Layer::Sa] { + for kind in Kind::ALL { + if kind.supports(layer) { + kinds.insert(element_word(kind, layer), DeclKind::Element(kind, layer)); + } + } + } + for kind in RelationKind::ALL { + kinds.insert(relation_word(kind), DeclKind::Relation(kind)); + } + kinds + }) + .get(word) + .copied() +} +fn quote(value: &str) -> String { + serde_json::to_string(value).expect("string serialization") +} +fn reference_field(key: &str) -> bool { + matches!( + key, + "parent" + | "steps" + | "occurrences" + | "branches" + | "Named" + | "items" + | "triggers" + | "effects" + | "interface" + | "context" + | "represented" + | "exchange" + | "sequence" + | "scenario" + | "reply_to" + | "referenced" + | "local" + | "field" + | "sender" + | "receiver" + | "action" + | "lifeline" + | "state" + | "constrained" + | "source" + | "target" + | "source_port" + | "target_port" + | "element" + | "owner" + | "oa" + | "sa" + | "diagram" + ) +} +fn enum_field(key: &str) -> bool { + matches!( + key, + "kind" + | "direction" + | "layer" + | "projection" + | "relation" + | "phase" + | "operator" + | "definition" + | "type_ref" + | "viewpoint" + | "message_kind" + | "transition_kind" + ) +} + +/// Parse V2, or explicitly migrate a valid V1 source candidate. Parsing never +/// adopts a model and never overwrites an editor draft. +pub fn parse(source: &str) -> Result> { + parse_limited(source, ParseLimits::UNLIMITED) +} + +pub fn parse_limited(source: &str, limits: ParseLimits) -> Result> { + let parsed = parse_document(source, limits)?; + let errors: Vec<_> = parsed + .diagnostics(source) + .into_iter() + .filter(|d| d.severity == validation::Severity::Error) + .collect(); + if errors.is_empty() { + Ok(parsed.project) + } else { + Err(errors) + } +} + +struct ParsedDocument { + project: Project, + locations: BTreeMap, + fields: BTreeMap<(Id, String), usize>, +} +impl ParsedDocument { + fn diagnostics(&self, source: &str) -> Vec { + // Semantic analysis can report thousands of warnings in a large draft. + // Index line starts once rather than rescanning the entire source prefix + // for every issue. Columns still count scalars, never UTF-8 bytes. + let line_starts: Vec<_> = std::iter::once(0) + .chain(source.match_indices('\n').map(|(offset, _)| offset + 1)) + .collect(); + validation::validate(&self.project) + .into_iter() + .map(|issue| { + let offset = issue + .element + .and_then(|id| { + issue + .field + .as_ref() + .and_then(|field| self.fields.get(&(id, field.clone())).copied()) + .or_else(|| self.locations.get(&id).copied()) + }) + .unwrap_or(0); + let line = line_starts.partition_point(|start| *start <= offset); + Diagnostic { + severity: issue.severity, + code: issue.code, + object_id: issue.element, + field: issue.field, + line, + column: source[line_starts[line - 1]..offset].chars().count() + 1, + message: issue.message, + span: Span { + start: offset, + end: offset + source[offset..].chars().next().map_or(0, char::len_utf8), + }, + } + }) + .collect() + } +} + +fn parse_document(source: &str, limits: ParseLimits) -> Result> { + if source.len() > limits.max_source_bytes { + let mut result = diagnostic( + source, + Error { + code: "invalid_source", + offset: 0, + message: format!("Source exceeds {} bytes.", limits.max_source_bytes), + }, + ); + result.code = "source_limit".into(); + return Err(vec![result]); + } + let tokens = strict_tokens(source).map_err(|e| vec![diagnostic(source, e)])?; + if tokens.get(1).is_some_and(|t| t.text(source) == "1") { + let old = legacy::parse_limited(source, limits).map_err(|errors| { + errors + .into_iter() + .map(|e| { + let start = offset_for(source, e.line, e.column); + diagnostic( + source, + Error { + code: e.code, + offset: start, + message: e.message, + }, + ) + }) + .collect::>() + })?; + let project = crate::persistence::migrate_v1_limited(old, limits).map_err(|message| { + vec![diagnostic( + source, + Error { + code: "invalid_source", + offset: 0, + message, + }, + )] + })?; + return Ok(ParsedDocument { + project, + locations: BTreeMap::new(), + fields: BTreeMap::new(), + }); + } + Parser { + source, + tokens, + cursor: 0, + value_depth: 0, + limits, + } + .document() + .map_err(|e| vec![diagnostic(source, e)]) +} +fn offset_for(source: &str, line: usize, column: usize) -> usize { + let mut offset = 0; + for (i, part) in source.split_inclusive('\n').enumerate() { + if i + 1 == line { + return offset + + part + .char_indices() + .nth(column.saturating_sub(1)) + .map_or(part.len(), |(n, _)| n); + } + offset += part.len(); + } + source.len() +} + +/// Canonical output uses persistent aliases and explicit identities. Typed +/// nested records are ordinary language fields; JSON documents are not embedded. +pub fn format(project: &Project) -> String { + let aliases = project + .identities() + .into_iter() + .map(|(id, a)| (id, a.to_owned())) + .collect::>(); + let mut out = std::format!( + "arcadia 2;\nproject {} {} id \"{}\" {{\n description {};\n\n", + project.alias, + quote(&project.name), + project.id, + quote(&project.description) + ); + for e in project.elements.values() { + let mut fields = payload(&serde_json::to_value(&e.body).unwrap()); + fields.insert("description".into(), Value::String(e.description.clone())); + fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap()); + write_declaration( + &mut out, + &element_word(e.kind(), e.layer()), + &e.alias, + Some(&e.name), + e.id, + &fields, + &aliases, + ); + } + for r in project.relations.values() { + let mut fields = payload(&serde_json::to_value(&r.body).unwrap()); + fields.insert("description".into(), Value::String(r.description.clone())); + write_declaration( + &mut out, + &relation_word(r.kind()), + &r.alias, + Some(&r.name), + r.id, + &fields, + &aliases, + ); + } + for d in project.diagrams.values() { + let mut fields = record_fields(d); + fields.remove("nodes"); + write_declaration( + &mut out, + "diagram", + &d.alias, + Some(&d.name), + d.id, + &fields, + &aliases, + ); + for n in d.nodes.values() { + let mut fields = record_fields(n); + fields.insert("diagram".into(), Value::String(d.id.to_string())); + write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases); + } + } + for a in project.artifact_links.values() { + write_declaration( + &mut out, + "artifact", + &a.alias, + Some(&a.name), + a.id, + &record_fields(a), + &aliases, + ); + } + for t in project.traces.values() { + write_declaration( + &mut out, + "trace", + &t.alias, + Some(&t.name), + t.id, + &record_fields(t), + &aliases, + ); + } + out.push_str("}\n"); + out +} +fn record_fields(value: &T) -> Map { + let mut fields = serde_json::to_value(value) + .unwrap() + .as_object() + .unwrap() + .clone(); + for field in ["id", "alias", "name"] { + fields.remove(field); + } + fields +} +fn payload(value: &Value) -> Map { + match value { + Value::Object(m) + if m.len() == 1 + && m.keys() + .next() + .unwrap() + .chars() + .next() + .unwrap() + .is_ascii_uppercase() => + { + payload(m.values().next().unwrap()) + } + Value::Object(m) => m.clone(), + Value::String(_) => Map::new(), + _ => unreachable!("typed enum body"), + } +} +fn replace_payload(value: &mut Value, fields: Map) { + match value { + Value::Object(m) + if m.len() == 1 + && m.keys() + .next() + .unwrap() + .chars() + .next() + .unwrap() + .is_ascii_uppercase() => + { + replace_payload(m.values_mut().next().unwrap(), fields) + } + Value::Object(m) => *m = fields, + Value::String(_) => debug_assert!(fields.is_empty()), + _ => unreachable!("typed enum body"), + } +} +fn write_declaration( + out: &mut String, + kind: &str, + alias: &str, + name: Option<&str>, + id: Id, + fields: &Map, + aliases: &BTreeMap, +) { + out.push_str(&std::format!(" {kind} {alias}")); + if let Some(name) = name { + out.push(' '); + out.push_str("e(name)); + } + out.push_str(&std::format!(" id \"{id}\" {{\n")); + write_fields(out, fields, 2, aliases); + out.push_str(" }\n\n"); +} +fn write_fields( + out: &mut String, + fields: &Map, + indent: usize, + aliases: &BTreeMap, +) { + for (key, value) in fields { + out.push_str(&" ".repeat(indent)); + out.push_str(key); + out.push(' '); + write_value(out, value, key, indent, aliases); + out.push_str(";\n"); + } +} +fn write_value( + out: &mut String, + value: &Value, + key: &str, + indent: usize, + aliases: &BTreeMap, +) { + match value { + Value::Null => out.push_str("none"), + Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }), + Value::Number(v) => out.push_str(&v.to_string()), + Value::String(v) => { + if reference_field(key) + && let Ok(id) = Id::parse_str(v) + && let Some(alias) = aliases.get(&id) + { + out.push('@'); + out.push_str(alias); + return; + } + if enum_field(key) && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + out.push_str(v) + } else { + out.push_str("e(v)) + } + } + Value::Array(values) => { + out.push('['); + for (i, v) in values.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + write_value(out, v, key, indent, aliases); + } + out.push(']'); + } + Value::Object(fields) => { + out.push_str("{\n"); + write_fields(out, fields, indent + 1, aliases); + out.push_str(&" ".repeat(indent)); + out.push('}'); + } + } +} + +#[derive(Debug, Clone)] +enum Ast { + Literal(Value, usize), + Word(String, usize), + Reference(String, usize), + Array(Vec, usize), + Object(BTreeMap, usize), +} +impl Ast { + fn offset(&self) -> usize { + match self { + Self::Literal(_, p) + | Self::Word(_, p) + | Self::Reference(_, p) + | Self::Array(_, p) + | Self::Object(_, p) => *p, + } + } + fn resolve(&self, key: &str, aliases: &BTreeMap) -> Result { + match self { + Self::Word(word, offset) => { + if !enum_field(key) { + return Err(Error { + code: "invalid_source", + offset: *offset, + message: std::format!( + "Field '{key}' requires a quoted string or a typed value; '{word}' is not an enum field." + ), + }); + } + Ok(Value::String(word.clone())) + } + Self::Reference(alias, offset) => { + if !reference_field(key) { + return Err(Error { + code: "invalid_source", + offset: *offset, + message: std::format!("Field '{key}' does not accept an @alias reference."), + }); + } + aliases + .get(alias) + .map(|id| Value::String(id.to_string())) + .ok_or_else(|| Error { + code: "invalid_source", + offset: *offset, + message: std::format!("Unknown reference '@{alias}'."), + }) + } + Self::Literal(value, offset) => { + if reference_field(key) && value.is_string() { + return Err(Error { + code: "invalid_source", + offset: *offset, + message: std::format!( + "Field '{key}' requires an @alias reference, not a quoted UUID or enum." + ), + }); + } + if enum_field(key) && value.is_string() { + return Err(Error { + code: "invalid_source", + offset: *offset, + message: std::format!( + "Field '{key}' requires an unquoted enum word or typed constructor." + ), + }); + } + Ok(value.clone()) + } + Self::Array(items, _) => Ok(Value::Array( + items + .iter() + .map(|v| v.resolve(key, aliases)) + .collect::>()?, + )), + Self::Object(fields, _) => Ok(Value::Object( + fields + .iter() + .map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?))) + .collect::>()?, + )), + } + } +} +#[derive(Clone, Copy)] +struct Lexeme { + class: TokenClass, + span: Span, +} +impl Lexeme { + fn text<'a>(&self, source: &'a str) -> &'a str { + &source[self.span.start..self.span.end] + } +} +fn strict_tokens(source: &str) -> Result, Error> { + let mut out = Vec::new(); + for t in tokenize(source) { + if t.class == TokenClass::Error { + return Err(Error { + code: "invalid_source", + offset: t.span.start, + message: if source[t.span.start..].starts_with('"') { + "Invalid or unterminated quoted string.".into() + } else { + std::format!("Invalid token '{}'.", &source[t.span.start..t.span.end]) + }, + }); + } + if !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment) { + out.push(Lexeme { + class: t.class, + span: t.span, + }); + } + } + out.push(Lexeme { + class: TokenClass::Punctuation, + span: Span { + start: source.len(), + end: source.len(), + }, + }); + Ok(out) +} +struct Declaration { + kind: DeclKind, + alias: String, + name: String, + id: Id, + fields: BTreeMap, + offset: usize, +} +struct Parser<'a> { + source: &'a str, + tokens: Vec, + cursor: usize, + value_depth: usize, + limits: ParseLimits, +} +impl Parser<'_> { + fn current(&self) -> Lexeme { + self.tokens[self.cursor.min(self.tokens.len() - 1)] + } + fn text(&self) -> &str { + self.current().text(self.source) + } + fn error(&self, message: impl Into) -> Error { + Error { + code: "invalid_source", + offset: self.current().span.start, + message: message.into(), + } + } + fn take(&mut self, word: &str) -> Result<(), Error> { + if self.text() != word { + return Err(self.error(std::format!("Expected '{word}'."))); + } + self.cursor += 1; + Ok(()) + } + fn quoted(&mut self) -> Result { + if self.current().class != TokenClass::String { + return Err(self.error("Expected a double-quoted string.")); + } + let value = serde_json::from_str(self.text()).unwrap(); + self.cursor += 1; + Ok(value) + } + fn identifier(&mut self) -> Result { + let value = self.text().to_owned(); + if !crate::valid_alias(&value) { + return Err(self.error(std::format!("Invalid or reserved alias '{value}'."))); + } + self.cursor += 1; + Ok(value) + } + fn optional_id(&mut self) -> Result { + if self.text() != "id" { + return Ok(Id::new_v4()); + } + self.cursor += 1; + let offset = self.current().span.start; + let value = self.quoted()?; + if value.len() != 36 + || ![8, 13, 18, 23] + .into_iter() + .all(|i| value.as_bytes()[i] == b'-') + { + return Err(Error { + code: "invalid_source", + offset, + message: "Expected UUID in 8-4-4-4-12 hexadecimal form.".into(), + }); + } + Id::parse_str(&value).map_err(|_| Error { + code: "invalid_source", + offset, + message: "Invalid UUID identity.".into(), + }) + } + fn fields(&mut self) -> Result, Error> { + self.take("{")?; + let mut fields = BTreeMap::new(); + while self.text() != "}" { + let key = self.text().to_owned(); + let offset = self.current().span.start; + if !matches!( + self.current().class, + TokenClass::Keyword | TokenClass::Identifier + ) { + return Err(self.error("Expected a field name or '}'.")); + } + self.cursor += 1; + let value = self.value()?; + self.take(";")?; + if fields.insert(key.clone(), value).is_some() { + return Err(Error { + code: "invalid_source", + offset, + message: std::format!("Duplicate field '{key}'."), + }); + } + } + self.take("}")?; + Ok(fields) + } + fn value(&mut self) -> Result { + if self.value_depth >= 64 { + return Err(self.error("Typed values may not nest more than 64 levels.")); + } + self.value_depth += 1; + let result = self.value_inner(); + self.value_depth -= 1; + result + } + fn value_inner(&mut self) -> Result { + let token = self.current(); + let offset = token.span.start; + match self.text() { + "@" => { + self.cursor += 1; + Ok(Ast::Reference(self.identifier()?, offset)) + } + "{" => Ok(Ast::Object(self.fields()?, offset)), + "[" => { + self.cursor += 1; + let mut values = Vec::new(); + if self.text() != "]" { + loop { + values.push(self.value()?); + if self.text() != "," { + break; + } + self.cursor += 1; + } + } + self.take("]")?; + Ok(Ast::Array(values, offset)) + } + "none" => { + self.cursor += 1; + Ok(Ast::Literal(Value::Null, offset)) + } + "true" | "false" => { + let value = self.text() == "true"; + self.cursor += 1; + Ok(Ast::Literal(Value::Bool(value), offset)) + } + _ => match token.class { + TokenClass::String => Ok(Ast::Literal(Value::String(self.quoted()?), offset)), + TokenClass::Number => { + let value = serde_json::from_str(self.text()) + .map_err(|_| self.error("Expected a finite JSON decimal number."))?; + self.cursor += 1; + Ok(Ast::Literal(value, offset)) + } + TokenClass::Identifier | TokenClass::Keyword => { + let value = self.text().to_owned(); + self.cursor += 1; + Ok(Ast::Word(value, offset)) + } + _ => Err(self.error("Expected a value, @alias reference, list, or typed record.")), + }, + } + } + fn document(&mut self) -> Result { + self.take("arcadia")?; + if self.text() != "2" { + return Err(self.error("Unsupported schema version: expected 'arcadia 2;'.")); + } + self.cursor += 1; + self.take(";")?; + self.take("project")?; + let alias = self.identifier()?; + let name = self.quoted()?; + let id = self.optional_id()?; + self.take("{")?; + let mut project = Project { + schema_version: SCHEMA_VERSION, + id, + alias: alias.clone(), + name, + description: String::new(), + elements: BTreeMap::new(), + relations: BTreeMap::new(), + diagrams: BTreeMap::new(), + artifact_links: BTreeMap::new(), + traces: BTreeMap::new(), + }; + let mut aliases = BTreeMap::from([(alias, id)]); + let mut locations = BTreeMap::from([(id, 0)]); + let mut declarations = Vec::new(); + let mut graph_count = 0usize; + let mut field_locations = BTreeMap::new(); + let mut has_description = false; + while self.text() != "}" { + if self.text() == "description" { + if has_description { + return Err(self.error("Duplicate project description.")); + } + has_description = true; + self.cursor += 1; + project.description = self.quoted()?; + self.take(";")?; + continue; + } + let offset = self.current().span.start; + let word = self.text().to_owned(); + let kind = declaration_kind(&word) + .ok_or_else(|| self.error(std::format!("Unknown declaration '{word}'.")))?; + graph_count += usize::from(matches!( + kind, + DeclKind::Element(..) | DeclKind::Relation(..) + )); + self.limits + .check_counts(graph_count, declarations.len().saturating_add(2), 0) + .map_err(|message| Error { + code: "model_limit", + offset: self.current().span.start, + message, + })?; + self.cursor += 1; + let alias = self.identifier()?; + let name = if matches!(kind, DeclKind::Node) { + String::new() + } else { + self.quoted()? + }; + let id = self.optional_id()?; + let fields = self.fields()?; + for (field, value) in &fields { + field_locations.insert((id, field.clone()), value.offset()); + } + if aliases.insert(alias.clone(), id).is_some() { + return Err(Error { + code: "invalid_source", + offset, + message: std::format!("Duplicate alias '{alias}'."), + }); + } + if locations.insert(id, offset).is_some() { + return Err(Error { + code: "invalid_source", + offset, + message: std::format!("Duplicate identity '{id}'."), + }); + } + declarations.push(Declaration { + kind, + alias, + name, + id, + fields, + offset, + }); + } + self.take("}")?; + if !self.text().is_empty() { + return Err(self.error("Unexpected content after the project closing brace.")); + } + let mut nodes = Vec::new(); + for declaration in declarations { + let mut fields = declaration + .fields + .iter() + .map(|(k, v)| Ok((k.clone(), v.resolve(k, &aliases)?))) + .collect::, Error>>()?; + let d = &declaration; + let conversion_error = |message: String| Error { + code: "invalid_source", + offset: d + .fields + .iter() + .find(|(field, _)| message.contains(&std::format!("'{field}'"))) + .map_or(d.offset, |(_, value)| value.offset()), + message, + }; + match d.kind { + DeclKind::Element(kind, layer) => { + let description = + take_string(&mut fields, "description", "").map_err(&conversion_error)?; + let parent = + take_optional_id(&mut fields, "parent").map_err(&conversion_error)?; + let mut initial = ElementBody::new(kind, layer).unwrap(); + // Schema-2 sources predate explicit scenario viewpoints. + // Preserve their mixed lifelines instead of reinterpreting + // old source using a new graphical factory's typed default. + if !fields.contains_key("viewpoint") + && let Some(SharedBody::Scenario { viewpoint, .. }) = initial.shared_mut() + { + *viewpoint = ScenarioViewpoint::MixedLegacy; + } + let mut body = serde_json::to_value(initial).unwrap(); + let body_fields = + merge_fields(payload(&body), fields).map_err(&conversion_error)?; + replace_payload(&mut body, body_fields); + let body: ElementBody = serde_json::from_value(body) + .map_err(|e| conversion_error(e.to_string()))?; + if body.kind() != kind || body.layer() != layer { + return Err(conversion_error( + "Declaration kind disagrees with its typed body.".into(), + )); + } + project.elements.insert( + d.id, + Element { + id: d.id, + alias: d.alias.clone(), + name: d.name.clone(), + description, + parent, + body, + }, + ); + } + DeclKind::Relation(kind) => { + let description = + take_string(&mut fields, "description", "").map_err(&conversion_error)?; + for required in ["source", "target"] { + if !fields.contains_key(required) { + return Err(conversion_error(std::format!( + "Missing required field '{required}'." + ))); + } + } + let mut body = + serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil())) + .unwrap(); + let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?; + replace_payload(&mut body, merged); + let body = serde_json::from_value(body) + .map_err(|e| conversion_error(e.to_string()))?; + project.relations.insert( + d.id, + Relation { + id: d.id, + alias: d.alias.clone(), + name: d.name.clone(), + description, + body, + }, + ); + } + DeclKind::Diagram => { + let defaults = + serde_json::json!({"description":"","layer":"Oa","projection":"Overview"}); + let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + .map_err(&conversion_error)?; + header(&mut record, d, true); + record.insert("nodes".into(), serde_json::json!({})); + let diagram: Diagram = serde_json::from_value(Value::Object(record)) + .map_err(|e| conversion_error(e.to_string()))?; + project.diagrams.insert(d.id, diagram); + } + DeclKind::Node => { + let diagram = + take_required_id(&mut fields, "diagram").map_err(&conversion_error)?; + if !fields.contains_key("element") { + return Err(conversion_error("Missing required field 'element'.".into())); + } + let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}); + let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + .map_err(&conversion_error)?; + header(&mut record, d, false); + let node: NodePlacement = serde_json::from_value(Value::Object(record)) + .map_err(|e| conversion_error(e.to_string()))?; + nodes.push((diagram, node, d.offset)); + } + DeclKind::Artifact => { + if !fields.contains_key("owner") { + return Err(conversion_error("Missing required field 'owner'.".into())); + } + let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null}); + let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + .map_err(&conversion_error)?; + header(&mut record, d, true); + let artifact = serde_json::from_value(Value::Object(record)) + .map_err(|e| conversion_error(e.to_string()))?; + project.artifact_links.insert(d.id, artifact); + } + DeclKind::Trace => { + for required in ["oa", "sa"] { + if !fields.contains_key(required) { + return Err(conversion_error(std::format!( + "Missing required field '{required}'." + ))); + } + } + let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null}); + let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) + .map_err(&conversion_error)?; + header(&mut record, d, true); + let trace = serde_json::from_value(Value::Object(record)) + .map_err(|e| conversion_error(e.to_string()))?; + project.traces.insert(d.id, trace); + } + } + } + for (diagram, node, offset) in nodes { + let target = project.diagrams.get_mut(&diagram).ok_or_else(|| Error { + code: "invalid_source", + offset, + message: "Node 'diagram' must reference a diagram declaration.".into(), + })?; + target.nodes.insert(node.id, node); + } + self.limits.check_model(&project).map_err(|message| Error { + code: "model_limit", + offset: 0, + message, + })?; + Ok(ParsedDocument { + project, + locations, + fields: field_locations, + }) + } +} +fn header(fields: &mut Map, d: &Declaration, named: bool) { + fields.insert("id".into(), Value::String(d.id.to_string())); + fields.insert("alias".into(), Value::String(d.alias.clone())); + if named { + fields.insert("name".into(), Value::String(d.name.clone())); + } +} +fn merge_fields( + mut defaults: Map, + fields: Map, +) -> Result, String> { + for (key, value) in fields { + if !defaults.contains_key(&key) { + return Err(std::format!("Unknown field '{key}' for this declaration.")); + } + defaults.insert(key, value); + } + Ok(defaults) +} +fn take_string( + fields: &mut Map, + key: &str, + default: &str, +) -> Result { + match fields.remove(key) { + None => Ok(default.into()), + Some(Value::String(value)) => Ok(value), + _ => Err(std::format!("Field '{key}' requires a string.")), + } +} +fn take_optional_id(fields: &mut Map, key: &str) -> Result, String> { + match fields.remove(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Id::parse_str(&value) + .map(Some) + .map_err(|_| std::format!("Field '{key}' requires an @alias reference.")), + _ => Err(std::format!( + "Field '{key}' requires an @alias reference or none." + )), + } +} +fn take_required_id(fields: &mut Map, key: &str) -> Result { + take_optional_id(fields, key)? + .ok_or_else(|| std::format!("Missing required @alias field '{key}'.")) +} + +/// Analyze an incomplete source buffer without adopting a candidate. Declaration +/// spans remain available even when a later declaration contains errors. +pub fn analyze(source: &str) -> Analysis { + let tokens = tokenize(source); + let significant = tokens + .iter() + .filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment)) + .collect::>(); + let mut declarations = Vec::new(); + for (i, t) in significant.iter().enumerate() { + let word = &source[t.span.start..t.span.end]; + if declaration_kind(word).is_none() && word != "project" { + continue; + } + let Some(alias_token) = significant.get(i + 1) else { + continue; + }; + let alias = &source[alias_token.span.start..alias_token.span.end]; + if !crate::valid_alias(alias) { + continue; + } + let mut j = i + 2; + let named = word != "node"; + if named { + if significant + .get(j) + .is_none_or(|t| t.class != TokenClass::String) + { + continue; + } + j += 1; + } + let mut id = None; + if significant + .get(j) + .is_some_and(|t| &source[t.span.start..t.span.end] == "id") + { + if let Some(value) = significant.get(j + 1) + && value.class == TokenClass::String + { + let text = + serde_json::from_str::(&source[value.span.start..value.span.end]) + .unwrap(); + id = Id::parse_str(&text).ok(); + } + j += 2; + } + let mut end = significant + .get(j) + .map_or(alias_token.span.end, |t| t.span.end); + let mut depth = 0usize; + for next in significant.iter().skip(j) { + match &source[next.span.start..next.span.end] { + "{" => depth += 1, + "}" => { + if depth == 0 { + break; + } + depth -= 1; + if depth == 0 { + end = next.span.end; + break; + } + } + _ => {} + } + end = next.span.end; + } + declarations.push(DeclarationSpan { + alias: alias.into(), + id, + kind: word.into(), + span: Span { + start: t.span.start, + end, + }, + }); + } + Analysis { + tokens, + diagnostics: lint(source), + declarations, + } +} + +pub(crate) mod legacy { + //! Arcadia text: a readable, lossless representation of a valid OA project. + //! + //! Parsing creates a candidate only. Callers adopt it through `Session` so that + //! graphical and textual edits share validation, history, and saved-state rules. + use crate::persistence::v1::{ + Element, Id, Kind, Position, Project, Relation, RelationKind, validation, + }; + use std::collections::{BTreeMap, BTreeSet}; + use std::fmt; + + /// A source error. Positions are one-based and columns count Unicode scalars. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct Diagnostic { + pub code: &'static str, + pub line: usize, + pub column: usize, + pub message: String, + } + + impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}: {}", self.line, self.column, self.message) + } + } + + /// Parse an entire document, resolve forward references, and validate its model. + /// Missing `id` clauses generate fresh identities; canonical output includes them. + /// Incomplete models with warnings are accepted. Syntax and structural errors are + /// rejected, without returning a partially usable model. + pub fn parse(source: &str) -> Result> { + parse_limited(source, super::ParseLimits::UNLIMITED) + } + + pub(super) fn parse_limited( + source: &str, + limits: super::ParseLimits, + ) -> Result> { + let tokens = lex(source).map_err(|error| vec![diagnostic(source, error)])?; + Parser { + tokens, + cursor: 0, + limits, + } + .document() + .map_err(|error| vec![diagnostic(source, error)]) + } + + /// Produce deterministic canonical text for a structurally valid project. + /// All persistent model values and identities are represented. Local aliases are + /// derived from display names and are not persistent model identity. Formatting + /// discards source comments and whitespace. Invalid projects must be validated + /// before formatting; only valid projects have the round-trip guarantee. + pub fn format(project: &Project) -> String { + let mut used = BTreeSet::new(); + let mut aliases = BTreeMap::new(); + for element in project.elements.values() { + aliases.insert( + element.id, + alias(&element.name, kind_word(element.kind), &mut used), + ); + } + for relation in project.relations.values() { + aliases.insert( + relation.id, + alias(&relation.name, relation_word(relation.kind), &mut used), + ); + } + let reference = |id: Id| { + aliases + .get(&id) + .cloned() + .unwrap_or_else(|| std::format!("missing_{}", id.simple())) + }; + let mut text = std::format!( + "arcadia {};\nproject {} id \"{}\" {{\n", + project.schema_version, + quote(&project.name), + project.id, + ); + for element in project.elements.values() { + text.push_str(&std::format!( + " {} {} {} id \"{}\" {{\n description {};\n position ({}, {});\n", + kind_word(element.kind), + reference(element.id), + quote(&element.name), + element.id, + quote(&element.description), + element.position.x, + element.position.y, + )); + if element.kind == Kind::Process { + text.push_str(&std::format!( + " steps [{}];\n", + element + .steps + .iter() + .map(|id| reference(*id)) + .collect::>() + .join(", ") + )); + } + text.push_str(" }\n\n"); + } + for relation in project.relations.values() { + text.push_str(&std::format!( + " {} {} {} id \"{}\" from {} to {};\n", + relation_word(relation.kind), + reference(relation.id), + quote(&relation.name), + relation.id, + reference(relation.source), + reference(relation.target), + )); + } + text.push_str("}\n"); + text + } + + fn quote(value: &str) -> String { + serde_json::to_string(value).expect("serializing a Rust string cannot fail") + } + + fn alias(name: &str, fallback: &str, used: &mut BTreeSet) -> String { + let mut stem = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + stem.push(ch.to_ascii_lowercase()); + } else if !stem.is_empty() && !stem.ends_with('_') { + stem.push('_'); + } + } + let mut stem = stem.trim_end_matches('_').to_owned(); + if stem.is_empty() { + stem = std::format!("{fallback}_item"); + } else if stem.as_bytes()[0].is_ascii_digit() || reserved(&stem) { + stem = std::format!("{fallback}_{stem}"); + } + let mut candidate = stem.clone(); + let mut suffix = 2; + while !used.insert(candidate.clone()) { + candidate = std::format!("{stem}_{suffix}"); + suffix += 1; + } + candidate + } + + fn kind_word(kind: Kind) -> &'static str { + match kind { + Kind::Entity => "entity", + Kind::Actor => "actor", + Kind::Activity => "activity", + Kind::Capability => "capability", + Kind::Process => "process", + } + } + + fn relation_word(kind: RelationKind) -> &'static str { + match kind { + RelationKind::Allocation => "allocation", + RelationKind::Interaction => "interaction", + RelationKind::Contribution => "contribution", + RelationKind::Realization => "realization", + } + } + + fn reserved(word: &str) -> bool { + matches!( + word, + "arcadia" + | "project" + | "id" + | "entity" + | "actor" + | "activity" + | "capability" + | "process" + | "allocation" + | "interaction" + | "contribution" + | "realization" + | "description" + | "position" + | "steps" + | "from" + | "to" + ) + } + + struct Error { + code: &'static str, + offset: usize, + message: String, + } + + fn diagnostic(source: &str, error: Error) -> Diagnostic { + let prefix = &source[..error.offset]; + Diagnostic { + code: error.code, + line: prefix.bytes().filter(|byte| *byte == b'\n').count() + 1, + column: prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1, + message: error.message, + } + } + + #[derive(Clone)] + enum TokenKind { + Word(String), + Quoted(String), + Number(String), + Symbol(char), + End, + } + + struct Token { + kind: TokenKind, + offset: usize, + } + + fn lex(source: &str) -> Result, Error> { + let mut result = Vec::new(); + let mut cursor = 0; + while cursor < source.len() { + let start = cursor; + let ch = source[cursor..].chars().next().unwrap(); + if matches!(ch, ' ' | '\t' | '\r' | '\n') { + cursor += 1; + continue; + } + if source[cursor..].starts_with("//") { + cursor += source[cursor..].find('\n').unwrap_or(source.len() - cursor); + continue; + } + let kind = if ch == '"' { + cursor += 1; + let mut escaped = false; + let mut closed = false; + for next in source[cursor..].chars() { + cursor += next.len_utf8(); + if escaped { + escaped = false; + } else if next == '\\' { + escaped = true; + } else if next == '"' { + closed = true; + break; + } + } + if !closed { + return Err(Error { + code: "invalid_source", + offset: start, + message: "Unterminated quoted string.".into(), + }); + } + TokenKind::Quoted(serde_json::from_str(&source[start..cursor]).map_err( + |error| Error { + code: "invalid_source", + offset: start, + message: std::format!("Invalid quoted string: {error}"), + }, + )?) + } else if ch.is_ascii_alphabetic() || ch == '_' { + cursor += 1; + while source + .as_bytes() + .get(cursor) + .is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_') + { + cursor += 1; + } + TokenKind::Word(source[start..cursor].into()) + } else if ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.') { + cursor += 1; + while source.as_bytes().get(cursor).is_some_and(|next| { + next.is_ascii_alphanumeric() || matches!(*next, b'.' | b'+' | b'-' | b'_') + }) { + cursor += 1; + } + TokenKind::Number(source[start..cursor].into()) + } else if "{}();,[]".contains(ch) { + cursor += 1; + TokenKind::Symbol(ch) + } else { + return Err(Error { + code: "invalid_source", + offset: start, + message: std::format!("Unexpected character {ch:?}."), + }); + }; + result.push(Token { + kind, + offset: start, + }); + } + result.push(Token { + kind: TokenKind::End, + offset: source.len(), + }); + Ok(result) + } + + struct Reference { + alias: String, + offset: usize, + } + + enum Declaration { + Element(Element, Vec), + Relation(Id, RelationKind, String, Reference, Reference), + } + + struct Parser { + tokens: Vec, + cursor: usize, + limits: super::ParseLimits, + } + + impl Parser { + fn current(&self) -> &Token { + &self.tokens[self.cursor] + } + + fn error(&self, message: impl Into) -> Error { + Error { + code: "invalid_source", + offset: self.current().offset, + message: message.into(), + } + } + + fn word_is(&self, word: &str) -> bool { + matches!(&self.current().kind, TokenKind::Word(actual) if actual == word) + } + + fn symbol_is(&self, symbol: char) -> bool { + matches!(self.current().kind, TokenKind::Symbol(actual) if actual == symbol) + } + + fn word(&mut self, word: &str) -> Result<(), Error> { + if !self.word_is(word) { + return Err(self.error(std::format!("Expected '{word}'."))); + } + self.cursor += 1; + Ok(()) + } + + fn symbol(&mut self, symbol: char) -> Result<(), Error> { + if !self.symbol_is(symbol) { + return Err(self.error(std::format!("Expected '{symbol}'."))); + } + self.cursor += 1; + Ok(()) + } + + fn quoted(&mut self) -> Result { + let TokenKind::Quoted(value) = &self.current().kind else { + return Err(self.error("Expected a double-quoted string.")); + }; + let value = value.clone(); + self.cursor += 1; + Ok(value) + } + + fn reference(&mut self) -> Result { + let TokenKind::Word(alias) = &self.current().kind else { + return Err(self.error("Expected an alias (letters, digits, or underscores).")); + }; + if reserved(alias) { + return Err( + self.error(std::format!("'{alias}' is reserved; choose another alias.")) + ); + } + let reference = Reference { + alias: alias.clone(), + offset: self.current().offset, + }; + self.cursor += 1; + Ok(reference) + } + + fn optional_id(&mut self) -> Result { + if !self.word_is("id") { + return Ok(Id::new_v4()); + } + self.cursor += 1; + let offset = self.current().offset; + let value = self.quoted()?; + // Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN, + // braces, or compact alternatives. Uppercase hexadecimal is accepted. + if value.len() != 36 + || ![8, 13, 18, 23] + .into_iter() + .all(|index| value.as_bytes()[index] == b'-') + { + return Err(Error { + code: "invalid_source", + offset, + message: "Expected a UUID string in 8-4-4-4-12 hexadecimal form.".into(), + }); + } + Id::parse_str(&value).map_err(|_| Error { + code: "invalid_source", + offset, + message: "Invalid UUID: use 8-4-4-4-12 hexadecimal form.".into(), + }) + } + + fn number(&mut self) -> Result { + let TokenKind::Number(value) = &self.current().kind else { + return Err(self.error("Expected a finite decimal coordinate.")); + }; + // JSON establishes an exact lexical grammar; Rust's f32 parser retains + // negative zero and supplies the actual model's IEEE-754 rounding. + if serde_json::from_str::(value).is_err() { + return Err(self.error("Invalid number: use JSON decimal or exponent notation.")); + } + let parsed = value + .parse::() + .ok() + .filter(|number| number.is_finite()) + .ok_or_else(|| self.error("Coordinate must be a finite 32-bit number."))?; + self.cursor += 1; + Ok(parsed) + } + + fn document(&mut self) -> Result { + self.word("arcadia")?; + let version_offset = self.current().offset; + let TokenKind::Number(version) = &self.current().kind else { + return Err(self.error("Expected the language version number 1.")); + }; + let schema_version = version + .parse::() + .map_err(|_| self.error("Expected an unsigned integer language version."))?; + if version != &schema_version.to_string() { + return Err(self.error("Use an unsigned decimal version without leading zeros.")); + } + self.cursor += 1; + self.symbol(';')?; + self.word("project")?; + let project_offset = self.current().offset; + let name = self.quoted()?; + let id = self.optional_id()?; + let mut project = Project { + schema_version, + id, + name, + elements: BTreeMap::new(), + relations: BTreeMap::new(), + aliases: BTreeMap::new(), + }; + self.symbol('{')?; + let mut aliases = BTreeMap::new(); + let mut locations = BTreeMap::from([(id, project_offset)]); + let mut declarations = Vec::new(); + while !self.symbol_is('}') { + self.limits + .check_counts( + declarations.len().saturating_add(1), + declarations.len().saturating_add(2), + 0, + ) + .map_err(|message| Error { + code: "model_limit", + offset: self.current().offset, + message, + })?; + let offset = self.current().offset; + let (kind, relation_kind) = match &self.current().kind { + TokenKind::Word(word) => match word.as_str() { + "entity" => (Some(Kind::Entity), None), + "actor" => (Some(Kind::Actor), None), + "activity" => (Some(Kind::Activity), None), + "capability" => (Some(Kind::Capability), None), + "process" => (Some(Kind::Process), None), + "allocation" => (None, Some(RelationKind::Allocation)), + "interaction" => (None, Some(RelationKind::Interaction)), + "contribution" => (None, Some(RelationKind::Contribution)), + "realization" => (None, Some(RelationKind::Realization)), + _ => return Err(self.error(std::format!("Unknown declaration '{word}'."))), + }, + _ => return Err(self.error("Expected an element, relationship, or '}'.")), + }; + self.cursor += 1; + let alias = self.reference()?; + let name = self.quoted()?; + let id = self.optional_id()?; + if aliases.insert(alias.alias.clone(), id).is_some() { + return Err(Error { + code: "invalid_source", + offset: alias.offset, + message: std::format!("Duplicate alias '{}'.", alias.alias), + }); + } + if locations.insert(id, offset).is_some() { + return Err(Error { + code: "invalid_source", + offset: alias.offset, + message: std::format!( + "Duplicate identity '{id}'; all IDs must be unique, including the project ID." + ), + }); + } + if let Some(kind) = kind { + let (element, steps) = self.element(id, kind, name)?; + declarations.push(Declaration::Element(element, steps)); + } else if let Some(kind) = relation_kind { + self.word("from")?; + let source = self.reference()?; + self.word("to")?; + let target = self.reference()?; + self.symbol(';')?; + declarations.push(Declaration::Relation(id, kind, name, source, target)); + } + } + self.symbol('}')?; + if !matches!(self.current().kind, TokenKind::End) { + return Err(self.error("Unexpected content after the project closing brace.")); + } + let resolve = |reference: &Reference| { + aliases.get(&reference.alias).copied().ok_or_else(|| Error { + code: "invalid_source", + offset: reference.offset, + message: std::format!("Unknown reference '{}'.", reference.alias), + }) + }; + project.aliases = aliases + .iter() + .map(|(alias, id)| (*id, alias.clone())) + .collect(); + for declaration in declarations { + match declaration { + Declaration::Element(mut element, steps) => { + element.steps = steps.iter().map(resolve).collect::>()?; + project.elements.insert(element.id, element); + } + Declaration::Relation(id, kind, name, source, target) => { + project.relations.insert( + id, + Relation { + id, + kind, + name, + source: resolve(&source)?, + target: resolve(&target)?, + }, + ); + } + } + } + crate::persistence::check_v1_limits(&project, self.limits).map_err(|message| { + Error { + code: "model_limit", + offset: self.current().offset, + message, + } + })?; + if let Some(issue) = validation::validate(&project) + .into_iter() + .find(|issue| issue.severity == validation::Severity::Error) + { + let offset = if schema_version != 1 { + version_offset + } else { + issue + .element + .and_then(|id| locations.get(&id).copied()) + .unwrap_or(project_offset) + }; + return Err(Error { + code: "invalid_source", + offset, + message: issue.message, + }); + } + Ok(project) + } + + fn element( + &mut self, + id: Id, + kind: Kind, + name: String, + ) -> Result<(Element, Vec), Error> { + self.symbol('{')?; + let mut element = Element { + id, + kind, + name, + description: String::new(), + position: Position { x: 0., y: 0. }, + steps: Vec::new(), + }; + let mut fields = BTreeSet::new(); + let mut steps = Vec::new(); + while !self.symbol_is('}') { + let TokenKind::Word(field) = self.current().kind.clone() else { + return Err(self.error("Expected 'description', 'position', 'steps', or '}'.")); + }; + if !fields.insert(field.clone()) { + return Err(self.error(std::format!("Duplicate field '{field}'."))); + } + match field.as_str() { + "description" => { + self.cursor += 1; + element.description = self.quoted()?; + } + "position" => { + self.cursor += 1; + self.symbol('(')?; + element.position.x = self.number()?; + self.symbol(',')?; + element.position.y = self.number()?; + self.symbol(')')?; + } + "steps" if kind == Kind::Process => { + self.cursor += 1; + self.symbol('[')?; + if !self.symbol_is(']') { + loop { + steps.push(self.reference()?); + if !self.symbol_is(',') { + break; + } + self.cursor += 1; + } + } + self.symbol(']')?; + } + "steps" => return Err(self.error("Only processes can contain steps.")), + _ => return Err(self.error(std::format!("Unknown field '{field}'."))), + } + self.symbol(';')?; + } + self.symbol('}')?; + Ok((element, steps)) + } + } +} diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs new file mode 100644 index 00000000..d17a386b --- /dev/null +++ b/crates/oa-core/src/lib.rs @@ -0,0 +1,13 @@ +//! Framework-independent OA/SA model, transactional commands and editing services. +pub mod language; +pub mod model; +pub mod persistence; +pub mod projection; +pub mod references; +pub mod session; +pub mod transitions; +pub mod validation; +pub use model::*; +pub use session::*; +mod demo; +mod state_validation; diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs new file mode 100644 index 00000000..21db8d76 --- /dev/null +++ b/crates/oa-core/src/model.rs @@ -0,0 +1,1613 @@ +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; +use uuid::Uuid; + +pub type Id = Uuid; +pub const SCHEMA_VERSION: u32 = 2; +pub const MAX_COORDINATE: f32 = 1_000_000.; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Layer { + Oa, + Sa, +} +impl fmt::Display for Layer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Oa => "OA", + Self::Sa => "SA", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Kind { + Entity, + Actor, + Role, + Activity, + Capability, + OperationalMission, + Process, + System, + SystemActor, + Function, + SystemCapability, + Mission, + FunctionalChain, + ExchangeItem, + DataType, + DataField, + Interface, + Port, + Scenario, + Lifeline, + Occurrence, + StateMachine, + Region, + State, + Mode, + Initial, + Final, + Choice, + Junction, + Fork, + Join, + ShallowHistory, + DeepHistory, + EntryPoint, + ExitPoint, + Terminate, + Event, + Constraint, +} +impl Kind { + pub const ALL: [Self; 38] = [ + Self::Entity, + Self::Actor, + Self::Role, + Self::Activity, + Self::Capability, + Self::OperationalMission, + Self::Process, + Self::System, + Self::SystemActor, + Self::Function, + Self::SystemCapability, + Self::Mission, + Self::FunctionalChain, + Self::ExchangeItem, + Self::DataType, + Self::DataField, + Self::Interface, + Self::Port, + Self::Scenario, + Self::Lifeline, + Self::Occurrence, + Self::StateMachine, + Self::Region, + Self::State, + Self::Mode, + Self::Initial, + Self::Final, + Self::Choice, + Self::Junction, + Self::Fork, + Self::Join, + Self::ShallowHistory, + Self::DeepHistory, + Self::EntryPoint, + Self::ExitPoint, + Self::Terminate, + Self::Event, + Self::Constraint, + ]; + pub fn label(self) -> &'static str { + match self { + Self::Entity => "Entity", + Self::Actor => "Actor", + Self::Role => "Role", + Self::Activity => "Activity", + Self::Capability => "Capability", + Self::OperationalMission => "Operational mission", + Self::Process => "Process", + Self::System => "System", + Self::SystemActor => "System actor", + Self::Function => "Function", + Self::SystemCapability => "System capability", + Self::Mission => "Mission", + Self::FunctionalChain => "Functional chain", + Self::ExchangeItem => "Exchange item", + Self::DataType => "Data type", + Self::DataField => "Data field", + Self::Interface => "Interface", + Self::Port => "Port", + Self::Scenario => "Scenario", + Self::Lifeline => "Lifeline", + Self::Occurrence => "Occurrence", + Self::StateMachine => "State machine", + Self::Region => "Region", + Self::State => "State", + Self::Mode => "Mode", + Self::Initial => "Initial", + Self::Final => "Final", + Self::Choice => "Choice", + Self::Junction => "Junction", + Self::Fork => "Fork", + Self::Join => "Join", + Self::ShallowHistory => "Shallow history", + Self::DeepHistory => "Deep history", + Self::EntryPoint => "Entry point", + Self::ExitPoint => "Exit point", + Self::Terminate => "Terminate", + Self::Event => "Event", + Self::Constraint => "Constraint", + } + } + pub fn is_performer(self) -> bool { + matches!( + self, + Self::Entity | Self::Actor | Self::System | Self::SystemActor + ) + } + pub fn is_work(self) -> bool { + matches!(self, Self::Activity | Self::Function) + } + pub fn is_sequence(self) -> bool { + matches!(self, Self::Process | Self::FunctionalChain | Self::Scenario) + } + pub fn is_state(self) -> bool { + self.state_kind().is_some() + } + pub fn state_kind(self) -> Option { + Some(match self { + Self::State => StateKind::State, + Self::Mode => StateKind::Mode, + Self::Initial => StateKind::Initial, + Self::Final => StateKind::Final, + Self::Choice => StateKind::Choice, + Self::Junction => StateKind::Junction, + Self::Fork => StateKind::Fork, + Self::Join => StateKind::Join, + Self::ShallowHistory => StateKind::ShallowHistory, + Self::DeepHistory => StateKind::DeepHistory, + Self::EntryPoint => StateKind::EntryPoint, + Self::ExitPoint => StateKind::ExitPoint, + Self::Terminate => StateKind::Terminate, + _ => return None, + }) + } + pub fn is_stable_state(self) -> bool { + matches!(self, Self::State | Self::Mode) + } + pub fn supports(self, layer: Layer) -> bool { + match self { + Self::Entity + | Self::Actor + | Self::Role + | Self::Activity + | Self::Capability + | Self::OperationalMission + | Self::Process => layer == Layer::Oa, + Self::System + | Self::SystemActor + | Self::Function + | Self::SystemCapability + | Self::Mission + | Self::FunctionalChain => layer == Layer::Sa, + _ => true, + } + } +} +impl fmt::Display for Kind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Position { + pub x: f32, + pub y: f32, +} +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Size { + pub width: f32, + pub height: f32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Element { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub parent: Option, + pub body: ElementBody, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum ElementBody { + Oa(OaBody), + Sa(SaBody), +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum OaBody { + Entity, + Actor, + Role, + Activity, + Capability, + Mission, + Process { steps: Vec }, + Shared(SharedBody), +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SaBody { + System, + Actor, + Function, + Capability, + Mission, + FunctionalChain { steps: Vec }, + Shared(SharedBody), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExchangeItemKind { + Information, + Material, + Energy, + Event, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum DataTypeDefinition { + Boolean, + Integer, + Real, + String, + Enumeration { literals: Vec }, + Record, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum TypeRef { + Boolean, + Integer, + Real, + String, + Named(Id), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PortDirection { + In, + Out, + InOut, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StateKind { + State, + Mode, + Initial, + Final, + Choice, + Junction, + Fork, + Join, + ShallowHistory, + DeepHistory, + EntryPoint, + ExitPoint, + Terminate, +} +impl StateKind { + pub fn kind(self) -> Kind { + match self { + Self::State => Kind::State, + Self::Mode => Kind::Mode, + Self::Initial => Kind::Initial, + Self::Final => Kind::Final, + Self::Choice => Kind::Choice, + Self::Junction => Kind::Junction, + Self::Fork => Kind::Fork, + Self::Join => Kind::Join, + Self::ShallowHistory => Kind::ShallowHistory, + Self::DeepHistory => Kind::DeepHistory, + Self::EntryPoint => Kind::EntryPoint, + Self::ExitPoint => Kind::ExitPoint, + Self::Terminate => Kind::Terminate, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TimeEventKind { + After, + At, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum EventBody { + Signal { + items: Vec, + }, + Time { + kind: TimeEventKind, + expression: String, + }, + Change { + expression: String, + }, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TransitionKind { + #[default] + External, + Local, + Internal, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StateActionKind { + Entry, + Do, + Exit, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FragmentOperator { + Alternative, + Parallel, + Loop, + Optional, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ControlKind { + Choice, + Merge, + Fork, + Join, + Loop, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum ScenarioViewpoint { + OperationalActivity, + ParticipantInteraction, + Functional, + BoundaryInterface, + #[default] + MixedLegacy, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub enum MessageKind { + #[default] + Asynchronous, + Call, + Reply, + Create, + Delete, + Timer { + expression: String, + }, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LifelineBinding { + pub referenced: Id, + pub local: Id, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MessageValue { + pub field: Id, + pub expression: String, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum OccurrenceBody { + Exchange { + exchange: Option, + sender: Option, + receiver: Option, + #[serde(default)] + message_kind: MessageKind, + #[serde(default)] + reply_to: Option, + #[serde(default)] + values: Vec, + }, + Action { + action: Option, + lifeline: Option, + }, + Control { + kind: ControlKind, + }, + SequenceReference { + sequence: Option, + }, + ScenarioUse { + scenario: Option, + bindings: Vec, + }, + State { + state: Option, + lifeline: Option, + }, + Fragment { + operator: FragmentOperator, + branches: Vec, + }, + Branch { + guard: String, + occurrences: Vec, + }, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SharedBody { + ExchangeItem { + kind: ExchangeItemKind, + }, + DataType { + definition: DataTypeDefinition, + }, + DataField { + type_ref: TypeRef, + minimum: u32, + maximum: Option, + unit: String, + }, + Interface { + items: Vec, + }, + Port { + direction: PortDirection, + interface: Option, + }, + Scenario { + #[serde(default)] + viewpoint: ScenarioViewpoint, + context: Option, + precondition: String, + postcondition: String, + steps: Vec, + }, + Lifeline { + represented: Option, + }, + Occurrence { + body: OccurrenceBody, + precondition: String, + postcondition: String, + }, + StateMachine, + Event { + body: EventBody, + }, + Region, + State { + kind: StateKind, + entry: String, + exit: String, + do_activity: String, + }, + Constraint { + expression: String, + constrained: Vec, + }, +} +impl Element { + pub fn layer(&self) -> Layer { + self.body.layer() + } + pub fn kind(&self) -> Kind { + self.body.kind() + } + pub fn shared(&self) -> Option<&SharedBody> { + self.body.shared() + } + pub fn shared_mut(&mut self) -> Option<&mut SharedBody> { + self.body.shared_mut() + } + pub fn steps(&self) -> Option<&Vec> { + self.body.steps() + } + pub fn steps_mut(&mut self) -> Option<&mut Vec> { + self.body.steps_mut() + } +} +impl ElementBody { + pub fn layer(&self) -> Layer { + match self { + Self::Oa(_) => Layer::Oa, + Self::Sa(_) => Layer::Sa, + } + } + pub fn shared(&self) -> Option<&SharedBody> { + match self { + Self::Oa(OaBody::Shared(v)) | Self::Sa(SaBody::Shared(v)) => Some(v), + _ => None, + } + } + pub fn shared_mut(&mut self) -> Option<&mut SharedBody> { + match self { + Self::Oa(OaBody::Shared(v)) | Self::Sa(SaBody::Shared(v)) => Some(v), + _ => None, + } + } + pub fn steps(&self) -> Option<&Vec> { + match self { + Self::Oa(OaBody::Process { steps }) | Self::Sa(SaBody::FunctionalChain { steps }) => { + Some(steps) + } + _ => match self.shared() { + Some(SharedBody::Scenario { steps, .. }) => Some(steps), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { occurrences, .. }, + .. + }) => Some(occurrences), + _ => None, + }, + } + } + pub fn steps_mut(&mut self) -> Option<&mut Vec> { + match self { + Self::Oa(OaBody::Process { steps }) | Self::Sa(SaBody::FunctionalChain { steps }) => { + Some(steps) + } + _ => match self.shared_mut() { + Some(SharedBody::Scenario { steps, .. }) => Some(steps), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { occurrences, .. }, + .. + }) => Some(occurrences), + _ => None, + }, + } + } + pub fn kind(&self) -> Kind { + match self { + Self::Oa(v) => match v { + OaBody::Entity => Kind::Entity, + OaBody::Actor => Kind::Actor, + OaBody::Role => Kind::Role, + OaBody::Activity => Kind::Activity, + OaBody::Capability => Kind::Capability, + OaBody::Mission => Kind::OperationalMission, + OaBody::Process { .. } => Kind::Process, + OaBody::Shared(s) => s.kind(), + }, + Self::Sa(v) => match v { + SaBody::System => Kind::System, + SaBody::Actor => Kind::SystemActor, + SaBody::Function => Kind::Function, + SaBody::Capability => Kind::SystemCapability, + SaBody::Mission => Kind::Mission, + SaBody::FunctionalChain { .. } => Kind::FunctionalChain, + SaBody::Shared(s) => s.kind(), + }, + } + } + pub fn shared_in(layer: Layer, body: SharedBody) -> Self { + match layer { + Layer::Oa => Self::Oa(OaBody::Shared(body)), + Layer::Sa => Self::Sa(SaBody::Shared(body)), + } + } + pub fn new(kind: Kind, layer: Layer) -> Option { + if !kind.supports(layer) { + return None; + } + Some(match kind { + Kind::Entity => Self::Oa(OaBody::Entity), + Kind::Actor => Self::Oa(OaBody::Actor), + Kind::Role => Self::Oa(OaBody::Role), + Kind::Activity => Self::Oa(OaBody::Activity), + Kind::Capability => Self::Oa(OaBody::Capability), + Kind::OperationalMission => Self::Oa(OaBody::Mission), + Kind::Process => Self::Oa(OaBody::Process { steps: vec![] }), + Kind::System => Self::Sa(SaBody::System), + Kind::SystemActor => Self::Sa(SaBody::Actor), + Kind::Function => Self::Sa(SaBody::Function), + Kind::SystemCapability => Self::Sa(SaBody::Capability), + Kind::Mission => Self::Sa(SaBody::Mission), + Kind::FunctionalChain => Self::Sa(SaBody::FunctionalChain { steps: vec![] }), + k => Self::shared_in( + layer, + match k { + Kind::ExchangeItem => SharedBody::ExchangeItem { + kind: ExchangeItemKind::Information, + }, + Kind::DataType => SharedBody::DataType { + definition: DataTypeDefinition::String, + }, + Kind::DataField => SharedBody::DataField { + type_ref: TypeRef::String, + minimum: 1, + maximum: Some(1), + unit: String::new(), + }, + Kind::Interface => SharedBody::Interface { items: vec![] }, + Kind::Port => SharedBody::Port { + direction: PortDirection::InOut, + interface: None, + }, + Kind::Scenario => SharedBody::Scenario { + viewpoint: if layer == Layer::Oa { + ScenarioViewpoint::OperationalActivity + } else { + ScenarioViewpoint::Functional + }, + context: None, + precondition: String::new(), + postcondition: String::new(), + steps: vec![], + }, + Kind::Lifeline => SharedBody::Lifeline { represented: None }, + Kind::Occurrence => SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }, + Kind::StateMachine => SharedBody::StateMachine, + Kind::Event => SharedBody::Event { + body: EventBody::Signal { items: vec![] }, + }, + Kind::Region => SharedBody::Region, + k if k.is_state() => SharedBody::State { + kind: k.state_kind().expect("state kind"), + entry: String::new(), + exit: String::new(), + do_activity: String::new(), + }, + Kind::Constraint => SharedBody::Constraint { + expression: String::new(), + constrained: vec![], + }, + _ => unreachable!(), + }, + ), + }) + } +} +impl SharedBody { + pub fn kind(&self) -> Kind { + match self { + Self::ExchangeItem { .. } => Kind::ExchangeItem, + Self::DataType { .. } => Kind::DataType, + Self::DataField { .. } => Kind::DataField, + Self::Interface { .. } => Kind::Interface, + Self::Port { .. } => Kind::Port, + Self::Scenario { .. } => Kind::Scenario, + Self::Lifeline { .. } => Kind::Lifeline, + Self::Occurrence { .. } => Kind::Occurrence, + Self::StateMachine => Kind::StateMachine, + Self::Event { .. } => Kind::Event, + Self::Region => Kind::Region, + Self::State { kind, .. } => kind.kind(), + Self::Constraint { .. } => Kind::Constraint, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RelationKind { + Allocation, + Interaction, + CapabilityInvolvement, + ChainInvolvement, + RoleRealization, + RoleAssignment, + FunctionalExchange, + BoundaryExchange, + MissionCapability, + CapabilityInclude, + CapabilityExtend, + CapabilityGeneralization, + MissionInvolvement, + SequenceLink, + StateTransition, + Availability, + ExchangeAllocation, + InterfaceProvision, + InterfaceRequirement, + StateAction, +} +impl RelationKind { + pub const ALL: [Self; 20] = [ + Self::Allocation, + Self::Interaction, + Self::CapabilityInvolvement, + Self::ChainInvolvement, + Self::RoleRealization, + Self::RoleAssignment, + Self::FunctionalExchange, + Self::BoundaryExchange, + Self::MissionCapability, + Self::CapabilityInclude, + Self::CapabilityExtend, + Self::CapabilityGeneralization, + Self::MissionInvolvement, + Self::SequenceLink, + Self::StateTransition, + Self::Availability, + Self::ExchangeAllocation, + Self::InterfaceProvision, + Self::InterfaceRequirement, + Self::StateAction, + ]; + pub fn permits(self, from: Kind, to: Kind) -> bool { + match self { + Self::Allocation => from.is_performer() && to.is_work(), + Self::Interaction => from == Kind::Activity && to == Kind::Activity, + Self::CapabilityInvolvement => { + (from.is_performer() || from.is_work() || from == Kind::Role) + && matches!(to, Kind::Capability | Kind::SystemCapability) + } + Self::ChainInvolvement => { + matches!(from, Kind::Process | Kind::FunctionalChain) + && matches!(to, Kind::Capability | Kind::SystemCapability) + } + Self::RoleRealization => matches!(from, Kind::Entity | Kind::Actor) && to == Kind::Role, + Self::RoleAssignment => from == Kind::Role && to == Kind::Activity, + Self::FunctionalExchange => from == Kind::Function && to == Kind::Function, + Self::BoundaryExchange => from == Kind::Port && to == Kind::Port, + Self::MissionCapability => matches!( + (from, to), + (Kind::OperationalMission, Kind::Capability) + | (Kind::Mission, Kind::SystemCapability) + ), + Self::CapabilityInclude | Self::CapabilityExtend | Self::CapabilityGeneralization => { + matches!( + (from, to), + (Kind::Capability, Kind::Capability) + | (Kind::SystemCapability, Kind::SystemCapability) + ) + } + Self::MissionInvolvement => matches!( + (from, to), + (Kind::Entity | Kind::Actor, Kind::OperationalMission) + | (Kind::System | Kind::SystemActor, Kind::Mission) + ), + Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence, + Self::StateTransition => from.is_state() && to.is_state(), + Self::ExchangeAllocation => false, + Self::InterfaceProvision | Self::InterfaceRequirement => { + (from.is_performer() || from == Kind::Port) && to == Kind::Interface + } + Self::StateAction => from.is_stable_state() && to.is_work(), + Self::Availability => { + (from.is_work() + || from.is_performer() + || matches!( + from, + Kind::Capability + | Kind::SystemCapability + | Kind::Process + | Kind::FunctionalChain + )) + && to.is_stable_state() + } + } + } + pub fn hint(self) -> &'static str { + match self { + Self::Allocation => "Performer → leaf activity/function", + Self::Interaction => "OA activity → OA activity", + Self::CapabilityInvolvement => "Participant/activity/function → capability", + Self::ChainInvolvement => "Process/chain → capability", + Self::RoleRealization => "OA entity/actor → role", + Self::RoleAssignment => "OA role → activity", + Self::FunctionalExchange => "SA function → SA function", + Self::BoundaryExchange => "Output port → input port", + Self::MissionCapability => "Mission → capability in the same perspective", + Self::CapabilityInclude => "Including capability → included capability", + Self::CapabilityExtend => { + "Extending capability → extended capability, under a condition" + } + 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::StateTransition => "State → state in one machine", + Self::ExchangeAllocation => { + "Boundary exchange → operational interaction / functional exchange" + } + Self::InterfaceProvision => "Participant/port → provided interface", + Self::InterfaceRequirement => "Participant/port → required interface", + Self::StateAction => "State/mode → activity/function", + Self::Availability => "Behavior/participant → state/mode", + } + } +} +impl fmt::Display for RelationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Allocation => "Allocation", + Self::Interaction => "Interaction", + Self::CapabilityInvolvement => "Capability involvement", + Self::ChainInvolvement => "Chain involvement", + Self::RoleRealization => "Role realization", + Self::RoleAssignment => "Role assignment", + Self::FunctionalExchange => "Functional exchange", + Self::BoundaryExchange => "Boundary exchange", + Self::MissionCapability => "Mission capability", + Self::CapabilityInclude => "Capability include", + Self::CapabilityExtend => "Capability extend", + Self::CapabilityGeneralization => "Capability generalization", + Self::MissionInvolvement => "Mission involvement", + Self::SequenceLink => "Sequence link", + Self::StateTransition => "State transition", + Self::ExchangeAllocation => "Exchange allocation", + Self::InterfaceProvision => "Interface provision", + Self::InterfaceRequirement => "Interface requirement", + Self::StateAction => "State action", + Self::Availability => "Availability", + }) + } +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Relation { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub body: RelationBody, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum RelationBody { + Allocation { + source: Id, + target: Id, + }, + Interaction { + source: Id, + target: Id, + items: Vec, + }, + CapabilityInvolvement { + source: Id, + target: Id, + }, + ChainInvolvement { + source: Id, + target: Id, + }, + RoleRealization { + source: Id, + target: Id, + }, + RoleAssignment { + source: Id, + target: Id, + }, + FunctionalExchange { + source: Id, + target: Id, + source_port: Option, + target_port: Option, + items: Vec, + }, + BoundaryExchange { + source: Id, + target: Id, + items: Vec, + }, + MissionCapability { + source: Id, + target: Id, + }, + CapabilityInclude { + source: Id, + target: Id, + }, + CapabilityExtend { + source: Id, + target: Id, + condition: String, + }, + CapabilityGeneralization { + source: Id, + target: Id, + }, + MissionInvolvement { + source: Id, + target: Id, + }, + SequenceLink { + source: Id, + target: Id, + guard: String, + }, + StateTransition { + source: Id, + target: Id, + #[serde(default)] + transition_kind: TransitionKind, + #[serde(default)] + triggers: Vec, + #[serde(default)] + effects: Vec, + trigger: String, + guard: String, + effect: String, + }, + Availability { + source: Id, + target: Id, + }, + ExchangeAllocation { + source: Id, + target: Id, + }, + InterfaceProvision { + source: Id, + target: Id, + }, + InterfaceRequirement { + source: Id, + target: Id, + }, + StateAction { + source: Id, + target: Id, + phase: StateActionKind, + }, +} +impl Relation { + pub fn kind(&self) -> RelationKind { + self.body.kind() + } + pub fn source(&self) -> Id { + self.body.endpoints().0 + } + pub fn target(&self) -> Id { + self.body.endpoints().1 + } +} +impl RelationBody { + pub fn kind(&self) -> RelationKind { + match self { + Self::Allocation { .. } => RelationKind::Allocation, + Self::Interaction { .. } => RelationKind::Interaction, + Self::CapabilityInvolvement { .. } => RelationKind::CapabilityInvolvement, + Self::ChainInvolvement { .. } => RelationKind::ChainInvolvement, + Self::RoleRealization { .. } => RelationKind::RoleRealization, + Self::RoleAssignment { .. } => RelationKind::RoleAssignment, + Self::FunctionalExchange { .. } => RelationKind::FunctionalExchange, + Self::BoundaryExchange { .. } => RelationKind::BoundaryExchange, + Self::MissionCapability { .. } => RelationKind::MissionCapability, + Self::CapabilityInclude { .. } => RelationKind::CapabilityInclude, + Self::CapabilityExtend { .. } => RelationKind::CapabilityExtend, + Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization, + Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement, + Self::SequenceLink { .. } => RelationKind::SequenceLink, + Self::StateTransition { .. } => RelationKind::StateTransition, + Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation, + Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision, + Self::InterfaceRequirement { .. } => RelationKind::InterfaceRequirement, + Self::StateAction { .. } => RelationKind::StateAction, + Self::Availability { .. } => RelationKind::Availability, + } + } + pub fn endpoints(&self) -> (Id, Id) { + match self { + Self::Allocation { source, target } + | Self::Interaction { source, target, .. } + | Self::CapabilityInvolvement { source, target } + | Self::ChainInvolvement { source, target } + | Self::RoleRealization { source, target } + | Self::RoleAssignment { source, target } + | Self::FunctionalExchange { source, target, .. } + | Self::BoundaryExchange { source, target, .. } + | Self::MissionCapability { source, target } + | Self::CapabilityInclude { source, target } + | Self::CapabilityExtend { source, target, .. } + | Self::CapabilityGeneralization { source, target } + | Self::MissionInvolvement { source, target } + | Self::SequenceLink { source, target, .. } + | Self::StateTransition { source, target, .. } + | Self::ExchangeAllocation { source, target } + | Self::InterfaceProvision { source, target } + | Self::InterfaceRequirement { source, target } + | Self::StateAction { source, target, .. } + | Self::Availability { source, target } => (*source, *target), + } + } + pub fn set_endpoints(&mut self, a: Id, b: Id) { + match self { + Self::Allocation { source, target } + | Self::Interaction { source, target, .. } + | Self::CapabilityInvolvement { source, target } + | Self::ChainInvolvement { source, target } + | Self::RoleRealization { source, target } + | Self::RoleAssignment { source, target } + | Self::FunctionalExchange { source, target, .. } + | Self::BoundaryExchange { source, target, .. } + | Self::MissionCapability { source, target } + | Self::CapabilityInclude { source, target } + | Self::CapabilityExtend { source, target, .. } + | Self::CapabilityGeneralization { source, target } + | Self::MissionInvolvement { source, target } + | Self::SequenceLink { source, target, .. } + | Self::StateTransition { source, target, .. } + | Self::ExchangeAllocation { source, target } + | Self::InterfaceProvision { source, target } + | Self::InterfaceRequirement { source, target } + | Self::StateAction { source, target, .. } + | Self::Availability { source, target } => { + *source = a; + *target = b; + } + } + } + pub fn items(&self) -> &[Id] { + match self { + Self::Interaction { items, .. } + | Self::FunctionalExchange { items, .. } + | Self::BoundaryExchange { items, .. } => items, + _ => &[], + } + } + pub fn new(kind: RelationKind, source: Id, target: Id) -> Self { + match kind { + RelationKind::Allocation => Self::Allocation { source, target }, + RelationKind::Interaction => Self::Interaction { + source, + target, + items: vec![], + }, + RelationKind::CapabilityInvolvement => Self::CapabilityInvolvement { source, target }, + RelationKind::ChainInvolvement => Self::ChainInvolvement { source, target }, + RelationKind::RoleRealization => Self::RoleRealization { source, target }, + RelationKind::RoleAssignment => Self::RoleAssignment { source, target }, + RelationKind::FunctionalExchange => Self::FunctionalExchange { + source, + target, + source_port: None, + target_port: None, + items: vec![], + }, + RelationKind::BoundaryExchange => Self::BoundaryExchange { + source, + target, + items: vec![], + }, + RelationKind::MissionCapability => Self::MissionCapability { source, target }, + RelationKind::CapabilityInclude => Self::CapabilityInclude { source, target }, + RelationKind::CapabilityExtend => Self::CapabilityExtend { + source, + target, + condition: String::new(), + }, + RelationKind::CapabilityGeneralization => { + Self::CapabilityGeneralization { source, target } + } + RelationKind::MissionInvolvement => Self::MissionInvolvement { source, target }, + RelationKind::SequenceLink => Self::SequenceLink { + source, + target, + guard: String::new(), + }, + RelationKind::StateTransition => Self::StateTransition { + source, + target, + transition_kind: TransitionKind::External, + triggers: vec![], + effects: vec![], + trigger: String::new(), + guard: String::new(), + effect: String::new(), + }, + RelationKind::ExchangeAllocation => Self::ExchangeAllocation { source, target }, + RelationKind::InterfaceProvision => Self::InterfaceProvision { source, target }, + RelationKind::InterfaceRequirement => Self::InterfaceRequirement { source, target }, + RelationKind::StateAction => Self::StateAction { + source, + target, + phase: StateActionKind::Do, + }, + RelationKind::Availability => Self::Availability { source, target }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProjectionKind { + Overview, + Decomposition, + Allocation, + Traceability, + FunctionalDataflow, + Capabilities, +} +impl ProjectionKind { + pub fn includes(self, kind: Kind) -> bool { + match self { + Self::Allocation => kind.is_performer() || kind.is_work(), + Self::FunctionalDataflow => kind.is_work(), + Self::Capabilities => { + kind.is_performer() + || matches!( + kind, + Kind::Role + | Kind::Capability + | Kind::OperationalMission + | Kind::Mission + | Kind::SystemCapability + ) + } + _ => true, + } + } +} +impl std::fmt::Display for ProjectionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Allocation => "Architecture · allocated work", + Self::FunctionalDataflow => "Functional dataflow", + Self::Capabilities => "Missions & capabilities", + Self::Decomposition => "Containment", + Self::Overview => "All relationships", + Self::Traceability => "OA ↔ SA traceability", + }) + } +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Diagram { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub layer: Layer, + pub projection: ProjectionKind, + #[serde(deserialize_with = "unique_id_map")] + pub nodes: BTreeMap, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NodePlacement { + pub id: Id, + pub alias: String, + pub element: Id, + pub position: Position, + pub size: Size, + pub collapsed: bool, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ArtifactRelation { + References, + Satisfies, + Verifies, + Implements, + DerivedFrom, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactLink { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub owner: Id, + pub relation: ArtifactRelation, + pub uri: String, + pub revision: Option, + pub fragment: Option, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TraceKind { + Refinement, + Participant, + Capability, + Mission, + Exchange, + Sequence, + Item, + Scenario, + State, + Manual, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TracedProperties { + pub name: String, + pub description: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TraceBaseline { + pub oa: TracedProperties, + pub sa: TracedProperties, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Trace { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub oa: Id, + pub sa: Id, + pub kind: TraceKind, + pub baseline: Option, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Project { + pub schema_version: u32, + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + #[serde(deserialize_with = "unique_id_map")] + pub elements: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub relations: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub diagrams: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub artifact_links: BTreeMap, + #[serde(deserialize_with = "unique_id_map")] + pub traces: BTreeMap, +} + +pub fn unique_id_map<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + struct Unique(std::marker::PhantomData); + impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Unique { + type Value = BTreeMap; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("unique UUID keys") + } + fn visit_map>( + self, + mut access: A, + ) -> Result { + let mut map = BTreeMap::new(); + while let Some((id, value)) = access.next_entry()? { + if map.insert(id, value).is_some() { + return Err(serde::de::Error::custom(format!( + "Duplicate object ID {id}" + ))); + } + } + Ok(map) + } + } + deserializer.deserialize_map(Unique(std::marker::PhantomData)) +} +pub fn reserved_alias(value: &str) -> bool { + matches!( + value, + "arcadia" + | "project" + | "id" + | "oa" + | "sa" + | "element" + | "relation" + | "diagram" + | "node" + | "artifact" + | "trace" + | "body" + | "alias" + | "name" + | "description" + | "parent" + | "from" + | "to" + | "entity" + | "actor" + | "activity" + | "capability" + | "process" + | "allocation" + | "interaction" + | "contribution" + | "realization" + | "position" + | "steps" + | "true" + | "false" + | "null" + ) +} +pub fn valid_alias(value: &str) -> bool { + let mut chars = value.chars(); + chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + && !reserved_alias(value) +} +impl Project { + pub fn empty() -> Self { + Self { + schema_version: SCHEMA_VERSION, + id: Id::new_v4(), + alias: "mission".into(), + name: "Untitled mission".into(), + description: String::new(), + elements: BTreeMap::new(), + relations: BTreeMap::new(), + diagrams: BTreeMap::new(), + artifact_links: BTreeMap::new(), + traces: BTreeMap::new(), + } + } + pub fn identities(&self) -> Vec<(Id, &str)> { + let mut out = vec![(self.id, self.alias.as_str())]; + out.extend(self.elements.values().map(|v| (v.id, v.alias.as_str()))); + out.extend(self.relations.values().map(|v| (v.id, v.alias.as_str()))); + out.extend(self.diagrams.values().map(|v| (v.id, v.alias.as_str()))); + out.extend( + self.diagrams + .values() + .flat_map(|v| v.nodes.values()) + .map(|v| (v.id, v.alias.as_str())), + ); + out.extend( + self.artifact_links + .values() + .map(|v| (v.id, v.alias.as_str())), + ); + out.extend(self.traces.values().map(|v| (v.id, v.alias.as_str()))); + out + } + pub fn contains(&self, id: Id) -> bool { + self.identities().iter().any(|(i, _)| *i == id) + } + pub fn fresh_alias(&self, name: &str, fallback: &str, id: Id) -> String { + let used: BTreeSet<_> = self.identities().into_iter().map(|(_, a)| a).collect(); + let mut stem = String::new(); + for c in name.chars() { + if c.is_ascii_alphanumeric() { + stem.push(c.to_ascii_lowercase()) + } else if !stem.is_empty() && !stem.ends_with('_') { + stem.push('_') + } + } + let mut stem = stem.trim_end_matches('_').to_owned(); + if !valid_alias(&stem) { + stem = format!("{fallback}_{}", stem.trim_matches('_')); + } + if !valid_alias(&stem) { + stem = "item".into(); + } + if !used.contains(stem.as_str()) { + return stem; + } + let candidate = format!("{stem}_{}", &id.simple().to_string()[..8]); + if !used.contains(candidate.as_str()) { + return candidate; + } + let mut n = 2; + loop { + let value = format!("{candidate}_{n}"); + if !used.contains(value.as_str()) { + return value; + } + n += 1; + } + } + pub fn named(&self, id: Id) -> &str { + if id == self.id { + return &self.name; + } + if let Some(v) = self.elements.get(&id) { + return &v.name; + } + if let Some(v) = self.relations.get(&id) { + return &v.name; + } + if let Some(v) = self.traces.get(&id) { + return &v.name; + } + if let Some(v) = self.artifact_links.get(&id) { + return &v.name; + } + if let Some(v) = self.diagrams.get(&id) { + return &v.name; + } + "Missing object" + } + pub fn layer_of(&self, id: Id) -> Option { + let mut current = id; + let mut seen = BTreeSet::new(); + loop { + if !seen.insert(current) { + return None; + } + if let Some(e) = self.elements.get(¤t) { + return Some(e.layer()); + } + current = self.relations.get(¤t)?.source(); + } + } + pub fn properties(&self, id: Id) -> Option { + if let Some(e) = self.elements.get(&id) { + return Some(TracedProperties { + name: e.name.clone(), + description: e.description.clone(), + }); + } + self.relations.get(&id).map(|r| TracedProperties { + name: r.name.clone(), + description: r.description.clone(), + }) + } + pub fn children(&self, parent: Id) -> impl Iterator { + self.elements + .values() + .filter(move |e| e.parent == Some(parent)) + } + pub fn make_element( + &self, + kind: Kind, + layer: Layer, + name: &str, + parent: Option, + ) -> Option { + let id = Id::new_v4(); + let mut body = ElementBody::new(kind, layer)?; + if kind == Kind::Occurrence + && parent + .and_then(|id| self.elements.get(&id)) + .is_some_and(|owner| { + matches!( + owner.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { .. }, + .. + }) + ) + }) + { + body = ElementBody::shared_in( + layer, + SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: String::new(), + occurrences: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }, + ); + } + Some(Element { + id, + alias: self.fresh_alias(name, "element", id), + name: name.into(), + description: String::new(), + parent, + body, + }) + } + pub fn add_element( + &mut self, + kind: Kind, + layer: Layer, + name: &str, + parent: Option, + ) -> Option { + let e = self.make_element(kind, layer, name, parent)?; + let id = e.id; + self.elements.insert(id, e); + Some(id) + } + pub fn add(&mut self, kind: Kind, name: &str, x: f32, y: f32) -> Id { + let layer = if kind.supports(Layer::Oa) { + Layer::Oa + } else { + Layer::Sa + }; + let id = self + .add_element(kind, layer, name, None) + .expect("kind belongs to a layer"); + let diagram = self.ensure_diagram(layer); + self.place(diagram, id, Position { x, y }); + id + } + pub fn ensure_diagram(&mut self, layer: Layer) -> Id { + if let Some(d) = self + .diagrams + .values() + .find(|d| d.layer == layer && d.projection == ProjectionKind::Overview) + { + return d.id; + } + let id = Id::new_v4(); + let name = format!("{layer} overview"); + let alias = self.fresh_alias(&name, "diagram", id); + self.diagrams.insert( + id, + Diagram { + id, + alias, + name, + description: String::new(), + layer, + projection: ProjectionKind::Overview, + nodes: BTreeMap::new(), + }, + ); + id + } + pub fn place(&mut self, diagram: Id, element: Id, position: Position) -> Id { + if let Some(node) = self + .diagrams + .get(&diagram) + .and_then(|d| d.nodes.values().find(|n| n.element == element)) + { + return node.id; + } + let id = Id::new_v4(); + let alias = self.fresh_alias(&format!("view_{}", self.named(element)), "node", id); + let node = NodePlacement { + id, + alias, + element, + position, + size: Size { + width: 224., + height: 94., + }, + collapsed: false, + }; + if let Some(d) = self.diagrams.get_mut(&diagram) { + d.nodes.insert(id, node); + } + id + } + pub fn relate(&mut self, kind: RelationKind, name: &str, source: Id, target: Id) -> Id { + let id = Id::new_v4(); + let alias = self.fresh_alias(name, "relation", id); + let mut body = RelationBody::new(kind, source, target); + if self + .elements + .get(&source) + .is_some_and(|e| e.kind() == Kind::EntryPoint) + && let RelationBody::StateTransition { + transition_kind, .. + } = &mut body + { + *transition_kind = TransitionKind::Local; + } + self.relations.insert( + id, + Relation { + id, + alias, + name: name.into(), + description: String::new(), + body, + }, + ); + id + } + pub fn demo() -> Self { + crate::demo::coastal_rescue() + } +} diff --git a/crates/oa-core/src/persistence.rs b/crates/oa-core/src/persistence.rs new file mode 100644 index 00000000..019ef782 --- /dev/null +++ b/crates/oa-core/src/persistence.rs @@ -0,0 +1,529 @@ +use crate::*; +use std::{io::Write, path::Path}; +use thiserror::Error; +#[derive(Debug, Error)] +pub enum FileError { + #[error("File operation failed: {0}")] + Io(#[from] std::io::Error), + #[error("Invalid project JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("Invalid Arcadia text: {0}")] + Text(String), + #[error("Invalid project: {0}")] + Invalid(String), + #[error( + "This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes." + )] + Conflict, + #[error("Model admission limit: {0}")] + Limit(String), +} +pub fn decode(bytes: &[u8]) -> Result { + decode_limited(bytes, crate::language::ParseLimits::UNLIMITED) +} + +/// Apply admission budgets before validating a candidate or its migrated form. +pub fn decode_limited( + bytes: &[u8], + limits: crate::language::ParseLimits, +) -> Result { + if bytes.len() > limits.max_source_bytes { + return Err(FileError::Limit(format!( + "Input exceeds {} bytes.", + limits.max_source_bytes + ))); + } + let p: Project = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') { + let header: serde_json::Value = serde_json::from_slice(bytes)?; + match header + .get("schema_version") + .and_then(serde_json::Value::as_u64) + { + Some(1) => { + let old = serde_json::from_slice(bytes)?; + check_v1_limits(&old, limits).map_err(FileError::Limit)?; + migrate_v1_limited(old, limits).map_err(FileError::Invalid)? + } + Some(2) => serde_json::from_slice(bytes)?, + _ => { + return Err(FileError::Invalid( + "Unsupported or missing schema_version; expected 1 or 2.".into(), + )); + } + } + } else { + let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?; + crate::language::parse_limited(text, limits).map_err(|diagnostics| { + let detail = diagnostics + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + if diagnostics.iter().any(|diagnostic| { + matches!(diagnostic.code.as_str(), "model_limit" | "source_limit") + }) { + FileError::Limit(detail) + } else { + FileError::Text(detail) + } + })? + }; + limits.check_model(&p).map_err(FileError::Limit)?; + validate_project(&p)?; + Ok(p) +} + +/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and +/// coordinates survive; every newly required identity is deterministic. +pub(crate) fn migrate_v1_limited( + mut old: v1::Project, + limits: crate::language::ParseLimits, +) -> Result { + check_v1_limits(&old, limits)?; + if let Some(issue) = v1::validation::validate(&old).into_iter().next() { + return Err(issue.message); + } + if old.aliases.is_empty() { + old.aliases = crate::language::legacy::parse(&crate::language::legacy::format(&old)) + .map_err(|e| { + e.into_iter() + .map(|d| d.to_string()) + .collect::>() + .join("\n") + })? + .aliases; + } + let mut p = Project::empty(); + p.id = old.id; + p.name = old.name.clone(); + p.alias = "v1_project".into(); + let used = old + .aliases + .values() + .cloned() + .collect::>(); + while used.contains(&p.alias) { + p.alias.push('_'); + } + for e in old.elements.values() { + let body = match e.kind { + v1::Kind::Entity => OaBody::Entity, + v1::Kind::Actor => OaBody::Actor, + v1::Kind::Activity => OaBody::Activity, + v1::Kind::Capability => OaBody::Capability, + v1::Kind::Process => OaBody::Process { steps: vec![] }, + }; + p.elements.insert( + e.id, + Element { + id: e.id, + alias: old.aliases[&e.id].clone(), + name: e.name.clone(), + description: e.description.clone(), + parent: None, + body: ElementBody::Oa(body), + }, + ); + } + for r in old.relations.values() { + let kind = match r.kind { + v1::RelationKind::Allocation => RelationKind::Allocation, + v1::RelationKind::Interaction => RelationKind::Interaction, + v1::RelationKind::Contribution => RelationKind::CapabilityInvolvement, + v1::RelationKind::Realization => RelationKind::ChainInvolvement, + }; + p.relations.insert( + r.id, + Relation { + id: r.id, + alias: old.aliases[&r.id].clone(), + name: r.name.clone(), + description: String::new(), + body: RelationBody::new(kind, r.source, r.target), + }, + ); + } + // V1 allowed aliases later reserved by V2. Preserve all valid aliases first; + // deterministically suffix only the conflicting names in stable UUID order. + let invalid = p + .identities() + .into_iter() + .filter(|(_, alias)| !valid_alias(alias)) + .map(|(id, alias)| (id, alias.to_owned())) + .collect::>(); + for (id, alias) in invalid { + let replacement = p.fresh_alias(&std::format!("{alias}_v1"), "legacy", id); + if let Some(e) = p.elements.get_mut(&id) { + e.alias = replacement; + } else if let Some(r) = p.relations.get_mut(&id) { + r.alias = replacement; + } + } + let diagram_id = migration_id(&p, "diagram:oa"); + let diagram_alias = p.fresh_alias("OA overview", "diagram", diagram_id); + p.diagrams.insert( + diagram_id, + Diagram { + id: diagram_id, + alias: diagram_alias, + name: "OA overview".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Overview, + nodes: std::collections::BTreeMap::new(), + }, + ); + for e in old.elements.values() { + let id = migration_id(&p, &std::format!("node:{}", e.id)); + let alias = p.fresh_alias(&std::format!("{}_view", old.aliases[&e.id]), "node", id); + let node = NodePlacement { + id, + alias, + element: e.id, + position: e.position, + size: Size { + width: 224., + height: 94., + }, + collapsed: false, + }; + p.diagrams + .get_mut(&diagram_id) + .unwrap() + .nodes + .insert(id, node); + } + for e in old + .elements + .values() + .filter(|e| e.kind == v1::Kind::Process) + { + let mut steps = Vec::new(); + for (index, exchange) in e.steps.iter().enumerate() { + let id = migration_id(&p, &std::format!("occurrence:{}:{index}", e.id)); + let alias = p.fresh_alias( + &std::format!("{}_step_{}", old.aliases[&e.id], index + 1), + "occurrence", + id, + ); + let name = old.relations[exchange].name.clone(); + p.elements.insert( + id, + Element { + id, + alias, + name, + description: String::new(), + parent: Some(e.id), + body: ElementBody::shared_in( + Layer::Oa, + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(*exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }, + ), + }, + ); + steps.push(id); + } + *p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps; + } + if let Some(issue) = validation::validate(&p) + .into_iter() + .find(|i| i.severity == validation::Severity::Error) + { + return Err(std::format!( + "V1 migration cannot satisfy V2 validation: {}", + issue.message + )); + } + Ok(p) +} + +pub(crate) fn check_v1_limits( + old: &v1::Project, + limits: crate::language::ParseLimits, +) -> Result<(), String> { + let steps = old.elements.values().map(|e| e.steps.len()).sum::(); + let graph = old + .elements + .len() + .saturating_add(old.relations.len()) + .saturating_add(steps); + // Migration adds one diagram, a placement per old element, and one stable + // occurrence per process step. Count those before validating the old graph. + let records = graph.saturating_add(old.elements.len()).saturating_add(2); + limits.check_counts(graph, records, steps) +} +fn migration_id(p: &Project, role: &str) -> Id { + let mut suffix = 0; + loop { + let id = Id::new_v5( + &p.id, + std::format!("arcadia-studio:v1:{role}:{suffix}").as_bytes(), + ); + if !p.contains(id) { + return id; + } + suffix += 1; + } +} + +pub(crate) mod v1 { + pub use crate::{Id, Position}; + use serde::{Deserialize, Deserializer}; + use std::collections::BTreeMap; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] + pub enum Kind { + Entity, + Actor, + Activity, + Capability, + Process, + } + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] + pub enum RelationKind { + Allocation, + Interaction, + Contribution, + Realization, + } + #[derive(Debug, Clone, PartialEq, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct Element { + pub id: Id, + pub kind: Kind, + pub name: String, + pub description: String, + pub position: Position, + pub steps: Vec, + } + #[derive(Debug, Clone, PartialEq, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct Relation { + pub id: Id, + pub kind: RelationKind, + pub name: String, + pub source: Id, + pub target: Id, + } + #[derive(Debug, Clone, PartialEq)] + pub struct Project { + pub schema_version: u32, + pub id: Id, + pub name: String, + pub elements: BTreeMap, + pub relations: BTreeMap, + pub aliases: BTreeMap, + } + impl<'de> Deserialize<'de> for Project { + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Raw { + schema_version: u32, + id: Id, + name: String, + #[serde(deserialize_with = "crate::unique_id_map")] + elements: BTreeMap, + #[serde(deserialize_with = "crate::unique_id_map")] + relations: BTreeMap, + } + let r = Raw::deserialize(d)?; + Ok(Self { + schema_version: r.schema_version, + id: r.id, + name: r.name, + elements: r.elements, + relations: r.relations, + aliases: BTreeMap::new(), + }) + } + } + pub mod validation { + use super::*; + #[derive(PartialEq, Eq)] + pub enum Severity { + Error, + } + pub struct Issue { + pub severity: Severity, + pub element: Option, + pub message: String, + } + fn issue(id: Option, message: impl Into) -> Issue { + Issue { + severity: Severity::Error, + element: id, + message: message.into(), + } + } + fn string_ok(s: &str) -> bool { + s.chars().all(|c| { + matches!(c, '\t' | '\n' | '\r') + || matches!(c as u32,0x20..=0xd7ff|0xe000..=0xfffd|0x10000..=0x10ffff) + }) + } + pub fn validate(p: &Project) -> Vec { + let mut out = Vec::new(); + if p.schema_version != 1 { + out.push(issue(None, "Unsupported schema version; expected 1.")); + } + if !string_ok(&p.name) { + out.push(issue( + None, + "Project name contains a character forbidden by XML 1.0.", + )); + } + let mut ids = std::collections::BTreeSet::from([p.id]); + let mut allocated = std::collections::BTreeSet::new(); + for (id, e) in &p.elements { + if *id != e.id || !ids.insert(e.id) { + out.push(issue( + Some(e.id), + "Inconsistent or duplicate element identity.", + )); + } + if !string_ok(&e.name) || !string_ok(&e.description) { + out.push(issue( + Some(e.id), + "Element text contains a character forbidden by XML 1.0.", + )); + } + if !e.position.x.is_finite() + || !e.position.y.is_finite() + || e.position.x.abs() > crate::MAX_COORDINATE + || e.position.y.abs() > crate::MAX_COORDINATE + { + out.push(issue( + Some(e.id), + "Coordinates must be finite and between -1000000 and 1000000.", + )); + } + if e.kind != Kind::Process && !e.steps.is_empty() { + out.push(issue(Some(e.id), "Only processes can contain steps.")); + } + for step in &e.steps { + if !p + .relations + .get(step) + .is_some_and(|r| r.kind == RelationKind::Interaction) + { + out.push(issue( + Some(e.id), + "Every process step must reference an existing interaction.", + )); + } + } + } + for (id, r) in &p.relations { + if *id != r.id || !ids.insert(r.id) { + out.push(issue( + Some(r.id), + "Inconsistent or duplicate relation identity.", + )); + } + if !string_ok(&r.name) { + out.push(issue( + Some(r.id), + "Relation text contains a character forbidden by XML 1.0.", + )); + } + let permitted = p + .elements + .get(&r.source) + .zip(p.elements.get(&r.target)) + .is_some_and(|(s, t)| match r.kind { + RelationKind::Allocation => { + matches!(s.kind, Kind::Entity | Kind::Actor) && t.kind == Kind::Activity + } + RelationKind::Interaction => { + s.kind == Kind::Activity && t.kind == Kind::Activity + } + RelationKind::Contribution => { + s.kind == Kind::Activity && t.kind == Kind::Capability + } + RelationKind::Realization => { + s.kind == Kind::Process && t.kind == Kind::Capability + } + }); + if !permitted || r.source == r.target { + out.push(issue( + Some(r.id), + std::format!("Invalid {:?} endpoints.", r.kind), + )); + } + if r.kind == RelationKind::Allocation && !allocated.insert(r.target) { + out.push(issue( + Some(r.id), + "An activity may have at most one direct allocation.", + )); + } + } + out + } + } +} + +fn validate_project(p: &Project) -> Result<(), FileError> { + if let Some(i) = validation::validate(p) + .into_iter() + .find(|i| i.severity == validation::Severity::Error) + { + return Err(FileError::Invalid(i.message)); + } + Ok(()) +} +pub fn load(path: &Path) -> Result { + decode(&std::fs::read(path)?) +} +/// Serialize to a sibling temporary file, flush, then atomically replace the destination. +pub fn save(path: &Path, p: &Project) -> Result<(), FileError> { + save_checked(path, p, None).map(|_| ()) +} +/// Return the exact bytes written. When supplied, `expected` is the last read or +/// saved file content; an external change/deletion is rejected before replacement. +/// This detects ordinary editor/Git conflicts; it is not a filesystem CAS lock. +pub fn save_checked( + path: &Path, + p: &Project, + expected: Option<&[u8]>, +) -> Result, FileError> { + validate_project(p)?; + let mut bytes = if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")) + { + crate::language::format(p).into_bytes() + } else { + serde_json::to_vec_pretty(p)? + }; + if !bytes.ends_with(b"\n") { + bytes.push(b'\n'); + } + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut temp = tempfile::NamedTempFile::new_in(parent)?; + temp.write_all(&bytes)?; + temp.as_file().sync_all()?; + if let Some(expected) = expected { + match std::fs::read(path) { + Ok(current) if current == expected => {} + Ok(_) => return Err(FileError::Conflict), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict), + Err(e) => return Err(FileError::Io(e)), + } + } + temp.persist(path).map_err(|e| FileError::Io(e.error))?; + Ok(bytes) +} diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs new file mode 100644 index 00000000..b5a2299a --- /dev/null +++ b/crates/oa-core/src/projection.rs @@ -0,0 +1,310 @@ +use crate::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectedNode { + pub id: Id, + pub element: Id, + pub position: Position, + pub size: Size, + pub visual_parent: Option, + pub depth: usize, + pub collapsed: bool, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectedEdge { + pub id: Id, + pub source: Id, + pub target: Id, + pub trace: bool, + pub source_port: Option, + pub target_port: Option, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectedPort { + pub element: Id, + /// Placement identity of the owning visible function/participant. + pub owner: Id, + pub position: Position, + pub direction: PortDirection, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct ProjectedView { + pub nodes: Vec, + pub edges: Vec, + pub ports: Vec, +} +pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size { + let count = [PortDirection::In, PortDirection::Out].into_iter().map(|direction| p.children(element).filter(|port| matches!(port.shared(), Some(SharedBody::Port {direction:d,..}) if *d==direction)).count()).max().unwrap_or(0); + if count > 0 { + size.height = size.height.max(76. + 28. * (count + 1) as f32); + } + size +} + +pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap> { + let placed: BTreeMap<_, _> = d + .nodes + .values() + .filter(|n| { + p.elements + .get(&n.element) + .is_some_and(|e| e.kind() != Kind::Port && d.projection.includes(e.kind())) + }) + .map(|n| (n.element, n.id)) + .collect(); + d.nodes + .values() + .filter(|n| placed.contains_key(&n.element)) + .map(|n| { + let mut semantic = p.elements.get(&n.element).and_then(|e| e.parent); + if d.projection == ProjectionKind::Allocation + && let Some(performer) = crate::validation::allocation_summary(p, n.element) + { + let keep_parent = semantic.is_some_and(|parent| { + crate::validation::allocation_summary(p, parent) == Some(performer) + }); + if !keep_parent { + semantic = Some(performer); + } + } + if d.projection == ProjectionKind::Traceability { + return (n.id, None); + } + let mut seen = BTreeSet::new(); + let mut parent = None; + while let Some(id) = semantic { + if !seen.insert(id) { + break; + } + if let Some(node) = placed.get(&id) { + parent = Some(*node); + break; + } + semantic = p.elements.get(&id).and_then(|e| e.parent); + } + (n.id, parent) + }) + .collect() +} +pub fn descendant_nodes(p: &Project, diagram: Id, node: Id) -> Vec { + let Some(d) = p.diagrams.get(&diagram) else { + return vec![]; + }; + let parents = parents(p, d); + parents + .keys() + .copied() + .filter(|candidate| { + let mut current = parents.get(candidate).copied().flatten(); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if id == node { + return true; + } + if !seen.insert(id) { + break; + } + current = parents.get(&id).copied().flatten(); + } + false + }) + .collect() +} +/// One projection drives drawing, hit testing, Fit, SVG, and grouped movement. +pub fn project_view(p: &Project, diagram: Id) -> ProjectedView { + let Some(d) = p.diagrams.get(&diagram) else { + return ProjectedView::default(); + }; + let parents = parents(p, d); + let mut nodes = Vec::new(); + for n in d.nodes.values() { + let Some(e) = p.elements.get(&n.element) else { + continue; + }; + if e.kind() == Kind::Port || !d.projection.includes(e.kind()) { + continue; + } + if d.projection != ProjectionKind::Traceability && e.layer() != d.layer { + continue; + } + let mut current = parents.get(&n.id).copied().flatten(); + let mut seen = BTreeSet::new(); + let mut depth = 0; + let mut hidden = false; + while let Some(id) = current { + if !seen.insert(id) { + hidden = true; + break; + } + depth += 1; + if d.nodes.get(&id).is_some_and(|n| n.collapsed) { + hidden = true; + } + current = parents.get(&id).copied().flatten(); + } + if !hidden { + let size = minimum_node_size(p, e.id, n.size); + nodes.push(ProjectedNode { + id: n.id, + element: n.element, + position: n.position, + size, + visual_parent: parents.get(&n.id).copied().flatten(), + depth, + collapsed: n.collapsed, + }); + } + } + nodes.sort_by_key(|n| (n.depth, n.id)); + for index in (0..nodes.len()).rev() { + let child = nodes[index].clone(); + if let Some(parent) = child + .visual_parent + .and_then(|id| nodes.iter().position(|n| n.id == id)) + { + let p = &mut nodes[parent]; + let right = + (p.position.x + p.size.width).max(child.position.x + child.size.width + 24.); + let bottom = + (p.position.y + p.size.height).max(child.position.y + child.size.height + 24.); + p.position.x = p.position.x.min(child.position.x - 24.); + // Reserve two readable header lines above the first nested card. + p.position.y = p.position.y.min(child.position.y - 64.); + p.size.width = right - p.position.x; + p.size.height = bottom - p.position.y; + } + } + let visible: BTreeSet<_> = nodes.iter().map(|n| n.id).collect(); + let placed: BTreeMap<_, _> = d.nodes.values().map(|n| (n.element, n.id)).collect(); + let anchor = |element: Id| { + let owner = p + .elements + .get(&element) + .filter(|e| e.kind() == Kind::Port) + .and_then(|e| e.parent) + .unwrap_or(element); + let mut id = placed.get(&owner).copied(); + let mut seen = BTreeSet::new(); + while let Some(n) = id { + if visible.contains(&n) { + return Some(n); + } + if !seen.insert(n) { + break; + } + id = parents.get(&n).copied().flatten(); + } + None + }; + let mut ports = Vec::new(); + for node in &nodes { + let mut children: Vec<_> = p + .children(node.element) + .filter(|e| e.kind() == Kind::Port) + .collect(); + children.sort_by_key(|e| (&e.alias, e.id)); + for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] { + let side: Vec<_> = children.iter().filter(|e| matches!(e.shared(), Some(SharedBody::Port {direction: d, ..}) if *d == direction)).collect(); + for (index, port) in side.iter().enumerate() { + let fraction = (index + 1) as f32 / (side.len() + 1) as f32; + let position = match direction { + PortDirection::In => Position { + x: node.position.x, + y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction, + }, + PortDirection::Out => Position { + x: node.position.x + node.size.width, + y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction, + }, + PortDirection::InOut => Position { + x: node.position.x + node.size.width * fraction, + y: node.position.y + node.size.height, + }, + }; + ports.push(ProjectedPort { + element: port.id, + owner: node.id, + position, + direction, + }); + } + } + } + let mut edges = Vec::new(); + for r in p.relations.values() { + if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation { + continue; + } + let included = match d.projection { + ProjectionKind::Allocation => matches!( + r.kind(), + RelationKind::Interaction + | RelationKind::FunctionalExchange + | RelationKind::BoundaryExchange + ), + ProjectionKind::FunctionalDataflow => matches!( + r.kind(), + RelationKind::Interaction | RelationKind::FunctionalExchange + ), + ProjectionKind::Capabilities => matches!( + r.kind(), + RelationKind::CapabilityInclude + | RelationKind::CapabilityExtend + | RelationKind::CapabilityGeneralization + | RelationKind::MissionCapability + | RelationKind::MissionInvolvement + | RelationKind::CapabilityInvolvement + ), + _ => true, + }; + if !included { + continue; + } + if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target())) + && source != target + { + let (source_port, target_port) = match &r.body { + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } => (*source_port, *target_port), + RelationBody::BoundaryExchange { source, target, .. } => { + (Some(*source), Some(*target)) + } + _ => (None, None), + }; + edges.push(ProjectedEdge { + id: r.id, + source, + target, + trace: false, + source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)), + target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)), + }); + } + } + if d.projection == ProjectionKind::Traceability { + for t in p.traces.values() { + if let (Some(source), Some(target)) = (anchor(t.oa), anchor(t.sa)) + && source != target + { + edges.push(ProjectedEdge { + id: t.id, + source, + target, + trace: true, + source_port: None, + target_port: None, + }); + } + } + } + ProjectedView { + nodes, + edges, + ports, + } +} diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs new file mode 100644 index 00000000..b03d10bc --- /dev/null +++ b/crates/oa-core/src/references.rs @@ -0,0 +1,380 @@ +use crate::*; +use std::collections::BTreeSet; + +/// Every live reference in a typed element, including structural ownership. +pub fn element_references(e: &Element) -> Vec { + let mut copy = e.clone(); + let mut refs = Vec::new(); + visit_element_refs(&mut copy, |id| refs.push(*id)); + refs +} +pub fn relation_references(r: &Relation) -> Vec { + let mut copy = r.clone(); + let mut refs = Vec::new(); + visit_relation_refs(&mut copy, |id| refs.push(*id)); + refs +} +pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) { + if let Some(parent) = &mut e.parent { + visit(parent); + } + match &mut e.body { + ElementBody::Oa(OaBody::Process { steps }) + | ElementBody::Sa(SaBody::FunctionalChain { steps }) => { + for id in steps { + visit(id); + } + } + _ => {} + } + if let Some(shared) = e.shared_mut() { + match shared { + SharedBody::DataField { + type_ref: TypeRef::Named(id), + .. + } => visit(id), + SharedBody::Interface { items } => { + for id in items { + visit(id); + } + } + SharedBody::Event { + body: EventBody::Signal { items }, + } => { + for id in items { + visit(id); + } + } + SharedBody::Port { + interface: Some(id), + .. + } => visit(id), + SharedBody::Scenario { context, steps, .. } => { + if let Some(id) = context { + visit(id); + } + for id in steps { + visit(id); + } + } + SharedBody::Lifeline { + represented: Some(id), + } => visit(id), + SharedBody::Occurrence { body, .. } => match body { + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + reply_to, + values, + .. + } => { + for id in [exchange, sender, receiver, reply_to].into_iter().flatten() { + visit(id); + } + for value in values { + visit(&mut value.field); + } + } + OccurrenceBody::Action { action, lifeline } => { + for id in [action, lifeline].into_iter().flatten() { + visit(id); + } + } + OccurrenceBody::SequenceReference { sequence } => { + if let Some(id) = sequence { + visit(id); + } + } + OccurrenceBody::Control { .. } => {} + OccurrenceBody::ScenarioUse { scenario, bindings } => { + if let Some(id) = scenario { + visit(id); + } + for binding in bindings { + visit(&mut binding.referenced); + visit(&mut binding.local); + } + } + OccurrenceBody::State { state, lifeline } => { + for id in [state, lifeline].into_iter().flatten() { + visit(id); + } + } + OccurrenceBody::Fragment { branches, .. } => { + for id in branches { + visit(id); + } + } + OccurrenceBody::Branch { occurrences, .. } => { + for id in occurrences { + visit(id); + } + } + }, + SharedBody::Constraint { constrained, .. } => { + for id in constrained { + visit(id); + } + } + _ => {} + } + } +} +pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) { + let (mut source, mut target) = r.body.endpoints(); + visit(&mut source); + visit(&mut target); + r.body.set_endpoints(source, target); + match &mut r.body { + RelationBody::StateTransition { + triggers, effects, .. + } => { + for id in triggers.iter_mut().chain(effects) { + visit(id); + } + } + RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => { + for id in items { + visit(id); + } + } + RelationBody::FunctionalExchange { + source_port, + target_port, + items, + .. + } => { + for id in [source_port, target_port].into_iter().flatten() { + visit(id); + } + for id in items { + visit(id); + } + } + _ => {} + } +} +pub fn remap_references(p: &mut Project, old: Id, new: Id) { + for e in p.elements.values_mut() { + visit_element_refs(e, |id| { + if *id == old { + *id = new; + } + }); + } + for r in p.relations.values_mut() { + visit_relation_refs(r, |id| { + if *id == old { + *id = new; + } + }); + } + for d in p.diagrams.values_mut() { + for n in d.nodes.values_mut() { + if n.element == old { + n.element = new; + } + } + } + for a in p.artifact_links.values_mut() { + if a.owner == old { + a.owner = new; + } + } + for t in p.traces.values_mut() { + if t.oa == old { + t.oa = new; + } + if t.sa == old { + t.sa = new; + } + } +} +/// IDs removed by a cascade. Cross-layer traced counterparts are never included. +pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet { + let mut removed = BTreeSet::from([id]); + loop { + let before = removed.len(); + for e in p.elements.values() { + let dependent_type = matches!(e.shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(t),..}) if removed.contains(t)); + if e.parent.is_some_and(|parent| removed.contains(&parent)) || dependent_type { + removed.insert(e.id); + } + if matches!(e.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(r),..},..}) if removed.contains(r)) + { + removed.insert(e.id); + } + } + for r in p.relations.values() { + if removed.contains(&r.source()) || removed.contains(&r.target()) { + removed.insert(r.id); + } + } + for d in p.diagrams.values() { + for n in d.nodes.values() { + if removed.contains(&d.id) || removed.contains(&n.element) { + removed.insert(n.id); + } + } + } + for t in p.traces.values() { + if removed.contains(&t.oa) || removed.contains(&t.sa) { + removed.insert(t.id); + } + } + for a in p.artifact_links.values() { + if removed.contains(&a.owner) { + removed.insert(a.id); + } + } + if removed.len() == before { + break; + } + } + removed +} +pub fn delete_cascade(p: &mut Project, id: Id) { + let removed = deletion_impact(p, id); + let removed_names: std::collections::BTreeMap<_, _> = removed + .iter() + .map(|id| (*id, p.named(*id).to_string())) + .collect(); + p.elements.retain(|id, _| !removed.contains(id)); + p.relations.retain(|id, _| !removed.contains(id)); + p.diagrams.retain(|id, _| !removed.contains(id)); + p.traces.retain(|id, _| !removed.contains(id)); + p.artifact_links.retain(|id, _| !removed.contains(id)); + for d in p.diagrams.values_mut() { + d.nodes.retain(|id, _| !removed.contains(id)); + } + let clear = |v: &mut Option| { + if v.is_some_and(|id| removed.contains(&id)) { + *v = None; + } + }; + for e in p.elements.values_mut() { + if let Some(steps) = e.steps_mut() { + steps.retain(|id| !removed.contains(id)); + } + if let Some(shared) = e.shared_mut() { + match shared { + SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)), + SharedBody::Event { + body: EventBody::Signal { items }, + } => items.retain(|id| !removed.contains(id)), + SharedBody::Port { interface, .. } => clear(interface), + SharedBody::Scenario { context, .. } => clear(context), + SharedBody::Lifeline { represented } => clear(represented), + SharedBody::Constraint { constrained, .. } => { + constrained.retain(|id| !removed.contains(id)) + } + SharedBody::Occurrence { body, .. } => match body { + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + reply_to, + values, + .. + } => { + clear(exchange); + clear(sender); + clear(receiver); + clear(reply_to); + values.retain(|value| !removed.contains(&value.field)); + } + OccurrenceBody::Action { action, lifeline } => { + clear(action); + clear(lifeline); + } + OccurrenceBody::SequenceReference { sequence } => clear(sequence), + OccurrenceBody::Control { .. } => {} + OccurrenceBody::ScenarioUse { scenario, bindings } => { + clear(scenario); + bindings.retain(|binding| { + !removed.contains(&binding.referenced) + && !removed.contains(&binding.local) + }); + } + OccurrenceBody::State { state, lifeline } => { + clear(state); + clear(lifeline); + } + OccurrenceBody::Fragment { branches, .. } => { + branches.retain(|id| !removed.contains(id)) + } + OccurrenceBody::Branch { .. } => {} + }, + _ => {} + } + } + } + for r in p.relations.values_mut() { + match &mut r.body { + RelationBody::StateTransition { + triggers, + effects, + trigger, + .. + } => { + let deleted: Vec<_> = triggers + .iter() + .filter(|id| removed.contains(id)) + .filter_map(|id| removed_names.get(id).cloned()) + .collect(); + triggers.retain(|id| !removed.contains(id)); + effects.retain(|id| !removed.contains(id)); + if !deleted.is_empty() && triggers.is_empty() && trigger.trim().is_empty() { + *trigger = format!( + "Removed trigger: {}. Choose a replacement trigger.", + deleted.join(", ") + ); + } + } + RelationBody::Interaction { items, .. } + | RelationBody::BoundaryExchange { items, .. } => { + items.retain(|id| !removed.contains(id)) + } + RelationBody::FunctionalExchange { + items, + source_port, + target_port, + .. + } => { + items.retain(|id| !removed.contains(id)); + clear(source_port); + clear(target_port); + } + _ => {} + } + } + // Removing an item or a containing payload field can disconnect a still-live + // record field from an exchange. Its contextual annotation is no longer valid. + let allowed: std::collections::BTreeMap<_, _> = p + .elements + .values() + .filter_map(|e| match e.shared() { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(id), .. + }, + .. + }) => p + .relations + .get(id) + .map(|r| (e.id, crate::validation::carried_fields(p, r))), + _ => None, + }) + .collect(); + for (id, fields) in allowed { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = p.elements.get_mut(&id).and_then(Element::shared_mut) + { + values.retain(|value| fields.contains(&value.field)); + } + } +} diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs new file mode 100644 index 00000000..b6666c4a --- /dev/null +++ b/crates/oa-core/src/session.rs @@ -0,0 +1,792 @@ +use crate::*; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Error, Serialize, Deserialize)] +#[error("{0}")] +pub struct ModelError(pub String); +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum Command { + Replace(Project), + Add(Kind), + AddElement { + kind: Kind, + layer: Layer, + name: String, + parent: Option, + }, + CreateElement(Element), + EditElement(Id, Element), + CreateRelation(Relation), + EditRelation(Id, Relation), + CreateDiagram(Diagram), + EditDiagram(Id, Diagram), + CreateArtifact(ArtifactLink), + EditArtifact(Id, ArtifactLink), + CreateTrace(Trace), + EditTrace(Id, Trace), + CreateNode { + diagram: Id, + node: NodePlacement, + }, + EditNode { + diagram: Id, + original: Id, + node: NodePlacement, + }, + RelocateNode { + node: Id, + diagram: Id, + }, + EditProject { + id: Id, + alias: String, + name: String, + description: String, + }, + Reparent { + element: Id, + parent: Option, + }, + Reidentify(Id, Id), + Rename(Id, String), + Describe(Id, String), + SetAlias(Id, String), + Connect(RelationKind, String, Id, Id), + Delete(Id), + Batch(Vec), + AddStep(Id, Id), + RemoveStep(Id, usize), + MoveStep(Id, usize, usize), + SetSteps(Id, Vec), + MoveNode { + diagram: Id, + node: Id, + position: Position, + with_descendants: bool, + }, + Layout(Id), +} + +pub struct Session { + project: Project, + past: Vec, + future: Vec, + saved: Project, + revision: u64, +} +impl Session { + pub fn try_new(project: Project) -> Result { + validate_candidate(&project)?; + Ok(Self { + saved: project.clone(), + project, + past: vec![], + future: vec![], + revision: 0, + }) + } + pub fn new(project: Project) -> Self { + Self::try_new(project).expect("Session requires a valid project") + } + pub fn project(&self) -> &Project { + &self.project + } + pub fn revision(&self) -> u64 { + self.revision + } + pub fn dirty(&self) -> bool { + self.project != self.saved + } + pub fn mark_saved(&mut self, snapshot: Project) { + self.saved = snapshot; + } + pub fn can_undo(&self) -> bool { + !self.past.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.future.is_empty() + } + pub fn undo(&mut self) { + if let Some(p) = self.past.pop() { + self.future.push(std::mem::replace(&mut self.project, p)); + self.revision = self.revision.saturating_add(1); + } + } + pub fn redo(&mut self) { + if let Some(p) = self.future.pop() { + self.past.push(std::mem::replace(&mut self.project, p)); + self.revision = self.revision.saturating_add(1); + } + } + pub fn execute_many(&mut self, commands: &[Command]) -> Result<(), ModelError> { + let candidate = execute_batch(&self.project, commands)?; + if candidate != self.project { + self.past + .push(std::mem::replace(&mut self.project, candidate)); + if self.past.len() > 100 { + self.past.remove(0); + } + self.future.clear(); + self.revision = self.revision.saturating_add(1); + } + Ok(()) + } + pub fn execute(&mut self, command: Command) -> Result, ModelError> { + let mut candidate = self.project.clone(); + let selected = apply_command(&mut candidate, &command, 0)?; + validate_candidate(&candidate)?; + if candidate != self.project { + self.past + .push(std::mem::replace(&mut self.project, candidate)); + if self.past.len() > 100 { + self.past.remove(0); + } + self.future.clear(); + self.revision = self.revision.saturating_add(1); + } + Ok(selected) + } +} +pub fn validate_candidate(p: &Project) -> Result<(), ModelError> { + if let Some(i) = validation::validate(p) + .into_iter() + .find(|i| i.severity == validation::Severity::Error) + { + Err(ModelError(format!("{}: {}", i.code, i.message))) + } else { + Ok(()) + } +} +pub fn execute_batch(project: &Project, commands: &[Command]) -> Result { + let mut candidate = project.clone(); + for command in commands { + apply_command(&mut candidate, command, 0)?; + } + validate_candidate(&candidate)?; + Ok(candidate) +} +fn vacant(p: &Project, id: Id) -> Result<(), ModelError> { + if p.contains(id) { + Err(ModelError( + "This ID already belongs to another object.".into(), + )) + } else { + Ok(()) + } +} +fn missing() -> ModelError { + ModelError("Object no longer exists.".into()) +} +fn detach(p: &mut Project, id: Id) { + for e in p.elements.values_mut() { + if let Some(steps) = e.steps_mut() { + steps.retain(|step| *step != id); + } + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = e.shared_mut() + { + branches.retain(|branch| *branch != id); + } + } +} +fn attach(p: &mut Project, id: Id) { + let Some(e) = p.elements.get(&id) else { + return; + }; + if e.kind() != Kind::Occurrence { + return; + } + let parent = e.parent; + let branch = matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) + ); + if let Some(owner) = parent.and_then(|id| p.elements.get_mut(&id)) { + if let Some(steps) = owner.steps_mut() { + if !steps.contains(&id) { + steps.push(id); + } + } else if branch + && let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = owner.shared_mut() + && !branches.contains(&id) + { + branches.push(id); + } + } +} +fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result, ModelError> { + if depth > 64 { + return Err(ModelError( + "Command batch nesting exceeds 64 levels.".into(), + )); + } + let selected = match c { + Command::Replace(value) => { + *p = value.clone(); + None + } + Command::Batch(commands) => { + let mut selected = None; + for command in commands { + let id = apply_command(p, command, depth + 1)?; + if id.is_some() { + selected = id; + } + } + selected + } + Command::Add(kind) => { + let layer = if kind.supports(Layer::Oa) { + Layer::Oa + } else { + Layer::Sa + }; + let name = format!("New {kind}"); + let e = p + .make_element(*kind, layer, &name, None) + .ok_or_else(|| ModelError("Invalid layer for kind.".into()))?; + let id = e.id; + p.elements.insert(id, e); + let d = p.ensure_diagram(layer); + let slot = p.diagrams[&d].nodes.len(); + p.place( + d, + id, + Position { + x: 70. + (slot % 4) as f32 * 270., + y: 70. + (slot / 4) as f32 * 150., + }, + ); + Some(id) + } + Command::AddElement { + kind, + layer, + name, + parent, + } => { + let e = p + .make_element(*kind, *layer, name, *parent) + .ok_or_else(|| ModelError("Invalid layer for kind.".into()))?; + let id = e.id; + p.elements.insert(id, e); + attach(p, id); + Some(id) + } + Command::CreateElement(value) => { + vacant(p, value.id)?; + p.elements.insert(value.id, value.clone()); + attach(p, value.id); + Some(value.id) + } + Command::EditElement(old, value) => { + let previous = p.elements.get(old).ok_or_else(missing)?; + let ownership_change = + previous.parent != value.parent || previous.kind() != value.kind(); + if ownership_change { + detach(p, *old); + } + let mut e = value.clone(); + e.id = *old; + p.elements.insert(*old, e); + reidentify(p, *old, value.id)?; + if ownership_change { + attach(p, value.id); + } + Some(value.id) + } + Command::CreateRelation(value) => { + vacant(p, value.id)?; + p.relations.insert(value.id, value.clone()); + Some(value.id) + } + Command::EditRelation(old, value) => { + if !p.relations.contains_key(old) { + return Err(missing()); + } + let mut v = value.clone(); + v.id = *old; + p.relations.insert(*old, v); + reidentify(p, *old, value.id)?; + Some(value.id) + } + Command::CreateDiagram(value) => { + vacant(p, value.id)?; + p.diagrams.insert(value.id, value.clone()); + Some(value.id) + } + Command::EditDiagram(old, value) => { + if !p.diagrams.contains_key(old) { + return Err(missing()); + } + let mut v = value.clone(); + v.id = *old; + p.diagrams.insert(*old, v); + reidentify(p, *old, value.id)?; + Some(value.id) + } + Command::CreateArtifact(value) => { + vacant(p, value.id)?; + p.artifact_links.insert(value.id, value.clone()); + Some(value.id) + } + Command::EditArtifact(old, value) => { + if !p.artifact_links.contains_key(old) { + return Err(missing()); + } + let mut v = value.clone(); + v.id = *old; + p.artifact_links.insert(*old, v); + reidentify(p, *old, value.id)?; + Some(value.id) + } + Command::CreateTrace(value) => { + vacant(p, value.id)?; + p.traces.insert(value.id, value.clone()); + Some(value.id) + } + Command::EditTrace(old, value) => { + if !p.traces.contains_key(old) { + return Err(missing()); + } + let mut v = value.clone(); + v.id = *old; + p.traces.insert(*old, v); + reidentify(p, *old, value.id)?; + Some(value.id) + } + Command::CreateNode { diagram, node } => { + vacant(p, node.id)?; + p.diagrams + .get_mut(diagram) + .ok_or_else(missing)? + .nodes + .insert(node.id, node.clone()); + Some(node.id) + } + Command::EditNode { + diagram, + original, + node, + } => { + let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?; + if !d.nodes.contains_key(original) { + return Err(missing()); + } + let mut n = node.clone(); + n.id = *original; + d.nodes.insert(*original, n); + reidentify(p, *original, node.id)?; + Some(node.id) + } + Command::RelocateNode { node, diagram } => { + if !p.diagrams.contains_key(diagram) { + return Err(missing()); + } + let source = p + .diagrams + .values() + .find(|d| d.nodes.contains_key(node)) + .map(|d| d.id) + .ok_or_else(missing)?; + if source != *diagram { + let placement = p + .diagrams + .get_mut(&source) + .unwrap() + .nodes + .remove(node) + .unwrap(); + p.diagrams + .get_mut(diagram) + .unwrap() + .nodes + .insert(*node, placement); + } + Some(*node) + } + Command::EditProject { + id, + alias, + name, + description, + } => { + reidentify(p, p.id, *id)?; + p.alias = alias.clone(); + p.name = name.clone(); + p.description = description.clone(); + None + } + Command::Reparent { element, parent } => { + let current_parent = p.elements.get(element).ok_or_else(missing)?.parent; + if current_parent != *parent { + detach(p, *element); + p.elements.get_mut(element).unwrap().parent = *parent; + attach(p, *element); + } + Some(*element) + } + Command::Reidentify(old, new) => { + reidentify(p, *old, *new)?; + Some(*new) + } + Command::Rename(id, value) => { + edit_header(p, *id, Some(value), None, None)?; + Some(*id) + } + Command::Describe(id, value) => { + edit_header(p, *id, None, Some(value), None)?; + Some(*id) + } + Command::SetAlias(id, value) => { + edit_header(p, *id, None, None, Some(value))?; + Some(*id) + } + Command::Connect(kind, name, source, target) => { + Some(p.relate(*kind, name, *source, *target)) + } + Command::Delete(id) => { + if *id == p.id { + return Err(ModelError("Use New/Replace to delete a project.".into())); + } + if !p.contains(*id) { + return Err(missing()); + } + crate::references::delete_cascade(p, *id); + None + } + Command::AddStep(owner, exchange) => { + let e = p.elements.get(owner).ok_or_else(missing)?; + if e.steps().is_none() { + return Err(ModelError( + "Selected element does not own an ordered sequence.".into(), + )); + } + let layer = e.layer(); + let name = format!("{} occurrence", p.named(*exchange)); + let mut occ = p + .make_element(Kind::Occurrence, layer, &name, Some(*owner)) + .unwrap(); + *occ.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(*exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + let id = occ.id; + p.elements.insert(id, occ); + attach(p, id); + Some(*owner) + } + Command::RemoveStep(owner, index) => { + let id = *p + .elements + .get(owner) + .and_then(Element::steps) + .and_then(|s| s.get(*index)) + .ok_or_else(|| ModelError("Step index is out of range.".into()))?; + crate::references::delete_cascade(p, id); + Some(*owner) + } + Command::MoveStep(owner, from, to) => { + let steps = p + .elements + .get_mut(owner) + .and_then(Element::steps_mut) + .ok_or_else(missing)?; + if *from >= steps.len() || *to >= steps.len() { + return Err(ModelError("Step index is out of range.".into())); + } + let step = steps.remove(*from); + steps.insert(*to, step); + Some(*owner) + } + Command::SetSteps(owner, value) => { + *p.elements + .get_mut(owner) + .and_then(Element::steps_mut) + .ok_or_else(missing)? = value.clone(); + Some(*owner) + } + Command::MoveNode { + diagram, + node, + position, + with_descendants, + } => { + let descendants = if *with_descendants { + crate::projection::descendant_nodes(p, *diagram, *node) + } else { + vec![] + }; + let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?; + let n = d.nodes.get_mut(node).ok_or_else(missing)?; + let delta = Position { + x: position.x - n.position.x, + y: position.y - n.position.y, + }; + n.position = *position; + for id in descendants { + if let Some(child) = d.nodes.get_mut(&id) { + child.position.x += delta.x; + child.position.y += delta.y; + } + } + Some(*node) + } + Command::Layout(diagram) => { + layout(p, *diagram)?; + Some(*diagram) + } + }; + Ok(selected) +} +fn edit_header( + p: &mut Project, + id: Id, + name: Option<&String>, + description: Option<&String>, + alias: Option<&String>, +) -> Result<(), ModelError> { + let assign = |n: &mut String, d: &mut String, a: &mut String| { + if let Some(v) = name { + *n = v.clone(); + } + if let Some(v) = description { + *d = v.clone(); + } + if let Some(v) = alias { + *a = v.clone(); + } + }; + if p.id == id { + assign(&mut p.name, &mut p.description, &mut p.alias); + return Ok(()); + } + if let Some(v) = p.elements.get_mut(&id) { + assign(&mut v.name, &mut v.description, &mut v.alias); + return Ok(()); + } + if let Some(v) = p.relations.get_mut(&id) { + assign(&mut v.name, &mut v.description, &mut v.alias); + return Ok(()); + } + if let Some(v) = p.diagrams.get_mut(&id) { + assign(&mut v.name, &mut v.description, &mut v.alias); + return Ok(()); + } + if let Some(v) = p.artifact_links.get_mut(&id) { + assign(&mut v.name, &mut v.description, &mut v.alias); + return Ok(()); + } + if let Some(v) = p.traces.get_mut(&id) { + assign(&mut v.name, &mut v.description, &mut v.alias); + return Ok(()); + } + for d in p.diagrams.values_mut() { + if let Some(n) = d.nodes.get_mut(&id) { + if name.is_some() || description.is_some() { + return Err(ModelError( + "Diagram nodes have no independent name/description.".into(), + )); + } + if let Some(a) = alias { + n.alias = a.clone(); + } + return Ok(()); + } + } + Err(missing()) +} +pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> { + if !p.contains(old) { + return Err(missing()); + } + if old == new { + return Ok(()); + } + vacant(p, new)?; + if p.id == old { + p.id = new; + } else if let Some(mut v) = p.elements.remove(&old) { + v.id = new; + p.elements.insert(new, v); + } else if let Some(mut v) = p.relations.remove(&old) { + v.id = new; + p.relations.insert(new, v); + } else if let Some(mut v) = p.diagrams.remove(&old) { + v.id = new; + p.diagrams.insert(new, v); + } else if let Some(mut v) = p.artifact_links.remove(&old) { + v.id = new; + p.artifact_links.insert(new, v); + } else if let Some(mut v) = p.traces.remove(&old) { + v.id = new; + p.traces.insert(new, v); + } else { + for d in p.diagrams.values_mut() { + if let Some(mut n) = d.nodes.remove(&old) { + n.id = new; + d.nodes.insert(new, n); + break; + } + } + } + crate::references::remap_references(p, old, new); + Ok(()) +} +fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> { + use std::collections::{BTreeMap, BTreeSet}; + let d = p.diagrams.get(&diagram).ok_or_else(missing)?; + let parents = crate::projection::parents(p, d); + let mut children: BTreeMap, Vec> = BTreeMap::new(); + for (id, parent) in &parents { + children.entry(*parent).or_default().push(*id); + } + fn measure( + p: &Project, + id: Id, + d: &Diagram, + children: &BTreeMap, Vec>, + sizes: &mut BTreeMap, + seen: &mut BTreeSet, + ) -> Result { + if !seen.insert(id) { + return Err(ModelError("Diagram containment cycle.".into())); + } + let own = crate::projection::minimum_node_size(p, d.nodes[&id].element, d.nodes[&id].size); + let mut width = own.width; + let mut height = 64.; + let kids = children.get(&Some(id)); + if let Some(kids) = kids { + for child in kids { + let size = measure(p, *child, d, children, sizes, seen)?; + width = width.max(size.width + 48.); + height += size.height + 16.; + } + height += 8.; + } else { + height = own.height; + } + let size = Size { + width, + height: height.max(own.height), + }; + sizes.insert(id, size); + Ok(size) + } + fn place( + id: Id, + position: Position, + children: &BTreeMap, Vec>, + sizes: &BTreeMap, + positions: &mut BTreeMap, + ) { + positions.insert(id, position); + let mut y = position.y + 64.; + if let Some(kids) = children.get(&Some(id)) { + for child in kids { + place( + *child, + Position { + x: position.x + 24., + y, + }, + children, + sizes, + positions, + ); + y += sizes[child].height + 16.; + } + } + } + // Orient containers by their actual exchanges. UUID ordering made otherwise + // identical models swap source/sink sides and sent boundary ports backwards. + let mut remaining: BTreeSet<_> = children + .get(&None) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + let root_of = |mut node| { + let mut seen = BTreeSet::new(); + while let Some(parent) = parents.get(&node).copied().flatten() { + if !seen.insert(node) { + break; + } + node = parent; + } + node + }; + let edges: BTreeSet<_> = crate::projection::project_view(p, diagram) + .edges + .iter() + .filter_map(|edge| { + let source = root_of(edge.source); + let target = root_of(edge.target); + (source != target).then_some((source, target)) + }) + .collect(); + let mut roots = Vec::new(); + while !remaining.is_empty() { + let next = remaining + .iter() + .filter(|id| { + !edges + .iter() + .any(|(source, target)| target == *id && remaining.contains(source)) + }) + .min_by_key(|id| (&p.elements[&d.nodes[id].element].alias, **id)) + .or_else(|| { + remaining + .iter() + .min_by_key(|id| (&p.elements[&d.nodes[id].element].alias, **id)) + }) + .copied() + .unwrap(); + remaining.remove(&next); + roots.push(next); + } + let mut sizes = BTreeMap::new(); + let mut seen = BTreeSet::new(); + for id in &roots { + measure(p, *id, d, &children, &mut sizes, &mut seen)?; + } + let mut positions = BTreeMap::new(); + let mut x = 60.; + let mut y = 60.; + let mut row_height = 0f32; + for (index, id) in roots.iter().enumerate() { + if index > 0 && index % 4 == 0 { + x = 60.; + y += row_height + 60.; + row_height = 0.; + } + place(*id, Position { x, y }, &children, &sizes, &mut positions); + x += sizes[id].width + 60.; + row_height = row_height.max(sizes[id].height); + } + let d = p.diagrams.get_mut(&diagram).unwrap(); + for (id, position) in positions { + let n = d.nodes.get_mut(&id).unwrap(); + n.position = position; + n.size = sizes[&id]; + } + Ok(()) +} diff --git a/crates/oa-core/src/state_validation.rs b/crates/oa-core/src/state_validation.rs new file mode 100644 index 00000000..5a8a2f19 --- /dev/null +++ b/crates/oa-core/src/state_validation.rs @@ -0,0 +1,469 @@ +//! Structural state-machine rules. Expressions describe intent; they are not executed. +use crate::{ + validation::{Issue, ancestor_of_kind, error, warning}, + *, +}; +use std::collections::BTreeSet; + +fn ancestors(p: &Project, id: Id) -> Vec { + let mut result = Vec::new(); + let mut current = p.elements.get(&id).and_then(|e| e.parent); + let mut seen = BTreeSet::from([id]); + while let Some(id) = current { + if !seen.insert(id) { + break; + } + result.push(id); + current = p.elements.get(&id).and_then(|e| e.parent); + } + result +} +fn within(p: &Project, id: Id, owner: Id) -> bool { + ancestors(p, id).contains(&owner) +} +fn boundary(p: &Project, vertex: &Element) -> Option { + vertex + .parent + .and_then(|region| p.elements.get(®ion)) + .and_then(|region| region.parent) +} +fn first_region(p: &Project, vertex: Id, owner: Id) -> Option { + ancestors(p, vertex).into_iter().find(|id| { + p.elements + .get(id) + .is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner)) + }) +} +fn edges<'a>( + p: &'a Project, + candidate: Option<&'a Relation>, +) -> impl Iterator { + p.relations + .values() + .filter(move |r| candidate.is_none_or(|c| c.id != r.id)) + .chain(candidate) + .filter(|r| r.kind() == RelationKind::StateTransition) +} +fn guard(r: &Relation) -> &str { + match &r.body { + RelationBody::StateTransition { guard, .. } => guard.trim(), + _ => "", + } +} +fn orthogonal(p: &Project, arms: &[Id]) -> bool { + let Some(first) = arms.first() else { + return true; + }; + if arms.len() < 2 { + return true; + } + let Some(owner) = ancestors(p, *first).into_iter().find(|id| { + p.elements + .get(id) + .is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine) + && arms.iter().all(|arm| within(p, *arm, *id)) + }) else { + return false; + }; + let regions: Option> = arms + .iter() + .map(|arm| first_region(p, *arm, owner)) + .collect(); + regions.is_some_and(|regions| regions.len() == arms.len()) +} + +pub(crate) fn validate_transition( + p: &Project, + r: &Relation, + a: &Element, + b: &Element, + out: &mut Vec, +) { + let RelationBody::StateTransition { + transition_kind, + trigger, + triggers, + guard, + .. + } = &r.body + else { + return; + }; + let machine = ancestor_of_kind(p, a.id, Kind::StateMachine); + if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) { + error( + out, + r.id, + "transition_machine", + "endpoints", + "State transition endpoints belong to one state machine.", + ); + } + if !a.kind().is_state() || !b.kind().is_state() { + return; + } + if a.id == b.id && !a.kind().is_stable_state() { + error( + out, + r.id, + "pseudostate_self", + "endpoints", + "Only a State or Mode can have a self-transition.", + ); + } + let valid_kind = match transition_kind { + TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id, + TransitionKind::Local if a.kind() == Kind::EntryPoint => { + boundary(p, a).is_some_and(|owner| within(p, b.id, owner)) + } + TransitionKind::Local => { + a.kind().is_stable_state() + && p.children(a.id).any(|e| e.kind() == Kind::Region) + && within(p, b.id, a.id) + } + TransitionKind::External => a.kind() != Kind::EntryPoint, + }; + if !valid_kind { + error( + out, + r.id, + "transition_kind", + "transition_kind", + "Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.", + ); + } + if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial { + error( + out, + r.id, + "pseudostate_direction", + "endpoints", + "Final/Terminate have no outgoing transitions; Initial has no incoming transition.", + ); + } + let triggered = !trigger.trim().is_empty() || !triggers.is_empty(); + if !a.kind().is_stable_state() && triggered { + error( + out, + r.id, + if a.kind() == Kind::Initial { + "initial_transition" + } else { + "pseudostate_trigger" + }, + "triggers", + "Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.", + ); + } + if (matches!( + a.kind(), + Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory + ) || b.kind() == Kind::Join) + && (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered)) + { + error( + out, + r.id, + if a.kind() == Kind::Initial { + "initial_transition" + } else { + "pseudostate_segment" + }, + "guard", + "Initial/history fallback and Fork output/Join input segments have no guard or trigger.", + ); + } + if a.kind() == Kind::Fork && !b.kind().is_stable_state() + || b.kind() == Kind::Join && !a.kind().is_stable_state() + { + error( + out, + r.id, + "orthogonal_endpoint", + "endpoints", + "Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.", + ); + } + if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) { + let scope = a.parent.is_some_and(|region| { + if a.kind() == Kind::ShallowHistory { + b.parent == Some(region) + } else { + within(p, b.id, region) + } + }); + if !b.kind().is_stable_state() || !scope { + error( + out, + r.id, + "history_scope", + "target", + "Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.", + ); + } + } + for (point, other, outgoing) in [(a, b, true), (b, a, false)] { + if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) { + continue; + } + let Some(owner) = boundary(p, point) else { + continue; + }; + let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing; + if within(p, other.id, owner) != needs_inside || other.id == owner { + error( + out, + r.id, + "boundary_transition", + "endpoints", + "EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.", + ); + } + } +} + +pub(crate) fn validate_vertex( + p: &Project, + e: &Element, + candidate: Option<&Relation>, + out: &mut Vec, +) { + if !e.kind().is_state() { + return; + } + let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect(); + let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect(); + let (ins, outs) = (incoming.len(), outgoing.len()); + let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1); + let max_out = matches!( + e.kind(), + Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint + ) + .then_some(1); + if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) { + error( + out, + e.id, + if e.kind() == Kind::Initial { + "initial_transition_count" + } else { + "pseudostate_cardinality" + }, + "body", + format!( + "{} has too many {} transitions.", + e.kind(), + if max_in.is_some_and(|max| ins > max) { + "incoming" + } else { + "outgoing" + } + ), + ); + } + let incomplete = match e.kind() { + Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0, + Kind::Final | Kind::Terminate => ins == 0, + Kind::Choice | Kind::Fork => ins == 0 || outs < 2, + Kind::Join => ins < 2 || outs == 0, + Kind::Junction => ins == 0 || outs == 0, + Kind::EntryPoint => outs == 0, + Kind::ExitPoint => { + ins == 0 + || (outs == 0 + && boundary(p, e) + .and_then(|id| p.elements.get(&id)) + .is_some_and(|e| e.kind() != Kind::StateMachine)) + } + _ => false, + }; + if incomplete { + warning( + out, + e.id, + "incomplete_state_vertex", + "body", + format!( + "Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.", + e.kind() + ), + ); + } + if e.kind() == Kind::Choice { + let defaults = outgoing.iter().filter(|r| guard(r) == "else").count(); + if defaults > 1 { + error( + out, + e.id, + "choice_default", + "guard", + "Choice has at most one literal else branch.", + ); + } + if defaults == 0 { + warning( + out, + e.id, + "missing_choice_default", + "guard", + "Add an else branch, or review and document why no alternative may apply.", + ); + } + if outgoing.iter().any(|r| guard(r).is_empty()) { + warning( + out, + e.id, + "missing_choice_guard", + "guard", + "Each Choice alternative needs an explicit guard or literal else.", + ); + } + } + if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) { + warning( + out, + e.id, + "junction_guard", + "guard", + "Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.", + ); + } + if matches!(e.kind(), Kind::Fork | Kind::Join) { + let arms: Vec<_> = if e.kind() == Kind::Fork { + outgoing.iter().map(|r| r.target()).collect() + } else { + incoming.iter().map(|r| r.source()).collect() + }; + if !orthogonal(p, &arms) { + error( + out, + e.id, + "orthogonal_regions", + "endpoints", + "Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.", + ); + } + } + if e.kind() == Kind::EntryPoint + && let Some(owner) = boundary(p, e) + { + let regions: Vec<_> = outgoing + .iter() + .filter_map(|r| first_region(p, r.target(), owner)) + .collect(); + let distinct: BTreeSet<_> = regions.iter().copied().collect(); + if distinct.len() != regions.len() { + error( + out, + e.id, + "entry_point_cardinality", + "target", + "EntryPoint has at most one outgoing transition per destination region.", + ); + } + if p.children(owner) + .filter(|r| r.kind() == Kind::Region) + .any(|r| { + !distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial) + }) + { + warning( + out, + e.id, + "entry_region_default", + "target", + "Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.", + ); + } + } +} + +pub(crate) fn validate_graph(p: &Project, out: &mut Vec) { + for e in p.elements.values() { + validate_vertex(p, e, None, out); + if e.kind() == Kind::Region { + for (kind, code) in [ + (Kind::Initial, "multiple_initial"), + (Kind::ShallowHistory, "multiple_shallow_history"), + (Kind::DeepHistory, "multiple_deep_history"), + ] { + if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 { + error( + out, + e.id, + code, + "parent", + format!("A region has at most one {kind}."), + ); + } + } + let explicit_entry = + edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id)); + if !p + .children(e.id) + .any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint)) + && !explicit_entry + { + warning( + out, + e.id, + "missing_initial", + "parent", + "Add an Initial default entry or an explicit incoming entry path for this region.", + ); + } + } + if e.kind() == Kind::StateMachine { + validate_reachability(p, e.id, out); + } + } +} + +fn validate_reachability(p: &Project, machine: Id, out: &mut Vec) { + let mut pending: Vec<_> = p + .children(machine) + .filter(|e| e.kind() == Kind::Region) + .flat_map(|region| p.children(region.id)) + .filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint)) + .map(|e| e.id) + .collect(); + let mut reached = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !reached.insert(id) { + continue; + } + pending.extend( + edges(p, None) + .filter(|r| r.source() == id) + .map(Relation::target), + ); + // Entering a nested vertex activates its composite ancestors. Reaching a + // composite also permits each region's default entry; this deliberately + // over-approximates guards/history instead of pretending to execute them. + pending.extend(ancestors(p, id).into_iter().filter(|id| { + p.elements + .get(id) + .is_some_and(|e| e.kind().is_stable_state()) + })); + pending.extend( + p.children(id) + .filter(|e| e.kind() == Kind::Region) + .flat_map(|region| p.children(region.id)) + .filter(|e| e.kind() == Kind::Initial) + .map(|e| e.id), + ); + } + for e in p.elements.values().filter(|e| { + e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine) + }) { + if !reached.contains(&e.id) { + warning( + out, + e.id, + "unreachable_state", + "body", + "No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).", + ); + } + } +} diff --git a/crates/oa-core/src/transitions.rs b/crates/oa-core/src/transitions.rs new file mode 100644 index 00000000..32789869 --- /dev/null +++ b/crates/oa-core/src/transitions.rs @@ -0,0 +1,945 @@ +use crate::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransitionDirection { + OaToSa, + SaToOa, +} +impl TransitionDirection { + pub fn source_layer(self) -> Layer { + match self { + Self::OaToSa => Layer::Oa, + Self::SaToOa => Layer::Sa, + } + } + pub fn target_layer(self) -> Layer { + match self { + Self::OaToSa => Layer::Sa, + Self::SaToOa => Layer::Oa, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DependencyScope { + SelectedOnly, + Required, + Subtree, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransitionField { + Name, + Description, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TransitionMapping { + pub source: Id, + pub target: Option, + pub target_kind: Option, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum FieldDecision { + TakeSource, + KeepTarget, + Value(String), +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FieldResolution { + pub source: Id, + pub field: TransitionField, + pub decision: FieldDecision, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TransitionRequest { + pub direction: TransitionDirection, + pub sources: Vec, + pub scope: DependencyScope, + pub mappings: Vec, + pub fields: Vec, + pub resolutions: Vec, + pub destination_diagram: Option, +} +impl TransitionRequest { + pub fn new(direction: TransitionDirection, sources: Vec) -> Self { + Self { + direction, + sources, + scope: DependencyScope::SelectedOnly, + mappings: vec![], + fields: vec![TransitionField::Name, TransitionField::Description], + resolutions: vec![], + destination_diagram: None, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransitionAction { + Create, + Update, + Reuse, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FieldChange { + pub field: TransitionField, + pub before: String, + pub after: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TransitionChange { + pub source: Id, + pub target: Id, + pub action: TransitionAction, + pub fields: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TransitionConflict { + pub source: Option, + pub target: Option, + pub field: Option, + pub message: String, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TransitionPlan { + pub base_fingerprint: String, + pub request: TransitionRequest, + pub changes: Vec, + pub conflicts: Vec, + pub warnings: Vec, + pub commands: Vec, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Drift { + Unreviewed, + Unchanged, + OaChanged, + SaChanged, + BothChanged, +} +pub fn trace_drift(p: &Project, t: &Trace) -> Drift { + let Some(base) = &t.baseline else { + return Drift::Unreviewed; + }; + let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else { + return Drift::Unreviewed; + }; + match (oa != base.oa, sa != base.sa) { + (false, false) => Drift::Unchanged, + (true, false) => Drift::OaChanged, + (false, true) => Drift::SaChanged, + (true, true) => Drift::BothChanged, + } +} +/// Deterministic change token, not a security hash or persisted revision. +pub fn project_fingerprint(p: &Project) -> String { + let bytes = serde_json::to_vec(p).expect("Project serializes"); + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} +fn conflict( + plan: &mut TransitionPlan, + source: Option, + target: Option, + field: Option, + message: impl Into, +) { + plan.conflicts.push(TransitionConflict { + source, + target, + field, + message: message.into(), + }); +} +fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option { + Some(match (direction, kind) { + (TransitionDirection::OaToSa, Kind::Activity) => Kind::Function, + (TransitionDirection::SaToOa, Kind::Function) => Kind::Activity, + (TransitionDirection::OaToSa, Kind::Capability) => Kind::SystemCapability, + (TransitionDirection::SaToOa, Kind::SystemCapability) => Kind::Capability, + (TransitionDirection::OaToSa, Kind::OperationalMission) => Kind::Mission, + (TransitionDirection::SaToOa, Kind::Mission) => Kind::OperationalMission, + (TransitionDirection::OaToSa, Kind::Process) => Kind::FunctionalChain, + (TransitionDirection::SaToOa, Kind::FunctionalChain) => Kind::Process, + (_, k) + if k.is_state() + || matches!( + k, + Kind::ExchangeItem + | Kind::DataType + | Kind::DataField + | Kind::Interface + | Kind::Port + | Kind::Scenario + | Kind::Lifeline + | Kind::Occurrence + | Kind::StateMachine + | Kind::Region + | Kind::Event + | Kind::Constraint + ) => + { + k + } + _ => return None, + }) +} +fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option { + if let Some(shared) = e.shared() + && kind == e.kind() + { + let mut shared = shared.clone(); + if let SharedBody::Scenario { viewpoint, .. } = &mut shared { + *viewpoint = match (*viewpoint, layer) { + (ScenarioViewpoint::OperationalActivity, Layer::Sa) => { + ScenarioViewpoint::Functional + } + (ScenarioViewpoint::Functional, Layer::Oa) => { + ScenarioViewpoint::OperationalActivity + } + (ScenarioViewpoint::ParticipantInteraction, Layer::Sa) => { + ScenarioViewpoint::BoundaryInterface + } + (ScenarioViewpoint::BoundaryInterface, Layer::Oa) => { + ScenarioViewpoint::ParticipantInteraction + } + (viewpoint, _) => viewpoint, + }; + } + return Some(ElementBody::shared_in(layer, shared)); + } + let mut body = ElementBody::new(kind, layer)?; + if let (Some(old), Some(new)) = (e.steps(), body.steps_mut()) { + *new = old.clone(); + } + Some(body) +} +fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option { + let (source, target) = r.body.endpoints(); + Some(match (&r.body, direction) { + (RelationBody::Interaction { items, .. }, TransitionDirection::OaToSa) => { + RelationBody::FunctionalExchange { + source, + target, + source_port: None, + target_port: None, + items: items.clone(), + } + } + (RelationBody::FunctionalExchange { items, .. }, TransitionDirection::SaToOa) => { + RelationBody::Interaction { + source, + target, + items: items.clone(), + } + } + ( + RelationBody::Allocation { .. } + | RelationBody::ExchangeAllocation { .. } + | RelationBody::CapabilityInvolvement { .. } + | RelationBody::CapabilityInclude { .. } + | RelationBody::CapabilityExtend { .. } + | RelationBody::CapabilityGeneralization { .. } + | RelationBody::MissionInvolvement { .. } + | RelationBody::MissionCapability { .. } + | RelationBody::SequenceLink { .. } + | RelationBody::ChainInvolvement { .. } + | RelationBody::BoundaryExchange { .. } + | RelationBody::InterfaceProvision { .. } + | RelationBody::InterfaceRequirement { .. } + | RelationBody::StateTransition { .. } + | RelationBody::Availability { .. } + | RelationBody::StateAction { .. }, + _, + ) => r.body.clone(), + _ => return None, + }) +} +fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind { + match (p.elements.get(&oa), p.elements.get(&sa)) { + (Some(a), Some(b)) => match (a.kind(), b.kind()) { + (Kind::Activity, Kind::Function) => TraceKind::Refinement, + (Kind::Entity | Kind::Actor, Kind::System | Kind::SystemActor) => { + TraceKind::Participant + } + (Kind::Capability, Kind::SystemCapability) => TraceKind::Capability, + (Kind::OperationalMission, Kind::Mission) => TraceKind::Mission, + (Kind::Process, Kind::FunctionalChain) => TraceKind::Sequence, + (Kind::Occurrence, Kind::Occurrence) + if a.parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|owner| owner.kind() == Kind::Process) + && b.parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|owner| owner.kind() == Kind::FunctionalChain) => + { + TraceKind::Sequence + } + ( + Kind::ExchangeItem + | Kind::DataType + | Kind::DataField + | Kind::Interface + | Kind::Port, + _, + ) => TraceKind::Item, + (Kind::Scenario | Kind::Lifeline | Kind::Occurrence, _) => TraceKind::Scenario, + (kind, other) + if kind == other + && (kind.is_state() + || matches!(kind, Kind::StateMachine | Kind::Region | Kind::Event)) => + { + TraceKind::State + } + _ => TraceKind::Manual, + }, + _ => { + if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange) + { + TraceKind::Exchange + } else { + TraceKind::Manual + } + } + } +} +fn value(props: &TracedProperties, field: TransitionField) -> &String { + match field { + TransitionField::Name => &props.name, + TransitionField::Description => &props.description, + } +} +fn set(props: &mut TracedProperties, field: TransitionField, value: String) { + match field { + TransitionField::Name => props.name = value, + TransitionField::Description => props.description = value, + } +} + +pub fn plan_transition( + project: &Project, + request: &TransitionRequest, +) -> Result { + validate_candidate(project)?; + let mut plan = TransitionPlan { + base_fingerprint: project_fingerprint(project), + request: request.clone(), + changes: vec![], + conflicts: vec![], + warnings: vec![], + commands: vec![], + }; + let mut selected: BTreeSet<_> = request.sources.iter().copied().collect(); + if selected.len() != request.sources.len() { + conflict( + &mut plan, + None, + None, + None, + "Source selection contains duplicate identities.", + ); + } + if selected.is_empty() { + conflict( + &mut plan, + None, + None, + None, + "Select at least one source object.", + ); + } + if request.scope != DependencyScope::SelectedOnly { + loop { + let before = selected.len(); + let existing: Vec<_> = selected.iter().copied().collect(); + for id in existing { + if let Some(e) = project.elements.get(&id) { + selected.extend(crate::references::element_references(e)); + // Contextual links are owned by the process/chain shared by + // their endpoint involvements, independently of list order. + if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + selected.extend(project.relations.values().filter_map(|relation| { + (relation.kind() == RelationKind::SequenceLink + && project + .elements + .get(&relation.source()) + .and_then(|e| e.parent) + == Some(id)) + .then_some(relation.id) + })); + } + if e.kind() == Kind::StateMachine && request.scope == DependencyScope::Subtree { + selected.extend(project.relations.values().filter_map(|relation| { + (matches!( + relation.kind(), + RelationKind::StateTransition | RelationKind::StateAction + ) && validation::ancestor_of_kind( + project, + relation.source(), + Kind::StateMachine, + ) == Some(id)) + .then_some(relation.id) + })); + } + if request.scope == DependencyScope::Subtree + || matches!(e.kind(), Kind::ExchangeItem | Kind::DataType) + { + selected.extend(project.children(id).map(|e| e.id)); + } + } else if let Some(r) = project.relations.get(&id) { + selected.extend(crate::references::relation_references(r)); + } + } + if selected.len() == before { + break; + } + } + } + let mut choices = BTreeMap::new(); + for mapping in &request.mappings { + if choices.insert(mapping.source, mapping).is_some() { + conflict( + &mut plan, + Some(mapping.source), + None, + None, + "Choose one counterpart per source in this plan; run separately for another existing trace.", + ); + } + } + let mut targets = BTreeMap::new(); + let mut creating = BTreeSet::new(); + let mut kinds = BTreeMap::new(); + for source in &selected { + if project.layer_of(*source) != Some(request.direction.source_layer()) { + conflict( + &mut plan, + Some(*source), + None, + None, + "Source is not a semantic object in the selected source layer.", + ); + continue; + } + let explicit = choices.get(source).copied(); + let existing: Vec<_> = project + .traces + .values() + .filter_map(|t| match request.direction { + TransitionDirection::OaToSa if t.oa == *source => Some(t.sa), + TransitionDirection::SaToOa if t.sa == *source => Some(t.oa), + _ => None, + }) + .collect::>() + .into_iter() + .collect(); + let target = if let Some(mapping) = explicit { + mapping.target + } else if existing.len() == 1 { + Some(existing[0]) + } else if existing.len() > 1 { + conflict( + &mut plan, + Some(*source), + None, + None, + "Several traced counterparts exist; select the target explicitly.", + ); + continue; + } else { + None + }; + if let Some(target) = target { + if project.layer_of(target) != Some(request.direction.target_layer()) { + conflict( + &mut plan, + Some(*source), + Some(target), + None, + "Selected target must exist in the opposite layer.", + ); + continue; + } + if project.elements.contains_key(source) != project.elements.contains_key(&target) { + conflict( + &mut plan, + Some(*source), + Some(target), + None, + "Elements map to elements and relationships map to relationships.", + ); + continue; + } + if !compatible_existing(project, *source, target, request.direction) { + conflict( + &mut plan, + Some(*source), + Some(target), + None, + "These kinds have no automatic transition mapping; use a manual trace.", + ); + continue; + } + targets.insert(*source, target); + continue; + } + if let Some(e) = project.elements.get(source) { + let kind = explicit + .and_then(|m| m.target_kind) + .or_else(|| inferred_kind(e.kind(), request.direction)); + let Some(kind) = kind else { + conflict( + &mut plan, + Some(*source), + None, + None, + "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred.", + ); + continue; + }; + if !kind.supports(request.direction.target_layer()) + || !compatible_kinds(e.kind(), kind, request.direction) + { + conflict( + &mut plan, + Some(*source), + None, + None, + "Counterpart kind is not valid for this automatic transition.", + ); + continue; + } + kinds.insert(*source, kind); + } else if let Some(r) = project.relations.get(source) + && mapped_relation(r, request.direction).is_none() + { + conflict( + &mut plan, + Some(*source), + None, + None, + "This relation has no automatic cross-layer mapping; use an explicit manual trace.", + ); + continue; + } + targets.insert(*source, Id::new_v4()); + creating.insert(*source); + } + // Explicit mapping choices may provide dependencies without selecting updates. + for mapping in &request.mappings { + if let Some(target) = mapping.target + && project.layer_of(mapping.source) == Some(request.direction.source_layer()) + && project.layer_of(target) == Some(request.direction.target_layer()) + { + targets.entry(mapping.source).or_insert(target); + } + } + let mut reused = BTreeMap::new(); + for source in &selected { + if let Some(target) = targets.get(source) + && let Some(previous) = reused.insert(*target, *source) + { + let different = match (project.properties(previous), project.properties(*source)) { + (Some(a), Some(b)) => request + .fields + .iter() + .any(|field| value(&a, *field) != value(&b, *field)), + _ => true, + }; + if different { + conflict( + &mut plan, + Some(*source), + Some(*target), + None, + "Several selected sources propose different values for one target. Reconcile that target explicitly in separate previews.", + ); + } + } + } + let mut working = project.clone(); + for source in selected.iter().filter(|id| creating.contains(id)) { + let Some(target) = targets.get(source).copied() else { + continue; + }; + if let Some(e) = project.elements.get(source) { + let Some(body) = mapped_body(e, kinds[source], request.direction.target_layer()) else { + conflict( + &mut plan, + Some(*source), + Some(target), + None, + "Unsupported typed body conversion.", + ); + continue; + }; + let mut created = e.clone(); + created.id = target; + created.body = body; + created.alias = working.fresh_alias( + &format!( + "{}_{}", + request.direction.target_layer().to_string().to_lowercase(), + e.alias + ), + "element", + target, + ); + crate::references::visit_element_refs(&mut created, |id| { + if let Some(mapped) = targets.get(id) { + *id = *mapped; + } + }); + for dependency in crate::references::element_references(&created) { + if !targets.values().any(|id| *id == dependency) + && working.layer_of(dependency) != Some(request.direction.target_layer()) + { + conflict( + &mut plan, + Some(*source), + Some(target), + None, + format!( + "Dependency {dependency} needs an explicit counterpart or a broader dependency scope." + ), + ); + } + } + if !request.fields.contains(&TransitionField::Description) { + created.description.clear(); + } + working.elements.insert(target, created.clone()); + plan.commands.push(Command::CreateElement(created)); + } else if let Some(r) = project.relations.get(source) { + let Some(body) = mapped_relation(r, request.direction) else { + continue; + }; + let mut created = r.clone(); + created.id = target; + created.body = body; + created.alias = working.fresh_alias( + &format!( + "{}_{}", + request.direction.target_layer().to_string().to_lowercase(), + r.alias + ), + "relation", + target, + ); + crate::references::visit_relation_refs(&mut created, |id| { + if let Some(mapped) = targets.get(id) { + *id = *mapped; + } + }); + for dependency in crate::references::relation_references(&created) { + if !targets.values().any(|id| *id == dependency) + && working.layer_of(dependency) != Some(request.direction.target_layer()) + { + conflict( + &mut plan, + Some(*source), + Some(target), + None, + format!( + "Relationship dependency {dependency} needs a counterpart mapping." + ), + ); + } + } + if !request.fields.contains(&TransitionField::Description) { + created.description.clear(); + } + working.relations.insert(target, created.clone()); + plan.commands.push(Command::CreateRelation(created)); + } + } + for source in &selected { + let Some(target) = targets.get(source).copied() else { + continue; + }; + let (Some(source_props), Some(mut target_props)) = + (project.properties(*source), working.properties(target)) + else { + continue; + }; + let (oa, sa) = match request.direction { + TransitionDirection::OaToSa => (*source, target), + TransitionDirection::SaToOa => (target, *source), + }; + let existing_trace = project.traces.values().find(|t| t.oa == oa && t.sa == sa); + let mut changes = vec![]; + let mut reviewed_fields = vec![]; + for field in &request.fields { + let before = value(&target_props, *field).clone(); + let incoming = value(&source_props, *field).clone(); + if before == incoming { + reviewed_fields.push(*field); + continue; + } + let decision = request + .resolutions + .iter() + .find(|r| r.source == *source && r.field == *field) + .map(|r| &r.decision); + let changed = existing_trace + .and_then(|t| t.baseline.as_ref()) + .map(|base| { + let (origin, destination) = match request.direction { + TransitionDirection::OaToSa => (&base.oa, &base.sa), + TransitionDirection::SaToOa => (&base.sa, &base.oa), + }; + ( + value(origin, *field) != &incoming, + value(destination, *field) != &before, + ) + }); + let after = match decision { + Some(FieldDecision::TakeSource) => incoming, + Some(FieldDecision::KeepTarget) => before.clone(), + Some(FieldDecision::Value(v)) => v.clone(), + None if creating.contains(source) => incoming, + // A reviewed pair may deliberately have different values. No + // source edit means no propagation, including when the target + // has a local edit that can later propagate in reverse. + None if matches!(changed, Some((false, _))) => continue, + None if matches!(changed, Some((true, false))) => incoming, + None => { + conflict( + &mut plan, + Some(*source), + Some(target), + Some(*field), + "Both sides changed or no reviewed baseline exists. Choose take source, keep target, or a merged value.", + ); + continue; + } + }; + reviewed_fields.push(*field); + if after != before { + set(&mut target_props, *field, after.clone()); + changes.push(FieldChange { + field: *field, + before, + after, + }); + } + } + if !changes.is_empty() { + if let Some(e) = working.elements.get_mut(&target) { + e.name = target_props.name.clone(); + e.description = target_props.description.clone(); + plan.commands.push(Command::EditElement(target, e.clone())); + } else if let Some(r) = working.relations.get_mut(&target) { + r.name = target_props.name.clone(); + r.description = target_props.description.clone(); + plan.commands.push(Command::EditRelation(target, r.clone())); + } + } + plan.changes.push(TransitionChange { + source: *source, + target, + action: if creating.contains(source) { + TransitionAction::Create + } else if changes.is_empty() { + TransitionAction::Reuse + } else { + TransitionAction::Update + }, + fields: changes, + }); + let current_oa = working.properties(oa).unwrap(); + let current_sa = working.properties(sa).unwrap(); + let mut baseline = + existing_trace + .and_then(|t| t.baseline.clone()) + .unwrap_or(TraceBaseline { + oa: current_oa.clone(), + sa: current_sa.clone(), + }); + for field in &reviewed_fields { + set(&mut baseline.oa, *field, value(¤t_oa, *field).clone()); + set(&mut baseline.sa, *field, value(¤t_sa, *field).clone()); + } + if let Some(old) = existing_trace { + let mut trace = old.clone(); + trace.baseline = Some(baseline); + if &trace != old { + working.traces.insert(trace.id, trace.clone()); + plan.commands.push(Command::EditTrace(trace.id, trace)); + } + } else { + let id = Id::new_v4(); + let name = format!("{} ↔ {}", working.named(oa), working.named(sa)); + let alias = working.fresh_alias( + &format!( + "trace_{}_{}", + working + .elements + .get(&oa) + .map(|e| e.alias.as_str()) + .unwrap_or("exchange"), + working + .elements + .get(&sa) + .map(|e| e.alias.as_str()) + .unwrap_or("exchange") + ), + "trace", + id, + ); + let trace = Trace { + id, + alias, + name, + description: String::new(), + oa, + sa, + kind: trace_kind(&working, oa, sa), + baseline: Some(baseline), + }; + working.traces.insert(id, trace.clone()); + plan.commands.push(Command::CreateTrace(trace)); + } + if let Some(diagram) = request.destination_diagram + && working.elements.contains_key(&target) + { + match working.diagrams.get(&diagram) { + Some(d) + if d.layer == request.direction.target_layer() + || d.projection == ProjectionKind::Traceability => + { + if !d.nodes.values().any(|n| n.element == target) { + let index = d.nodes.len(); + let id = Id::new_v4(); + let alias = working.fresh_alias( + &format!("view_{}", working.named(target)), + "node", + id, + ); + let node = NodePlacement { + id, + alias, + element: target, + position: Position { + x: 60. + (index % 4) as f32 * 270., + y: 60. + (index / 4) as f32 * 160., + }, + size: Size { + width: 224., + height: 94., + }, + collapsed: false, + }; + working + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .insert(id, node.clone()); + plan.commands.push(Command::CreateNode { diagram, node }); + } + } + _ => conflict( + &mut plan, + Some(*source), + Some(target), + None, + "Destination diagram is missing or belongs to the wrong layer.", + ), + } + } + } + // Final source-order-independent batch execution also applies occurrence ownership rules. + match execute_batch(project, &plan.commands) { + Ok(candidate) => { + for issue in validation::validate(&candidate) { + if issue.severity == validation::Severity::Warning { + plan.warnings.push(issue.message); + } + } + } + Err(err) => conflict( + &mut plan, + None, + None, + None, + format!("Candidate validation: {err}"), + ), + } + Ok(plan) +} +pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result { + if project_fingerprint(project) != plan.base_fingerprint { + return Err(ModelError( + "Transition preview is stale; create a new preview.".into(), + )); + } + if !plan.conflicts.is_empty() { + return Err(ModelError( + "Resolve transition conflicts before applying.".into(), + )); + } + execute_batch(project, &plan.commands) +} + +/// Supported explicit element counterparts, in stable kind-catalog order. +pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec { + let mut kinds: Vec<_> = Kind::ALL + .into_iter() + .filter(|target| { + target.supports(direction.target_layer()) + && compatible_kinds(source, *target, direction) + }) + .collect(); + // Offer the semantic counterpart first; alternative engineering mappings + // remain explicit choices and existing traces remain reusable. + let preferred = inferred_kind(source, direction); + kinds.sort_by_key(|kind| Some(*kind) != preferred); + kinds +} + +fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool { + if matches!( + (direction, source, target), + ( + TransitionDirection::OaToSa, + Kind::Entity | Kind::Actor, + Kind::System | Kind::SystemActor + ) | ( + TransitionDirection::SaToOa, + Kind::System | Kind::SystemActor, + Kind::Entity | Kind::Actor + ) | (TransitionDirection::OaToSa, Kind::Capability, Kind::Mission) + | (TransitionDirection::SaToOa, Kind::Mission, Kind::Capability) + ) { + return true; + } + inferred_kind(source, direction) == Some(target) +} +fn compatible_existing( + p: &Project, + source: Id, + target: Id, + direction: TransitionDirection, +) -> bool { + match (p.elements.get(&source), p.elements.get(&target)) { + (Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction), + _ => match (p.relations.get(&source), p.relations.get(&target)) { + (Some(a), Some(b)) => { + mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind()) + } + _ => false, + }, + } +} diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs new file mode 100644 index 00000000..cd7d97ef --- /dev/null +++ b/crates/oa-core/src/validation.rs @@ -0,0 +1,2725 @@ +use crate::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Severity { + Error, + Warning, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + pub severity: Severity, + pub element: Option, + pub code: String, + pub field: Option, + pub message: String, +} +fn issue( + out: &mut Vec, + severity: Severity, + id: Option, + code: &str, + field: &str, + message: impl Into, +) { + out.push(Issue { + severity, + element: id, + code: code.into(), + field: (!field.is_empty()).then(|| field.into()), + message: message.into(), + }); +} +pub(crate) fn error( + out: &mut Vec, + id: Id, + code: &str, + field: &str, + message: impl Into, +) { + issue(out, Severity::Error, Some(id), code, field, message); +} +pub(crate) fn warning( + out: &mut Vec, + id: Id, + code: &str, + field: &str, + message: impl Into, +) { + issue(out, Severity::Warning, Some(id), code, field, message); +} +fn text(out: &mut Vec, id: Id, field: &str, value: &str) { + if !valid_text(value) { + error( + out, + id, + "invalid_text", + field, + "Text contains a character forbidden by XML 1.0.", + ); + } +} +pub fn valid_text(value: &str) -> bool { + value.chars().all(|c|matches!(c,'\t'|'\n'|'\r'|'\u{20}'..='\u{d7ff}'|'\u{e000}'..='\u{fffd}'|'\u{10000}'..='\u{10ffff}')) +} +pub fn valid_uri_reference(uri: &str) -> bool { + if uri.is_empty() + || uri + .chars() + .any(|c| c.is_whitespace() || c.is_control() || c == '\\') + { + return false; + } + let bytes = uri.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() + || !bytes[i + 1].is_ascii_hexdigit() + || !bytes[i + 2].is_ascii_hexdigit() + { + return false; + } + i += 2; + } + i += 1; + } + true +} +pub fn parent_permits(parent: Kind, child: Kind) -> bool { + if child.is_state() { + return parent == Kind::Region; + } + match child { + Kind::Entity | Kind::Actor | Kind::Role => parent == Kind::Entity, + Kind::Activity => parent == Kind::Activity, + Kind::Function => parent == Kind::Function, + Kind::Capability => parent == Kind::Capability, + Kind::SystemCapability => parent == Kind::SystemCapability, + Kind::DataField => matches!(parent, Kind::ExchangeItem | Kind::DataType), + Kind::Port => parent.is_performer() || parent.is_work(), + Kind::Lifeline => parent == Kind::Scenario, + Kind::Occurrence => parent.is_sequence() || parent == Kind::Occurrence, + Kind::StateMachine => parent.is_performer(), + Kind::Region => parent == Kind::StateMachine || parent.is_stable_state(), + _ => false, + } +} +pub fn ancestor_of_kind(p: &Project, id: Id, kind: Kind) -> Option { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id) { + return None; + } + let e = p.elements.get(&id)?; + if e.kind() == kind { + return Some(id); + } + current = e.parent; + } + None +} +fn expect_element( + p: &Project, + out: &mut Vec, + owner: Id, + reference: Id, + field: &str, + allowed: impl Fn(Kind) -> bool, +) { + match p.elements.get(&reference) { + Some(e) if allowed(e.kind()) && p.layer_of(owner) == Some(e.layer()) => {} + _ => error( + out, + owner, + "invalid_reference_kind", + field, + format!("{field} must reference an existing compatible element in the same layer."), + ), + } +} +fn optional_element( + p: &Project, + out: &mut Vec, + owner: Id, + id: Option, + field: &str, + allowed: impl Fn(Kind) -> bool, +) { + if let Some(id) = id { + expect_element(p, out, owner, id, field, allowed) + } else { + warning( + out, + owner, + "incomplete_reference", + field, + format!("Choose {field}."), + ); + } +} +fn check_ids(out: &mut Vec, owner: Id, field: &str, ids: &[Id]) { + let mut seen = BTreeSet::new(); + for id in ids { + if !seen.insert(id) { + error( + out, + owner, + "duplicate_reference", + field, + format!( + "{field} repeats identity {id}; create distinct occurrence objects for repeated execution." + ), + ); + } + } +} + +pub fn validate(p: &Project) -> Vec { + let mut out = Vec::new(); + if p.schema_version != SCHEMA_VERSION { + error( + &mut out, + p.id, + "unsupported_schema", + "schema_version", + format!( + "Unsupported schema {} (expected {SCHEMA_VERSION})", + p.schema_version + ), + ); + } + let mut ids = BTreeSet::new(); + let mut aliases = BTreeMap::new(); + for (id, alias) in p.identities() { + if !ids.insert(id) { + error( + &mut out, + id, + "duplicate_identity", + "id", + "All model objects share one UUID namespace.", + ); + } + if !valid_alias(alias) { + error( + &mut out, + id, + "invalid_alias", + "alias", + format!("Invalid or reserved alias '{alias}'."), + ); + } + if let Some(other) = aliases.insert(alias, id) { + error( + &mut out, + id, + "duplicate_alias", + "alias", + format!("Alias '{alias}' also belongs to {other}."), + ); + } + } + text(&mut out, p.id, "name", &p.name); + text(&mut out, p.id, "description", &p.description); + if p.name.trim().is_empty() { + warning( + &mut out, + p.id, + "unnamed", + "name", + "Give the project a descriptive name.", + ); + } + if p.elements + .values() + .filter(|e| e.kind() == Kind::System) + .count() + > 1 + { + error( + &mut out, + p.id, + "multiple_systems", + "elements", + "A single SA analysis has at most one system of interest.", + ); + } + if p.elements.values().any(|e| e.layer() == Layer::Sa) + && !p.elements.values().any(|e| e.kind() == Kind::System) + { + warning( + &mut out, + p.id, + "missing_system", + "elements", + "Identify the SA system of interest.", + ); + } + for (key, e) in &p.elements { + if *key != e.id { + error( + &mut out, + e.id, + "map_identity", + "id", + "Element ID does not match its map key.", + ); + } + text(&mut out, e.id, "name", &e.name); + text(&mut out, e.id, "description", &e.description); + if e.name.trim().is_empty() { + warning( + &mut out, + e.id, + "unnamed", + "name", + "Give this element a name.", + ); + } + if let Some(parent) = e.parent { + match p.elements.get(&parent) { + Some(owner) + if parent != e.id + && owner.layer() == e.layer() + && parent_permits(owner.kind(), e.kind()) => {} + _ => error( + &mut out, + e.id, + "invalid_parent", + "parent", + "Parent must be a compatible owner in the same layer.", + ), + } + } else if e.kind().is_state() + || matches!( + e.kind(), + Kind::DataField | Kind::Port | Kind::Lifeline | Kind::Occurrence | Kind::Region + ) + { + error( + &mut out, + e.id, + "missing_parent", + "parent", + "This kind requires a structural owner.", + ); + } + let mut current = e.parent; + let mut seen = BTreeSet::from([e.id]); + while let Some(id) = current { + if !seen.insert(id) { + error( + &mut out, + e.id, + "containment_cycle", + "parent", + "Containment must be acyclic.", + ); + break; + } + current = p.elements.get(&id).and_then(|e| e.parent); + } + for id in crate::references::element_references(e) { + if !p.contains(id) { + error( + &mut out, + e.id, + "dangling_reference", + "body", + format!("Reference {id} does not exist."), + ); + } + } + if e.kind().is_work() + && !p.children(e.id).any(|child| child.kind() == e.kind()) + && effective_performer(p, e.id).is_none() + { + warning( + &mut out, + e.id, + "unallocated_leaf", + "allocation", + format!("Allocate leaf '{}' to a performer.", e.name), + ); + } + if matches!(e.kind(), Kind::Capability | Kind::SystemCapability) + && !p.relations.values().any(|r| { + matches!( + r.kind(), + RelationKind::CapabilityInvolvement | RelationKind::ChainInvolvement + ) && r.target() == e.id + }) + { + warning( + &mut out, + e.id, + "unsupported_capability", + "involvement", + "Connect the capability to involved participants, work, or a process/chain.", + ); + } + if let Some(steps) = e.steps() { + check_ids(&mut out, e.id, "steps", steps); + for step in steps { + match p.elements.get(step) { + Some(occ) if occ.kind() == Kind::Occurrence && occ.parent == Some(e.id) => {} + _ => error( + &mut out, + e.id, + "invalid_occurrence", + "steps", + "Ordered steps must reference occurrences owned by this sequence.", + ), + } + } + if e.kind().is_sequence() && steps.is_empty() { + warning( + &mut out, + e.id, + "empty_sequence", + "steps", + "Add ordered occurrences.", + ); + } + validate_sequence(p, e, steps, &mut out); + } + if let Some(shared) = e.shared() { + match shared { + SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { literals }, + } => { + let mut seen = BTreeSet::new(); + for literal in literals { + text(&mut out, e.id, "literals", literal); + if !seen.insert(literal) { + error( + &mut out, + e.id, + "duplicate_literal", + "literals", + "Enumeration literals must be unique.", + ); + } + } + } + SharedBody::DataField { + type_ref, + minimum, + maximum, + unit, + } => { + if maximum.is_some_and(|max| max < *minimum) { + error( + &mut out, + e.id, + "invalid_multiplicity", + "maximum", + "Maximum multiplicity must be at least minimum, or unbounded.", + ); + } + text(&mut out, e.id, "unit", unit); + if let TypeRef::Named(id) = type_ref { + expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType); + } + if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id)) + && parent.kind() == Kind::DataType + && !matches!( + parent.shared(), + Some(SharedBody::DataType { + definition: DataTypeDefinition::Record + }) + ) + { + error( + &mut out, + e.id, + "field_owner", + "parent", + "Fields under a data type require a Record type.", + ); + } + } + SharedBody::Interface { items } => { + check_ids(&mut out, e.id, "items", items); + for id in items { + expect_element(p, &mut out, e.id, *id, "items", |k| { + k == Kind::ExchangeItem + }); + } + } + SharedBody::Port { interface, .. } => { + if let Some(id) = interface { + expect_element(p, &mut out, e.id, *id, "interface", |k| { + k == Kind::Interface + }); + } else { + warning( + &mut out, + e.id, + "missing_interface", + "interface", + "Choose a port interface or add an explicit provision/requirement contract.", + ); + } + } + SharedBody::Scenario { + viewpoint, + context, + precondition, + postcondition, + .. + } => { + match viewpoint { + ScenarioViewpoint::MixedLegacy => warning( + &mut out, + e.id, + "legacy_scenario_viewpoint", + "viewpoint", + "Choose a scenario viewpoint after reviewing the legacy lifelines and messages.", + ), + ScenarioViewpoint::OperationalActivity + | ScenarioViewpoint::ParticipantInteraction + if e.layer() != Layer::Oa => + { + error( + &mut out, + e.id, + "scenario_viewpoint_layer", + "viewpoint", + "Operational scenario viewpoints belong to OA.", + ) + } + ScenarioViewpoint::Functional | ScenarioViewpoint::BoundaryInterface + if e.layer() != Layer::Sa => + { + error( + &mut out, + e.id, + "scenario_viewpoint_layer", + "viewpoint", + "System scenario viewpoints belong to SA.", + ) + } + _ => {} + } + if let Some(id) = context { + expect_element(p, &mut out, e.id, *id, "context", |k| { + k.is_sequence() + || k.is_work() + || k.is_performer() + || matches!( + k, + Kind::Capability + | Kind::SystemCapability + | Kind::OperationalMission + | Kind::Mission + ) + }); + } + text(&mut out, e.id, "precondition", precondition); + text(&mut out, e.id, "postcondition", postcondition); + } + SharedBody::Lifeline { represented } => { + optional_element(p, &mut out, e.id, *represented, "represented", |k| { + k.is_performer() || k.is_work() || k == Kind::Role + }); + if let Some(element) = represented.and_then(|id| p.elements.get(&id)) + && let Some(viewpoint) = e.parent.and_then(|id| scenario_viewpoint(p, id)) + && !viewpoint_represents(viewpoint, element.kind()) + { + error( + &mut out, + e.id, + "scenario_viewpoint_lifeline", + "represented", + "The represented object must match the scenario's work or participant viewpoint.", + ); + } + } + SharedBody::Occurrence { + body, + precondition, + postcondition, + } => { + text(&mut out, e.id, "precondition", precondition); + text(&mut out, e.id, "postcondition", postcondition); + validate_occurrence(p, e, body, &mut out); + validate_occurrence_context(p, e, body, &mut out); + } + SharedBody::StateMachine => { + if e.parent.is_none() { + warning( + &mut out, + e.id, + "machine_owner", + "parent", + "Assign a state-machine owner.", + ); + } + if !p.children(e.id).any(|c| c.kind() == Kind::Region) { + warning( + &mut out, + e.id, + "empty_machine", + "body", + "Add a region; sibling regions describe concurrent state configurations.", + ); + } + } + SharedBody::State { + entry, + exit, + do_activity, + .. + } => { + text(&mut out, e.id, "entry", entry); + text(&mut out, e.id, "exit", exit); + text(&mut out, e.id, "do_activity", do_activity); + if !e.kind().is_stable_state() + && (!entry.is_empty() || !exit.is_empty() || !do_activity.is_empty()) + { + warning( + &mut out, + e.id, + "pseudostate_behavior", + "body", + "Pseudostate entry/do/exit prose is retained for review; only State/Mode vertices execute state behaviors.", + ); + } + } + SharedBody::Event { body } => match body { + EventBody::Signal { items } => { + check_ids(&mut out, e.id, "items", items); + for item in items { + expect_element(p, &mut out, e.id, *item, "items", |kind| { + kind == Kind::ExchangeItem + }); + } + } + EventBody::Time { expression, .. } | EventBody::Change { expression } => { + text(&mut out, e.id, "expression", expression); + if expression.trim().is_empty() { + warning( + &mut out, + e.id, + "missing_event_expression", + "expression", + "Describe the time instant/duration or change condition for this event.", + ); + } + } + }, + SharedBody::Constraint { + expression, + constrained, + } => { + text(&mut out, e.id, "expression", expression); + check_ids(&mut out, e.id, "constrained", constrained); + if expression.trim().is_empty() { + warning( + &mut out, + e.id, + "empty_constraint", + "expression", + "Write the constraint expression.", + ); + } + for id in constrained { + if p.layer_of(*id) != Some(e.layer()) { + error( + &mut out, + e.id, + "constraint_layer", + "constrained", + "Constraints refer to semantic records in the same layer.", + ); + } + } + } + _ => {} + } + } + } + crate::state_validation::validate_graph(p, &mut out); + for (key, r) in &p.relations { + if *key != r.id { + error( + &mut out, + r.id, + "map_identity", + "id", + "Relation ID does not match its map key.", + ); + } + text(&mut out, r.id, "name", &r.name); + text(&mut out, r.id, "description", &r.description); + validate_relation(p, r, &mut out); + validate_relation_context(p, r, &mut out); + } + for (key, d) in &p.diagrams { + if *key != d.id { + error( + &mut out, + d.id, + "map_identity", + "id", + "Diagram ID does not match its map key.", + ); + } + text(&mut out, d.id, "name", &d.name); + text(&mut out, d.id, "description", &d.description); + let mut elements = BTreeSet::new(); + for (key, n) in &d.nodes { + if *key != n.id { + error( + &mut out, + n.id, + "map_identity", + "id", + "Node ID does not match its map key.", + ); + } + if !elements.insert(n.element) { + error( + &mut out, + n.id, + "duplicate_placement", + "element", + "An element appears at most once in each diagram.", + ); + } + match p.elements.get(&n.element) { + Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => { + } + _ => error( + &mut out, + n.id, + "diagram_layer", + "element", + "Diagram nodes reference existing elements in the selected layer.", + ), + }; + for (value, field) in [ + (n.position.x, "x"), + (n.position.y, "y"), + (n.size.width, "width"), + (n.size.height, "height"), + ] { + if !value.is_finite() + || value.abs() > MAX_COORDINATE + || matches!(field, "width" | "height") && value <= 0. + { + error( + &mut out, + n.id, + "invalid_bounds", + field, + "Coordinates must be finite and bounded; width and height must be positive.", + ); + } + } + } + } + for (key, a) in &p.artifact_links { + if *key != a.id { + error( + &mut out, + a.id, + "map_identity", + "id", + "Artifact ID does not match its map key.", + ); + } + for (field, value) in [ + ("name", &a.name), + ("description", &a.description), + ("uri", &a.uri), + ] { + text(&mut out, a.id, field, value); + } + if let Some(v) = &a.revision { + text(&mut out, a.id, "revision", v); + } + if let Some(v) = &a.fragment { + text(&mut out, a.id, "fragment", v); + } + if !p.contains(a.owner) || a.owner == a.id { + error( + &mut out, + a.id, + "artifact_owner", + "owner", + "Artifact owner must be another existing model object.", + ); + } + if !valid_uri_reference(&a.uri) { + error( + &mut out, + a.id, + "invalid_uri", + "uri", + "Use a nonempty URI reference without whitespace, backslashes, or invalid percent escapes.", + ); + } + } + for (key, t) in &p.traces { + if *key != t.id { + error( + &mut out, + t.id, + "map_identity", + "id", + "Trace ID does not match its map key.", + ); + } + text(&mut out, t.id, "name", &t.name); + text(&mut out, t.id, "description", &t.description); + if p.layer_of(t.oa) != Some(Layer::Oa) || p.layer_of(t.sa) != Some(Layer::Sa) { + error( + &mut out, + t.id, + "trace_layers", + "endpoints", + "Traces have a live OA endpoint and a live SA endpoint in canonical order.", + ); + } else if !trace_permits(p, t) { + error( + &mut out, + t.id, + "trace_kinds", + "kind", + "Trace kind is incompatible with its endpoint kinds.", + ); + } + if let Some(b) = &t.baseline { + for props in [&b.oa, &b.sa] { + text(&mut out, t.id, "baseline.name", &props.name); + text(&mut out, t.id, "baseline.description", &props.description); + } + } + match crate::transitions::trace_drift(p, t) { + crate::transitions::Drift::Unchanged => {} + crate::transitions::Drift::Unreviewed => warning( + &mut out, + t.id, + "trace_unreviewed", + "baseline", + "Review the linked OA/SA name and description values before propagating updates.", + ), + drift => warning( + &mut out, + t.id, + "trace_drift", + "baseline", + format!( + "Linked descriptions changed since review ({drift:?}); preview the transition to reconcile them." + ), + ), + } + if p.traces.values().any(|other| { + other.id != t.id && other.oa == t.oa && other.sa == t.sa && other.kind == t.kind + }) { + error( + &mut out, + t.id, + "duplicate_trace", + "endpoints", + "An identical trace mapping already exists.", + ); + } + } + out +} +fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec) { + if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return; + } + let links: Vec<_> = p + .relations + .values() + .filter(|relation| { + relation.kind() == RelationKind::SequenceLink + && p.elements.get(&relation.source()).and_then(|e| e.parent) == Some(e.id) + && p.elements.get(&relation.target()).and_then(|e| e.parent) == Some(e.id) + }) + .collect(); + let explicit_topology = !links.is_empty() + || steps.iter().any(|id| { + p.elements.get(id).is_some_and(|step| { + matches!( + step.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + }) + }); + if explicit_topology { + validate_topology(p, e, steps, &links, out); + return; + } + // Version 2 exchange-only lists preserve their established linear meaning. + let mut previous: Option<(usize, &Relation)> = None; + for (index, id) in steps.iter().enumerate() { + let exchange = p + .elements + .get(id) + .and_then(|o| match o.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + }) => *exchange, + _ => None, + }) + .and_then(|r| p.relations.get(&r)); + if let Some(r) = exchange { + if let Some((previous_index, prev)) = previous + && prev.target() != r.source() + { + warning( + out, + e.id, + "sequence_gap", + "steps", + format!( + "Process '{}': disconnected steps {} and {}. '{}' ends at '{}'; '{}' starts at '{}'.", + e.name, + previous_index + 1, + index + 1, + prev.name, + p.named(prev.target()), + r.name, + p.named(r.source()) + ), + ); + } + previous = Some((index, r)); + } else { + warning( + out, + e.id, + "incomplete_step", + "steps", + format!("Select an exchange for step {}.", index + 1), + ); + } + } +} + +fn reachable(starts: &[Id], graph: &BTreeMap>) -> BTreeSet { + let mut pending = starts.to_vec(); + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if seen.insert(id) { + pending.extend(graph.get(&id).into_iter().flatten().copied()); + } + } + seen +} + +fn validate_topology( + p: &Project, + sequence: &Element, + steps: &[Id], + links: &[&Relation], + out: &mut Vec, +) { + let mut outgoing: BTreeMap> = BTreeMap::new(); + let mut incoming: BTreeMap> = BTreeMap::new(); + for link in links { + outgoing + .entry(link.source()) + .or_default() + .push(link.target()); + incoming + .entry(link.target()) + .or_default() + .push(link.source()); + } + let count = |graph: &BTreeMap>, id| graph.get(&id).map_or(0, Vec::len); + let starts: Vec<_> = steps + .iter() + .copied() + .filter(|id| count(&incoming, *id) == 0) + .collect(); + let ends: Vec<_> = steps + .iter() + .copied() + .filter(|id| count(&outgoing, *id) == 0) + .collect(); + for (code, label, nodes) in [ + ("sequence_starts", "start", &starts), + ("sequence_ends", "end", &ends), + ] { + if nodes.len() != 1 { + warning( + out, + sequence.id, + code, + "steps", + format!( + "Process/chain '{}' has {} {} contributions; connect a clear entry/exit or review the intended independent paths.", + sequence.name, + nodes.len(), + label + ), + ); + } + } + let from_start = reachable(&starts, &outgoing); + let to_end = reachable(&ends, &incoming); + for id in steps { + let Some(step) = p.elements.get(id) else { + continue; + }; + let before = count(&incoming, *id); + let after = count(&outgoing, *id); + if steps.len() > 1 && before == 0 && after == 0 { + warning( + out, + *id, + "isolated_contribution", + "parent", + "Connect this contribution to its process/chain path.", + ); + } + if !from_start.contains(id) { + warning( + out, + *id, + "unreachable_contribution", + "parent", + "This contribution cannot be reached from a process/chain entry.", + ); + } + if !to_end.contains(id) { + warning( + out, + *id, + "nonterminating_contribution", + "parent", + "No process/chain exit is reachable from this contribution; review the intended loop.", + ); + } + let control = match step.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Control { kind }, + .. + }) => Some(*kind), + _ => None, + }; + if let Some(kind) = control { + let expected = match kind { + ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, + ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, + ControlKind::Loop => before >= 1 && after >= 2, + }; + if !expected { + let rule = match kind { + ControlKind::Choice | ControlKind::Fork => { + "one incoming and at least two outgoing links" + } + ControlKind::Merge | ControlKind::Join => { + "at least two incoming and one outgoing link" + } + ControlKind::Loop => { + "at least one incoming and two outgoing links (repeat and exit)" + } + }; + warning( + out, + *id, + "control_cardinality", + "body.kind", + format!( + "{kind:?} needs {rule}; currently {before} incoming and {after} outgoing." + ), + ); + } + if matches!(kind, ControlKind::Choice | ControlKind::Loop) { + for link in links.iter().filter(|link| link.source() == *id) { + if matches!(&link.body, RelationBody::SequenceLink { guard, .. } if guard.trim().is_empty()) + { + warning( + out, + link.id, + "missing_sequence_guard", + "guard", + "Describe the choice/loop condition for this path, or use an explicit 'else' guard.", + ); + } + } + } + } else if before > 1 || after > 1 { + warning( + out, + *id, + "ambiguous_sequence_branch", + "parent", + "Use an explicit choice/merge or fork/join control to distinguish alternative from parallel paths.", + ); + } + } +} + +fn sequence_reference_path(p: &Project, from: Id, to: Id) -> bool { + let mut graph: BTreeMap> = BTreeMap::new(); + for element in p.elements.values() { + if let Some(owner) = element.parent + && let Some(SharedBody::Occurrence { + body: + OccurrenceBody::SequenceReference { + sequence: Some(target), + }, + .. + }) = element.shared() + { + graph.entry(owner).or_default().push(*target); + } + } + reachable(&[from], &graph).contains(&to) +} + +fn scenario_viewpoint(p: &Project, scenario: Id) -> Option { + match p.elements.get(&scenario)?.shared()? { + SharedBody::Scenario { viewpoint, .. } => Some(*viewpoint), + _ => None, + } +} +/// Represented element families allowed by a declared scenario viewpoint. +/// Shared by validation and graphical lifeline selection; layers are checked separately. +pub fn viewpoint_represents(viewpoint: ScenarioViewpoint, kind: Kind) -> bool { + match viewpoint { + ScenarioViewpoint::OperationalActivity => kind == Kind::Activity, + ScenarioViewpoint::Functional => kind == Kind::Function, + ScenarioViewpoint::ParticipantInteraction => { + matches!(kind, Kind::Entity | Kind::Actor | Kind::Role) + } + ScenarioViewpoint::BoundaryInterface => matches!(kind, Kind::System | Kind::SystemActor), + ScenarioViewpoint::MixedLegacy => { + kind.is_work() || kind.is_performer() || kind == Kind::Role + } + } +} +fn represented(p: &Project, line: Id) -> Option { + match p.elements.get(&line)?.shared()? { + SharedBody::Lifeline { represented } => *represented, + _ => None, + } +} +/// Whether an explicit semantic representation agrees with an endpoint. +/// None means a missing reference or an unresolved concrete performer; it is not +/// permission to infer one participant from several role realizations. +pub fn semantic_representation_compatible( + p: &Project, + representation: Id, + semantic: Id, +) -> Option { + let represented = p.elements.get(&representation)?; + let endpoint = p.elements.get(&semantic)?; + if represented.layer() != endpoint.layer() { + return Some(false); + } + if representation == semantic { + return Some(true); + } + let realizes = |participant, role| { + p.relations.values().any(|r| { + r.kind() == RelationKind::RoleRealization + && r.source() == participant + && r.target() == role + }) + }; + if endpoint.kind().is_work() { + if represented.kind() == Kind::Role { + return Some(p.relations.values().any(|r| { + r.kind() == RelationKind::RoleAssignment + && r.source() == representation + && r.target() == semantic + })); + } + if !represented.kind().is_performer() { + return Some(false); + } + return effective_performer(p, semantic).map(|performer| performer == representation); + } + if endpoint.kind() == Kind::Port { + return endpoint.parent.map(|owner| { + owner == representation + || (represented.kind() == Kind::Role && realizes(owner, representation)) + }); + } + Some( + (represented.kind() == Kind::Role && realizes(semantic, representation)) + || (endpoint.kind() == Kind::Role && realizes(representation, semantic)), + ) +} +/// Compatibility shared by scenario validation and graphical lifeline pickers. +pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option { + semantic_representation_compatible(p, represented(p, line)?, endpoint) +} +/// Effective semantic direction, including a Reply that reuses its Call exchange. +/// An incomplete reply correlation and a local timer have no inferred endpoints. +pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> { + let OccurrenceBody::Exchange { + exchange: Some(exchange), + message_kind, + reply_to, + .. + } = body + else { + return None; + }; + if matches!(message_kind, MessageKind::Timer { .. }) { + return None; + } + let relation = p.relations.get(exchange)?; + if message_kind == &MessageKind::Reply { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + exchange: Some(call_exchange), + .. + }, + .. + }) = p.elements.get(&(*reply_to)?).and_then(Element::shared) + else { + return None; + }; + if call_exchange == exchange { + return Some((relation.target(), relation.source())); + } + } + Some(relation.body.endpoints()) +} +/// Only report order guaranteed by a common sequence or an enclosing branch path. +/// A call in one alternative does not become an unconditional call after the fragment. +fn guaranteed_before(p: &Project, before: Id, after: Id) -> bool { + let mut current = after; + let mut seen = BTreeSet::new(); + while seen.insert(current) { + let Some(parent) = p + .elements + .get(¤t) + .and_then(|e| e.parent) + .and_then(|id| p.elements.get(&id)) + else { + break; + }; + if let Some(steps) = parent.steps() + && let (Some(a), Some(b)) = ( + steps.iter().position(|id| *id == before), + steps.iter().position(|id| *id == current), + ) + { + return a < b; + } + current = parent.id; + } + false +} +/// Structural co-execution/order, distinct from call dominance. A conditional +/// branch can execute before a following sibling; distinct ALT operands cannot +/// execute together. Parallel operands can execute in either order. +fn possibly_before(p: &Project, before: Id, after: Id) -> bool { + let path = |id: Id| { + let mut path = vec![]; + let mut current = Some(id); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id) { + return vec![]; + } + let Some(element) = p.elements.get(&id) else { + return vec![]; + }; + path.push(id); + if element.kind() == Kind::Scenario { + path.reverse(); + return path; + } + current = element.parent; + } + vec![] + }; + let a = path(before); + let b = path(after); + let common = a.iter().zip(&b).take_while(|(a, b)| a == b).count(); + if common == 0 || common == a.len() || common == b.len() { + return false; + } + let parent = &p.elements[&a[common - 1]]; + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { operator, branches }, + .. + }) = parent.shared() + { + return match operator { + FragmentOperator::Alternative | FragmentOperator::Optional => false, + FragmentOperator::Parallel => true, + FragmentOperator::Loop => { + matches!((branches.iter().position(|id|*id==a[common]),branches.iter().position(|id|*id==b[common])),(Some(a),Some(b))if a BTreeSet { + let mut loops = BTreeSet::new(); + let mut current = p.elements.get(&occurrence).and_then(|e| e.parent); + let mut seen = BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id) { + break; + } + let Some(element) = p.elements.get(&id) else { + break; + }; + if matches!( + element.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: FragmentOperator::Loop, + .. + }, + .. + }) + ) { + loops.insert(id); + } + if element.kind() == Kind::Scenario { + break; + } + current = element.parent; + } + loops +} +/// Fields carried by an exchange, including fields of nested named records. +pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet { + let mut pending = relation.body.items().to_vec(); + let mut seen = BTreeSet::new(); + let mut fields = BTreeSet::new(); + while let Some(owner) = pending.pop() { + if !seen.insert(owner) { + continue; + } + for field in p.children(owner) { + if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() { + fields.insert(field.id); + if let TypeRef::Named(id) = type_ref { + pending.push(*id); + } + } + } + } + fields +} +fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool { + match body { + OccurrenceBody::Exchange { + sender, receiver, .. + } => *sender == Some(line) || *receiver == Some(line), + OccurrenceBody::Action { lifeline, .. } | OccurrenceBody::State { lifeline, .. } => { + *lifeline == Some(line) + } + OccurrenceBody::ScenarioUse { bindings, .. } => bindings.iter().any(|b| b.local == line), + _ => false, + } +} +fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut Vec) { + let OccurrenceBody::Exchange { + message_kind, + exchange, + sender, + receiver, + reply_to, + values, + } = body + else { + return; + }; + let scenario = ancestor_of_kind(p, e.id, Kind::Scenario); + if scenario.is_none() && (message_kind != &MessageKind::Asynchronous || reply_to.is_some()) { + error( + out, + e.id, + "message_context", + "message_kind", + "Call/reply, lifecycle and timer messages belong to a scenario, not a process/chain exchange contribution.", + ); + } + if let Some(viewpoint) = scenario.and_then(|id| scenario_viewpoint(p, id)) + && let Some(relation) = exchange.and_then(|id| p.relations.get(&id)) + { + let permitted = match viewpoint { + ScenarioViewpoint::OperationalActivity => relation.kind() == RelationKind::Interaction, + ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange, + ScenarioViewpoint::ParticipantInteraction => matches!( + relation.kind(), + RelationKind::Interaction | RelationKind::BoundaryExchange + ), + ScenarioViewpoint::BoundaryInterface => matches!( + relation.kind(), + RelationKind::FunctionalExchange | RelationKind::BoundaryExchange + ), + ScenarioViewpoint::MixedLegacy => true, + }; + if !permitted { + error( + out, + e.id, + "scenario_viewpoint_exchange", + "exchange", + "Choose an exchange compatible with this scenario viewpoint.", + ); + } + } + if let MessageKind::Timer { expression } = message_kind { + text(out, e.id, "message_kind.expression", expression); + if expression.trim().is_empty() { + warning( + out, + e.id, + "missing_timer_expression", + "message_kind.expression", + "Describe the timer duration or deadline expression.", + ); + } + if exchange.is_some() + || !values.is_empty() + || receiver.is_some_and(|line| Some(line) != *sender) + { + error( + out, + e.id, + "timer_contract", + "body", + "A timer is local to its sender lifeline, has no global exchange or carried message values, and its receiver is absent or the same lifeline.", + ); + } + } + if message_kind == &MessageKind::Reply { + if let Some(call) = reply_to { + match p.elements.get(call).and_then(Element::shared) { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + exchange: call_exchange, + sender: call_sender, + receiver: call_receiver, + .. + }, + .. + }) if scenario.is_some() + && ancestor_of_kind(p, *call, Kind::Scenario) == scenario => + { + if !guaranteed_before(p, *call, e.id) { + error( + out, + e.id, + "reply_order", + "reply_to", + "The call must precede its reply on the same guaranteed branch path.", + ); + } + let call_loops = enclosing_loops(p, *call); + for repeated in enclosing_loops(p, e.id).difference(&call_loops) { + warning( + out, + e.id, + "reply_repetition", + "reply_to", + format!( + "Loop '{}' can repeat this reply without repeating its correlated call. Move the call into the same repeating scope or review an explicit at-most-once constraint.", + p.named(*repeated) + ), + ); + } + if sender.zip(*call_receiver).is_some_and(|(a, b)| a != b) + || receiver.zip(*call_sender).is_some_and(|(a, b)| a != b) + { + error( + out, + e.id, + "reply_lifelines", + "reply_to", + "Reply lifelines must reverse the correlated call's sender and receiver.", + ); + } + if let (Some(response), Some(request)) = ( + exchange.and_then(|id| p.relations.get(&id)), + call_exchange.and_then(|id| p.relations.get(&id)), + ) && response.id != request.id + && response.body.endpoints() != (request.target(), request.source()) + { + error( + out, + e.id, + "reply_exchange", + "exchange", + "A reply reuses the call exchange in reverse, or references an explicit reverse-direction exchange.", + ); + } + if p.elements.values().any(|other| other.id != e.id && possibly_before(p,other.id,e.id) + && matches!(other.shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {message_kind:MessageKind::Reply,reply_to:Some(previous),..},..}) if previous == call)) { + error(out,e.id,"duplicate_reply","reply_to","Another reply to this call can execute earlier on the same path, including a conditional or parallel branch."); + } + } + _ => error( + out, + e.id, + "reply_call", + "reply_to", + "A reply references a synchronous call in the same scenario.", + ), + } + } else { + warning( + out, + e.id, + "missing_reply_call", + "reply_to", + "Choose the synchronous call answered by this reply.", + ); + } + } else if reply_to.is_some() { + error( + out, + e.id, + "unexpected_reply_reference", + "reply_to", + "Only a Reply message correlates to a call.", + ); + } + if matches!(message_kind, MessageKind::Create | MessageKind::Delete) + && let Some(line) = receiver + { + for other in p.elements.values().filter(|other| { + other.id != e.id && ancestor_of_kind(p, other.id, Kind::Scenario) == scenario + }) { + if let Some(SharedBody::Occurrence { body, .. }) = other.shared() + && occurrence_uses_lifeline(body, *line) + && (if message_kind == &MessageKind::Create { + possibly_before(p, other.id, e.id) + } else { + possibly_before(p, e.id, other.id) + }) + { + error( + out, + e.id, + "lifeline_lifetime", + "receiver", + if message_kind == &MessageKind::Create { + "The created lifeline can already be used earlier on this path, including a conditional or parallel branch." + } else { + "The deleted lifeline can be used later on this path, including a conditional or parallel branch." + }, + ); + } + } + if !enclosing_loops(p, e.id).is_empty() { + let complement = if message_kind == &MessageKind::Create { + MessageKind::Delete + } else { + MessageKind::Create + }; + let paired=p.elements.values().any(|other| other.id!=e.id && other.parent==e.parent + && matches!(other.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{message_kind,receiver:Some(target),..},..})if message_kind==&complement && target==line) + && (if message_kind==&MessageKind::Create {guaranteed_before(p,e.id,other.id)} else {guaranteed_before(p,other.id,e.id)})); + if !paired { + warning( + out, + e.id, + "lifeline_repetition", + "receiver", + if message_kind == &MessageKind::Create { + "This loop can recreate an existing lifeline. Pair creation with a later deletion in the same operand, or review the lifetime across iterations." + } else { + "This loop can use or delete an already destroyed lifeline on its next iteration. Pair deletion with an earlier creation in the same operand, or review the lifetime across iterations." + }, + ); + } + } + } + let allowed = exchange + .and_then(|id| p.relations.get(&id)) + .map(|r| carried_fields(p, r)); + let mut fields = BTreeSet::new(); + for value in values { + expect_element(p, out, e.id, value.field, "values.field", |kind| { + kind == Kind::DataField + }); + text(out, e.id, "values.expression", &value.expression); + if !fields.insert(value.field) { + error( + out, + e.id, + "duplicate_message_value", + "values", + "Each carried field has at most one contextual value annotation in a message.", + ); + } + if allowed + .as_ref() + .is_some_and(|allowed| !allowed.contains(&value.field)) + { + error( + out, + e.id, + "message_value_field", + "values.field", + "Value annotations must reference fields carried by this message's exchange, including nested record fields.", + ); + } + if value.expression.trim().is_empty() { + warning( + out, + e.id, + "missing_message_value", + "values.expression", + "Enter the contextual value expression for this field.", + ); + } + } +} +fn validate_scenario_use( + p: &Project, + e: &Element, + target: Option, + bindings: &[LifelineBinding], + out: &mut Vec, +) { + let local = ancestor_of_kind(p, e.id, Kind::Scenario); + if local.is_none() { + error( + out, + e.id, + "scenario_use_owner", + "parent", + "A scenario use belongs to a scenario or one of its branches.", + ); + } + optional_element(p, out, e.id, target, "scenario", |kind| { + kind == Kind::Scenario + }); + if let (Some(local), Some(target)) = (local, target) { + let mut graph: BTreeMap> = BTreeMap::new(); + for use_element in p.elements.values() { + if let Some(owner) = ancestor_of_kind(p, use_element.id, Kind::Scenario) + && let Some(SharedBody::Occurrence { + body: + OccurrenceBody::ScenarioUse { + scenario: Some(referenced), + .. + }, + .. + }) = use_element.shared() + { + graph.entry(owner).or_default().push(*referenced); + } + } + if reachable(&[target], &graph).contains(&local) { + error( + out, + e.id, + "recursive_scenario_use", + "scenario", + "A scenario cannot directly or transitively reuse itself; use an explicit loop fragment for repetition.", + ); + } + if let (Some(a), Some(b)) = (scenario_viewpoint(p, local), scenario_viewpoint(p, target)) + && a != b + && a != ScenarioViewpoint::MixedLegacy + && b != ScenarioViewpoint::MixedLegacy + { + error( + out, + e.id, + "scenario_use_viewpoint", + "scenario", + "A reused scenario must have the same viewpoint as its caller.", + ); + } + } + let mut referenced_lines = BTreeSet::new(); + let mut local_lines = BTreeSet::new(); + for binding in bindings { + for (line, owner, field) in [ + (binding.referenced, target, "bindings.referenced"), + (binding.local, local, "bindings.local"), + ] { + expect_element(p, out, e.id, line, field, |kind| kind == Kind::Lifeline); + if owner.is_none() || p.elements.get(&line).and_then(|e| e.parent) != owner { + error( + out, + e.id, + "scenario_binding_context", + field, + "Bind a lifeline from the referenced scenario to one in the local scenario.", + ); + } + } + if !referenced_lines.insert(binding.referenced) || !local_lines.insert(binding.local) { + error( + out, + e.id, + "duplicate_lifeline_binding", + "bindings", + "Scenario-use lifeline bindings are one-to-one.", + ); + } + if let (Some(a), Some(b)) = ( + represented(p, binding.referenced), + represented(p, binding.local), + ) && semantic_representation_compatible(p, b, a) == Some(false) + { + error( + out, + e.id, + "scenario_binding_identity", + "bindings", + "Bound lifelines represent the same semantic object, or an explicitly realized operational role.", + ); + } + } + if let Some(target) = target { + for line in p.children(target).filter(|e| e.kind() == Kind::Lifeline) { + if !referenced_lines.contains(&line.id) { + warning( + out, + e.id, + "missing_lifeline_binding", + "bindings", + format!( + "Bind referenced lifeline '{}' to its local role.", + line.name + ), + ); + } + } + } +} + +fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut Vec) { + let scenario = ancestor_of_kind(p, e.id, Kind::Scenario); + let sequence = e + .parent + .and_then(|id| p.elements.get(&id)) + .filter(|owner| matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)); + let lifeline = |out: &mut Vec, id: Option, field: &str| { + if let Some(id) = id { + expect_element(p, out, e.id, id, field, |k| k == Kind::Lifeline); + if p.elements.get(&id).and_then(|l| l.parent) != scenario { + error( + out, + e.id, + "lifeline_context", + field, + "Lifeline must belong to the occurrence's scenario.", + ); + } + } else if scenario.is_some() { + warning( + out, + e.id, + "missing_lifeline", + field, + format!("Choose {field}."), + ); + } + }; + match body { + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + message_kind, + .. + } => { + if let Some(id) = exchange { + match p.relations.get(id) { + Some(r) + if p.layer_of(*id) == Some(e.layer()) + && matches!( + r.kind(), + RelationKind::Interaction + | RelationKind::FunctionalExchange + | RelationKind::BoundaryExchange + ) => {} + _ => error( + out, + e.id, + "occurrence_exchange", + "exchange", + "Occurrence must reference an exchange in the same layer.", + ), + } + } else if !matches!(message_kind, MessageKind::Timer { .. }) { + warning( + out, + e.id, + "missing_exchange", + "exchange", + "Choose an exchange.", + ); + } + lifeline(out, *sender, "sender"); + if !matches!(message_kind, MessageKind::Timer { .. }) || receiver.is_some() { + lifeline(out, *receiver, "receiver"); + } + validate_message(p, e, body, out); + if sequence.is_some() { + if sender.is_some() || receiver.is_some() { + error( + out, + e.id, + "process_lifeline", + "body", + "Process/chain exchange contributions do not use scenario lifelines.", + ); + } + if let Some(relation) = exchange.and_then(|id| p.relations.get(&id)) + && relation.kind() + != if e.layer() == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + } + { + error( + out, + e.id, + "process_exchange", + "exchange", + "Process/chain contributions reference a work exchange, not a boundary exchange.", + ); + } + } + } + OccurrenceBody::Action { + action, + lifeline: line, + } => { + optional_element(p, out, e.id, *action, "action", Kind::is_work); + lifeline(out, *line, "lifeline"); + if sequence.is_some() && line.is_some() { + error( + out, + e.id, + "process_lifeline", + "lifeline", + "A process/chain work involvement does not use a scenario lifeline.", + ); + } + } + OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. } => { + if let Some(owner) = sequence { + if let OccurrenceBody::SequenceReference { sequence } = body { + optional_element(p, out, e.id, *sequence, "sequence", |kind| { + kind == owner.kind() + }); + if let Some(target) = sequence + && sequence_reference_path(p, *target, owner.id) + { + error( + out, + e.id, + "recursive_sequence_reference", + "sequence", + "A process/chain cannot directly or transitively reuse itself.", + ); + } + } + } else { + error( + out, + e.id, + "sequence_contribution_owner", + "parent", + "Controls and reusable sequence contributions belong directly to a process or functional chain.", + ); + } + } + OccurrenceBody::State { + state, + lifeline: line, + } => { + optional_element(p, out, e.id, *state, "state", |k| { + matches!(k, Kind::State | Kind::Mode) + }); + lifeline(out, *line, "lifeline"); + } + OccurrenceBody::ScenarioUse { scenario, bindings } => { + validate_scenario_use(p, e, *scenario, bindings, out) + } + OccurrenceBody::Fragment { branches, .. } => { + check_ids(out, e.id, "branches", branches); + for id in branches { + match p.elements.get(id) { + Some(b) + if b.parent == Some(e.id) + && matches!( + b.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) + ) => {} + _ => error( + out, + e.id, + "fragment_branch", + "branches", + "Fragments contain branch occurrences they own.", + ), + } + } + } + OccurrenceBody::Branch { guard, .. } => { + text(out, e.id, "guard", guard); + if !e + .parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|p| { + matches!( + p.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { .. }, + .. + }) + ) + }) + { + error( + out, + e.id, + "branch_owner", + "parent", + "A branch belongs to a fragment occurrence.", + ); + } + } + } + if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id)) + && matches!(parent.kind(), Kind::Process | Kind::FunctionalChain) + && !matches!( + body, + OccurrenceBody::Exchange { .. } + | OccurrenceBody::Action { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. } + ) + { + error( + out, + e.id, + "process_occurrence", + "body", + "Processes and functional chains contain work, exchange, control, or reusable sequence contributions.", + ); + } +} +/// Iterative reachability keeps cyclic or deeply nested capability drafts safe. +/// Inclusion and specialization are separate graphs, not containment edges. +fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bool { + let mut pending = vec![from]; + let mut seen = BTreeSet::new(); + while let Some(current) = pending.pop() { + if current == to { + return true; + } + if seen.insert(current) { + pending.extend( + p.relations + .values() + .filter(|r| r.kind() == kind && r.source() == current) + .map(Relation::target), + ); + } + } + false +} + +fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { + let (source, target) = r.body.endpoints(); + for id in crate::references::relation_references(r) { + if !p.contains(id) { + error( + out, + r.id, + "dangling_reference", + "body", + format!("Reference {id} does not exist."), + ); + } + } + if let RelationBody::ExchangeAllocation { .. } = r.body { + match (p.relations.get(&source), p.relations.get(&target)) { + (Some(a), Some(b)) + if a.kind() == RelationKind::BoundaryExchange + && matches!( + b.kind(), + RelationKind::FunctionalExchange | RelationKind::Interaction + ) + && p.layer_of(source) == p.layer_of(target) => {} + _ => error( + out, + r.id, + "exchange_allocation", + "endpoints", + "Exchange allocation relates a boundary exchange to a work exchange in the same layer.", + ), + }; + validate_exchange_allocation(p, r, out); + return; + } + let (Some(a), Some(b)) = (p.elements.get(&source), p.elements.get(&target)) else { + error( + out, + r.id, + "invalid_endpoints", + "endpoints", + "Relationship endpoints must be existing elements.", + ); + return; + }; + if a.layer() != b.layer() + || !r.kind().permits(a.kind(), b.kind()) + || (a.id == b.id && r.kind() != RelationKind::StateTransition) + { + error( + out, + r.id, + "invalid_endpoints", + "endpoints", + format!("Invalid {}: {} in one layer.", r.kind(), r.kind().hint()), + ); + } + if let RelationBody::SequenceLink { guard, .. } = &r.body { + text(out, r.id, "guard", guard); + let owner = a.parent.and_then(|id| p.elements.get(&id)); + if a.parent != b.parent + || !owner + .is_some_and(|owner| matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)) + { + error( + out, + r.id, + "sequence_link_context", + "endpoints", + "Sequence links connect contributions directly owned by the same process or functional chain.", + ); + } + let work = |element: &Element| match element.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { action, .. }, + .. + }) => *action, + _ => None, + }; + let exchange = |element: &Element| match element.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + }) => exchange.and_then(|id| p.relations.get(&id)), + _ => None, + }; + let mismatch = match (work(a), exchange(a), work(b), exchange(b)) { + (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 { + error( + out, + r.id, + "sequence_exchange_endpoint", + "endpoints", + "Adjacent work/exchange contributions must agree with the referenced exchange's source and target work.", + ); + } + } + if let RelationBody::CapabilityExtend { condition, .. } = &r.body { + text(out, r.id, "condition", condition); + if condition.trim().is_empty() { + warning( + out, + r.id, + "missing_extension_condition", + "condition", + "Describe when the extending capability adds behavior to the extended capability.", + ); + } + } + if matches!( + r.kind(), + RelationKind::CapabilityInclude | RelationKind::CapabilityGeneralization + ) && relation_path_exists(p, r.kind(), target, source) + { + let (code, message) = if r.kind() == RelationKind::CapabilityInclude { + ( + "capability_include_cycle", + "Capability inclusion must be acyclic; a capability cannot transitively include itself.", + ) + } else { + ( + "capability_generalization_cycle", + "Capability specialization must be acyclic; a capability cannot be its own ancestor.", + ) + }; + error(out, r.id, code, "target", message); + } + if r.kind() == RelationKind::Allocation { + if p.children(b.id).any(|child| child.kind() == b.kind()) { + error( + out, + r.id, + "allocation_leaf", + "target", + "Allocate leaf activities/functions; decomposition is independent of performer allocation.", + ); + } + if p.relations + .values() + .filter(|other| other.kind() == RelationKind::Allocation && other.target() == target) + .count() + > 1 + { + error( + out, + r.id, + "multiple_performers", + "target", + "A leaf activity/function has at most one direct performer.", + ); + } + } + if r.kind() == RelationKind::RoleAssignment { + if p.children(b.id).any(|child| child.kind() == b.kind()) { + error( + out, + r.id, + "role_leaf", + "target", + "Assign roles to leaf activities.", + ); + } + let performers: BTreeSet<_> = p + .relations + .values() + .filter(|rel| rel.kind() == RelationKind::RoleRealization && rel.target() == source) + .map(Relation::source) + .collect(); + if let Some(allocated) = p + .relations + .values() + .find(|rel| rel.kind() == RelationKind::Allocation && rel.target() == target) + && !performers.is_empty() + && !performers.contains(&allocated.source()) + { + warning( + out, + r.id, + "role_allocation_conflict", + "target", + "Direct performer does not realize the assigned role.", + ); + } + } + check_ids(out, r.id, "items", r.body.items()); + for item in r.body.items() { + expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem); + } + if let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = &r.body + { + for (port, owner, outgoing, field) in [ + (*source_port, source, true, "source_port"), + (*target_port, target, false, "target_port"), + ] { + if let Some(port) = port { + match p.elements.get(&port) { + Some(e) + if e.parent == Some(owner) + && matches!(e.shared(),Some(SharedBody::Port{direction,..})if if outgoing{*direction!=PortDirection::In}else{*direction!=PortDirection::Out}) => + {} + _ => error( + out, + r.id, + "functional_port", + field, + "Functional port must belong to the endpoint function with compatible direction.", + ), + } + } + } + } + if r.kind() == RelationKind::BoundaryExchange { + for (e, outgoing, field) in [(a, true, "source"), (b, false, "target")] { + match e.shared() { + Some(SharedBody::Port { direction, .. }) + if e.parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|e| e.kind().is_performer()) + && (if outgoing { + *direction != PortDirection::In + } else { + *direction != PortDirection::Out + }) => {} + _ => error( + out, + r.id, + "boundary_port", + field, + "Boundary exchange uses participant ports with output/input compatible direction.", + ), + } + } + let interface_items = |port: &Element| { + if let Some(SharedBody::Port { + interface: Some(id), + .. + }) = port.shared() + && let Some(SharedBody::Interface { items }) = + p.elements.get(id).and_then(Element::shared) + { + return Some(items); + } + None + }; + for port in [a, b] { + if let Some(items) = interface_items(port) { + for item in r.body.items() { + if !items.contains(item) { + error( + out, + r.id, + "interface_contract", + "items", + "Exchange item is not included in a referenced port interface.", + ); + } + } + } + } + } + if let RelationBody::StateTransition { + trigger, + guard, + effect, + triggers, + effects, + .. + } = &r.body + { + text(out, r.id, "trigger", trigger); + text(out, r.id, "guard", guard); + text(out, r.id, "effect", effect); + check_ids(out, r.id, "triggers", triggers); + check_ids(out, r.id, "effects", effects); + for id in triggers { + expect_element(p, out, r.id, *id, "triggers", |kind| { + kind == Kind::Event || kind.is_work() + }); + } + for id in effects { + expect_element(p, out, r.id, *id, "effects", Kind::is_work); + } + if triggers.is_empty() && !trigger.trim().is_empty() { + warning( + out, + r.id, + "unresolved_trigger", + "trigger", + "This transition retains descriptive triggering, not completion. Choose a typed Event or work trigger to resolve it.", + ); + } + crate::state_validation::validate_transition(p, r, a, b, out); + } +} +pub fn trace_permits(p: &Project, t: &Trace) -> bool { + if t.kind == TraceKind::Manual { + return p.properties(t.oa).is_some() && p.properties(t.sa).is_some(); + } + match (p.elements.get(&t.oa), p.elements.get(&t.sa)) { + (Some(a), Some(b)) => match t.kind { + TraceKind::Refinement => a.kind() == Kind::Activity && b.kind() == Kind::Function, + TraceKind::Participant => { + matches!(a.kind(), Kind::Entity | Kind::Actor) + && matches!(b.kind(), Kind::System | Kind::SystemActor) + } + TraceKind::Capability => { + a.kind() == Kind::Capability && b.kind() == Kind::SystemCapability + } + TraceKind::Mission => a.kind() == Kind::OperationalMission && b.kind() == Kind::Mission, + TraceKind::Sequence => { + (a.kind() == Kind::Process && b.kind() == Kind::FunctionalChain) + || (a.kind() == Kind::Occurrence + && b.kind() == Kind::Occurrence + && a.parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|owner| owner.kind() == Kind::Process) + && b.parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|owner| owner.kind() == Kind::FunctionalChain)) + } + TraceKind::Item => { + matches!( + a.kind(), + Kind::ExchangeItem + | Kind::DataType + | Kind::DataField + | Kind::Interface + | Kind::Port + ) && a.kind() == b.kind() + } + TraceKind::Scenario => { + matches!(a.kind(), Kind::Scenario | Kind::Lifeline | Kind::Occurrence) + && a.kind() == b.kind() + } + TraceKind::State => { + (a.kind().is_state() + || matches!(a.kind(), Kind::StateMachine | Kind::Region | Kind::Event)) + && a.kind() == b.kind() + } + _ => false, + }, + _ => { + matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange) + } + } +} + +/// Parent choices for a newly created object; editing also validates cycles. +pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option) -> bool { + if !child_kind.supports(layer) { + return false; + } + match parent { + None => { + !child_kind.is_state() + && !matches!( + child_kind, + Kind::DataField | Kind::Port | Kind::Lifeline | Kind::Occurrence | Kind::Region + ) + } + Some(id) => p.elements.get(&id).is_some_and(|owner| { + owner.layer() == layer + && parent_permits(owner.kind(), child_kind) + && match owner.shared() { + Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => { + *definition == DataTypeDefinition::Record + } + Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => { + matches!( + body, + OccurrenceBody::Fragment { .. } | OccurrenceBody::Branch { .. } + ) + } + _ => true, + } + }), + } +} +/// Uses the same relation rules as committed edits, without cloning a project. +pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> bool { + if kind == RelationKind::Allocation + && p.relations + .values() + .any(|r| r.kind() == kind && r.target() == target) + { + return false; + } + if kind == RelationKind::StateTransition + && p.elements + .get(&source) + .is_some_and(|e| e.kind() == Kind::Initial) + && p.relations + .values() + .any(|r| r.kind() == kind && r.source() == source) + { + return false; + } + let mut candidate = Relation { + id: Id::nil(), + alias: "candidate".into(), + name: String::new(), + description: String::new(), + body: RelationBody::new(kind, source, target), + }; + if p.elements + .get(&source) + .is_some_and(|e| e.kind() == Kind::EntryPoint) + && let RelationBody::StateTransition { + transition_kind, .. + } = &mut candidate.body + { + *transition_kind = TransitionKind::Local; + } + let mut issues = vec![]; + validate_relation(p, &candidate, &mut issues); + validate_relation_context(p, &candidate, &mut issues); + if kind == RelationKind::StateTransition { + for endpoint in [source, target] { + if let Some(e) = p.elements.get(&endpoint) { + crate::state_validation::validate_vertex(p, e, Some(&candidate), &mut issues); + } + } + } + !issues.iter().any(|i| i.severity == Severity::Error) +} +/// Aggregate display information only; this never creates inherited allocations. +pub fn allocation_summary(p: &Project, work: Id) -> Option { + let root = p.elements.get(&work)?; + if !root.kind().is_work() { + return None; + } + let mut leaves = Vec::new(); + let mut pending = vec![work]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + return None; + } + let children: Vec<_> = p + .children(id) + .filter(|e| e.kind() == root.kind()) + .map(|e| e.id) + .collect(); + if children.is_empty() { + leaves.push(id); + } else { + pending.extend(children); + } + } + let mut performer = None; + for leaf in leaves { + let current = effective_performer(p, leaf)?; + if performer.is_some_and(|p| p != current) { + return None; + } + performer = Some(current); + } + performer +} + +fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec) { + let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target())) + else { + return; + }; + for (function, port, field) in [ + (work.source(), boundary.source(), "source"), + (work.target(), boundary.target(), "target"), + ] { + let performer = p + .relations + .values() + .find(|a| a.kind() == RelationKind::Allocation && a.target() == function) + .map(Relation::source); + let owner = p.elements.get(&port).and_then(|e| e.parent); + if let (Some(performer), Some(owner)) = (performer, owner) { + if performer != owner { + error( + out, + r.id, + "exchange_allocation_performer", + field, + "Boundary port owner must perform the corresponding work-exchange endpoint.", + ); + } + } else { + warning( + out, + r.id, + "incomplete_exchange_allocation", + field, + "Allocate work endpoints to the boundary-port participants.", + ); + } + } + for item in work.body.items() { + if !boundary.body.items().contains(item) { + error( + out, + r.id, + "exchange_allocation_items", + "items", + "The boundary exchange must carry every item of the allocated work exchange.", + ); + } + } +} + +pub fn effective_performer(p: &Project, work: Id) -> Option { + let direct: Vec<_> = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::Allocation && r.target() == work) + .map(Relation::source) + .collect(); + if direct.len() == 1 { + return Some(direct[0]); + } + if !direct.is_empty() { + return None; + } + let roles: BTreeSet<_> = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == work) + .map(Relation::source) + .collect(); + let performers: BTreeSet<_> = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target())) + .map(Relation::source) + .collect(); + (performers.len() == 1).then(|| *performers.first().unwrap()) +} + +fn validate_relation_context(p: &Project, r: &Relation, out: &mut Vec) { + if r.kind() == RelationKind::RoleAssignment + && !p + .relations + .values() + .any(|a| a.kind() == RelationKind::Allocation && a.target() == r.target()) + { + let roles: BTreeSet<_> = p + .relations + .values() + .filter(|a| a.kind() == RelationKind::RoleAssignment && a.target() == r.target()) + .map(Relation::source) + .collect(); + let performers: BTreeSet<_> = p + .relations + .values() + .filter(|a| a.kind() == RelationKind::RoleRealization && roles.contains(&a.target())) + .map(Relation::source) + .collect(); + if performers.len() > 1 { + warning( + out, + r.id, + "ambiguous_role_performer", + "target", + "Assigned roles are shared by several performers; no unique effective performer is inferred.", + ); + } + } + if r.kind() == RelationKind::BoundaryExchange + && let (Some(a), Some(b)) = (p.elements.get(&r.source()), p.elements.get(&r.target())) + && a.parent.is_some() + && a.parent == b.parent + { + error( + out, + r.id, + "boundary_owner", + "endpoints", + "A boundary exchange connects different participants.", + ); + } + let ports: Vec<_> = match &r.body { + RelationBody::BoundaryExchange { source, target, .. } => vec![*source, *target], + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } => [*source_port, *target_port].into_iter().flatten().collect(), + _ => vec![], + }; + for port in ports { + let mut interfaces = BTreeSet::new(); + if let Some(SharedBody::Port { + interface: Some(id), + .. + }) = p.elements.get(&port).and_then(Element::shared) + { + interfaces.insert(*id); + } + interfaces.extend( + p.relations + .values() + .filter(|rel| { + matches!( + rel.kind(), + RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement + ) && rel.source() == port + }) + .map(Relation::target), + ); + if !interfaces.is_empty() { + let allowed: BTreeSet<_> = interfaces + .iter() + .filter_map(|id| p.elements.get(id)) + .filter_map(|e| match e.shared() { + Some(SharedBody::Interface { items }) => Some(items), + _ => None, + }) + .flatten() + .copied() + .collect(); + for item in r.body.items() { + if !allowed.contains(item) { + error( + out, + r.id, + "port_interface_items", + "items", + "The exchange carries an item outside a port's declared interface contract.", + ); + } + } + } + } + if matches!( + r.kind(), + RelationKind::Availability | RelationKind::StateAction + ) { + let (state, behavior) = if r.kind() == RelationKind::Availability { + (r.target(), r.source()) + } else { + (r.source(), r.target()) + }; + let owner = ancestor_of_kind(p, state, Kind::StateMachine) + .and_then(|id| p.elements.get(&id)) + .and_then(|e| e.parent); + let performer = p.elements.get(&behavior).and_then(|e| { + if e.kind().is_performer() { + Some(e.id) + } else { + effective_performer(p, e.id) + } + }); + if let (Some(owner), Some(performer)) = (owner, performer) + && owner != performer + { + error( + out, + r.id, + "state_owner_context", + "endpoints", + "State availability/action must agree with the behavior's allocated participant.", + ); + } + } +} +fn validate_occurrence_context( + p: &Project, + e: &Element, + body: &OccurrenceBody, + out: &mut Vec, +) { + if let Some(owner) = e.parent.and_then(|id| p.elements.get(&id)) { + if owner.kind() == Kind::Occurrence { + match owner.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { .. }, + .. + }) if matches!(body, OccurrenceBody::Branch { .. }) => {} + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) if !matches!(body, OccurrenceBody::Branch { .. }) => {} + _ => error( + out, + e.id, + "occurrence_owner", + "parent", + "Nested occurrences belong to branches; branches belong to fragments.", + ), + } + } + if let Some(steps) = owner.steps() + && !steps.contains(&e.id) + { + warning( + out, + e.id, + "unordered_occurrence", + "parent", + "Add this occurrence to its owner's ordered steps.", + ); + } + if let OccurrenceBody::Exchange { + exchange: Some(id), .. + } = body + && let Some(r) = p.relations.get(id) + && (owner.kind() == Kind::Process && r.kind() != RelationKind::Interaction + || owner.kind() == Kind::FunctionalChain + && r.kind() != RelationKind::FunctionalExchange) + { + error( + out, + e.id, + "sequence_exchange_kind", + "exchange", + "OA processes use interactions; SA functional chains use functional exchanges.", + ); + } + } + let check = |out: &mut Vec, line: Option, endpoint: Option, field: &str| { + if let (Some(line), Some(endpoint)) = (line, endpoint) + && lifeline_matches_endpoint(p, line, endpoint) == Some(false) + { + error( + out, + e.id, + "scenario_endpoint", + field, + "The lifeline must represent the endpoint work, its explicitly assigned role, or its compatible participant.", + ); + } + }; + match body { + OccurrenceBody::Exchange { + sender, receiver, .. + } => { + if let Some((source, target)) = message_endpoints(p, body) { + check(out, *sender, Some(source), "sender"); + check(out, *receiver, Some(target), "receiver"); + } + } + OccurrenceBody::Action { action, lifeline } => check(out, *lifeline, *action, "lifeline"), + OccurrenceBody::State { + state: Some(state), + lifeline: Some(line), + } => { + let owner = ancestor_of_kind(p, *state, Kind::StateMachine) + .and_then(|id| p.elements.get(&id)) + .and_then(|e| e.parent); + let represented = match p.elements.get(line).and_then(Element::shared) { + Some(SharedBody::Lifeline { represented }) => *represented, + _ => None, + }; + if let (Some(owner), Some(represented)) = (owner, represented) + && owner != represented + && effective_performer(p, represented) != Some(owner) + { + error( + out, + e.id, + "scenario_state_owner", + "lifeline", + "State observation must agree with the state-machine owner.", + ); + } + } + _ => {} + } +} diff --git a/crates/oa-core/tests/fixtures/v2-coastal-rescue.arcadia b/crates/oa-core/tests/fixtures/v2-coastal-rescue.arcadia new file mode 100644 index 00000000..13395396 --- /dev/null +++ b/crates/oa-core/tests/fixtures/v2-coastal-rescue.arcadia @@ -0,0 +1,900 @@ +arcadia 2; +project mission "Coastal rescue — operational need to system behavior" id "eceea6cd-108b-45a9-9505-8b7fa462af96" { + description "A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts."; + + sa_lifeline vessel_service_lifeline "Vessel service lifeline" id "03b71dc5-435f-4c7f-923a-cb6e672212ba" { + description ""; + parent @repeated_distress_reports; + represented @vessel_communication_service; + } + + sa_initial start_reporting "Start reporting" id "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978" { + description ""; + do_activity ""; + entry ""; + exit ""; + kind Initial; + parent @reporting_quality_region; + } + + sa_state_machine reporting_operation_modes "Reporting operation modes" id "1a1b215a-c102-4948-9c88-dabfadc8e08e" { + description ""; + parent @rescue_coordination_system; + } + + sa_exchange_item backup_electrical_energy "Backup electrical energy" id "1b81d963-06e7-42d3-af31-bf5236829477" { + description ""; + kind Energy; + parent none; + } + + sa_lifeline coordination_system_lifeline "Coordination system lifeline" id "1f7da722-c91e-4d0e-a344-c1a2511dd96b" { + description ""; + parent @repeated_distress_reports; + represented @rescue_coordination_system; + } + + sa_mission support_timely_rescue_decisions "Support timely rescue decisions" id "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746" { + description ""; + parent none; + } + + sa_occurrence updated_distress_report "Updated distress report" id "28b8e56d-7ddf-4950-8c30-7385c044cc72" { + body { + Exchange { + exchange @validated_distress_report; + receiver @coordination_system_lifeline; + sender @vessel_service_lifeline; + }; + }; + description ""; + parent @repeated_distress_reports; + postcondition "The incident position is refreshed without creating a second incident."; + precondition "The vessel has more recent position information."; + } + + oa_activity assess_incident "Assess incident" id "3388cc0d-0abc-4479-a948-6740266ec236" { + description ""; + parent @respond_to_distress; + } + + sa_constraint response_time_budget "Response time budget" id "3584d050-0b4e-4620-bbea-8c3c87d2a19f" { + constrained [@classify_incident_and_alert_operator, @validated_distress_report]; + description ""; + expression "incident_alert_latency <= 120 s"; + parent none; + } + + sa_system rescue_coordination_system "Rescue coordination system" id "47e28c63-803b-4ea6-af92-e778f62ecc3e" { + description ""; + parent none; + } + + sa_functional_chain digital_distress_handling "Digital distress handling" id "49743116-7777-4df8-9601-74fd4cba22c0" { + description ""; + parent none; + steps [@report_handled]; + } + + oa_process distress_to_response "Distress to response" id "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5" { + description ""; + parent none; + steps [@distress_received]; + } + + oa_occurrence distress_received "Distress received" id "5e0c0d0e-5eb5-467c-bc35-dfe29d534703" { + body { + Exchange { + exchange @distress_signal; + receiver none; + sender none; + }; + }; + description ""; + parent @distress_to_response; + postcondition ""; + precondition ""; + } + + sa_exchange_item digital_distress_report "Digital distress report" id "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" { + description ""; + kind Information; + parent none; + } + + oa_capability protect_life_at_sea "Protect life at sea" id "6ca5efd6-2e92-4508-a02d-4013793743cb" { + description ""; + parent none; + } + + sa_port report_function_output "Report function output" id "6ecf32ed-4009-48ba-b374-3bf156f6c355" { + description ""; + direction Out; + interface @distress_reporting_interface; + parent @transmit_distress_report; + } + + oa_entity rescue_coordination "Rescue coordination" id "721b68ab-5136-408e-890d-925215d5661e" { + description ""; + parent none; + } + + sa_capability coordinate_rescue_response "Coordinate rescue response" id "7781bd9f-e788-4049-9af4-9a3eab40d377" { + description ""; + parent none; + } + + oa_actor vessel_in_distress "Vessel in distress" id "7e8ea298-1c68-4c9e-994d-6008d47f62ce" { + description ""; + parent none; + } + + oa_exchange_item distress_report "Distress report" id "835c2e84-f4e9-4592-b5df-d205803d97b7" { + description ""; + kind Information; + parent none; + } + + sa_port system_reporting_input "System reporting input" id "8477d19d-4a62-43bb-a7e3-845b9b3d1002" { + description ""; + direction In; + interface @distress_reporting_interface; + parent @rescue_coordination_system; + } + + sa_occurrence initial_distress_report "Initial distress report" id "84a0db92-ac74-493a-af29-74ec7715d7fa" { + body { + Exchange { + exchange @validated_distress_report; + receiver @coordination_system_lifeline; + sender @vessel_service_lifeline; + }; + }; + description ""; + parent @repeated_distress_reports; + postcondition ""; + precondition ""; + } + + sa_port vessel_reporting_output "Vessel reporting output" id "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932" { + description ""; + direction Out; + interface @distress_reporting_interface; + parent @vessel_communication_service; + } + + sa_function transmit_distress_report "Transmit distress report" id "b0a649d6-0558-48f5-8437-161be8329159" { + description ""; + parent @manage_distress_response; + } + + sa_function classify_incident_and_alert_operator "Classify incident and alert operator" id "b1cc95c7-1d2f-42d0-9d93-587954bd563b" { + description ""; + parent @manage_distress_response; + } + + sa_occurrence report_handled "Report handled" id "bcdb57f1-5fa8-46ce-9498-b467cea5d98f" { + body { + Exchange { + exchange @validated_distress_report; + receiver none; + sender none; + }; + }; + description ""; + parent @digital_distress_handling; + postcondition ""; + precondition ""; + } + + sa_function manage_distress_response "Manage distress response" id "c64f93a7-bf47-42b5-b432-7589b9144f2c" { + description ""; + parent none; + } + + sa_actor vessel_communication_service "Vessel communication service" id "cfb45cf5-bebe-4632-9bde-0ee023c42583" { + description ""; + parent none; + } + + sa_mode normal_reporting "Normal reporting" id "d00b5d17-8660-4955-a70d-988a6e6b6c4e" { + description ""; + do_activity ""; + entry ""; + exit ""; + kind Mode; + parent @reporting_quality_region; + } + + sa_data_field reported_latitude "Reported latitude" id "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36" { + description ""; + maximum 1; + minimum 1; + parent @digital_distress_report; + type_ref Real; + unit "degrees north"; + } + + oa_activity report_distress "Report distress" id "d50e92c4-840e-4822-bcf7-033feb2eb879" { + description ""; + parent @respond_to_distress; + } + + sa_mode degraded_reporting "Degraded reporting" id "d7fad7e9-d799-4fa6-b49f-1304d3285b5e" { + description ""; + do_activity ""; + entry ""; + exit ""; + kind Mode; + parent @reporting_quality_region; + } + + sa_exchange_item rescue_supply_package "Rescue supply package" id "eb8cf40e-8194-403f-9896-f968790a9504" { + description ""; + kind Material; + parent none; + } + + sa_region reporting_quality_region "Reporting quality region" id "edc1ad70-8fc9-42a1-a668-05c4738bb225" { + description ""; + parent @reporting_operation_modes; + } + + sa_interface distress_reporting_interface "Distress reporting interface" id "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" { + description ""; + items [@digital_distress_report]; + parent none; + } + + oa_role incident_coordinator "Incident coordinator" id "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b" { + description ""; + parent none; + } + + sa_scenario repeated_distress_reports "Repeated distress reports" id "f7514a9c-933c-4133-8b77-37c0dccd935f" { + context @coordinate_rescue_response; + description ""; + parent none; + postcondition "The operator has a classified incident with the latest vessel report."; + precondition "A vessel has detected an emergency and the reporting service is available."; + steps [@initial_distress_report, @updated_distress_report]; + } + + oa_activity respond_to_distress "Respond to distress" id "fa23a2a8-6f13-49e3-be47-f757f26d5c9f" { + description ""; + parent none; + } + + sa_port assessment_function_input "Assessment function input" id "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f" { + description ""; + direction In; + interface @distress_reporting_interface; + parent @classify_incident_and_alert_operator; + } + + state_transition signal_restored "Signal restored" id "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018" { + description ""; + effect "Clear the degraded-reporting alert."; + guard "position_is_current"; + source @degraded_reporting; + target @normal_reporting; + trigger "validated_report_received"; + } + + state_action review_on_degraded_entry "Review on degraded entry" id "044cdc5f-a34a-4e51-9774-c1774a33dc91" { + description ""; + phase Entry; + source @degraded_reporting; + target @classify_incident_and_alert_operator; + } + + allocation assesses_incident "Assesses incident" id "17fef4ab-acc7-4f21-b70d-5d64f6ad1261" { + description ""; + source @rescue_coordination; + target @assess_incident; + } + + availability assessment_in_degraded_mode "Assessment in degraded mode" id "2c092728-eafb-47e3-bcc7-8b795e36a25e" { + description ""; + source @classify_incident_and_alert_operator; + target @degraded_reporting; + } + + state_transition signal_degraded "Signal degraded" id "331c78a2-b6b3-452f-a238-84a6a3df3547" { + description ""; + effect "Flag the incident and request an operator review."; + guard "seconds_since_last_report > 30"; + source @normal_reporting; + target @degraded_reporting; + trigger "report_timeout"; + } + + capability_involvement system_capability_support "System capability support" id "39ff3786-d94c-40ad-90fa-c009ab6f53f9" { + description ""; + source @classify_incident_and_alert_operator; + target @coordinate_rescue_response; + } + + chain_involvement operational_realization "Operational realization" id "41d934ba-76f7-4425-b433-2d4d293ab0df" { + description ""; + source @distress_to_response; + target @protect_life_at_sea; + } + + role_realization fulfils_coordinator_role "Fulfils coordinator role" id "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef" { + description ""; + source @rescue_coordination; + target @incident_coordinator; + } + + state_transition initialize_reporting "Initialize reporting" id "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b" { + description ""; + effect ""; + guard ""; + source @start_reporting; + target @normal_reporting; + trigger ""; + } + + capability_involvement operational_contribution "Operational contribution" id "64ab242b-6043-4452-9b28-7205fec309df" { + description ""; + source @assess_incident; + target @protect_life_at_sea; + } + + interaction distress_signal "Distress signal" id "689a8e09-b7ca-4d97-b551-a21e3b438bfe" { + description ""; + items [@distress_report]; + source @report_distress; + target @assess_incident; + } + + allocation vessel_transmission "Vessel transmission" id "6a82e148-d38e-4103-8481-046b4c474952" { + description ""; + source @vessel_communication_service; + target @transmit_distress_report; + } + + interface_requirement reporting_service_required "Reporting service required" id "7c32cbe5-0821-4793-a37f-9f017fc8a3f4" { + description ""; + source @rescue_coordination_system; + target @distress_reporting_interface; + } + + chain_involvement functional_chain_support "Functional chain support" id "92468388-63c2-4f2c-a1fd-2c3f27b76398" { + description ""; + source @digital_distress_handling; + target @coordinate_rescue_response; + } + + allocation system_assessment "System assessment" id "93e3a60d-33c0-4110-83d9-63f2902d5eeb" { + description ""; + source @rescue_coordination_system; + target @classify_incident_and_alert_operator; + } + + role_assignment coordinates_assessment "Coordinates assessment" id "a06f90b9-361b-4da2-86a9-0447df519e0c" { + description ""; + source @incident_coordinator; + target @assess_incident; + } + + functional_exchange validated_distress_report "Validated distress report" id "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" { + description ""; + items [@digital_distress_report]; + source @transmit_distress_report; + source_port @report_function_output; + target @classify_incident_and_alert_operator; + target_port @assessment_function_input; + } + + availability assessment_in_normal_mode "Assessment in normal mode" id "bce2587e-f47c-4d85-ac6d-a098170bbaa5" { + description ""; + source @classify_incident_and_alert_operator; + target @normal_reporting; + } + + exchange_allocation report_carried_by_connection "Report carried by connection" id "d6a2c8d2-2a4f-4d90-a490-c2c661db4423" { + description ""; + source @distress_reporting_connection; + target @validated_distress_report; + } + + mission_capability mission_capability "Mission capability" id "e88a9747-0d1c-4813-8273-e6710f622e9b" { + description ""; + source @support_timely_rescue_decisions; + target @coordinate_rescue_response; + } + + boundary_exchange distress_reporting_connection "Distress reporting connection" id "eb7ae249-165c-4717-bebc-da476eebb05e" { + description ""; + items [@digital_distress_report]; + source @vessel_reporting_output; + target @system_reporting_input; + } + + interface_provision reporting_service_provided "Reporting service provided" id "ec3f9e3f-5134-49af-9969-29f818dfcb46" { + description ""; + source @vessel_communication_service; + target @distress_reporting_interface; + } + + allocation reports_distress "Reports distress" id "f844c555-b407-402c-81c9-4928245249ca" { + description ""; + source @vessel_in_distress; + target @report_distress; + } + + diagram sa_system_boundary_and_functions "SA system boundary and functions" id "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a" { + description "A focused projection of the shared engineering model."; + layer Sa; + projection Allocation; + } + + node view_vessel_communication_service id "00edf6bc-b857-439a-8747-f1f66c1675e2" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @vessel_communication_service; + position { + x 60.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_transmit_distress_report id "035dab75-ef9e-4ca3-a107-87262e3c680b" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @transmit_distress_report; + position { + x 84.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_support_timely_rescue_decisions id "16b5055a-c98b-4276-b564-85b7fe1f020f" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @support_timely_rescue_decisions; + position { + x 392.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_digital_distress_handling id "4aa2fb09-b367-402c-a438-d4f918f04e08" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @digital_distress_handling; + position { + x 676.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_rescue_coordination_system id "5a6d6a1c-9915-4e33-8299-b512c50b18f5" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @rescue_coordination_system; + position { + x 960.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_manage_distress_response id "621e0e5e-d1be-4693-b95a-7c48d36e07b4" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @manage_distress_response; + position { + x 60.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_coordinate_rescue_response id "9f41eaff-3456-494b-b136-9f6f01afd90f" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @coordinate_rescue_response; + position { + x 344.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_classify_incident_and_alert_operator id "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @classify_incident_and_alert_operator; + position { + x 984.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + diagram sa_functional_decomposition "SA functional decomposition" id "a67786d6-5b08-4b06-a0c8-584a04f179ef" { + description "A focused projection of the shared engineering model."; + layer Sa; + projection Decomposition; + } + + node view_classify_incident_and_alert_operator_5ae489f2 id "5ae489f2-4be1-4842-b464-37900983e333" { + collapsed false; + diagram @sa_functional_decomposition; + element @classify_incident_and_alert_operator; + position { + x 84.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_manage_distress_response_80ab61e0 id "80ab61e0-d1f2-4008-9858-0759bdaa02e3" { + collapsed false; + diagram @sa_functional_decomposition; + element @manage_distress_response; + position { + x 60.0; + y 60.0; + }; + size { + height 276.0; + width 272.0; + }; + } + + node view_transmit_distress_report_e02431fd id "e02431fd-c27e-4b48-9c94-37b8e3d55375" { + collapsed false; + diagram @sa_functional_decomposition; + element @transmit_distress_report; + position { + x 84.0; + y 218.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + diagram oa_participants_and_activities "OA participants and activities" id "d7f59b88-4a9b-4906-b859-1c6d044bbb52" { + description "A focused projection of the shared engineering model."; + layer Oa; + projection Allocation; + } + + node view_rescue_coordination id "2f5fed31-e2dc-419d-9ebc-a5c9e9682277" { + collapsed false; + diagram @oa_participants_and_activities; + element @rescue_coordination; + position { + x 60.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_protect_life_at_sea id "91fe2d63-0083-4df2-9c29-563766e1b706" { + collapsed false; + diagram @oa_participants_and_activities; + element @protect_life_at_sea; + position { + x 392.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_distress_to_response id "a045fd40-84ea-48f3-a6d4-95641492d960" { + collapsed false; + diagram @oa_participants_and_activities; + element @distress_to_response; + position { + x 676.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_report_distress id "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe" { + collapsed false; + diagram @oa_participants_and_activities; + element @report_distress; + position { + x 984.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_assess_incident id "acaa5a3d-51c4-45da-b5aa-4825bccd747c" { + collapsed false; + diagram @oa_participants_and_activities; + element @assess_incident; + position { + x 84.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_vessel_in_distress id "af3a61b3-db02-400f-bd18-b4422e2db7a7" { + collapsed false; + diagram @oa_participants_and_activities; + element @vessel_in_distress; + position { + x 960.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_respond_to_distress id "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649" { + collapsed false; + diagram @oa_participants_and_activities; + element @respond_to_distress; + position { + x 60.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_incident_coordinator id "fcbda72a-5478-4d77-8330-16f89cdefc37" { + collapsed false; + diagram @oa_participants_and_activities; + element @incident_coordinator; + position { + x 344.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + artifact console_enclosure_cad "Console enclosure CAD" id "091b5a40-1631-49ef-8b53-1471c016a13f" { + description "Illustrative external engineering reference; opening a model does not fetch it."; + fragment none; + owner @rescue_coordination_system; + relation References; + revision "B.3"; + uri "cad://rescue-console/enclosure.step"; + } + + artifact response_implementation "Response implementation" id "0b09f95e-0545-44c3-b687-84489ce1422b" { + description "Illustrative external engineering reference; opening a model does not fetch it."; + fragment none; + owner @classify_incident_and_alert_operator; + relation Implements; + revision "design-v2"; + uri "git+https://example.org/rescue/control?path=src/response.rs"; + } + + trace trace_respond_to_distress "Respond to distress ↔ Manage distress response" id "1d0a908f-9e22-42b7-9aac-26bbe3f5df78" { + baseline { + oa { + description ""; + name "Respond to distress"; + }; + sa { + description ""; + name "Manage distress response"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Refinement; + oa @respond_to_distress; + sa @manage_distress_response; + } + + trace trace_rescue_coordination "Rescue coordination ↔ Rescue coordination system" id "49f2b55a-6dcc-44ba-a68f-a305942db7c9" { + baseline { + oa { + description ""; + name "Rescue coordination"; + }; + sa { + description ""; + name "Rescue coordination system"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Participant; + oa @rescue_coordination; + sa @rescue_coordination_system; + } + + trace trace_distress_to_response "Distress to response ↔ Digital distress handling" id "4ac98979-6c84-4a66-b043-373b70bb5203" { + baseline { + oa { + description ""; + name "Distress to response"; + }; + sa { + description ""; + name "Digital distress handling"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Sequence; + oa @distress_to_response; + sa @digital_distress_handling; + } + + trace trace_protect_life_at_sea "Protect life at sea ↔ Coordinate rescue response" id "4b6acf23-dab3-462d-b45f-066aea916932" { + baseline { + oa { + description ""; + name "Protect life at sea"; + }; + sa { + description ""; + name "Coordinate rescue response"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Capability; + oa @protect_life_at_sea; + sa @coordinate_rescue_response; + } + + trace trace_distress_signal "Distress signal ↔ Validated distress report" id "587cca51-69e8-47e7-9674-32ff49926a22" { + baseline { + oa { + description ""; + name "Distress signal"; + }; + sa { + description ""; + name "Validated distress report"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Exchange; + oa @distress_signal; + sa @validated_distress_report; + } + + trace trace_distress_report "Distress report ↔ Digital distress report" id "7790684b-220f-443d-8754-52c29414367f" { + baseline { + oa { + description ""; + name "Distress report"; + }; + sa { + description ""; + name "Digital distress report"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Item; + oa @distress_report; + sa @digital_distress_report; + } + + trace trace_vessel_in_distress "Vessel in distress ↔ Vessel communication service" id "9a8101e4-ebce-4836-a0e3-63440c2e1e87" { + baseline { + oa { + description ""; + name "Vessel in distress"; + }; + sa { + description ""; + name "Vessel communication service"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Participant; + oa @vessel_in_distress; + sa @vessel_communication_service; + } + + trace trace_assess_incident "Assess incident ↔ Classify incident and alert operator" id "b8fee65b-21da-4a27-9b42-575d417bd942" { + baseline { + oa { + description ""; + name "Assess incident"; + }; + sa { + description ""; + name "Classify incident and alert operator"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Refinement; + oa @assess_incident; + sa @classify_incident_and_alert_operator; + } + + trace trace_distress_received "Distress received ↔ Report handled" id "be4365d6-2a43-433c-935f-c039b266242f" { + baseline { + oa { + description ""; + name "Distress received"; + }; + sa { + description ""; + name "Report handled"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Scenario; + oa @distress_received; + sa @report_handled; + } + + trace trace_report_distress "Report distress ↔ Transmit distress report" id "d3f224a5-0b2d-4eba-93c9-232b9e84e742" { + baseline { + oa { + description ""; + name "Report distress"; + }; + sa { + description ""; + name "Transmit distress report"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Refinement; + oa @report_distress; + sa @transmit_distress_report; + } + +} diff --git a/crates/oa-core/tests/fixtures/v2-coastal-rescue.json b/crates/oa-core/tests/fixtures/v2-coastal-rescue.json new file mode 100644 index 00000000..b98ce427 --- /dev/null +++ b/crates/oa-core/tests/fixtures/v2-coastal-rescue.json @@ -0,0 +1,1414 @@ +{ + "schema_version": 2, + "id": "eceea6cd-108b-45a9-9505-8b7fa462af96", + "alias": "mission", + "name": "Coastal rescue — operational need to system behavior", + "description": "A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts.", + "elements": { + "03b71dc5-435f-4c7f-923a-cb6e672212ba": { + "id": "03b71dc5-435f-4c7f-923a-cb6e672212ba", + "alias": "vessel_service_lifeline", + "name": "Vessel service lifeline", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Lifeline": { + "represented": "cfb45cf5-bebe-4632-9bde-0ee023c42583" + } + } + } + } + }, + "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978": { + "id": "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978", + "alias": "start_reporting", + "name": "Start reporting", + "description": "", + "parent": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "body": { + "Sa": { + "Shared": { + "State": { + "kind": "Initial", + "entry": "", + "exit": "", + "do_activity": "" + } + } + } + } + }, + "1a1b215a-c102-4948-9c88-dabfadc8e08e": { + "id": "1a1b215a-c102-4948-9c88-dabfadc8e08e", + "alias": "reporting_operation_modes", + "name": "Reporting operation modes", + "description": "", + "parent": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "body": { + "Sa": { + "Shared": "StateMachine" + } + } + }, + "1b81d963-06e7-42d3-af31-bf5236829477": { + "id": "1b81d963-06e7-42d3-af31-bf5236829477", + "alias": "backup_electrical_energy", + "name": "Backup electrical energy", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "ExchangeItem": { + "kind": "Energy" + } + } + } + } + }, + "1f7da722-c91e-4d0e-a344-c1a2511dd96b": { + "id": "1f7da722-c91e-4d0e-a344-c1a2511dd96b", + "alias": "coordination_system_lifeline", + "name": "Coordination system lifeline", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Lifeline": { + "represented": "47e28c63-803b-4ea6-af92-e778f62ecc3e" + } + } + } + } + }, + "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746": { + "id": "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746", + "alias": "support_timely_rescue_decisions", + "name": "Support timely rescue decisions", + "description": "", + "parent": null, + "body": { + "Sa": "Mission" + } + }, + "28b8e56d-7ddf-4950-8c30-7385c044cc72": { + "id": "28b8e56d-7ddf-4950-8c30-7385c044cc72", + "alias": "updated_distress_report", + "name": "Updated distress report", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "sender": "03b71dc5-435f-4c7f-923a-cb6e672212ba", + "receiver": "1f7da722-c91e-4d0e-a344-c1a2511dd96b" + } + }, + "precondition": "The vessel has more recent position information.", + "postcondition": "The incident position is refreshed without creating a second incident." + } + } + } + } + }, + "3388cc0d-0abc-4479-a948-6740266ec236": { + "id": "3388cc0d-0abc-4479-a948-6740266ec236", + "alias": "assess_incident", + "name": "Assess incident", + "description": "", + "parent": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "body": { + "Oa": "Activity" + } + }, + "3584d050-0b4e-4620-bbea-8c3c87d2a19f": { + "id": "3584d050-0b4e-4620-bbea-8c3c87d2a19f", + "alias": "response_time_budget", + "name": "Response time budget", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "Constraint": { + "expression": "incident_alert_latency <= 120 s", + "constrained": [ + "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" + ] + } + } + } + } + }, + "47e28c63-803b-4ea6-af92-e778f62ecc3e": { + "id": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "alias": "rescue_coordination_system", + "name": "Rescue coordination system", + "description": "", + "parent": null, + "body": { + "Sa": "System" + } + }, + "49743116-7777-4df8-9601-74fd4cba22c0": { + "id": "49743116-7777-4df8-9601-74fd4cba22c0", + "alias": "digital_distress_handling", + "name": "Digital distress handling", + "description": "", + "parent": null, + "body": { + "Sa": { + "FunctionalChain": { + "steps": [ + "bcdb57f1-5fa8-46ce-9498-b467cea5d98f" + ] + } + } + } + }, + "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5": { + "id": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "alias": "distress_to_response", + "name": "Distress to response", + "description": "", + "parent": null, + "body": { + "Oa": { + "Process": { + "steps": [ + "5e0c0d0e-5eb5-467c-bc35-dfe29d534703" + ] + } + } + } + }, + "5e0c0d0e-5eb5-467c-bc35-dfe29d534703": { + "id": "5e0c0d0e-5eb5-467c-bc35-dfe29d534703", + "alias": "distress_received", + "name": "Distress received", + "description": "", + "parent": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "body": { + "Oa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "689a8e09-b7ca-4d97-b551-a21e3b438bfe", + "sender": null, + "receiver": null + } + }, + "precondition": "", + "postcondition": "" + } + } + } + } + }, + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f": { + "id": "5e16affb-7fa4-4a3d-8bfe-6340586ab83f", + "alias": "digital_distress_report", + "name": "Digital distress report", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "ExchangeItem": { + "kind": "Information" + } + } + } + } + }, + "6ca5efd6-2e92-4508-a02d-4013793743cb": { + "id": "6ca5efd6-2e92-4508-a02d-4013793743cb", + "alias": "protect_life_at_sea", + "name": "Protect life at sea", + "description": "", + "parent": null, + "body": { + "Oa": "Capability" + } + }, + "6ecf32ed-4009-48ba-b374-3bf156f6c355": { + "id": "6ecf32ed-4009-48ba-b374-3bf156f6c355", + "alias": "report_function_output", + "name": "Report function output", + "description": "", + "parent": "b0a649d6-0558-48f5-8437-161be8329159", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "Out", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + }, + "721b68ab-5136-408e-890d-925215d5661e": { + "id": "721b68ab-5136-408e-890d-925215d5661e", + "alias": "rescue_coordination", + "name": "Rescue coordination", + "description": "", + "parent": null, + "body": { + "Oa": "Entity" + } + }, + "7781bd9f-e788-4049-9af4-9a3eab40d377": { + "id": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "alias": "coordinate_rescue_response", + "name": "Coordinate rescue response", + "description": "", + "parent": null, + "body": { + "Sa": "Capability" + } + }, + "7e8ea298-1c68-4c9e-994d-6008d47f62ce": { + "id": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "alias": "vessel_in_distress", + "name": "Vessel in distress", + "description": "", + "parent": null, + "body": { + "Oa": "Actor" + } + }, + "835c2e84-f4e9-4592-b5df-d205803d97b7": { + "id": "835c2e84-f4e9-4592-b5df-d205803d97b7", + "alias": "distress_report", + "name": "Distress report", + "description": "", + "parent": null, + "body": { + "Oa": { + "Shared": { + "ExchangeItem": { + "kind": "Information" + } + } + } + } + }, + "8477d19d-4a62-43bb-a7e3-845b9b3d1002": { + "id": "8477d19d-4a62-43bb-a7e3-845b9b3d1002", + "alias": "system_reporting_input", + "name": "System reporting input", + "description": "", + "parent": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "In", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + }, + "84a0db92-ac74-493a-af29-74ec7715d7fa": { + "id": "84a0db92-ac74-493a-af29-74ec7715d7fa", + "alias": "initial_distress_report", + "name": "Initial distress report", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "sender": "03b71dc5-435f-4c7f-923a-cb6e672212ba", + "receiver": "1f7da722-c91e-4d0e-a344-c1a2511dd96b" + } + }, + "precondition": "", + "postcondition": "" + } + } + } + } + }, + "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932": { + "id": "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932", + "alias": "vessel_reporting_output", + "name": "Vessel reporting output", + "description": "", + "parent": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "Out", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + }, + "b0a649d6-0558-48f5-8437-161be8329159": { + "id": "b0a649d6-0558-48f5-8437-161be8329159", + "alias": "transmit_distress_report", + "name": "Transmit distress report", + "description": "", + "parent": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "body": { + "Sa": "Function" + } + }, + "b1cc95c7-1d2f-42d0-9d93-587954bd563b": { + "id": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "alias": "classify_incident_and_alert_operator", + "name": "Classify incident and alert operator", + "description": "", + "parent": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "body": { + "Sa": "Function" + } + }, + "bcdb57f1-5fa8-46ce-9498-b467cea5d98f": { + "id": "bcdb57f1-5fa8-46ce-9498-b467cea5d98f", + "alias": "report_handled", + "name": "Report handled", + "description": "", + "parent": "49743116-7777-4df8-9601-74fd4cba22c0", + "body": { + "Sa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "sender": null, + "receiver": null + } + }, + "precondition": "", + "postcondition": "" + } + } + } + } + }, + "c64f93a7-bf47-42b5-b432-7589b9144f2c": { + "id": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "alias": "manage_distress_response", + "name": "Manage distress response", + "description": "", + "parent": null, + "body": { + "Sa": "Function" + } + }, + "cfb45cf5-bebe-4632-9bde-0ee023c42583": { + "id": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "alias": "vessel_communication_service", + "name": "Vessel communication service", + "description": "", + "parent": null, + "body": { + "Sa": "Actor" + } + }, + "d00b5d17-8660-4955-a70d-988a6e6b6c4e": { + "id": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "alias": "normal_reporting", + "name": "Normal reporting", + "description": "", + "parent": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "body": { + "Sa": { + "Shared": { + "State": { + "kind": "Mode", + "entry": "", + "exit": "", + "do_activity": "" + } + } + } + } + }, + "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36": { + "id": "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36", + "alias": "reported_latitude", + "name": "Reported latitude", + "description": "", + "parent": "5e16affb-7fa4-4a3d-8bfe-6340586ab83f", + "body": { + "Sa": { + "Shared": { + "DataField": { + "type_ref": "Real", + "minimum": 1, + "maximum": 1, + "unit": "degrees north" + } + } + } + } + }, + "d50e92c4-840e-4822-bcf7-033feb2eb879": { + "id": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "alias": "report_distress", + "name": "Report distress", + "description": "", + "parent": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "body": { + "Oa": "Activity" + } + }, + "d7fad7e9-d799-4fa6-b49f-1304d3285b5e": { + "id": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "alias": "degraded_reporting", + "name": "Degraded reporting", + "description": "", + "parent": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "body": { + "Sa": { + "Shared": { + "State": { + "kind": "Mode", + "entry": "", + "exit": "", + "do_activity": "" + } + } + } + } + }, + "eb8cf40e-8194-403f-9896-f968790a9504": { + "id": "eb8cf40e-8194-403f-9896-f968790a9504", + "alias": "rescue_supply_package", + "name": "Rescue supply package", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "ExchangeItem": { + "kind": "Material" + } + } + } + } + }, + "edc1ad70-8fc9-42a1-a668-05c4738bb225": { + "id": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "alias": "reporting_quality_region", + "name": "Reporting quality region", + "description": "", + "parent": "1a1b215a-c102-4948-9c88-dabfadc8e08e", + "body": { + "Sa": { + "Shared": "Region" + } + } + }, + "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4": { + "id": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4", + "alias": "distress_reporting_interface", + "name": "Distress reporting interface", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "Interface": { + "items": [ + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" + ] + } + } + } + } + }, + "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b": { + "id": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b", + "alias": "incident_coordinator", + "name": "Incident coordinator", + "description": "", + "parent": null, + "body": { + "Oa": "Role" + } + }, + "f7514a9c-933c-4133-8b77-37c0dccd935f": { + "id": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "alias": "repeated_distress_reports", + "name": "Repeated distress reports", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "Scenario": { + "context": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "precondition": "A vessel has detected an emergency and the reporting service is available.", + "postcondition": "The operator has a classified incident with the latest vessel report.", + "steps": [ + "84a0db92-ac74-493a-af29-74ec7715d7fa", + "28b8e56d-7ddf-4950-8c30-7385c044cc72" + ] + } + } + } + } + }, + "fa23a2a8-6f13-49e3-be47-f757f26d5c9f": { + "id": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "alias": "respond_to_distress", + "name": "Respond to distress", + "description": "", + "parent": null, + "body": { + "Oa": "Activity" + } + }, + "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f": { + "id": "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f", + "alias": "assessment_function_input", + "name": "Assessment function input", + "description": "", + "parent": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "In", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + } + }, + "relations": { + "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018": { + "id": "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018", + "alias": "signal_restored", + "name": "Signal restored", + "description": "", + "body": { + "StateTransition": { + "source": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "target": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "trigger": "validated_report_received", + "guard": "position_is_current", + "effect": "Clear the degraded-reporting alert." + } + } + }, + "044cdc5f-a34a-4e51-9774-c1774a33dc91": { + "id": "044cdc5f-a34a-4e51-9774-c1774a33dc91", + "alias": "review_on_degraded_entry", + "name": "Review on degraded entry", + "description": "", + "body": { + "StateAction": { + "source": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "target": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "phase": "Entry" + } + } + }, + "17fef4ab-acc7-4f21-b70d-5d64f6ad1261": { + "id": "17fef4ab-acc7-4f21-b70d-5d64f6ad1261", + "alias": "assesses_incident", + "name": "Assesses incident", + "description": "", + "body": { + "Allocation": { + "source": "721b68ab-5136-408e-890d-925215d5661e", + "target": "3388cc0d-0abc-4479-a948-6740266ec236" + } + } + }, + "2c092728-eafb-47e3-bcc7-8b795e36a25e": { + "id": "2c092728-eafb-47e3-bcc7-8b795e36a25e", + "alias": "assessment_in_degraded_mode", + "name": "Assessment in degraded mode", + "description": "", + "body": { + "Availability": { + "source": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "target": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e" + } + } + }, + "331c78a2-b6b3-452f-a238-84a6a3df3547": { + "id": "331c78a2-b6b3-452f-a238-84a6a3df3547", + "alias": "signal_degraded", + "name": "Signal degraded", + "description": "", + "body": { + "StateTransition": { + "source": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "target": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "trigger": "report_timeout", + "guard": "seconds_since_last_report > 30", + "effect": "Flag the incident and request an operator review." + } + } + }, + "39ff3786-d94c-40ad-90fa-c009ab6f53f9": { + "id": "39ff3786-d94c-40ad-90fa-c009ab6f53f9", + "alias": "system_capability_support", + "name": "System capability support", + "description": "", + "body": { + "CapabilityInvolvement": { + "source": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "target": "7781bd9f-e788-4049-9af4-9a3eab40d377" + } + } + }, + "41d934ba-76f7-4425-b433-2d4d293ab0df": { + "id": "41d934ba-76f7-4425-b433-2d4d293ab0df", + "alias": "operational_realization", + "name": "Operational realization", + "description": "", + "body": { + "ChainInvolvement": { + "source": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "target": "6ca5efd6-2e92-4508-a02d-4013793743cb" + } + } + }, + "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef": { + "id": "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef", + "alias": "fulfils_coordinator_role", + "name": "Fulfils coordinator role", + "description": "", + "body": { + "RoleRealization": { + "source": "721b68ab-5136-408e-890d-925215d5661e", + "target": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b" + } + } + }, + "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b": { + "id": "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b", + "alias": "initialize_reporting", + "name": "Initialize reporting", + "description": "", + "body": { + "StateTransition": { + "source": "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978", + "target": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "trigger": "", + "guard": "", + "effect": "" + } + } + }, + "64ab242b-6043-4452-9b28-7205fec309df": { + "id": "64ab242b-6043-4452-9b28-7205fec309df", + "alias": "operational_contribution", + "name": "Operational contribution", + "description": "", + "body": { + "CapabilityInvolvement": { + "source": "3388cc0d-0abc-4479-a948-6740266ec236", + "target": "6ca5efd6-2e92-4508-a02d-4013793743cb" + } + } + }, + "689a8e09-b7ca-4d97-b551-a21e3b438bfe": { + "id": "689a8e09-b7ca-4d97-b551-a21e3b438bfe", + "alias": "distress_signal", + "name": "Distress signal", + "description": "", + "body": { + "Interaction": { + "source": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "target": "3388cc0d-0abc-4479-a948-6740266ec236", + "items": [ + "835c2e84-f4e9-4592-b5df-d205803d97b7" + ] + } + } + }, + "6a82e148-d38e-4103-8481-046b4c474952": { + "id": "6a82e148-d38e-4103-8481-046b4c474952", + "alias": "vessel_transmission", + "name": "Vessel transmission", + "description": "", + "body": { + "Allocation": { + "source": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "target": "b0a649d6-0558-48f5-8437-161be8329159" + } + } + }, + "7c32cbe5-0821-4793-a37f-9f017fc8a3f4": { + "id": "7c32cbe5-0821-4793-a37f-9f017fc8a3f4", + "alias": "reporting_service_required", + "name": "Reporting service required", + "description": "", + "body": { + "InterfaceRequirement": { + "source": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "target": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + }, + "92468388-63c2-4f2c-a1fd-2c3f27b76398": { + "id": "92468388-63c2-4f2c-a1fd-2c3f27b76398", + "alias": "functional_chain_support", + "name": "Functional chain support", + "description": "", + "body": { + "ChainInvolvement": { + "source": "49743116-7777-4df8-9601-74fd4cba22c0", + "target": "7781bd9f-e788-4049-9af4-9a3eab40d377" + } + } + }, + "93e3a60d-33c0-4110-83d9-63f2902d5eeb": { + "id": "93e3a60d-33c0-4110-83d9-63f2902d5eeb", + "alias": "system_assessment", + "name": "System assessment", + "description": "", + "body": { + "Allocation": { + "source": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "target": "b1cc95c7-1d2f-42d0-9d93-587954bd563b" + } + } + }, + "a06f90b9-361b-4da2-86a9-0447df519e0c": { + "id": "a06f90b9-361b-4da2-86a9-0447df519e0c", + "alias": "coordinates_assessment", + "name": "Coordinates assessment", + "description": "", + "body": { + "RoleAssignment": { + "source": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b", + "target": "3388cc0d-0abc-4479-a948-6740266ec236" + } + } + }, + "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d": { + "id": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "alias": "validated_distress_report", + "name": "Validated distress report", + "description": "", + "body": { + "FunctionalExchange": { + "source": "b0a649d6-0558-48f5-8437-161be8329159", + "target": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "source_port": "6ecf32ed-4009-48ba-b374-3bf156f6c355", + "target_port": "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f", + "items": [ + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" + ] + } + } + }, + "bce2587e-f47c-4d85-ac6d-a098170bbaa5": { + "id": "bce2587e-f47c-4d85-ac6d-a098170bbaa5", + "alias": "assessment_in_normal_mode", + "name": "Assessment in normal mode", + "description": "", + "body": { + "Availability": { + "source": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "target": "d00b5d17-8660-4955-a70d-988a6e6b6c4e" + } + } + }, + "d6a2c8d2-2a4f-4d90-a490-c2c661db4423": { + "id": "d6a2c8d2-2a4f-4d90-a490-c2c661db4423", + "alias": "report_carried_by_connection", + "name": "Report carried by connection", + "description": "", + "body": { + "ExchangeAllocation": { + "source": "eb7ae249-165c-4717-bebc-da476eebb05e", + "target": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" + } + } + }, + "e88a9747-0d1c-4813-8273-e6710f622e9b": { + "id": "e88a9747-0d1c-4813-8273-e6710f622e9b", + "alias": "mission_capability", + "name": "Mission capability", + "description": "", + "body": { + "MissionCapability": { + "source": "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746", + "target": "7781bd9f-e788-4049-9af4-9a3eab40d377" + } + } + }, + "eb7ae249-165c-4717-bebc-da476eebb05e": { + "id": "eb7ae249-165c-4717-bebc-da476eebb05e", + "alias": "distress_reporting_connection", + "name": "Distress reporting connection", + "description": "", + "body": { + "BoundaryExchange": { + "source": "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932", + "target": "8477d19d-4a62-43bb-a7e3-845b9b3d1002", + "items": [ + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" + ] + } + } + }, + "ec3f9e3f-5134-49af-9969-29f818dfcb46": { + "id": "ec3f9e3f-5134-49af-9969-29f818dfcb46", + "alias": "reporting_service_provided", + "name": "Reporting service provided", + "description": "", + "body": { + "InterfaceProvision": { + "source": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "target": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + }, + "f844c555-b407-402c-81c9-4928245249ca": { + "id": "f844c555-b407-402c-81c9-4928245249ca", + "alias": "reports_distress", + "name": "Reports distress", + "description": "", + "body": { + "Allocation": { + "source": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "target": "d50e92c4-840e-4822-bcf7-033feb2eb879" + } + } + } + }, + "diagrams": { + "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a": { + "id": "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a", + "alias": "sa_system_boundary_and_functions", + "name": "SA system boundary and functions", + "description": "A focused projection of the shared engineering model.", + "layer": "Sa", + "projection": "Allocation", + "nodes": { + "00edf6bc-b857-439a-8747-f1f66c1675e2": { + "id": "00edf6bc-b857-439a-8747-f1f66c1675e2", + "alias": "view_vessel_communication_service", + "element": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "position": { + "x": 60.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "035dab75-ef9e-4ca3-a107-87262e3c680b": { + "id": "035dab75-ef9e-4ca3-a107-87262e3c680b", + "alias": "view_transmit_distress_report", + "element": "b0a649d6-0558-48f5-8437-161be8329159", + "position": { + "x": 84.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "16b5055a-c98b-4276-b564-85b7fe1f020f": { + "id": "16b5055a-c98b-4276-b564-85b7fe1f020f", + "alias": "view_support_timely_rescue_decisions", + "element": "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746", + "position": { + "x": 392.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "4aa2fb09-b367-402c-a438-d4f918f04e08": { + "id": "4aa2fb09-b367-402c-a438-d4f918f04e08", + "alias": "view_digital_distress_handling", + "element": "49743116-7777-4df8-9601-74fd4cba22c0", + "position": { + "x": 676.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "5a6d6a1c-9915-4e33-8299-b512c50b18f5": { + "id": "5a6d6a1c-9915-4e33-8299-b512c50b18f5", + "alias": "view_rescue_coordination_system", + "element": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "position": { + "x": 960.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "621e0e5e-d1be-4693-b95a-7c48d36e07b4": { + "id": "621e0e5e-d1be-4693-b95a-7c48d36e07b4", + "alias": "view_manage_distress_response", + "element": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "position": { + "x": 60.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "9f41eaff-3456-494b-b136-9f6f01afd90f": { + "id": "9f41eaff-3456-494b-b136-9f6f01afd90f", + "alias": "view_coordinate_rescue_response", + "element": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "position": { + "x": 344.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68": { + "id": "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68", + "alias": "view_classify_incident_and_alert_operator", + "element": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "position": { + "x": 984.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + } + } + }, + "a67786d6-5b08-4b06-a0c8-584a04f179ef": { + "id": "a67786d6-5b08-4b06-a0c8-584a04f179ef", + "alias": "sa_functional_decomposition", + "name": "SA functional decomposition", + "description": "A focused projection of the shared engineering model.", + "layer": "Sa", + "projection": "Decomposition", + "nodes": { + "5ae489f2-4be1-4842-b464-37900983e333": { + "id": "5ae489f2-4be1-4842-b464-37900983e333", + "alias": "view_classify_incident_and_alert_operator_5ae489f2", + "element": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "position": { + "x": 84.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "80ab61e0-d1f2-4008-9858-0759bdaa02e3": { + "id": "80ab61e0-d1f2-4008-9858-0759bdaa02e3", + "alias": "view_manage_distress_response_80ab61e0", + "element": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "position": { + "x": 60.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 276.0 + }, + "collapsed": false + }, + "e02431fd-c27e-4b48-9c94-37b8e3d55375": { + "id": "e02431fd-c27e-4b48-9c94-37b8e3d55375", + "alias": "view_transmit_distress_report_e02431fd", + "element": "b0a649d6-0558-48f5-8437-161be8329159", + "position": { + "x": 84.0, + "y": 218.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + } + } + }, + "d7f59b88-4a9b-4906-b859-1c6d044bbb52": { + "id": "d7f59b88-4a9b-4906-b859-1c6d044bbb52", + "alias": "oa_participants_and_activities", + "name": "OA participants and activities", + "description": "A focused projection of the shared engineering model.", + "layer": "Oa", + "projection": "Allocation", + "nodes": { + "2f5fed31-e2dc-419d-9ebc-a5c9e9682277": { + "id": "2f5fed31-e2dc-419d-9ebc-a5c9e9682277", + "alias": "view_rescue_coordination", + "element": "721b68ab-5136-408e-890d-925215d5661e", + "position": { + "x": 60.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "91fe2d63-0083-4df2-9c29-563766e1b706": { + "id": "91fe2d63-0083-4df2-9c29-563766e1b706", + "alias": "view_protect_life_at_sea", + "element": "6ca5efd6-2e92-4508-a02d-4013793743cb", + "position": { + "x": 392.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "a045fd40-84ea-48f3-a6d4-95641492d960": { + "id": "a045fd40-84ea-48f3-a6d4-95641492d960", + "alias": "view_distress_to_response", + "element": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "position": { + "x": 676.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe": { + "id": "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe", + "alias": "view_report_distress", + "element": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "position": { + "x": 984.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "acaa5a3d-51c4-45da-b5aa-4825bccd747c": { + "id": "acaa5a3d-51c4-45da-b5aa-4825bccd747c", + "alias": "view_assess_incident", + "element": "3388cc0d-0abc-4479-a948-6740266ec236", + "position": { + "x": 84.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "af3a61b3-db02-400f-bd18-b4422e2db7a7": { + "id": "af3a61b3-db02-400f-bd18-b4422e2db7a7", + "alias": "view_vessel_in_distress", + "element": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "position": { + "x": 960.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649": { + "id": "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649", + "alias": "view_respond_to_distress", + "element": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "position": { + "x": 60.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "fcbda72a-5478-4d77-8330-16f89cdefc37": { + "id": "fcbda72a-5478-4d77-8330-16f89cdefc37", + "alias": "view_incident_coordinator", + "element": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b", + "position": { + "x": 344.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + } + } + } + }, + "artifact_links": { + "091b5a40-1631-49ef-8b53-1471c016a13f": { + "id": "091b5a40-1631-49ef-8b53-1471c016a13f", + "alias": "console_enclosure_cad", + "name": "Console enclosure CAD", + "description": "Illustrative external engineering reference; opening a model does not fetch it.", + "owner": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "relation": "References", + "uri": "cad://rescue-console/enclosure.step", + "revision": "B.3", + "fragment": null + }, + "0b09f95e-0545-44c3-b687-84489ce1422b": { + "id": "0b09f95e-0545-44c3-b687-84489ce1422b", + "alias": "response_implementation", + "name": "Response implementation", + "description": "Illustrative external engineering reference; opening a model does not fetch it.", + "owner": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "relation": "Implements", + "uri": "git+https://example.org/rescue/control?path=src/response.rs", + "revision": "design-v2", + "fragment": null + } + }, + "traces": { + "1d0a908f-9e22-42b7-9aac-26bbe3f5df78": { + "id": "1d0a908f-9e22-42b7-9aac-26bbe3f5df78", + "alias": "trace_respond_to_distress", + "name": "Respond to distress ↔ Manage distress response", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "sa": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "kind": "Refinement", + "baseline": { + "oa": { + "name": "Respond to distress", + "description": "" + }, + "sa": { + "name": "Manage distress response", + "description": "" + } + } + }, + "49f2b55a-6dcc-44ba-a68f-a305942db7c9": { + "id": "49f2b55a-6dcc-44ba-a68f-a305942db7c9", + "alias": "trace_rescue_coordination", + "name": "Rescue coordination ↔ Rescue coordination system", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "721b68ab-5136-408e-890d-925215d5661e", + "sa": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "kind": "Participant", + "baseline": { + "oa": { + "name": "Rescue coordination", + "description": "" + }, + "sa": { + "name": "Rescue coordination system", + "description": "" + } + } + }, + "4ac98979-6c84-4a66-b043-373b70bb5203": { + "id": "4ac98979-6c84-4a66-b043-373b70bb5203", + "alias": "trace_distress_to_response", + "name": "Distress to response ↔ Digital distress handling", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "sa": "49743116-7777-4df8-9601-74fd4cba22c0", + "kind": "Sequence", + "baseline": { + "oa": { + "name": "Distress to response", + "description": "" + }, + "sa": { + "name": "Digital distress handling", + "description": "" + } + } + }, + "4b6acf23-dab3-462d-b45f-066aea916932": { + "id": "4b6acf23-dab3-462d-b45f-066aea916932", + "alias": "trace_protect_life_at_sea", + "name": "Protect life at sea ↔ Coordinate rescue response", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "6ca5efd6-2e92-4508-a02d-4013793743cb", + "sa": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "kind": "Capability", + "baseline": { + "oa": { + "name": "Protect life at sea", + "description": "" + }, + "sa": { + "name": "Coordinate rescue response", + "description": "" + } + } + }, + "587cca51-69e8-47e7-9674-32ff49926a22": { + "id": "587cca51-69e8-47e7-9674-32ff49926a22", + "alias": "trace_distress_signal", + "name": "Distress signal ↔ Validated distress report", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "689a8e09-b7ca-4d97-b551-a21e3b438bfe", + "sa": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "kind": "Exchange", + "baseline": { + "oa": { + "name": "Distress signal", + "description": "" + }, + "sa": { + "name": "Validated distress report", + "description": "" + } + } + }, + "7790684b-220f-443d-8754-52c29414367f": { + "id": "7790684b-220f-443d-8754-52c29414367f", + "alias": "trace_distress_report", + "name": "Distress report ↔ Digital distress report", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "835c2e84-f4e9-4592-b5df-d205803d97b7", + "sa": "5e16affb-7fa4-4a3d-8bfe-6340586ab83f", + "kind": "Item", + "baseline": { + "oa": { + "name": "Distress report", + "description": "" + }, + "sa": { + "name": "Digital distress report", + "description": "" + } + } + }, + "9a8101e4-ebce-4836-a0e3-63440c2e1e87": { + "id": "9a8101e4-ebce-4836-a0e3-63440c2e1e87", + "alias": "trace_vessel_in_distress", + "name": "Vessel in distress ↔ Vessel communication service", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "sa": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "kind": "Participant", + "baseline": { + "oa": { + "name": "Vessel in distress", + "description": "" + }, + "sa": { + "name": "Vessel communication service", + "description": "" + } + } + }, + "b8fee65b-21da-4a27-9b42-575d417bd942": { + "id": "b8fee65b-21da-4a27-9b42-575d417bd942", + "alias": "trace_assess_incident", + "name": "Assess incident ↔ Classify incident and alert operator", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "3388cc0d-0abc-4479-a948-6740266ec236", + "sa": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "kind": "Refinement", + "baseline": { + "oa": { + "name": "Assess incident", + "description": "" + }, + "sa": { + "name": "Classify incident and alert operator", + "description": "" + } + } + }, + "be4365d6-2a43-433c-935f-c039b266242f": { + "id": "be4365d6-2a43-433c-935f-c039b266242f", + "alias": "trace_distress_received", + "name": "Distress received ↔ Report handled", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "5e0c0d0e-5eb5-467c-bc35-dfe29d534703", + "sa": "bcdb57f1-5fa8-46ce-9498-b467cea5d98f", + "kind": "Scenario", + "baseline": { + "oa": { + "name": "Distress received", + "description": "" + }, + "sa": { + "name": "Report handled", + "description": "" + } + } + }, + "d3f224a5-0b2d-4eba-93c9-232b9e84e742": { + "id": "d3f224a5-0b2d-4eba-93c9-232b9e84e742", + "alias": "trace_report_distress", + "name": "Report distress ↔ Transmit distress report", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "sa": "b0a649d6-0558-48f5-8437-161be8329159", + "kind": "Refinement", + "baseline": { + "oa": { + "name": "Report distress", + "description": "" + }, + "sa": { + "name": "Transmit distress report", + "description": "" + } + } + } + } +} diff --git a/crates/oa-core/tests/language.rs b/crates/oa-core/tests/language.rs new file mode 100644 index 00000000..8e0fc96a --- /dev/null +++ b/crates/oa-core/tests/language.rs @@ -0,0 +1,1039 @@ +use oa_core::{language, persistence, validation, *}; + +fn read(source: &str) -> Project { + language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}")) +} +fn rejected(source: &str, fragment: &str) { + let errors = language::parse(source).expect_err(source); + assert!( + errors.iter().any(|e| e.message.contains(fragment)), + "Expected {fragment:?}: {errors:?}" + ); + for e in errors { + assert!(e.line > 0 && e.column > 0); + assert!(source.is_char_boundary(e.span.start)); + assert!(source.is_char_boundary(e.span.end)); + } +} + +fn complete_model() -> Project { + let mut p = read( + r##" +arcadia 2; +project mission "Harbor safety — 港 🌊" { + description "Need and selected system response.\nVerifiable in both editors."; + oa_entity coordination "Coordination" {} + oa_entity team "Response team" { parent @coordination; } + oa_actor operator_one "Operator" { parent @team; } + oa_role response_role "Response role" { parent @team; } + oa_activity operational_work "Operational work" {} + oa_activity report "Report event" { parent @operational_work; } + oa_activity assess "Assess event" { parent @operational_work; } + oa_capability safe_harbor "Safe harbor" {} + oa_capability routine_safety "Routine safety" {} + oa_capability emergency_safety "Emergency safety" {} + oa_mission operational_goal "Protect harbor users" {} + mission_capability operational_mission_support "Required capability" { source @operational_goal; target @safe_harbor; } + mission_involvement operational_mission_participant "Mission participant" { source @coordination; target @operational_goal; } + capability_include required_safety "Includes safety" { source @safe_harbor; target @routine_safety; } + capability_extend emergency_extension "Emergency extension" { source @emergency_safety; target @safe_harbor; condition "sea_state > 4 && operator == \"Ready\""; } + capability_generalization specialized_safety "Specializes safety" { source @emergency_safety; target @routine_safety; } + oa_process response_path "Response path" { steps [@first_exchange, @repeat_exchange]; } + oa_process reviewed_path "Reviewed response" { steps [@review_start, @review_response]; } + oa_occurrence review_start "Assess before response" { parent @reviewed_path; body { Action { action @assess; lifeline none; }; }; } + oa_occurrence review_response "Reuse response path" { parent @reviewed_path; body { SequenceReference { sequence @response_path; }; }; } + sequence_link reviewed_follow "Continue response" { source @review_start; target @review_response; guard "assessment == \"Approved\""; } + allocation reporting "Performs" { source @operator_one; target @report; } + allocation assessment "Performs" { source @coordination; target @assess; } + role_realization responsible "Realizes role" { source @coordination; target @response_role; } + role_assignment assigned_work "Assigned work" { source @response_role; target @assess; } + interaction event_report "Event report" { source @report; target @assess; items [@operational_information]; } + capability_involvement support_work "Supports" { source @assess; target @safe_harbor; } + chain_involvement support_path "Illustrates" { source @response_path; target @safe_harbor; } + oa_occurrence first_exchange "First report" { parent @response_path; body { Exchange { exchange @event_report; sender none; receiver none; }; }; } + oa_occurrence repeat_exchange "Repeated report" { parent @response_path; body { Exchange { exchange @event_report; sender none; receiver none; }; }; } + oa_exchange_item operational_information "Operational report" { kind Information; } + oa_event incoming_report "Report arrived" { body { Signal { items [@operational_information]; }; }; } + oa_data_type operational_record "Operational record" { definition Record; } + oa_data_type weather_state "Weather classification" { definition { Enumeration { literals ["Clear", "Fog", "Storm"]; }; }; } + oa_data_field weather_field "Weather" { parent @operational_record; type_ref { Named @weather_state; }; minimum 0; maximum none; unit ""; } + oa_interface operational_contract "Operational communication" { items [@operational_information]; } + oa_port operational_port "Report channel" { parent @coordination; direction InOut; interface @operational_contract; } + oa_scenario operational_story "Operational story" { context @safe_harbor; steps []; } + oa_state_machine operational_modes "Operational modes" { parent @coordination; } + oa_region operational_region "Operational region" { parent @operational_modes; } + oa_initial operational_initial "Start" { parent @operational_region; } + oa_mode operational_normal "Normal" { parent @operational_region; entry "Begin watch"; exit "End watch"; do_activity "Coordinate"; } + state_transition operational_start "Start watch" { source @operational_initial; target @operational_normal; } + availability operational_available "Available" { source @assess; target @operational_normal; } + state_action operational_do "Perform assessment" { source @operational_normal; target @assess; phase Do; } + oa_constraint operational_limit "Visibility limit" { expression "visibility >= threshold"; constrained [@assess, @event_report]; } + sa_system safety_system "Harbor safety system" {} + sa_actor duty_officer "Duty officer" {} + sa_function system_service "Provide event support" {} + sa_function detect "Detect event" { parent @system_service; } + sa_function notify_officer "Notify operator" { parent @system_service; } + sa_capability supported_response "Supported response" {} + sa_mission mission_goal "Protect harbor operations" {} + mission_involvement system_mission_participant "System mission participant" { source @duty_officer; target @mission_goal; } + sa_functional_chain selected_chain "Detection to notification" { steps [@system_exchange_occurrence]; } + allocation system_detection "Performs" { source @safety_system; target @detect; } + allocation operator_notice "Performs" { source @duty_officer; target @notify_officer; } + sa_exchange_item system_information "Event information" { kind Event; } + sa_interface notification_interface "Notification contract" { items [@system_information]; } + sa_port detect_out "Detected event" { parent @detect; direction Out; } + sa_port notice_in "Event input" { parent @notify_officer; direction In; } + sa_port system_out "System output" { parent @safety_system; direction Out; interface @notification_interface; } + sa_port officer_in "Operator input" { parent @duty_officer; direction In; interface @notification_interface; } + functional_exchange detected_event "Detected event" { source @detect; target @notify_officer; source_port @detect_out; target_port @notice_in; items [@system_information]; } + boundary_exchange boundary_notice "Boundary notice" { source @system_out; target @officer_in; items [@system_information]; } + exchange_allocation carried_notice "Carried exchange" { source @boundary_notice; target @detected_event; } + interface_provision provides_notice "Provides" { source @safety_system; target @notification_interface; } + interface_requirement requires_notice "Requires" { source @duty_officer; target @notification_interface; } + mission_capability mission_support "Uses capability" { source @mission_goal; target @supported_response; } + capability_involvement system_support "Supports" { source @detect; target @supported_response; } + chain_involvement chain_support "Illustrates" { source @selected_chain; target @supported_response; } + sa_occurrence system_exchange_occurrence "Event occurrence" { parent @selected_chain; body { Exchange { exchange @detected_event; sender none; receiver none; }; }; } + sa_data_type distance "Distance" { definition Real; } + sa_data_type event_record "Event record" { definition Record; } + sa_data_field named_field "Measured range" { parent @event_record; type_ref { Named @distance; }; minimum 0; maximum none; unit "m"; } + sa_state_machine system_modes "System modes" { parent @safety_system; } + sa_region system_region "System region" { parent @system_modes; } + sa_initial initial_mode "Initial" { parent @system_region; } + sa_mode normal_mode "Normal" { parent @system_region; entry "Prepare"; exit "Release"; do_activity "Monitor"; } + sa_state degraded_state "Degraded" { parent @system_region; } + sa_final final_mode "Stopped" { parent @system_region; } + state_transition initialize "Initialize" { source @initial_mode; target @normal_mode; } + state_transition degrade "Degrade" { source @normal_mode; target @degraded_state; trigger "sensor failure"; guard "redundancy exhausted"; effect "Annunciate degraded service"; } + state_transition stop_system "Stop" { source @degraded_state; target @final_mode; trigger "shutdown"; } + availability detection_available "Available" { source @detect; target @normal_mode; } + state_action prepare_detection "Entry work" { source @normal_mode; target @detect; phase Entry; } + state_action release_detection "Exit work" { source @normal_mode; target @detect; phase Exit; } + sa_constraint latency_limit "Latency" { expression "notification latency <= 2 s"; constrained [@detected_event]; } + sa_scenario system_story "System story" { context @supported_response; precondition "Watch is active"; postcondition "Operator informed"; steps [@story_exchange, @story_action, @story_state, @alternative_flow]; } + sa_lifeline detector_line "Detector" { parent @system_story; represented @detect; } + sa_lifeline receiver_line "Receiver" { parent @system_story; represented @notify_officer; } + sa_lifeline system_line "System" { parent @system_story; represented @safety_system; } + sa_occurrence story_exchange "Send report" { parent @system_story; body { Exchange { exchange @detected_event; sender @detector_line; receiver @receiver_line; }; }; precondition "Event accepted"; postcondition "Report sent"; } + sa_occurrence story_action "Detect" { parent @system_story; body { Action { action @detect; lifeline @detector_line; }; }; } + sa_occurrence story_state "Normal mode" { parent @system_story; body { State { state @normal_mode; lifeline @system_line; }; }; } + sa_occurrence alternative_flow "Alternative" { parent @system_story; body { Fragment { operator Alternative; branches [@good_branch, @bad_branch]; }; }; } + sa_occurrence good_branch "Good branch" { parent @alternative_flow; body { Branch { guard "valid event"; occurrences [@branch_action]; }; }; } + sa_occurrence bad_branch "Bad branch" { parent @alternative_flow; body { Branch { guard "invalid event"; occurrences []; }; }; } + sa_occurrence branch_action "Acknowledge" { parent @good_branch; body { Action { action @notify_officer; lifeline @receiver_line; }; }; } + diagram operational_view "OA view" { layer Oa; projection Allocation; } + diagram system_view "SA view" { layer Sa; projection Decomposition; } + diagram trace_view "Trace view" { layer Oa; projection Traceability; } + node report_view { diagram @operational_view; element @report; position { x -0.0; y 12.375; }; size { width 260.0; height 94.0; }; collapsed true; } + node detect_view { diagram @system_view; element @detect; } + node trace_oa_view { diagram @trace_view; element @report; } + node trace_sa_view { diagram @trace_view; element @detect; } + trace work_trace "Operational realization" { oa @report; sa @detect; kind Refinement; baseline { oa { name "Report event"; description "Operational intent"; }; sa { name "Detect event"; description "Selected service"; }; }; } + trace participant_trace "Participant realization" { oa @coordination; sa @safety_system; kind Participant; } + trace goal_trace "Capability realization" { oa @safe_harbor; sa @supported_response; kind Capability; } + trace mission_trace "Mission realization" { oa @operational_goal; sa @mission_goal; kind Mission; } + trace exchange_trace "Exchange realization" { oa @event_report; sa @detected_event; kind Exchange; } + trace path_trace "Path realization" { oa @response_path; sa @selected_chain; kind Sequence; } + trace information_trace "Information realization" { oa @operational_information; sa @system_information; kind Item; } + trace scenario_trace "Scenario realization" { oa @operational_story; sa @system_story; kind Scenario; } + trace mode_trace "Mode realization" { oa @operational_normal; sa @normal_mode; kind State; } + trace manual_trace "Engineer rationale" { oa @assess; sa @mission_goal; kind Manual; } + artifact design_record "Design decision" { owner @work_trace; relation DerivedFrom; uri "docs/decision%20record.md"; revision "r42"; fragment "#operational-need"; } +} +"##, + ); + let state_region = p + .elements + .values() + .find(|e| e.alias == "system_region") + .unwrap() + .id; + for kind in [ + Kind::Choice, + Kind::Junction, + Kind::Fork, + Kind::Join, + Kind::ShallowHistory, + Kind::DeepHistory, + Kind::EntryPoint, + Kind::ExitPoint, + Kind::Terminate, + ] { + p.add_element(kind, Layer::Sa, kind.label(), Some(state_region)) + .unwrap(); + } + let record = p + .elements + .values() + .find(|e| e.alias == "event_record") + .unwrap() + .id; + for (index, type_ref) in [ + TypeRef::Boolean, + TypeRef::Integer, + TypeRef::Real, + TypeRef::String, + ] + .into_iter() + .enumerate() + { + let id = p + .add_element( + Kind::DataField, + Layer::Sa, + &format!("Field {index}"), + Some(record), + ) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField { + type_ref, + minimum: 1, + maximum: Some(5), + unit: "unit".into(), + }; + } + for definition in [ + DataTypeDefinition::Boolean, + DataTypeDefinition::Integer, + DataTypeDefinition::String, + ] { + let id = p + .add_element(Kind::DataType, Layer::Sa, "Primitive type", None) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = + SharedBody::DataType { definition }; + } + for kind in [ + ExchangeItemKind::Material, + ExchangeItemKind::Energy, + ExchangeItemKind::Information, + ] { + let id = p + .add_element( + Kind::ExchangeItem, + Layer::Sa, + "Physical exchange item", + None, + ) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind }; + } + let scenario = p + .elements + .values() + .find(|e| e.alias == "system_story") + .unwrap() + .id; + for operator in [ + FragmentOperator::Parallel, + FragmentOperator::Loop, + FragmentOperator::Optional, + ] { + let id = p + .add_element( + Kind::Occurrence, + Layer::Sa, + "Control fragment", + Some(scenario), + ) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator, + branches: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + } + p +} + +#[test] +fn all_typed_fields_round_trip_with_stable_aliases_and_occurrences() { + let p = complete_model(); + let text = language::format(&p); + let parsed = read(&text); + assert_eq!(parsed, p); + assert_eq!(language::format(&parsed), text); + assert!(!text.contains("schema_version")); + assert!(text.contains("parent @operational_work;")); + assert!(text.contains("Named @distance;")); + assert!(text.contains("node report_view id \"")); + assert!(text.contains("body {\n")); + for kind in Kind::ALL { + assert!( + p.elements.values().any(|element| element.kind() == kind), + "missing {kind}" + ); + } + for kind in RelationKind::ALL { + assert!( + p.relations.values().any(|r| r.kind() == kind), + "missing {kind}" + ); + } + let old_aliases = p + .identities() + .into_iter() + .map(|(id, a)| (id, a.to_owned())) + .collect::>(); + let mut renamed = p.clone(); + for e in renamed.elements.values_mut() { + e.name.push_str(" renamed"); + } + assert_eq!( + renamed + .identities() + .into_iter() + .map(|(id, a)| (id, a.to_owned())) + .collect::>(), + old_aliases + ); + assert_eq!(read(&language::format(&renamed)), renamed); +} + +#[test] +fn branching_reused_processes_and_chains_have_lossless_readable_source() { + use std::fmt::Write; + for (prefix, work, sequence) in [ + ("oa", "activity", "process"), + ("sa", "function", "functional_chain"), + ] { + let mut source = format!( + "arcadia 2; project topology \"Contextual topology\" {{\n{prefix}_{work} prepare \"Prepare\" {{}}\n{prefix}_{work} assess \"Assess\" {{}}\n" + ); + writeln!( + source, + "{prefix}_{sequence} reusable \"Reusable assessment\" {{ steps [@nested_work]; }}" + ) + .unwrap(); + writeln!(source, "{prefix}_occurrence nested_work \"Assessment contribution\" {{ parent @reusable; body {{ Action {{ action @assess; lifeline none; }}; }}; }}").unwrap(); + writeln!(source, "{prefix}_{sequence} flow \"Reviewed process\" {{ steps [@entry, @forked, @left, @right, @joined, @choice, @merged, @looped, @reused, @finish]; }}").unwrap(); + for (alias, action) in [ + ("entry", "prepare"), + ("left", "prepare"), + ("right", "assess"), + ("finish", "assess"), + ] { + writeln!(source, "{prefix}_occurrence {alias} \"{alias}\" {{ parent @flow; body {{ Action {{ action @{action}; lifeline none; }}; }}; }}").unwrap(); + } + for (alias, kind) in [ + ("forked", "Fork"), + ("joined", "Join"), + ("choice", "Choice"), + ("merged", "Merge"), + ("looped", "Loop"), + ] { + writeln!(source, "{prefix}_occurrence {alias} \"{kind}\" {{ parent @flow; body {{ Control {{ kind {kind}; }}; }}; }}").unwrap(); + } + writeln!(source, "{prefix}_occurrence reused \"Reuse assessment\" {{ parent @flow; body {{ SequenceReference {{ sequence @reusable; }}; }}; }}").unwrap(); + for (index, (from, to, guard)) in [ + ("entry", "forked", ""), + ("forked", "left", ""), + ("forked", "right", ""), + ("left", "joined", ""), + ("right", "joined", ""), + ("joined", "choice", ""), + ("choice", "looped", "sea_state > 4 && operator == \"Ready\""), + ("choice", "merged", "else"), + ("looped", "merged", "assessment accepted"), + ("looped", "reused", "repeat assessment"), + ("reused", "looped", ""), + ("merged", "finish", ""), + ] + .into_iter() + .enumerate() + { + writeln!(source, "sequence_link path_{index} \"Path {index}\" {{ source @{from}; target @{to}; guard {}; }}", serde_json::to_string(guard).unwrap()).unwrap(); + } + source.push('}'); + let model = read(&source); + let canonical = language::format(&model); + assert!(canonical.contains("sequence @reusable;")); + assert!(canonical.contains("kind Loop;")); + assert!(canonical.contains("operator == \\\"Ready\\\"")); + assert_eq!(read(&canonical), model); + assert_eq!( + persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(), + model + ); + let mut broken = canonical.replace("sequence @reusable;", "sequence @flow;"); + assert!( + language::parse(&broken) + .unwrap_err() + .iter() + .any(|d| d.code == "recursive_sequence_reference") + ); + broken = canonical.replace("sequence @reusable;", "sequence \"reusable\";"); + assert!( + language::parse(&broken).is_err(), + "identity references must use @alias" + ); + } +} + +#[test] +fn authoring_defaults_and_forward_references_remain_strict() { + let p = read( + r#"arcadia 2; project mission "New" { allocation assigned "Performs" { source @crew; target @work; } oa_activity work "Work" {} oa_entity crew "Crew" {} }"#, + ); + assert_eq!(p.elements.len(), 2); + assert!( + p.elements + .values() + .all(|e| e.description.is_empty() && e.parent.is_none()) + ); + assert_eq!(p, read(&language::format(&p))); + let warnings = validation::validate(&read( + "arcadia 2; project mission \"Draft\" { sa_function unfinished \"Unallocated\" {} }", + )); + assert!( + warnings + .iter() + .any(|i| i.severity == validation::Severity::Warning) + ); +} + +#[test] +fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() { + for (source, expected) in [ + ("arcadia 3; project mission \"x\" {}", "Unsupported schema"), + ( + "arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }", + "Unknown field", + ), + ( + "arcadia 2; project mission \"x\" { oa_activity a \"A\" { description \"A\"; description \"B\"; } }", + "Duplicate field", + ), + ( + "arcadia 2; project mission \"x\" { oa_activity a \"A\" { description unquoted; } }", + "quoted string", + ), + ( + "arcadia 2; project mission \"x\" { oa_activity a \"A\" { description @mission; } }", + "does not accept an @alias", + ), + ( + "arcadia 2; project mission \"x\" { oa_activity a \"A\" { parent @missing; } }", + "Unknown reference", + ), + ( + "arcadia 2; project mission \"x\" { oa_activity a \"A\" { parent \"00000000-0000-0000-0000-000000000000\"; } }", + "requires an @alias", + ), + ( + "arcadia 2; project mission \"x\" { sa_exchange_item item_x \"Item\" { kind Unicorn; } }", + "unknown variant", + ), + ( + "arcadia 2; project mission \"x\" { sa_exchange_item item_x \"Item\" { kind \"Information\"; } }", + "unquoted enum", + ), + ( + "arcadia 2; project mission \"x\" { sa_data_type t \"Type\" { definition { Enumeration { literals [\"One\"]; surprise true; }; }; } }", + "unknown field", + ), + ( + "arcadia 2; project mission \"x\" { oa_actor shared \"A\" {} oa_activity shared \"B\" {} }", + "Duplicate alias", + ), + ( + "arcadia 2; project mission \"x\" { oa_actor a \"A\" {} allocation x \"X\" { source @a; } }", + "Missing required field", + ), + ] { + rejected(source, expected); + } +} + +#[test] +fn deeply_nested_invalid_draft_returns_a_diagnostic_without_exhausting_the_stack() { + let source = format!( + "arcadia 2; project mission \"x\" {{ oa_activity a \"A\" {{ parent {}none{}; }} }}", + "[".repeat(5000), + "]".repeat(5000) + ); + rejected(&source, "nest more than 64 levels"); + let analysis = language::analyze(&source); + assert_eq!(analysis.declarations.len(), 2); + assert!(analysis.diagnostics[0].span.start < source.len()); +} + +#[test] +fn global_identity_and_alias_collisions_include_diagram_nodes_and_project() { + rejected( + r#"arcadia 2; project mission "x" id "00000000-0000-0000-0000-000000000001" { oa_activity a "A" id "00000000-0000-0000-0000-000000000001" {} }"#, + "Duplicate identity", + ); + rejected( + r#"arcadia 2; project mission "x" { diagram view_one "View" {} oa_activity a "A" {} node a { diagram @view_one; element @a; } }"#, + "Duplicate alias", + ); + rejected( + r#"arcadia 2; project mission "x" { oa_activity mission "A" {} }"#, + "Duplicate alias", + ); +} + +#[test] +fn typed_scenario_messages_values_and_reuse_round_trip_in_both_work_perspectives() { + for (prefix, work, exchange, viewpoint) in [ + ("oa", "activity", "interaction", "OperationalActivity"), + ("sa", "function", "functional_exchange", "Functional"), + ] { + let source = format!( + r#"arcadia 2; project messages "Typed exchanges and reuse" {{ + {prefix}_{work} send_work "Send request" {{}} + {prefix}_{work} receive_work "Handle request" {{}} + {prefix}_exchange_item payload "Request payload" {{}} + {prefix}_data_field payload_value "Requested depth" {{ parent @payload; type_ref Real; unit "m"; }} + {exchange} request_exchange "Request" {{ source @send_work; target @receive_work; items [@payload]; }} + {prefix}_scenario reusable_story "Reusable request" {{ viewpoint {viewpoint}; steps [@nested_message]; }} + {prefix}_lifeline nested_sender "Sender" {{ parent @reusable_story; represented @send_work; }} + {prefix}_lifeline nested_receiver "Receiver" {{ parent @reusable_story; represented @receive_work; }} + {prefix}_occurrence nested_message "Asynchronous request" {{ parent @reusable_story; body {{ Exchange {{ exchange @request_exchange; sender @nested_sender; receiver @nested_receiver; message_kind Asynchronous; reply_to none; values []; }}; }}; }} + {prefix}_scenario story "Request lifecycle" {{ viewpoint {viewpoint}; steps [@created, @called, @replied, @waited, @reused_story, @deleted]; }} + {prefix}_lifeline sender_line "Requester" {{ parent @story; represented @send_work; }} + {prefix}_lifeline receiver_line "Responder" {{ parent @story; represented @receive_work; }} + {prefix}_occurrence created "Create responder" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @sender_line; receiver @receiver_line; message_kind Create; reply_to none; values []; }}; }}; }} + {prefix}_occurrence called "Call responder" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @sender_line; receiver @receiver_line; message_kind Call; reply_to none; values [{{ field @payload_value; expression "requested_depth + 2"; }}]; }}; }}; }} + {prefix}_occurrence replied "Return response" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @receiver_line; receiver @sender_line; message_kind Reply; reply_to @called; values []; }}; }}; }} + {prefix}_occurrence waited "Wait for next request" {{ parent @story; body {{ Exchange {{ exchange none; sender @sender_line; receiver none; message_kind {{ Timer {{ expression "after 5 s"; }}; }}; reply_to none; values []; }}; }}; }} + {prefix}_occurrence reused_story "Reuse request" {{ parent @story; body {{ ScenarioUse {{ scenario @reusable_story; bindings [{{ referenced @nested_sender; local @sender_line; }}, {{ referenced @nested_receiver; local @receiver_line; }}]; }}; }}; }} + {prefix}_occurrence deleted "Delete responder" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @sender_line; receiver @receiver_line; message_kind Delete; reply_to none; values []; }}; }}; }} + }}"# + ); + let model = read(&source); + let canonical = language::format(&model); + for field in [ + "reply_to @called;", + "field @payload_value;", + "scenario @reusable_story;", + "referenced @nested_sender;", + "local @sender_line;", + ] { + assert!( + canonical.contains(field), + "missing readable reference {field}" + ); + } + assert_eq!(read(&canonical), model); + assert_eq!( + persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(), + model + ); + assert!( + language::parse(&canonical.replace("reply_to @called;", "reply_to @created;")).is_err() + ); + assert!( + language::parse(&canonical.replace("scenario @reusable_story;", "scenario @story;")) + .is_err() + ); + let foreign_viewpoint = if prefix == "oa" { + "Functional" + } else { + "OperationalActivity" + }; + assert!( + language::parse(&canonical.replace( + &format!("viewpoint {viewpoint};"), + &format!("viewpoint {foreign_viewpoint};") + )) + .is_err() + ); + } +} + +#[test] +fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { + use std::fmt::Write; + for (prefix, participant, work) in [("oa", "entity", "activity"), ("sa", "system", "function")] + { + let mut source = format!( + r#"arcadia 2; project recovery "Recovery — безопасно" {{ + {prefix}_{participant} owner "Owner" {{}} + {prefix}_{work} assess "Assess recovery" {{}} + {prefix}_exchange_item payload "Recovery report" {{}} + {prefix}_event report "Report received" {{ body {{ Signal {{ items [@payload]; }}; }}; }} + {prefix}_event deadline "Recovery deadline" {{ body {{ Time {{ kind After; expression "30 s"; }}; }}; }} + {prefix}_event scheduled "Planned handover" {{ body {{ Time {{ kind At; expression "2026-09-05T18:00:00Z"; }}; }}; }} + {prefix}_event changed "Pressure recovered" {{ body {{ Change {{ expression "pressure > 4 && status == \"Ready\""; }}; }}; }} + {prefix}_state_machine machine "Recovery machine" {{ parent @owner; }} + {prefix}_region region "Main region" {{ parent @machine; }} + {prefix}_initial initial "Initial" {{ parent @region; }} + {prefix}_state idle "Idle" {{ parent @region; }} + {prefix}_state active "Active" {{ parent @region; }} + {prefix}_region nested "Active region" {{ parent @active; }} + {prefix}_state recovering "Recovering" {{ parent @nested; }} + state_transition start "Start" {{ source @initial; target @idle; }} + state_transition recover "Recover" {{ source @idle; target @active; transition_kind External; triggers [@deadline, @assess]; effects [@assess]; trigger "Operator confirms\nНе запускать код"; guard "available && status == \"Ready\""; effect "Record the assessment"; }} + state_transition keep_active "Handle report" {{ source @active; target @active; transition_kind Internal; triggers [@report, @scheduled, @changed]; effects []; }} + state_transition enter_recovery "Enter recovery" {{ source @active; target @recovering; transition_kind Local; triggers []; effects []; }} + "# + ); + for stem in [ + "choice", + "junction", + "fork", + "join", + "shallow_history", + "deep_history", + "entry_point", + "exit_point", + "terminate", + ] { + writeln!( + source, + "{prefix}_{stem} vertex_{stem} \"{stem}\" {{ parent @region; }}" + ) + .unwrap(); + } + source.push('}'); + let model = read(&source); + let canonical = language::format(&model); + for fragment in [ + "transition_kind External;", + "transition_kind Local;", + "transition_kind Internal;", + "triggers [@deadline, @assess];", + "effects [@assess];", + "items [@payload];", + "kind After;", + "kind At;", + ] { + assert!(canonical.contains(fragment), "missing {fragment}"); + } + assert_eq!(read(&canonical), model); + assert_eq!( + persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(), + model + ); + let transition = model + .relations + .values() + .find(|r| r.alias == "recover") + .unwrap(); + let RelationBody::StateTransition { + trigger, + guard, + effect, + .. + } = &transition.body + else { + panic!("expected transition") + }; + assert_eq!(trigger, "Operator confirms\nНе запускать код"); + assert_eq!(guard, "available && status == \"Ready\""); + assert_eq!(effect, "Record the assessment"); + for invalid in [ + canonical.replace("transition_kind External;", "transition_kind Unsupported;"), + canonical.replace( + "triggers [@deadline, @assess];", + "triggers [\"deadline\", @assess];", + ), + canonical.replace( + "triggers [@deadline, @assess];", + "triggers [@deadline, @deadline];", + ), + canonical.replace("effects [@assess];", "effects [@payload];"), + canonical.replace("kind After;", "kind Relative;"), + canonical.replace("items [@payload];", "items [@assess];"), + ] { + assert!( + language::parse(&invalid).is_err(), + "invalid typed state/event source accepted" + ); + } + let legacy = canonical + .replace("transition_kind External;", "") + .replace("triggers [@deadline, @assess];", "") + .replace("effects [@assess];", ""); + let legacy = read(&legacy); + let RelationBody::StateTransition { + transition_kind, + triggers, + effects, + trigger: retained_trigger, + guard: retained_guard, + effect: retained_effect, + .. + } = &legacy.relations[&transition.id].body + else { + panic!("expected transition") + }; + assert_eq!(*transition_kind, TransitionKind::External); + assert!(triggers.is_empty() && effects.is_empty()); + assert_eq!( + (retained_trigger, retained_guard, retained_effect), + (trigger, guard, effect) + ); + let draft = canonical.replace("expression \"30 s\";", "expression \"\";"); + assert!(language::parse(&draft).is_ok()); + let deadline = model + .elements + .values() + .find(|e| e.alias == "deadline") + .unwrap() + .id; + let diagnostics = language::lint(&draft); + let warning = diagnostics + .iter() + .find(|d| d.code == "missing_event_expression") + .unwrap(); + assert_eq!(warning.severity, validation::Severity::Warning); + assert_eq!(warning.object_id, Some(deadline)); + assert!(warning.line > 0 && warning.column > 0); + } +} + +#[test] +fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensions() { + // Golden V2 files predate explicit viewpoints and message semantics. Do not + // regenerate these fixtures from the current demo or canonical printer. + let old_source = include_str!("fixtures/v2-coastal-rescue.arcadia"); + let old_json = include_bytes!("fixtures/v2-coastal-rescue.json"); + assert!(!old_source.contains("viewpoint")); + assert!(!old_source.contains("message_kind")); + assert!(!old_source.contains("transition_kind")); + let from_source = read(old_source); + let from_json = persistence::decode(old_json).unwrap(); + assert_eq!(from_source, from_json); + let original: serde_json::Value = serde_json::from_slice(old_json).unwrap(); + let mut retained = serde_json::to_value(&from_json).unwrap(); + for element in retained["elements"].as_object_mut().unwrap().values_mut() { + for layer in ["Oa", "Sa"] { + let Some(shared) = element["body"] + .get_mut(layer) + .and_then(|body| body.get_mut("Shared")) + else { + continue; + }; + if let Some(scenario) = shared + .get_mut("Scenario") + .and_then(serde_json::Value::as_object_mut) + { + scenario.remove("viewpoint"); + } + if let Some(exchange) = shared + .get_mut("Occurrence") + .and_then(|body| body.get_mut("body")) + .and_then(|body| body.get_mut("Exchange")) + .and_then(serde_json::Value::as_object_mut) + { + for added in ["message_kind", "reply_to", "values"] { + exchange.remove(added); + } + } + } + } + for relation in retained["relations"].as_object_mut().unwrap().values_mut() { + if let Some(transition) = relation["body"] + .get_mut("StateTransition") + .and_then(serde_json::Value::as_object_mut) + { + for added in ["transition_kind", "triggers", "effects"] { + transition.remove(added); + } + } + } + assert_eq!( + retained, original, + "every original V2 field survives; only explicit new compatibility defaults are added" + ); + assert_eq!(from_json.id.to_string(), original["id"].as_str().unwrap()); + assert_eq!( + from_json.elements.len(), + original["elements"].as_object().unwrap().len() + ); + assert_eq!( + from_json.relations.len(), + original["relations"].as_object().unwrap().len() + ); + assert_eq!( + serde_json::to_value(&from_json.diagrams).unwrap(), + original["diagrams"] + ); + assert_eq!( + serde_json::to_value(&from_json.traces).unwrap(), + original["traces"] + ); + assert_eq!( + serde_json::to_value(&from_json.artifact_links).unwrap(), + original["artifact_links"] + ); + let mut scenarios = 0; + for element in from_source.elements.values() { + if let Some(SharedBody::Scenario { viewpoint, .. }) = element.shared() { + scenarios += 1; + assert_eq!(*viewpoint, ScenarioViewpoint::MixedLegacy); + } + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind, + reply_to, + values, + .. + }, + .. + }) = element.shared() + { + assert_eq!(*message_kind, MessageKind::Asynchronous); + assert_eq!(*reply_to, None); + assert!(values.is_empty()); + } + let before = &original["elements"][element.id.to_string()]; + assert_eq!(element.alias, before["alias"].as_str().unwrap()); + assert_eq!(element.name, before["name"].as_str().unwrap()); + assert_eq!( + serde_json::to_value(element.parent).unwrap(), + before["parent"] + ); + } + assert!( + scenarios > 0, + "fixture must cover actual old scenario content" + ); + let canonical = language::format(&from_source); + assert!(canonical.contains("viewpoint MixedLegacy;")); + for relation in from_source.relations.values() { + if let RelationBody::StateTransition { + transition_kind, + triggers, + effects, + .. + } = &relation.body + { + assert_eq!(*transition_kind, TransitionKind::External); + assert!(triggers.is_empty() && effects.is_empty()); + } + } + assert_eq!(read(&canonical), from_source); +} + +#[test] +fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics() { + let text = include_str!("../../../examples/legacy-v1-coastal-rescue.arcadia"); + let json = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json"); + let from_text = read(text); + let from_json = persistence::decode(json).unwrap(); + assert_eq!(from_text, from_json); + assert_eq!(read(text), from_text); + assert_eq!(persistence::decode(json).unwrap(), from_json); + assert_eq!(from_text.schema_version, 2); + assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa)); + let raw: serde_json::Value = serde_json::from_slice(json).unwrap(); + for (id, record) in raw["elements"].as_object().unwrap() { + let id = Id::parse_str(id).unwrap(); + let e = &from_text.elements[&id]; + assert_eq!(e.name, record["name"].as_str().unwrap()); + assert_eq!(e.description, record["description"].as_str().unwrap()); + let n = from_text + .diagrams + .values() + .flat_map(|d| d.nodes.values()) + .find(|n| n.element == id) + .unwrap(); + assert_eq!( + serde_json::to_value(n.position).unwrap(), + record["position"] + ); + if record["kind"] == "Process" { + let steps = e.steps().unwrap(); + for (occurrence, exchange) in steps.iter().zip(record["steps"].as_array().unwrap()) { + assert!( + matches!(from_text.elements[occurrence].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(actual),..},..})if actual.to_string()==exchange.as_str().unwrap()) + ); + } + } + } + assert_eq!(read(&language::format(&from_text)), from_text); +} + +#[test] +fn v1_repeated_exchange_occurrences_and_authored_aliases_survive() { + let source = r#"arcadia 1; project "Legacy" id "00000000-0000-0000-0000-000000000001" { activity first_custom "A" id "00000000-0000-0000-0000-000000000002" {} activity second_custom "B" id "00000000-0000-0000-0000-000000000003" {} interaction report_custom "Report" id "00000000-0000-0000-0000-000000000004" from first_custom to second_custom; process story_custom "Story" id "00000000-0000-0000-0000-000000000005" { position (-0, 12.5); steps [report_custom, report_custom]; } }"#; + let p = read(source); + assert_eq!(p, read(source)); + let process = p + .elements + .values() + .find(|e| e.alias == "story_custom") + .unwrap(); + let steps = process.steps().unwrap(); + assert_ne!(steps[0], steps[1]); + assert_eq!( + p.elements[&steps[0]].shared(), + p.elements[&steps[1]].shared() + ); + assert!(p.relations.values().any(|r| r.alias == "report_custom")); + let node = p + .diagrams + .values() + .flat_map(|d| d.nodes.values()) + .find(|n| n.element == process.id) + .unwrap(); + assert_eq!(node.position.x.to_bits(), (-0.0_f32).to_bits()); +} + +#[test] +fn malformed_json_never_ignores_duplicate_maps_or_nested_unknown_fields() { + let p = complete_model(); + let json = serde_json::to_string(&p).unwrap(); + let record = &p.elements.values().next().unwrap(); + let encoded = serde_json::to_string(record).unwrap(); + let duplicate = json.replacen( + &std::format!("\"{}\":{encoded}", record.id), + &std::format!("\"{}\":{encoded},\"{}\":{encoded}", record.id, record.id), + 1, + ); + assert!(persistence::decode(duplicate.as_bytes()).is_err()); + let mut value = serde_json::to_value(&p).unwrap(); + let item = p + .elements + .values() + .find(|e| e.kind() == Kind::ExchangeItem && e.layer() == Layer::Sa) + .unwrap(); + value["elements"][item.id.to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["surprise"] = + true.into(); + assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err()); +} + +#[test] +fn coordinate_bits_and_permitted_strings_survive_text_and_storage() { + let mut p = complete_model(); + let values = [ + 0., + -0., + f32::from_bits(1), + -f32::from_bits(1), + -12.375, + 999_999.94, + -1_000_000., + ]; + for value in values { + let node = p + .diagrams + .values_mut() + .find_map(|d| d.nodes.values_mut().next()) + .unwrap(); + node.position = Position { x: value, y: value }; + let id = node.id; + let parsed = read(&language::format(&p)); + let node = parsed + .diagrams + .values() + .find_map(|d| d.nodes.get(&id)) + .unwrap(); + assert_eq!(node.position.x.to_bits(), value.to_bits()); + } + p.description = "Unicode é e\u{301} 海 🌊\n\r\t\\\" <>&".into(); + assert_eq!(read(&language::format(&p)), p); + let temp = tempfile::tempdir().unwrap(); + for name in ["model.arcadia", "model.json"] { + let path = temp.path().join(name); + persistence::save(&path, &p).unwrap(); + assert_eq!(persistence::load(&path).unwrap(), p); + } + let path = temp.path().join("model.json"); + let before = std::fs::read(&path).unwrap(); + p.description.push('\0'); + assert!(persistence::save(&path, &p).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn tolerant_analysis_covers_unicode_and_returns_declarations_before_failure() { + use language::TokenClass; + for source in [ + "", + "// 検査 🌊\narcadia 2;", + "oa_activity assess \"unfinished 🌊", + "parent @coordination; guard \"x\\q\";", + "}\u{000c}☃\t-2.5e-3 [none, true, false]", + ] { + let mut end = 0; + for token in language::tokenize(source) { + assert_eq!(token.span.start, end); + assert!(token.span.end > token.span.start); + assert!(source.is_char_boundary(token.span.start)); + assert!(source.is_char_boundary(token.span.end)); + end = token.span.end; + } + assert_eq!(end, source.len()); + } + assert_eq!(language::tokenize("// 🌊")[0].class, TokenClass::Comment); + let source = "arcadia 2;\nproject mission \"測試 🌊\" {\n oa_activity work \"Work\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_activity bad \"Bad\" { parent @unknown; }\n}"; + let analysis = language::analyze(source); + assert!( + analysis + .declarations + .iter() + .any(|d| d.alias == "work" && d.id == Some(Id::from_u128(1))) + ); + let error = &analysis.diagnostics[0]; + let offset = source.find("@unknown").unwrap(); + assert_eq!(error.span.start, offset); + assert_eq!(error.line, 4); + assert_eq!( + error.column, + source[..offset] + .rsplit('\n') + .next() + .unwrap() + .chars() + .count() + + 1 + ); +} + +#[test] +fn text_and_model_transactions_preserve_history_with_stable_aliases() { + let mut session = Session::new(complete_model()); + let original = session.project().clone(); + let edited = language::format(&original).replace("Harbor safety — 港 🌊", "Updated mission"); + session.execute(Command::Replace(read(&edited))).unwrap(); + assert!(session.dirty()); + assert_eq!(session.project().name, "Updated mission"); + session.undo(); + assert_eq!(session.project(), &original); + assert!(!session.dirty()); + session.redo(); + assert_eq!(session.project().name, "Updated mission"); +} + +#[test] +fn complete_normative_examples_are_accepted_by_the_real_parser() { + let spec = include_str!("../../../docs/language.rst"); + let mut examples = 0; + for section in spec.split(".. code-block:: text").skip(1) { + let mut lines = section.lines().skip_while(|line| line.trim().is_empty()); + let mut code = String::new(); + for line in lines.by_ref() { + if let Some(indented) = line.strip_prefix(" ") { + code.push_str(indented); + code.push('\n'); + } else if line.is_empty() { + code.push('\n'); + } else { + break; + } + } + if code.trim_start().starts_with("arcadia 2;") { + read(&code); + examples += 1; + } + } + assert!( + examples > 0, + "The normative specification must include a complete authoring example." + ); +} diff --git a/crates/oa-core/tests/process_diagnostics.rs b/crates/oa-core/tests/process_diagnostics.rs new file mode 100644 index 00000000..afd06471 --- /dev/null +++ b/crates/oa-core/tests/process_diagnostics.rs @@ -0,0 +1,37 @@ +use oa_core::{ + validation::{Severity, validate}, + *, +}; +#[test] +fn sequence_gaps_name_occurrences_exchanges_and_endpoints() { + let mut p = Project::demo(); + let first = p + .relations + .values() + .find(|r| r.kind() == RelationKind::Interaction) + .unwrap() + .clone(); + let process = p + .elements + .values() + .find(|e| e.kind() == Kind::Process) + .unwrap() + .id; + let other = p.add(Kind::Activity, "Unrelated work", 0., 0.); + let reply = p.relate( + RelationKind::Interaction, + "Unrelated message", + other, + first.source(), + ); + let p = execute_batch(&p, &[Command::AddStep(process, reply)]).unwrap(); + let issue = validate(&p) + .into_iter() + .find(|i| i.code == "sequence_gap") + .unwrap(); + assert_eq!(issue.severity, Severity::Warning); + assert_eq!(issue.element, Some(process)); + assert!(issue.message.contains("steps 1 and 2")); + assert!(issue.message.contains("Distress signal")); + assert!(issue.message.contains("Unrelated message")); +} diff --git a/crates/oa-core/tests/release_capabilities.rs b/crates/oa-core/tests/release_capabilities.rs new file mode 100644 index 00000000..d22101ab --- /dev/null +++ b/crates/oa-core/tests/release_capabilities.rs @@ -0,0 +1,514 @@ +use oa_core::{ + projection, references, + transitions::*, + validation::{self, Severity}, + *, +}; + +fn kinds(layer: Layer) -> (Kind, Kind, Kind) { + match layer { + Layer::Oa => (Kind::Actor, Kind::OperationalMission, Kind::Capability), + Layer::Sa => (Kind::SystemActor, Kind::Mission, Kind::SystemCapability), + } +} + +fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id { + p.add_element(kind, layer, name, None).unwrap() +} + +fn transition(p: &Project, request: &TransitionRequest) -> (Project, TransitionPlan) { + let plan = plan_transition(p, request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + (apply_transition(p, &plan).unwrap(), plan) +} + +#[test] +fn capability_and_mission_links_enforce_direction_family_and_layer() { + for layer in [Layer::Oa, Layer::Sa] { + let mut p = Project::empty(); + let (participant_kind, mission_kind, capability_kind) = kinds(layer); + let participant = add(&mut p, participant_kind, layer, "Mission participant"); + let mission = add(&mut p, mission_kind, layer, "Protect lives"); + let capability = add(&mut p, capability_kind, layer, "Rescue"); + let other = add(&mut p, capability_kind, layer, "Locate distress"); + let opposite = if layer == Layer::Oa { + Layer::Sa + } else { + Layer::Oa + }; + let cross_layer = add(&mut p, kinds(opposite).2, opposite, "Remote capability"); + for (kind, source, target) in [ + (RelationKind::MissionInvolvement, participant, mission), + (RelationKind::MissionCapability, mission, capability), + (RelationKind::CapabilityInclude, capability, other), + (RelationKind::CapabilityExtend, capability, other), + (RelationKind::CapabilityGeneralization, capability, other), + ] { + assert!(validation::can_connect(&p, kind, source, target)); + let created = + execute_batch(&p, &[Command::Connect(kind, "Link".into(), source, target)]) + .unwrap(); + let relation = created.relations.values().next().unwrap(); + assert_eq!(relation.body.endpoints(), (source, target)); + for invalid_target in [source, cross_layer] { + assert!(!validation::can_connect(&p, kind, source, invalid_target)); + assert!( + execute_batch( + &p, + &[Command::Connect( + kind, + "Invalid".into(), + source, + invalid_target + )] + ) + .is_err() + ); + } + } + for (kind, source, target) in [ + (RelationKind::MissionInvolvement, mission, participant), + (RelationKind::MissionInvolvement, capability, mission), + (RelationKind::MissionCapability, capability, mission), + (RelationKind::CapabilityInclude, mission, capability), + (RelationKind::CapabilityExtend, participant, capability), + (RelationKind::CapabilityGeneralization, mission, mission), + ] { + assert!(!validation::can_connect(&p, kind, source, target)); + } + } +} + +#[test] +fn inclusion_and_specialization_reject_transitive_cycles_atomically() { + for layer in [Layer::Oa, Layer::Sa] { + for (kind, code) in [ + (RelationKind::CapabilityInclude, "capability_include_cycle"), + ( + RelationKind::CapabilityGeneralization, + "capability_generalization_cycle", + ), + ] { + let mut p = Project::empty(); + let ids: Vec<_> = ["A", "B", "C"] + .into_iter() + .map(|name| add(&mut p, kinds(layer).2, layer, name)) + .collect(); + p.relate(kind, "A to B", ids[0], ids[1]); + p.relate(kind, "B to C", ids[1], ids[2]); + let mut session = Session::new(p.clone()); + session + .execute(Command::Rename(ids[0], "Undoable draft".into())) + .unwrap(); + session.undo(); + let revision = session.revision(); + assert!(!validation::can_connect(&p, kind, ids[2], ids[0])); + let error = session + .execute_many(&[ + Command::Rename(ids[0], "Must roll back".into()), + Command::Connect(kind, "Would close cycle".into(), ids[2], ids[0]), + ]) + .unwrap_err(); + assert!(error.0.contains(code), "{error}"); + assert_eq!(session.project(), &p); + assert_eq!(session.revision(), revision); + assert!(session.can_redo()); + // Even an imported or edited complete graph is checked, not just the connector UI. + let closing = p.relate(kind, "Cycle", ids[2], ids[0]); + assert!( + validation::validate(&p) + .iter() + .any(|issue| issue.code == code && issue.element == Some(closing)) + ); + } + } +} + +#[test] +fn conditional_extension_remains_editable_and_condition_is_validated() { + let mut p = Project::empty(); + let base = add(&mut p, Kind::Capability, Layer::Oa, "Rescue"); + let extending = add(&mut p, Kind::Capability, Layer::Oa, "Night rescue"); + let mut session = Session::new(p); + let link = session + .execute(Command::Connect( + RelationKind::CapabilityExtend, + "Night assistance".into(), + extending, + base, + )) + .unwrap() + .unwrap(); + assert!( + validation::validate(session.project()) + .iter() + .any(|issue| issue.element == Some(link) + && issue.code == "missing_extension_condition" + && issue.severity == Severity::Warning) + ); + let mut edited = session.project().relations[&link].clone(); + if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body { + *condition = "daylight == false && visibility < 500".into(); + } + session + .execute(Command::EditRelation(link, edited.clone())) + .unwrap(); + assert!( + !validation::validate(session.project()) + .iter() + .any(|issue| issue.code == "missing_extension_condition") + ); + let accepted = session.project().clone(); + if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body { + *condition = "invalid\u{0000}condition".into(); + } + assert!( + session + .execute(Command::EditRelation(link, edited)) + .is_err() + ); + assert_eq!(session.project(), &accepted); + session.undo(); + assert!( + validation::validate(session.project()) + .iter() + .any(|issue| issue.code == "missing_extension_condition") + ); + session.redo(); + assert_eq!(session.project(), &accepted); +} + +#[test] +fn operational_missions_have_typed_bidirectional_counterparts_and_legacy_choices() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let mut p = Project::empty(); + let source = add( + &mut p, + kinds(direction.source_layer()).1, + direction.source_layer(), + "Protect lives", + ); + let request = TransitionRequest::new(direction, vec![source]); + let (candidate, plan) = transition(&p, &request); + let target = plan.changes[0].target; + assert_eq!( + candidate.elements[&target].kind(), + kinds(direction.target_layer()).1 + ); + let trace = candidate.traces.values().next().unwrap(); + assert_eq!(trace.kind, TraceKind::Mission); + assert_eq!( + candidate.elements[&trace.oa].kind(), + Kind::OperationalMission + ); + assert_eq!(candidate.elements[&trace.sa].kind(), Kind::Mission); + assert!(validation::trace_permits(&candidate, trace)); + assert!( + plan_transition(&candidate, &request) + .unwrap() + .commands + .is_empty() + ); + let mut session = Session::new(p.clone()); + session.execute_many(&plan.commands).unwrap(); + session.undo(); + assert_eq!(session.project(), &p); + session.redo(); + assert_eq!(session.project(), &candidate); + } + assert_eq!( + counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa), + vec![Kind::OperationalMission, Kind::Capability] + ); + assert_eq!( + counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa), + vec![Kind::SystemCapability, Kind::Mission] + ); + // A previously intentional capability↔mission relation remains a manual trace and is reused. + let mut p = Project::empty(); + let capability = add(&mut p, Kind::Capability, Layer::Oa, "User-level goal"); + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![capability]); + request.mappings.push(TransitionMapping { + source: capability, + target: None, + target_kind: Some(Kind::Mission), + }); + let (candidate, plan) = transition(&p, &request); + let mission = plan.changes[0].target; + assert_eq!( + candidate.traces.values().next().unwrap().kind, + TraceKind::Manual + ); + let reverse = plan_transition( + &candidate, + &TransitionRequest::new(TransitionDirection::SaToOa, vec![mission]), + ) + .unwrap(); + assert!(reverse.conflicts.is_empty()); + assert_eq!(reverse.changes[0].target, capability); + assert!(reverse.commands.is_empty()); +} + +fn mission_map(layer: Layer) -> (Project, Vec) { + let mut p = Project::empty(); + let (actor_kind, mission_kind, capability_kind) = kinds(layer); + let actor = add(&mut p, actor_kind, layer, "Rescue coordinator"); + let mission = add(&mut p, mission_kind, layer, "Protect lives"); + let rescue = add(&mut p, capability_kind, layer, "Rescue"); + let locate = add(&mut p, capability_kind, layer, "Locate distress"); + let night = add(&mut p, capability_kind, layer, "Night rescue"); + let mut relations = vec![ + p.relate( + RelationKind::MissionInvolvement, + "Participates", + actor, + mission, + ), + p.relate( + RelationKind::MissionCapability, + "Delivered by", + mission, + rescue, + ), + p.relate( + RelationKind::CapabilityInclude, + "Always locate", + rescue, + locate, + ), + p.relate( + RelationKind::CapabilityGeneralization, + "Specializes rescue", + night, + rescue, + ), + ]; + let extension = p.relate( + RelationKind::CapabilityExtend, + "Night assistance", + night, + rescue, + ); + if let RelationBody::CapabilityExtend { condition, .. } = + &mut p.relations.get_mut(&extension).unwrap().body + { + *condition = "light_level < 10".into(); + } + relations.push(extension); + (p, relations) +} + +#[test] +fn mission_map_transition_preserves_all_relationship_endpoints_and_conditions_both_ways() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let (p, relations) = mission_map(direction.source_layer()); + let mut request = TransitionRequest::new(direction, relations.clone()); + request.scope = DependencyScope::Required; + let actor = p + .elements + .values() + .find(|e| e.kind().is_performer()) + .unwrap() + .id; + request.mappings.push(TransitionMapping { + source: actor, + target: None, + target_kind: Some(kinds(direction.target_layer()).0), + }); + let (candidate, plan) = transition(&p, &request); + let mapped = |source| { + plan.changes + .iter() + .find(|change| change.source == source) + .unwrap() + .target + }; + for id in &relations { + let original = &p.relations[id]; + let counterpart = &candidate.relations[&mapped(*id)]; + assert_eq!(counterpart.kind(), original.kind()); + assert_eq!( + counterpart.body.endpoints(), + (mapped(original.source()), mapped(original.target())) + ); + if let RelationBody::CapabilityExtend { condition, .. } = &counterpart.body { + assert_eq!(condition, "light_level < 10"); + } + assert_eq!( + references::relation_references(counterpart), + vec![counterpart.source(), counterpart.target()] + ); + } + assert!( + candidate.elements.values().all(|e| e.parent.is_none()), + "capability relations do not create containment" + ); + assert!( + candidate + .traces + .values() + .any(|t| t.kind == TraceKind::Mission) + ); + let mut reverse = TransitionRequest::new( + if direction == TransitionDirection::OaToSa { + TransitionDirection::SaToOa + } else { + TransitionDirection::OaToSa + }, + relations.iter().map(|id| mapped(*id)).collect(), + ); + reverse.scope = DependencyScope::Required; + let repeated = plan_transition(&candidate, &reverse).unwrap(); + assert!(repeated.conflicts.is_empty(), "{:?}", repeated.conflicts); + assert!(repeated.commands.is_empty()); + } +} + +#[test] +fn mission_reidentification_and_deletion_preserve_reference_integrity_and_undo() { + let (mut p, relations) = mission_map(Layer::Oa); + let mission = p + .elements + .values() + .find(|e| e.kind() == Kind::OperationalMission) + .unwrap() + .id; + let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Mission demonstration"); + if let Some(SharedBody::Scenario { context, .. }) = + p.elements.get_mut(&scenario).unwrap().shared_mut() + { + *context = Some(mission); + } + let diagram = p.ensure_diagram(Layer::Oa); + let node = p.place(diagram, mission, Position { x: 10., y: 20. }); + let evidence = Id::new_v4(); + p.artifact_links.insert( + evidence, + ArtifactLink { + id: evidence, + alias: "mission_evidence".into(), + name: "Mission evidence".into(), + description: String::new(), + owner: mission, + relation: ArtifactRelation::Verifies, + uri: "git:reviews/mission.md".into(), + revision: Some("review-1".into()), + fragment: None, + }, + ); + let (p, plan) = transition( + &p, + &TransitionRequest::new(TransitionDirection::OaToSa, vec![mission]), + ); + let sa = plan.changes[0].target; + let mut session = Session::new(p.clone()); + let renamed_id = Id::new_v4(); + session + .execute(Command::Reidentify(mission, renamed_id)) + .unwrap(); + let updated = session.project(); + assert_eq!(updated.relations[&relations[0]].target(), renamed_id); + assert_eq!(updated.relations[&relations[1]].source(), renamed_id); + assert!(references::element_references(&updated.elements[&scenario]).contains(&renamed_id)); + assert_eq!(updated.diagrams[&diagram].nodes[&node].element, renamed_id); + assert_eq!(updated.artifact_links[&evidence].owner, renamed_id); + assert_eq!(updated.traces.values().next().unwrap().oa, renamed_id); + let reidentified = updated.clone(); + session.execute(Command::Delete(renamed_id)).unwrap(); + let deleted = session.project(); + assert!( + deleted.elements.contains_key(&sa), + "deletion never cascades to the opposite-layer counterpart" + ); + assert!(deleted.elements.contains_key(&scenario)); + assert!(references::element_references(&deleted.elements[&scenario]).is_empty()); + assert!(!deleted.relations.contains_key(&relations[0])); + assert!(!deleted.relations.contains_key(&relations[1])); + assert!(deleted.relations.contains_key(&relations[2])); + assert!(deleted.traces.is_empty()); + assert!(!deleted.artifact_links.contains_key(&evidence)); + assert!(!deleted.diagrams[&diagram].nodes.contains_key(&node)); + session.undo(); + assert_eq!(session.project(), &reidentified); + session.undo(); + assert_eq!(session.project(), &p); + // The same identity visitor covers incoming/outgoing include, extend, + // generalization, and mission-capability endpoints. + let capability = p.relations[&relations[1]].target(); + let updated_capability = Id::new_v4(); + session + .execute(Command::Reidentify(capability, updated_capability)) + .unwrap(); + for original in p.relations.values() { + let (source, target) = original.body.endpoints(); + let map = |id| { + if id == capability { + updated_capability + } else { + id + } + }; + assert_eq!( + session.project().relations[&original.id].body.endpoints(), + (map(source), map(target)) + ); + } + let updated = session.project().clone(); + session + .execute(Command::Delete(updated_capability)) + .unwrap(); + assert!(session.project().relations.values().all(|relation| { + !references::relation_references(relation).contains(&updated_capability) + })); + assert!(session.project().elements.contains_key(&mission)); + assert!(session.project().relations.contains_key(&relations[0])); + session.undo(); + assert_eq!(session.project(), &updated); +} + +#[test] +fn capability_semantics_remain_edges_independent_of_visual_containment() { + let mut p = Project::empty(); + let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group"); + let child = p + .add_element( + Kind::Capability, + Layer::Oa, + "Specific capability", + Some(parent), + ) + .unwrap(); + let include = p.relate( + RelationKind::CapabilityInclude, + "Includes group behavior", + child, + parent, + ); + let generalization = p.relate( + RelationKind::CapabilityGeneralization, + "Separate specialization", + parent, + child, + ); + validate_candidate(&p).unwrap(); + let diagram = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition; + let parent_node = p.place(diagram, parent, Position { x: 0., y: 0. }); + let child_node = p.place(diagram, child, Position { x: 25., y: 75. }); + let view = projection::project_view(&p, diagram); + assert_eq!( + view.nodes + .iter() + .find(|n| n.id == child_node) + .unwrap() + .visual_parent, + Some(parent_node) + ); + for (id, source, target) in [ + (include, child_node, parent_node), + (generalization, parent_node, child_node), + ] { + let edge = view.edges.iter().find(|edge| edge.id == id).unwrap(); + assert_eq!((edge.source, edge.target), (source, target)); + } +} diff --git a/crates/oa-core/tests/release_diagrams.rs b/crates/oa-core/tests/release_diagrams.rs new file mode 100644 index 00000000..c1525e0e --- /dev/null +++ b/crates/oa-core/tests/release_diagrams.rs @@ -0,0 +1,177 @@ +use oa_core::{projection::*, *}; + +#[test] +fn architecture_is_nested_exchange_context_and_ports_attach_without_placements() { + let p = Project::demo(); + let d = p + .diagrams + .values() + .find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation) + .unwrap(); + let view = project_view(&p, d.id); + assert!(!view.nodes.is_empty()); + assert!(view.nodes.iter().all(|n| { + let kind = p.elements[&n.element].kind(); + kind.is_performer() || kind.is_work() + })); + for n in view + .nodes + .iter() + .filter(|n| p.elements[&n.element].kind().is_work()) + { + let parent = view + .nodes + .iter() + .find(|parent| Some(parent.id) == n.visual_parent) + .unwrap(); + assert!(p.elements[&parent.element].kind().is_performer()); + assert!(n.position.x > parent.position.x && n.position.y > parent.position.y); + } + assert_eq!(view.ports.len(), 4); + assert!( + d.nodes + .values() + .all(|n| p.elements[&n.element].kind() != Kind::Port) + ); + assert!( + view.edges + .iter() + .any(|e| p.relations[&e.id].kind() == RelationKind::BoundaryExchange) + ); + for edge in &view.edges { + assert!(matches!( + p.relations[&edge.id].kind(), + RelationKind::FunctionalExchange | RelationKind::BoundaryExchange + )); + for (id, owner) in [ + (edge.source_port, edge.source), + (edge.target_port, edge.target), + ] { + let port = view.ports.iter().find(|p| Some(p.element) == id).unwrap(); + assert_eq!(port.owner, owner); + let node = view.nodes.iter().find(|n| n.id == owner).unwrap(); + assert!( + port.position.x == node.position.x + || port.position.x == node.position.x + node.size.width + ); + } + } +} + +#[test] +fn port_identity_collapse_and_group_movement_do_not_invent_semantic_edges() { + let mut p = Project::demo(); + let diagram = p + .diagrams + .values() + .find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation) + .unwrap() + .id; + let view = project_view(&p, diagram); + let system = view + .nodes + .iter() + .find(|n| p.elements[&n.element].kind() == Kind::System) + .unwrap(); + let node = system.id; + let before = p.clone(); + let moved = execute_batch( + &p, + &[Command::MoveNode { + diagram, + node, + position: Position { + x: p.diagrams[&diagram].nodes[&node].position.x + 140., + y: p.diagrams[&diagram].nodes[&node].position.y + 80., + }, + with_descendants: true, + }], + ) + .unwrap(); + let after = project_view(&moved, diagram); + for port in view.ports.iter().filter(|port| { + port.owner == node + || view + .nodes + .iter() + .any(|n| n.id == port.owner && n.visual_parent == Some(node)) + }) { + let shifted = after + .ports + .iter() + .find(|p| p.element == port.element) + .unwrap(); + assert_eq!(shifted.position.x, port.position.x + 140.); + assert_eq!(shifted.position.y, port.position.y + 80.); + } + assert_eq!(moved.elements, before.elements); + assert_eq!(moved.relations, before.relations); + p.diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .collapsed = true; + let collapsed = project_view(&p, diagram); + assert!(collapsed.ports.iter().all(|port| { + p.elements[&port.element].parent + != Some( + view.nodes + .iter() + .find(|n| n.visual_parent == Some(node)) + .unwrap() + .element, + ) + })); + let exchange = collapsed + .edges + .iter() + .find(|e| p.relations[&e.id].kind() == RelationKind::FunctionalExchange) + .unwrap(); + assert_eq!(exchange.target, node); + assert_eq!( + exchange.target_port, None, + "A collapsed link must not invent a system port" + ); + assert_eq!(p.relations, before.relations); +} + +#[test] +fn view_filtering_keeps_placements_and_source_and_separates_capability_content() { + let mut p = Project::demo(); + let diagram = p + .diagrams + .values() + .find(|d| d.layer == Layer::Oa && d.projection == ProjectionKind::Allocation) + .unwrap() + .id; + let cap = p + .elements + .values() + .find(|e| e.kind() == Kind::Capability) + .unwrap() + .id; + p.place(diagram, cap, Position { x: 600., y: 0. }); + let original = p.clone(); + assert!( + !project_view(&p, diagram) + .nodes + .iter() + .any(|n| n.element == cap) + ); + p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Capabilities; + let view = project_view(&p, diagram); + assert!(view.nodes.iter().any(|n| n.element == cap)); + assert!( + !view + .nodes + .iter() + .any(|n| p.elements[&n.element].kind().is_work()) + ); + assert_eq!( + p.diagrams[&diagram].nodes, + original.diagrams[&diagram].nodes + ); + assert_eq!(language::parse(&language::format(&p)).unwrap(), p); +} diff --git a/crates/oa-core/tests/release_language_limits.rs b/crates/oa-core/tests/release_language_limits.rs new file mode 100644 index 00000000..334500fb --- /dev/null +++ b/crates/oa-core/tests/release_language_limits.rs @@ -0,0 +1,139 @@ +use oa_core::{ + language::{self, ParseLimits}, + persistence, + validation::Severity, + *, +}; + +fn limits(graph: usize, records: usize, steps: usize) -> ParseLimits { + ParseLimits { + max_source_bytes: 8 * 1024 * 1024, + max_graph_objects: graph, + max_records: records, + max_step_references: steps, + } +} + +#[test] +fn lint_reports_located_engineering_warnings_without_rejecting_valid_drafts() { + let source = "arcadia 2; project example \"航海\" {\n sa_function assess \"Assess\" {}\n}"; + assert!(language::parse(source).is_ok()); + let diagnostics = language::lint(source); + let missing = diagnostics + .iter() + .find(|d| d.code == "missing_system") + .unwrap(); + assert_eq!(missing.severity, Severity::Warning); + let work = diagnostics + .iter() + .find(|d| d.code == "unallocated_leaf") + .unwrap(); + assert_eq!(work.severity, Severity::Warning); + assert_eq!((work.line, work.column), (2, 5)); + assert_eq!( + &source[work.span.start..work.span.start + 11], + "sa_function" + ); + assert!(work.object_id.is_some()); + assert_eq!(work.field.as_deref(), Some("allocation")); + let invalid = "arcadia 2; project example \"航海\" { oa_activity a \"A\" { parent @a; } }"; + let rejected = language::parse(invalid).unwrap_err(); + assert!(rejected.iter().all(|d| d.severity == Severity::Error)); + let parent = rejected + .iter() + .find(|d| d.code == "invalid_parent") + .unwrap(); + assert_eq!(parent.span.start, invalid.find("@a").unwrap()); + assert_eq!( + parent.column, + invalid[..parent.span.start].chars().count() + 1 + ); +} + +#[test] +fn declaration_limits_precede_reference_resolution_and_structural_validation() { + let mut source = String::from("arcadia 2; project example \"Oversized\" {\n"); + for i in 0..12_000 { + source.push_str(&format!("oa_activity a{i} \"A\" {{ parent @missing; }}\n")); + } + source.push('}'); + let rejected = language::parse_limited(&source, limits(5_000, 20_000, 50_000)).unwrap_err(); + assert_eq!(rejected[0].code, "model_limit"); + assert_eq!(rejected[0].line, 5_002); + assert!(rejected[0].span.start < source.len() / 2); + assert!(matches!( + persistence::decode_limited(source.as_bytes(), limits(5_000, 20_000, 50_000)), + Err(persistence::FileError::Limit(_)) + )); + let rejected = language::parse_limited( + &source, + ParseLimits { + max_source_bytes: 20, + ..ParseLimits::UNLIMITED + }, + ) + .unwrap_err(); + assert_eq!(rejected[0].code, "source_limit"); +} + +#[test] +fn json_record_and_step_limits_precede_model_errors_and_preserve_the_candidate() { + let mut project = Project::empty(); + // Invalid parents would fail validation; admission must reject the count first. + for i in 1..=6u128 { + let id = Id::from_u128(i); + project.elements.insert( + id, + Element { + id, + alias: format!("a{i}"), + name: "A".into(), + description: String::new(), + parent: Some(Id::nil()), + body: ElementBody::Oa(OaBody::Activity), + }, + ); + } + let bytes = serde_json::to_vec(&project).unwrap(); + assert!(matches!( + persistence::decode_limited(&bytes, limits(5, 100, 100)), + Err(persistence::FileError::Limit(_)) + )); + let demo = Project::demo(); + let count = demo.identities().len(); + assert!(limits(1_000, count, 50_000).check_model(&demo).is_ok()); + assert!(limits(1_000, count - 1, 50_000).check_model(&demo).is_err()); + assert!( + language::parse_limited(&language::format(&demo), limits(1_000, count - 1, 50_000)) + .is_err() + ); + let source = "arcadia 2; project example \"Steps\" { oa_scenario story \"Story\" { steps [@story, @story]; } }"; + assert_eq!( + language::parse_limited(source, limits(100, 100, 1)).unwrap_err()[0].code, + "model_limit" + ); +} + +#[test] +fn legacy_budgets_include_generated_occurrences_and_placements_before_migration() { + let source = r#"arcadia 1; project "Legacy" { activity a "A" {} activity b "B" {} interaction report "Report" from a to b; process story "Story" { steps [report, report]; } }"#; + // Four original graph records become six after occurrence creation. + assert_eq!( + language::parse_limited(source, limits(5, 100, 100)).unwrap_err()[0].code, + "model_limit" + ); + let accepted = language::parse_limited(source, limits(6, 11, 2)).unwrap(); + assert_eq!(accepted.identities().len(), 11); + assert!(language::parse_limited(source, limits(6, 10, 2)).is_err()); + let old = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json"); + assert!(matches!( + persistence::decode_limited(old, limits(1, 2, 1)), + Err(persistence::FileError::Limit(_)) + )); + let original = persistence::decode(old).unwrap(); + let native = serde_json::to_vec(&original).unwrap(); + assert_eq!( + persistence::decode_limited(&native, limits(5000, 20000, 50000)).unwrap(), + original + ); +} diff --git a/crates/oa-core/tests/release_scenarios.rs b/crates/oa-core/tests/release_scenarios.rs new file mode 100644 index 00000000..01b142f9 --- /dev/null +++ b/crates/oa-core/tests/release_scenarios.rs @@ -0,0 +1,1334 @@ +use oa_core::{ + references, + transitions::*, + validation::{self, Severity}, + *, +}; + +fn add_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 line(p: &mut Project, scenario: Id, represented: Id, name: &str) -> Id { + let id = p + .add_element( + Kind::Lifeline, + p.elements[&scenario].layer(), + name, + Some(scenario), + ) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(represented), + }; + id +} +fn message( + exchange: Option, + sender: Id, + receiver: Option, + kind: MessageKind, + call: Option, +) -> OccurrenceBody { + OccurrenceBody::Exchange { + exchange, + sender: Some(sender), + receiver, + message_kind: kind, + reply_to: call, + values: vec![], + } +} +struct Fixture { + p: Project, + scenario: Id, + a: Id, + b: Id, + call: Id, + reply: Id, + exchange: Id, + field: Id, +} +fn fixture(layer: Layer) -> Fixture { + let mut p = Project::empty(); + let work_kind = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let client = p + .add_element(work_kind, layer, "Request service", None) + .unwrap(); + let server = p + .add_element(work_kind, layer, "Provide service", None) + .unwrap(); + let scenario = p + .add_element(Kind::Scenario, layer, "Service conversation", None) + .unwrap(); + let a = line(&mut p, scenario, client, "Client"); + let b = line(&mut p, scenario, server, "Provider"); + let item = p + .add_element(Kind::ExchangeItem, layer, "Service request", None) + .unwrap(); + let record = p + .add_element(Kind::DataType, layer, "Request record", None) + .unwrap(); + *p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record, + }; + let field = p + .add_element(Kind::DataField, layer, "Requested level", Some(record)) + .unwrap(); + let payload = p + .add_element(Kind::DataField, layer, "Payload", Some(item)) + .unwrap(); + if let Some(SharedBody::DataField { type_ref, .. }) = + p.elements.get_mut(&payload).unwrap().shared_mut() + { + *type_ref = TypeRef::Named(record); + } + let exchange = p.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + "Service invocation", + client, + server, + ); + match &mut p.relations.get_mut(&exchange).unwrap().body { + RelationBody::Interaction { items, .. } + | RelationBody::FunctionalExchange { items, .. } => items.push(item), + _ => unreachable!(), + } + let call = add_occurrence( + &mut p, + scenario, + "Request service", + message(Some(exchange), a, Some(b), MessageKind::Call, None), + ); + let reply = add_occurrence( + &mut p, + scenario, + "Return result", + message(Some(exchange), b, Some(a), MessageKind::Reply, Some(call)), + ); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = p.elements.get_mut(&call).unwrap().shared_mut() + { + values.push(MessageValue { + field, + expression: "requested_level".into(), + }); + } + Fixture { + p, + scenario, + a, + b, + call, + reply, + exchange, + field, + } +} +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 edit_message(p: &Project, id: Id, edit: impl FnOnce(&mut OccurrenceBody)) -> Element { + let mut element = p.elements[&id].clone(); + if let Some(SharedBody::Occurrence { body, .. }) = element.shared_mut() { + edit(body); + } + element +} + +#[test] +fn calls_and_replies_have_real_direction_correlation_and_order() { + for layer in [Layer::Oa, Layer::Sa] { + let Fixture { + p, + scenario, + a, + b, + call, + reply, + exchange, + .. + } = fixture(layer); + valid(&p); + let mut session = Session::new(p.clone()); + assert!( + session + .execute(Command::MoveStep(scenario, 1, 0)) + .unwrap_err() + .0 + .contains("reply_order") + ); + assert_eq!(session.project(), &p); + for edit in [ + edit_message(&p, reply, |body| { + if let OccurrenceBody::Exchange { + sender, receiver, .. + } = body + { + *sender = Some(a); + *receiver = Some(b); + } + }), + edit_message(&p, reply, |body| { + if let OccurrenceBody::Exchange { reply_to, .. } = body { + *reply_to = Some(reply); + } + }), + ] { + assert!(session.execute(Command::EditElement(reply, edit)).is_err()); + assert_eq!(session.project(), &p); + } + let mut duplicate = p.clone(); + add_occurrence( + &mut duplicate, + scenario, + "Second return", + message(Some(exchange), b, Some(a), MessageKind::Reply, Some(call)), + ); + assert!( + validate_candidate(&duplicate) + .unwrap_err() + .0 + .contains("duplicate_reply") + ); + let mut reverse = p.clone(); + let original = &p.relations[&exchange]; + let reverse_exchange = reverse.relate( + original.kind(), + "Explicit return contract", + original.target(), + original.source(), + ); + let edited = edit_message(&reverse, reply, |body| { + if let OccurrenceBody::Exchange { exchange, .. } = body { + *exchange = Some(reverse_exchange); + } + }); + reverse.elements.insert(reply, edited); + valid(&reverse); + assert_eq!(reverse.relations[&exchange], p.relations[&exchange]); + } +} + +#[test] +fn reused_scenarios_bind_existing_roles_and_transition_the_reference_closure() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let Fixture { + mut p, + scenario, + a, + b, + .. + } = fixture(direction.source_layer()); + let represented = |p: &Project, id| match p.elements[&id].shared().unwrap() { + SharedBody::Lifeline { + represented: Some(id), + } => *id, + _ => unreachable!(), + }; + let client = represented(&p, a); + let provider = represented(&p, b); + let mut callers = vec![]; + for name in ["Nominal service", "Degraded service"] { + let caller = p + .add_element(Kind::Scenario, direction.source_layer(), name, None) + .unwrap(); + let local_a = line(&mut p, caller, client, "Caller client"); + let local_b = line(&mut p, caller, provider, "Caller provider"); + let use_id = add_occurrence( + &mut p, + caller, + "Reuse service conversation", + OccurrenceBody::ScenarioUse { + scenario: Some(scenario), + bindings: vec![ + LifelineBinding { + referenced: a, + local: local_a, + }, + LifelineBinding { + referenced: b, + local: local_b, + }, + ], + }, + ); + callers.push((caller, use_id)); + } + valid(&p); + let request = { + let mut r = + TransitionRequest::new(direction, callers.iter().map(|(id, _)| *id).collect()); + r.scope = DependencyScope::Required; + r + }; + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let mapped = |id| plan.changes.iter().find(|c| c.source == id).unwrap().target; + let target = apply_transition(&p, &plan).unwrap(); + valid(&target); + for (caller, use_id) in &callers { + assert!( + references::element_references(&target.elements[&mapped(*use_id)]) + .contains(&mapped(scenario)) + ); + assert_eq!( + target.elements[&mapped(*caller)].layer(), + direction.target_layer() + ); + assert!( + matches!(target.elements[&mapped(*use_id)].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::ScenarioUse{scenario:Some(id),..},..})if *id==mapped(scenario)) + ); + } + let mut session = Session::new(p.clone()); + session + .execute(Command::Rename( + scenario, + "Reviewed shared conversation".into(), + )) + .unwrap(); + for (_, use_id) in callers { + assert_eq!(session.project().elements[&use_id], p.elements[&use_id]); + } + session.undo(); + assert_eq!(session.project(), &p); + let reverse = { + let mut r = TransitionRequest::new( + if direction == TransitionDirection::OaToSa { + TransitionDirection::SaToOa + } else { + TransitionDirection::OaToSa + }, + vec![mapped(scenario)], + ); + r.scope = DependencyScope::Required; + r + }; + let repeat = plan_transition(&target, &reverse).unwrap(); + assert!(repeat.conflicts.is_empty()); + assert!(repeat.commands.is_empty()); + } +} +#[test] +fn reuse_rejects_wrong_bindings_recursive_calls_and_wrong_viewpoints() { + let Fixture { + mut p, + scenario, + a, + b, + .. + } = fixture(Layer::Sa); + let caller = p + .add_element(Kind::Scenario, Layer::Sa, "Caller", None) + .unwrap(); + let wrong_work = p + .add_element(Kind::Function, Layer::Sa, "Different function", None) + .unwrap(); + let wrong = line(&mut p, caller, wrong_work, "Wrong role"); + let use_id = add_occurrence( + &mut p, + caller, + "Bad role binding", + OccurrenceBody::ScenarioUse { + scenario: Some(scenario), + bindings: vec![LifelineBinding { + referenced: a, + local: wrong, + }], + }, + ); + assert!( + validate_candidate(&p) + .unwrap_err() + .0 + .contains("scenario_binding_identity") + ); + let correct = match p.elements[&a].shared().unwrap() { + SharedBody::Lifeline { + represented: Some(id), + } => *id, + _ => unreachable!(), + }; + *p.elements.get_mut(&wrong).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(correct), + }; + valid(&p); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(use_id) && i.code == "missing_lifeline_binding") + ); + let mut cycle = p.clone(); + add_occurrence( + &mut cycle, + scenario, + "Recursive caller", + OccurrenceBody::ScenarioUse { + scenario: Some(caller), + bindings: vec![], + }, + ); + assert!( + validation::validate(&cycle) + .iter() + .any(|i| i.code == "recursive_scenario_use" && i.severity == Severity::Error) + ); + let mut wrong_context = p.clone(); + let edited = edit_message(&wrong_context, use_id, |body| { + if let OccurrenceBody::ScenarioUse { bindings, .. } = body { + bindings[0].local = b; + } + }); + wrong_context.elements.insert(use_id, edited); + assert!( + validation::validate(&wrong_context) + .iter() + .any(|i| i.code == "scenario_binding_context") + ); + if let Some(SharedBody::Scenario { viewpoint, .. }) = + p.elements.get_mut(&caller).unwrap().shared_mut() + { + *viewpoint = ScenarioViewpoint::BoundaryInterface; + } + assert!( + validation::validate(&p) + .iter() + .any(|i| i.code == "scenario_use_viewpoint") + ); +} + +#[test] +fn create_delete_and_local_timer_messages_enforce_lifetime_rules() { + let Fixture { + mut p, + scenario, + a, + b, + exchange, + .. + } = fixture(Layer::Sa); + let create = add_occurrence( + &mut p, + scenario, + "Create provider", + message(Some(exchange), a, Some(b), MessageKind::Create, None), + ); + let steps = p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap(); + steps.pop(); + steps.insert(0, create); + let timer = add_occurrence( + &mut p, + scenario, + "Wait for completion", + message( + None, + b, + None, + MessageKind::Timer { + expression: "after 5 s".into(), + }, + None, + ), + ); + let delete = add_occurrence( + &mut p, + scenario, + "Delete provider", + message(Some(exchange), a, Some(b), MessageKind::Delete, None), + ); + valid(&p); + let mut session = Session::new(p.clone()); + for command in [ + Command::MoveStep(scenario, 0, 2), + Command::MoveStep(scenario, 4, 1), + ] { + assert!( + session + .execute(command) + .unwrap_err() + .0 + .contains("lifeline_lifetime") + ); + assert_eq!(session.project(), &p); + } + let bad = edit_message(&p, timer, |body| { + if let OccurrenceBody::Exchange { receiver, .. } = body { + *receiver = Some(a); + } + }); + assert!( + session + .execute(Command::EditElement(timer, bad)) + .unwrap_err() + .0 + .contains("timer_contract") + ); + let incomplete = edit_message(&p, timer, |body| { + if let OccurrenceBody::Exchange { message_kind, .. } = body { + *message_kind = MessageKind::Timer { + expression: String::new(), + }; + } + }); + session + .execute(Command::EditElement(timer, incomplete)) + .unwrap(); + assert!( + validation::validate(session.project()) + .iter() + .any(|i| i.element == Some(timer) && i.code == "missing_timer_expression") + ); + assert_eq!(session.project().elements[&delete], p.elements[&delete]); +} + +#[test] +fn value_annotations_are_bound_to_carried_fields_and_all_references_follow_identity_changes() { + let Fixture { p, call, field, .. } = fixture(Layer::Oa); + valid(&p); + let mut duplicate = edit_message(&p, call, |body| { + if let OccurrenceBody::Exchange { values, .. } = body { + values.push(values[0].clone()); + } + }); + let mut session = Session::new(p.clone()); + assert!( + session + .execute(Command::EditElement(call, duplicate.clone())) + .unwrap_err() + .0 + .contains("duplicate_message_value") + ); + let mut unrelated = p.clone(); + let item = unrelated + .add_element(Kind::ExchangeItem, Layer::Oa, "Unrelated item", None) + .unwrap(); + let other = unrelated + .add_element(Kind::DataField, Layer::Oa, "Unrelated field", Some(item)) + .unwrap(); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = duplicate.shared_mut() + { + values.truncate(1); + values[0].field = other; + } + assert!( + execute_batch(&unrelated, &[Command::EditElement(call, duplicate)]) + .unwrap_err() + .0 + .contains("message_value_field") + ); + for id in p.elements.keys().chain(p.relations.keys()).copied() { + let mut session = Session::new(p.clone()); + let replacement = Id::new_v4(); + session + .execute(Command::Reidentify(id, replacement)) + .unwrap(); + let renamed = session.project().clone(); + assert!( + renamed + .elements + .values() + .all(|e| !references::element_references(e).contains(&id)) + ); + session.execute(Command::Delete(replacement)).unwrap(); + valid(session.project()); + session.undo(); + assert_eq!(session.project(), &renamed); + session.undo(); + assert_eq!(session.project(), &p); + } + session.execute(Command::Delete(field)).unwrap(); + assert!( + matches!(session.project().elements[&call].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{values,..},..})if values.is_empty()) + ); +} + +#[test] +fn loop_fragment_repetition_is_distinct_from_recursive_scenario_use() { + let Fixture { + mut p, + scenario, + a, + b, + call, + reply, + .. + } = fixture(Layer::Oa); + let mut fragment = p + .make_element(Kind::Occurrence, Layer::Oa, "Retry service", Some(scenario)) + .unwrap(); + let fragment_id = fragment.id; + *fragment.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: FragmentOperator::Loop, + branches: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + let mut branch = p + .make_element( + Kind::Occurrence, + Layer::Oa, + "While needed", + Some(fragment_id), + ) + .unwrap(); + let branch_id = branch.id; + *branch.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: "retry && count < 3".into(), + occurrences: vec![call, reply], + }, + precondition: String::new(), + postcondition: String::new(), + }; + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = fragment.shared_mut() + { + branches.push(branch_id); + } + p.elements.get_mut(&call).unwrap().parent = Some(branch_id); + p.elements.get_mut(&reply).unwrap().parent = Some(branch_id); + *p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap() = vec![fragment_id]; + p.elements.insert(fragment_id, fragment); + p.elements.insert(branch_id, branch); + valid(&p); + let exchange = p_exchange(&p, call); + let outside = add_occurrence( + &mut p, + scenario, + "Reply outside optional loop", + message(Some(exchange), b, Some(a), MessageKind::Reply, Some(call)), + ); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(outside) && i.code == "reply_order") + ); +} +fn p_exchange(p: &Project, id: Id) -> Id { + match p.elements[&id].shared().unwrap() { + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(id), .. + }, + .. + } => *id, + _ => unreachable!(), + } +} + +#[test] +fn deleting_reused_scenarios_or_bound_lifelines_preserves_callers_and_undo() { + let Fixture { + mut p, + scenario, + a, + b, + .. + } = fixture(Layer::Sa); + let caller = p + .add_element(Kind::Scenario, Layer::Sa, "Caller", None) + .unwrap(); + let client = match p.elements[&a].shared().unwrap() { + SharedBody::Lifeline { + represented: Some(id), + } => *id, + _ => unreachable!(), + }; + let provider = match p.elements[&b].shared().unwrap() { + SharedBody::Lifeline { + represented: Some(id), + } => *id, + _ => unreachable!(), + }; + let local_a = line(&mut p, caller, client, "Local client"); + let local_b = line(&mut p, caller, provider, "Local provider"); + let use_id = add_occurrence( + &mut p, + caller, + "Shared conversation", + OccurrenceBody::ScenarioUse { + scenario: Some(scenario), + bindings: vec![ + LifelineBinding { + referenced: a, + local: local_a, + }, + LifelineBinding { + referenced: b, + local: local_b, + }, + ], + }, + ); + valid(&p); + for id in [scenario, a, b, local_a, local_b, use_id] { + let mut session = Session::new(p.clone()); + let replacement = Id::new_v4(); + session + .execute(Command::Reidentify(id, replacement)) + .unwrap(); + assert!( + session + .project() + .elements + .values() + .all(|e| !references::element_references(e).contains(&id)) + ); + let renamed = session.project().clone(); + session.execute(Command::Delete(replacement)).unwrap(); + valid(session.project()); + assert!(session.project().elements.contains_key(&caller)); + if id == scenario { + assert!( + matches!(session.project().elements[&use_id].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::ScenarioUse{scenario:None,bindings},..})if bindings.is_empty()) + ); + assert!(session.project().elements.contains_key(&local_a)); + } + session.undo(); + assert_eq!(session.project(), &renamed); + session.undo(); + assert_eq!(session.project(), &p); + } +} + +fn scenario_branch(p: &mut Project, owner: Id, operator: FragmentOperator) -> (Id, Id) { + let fragment = add_occurrence( + p, + owner, + "Conditional fragment", + OccurrenceBody::Fragment { + operator, + branches: vec![], + }, + ); + let branch = additional_branch(p, fragment); + (fragment, branch) +} +fn additional_branch(p: &mut Project, fragment: Id) -> Id { + let mut branch = p + .make_element( + Kind::Occurrence, + p.elements[&fragment].layer(), + "Operand", + Some(fragment), + ) + .unwrap(); + let id = branch.id; + *branch.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Branch { + guard: "condition".into(), + occurrences: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements.insert(id, branch); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = p.elements.get_mut(&fragment).unwrap().shared_mut() + { + branches.push(id); + } + id +} + +#[test] +fn conditional_and_parallel_replies_cannot_answer_one_call_twice_but_alternatives_can() { + for operator in [FragmentOperator::Optional, FragmentOperator::Alternative] { + let Fixture { + mut p, + scenario, + a, + b, + call, + reply, + exchange, + .. + } = fixture(Layer::Sa); + p.elements.remove(&reply); + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .retain(|id| *id != reply); + let (_, operand) = scenario_branch(&mut p, scenario, operator); + add_occurrence( + &mut p, + operand, + "Conditional return", + message(Some(exchange), b, Some(a), MessageKind::Reply, Some(call)), + ); + valid(&p); + let before = p.clone(); + add_occurrence( + &mut p, + scenario, + "Later return", + message(Some(exchange), b, Some(a), MessageKind::Reply, Some(call)), + ); + let mut session = Session::new(before.clone()); + assert!( + session + .execute(Command::Replace(p)) + .unwrap_err() + .0 + .contains("duplicate_reply") + ); + assert_eq!(session.project(), &before); + } + for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] { + let Fixture { + mut p, + scenario, + a, + b, + call, + reply, + exchange, + .. + } = fixture(Layer::Sa); + p.elements.remove(&reply); + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .retain(|id| *id != reply); + let (fragment, left) = scenario_branch(&mut p, scenario, operator); + let right = additional_branch(&mut p, fragment); + for operand in [left, right] { + add_occurrence( + &mut p, + operand, + "Branch return", + message(Some(exchange), b, Some(a), MessageKind::Reply, Some(call)), + ); + } + if operator == FragmentOperator::Alternative { + valid(&p); + } else { + assert!( + validate_candidate(&p) + .unwrap_err() + .0 + .contains("duplicate_reply") + ); + } + } +} + +#[test] +fn conditional_lifetime_changes_conflict_with_later_or_parallel_use_not_exclusive_use() { + for operator in [FragmentOperator::Optional, FragmentOperator::Alternative] { + let Fixture { + mut p, + scenario, + a, + b, + exchange, + .. + } = fixture(Layer::Sa); + let (_, operand) = scenario_branch(&mut p, scenario, operator); + add_occurrence( + &mut p, + operand, + "Conditional deletion", + message(Some(exchange), a, Some(b), MessageKind::Delete, None), + ); + valid(&p); + add_occurrence( + &mut p, + scenario, + "Use after fragment", + message(Some(exchange), a, Some(b), MessageKind::Asynchronous, None), + ); + assert!( + validate_candidate(&p) + .unwrap_err() + .0 + .contains("lifeline_lifetime") + ); + } + for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] { + let Fixture { + mut p, + scenario, + a, + b, + exchange, + .. + } = fixture(Layer::Sa); + let (fragment, left) = scenario_branch(&mut p, scenario, operator); + let right = additional_branch(&mut p, fragment); + add_occurrence( + &mut p, + left, + "Delete in one operand", + message(Some(exchange), a, Some(b), MessageKind::Delete, None), + ); + add_occurrence( + &mut p, + right, + "Use in another operand", + message(Some(exchange), a, Some(b), MessageKind::Asynchronous, None), + ); + if operator == FragmentOperator::Alternative { + valid(&p); + } else { + assert!( + validate_candidate(&p) + .unwrap_err() + .0 + .contains("lifeline_lifetime") + ); + } + } +} + +#[test] +fn role_lifelines_require_explicit_work_assignment_not_just_a_shared_realizing_entity() { + let Fixture { + mut p, + scenario, + a, + b, + exchange, + .. + } = fixture(Layer::Oa); + let work = p.relations[&exchange].source(); + let target = p.relations[&exchange].target(); + let entity = p + .add_element(Kind::Entity, Layer::Oa, "Controller", None) + .unwrap(); + let other = p + .add_element(Kind::Entity, Layer::Oa, "Recipient", None) + .unwrap(); + let role = p + .add_element(Kind::Role, Layer::Oa, "Coordinator", None) + .unwrap(); + let wrong_role = p + .add_element(Kind::Role, Layer::Oa, "Unassigned role", None) + .unwrap(); + let allocation = p.relate( + RelationKind::Allocation, + "Controller performs", + entity, + work, + ); + p.relate( + RelationKind::Allocation, + "Recipient performs", + other, + target, + ); + p.relate( + RelationKind::RoleRealization, + "Controller realizes coordinator", + entity, + role, + ); + p.relate( + RelationKind::RoleRealization, + "Controller also realizes unassigned role", + entity, + wrong_role, + ); + p.relate( + RelationKind::RoleAssignment, + "Coordinator assignment", + role, + work, + ); + if let Some(SharedBody::Scenario { viewpoint, .. }) = + p.elements.get_mut(&scenario).unwrap().shared_mut() + { + *viewpoint = ScenarioViewpoint::ParticipantInteraction; + } + *p.elements.get_mut(&a).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(role), + }; + *p.elements.get_mut(&b).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(other), + }; + valid(&p); + assert_eq!( + validation::lifeline_matches_endpoint(&p, a, work), + Some(true) + ); + assert_eq!( + validation::semantic_representation_compatible(&p, wrong_role, work), + Some(false) + ); + assert_eq!( + validation::semantic_representation_compatible(&p, work, work), + Some(true) + ); + assert_eq!( + validation::semantic_representation_compatible(&p, target, work), + Some(false) + ); + let port = p + .add_element(Kind::Port, Layer::Oa, "Controller port", Some(entity)) + .unwrap(); + assert_eq!( + validation::semantic_representation_compatible(&p, entity, port), + Some(true) + ); + assert_eq!( + validation::semantic_representation_compatible(&p, role, port), + Some(true) + ); + assert_eq!( + validation::semantic_representation_compatible(&p, other, port), + Some(false) + ); + let mut wrong = p.clone(); + *wrong.elements.get_mut(&a).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(wrong_role), + }; + assert!( + validation::validate(&wrong) + .iter() + .any(|i| i.code == "scenario_endpoint" && i.severity == Severity::Error) + ); + p.relations.remove(&allocation); + let additional = p + .add_element(Kind::Entity, Layer::Oa, "Alternative coordinator", None) + .unwrap(); + p.relate( + RelationKind::RoleRealization, + "Another coordinator realization", + additional, + role, + ); + assert_eq!(validation::effective_performer(&p, work), None); + assert_eq!( + validation::lifeline_matches_endpoint(&p, a, work), + Some(true) + ); + assert_eq!( + validation::semantic_representation_compatible(&p, entity, work), + None + ); + valid(&p); +} + +#[test] +fn effective_message_endpoints_are_shared_for_forward_and_reverse_reply_choices() { + let Fixture { + mut p, + a, + b, + call, + reply, + exchange, + .. + } = fixture(Layer::Sa); + let body = |p: &Project, id| match p.elements[&id].shared().unwrap() { + SharedBody::Occurrence { body, .. } => body.clone(), + _ => unreachable!(), + }; + let (source, target) = p.relations[&exchange].body.endpoints(); + assert_eq!( + validation::message_endpoints(&p, &body(&p, call)), + Some((source, target)) + ); + let reversed = validation::message_endpoints(&p, &body(&p, reply)).unwrap(); + assert_eq!(reversed, (target, source)); + assert_eq!( + validation::lifeline_matches_endpoint(&p, b, reversed.0), + Some(true) + ); + assert_eq!( + validation::lifeline_matches_endpoint(&p, a, reversed.0), + Some(false) + ); + let reverse = p.relate( + RelationKind::FunctionalExchange, + "Explicit reply exchange", + target, + source, + ); + let explicit = message(Some(reverse), b, Some(a), MessageKind::Reply, Some(call)); + assert_eq!( + validation::message_endpoints(&p, &explicit), + Some((target, source)) + ); + assert_eq!( + validation::message_endpoints( + &p, + &message(Some(exchange), b, Some(a), MessageKind::Reply, None) + ), + None + ); + assert_eq!( + validation::message_endpoints( + &p, + &message( + None, + b, + None, + MessageKind::Timer { + expression: "5 s".into() + }, + None + ) + ), + None + ); +} + +fn move_into_operand(p: &mut Project, id: Id, operand: Id) { + let old = p.elements[&id].parent.unwrap(); + p.elements + .get_mut(&old) + .unwrap() + .steps_mut() + .unwrap() + .retain(|step| *step != id); + p.elements.get_mut(&id).unwrap().parent = Some(operand); + p.elements + .get_mut(&operand) + .unwrap() + .steps_mut() + .unwrap() + .push(id); +} + +#[test] +fn replies_repeated_without_their_call_warn_in_each_extra_loop_scope() { + let Fixture { + mut p, + scenario, + call, + reply, + .. + } = fixture(Layer::Sa); + let (_, outer) = scenario_branch(&mut p, scenario, FragmentOperator::Loop); + move_into_operand(&mut p, reply, outer); + valid(&p); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(reply) + && i.code == "reply_repetition" + && i.severity == Severity::Warning) + ); + // Moving both into the same loop gives each iteration its own call and reply. + move_into_operand(&mut p, call, outer); + p.elements + .get_mut(&outer) + .unwrap() + .steps_mut() + .unwrap() + .swap(0, 1); + valid(&p); + assert!( + !validation::validate(&p) + .iter() + .any(|i| i.code == "reply_repetition") + ); + // An inner loop can still repeat the reply more often than the outer call. + let (_, inner) = scenario_branch(&mut p, outer, FragmentOperator::Loop); + move_into_operand(&mut p, reply, inner); + valid(&p); + assert_eq!( + validation::validate(&p) + .iter() + .filter(|i| i.code == "reply_repetition") + .count(), + 1 + ); + move_into_operand(&mut p, call, inner); + p.elements + .get_mut(&inner) + .unwrap() + .steps_mut() + .unwrap() + .swap(0, 1); + valid(&p); + assert!( + !validation::validate(&p) + .iter() + .any(|i| i.code == "reply_repetition") + ); +} + +#[test] +fn repeated_lifecycle_requires_per_iteration_balance_without_prohibiting_balanced_loops() { + for starts_with_creation in [false, true] { + let Fixture { + mut p, + scenario, + a, + b, + call, + reply, + exchange, + .. + } = fixture(Layer::Sa); + for id in [call, reply] { + p.elements.remove(&id); + } + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .clear(); + let (_, operand) = scenario_branch(&mut p, scenario, FragmentOperator::Loop); + let lifecycle = if starts_with_creation { + add_occurrence( + &mut p, + operand, + "Create each iteration", + message(Some(exchange), a, Some(b), MessageKind::Create, None), + ) + } else { + Id::nil() + }; + add_occurrence( + &mut p, + operand, + "Use provider", + message(Some(exchange), a, Some(b), MessageKind::Asynchronous, None), + ); + let lifecycle = if starts_with_creation { + lifecycle + } else { + add_occurrence( + &mut p, + operand, + "Delete each iteration", + message(Some(exchange), a, Some(b), MessageKind::Delete, None), + ) + }; + valid(&p); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(lifecycle) + && i.code == "lifeline_repetition" + && i.severity == Severity::Warning) + ); + if starts_with_creation { + add_occurrence( + &mut p, + operand, + "Complete lifetime", + message(Some(exchange), a, Some(b), MessageKind::Delete, None), + ); + } else { + add_occurrence( + &mut p, + operand, + "Start lifetime", + message(Some(exchange), a, Some(b), MessageKind::Create, None), + ); + p.elements + .get_mut(&operand) + .unwrap() + .steps_mut() + .unwrap() + .rotate_right(1); + } + valid(&p); + assert!( + !validation::validate(&p) + .iter() + .any(|i| i.code == "lifeline_repetition") + ); + } +} + +#[test] +fn optional_cleanup_does_not_claim_a_repeated_lifetime_is_balanced() { + let Fixture { + mut p, + scenario, + a, + b, + call, + reply, + exchange, + .. + } = fixture(Layer::Sa); + for id in [call, reply] { + p.elements.remove(&id); + } + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .clear(); + let (_, iteration) = scenario_branch(&mut p, scenario, FragmentOperator::Loop); + let create = add_occurrence( + &mut p, + iteration, + "Create provider", + message(Some(exchange), a, Some(b), MessageKind::Create, None), + ); + add_occurrence( + &mut p, + iteration, + "Use provider", + message(Some(exchange), a, Some(b), MessageKind::Asynchronous, None), + ); + let (_, cleanup) = scenario_branch(&mut p, iteration, FragmentOperator::Optional); + add_occurrence( + &mut p, + cleanup, + "Optional deletion", + message(Some(exchange), a, Some(b), MessageKind::Delete, None), + ); + valid(&p); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(create) && i.code == "lifeline_repetition") + ); +} diff --git a/crates/oa-core/tests/release_states.rs b/crates/oa-core/tests/release_states.rs new file mode 100644 index 00000000..c82311ae --- /dev/null +++ b/crates/oa-core/tests/release_states.rs @@ -0,0 +1,756 @@ +use oa_core::{ + references, + transitions::*, + validation::{self, Severity}, + *, +}; + +fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option) -> Id { + p.add_element(kind, layer, &kind.to_string(), parent) + .unwrap() +} +fn vertex(p: &mut Project, region: Id, kind: Kind) -> Id { + add(p, kind, p.elements[®ion].layer(), Some(region)) +} +fn region(p: &mut Project, owner: Id) -> Id { + add(p, Kind::Region, p.elements[&owner].layer(), Some(owner)) +} +fn edge(p: &mut Project, source: Id, target: Id) -> Id { + p.relate(RelationKind::StateTransition, "Transition", source, target) +} +fn set_kind(p: &mut Project, id: Id, value: TransitionKind) { + if let RelationBody::StateTransition { + transition_kind, .. + } = &mut p.relations.get_mut(&id).unwrap().body + { + *transition_kind = value; + } +} +fn set_guard(p: &mut Project, id: Id, value: &str) { + if let RelationBody::StateTransition { guard, .. } = &mut p.relations.get_mut(&id).unwrap().body + { + *guard = value.into(); + } +} +fn event(p: &mut Project, layer: Layer, body: EventBody) -> Id { + let id = add(p, Kind::Event, layer, None); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Event { body }; + id +} +fn diagnostics(p: &Project, code: &str) -> Vec { + validation::validate(p) + .into_iter() + .filter(|i| i.code == code) + .collect() +} +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 rejects(p: &Project, code: &str) { + assert!( + diagnostics(p, code) + .iter() + .any(|i| i.severity == Severity::Error), + "Expected {code}: {:?}", + validation::validate(p) + ); +} +fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) { + let mut p = Project::empty(); + let owner = add( + &mut p, + if layer == Layer::Oa { + Kind::Entity + } else { + Kind::System + }, + layer, + None, + ); + let machine = add(&mut p, Kind::StateMachine, layer, Some(owner)); + let main = region(&mut p, machine); + let initial = vertex(&mut p, main, Kind::Initial); + let ready = vertex(&mut p, main, Kind::State); + edge(&mut p, initial, ready); + (p, owner, machine, main, ready) +} + +#[test] +fn every_vertex_is_an_editable_draft_but_only_stable_states_own_behavior_or_regions() { + for layer in [Layer::Oa, Layer::Sa] { + for kind in Kind::ALL.into_iter().filter(|k| k.is_state()) { + let (mut p, _, _, main, _) = fixture(layer); + if kind == Kind::Initial { + continue; + } + let id = vertex(&mut p, main, kind); + valid(&p); + assert!(!validation::can_parent(&p, kind, layer, None)); + assert!(validation::can_parent(&p, kind, layer, Some(main))); + assert_eq!( + validation::can_parent(&p, Kind::Region, layer, Some(id)), + kind.is_stable_state() + ); + let work = add( + &mut p, + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + None, + ); + assert_eq!( + RelationKind::StateAction.permits(kind, p.elements[&work].kind()), + kind.is_stable_state() + ); + assert_eq!( + RelationKind::Availability.permits(p.elements[&work].kind(), kind), + kind.is_stable_state() + ); + if !kind.is_stable_state() { + if let Some(SharedBody::State { entry, .. }) = + p.elements.get_mut(&id).unwrap().shared_mut() + { + *entry = "Retained legacy annotation".into(); + } + assert_eq!(diagnostics(&p, "pseudostate_behavior").len(), 1); + valid(&p); + } + } + } +} + +#[test] +fn typed_events_trigger_alternatives_and_effects_validate_without_owner_restrictions() { + let (mut p, _, _, main, ready) = fixture(Layer::Sa); + let end = vertex(&mut p, main, Kind::Final); + let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, None); + let signal = event(&mut p, Layer::Sa, EventBody::Signal { items: vec![item] }); + let time = event( + &mut p, + Layer::Sa, + EventBody::Time { + kind: TimeEventKind::After, + expression: "30 s".into(), + }, + ); + let change = event( + &mut p, + Layer::Sa, + EventBody::Change { + expression: "pressure > limit".into(), + }, + ); + let work = add(&mut p, Kind::Function, Layer::Sa, None); + let external = add(&mut p, Kind::SystemActor, Layer::Sa, None); + p.relate( + RelationKind::Allocation, + "External function", + external, + work, + ); + let transition = edge(&mut p, ready, end); + if let RelationBody::StateTransition { + triggers, + effects, + trigger, + effect, + .. + } = &mut p.relations.get_mut(&transition).unwrap().body + { + *triggers = vec![signal, time, change, work]; + *effects = vec![work]; + *trigger = "Any of these events".into(); + *effect = "Record the reason".into(); + } + valid(&p); + assert!(diagnostics(&p, "unresolved_trigger").is_empty()); + let baseline = p.clone(); + for replacement in [vec![signal, signal], vec![item], vec![Id::new_v4()]] { + let mut invalid = baseline.clone(); + if let RelationBody::StateTransition { triggers, .. } = + &mut invalid.relations.get_mut(&transition).unwrap().body + { + *triggers = replacement; + } + assert!(Session::try_new(invalid).is_err()); + } + if let RelationBody::StateTransition { effects, .. } = + &mut p.relations.get_mut(&transition).unwrap().body + { + *effects = vec![signal]; + } + rejects(&p, "invalid_reference_kind"); + p = baseline.clone(); + if let Some(SharedBody::Event { body }) = p.elements.get_mut(&signal).unwrap().shared_mut() { + *body = EventBody::Signal { items: vec![work] }; + } + rejects(&p, "invalid_reference_kind"); + let mut p = baseline; + let foreign = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![] }); + if let RelationBody::StateTransition { triggers, .. } = + &mut p.relations.get_mut(&transition).unwrap().body + { + *triggers = vec![foreign]; + } + rejects(&p, "invalid_reference_kind"); + for body in [ + EventBody::Time { + kind: TimeEventKind::At, + expression: String::new(), + }, + EventBody::Change { + expression: " ".into(), + }, + ] { + let mut p = Project::empty(); + event(&mut p, Layer::Oa, body); + valid(&p); + assert_eq!(diagnostics(&p, "missing_event_expression").len(), 1); + } +} + +#[test] +fn event_reference_identity_cleanup_and_undo_preserve_triggering_intent() { + let (mut p, _, _, main, ready) = fixture(Layer::Oa); + let end = vertex(&mut p, main, Kind::Final); + let item = add(&mut p, Kind::ExchangeItem, Layer::Oa, None); + let signal = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![item] }); + let work = add(&mut p, Kind::Activity, Layer::Oa, None); + let transition = edge(&mut p, ready, end); + if let RelationBody::StateTransition { + triggers, + effects, + guard, + effect, + .. + } = &mut p.relations.get_mut(&transition).unwrap().body + { + *triggers = vec![signal]; + *effects = vec![work]; + *guard = "accepted".into(); + *effect = "Effect annotation survives".into(); + } + valid(&p); + for id in [item, signal, work, transition, ready, main] { + let mut session = Session::new(p.clone()); + let replacement = Id::new_v4(); + session + .execute(Command::Reidentify(id, replacement)) + .unwrap(); + assert!( + session + .project() + .elements + .values() + .all(|e| !references::element_references(e).contains(&id)) + ); + assert!( + session + .project() + .relations + .values() + .all(|r| !references::relation_references(r).contains(&id)) + ); + let renamed = session.project().clone(); + session.execute(Command::Delete(replacement)).unwrap(); + valid(session.project()); + if id == signal { + let RelationBody::StateTransition { + trigger, + triggers, + effects, + guard, + effect, + .. + } = &session.project().relations[&transition].body + else { + panic!() + }; + assert!(triggers.is_empty()); + assert!(trigger.contains(&p.elements[&signal].name)); + assert!(trigger.contains("replacement trigger")); + assert_eq!(effects, &[work]); + assert_eq!(guard, "accepted"); + assert_eq!(effect, "Effect annotation survives"); + assert_eq!( + diagnostics(session.project(), "unresolved_trigger").len(), + 1 + ); + } + if id == transition { + assert!(session.project().elements.contains_key(&signal)); + } + session.undo(); + assert_eq!(session.project(), &renamed); + session.undo(); + assert_eq!(session.project(), &p); + session.redo(); + assert_eq!(session.project(), &renamed); + } + let mut session = Session::new(p); + let mut changed = session.project().relations[&transition].clone(); + if let RelationBody::StateTransition { trigger, .. } = &mut changed.body { + *trigger = "Exact authored legacy trigger".into(); + } + session + .execute(Command::EditRelation(transition, changed)) + .unwrap(); + session.execute(Command::Delete(signal)).unwrap(); + assert!( + matches!(&session.project().relations[&transition].body, RelationBody::StateTransition { trigger, .. } if trigger == "Exact authored legacy trigger") + ); +} + +#[test] +fn transition_kinds_distinguish_internal_external_self_and_local_composite_routing() { + let (mut p, _, _, main, composite) = fixture(Layer::Sa); + let nested = region(&mut p, composite); + let child = vertex(&mut p, nested, Kind::Mode); + let other = vertex(&mut p, main, Kind::State); + let local = edge(&mut p, composite, child); + set_kind(&mut p, local, TransitionKind::Local); + let internal = edge(&mut p, composite, composite); + set_kind(&mut p, internal, TransitionKind::Internal); + edge(&mut p, child, child); + edge(&mut p, child, other); + valid(&p); + for (source, target, kind) in [ + (child, other, TransitionKind::Internal), + (composite, composite, TransitionKind::Local), + (composite, other, TransitionKind::Local), + (child, other, TransitionKind::Local), + ] { + let mut invalid = p.clone(); + let id = edge(&mut invalid, source, target); + set_kind(&mut invalid, id, kind); + rejects(&invalid, "transition_kind"); + } + let machine = add(&mut p, Kind::StateMachine, Layer::Sa, None); + let foreign = region(&mut p, machine); + let foreign = vertex(&mut p, foreign, Kind::State); + assert!(!validation::can_connect( + &p, + RelationKind::StateTransition, + child, + foreign + )); + edge(&mut p, child, foreign); + rejects(&p, "transition_machine"); +} + +#[test] +fn initial_final_and_terminate_reject_forbidden_routes_and_cardinality_atomically() { + let (p, _, _, main, ready) = fixture(Layer::Oa); + let initial = p + .children(main) + .find(|e| e.kind() == Kind::Initial) + .unwrap() + .id; + assert!(!validation::can_connect( + &p, + RelationKind::StateTransition, + initial, + ready + )); + let mut invalid = p.clone(); + edge(&mut invalid, initial, ready); + rejects(&invalid, "initial_transition_count"); + for kind in [Kind::Final, Kind::Terminate] { + let mut p = p.clone(); + let terminal = vertex(&mut p, main, kind); + edge(&mut p, ready, terminal); + valid(&p); + assert!(!validation::can_connect( + &p, + RelationKind::StateTransition, + terminal, + ready + )); + edge(&mut p, terminal, ready); + rejects(&p, "pseudostate_direction"); + } + for kind in Kind::ALL + .into_iter() + .filter(|k| k.is_state() && !k.is_stable_state()) + { + let mut p = p.clone(); + let v = if kind == Kind::Initial { + initial + } else { + vertex(&mut p, main, kind) + }; + let transition = edge(&mut p, v, v); + rejects(&p, "pseudostate_self"); + let mut before = p.clone(); + before.relations.remove(&transition); + let mut session = Session::new(before.clone()); + assert!(session.execute(Command::Replace(p)).is_err()); + assert_eq!(session.project(), &before); + } + for (prose, guard) in [("timer", ""), ("", "condition")] { + let mut p = p.clone(); + let id = p + .relations + .values() + .find(|r| r.source() == initial) + .unwrap() + .id; + if let RelationBody::StateTransition { + trigger, + guard: actual, + .. + } = &mut p.relations.get_mut(&id).unwrap().body + { + *trigger = prose.into(); + *actual = guard.into(); + } + rejects(&p, "initial_transition"); + } +} + +#[test] +fn choices_and_junctions_report_unfinished_guards_but_reject_conflicting_defaults_or_triggers() { + let (mut p, _, _, main, ready) = fixture(Layer::Sa); + let choice = vertex(&mut p, main, Kind::Choice); + let junction = vertex(&mut p, main, Kind::Junction); + let fallback = vertex(&mut p, main, Kind::State); + edge(&mut p, ready, choice); + let yes = edge(&mut p, choice, junction); + set_guard(&mut p, yes, "pressure <= limit"); + let no = edge(&mut p, choice, fallback); + set_guard(&mut p, no, "else"); + edge(&mut p, fallback, junction); + edge(&mut p, junction, ready); + valid(&p); + assert!(diagnostics(&p, "missing_choice_guard").is_empty()); + let mut invalid = p.clone(); + set_guard(&mut invalid, yes, "else"); + rejects(&invalid, "choice_default"); + let mut draft = p.clone(); + set_guard(&mut draft, yes, ""); + set_guard(&mut draft, no, "unknown"); + valid(&draft); + assert_eq!(diagnostics(&draft, "missing_choice_guard").len(), 1); + assert_eq!(diagnostics(&draft, "missing_choice_default").len(), 1); + assert!(!validation::can_connect( + &p, + RelationKind::StateTransition, + fallback, + choice + )); + let mut invalid = p.clone(); + edge(&mut invalid, fallback, choice); + rejects(&invalid, "pseudostate_cardinality"); + for source in [choice, junction] { + let mut invalid = p.clone(); + let id = invalid + .relations + .values() + .find(|r| r.source() == source) + .unwrap() + .id; + if let RelationBody::StateTransition { trigger, .. } = + &mut invalid.relations.get_mut(&id).unwrap().body + { + *trigger = "after 30s".into(); + } + rejects(&invalid, "pseudostate_trigger"); + } + edge(&mut p, junction, fallback); + valid(&p); + assert_eq!(diagnostics(&p, "junction_guard").len(), 1); +} + +#[test] +fn histories_remain_per_region_with_shallow_direct_and_deep_nested_fallbacks() { + let (mut p, _, _, main, ready) = fixture(Layer::Sa); + let nested = region(&mut p, ready); + let nested_state = vertex(&mut p, nested, Kind::State); + let shallow = vertex(&mut p, main, Kind::ShallowHistory); + let deep = vertex(&mut p, main, Kind::DeepHistory); + assert_eq!(diagnostics(&p, "incomplete_state_vertex").len(), 2); + let fallback = edge(&mut p, shallow, ready); + edge(&mut p, deep, nested_state); + valid(&p); + let mut invalid = p.clone(); + invalid + .relations + .get_mut(&fallback) + .unwrap() + .body + .set_endpoints(shallow, nested_state); + rejects(&invalid, "history_scope"); + let mut invalid = p.clone(); + set_guard(&mut invalid, fallback, "resume"); + rejects(&invalid, "pseudostate_segment"); + let mut invalid = p.clone(); + edge(&mut invalid, deep, ready); + rejects(&invalid, "pseudostate_cardinality"); + for (kind, code) in [ + (Kind::ShallowHistory, "multiple_shallow_history"), + (Kind::DeepHistory, "multiple_deep_history"), + ] { + let mut invalid = p.clone(); + vertex(&mut invalid, main, kind); + rejects(&invalid, code); + let mut valid_other = p.clone(); + vertex(&mut valid_other, nested, kind); + valid(&valid_other); + } + let outside = add(&mut p, Kind::StateMachine, Layer::Sa, None); + let outside_region = region(&mut p, outside); + let foreign = vertex(&mut p, outside_region, Kind::State); + edge(&mut p, deep, foreign); + rejects(&p, "history_scope"); +} + +#[test] +fn fork_join_detect_concurrency_at_the_common_composite_not_deep_immediate_regions() { + let (mut p, _, machine, main, ready) = fixture(Layer::Sa); + let fork = vertex(&mut p, main, Kind::Fork); + let join = vertex(&mut p, main, Kind::Join); + let left = vertex(&mut p, main, Kind::State); + let left_region = region(&mut p, left); + let left_leaf = vertex(&mut p, left_region, Kind::State); + let right_region = region(&mut p, machine); + let right = vertex(&mut p, right_region, Kind::State); + let right_region = region(&mut p, right); + let right_leaf = vertex(&mut p, right_region, Kind::State); + edge(&mut p, ready, fork); + let arm = edge(&mut p, fork, left_leaf); + edge(&mut p, fork, right_leaf); + edge(&mut p, left_leaf, join); + edge(&mut p, right_leaf, join); + edge(&mut p, join, ready); + valid(&p); + let sibling_region = region(&mut p, left); + let sibling_leaf = vertex(&mut p, sibling_region, Kind::State); + assert!(!validation::can_connect( + &p, + RelationKind::StateTransition, + fork, + sibling_leaf + )); + let mut invalid = p.clone(); + edge(&mut invalid, fork, sibling_leaf); + rejects(&invalid, "orthogonal_regions"); + let mut invalid = p.clone(); + set_guard(&mut invalid, arm, "ready"); + rejects(&invalid, "pseudostate_segment"); + let mut invalid = p.clone(); + edge(&mut invalid, fork, join); + rejects(&invalid, "orthogonal_endpoint"); + let mut invalid = p.clone(); + edge(&mut invalid, ready, fork); + rejects(&invalid, "pseudostate_cardinality"); + let mut invalid = p.clone(); + edge(&mut invalid, join, left); + rejects(&invalid, "pseudostate_cardinality"); + let mut invalid = p.clone(); + let incoming = invalid + .relations + .values() + .find(|r| r.target() == join) + .unwrap() + .id; + if let RelationBody::StateTransition { trigger, .. } = + &mut invalid.relations.get_mut(&incoming).unwrap().body + { + *trigger = "synchronize".into(); + } + rejects(&invalid, "pseudostate_segment"); +} + +#[test] +fn boundary_points_enforce_inside_outside_local_routing_and_one_route_per_region() { + let (mut p, _, machine, main, composite) = fixture(Layer::Sa); + let outside = vertex(&mut p, main, Kind::State); + let nested = region(&mut p, composite); + let child = vertex(&mut p, nested, Kind::State); + let entry = vertex(&mut p, nested, Kind::EntryPoint); + let exit = vertex(&mut p, nested, Kind::ExitPoint); + edge(&mut p, outside, entry); + assert!(validation::can_connect( + &p, + RelationKind::StateTransition, + entry, + child + )); + let route = edge(&mut p, entry, child); + assert!(matches!( + p.relations[&route].body, + RelationBody::StateTransition { + transition_kind: TransitionKind::Local, + .. + } + )); + edge(&mut p, child, exit); + edge(&mut p, exit, outside); + valid(&p); + for (source, target) in [ + (child, entry), + (entry, outside), + (outside, exit), + (exit, child), + ] { + assert!(!validation::can_connect( + &p, + RelationKind::StateTransition, + source, + target + )); + let mut invalid = p.clone(); + edge(&mut invalid, source, target); + rejects(&invalid, "boundary_transition"); + } + let other = vertex(&mut p, nested, Kind::Mode); + assert!(!validation::can_connect( + &p, + RelationKind::StateTransition, + entry, + other + )); + let mut invalid = p.clone(); + edge(&mut invalid, entry, other); + rejects(&invalid, "entry_point_cardinality"); + let mut invalid = p.clone(); + set_kind(&mut invalid, route, TransitionKind::External); + rejects(&invalid, "transition_kind"); + let concurrent = region(&mut p, composite); + let concurrent_state = vertex(&mut p, concurrent, Kind::State); + assert_eq!(diagnostics(&p, "entry_region_default").len(), 1); + edge(&mut p, entry, concurrent_state); + valid(&p); + assert!(diagnostics(&p, "entry_region_default").is_empty()); + let top_region = region(&mut p, machine); + let top_exit = vertex(&mut p, top_region, Kind::ExitPoint); + edge(&mut p, outside, top_exit); + valid(&p); + assert!( + !diagnostics(&p, "incomplete_state_vertex") + .iter() + .any(|i| i.element == Some(top_exit)) + ); +} + +#[test] +fn reachability_follows_nested_default_entry_and_reports_disconnected_vertices() { + let (mut p, _, _, main, composite) = fixture(Layer::Oa); + let nested = region(&mut p, composite); + let initial = vertex(&mut p, nested, Kind::Initial); + let child = vertex(&mut p, nested, Kind::State); + edge(&mut p, initial, child); + let isolated = vertex(&mut p, main, Kind::Final); + let unreachable = diagnostics(&p, "unreachable_state"); + assert_eq!(unreachable.len(), 1); + assert_eq!(unreachable[0].element, Some(isolated)); + edge(&mut p, child, isolated); + valid(&p); + assert!(diagnostics(&p, "unreachable_state").is_empty()); +} + +#[test] +fn machine_subtrees_and_selected_edges_transition_events_work_and_payloads_in_both_directions() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let layer = direction.source_layer(); + let (mut p, owner, machine, main, ready) = fixture(layer); + let end = vertex(&mut p, main, Kind::Terminate); + let vertices: Vec<_> = Kind::ALL + .into_iter() + .filter(|kind| { + kind.is_state() && !matches!(kind, Kind::Initial | Kind::State | Kind::Terminate) + }) + .map(|kind| vertex(&mut p, main, kind)) + .collect(); + let item = add(&mut p, Kind::ExchangeItem, layer, None); + let signal = event(&mut p, layer, EventBody::Signal { items: vec![item] }); + let work = add( + &mut p, + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + None, + ); + p.relate(RelationKind::Allocation, "Performs", owner, work); + let transition = edge(&mut p, ready, end); + if let RelationBody::StateTransition { + triggers, + effects, + guard, + .. + } = &mut p.relations.get_mut(&transition).unwrap().body + { + *triggers = vec![signal, work]; + *effects = vec![work]; + *guard = "accepted".into(); + } + let action = p.relate(RelationKind::StateAction, "On entry", ready, work); + let availability = p.relate(RelationKind::Availability, "Available", work, ready); + valid(&p); + for (source, scope) in [ + (machine, DependencyScope::Subtree), + (transition, DependencyScope::Required), + ] { + let mut request = TransitionRequest::new(direction, vec![source]); + request.scope = scope; + request.mappings.push(TransitionMapping { + source: owner, + target: None, + target_kind: Some(if layer == Layer::Oa { + Kind::System + } else { + Kind::Entity + }), + }); + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let candidate = execute_batch(&p, &plan.commands).unwrap(); + valid(&candidate); + let mapped = |id| plan.changes.iter().find(|c| c.source == id).unwrap().target; + assert!( + matches!(&candidate.relations[&mapped(transition)].body, RelationBody::StateTransition { triggers, effects, guard, .. } if *triggers == vec![mapped(signal), mapped(work)] && *effects == vec![mapped(work)] && guard == "accepted") + ); + assert!( + matches!(&candidate.elements[&mapped(signal)].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if *items == vec![mapped(item)]) + ); + if scope == DependencyScope::Subtree { + assert!(candidate.relations.contains_key(&mapped(action))); + for id in &vertices { + assert_eq!( + candidate.elements[&mapped(*id)].kind(), + p.elements[id].kind() + ); + assert!( + candidate + .traces + .values() + .any(|t| t.kind == TraceKind::State && (t.oa == *id || t.sa == *id)) + ); + } + } + assert!(!plan.changes.iter().any(|c| c.source == availability)); + assert!( + candidate + .traces + .values() + .any(|t| t.kind == TraceKind::State && (t.oa == signal || t.sa == signal)) + ); + let mut session = Session::new(p.clone()); + session.execute_many(&plan.commands).unwrap(); + session.undo(); + assert_eq!(session.project(), &p); + session.redo(); + assert_eq!(session.project(), &candidate); + } + } +} diff --git a/crates/oa-core/tests/release_topology.rs b/crates/oa-core/tests/release_topology.rs new file mode 100644 index 00000000..f0de15eb --- /dev/null +++ b/crates/oa-core/tests/release_topology.rs @@ -0,0 +1,519 @@ +use oa_core::{ + references, + transitions::*, + validation::{self, Severity}, + *, +}; + +fn kinds(layer: Layer) -> (Kind, Kind) { + match layer { + Layer::Oa => (Kind::Process, Kind::Activity), + Layer::Sa => (Kind::FunctionalChain, Kind::Function), + } +} +fn occurrence(p: &Project, owner: Id, name: &str, body: OccurrenceBody) -> Element { + let mut element = p + .make_element( + Kind::Occurrence, + p.elements[&owner].layer(), + name, + Some(owner), + ) + .unwrap(); + *element.shared_mut().unwrap() = SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }; + element +} +fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let element = occurrence(p, owner, name, body); + let id = element.id; + p.elements.insert(id, element); + p.elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} +fn action(p: &mut Project, owner: Id, name: &str) -> Id { + let layer = p.elements[&owner].layer(); + let work = p.add_element(kinds(layer).1, layer, name, None).unwrap(); + let id = add_occurrence( + p, + owner, + &format!("Perform {name}"), + OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + ); + p.elements.get_mut(&id).unwrap().description = + format!("Contribution within {}", p.elements[&owner].name); + id +} +fn link(p: &mut Project, source: Id, target: Id, guard: &str) -> Id { + let id = p.relate( + RelationKind::SequenceLink, + "Next contribution", + source, + target, + ); + if let RelationBody::SequenceLink { guard: value, .. } = + &mut p.relations.get_mut(&id).unwrap().body + { + *value = guard.into(); + } + id +} +fn branch_fixture(layer: Layer) -> (Project, Id, Id) { + let mut p = Project::empty(); + let main = p + .add_element(kinds(layer).0, layer, "Respond to distress", None) + .unwrap(); + let reusable = p + .add_element(kinds(layer).0, layer, "Prepare resources", None) + .unwrap(); + action(&mut p, reusable, "Mobilize responder"); + let start = action(&mut p, main, "Assess distress"); + let fork = add_occurrence( + &mut p, + main, + "Prepare in parallel", + OccurrenceBody::Control { + kind: ControlKind::Fork, + }, + ); + let locate = action(&mut p, main, "Locate vessel"); + let reuse = add_occurrence( + &mut p, + main, + "Use preparations", + OccurrenceBody::SequenceReference { + sequence: Some(reusable), + }, + ); + let join = add_occurrence( + &mut p, + main, + "Both preparations ready", + OccurrenceBody::Control { + kind: ControlKind::Join, + }, + ); + let choice = add_occurrence( + &mut p, + main, + "Select response", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let direct = action(&mut p, main, "Dispatch rescue"); + let fallback = action(&mut p, main, "Request assistance"); + let merge = add_occurrence( + &mut p, + main, + "Response selected", + OccurrenceBody::Control { + kind: ControlKind::Merge, + }, + ); + let end = action(&mut p, main, "Confirm response"); + for (source, target, guard) in [ + (start, fork, ""), + (fork, locate, ""), + (fork, reuse, ""), + (locate, join, ""), + (reuse, join, ""), + (join, choice, ""), + (choice, direct, "available_resources > 0"), + (choice, fallback, "else"), + (direct, merge, ""), + (fallback, merge, ""), + (merge, end, ""), + ] { + link(&mut p, source, target, guard); + } + (p, main, reusable) +} +fn errors(p: &Project) -> Vec { + validation::validate(p) + .into_iter() + .filter(|i| i.severity == Severity::Error) + .collect() +} +fn coherent_path(p: &Project) { + // These contextual-path fixtures deliberately leave the global system boundary + // and performer allocation open. + let unexpected: Vec<_> = validation::validate(p) + .into_iter() + .filter(|issue| { + issue.severity != Severity::Warning + || !matches!(issue.code.as_str(), "unallocated_leaf" | "missing_system") + }) + .collect(); + assert!(unexpected.is_empty(), "{unexpected:?}"); +} + +#[test] +fn branching_reusable_processes_transition_as_contextual_topology_in_both_directions() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let (p, main, reusable) = branch_fixture(direction.source_layer()); + coherent_path(&p); + let mut request = TransitionRequest::new(direction, vec![main]); + request.scope = DependencyScope::Required; + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let mapped = |id| { + plan.changes + .iter() + .find(|change| change.source == id) + .unwrap() + .target + }; + let candidate = apply_transition(&p, &plan).unwrap(); + assert_eq!( + candidate.elements[&mapped(main)].kind(), + kinds(direction.target_layer()).0 + ); + assert_eq!( + candidate.elements[&mapped(reusable)].kind(), + kinds(direction.target_layer()).0 + ); + for element in p.elements.values() { + let counterpart = &candidate.elements[&mapped(element.id)]; + assert_eq!(counterpart.description, element.description); + let expected: Vec<_> = references::element_references(element) + .into_iter() + .map(mapped) + .collect(); + assert_eq!(references::element_references(counterpart), expected); + if element.kind() == Kind::Occurrence { + let trace = candidate + .traces + .values() + .find(|trace| trace.oa == element.id || trace.sa == element.id) + .unwrap(); + assert_eq!(trace.kind, TraceKind::Sequence); + } + } + for relation in p.relations.values() { + let counterpart = &candidate.relations[&mapped(relation.id)]; + let mut expected = relation.body.clone(); + expected.set_endpoints(mapped(relation.source()), mapped(relation.target())); + assert_eq!(counterpart.body, expected); + } + assert!( + candidate + .relations + .values() + .all(|r| r.kind() == RelationKind::SequenceLink), + "contextual precedence never fabricates global exchanges" + ); + let mut session = Session::new(p.clone()); + session.execute_many(&plan.commands).unwrap(); + assert_eq!(session.project(), &candidate); + session.undo(); + assert_eq!(session.project(), &p); + session.redo(); + assert_eq!(session.project(), &candidate); + let mut reverse = TransitionRequest::new( + if direction == TransitionDirection::OaToSa { + TransitionDirection::SaToOa + } else { + TransitionDirection::OaToSa + }, + vec![mapped(main)], + ); + reverse.scope = DependencyScope::Required; + let repeat = plan_transition(&candidate, &reverse).unwrap(); + assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts); + assert!(repeat.commands.is_empty()); + } +} + +#[test] +fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() { + for layer in [Layer::Oa, Layer::Sa] { + let mut p = Project::empty(); + let owner = p + .add_element(kinds(layer).0, layer, "Context", None) + .unwrap(); + let a = action(&mut p, owner, "A"); + let b = action(&mut p, owner, "B"); + let wrong = action(&mut p, owner, "Wrong"); + let work = |p: &Project, id| match p.elements[&id].shared().unwrap() { + SharedBody::Occurrence { + body: + OccurrenceBody::Action { + action: Some(work), .. + }, + .. + } => *work, + _ => unreachable!(), + }; + let global = p.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + "A sends to B", + work(&p, a), + work(&p, b), + ); + let exchange = add_occurrence( + &mut p, + owner, + "Contextual message", + OccurrenceBody::Exchange { + exchange: Some(global), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }, + ); + let other = p + .add_element(kinds(layer).0, layer, "Other context", None) + .unwrap(); + let foreign = action(&mut p, other, "Foreign"); + for (source, target) in [(a, exchange), (exchange, b)] { + assert!(validation::can_connect( + &p, + RelationKind::SequenceLink, + source, + target + )); + } + for (source, target) in [(wrong, exchange), (exchange, wrong), (a, foreign), (a, a)] { + assert!(!validation::can_connect( + &p, + RelationKind::SequenceLink, + source, + target + )); + assert!( + execute_batch( + &p, + &[Command::Connect( + RelationKind::SequenceLink, + "Invalid".into(), + source, + target + )] + ) + .is_err() + ); + } + let original_exchange = p.relations[&global].clone(); + let accepted = execute_batch( + &p, + &[ + Command::Connect(RelationKind::SequenceLink, "Before".into(), a, exchange), + Command::Connect(RelationKind::SequenceLink, "After".into(), exchange, b), + ], + ) + .unwrap(); + assert_eq!(accepted.relations[&global], original_exchange); + let scenario = p + .add_element(Kind::Scenario, layer, "Timeline", None) + .unwrap(); + let invalid = occurrence( + &p, + scenario, + "Wrong control context", + OccurrenceBody::Control { + kind: ControlKind::Fork, + }, + ); + assert!( + execute_batch(&p, &[Command::CreateElement(invalid)]) + .unwrap_err() + .0 + .contains("sequence_contribution_owner") + ); + } +} + +#[test] +fn reuse_rejects_recursive_or_cross_layer_targets_and_deleted_targets_become_drafts() { + let mut p = Project::empty(); + let a = p.add_element(Kind::Process, Layer::Oa, "A", None).unwrap(); + let b = p.add_element(Kind::Process, Layer::Oa, "B", None).unwrap(); + let c = p.add_element(Kind::Process, Layer::Oa, "C", None).unwrap(); + let use_b = add_occurrence( + &mut p, + a, + "Reuse B", + OccurrenceBody::SequenceReference { sequence: Some(b) }, + ); + add_occurrence( + &mut p, + b, + "Reuse C", + OccurrenceBody::SequenceReference { sequence: Some(c) }, + ); + let foreign = p + .add_element(Kind::FunctionalChain, Layer::Sa, "SA sequence", None) + .unwrap(); + let mut session = Session::new(p.clone()); + for target in [a, c, foreign] { + let invalid = occurrence( + session.project(), + c, + "Invalid reuse", + OccurrenceBody::SequenceReference { + sequence: Some(target), + }, + ); + assert!(session.execute(Command::CreateElement(invalid)).is_err()); + assert_eq!(session.project(), &p); + } + let new_b = Id::new_v4(); + session.execute(Command::Reidentify(b, new_b)).unwrap(); + assert!(references::element_references(&session.project().elements[&use_b]).contains(&new_b)); + let reidentified = session.project().clone(); + session.execute(Command::Delete(new_b)).unwrap(); + assert!(session.project().elements.contains_key(&use_b)); + assert!(matches!( + session.project().elements[&use_b].shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence: None }, + .. + }) + )); + assert!(session.project().elements.contains_key(&c)); + assert!(errors(session.project()).is_empty()); + assert!( + validation::validate(session.project()) + .iter() + .any(|issue| issue.element == Some(use_b) && issue.code == "incomplete_reference") + ); + session.undo(); + assert_eq!(session.project(), &reidentified); + session.undo(); + assert_eq!(session.project(), &p); +} + +#[test] +fn loops_are_allowed_while_missing_guards_and_disconnected_paths_are_diagnosed() { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::Process, Layer::Oa, "Retry until ready", None) + .unwrap(); + let start = action(&mut p, owner, "Start"); + let gate = add_occurrence( + &mut p, + owner, + "Ready?", + OccurrenceBody::Control { + kind: ControlKind::Loop, + }, + ); + let repeat = action(&mut p, owner, "Retry"); + let end = action(&mut p, owner, "Finish"); + link(&mut p, start, gate, ""); + link(&mut p, gate, repeat, "!ready"); + link(&mut p, repeat, gate, ""); + let exit = link(&mut p, gate, end, "ready"); + coherent_path(&p); + if let RelationBody::SequenceLink { guard, .. } = &mut p.relations.get_mut(&exit).unwrap().body + { + guard.clear(); + } + assert!(errors(&p).is_empty()); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(exit) && i.code == "missing_sequence_guard") + ); + let island = action(&mut p, owner, "Disconnected work"); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(island) && i.code == "isolated_contribution") + ); + p.relations.remove(&exit); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(gate) && i.code == "control_cardinality") + ); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.element == Some(gate) && i.code == "nonterminating_contribution") + ); +} + +#[test] +fn topology_identity_edits_and_deletions_preserve_remaining_paths_and_undo() { + let (p, main, _) = branch_fixture(Layer::Sa); + for id in p.elements.keys().chain(p.relations.keys()).copied() { + let mut session = Session::new(p.clone()); + let replacement = Id::new_v4(); + session + .execute(Command::Reidentify(id, replacement)) + .unwrap(); + let reidentified = session.project().clone(); + assert!( + !reidentified + .elements + .values() + .any(|e| references::element_references(e).contains(&id)) + ); + assert!( + !reidentified + .relations + .values() + .any(|r| references::relation_references(r).contains(&id)) + ); + session.execute(Command::Delete(replacement)).unwrap(); + assert!(errors(session.project()).is_empty()); + session.undo(); + assert_eq!(session.project(), &reidentified); + session.undo(); + assert_eq!(session.project(), &p); + } + // List order is presentation order: changing it leaves explicit links untouched. + let mut session = Session::new(p.clone()); + session.execute(Command::MoveStep(main, 0, 4)).unwrap(); + assert_eq!(session.project().relations, p.relations); + coherent_path(session.project()); +} + +#[test] +fn legacy_linear_exchange_only_sequences_keep_their_existing_meaning() { + let mut p = Project::empty(); + let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap(); + let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap(); + let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap(); + let owner = p + .add_element(Kind::Process, Layer::Oa, "Legacy order", None) + .unwrap(); + let ab = p.relate(RelationKind::Interaction, "AB", a, b); + let ca = p.relate(RelationKind::Interaction, "CA", c, a); + let mut session = Session::new(p); + session.execute(Command::AddStep(owner, ab)).unwrap(); + session.execute(Command::AddStep(owner, ca)).unwrap(); + let issues = validation::validate(session.project()); + assert!(issues.iter().any(|i| i.code == "sequence_gap")); + assert!( + !issues + .iter() + .any(|i| i.code == "isolated_contribution" || i.code == "sequence_starts") + ); + assert!( + session + .project() + .relations + .values() + .all(|r| r.kind() != RelationKind::SequenceLink) + ); +} diff --git a/crates/oa-core/tests/review_regressions.rs b/crates/oa-core/tests/review_regressions.rs new file mode 100644 index 00000000..fc5ff864 --- /dev/null +++ b/crates/oa-core/tests/review_regressions.rs @@ -0,0 +1,575 @@ +use oa_core::{ + persistence::{self, FileError}, + transitions::*, + *, +}; + +#[test] +fn unowned_state_machine_is_valid_incomplete_work_in_both_layers() { + for layer in [Layer::Oa, Layer::Sa] { + let p = Project::empty(); + assert!(validation::can_parent(&p, Kind::StateMachine, layer, None)); + let machine = p + .make_element(Kind::StateMachine, layer, "Draft machine", None) + .unwrap(); + let id = machine.id; + let created = execute_batch(&p, &[Command::CreateElement(machine)]).unwrap(); + let issues = validation::validate(&created); + assert!( + issues + .iter() + .all(|i| i.severity != validation::Severity::Error) + ); + assert!(issues.iter().any(|i| i.element == Some(id) + && i.code == "machine_owner" + && i.severity == validation::Severity::Warning)); + assert!(!validation::can_parent(&created, Kind::Region, layer, None)); + } +} + +fn placement_fixture() -> (Project, Id, Id, Id) { + let mut p = Project::empty(); + let element = p + .add_element(Kind::Activity, Layer::Oa, "Placed work", None) + .unwrap(); + let source = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Decomposition; + let target = p.ensure_diagram(Layer::Oa); + let node = p.place(source, element, Position { x: 123., y: -42. }); + let placement = p + .diagrams + .get_mut(&source) + .unwrap() + .nodes + .get_mut(&node) + .unwrap(); + placement.collapsed = true; + placement.size = Size { + width: 350., + height: 135., + }; + let artifact = Id::new_v4(); + p.artifact_links.insert( + artifact, + ArtifactLink { + id: artifact, + alias: "placement_evidence".into(), + name: "Placement evidence".into(), + description: "Retained with the same placement".into(), + owner: node, + relation: ArtifactRelation::References, + uri: "git:designs/layout#view".into(), + revision: Some("layout-v2".into()), + fragment: None, + }, + ); + (p, source, target, node) +} + +#[test] +fn relocating_a_placement_preserves_identity_artifacts_and_history() { + let (p, source, target, node) = placement_fixture(); + let original = p.diagrams[&source].nodes[&node].clone(); + let command = Command::RelocateNode { + node, + diagram: target, + }; + let json = serde_json::to_string(&command).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), command); + let mut session = Session::new(p.clone()); + assert_eq!(session.execute(command.clone()).unwrap(), Some(node)); + assert!( + !session.project().diagrams[&source] + .nodes + .contains_key(&node) + ); + assert_eq!(session.project().diagrams[&target].nodes[&node], original); + assert_eq!(session.project().artifact_links, p.artifact_links); + assert_eq!(session.project().elements, p.elements); + let revision = session.revision(); + let relocated = session.project().clone(); + session.execute(command).unwrap(); + assert_eq!(session.revision(), revision); + session.undo(); + assert_eq!(session.project(), &p); + session.redo(); + assert_eq!(session.project(), &relocated); +} + +#[test] +fn invalid_placement_relocations_preserve_model_artifacts_and_redo() { + let (mut p, source, target, node) = placement_fixture(); + let element = p.diagrams[&source].nodes[&node].element; + p.place(target, element, Position { x: 0., y: 0. }); + let incompatible = p.ensure_diagram(Layer::Sa); + let mut session = Session::new(p.clone()); + session + .execute(Command::Rename(element, "Pending redo".into())) + .unwrap(); + session.undo(); + let revision = session.revision(); + for diagram in [target, incompatible, Id::new_v4()] { + assert!( + session + .execute(Command::RelocateNode { node, diagram }) + .is_err() + ); + assert_eq!(session.project(), &p); + assert_eq!(session.revision(), revision); + assert!(session.can_redo()); + } + assert!( + execute_batch( + &p, + &[Command::RelocateNode { + node: Id::new_v4(), + diagram: source + }] + ) + .is_err() + ); + let mut traceability = p.diagrams[&incompatible].clone(); + traceability.projection = ProjectionKind::Traceability; + let accepted = execute_batch( + &p, + &[ + Command::EditDiagram(incompatible, traceability), + Command::RelocateNode { + node, + diagram: incompatible, + }, + ], + ) + .unwrap(); + assert_eq!( + accepted.diagrams[&incompatible].nodes[&node], + p.diagrams[&source].nodes[&node] + ); + assert_eq!(accepted.artifact_links, p.artifact_links); +} + +fn reviewed_divergence(direction: TransitionDirection) -> (Project, Id, Id) { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::Activity, Layer::Oa, "Operational assessment", None) + .unwrap(); + let sa = p + .add_element(Kind::Function, Layer::Sa, "System classification", None) + .unwrap(); + p.elements.get_mut(&oa).unwrap().description = "Operational intent".into(); + p.elements.get_mut(&sa).unwrap().description = "Technical responsibility".into(); + let (source, target) = match direction { + TransitionDirection::OaToSa => (oa, sa), + TransitionDirection::SaToOa => (sa, oa), + }; + let mut request = TransitionRequest::new(direction, vec![source]); + request.mappings.push(TransitionMapping { + source, + target: Some(target), + target_kind: None, + }); + for field in [TransitionField::Name, TransitionField::Description] { + request.resolutions.push(FieldResolution { + source, + field, + decision: FieldDecision::KeepTarget, + }); + } + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let reviewed = apply_transition(&p, &plan).unwrap(); + assert_eq!(reviewed.properties(source), p.properties(source)); + assert_eq!(reviewed.properties(target), p.properties(target)); + (reviewed, source, target) +} + +fn reverse(direction: TransitionDirection) -> TransitionDirection { + match direction { + TransitionDirection::OaToSa => TransitionDirection::SaToOa, + TransitionDirection::SaToOa => TransitionDirection::OaToSa, + } +} + +#[test] +fn keeping_divergent_values_is_idempotent_in_both_directions() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let (p, source, target) = reviewed_divergence(direction); + for (direction, source) in [(direction, source), (reverse(direction), target)] { + let plan = + plan_transition(&p, &TransitionRequest::new(direction, vec![source])).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + assert!(plan.commands.is_empty(), "{:?}", plan.commands); + assert!( + plan.changes + .iter() + .all(|c| c.action == TransitionAction::Reuse && c.fields.is_empty()) + ); + assert_eq!(apply_transition(&p, &plan).unwrap(), p); + } + } +} + +#[test] +fn later_source_changes_propagate_without_overwriting_other_reviewed_fields() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let (p, source, target) = reviewed_divergence(direction); + let target_description = p.elements[&target].description.clone(); + let edited = + execute_batch(&p, &[Command::Rename(source, "New source decision".into())]).unwrap(); + let request = TransitionRequest::new(direction, vec![source]); + let plan = plan_transition(&edited, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + assert_eq!(plan.changes[0].fields.len(), 1); + assert_eq!(plan.changes[0].fields[0].field, TransitionField::Name); + let applied = apply_transition(&edited, &plan).unwrap(); + assert_eq!(applied.elements[&target].name, "New source decision"); + assert_eq!(applied.elements[&target].description, target_description); + let repeat = plan_transition(&applied, &request).unwrap(); + assert!(repeat.conflicts.is_empty()); + assert!(repeat.commands.is_empty()); + } +} + +#[test] +fn destination_only_changes_remain_available_for_reverse_propagation() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let (p, source, target) = reviewed_divergence(direction); + let edited = execute_batch( + &p, + &[Command::Rename(target, "Destination local change".into())], + ) + .unwrap(); + let forward = + plan_transition(&edited, &TransitionRequest::new(direction, vec![source])).unwrap(); + assert!(forward.conflicts.is_empty(), "{:?}", forward.conflicts); + assert!( + forward.commands.is_empty(), + "A skipped field must retain its previous baseline" + ); + assert_eq!(apply_transition(&edited, &forward).unwrap(), edited); + let back = plan_transition( + &edited, + &TransitionRequest::new(reverse(direction), vec![target]), + ) + .unwrap(); + assert!(back.conflicts.is_empty(), "{:?}", back.conflicts); + let applied = apply_transition(&edited, &back).unwrap(); + assert_eq!(applied.elements[&source].name, "Destination local change"); + } +} + +#[test] +fn divergent_changes_on_both_sides_still_require_review() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let (p, source, target) = reviewed_divergence(direction); + let changed = execute_batch( + &p, + &[ + Command::Rename(source, "Changed source".into()), + Command::Rename(target, "Changed target".into()), + ], + ) + .unwrap(); + let request = TransitionRequest::new(direction, vec![source]); + let plan = plan_transition(&changed, &request).unwrap(); + assert!( + plan.conflicts + .iter() + .any(|c| c.field == Some(TransitionField::Name)) + ); + assert!(apply_transition(&changed, &plan).is_err()); + assert_eq!(changed.elements[&target].name, "Changed target"); + } +} + +#[test] +fn an_explicit_merged_destination_stays_reviewed_on_repeated_preview() { + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let (p, source, target) = reviewed_divergence(direction); + let mut request = TransitionRequest::new(direction, vec![source]); + request.resolutions.push(FieldResolution { + source, + field: TransitionField::Name, + decision: FieldDecision::Value("Reviewed merged name".into()), + }); + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty()); + let applied = apply_transition(&p, &plan).unwrap(); + assert_eq!(applied.elements[&target].name, "Reviewed merged name"); + let repeated = + plan_transition(&applied, &TransitionRequest::new(direction, vec![source])).unwrap(); + assert!(repeated.conflicts.is_empty()); + assert!(repeated.commands.is_empty()); + } +} + +#[test] +fn same_parent_reparent_preserves_sequence_order_revision_and_redo() { + let mut p = Project::empty(); + let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap(); + let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap(); + let exchange = p.relate(RelationKind::Interaction, "Message", a, b); + let sequence = p + .add_element(Kind::Process, Layer::Oa, "Procedure", None) + .unwrap(); + let mut session = Session::new(p); + for _ in 0..3 { + session + .execute(Command::AddStep(sequence, exchange)) + .unwrap(); + } + let first = session.project().elements[&sequence].steps().unwrap()[0]; + session + .execute(Command::Rename(sequence, "Temporary name".into())) + .unwrap(); + session.undo(); + let before = session.project().clone(); + let revision = session.revision(); + session + .execute(Command::Reparent { + element: first, + parent: Some(sequence), + }) + .unwrap(); + assert_eq!(session.project(), &before); + assert_eq!(session.revision(), revision); + assert!(session.can_redo()); +} + +fn fragment_session(layer: Layer) -> (Session, Id) { + let mut p = Project::empty(); + let scenario = p + .add_element(Kind::Scenario, layer, "Scenario", None) + .unwrap(); + let mut fragment = p + .make_element(Kind::Occurrence, layer, "Alternative", Some(scenario)) + .unwrap(); + *fragment.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + let id = fragment.id; + let mut session = Session::new(p); + session.execute(Command::CreateElement(fragment)).unwrap(); + (session, id) +} + +fn branches(p: &Project, fragment: Id) -> &Vec { + match p.elements[&fragment].shared().unwrap() { + SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + } => branches, + _ => panic!("Expected fragment"), + } +} + +#[test] +fn same_parent_reparent_preserves_fragment_branch_order_and_history() { + for layer in [Layer::Oa, Layer::Sa] { + let (mut session, fragment) = fragment_session(layer); + for name in ["First branch", "Second branch", "Third branch"] { + session + .execute(Command::AddElement { + kind: Kind::Occurrence, + layer, + name: name.into(), + parent: Some(fragment), + }) + .unwrap(); + } + let first = branches(session.project(), fragment)[0]; + session + .execute(Command::Rename(fragment, "Temporary name".into())) + .unwrap(); + session.undo(); + let before = session.project().clone(); + let revision = session.revision(); + session + .execute(Command::Reparent { + element: first, + parent: Some(fragment), + }) + .unwrap(); + assert_eq!(session.project(), &before); + assert_eq!(session.revision(), revision); + assert!(session.can_redo()); + } +} + +#[test] +fn factory_and_add_commands_share_fragment_branch_defaults() { + for layer in [Layer::Oa, Layer::Sa] { + let (mut session, fragment) = fragment_session(layer); + let child = session + .project() + .make_element(Kind::Occurrence, layer, "Factory branch", Some(fragment)) + .unwrap(); + let factory_id = child.id; + assert!(matches!( + child.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) + )); + session.execute(Command::CreateElement(child)).unwrap(); + let added_id = session + .execute(Command::AddElement { + kind: Kind::Occurrence, + layer, + name: "Command branch".into(), + parent: Some(fragment), + }) + .unwrap() + .unwrap(); + assert_eq!( + session.project().elements[&factory_id].body, + session.project().elements[&added_id].body + ); + assert_eq!( + branches(session.project(), fragment), + &vec![factory_id, added_id] + ); + let step = session + .project() + .make_element(Kind::Occurrence, layer, "Branch message", Some(factory_id)) + .unwrap(); + assert!(matches!( + step.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) + )); + session.execute(Command::CreateElement(step)).unwrap(); + } +} + +#[test] +fn checked_save_returns_exact_bytes_and_accepts_unchanged_external_content() { + let dir = tempfile::tempdir().unwrap(); + for name in ["project.arcadia", "project.oa.json"] { + let path = dir.path().join(name); + let mut p = Project::empty(); + let previous = persistence::save_checked(&path, &p, None).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), previous); + p.description = "A checked update with Unicode 海岸 and a newline\n".into(); + let written = persistence::save_checked(&path, &p, Some(&previous)).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), written); + assert_eq!(persistence::decode(&written).unwrap(), p); + let same = persistence::save_checked(&path, &p, Some(&written)).unwrap(); + assert_eq!(same, written); + assert_eq!(std::fs::read(&path).unwrap(), written); + } +} + +#[test] +fn checked_save_rejects_external_changes_and_preserves_their_exact_bytes() { + let dir = tempfile::tempdir().unwrap(); + for name in ["project.arcadia", "project.oa.json"] { + let path = dir.path().join(name); + let mut p = Project::empty(); + let previous = persistence::save_checked(&path, &p, None).unwrap(); + let external = b"External editor content, including an unfinished draft\r\n"; + std::fs::write(&path, external).unwrap(); + p.name = "Local unsaved changes".into(); + assert!(matches!( + persistence::save_checked(&path, &p, Some(&previous)), + Err(FileError::Conflict) + )); + assert_eq!(std::fs::read(&path).unwrap(), external); + } +} + +#[test] +fn checked_save_rejects_external_deletion_without_recreating_the_file() { + let dir = tempfile::tempdir().unwrap(); + for name in ["project.arcadia", "project.oa.json"] { + let path = dir.path().join(name); + let p = Project::empty(); + let previous = persistence::save_checked(&path, &p, None).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert!(matches!( + persistence::save_checked(&path, &p, Some(&previous)), + Err(FileError::Conflict) + )); + assert!(!path.exists()); + } + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); +} + +#[test] +fn capability_mission_transition_remains_an_explicit_choice_in_both_directions() { + assert_eq!( + counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa), + vec![Kind::SystemCapability, Kind::Mission] + ); + assert_eq!( + counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa), + vec![Kind::OperationalMission, Kind::Capability] + ); + for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] { + let mut p = Project::empty(); + let (kind, layer) = match direction { + TransitionDirection::OaToSa => (Kind::Capability, Layer::Oa), + TransitionDirection::SaToOa => (Kind::Mission, Layer::Sa), + }; + let source = p.add_element(kind, layer, "Protect life", None).unwrap(); + let mut request = TransitionRequest::new(direction, vec![source]); + if direction == TransitionDirection::OaToSa { + let default_plan = plan_transition(&p, &request).unwrap(); + let default = apply_transition(&p, &default_plan).unwrap(); + assert_eq!( + default.elements[&default_plan.changes[0].target].kind(), + Kind::SystemCapability + ); + request.mappings.push(TransitionMapping { + source, + target: None, + target_kind: Some(Kind::Mission), + }); + } else { + let default_plan = plan_transition(&p, &request).unwrap(); + let default = apply_transition(&p, &default_plan).unwrap(); + assert_eq!( + default.elements[&default_plan.changes[0].target].kind(), + Kind::OperationalMission + ); + request.mappings.push(TransitionMapping { + source, + target: None, + target_kind: Some(Kind::Capability), + }); + } + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let target = plan.changes[0].target; + let applied = apply_transition(&p, &plan).unwrap(); + assert_eq!( + applied.elements[&target].kind(), + if direction == TransitionDirection::OaToSa { + Kind::Mission + } else { + Kind::Capability + } + ); + assert_eq!(applied.traces.len(), 1); + assert_eq!( + applied.traces.values().next().unwrap().kind, + TraceKind::Manual + ); + for (direction, source) in [(direction, source), (reverse(direction), target)] { + let repeat = + plan_transition(&applied, &TransitionRequest::new(direction, vec![source])) + .unwrap(); + assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts); + assert!(repeat.commands.is_empty()); + assert_eq!(apply_transition(&applied, &repeat).unwrap(), applied); + } + } +} diff --git a/crates/oa-core/tests/workflows.rs b/crates/oa-core/tests/workflows.rs new file mode 100644 index 00000000..b4e511dc --- /dev/null +++ b/crates/oa-core/tests/workflows.rs @@ -0,0 +1,859 @@ +use oa_core::{ + projection, + transitions::*, + validation::{self, Severity}, + *, +}; +fn accepted(p: &Project) { + let errors: Vec<_> = validation::validate(p) + .into_iter() + .filter(|i| i.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); +} +fn rejected(p: &Project, code: &str) { + assert!( + validation::validate(p) + .iter() + .any(|i| i.severity == Severity::Error && i.code == code), + "Expected {code}: {:?}", + validation::validate(p) + ); +} +fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option) -> Id { + p.add_element(kind, layer, name, parent).unwrap() +} +fn work_pair() -> (Project, Id, Id) { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None); + let sa = add(&mut p, Kind::Function, Layer::Sa, "Classify", None); + (p, oa, sa) +} +fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id { + let id = Id::new_v4(); + let t = Trace { + id, + alias: p.fresh_alias("work_trace", "trace", id), + name: "Refinement".into(), + description: "Rationale".into(), + oa, + sa, + kind: TraceKind::Refinement, + baseline: Some(TraceBaseline { + oa: p.properties(oa).unwrap(), + sa: p.properties(sa).unwrap(), + }), + }; + p.traces.insert(id, t); + id +} +fn artifact(p: &mut Project, owner: Id) -> Id { + let id = Id::new_v4(); + p.artifact_links.insert( + id, + ArtifactLink { + id, + alias: p.fresh_alias("requirement", "artifact", id), + name: "REQ-42".into(), + description: "Engineering requirement".into(), + owner, + relation: ArtifactRelation::Satisfies, + uri: "requirements+custom://project/REQ-42?view=full".into(), + revision: Some("baseline-2026/September".into()), + fragment: Some("verification".into()), + }, + ); + id +} + +#[test] +fn demo_is_valid_and_preserves_uuid_and_alias_through_json() { + let p = Project::demo(); + accepted(&p); + let bytes = serde_json::to_vec(&p).unwrap(); + assert_eq!(serde_json::from_slice::(&bytes).unwrap(), p); + assert!( + p.elements + .values() + .find(|e| e.kind() == Kind::Process) + .unwrap() + .steps() + .unwrap() + .iter() + .all(|id| p.elements[id].kind() == Kind::Occurrence) + ); +} +#[test] +fn failed_batch_preserves_model_history_revision_and_redo() { + let (mut p, oa, _) = work_pair(); + let actor = add(&mut p, Kind::Actor, Layer::Oa, "Reporter", None); + let mut s = Session::new(p); + s.execute(Command::Rename(oa, "Updated".into())).unwrap(); + s.undo(); + let before = s.project().clone(); + let revision = s.revision(); + let result = s.execute_many(&[ + Command::Rename(oa, "Would be lost".into()), + Command::Connect( + RelationKind::FunctionalExchange, + "Invalid".into(), + actor, + oa, + ), + ]); + assert!(result.is_err()); + assert_eq!(s.project(), &before); + assert_eq!(s.revision(), revision); + assert!(s.can_redo()); +} +#[test] +fn batch_is_one_undo_and_noop_does_not_change_revision() { + let (p, oa, _) = work_pair(); + let mut s = Session::new(p.clone()); + s.execute_many(&[ + Command::Rename(oa, "Renamed".into()), + Command::Describe(oa, "Detailed".into()), + ]) + .unwrap(); + assert_eq!(s.revision(), 1); + s.undo(); + assert_eq!(s.project(), &p); + assert!(!s.dirty()); + let revision = s.revision(); + s.execute_many(&[]).unwrap(); + assert_eq!(s.revision(), revision); + assert!(s.can_redo()); +} +#[test] +fn identity_aliases_and_map_keys_share_global_namespace() { + let (mut p, oa, _) = work_pair(); + let d = p.ensure_diagram(Layer::Oa); + let node = p.place(d, oa, Position { x: 0., y: 0. }); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .alias = p.elements[&oa].alias.clone(); + rejected(&p, "duplicate_alias"); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .alias = "placed_assess".into(); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .id = oa; + rejected(&p, "duplicate_identity"); + rejected(&p, "map_identity"); +} +#[test] +fn rename_and_collision_insertion_preserve_existing_aliases() { + let (mut p, oa, _) = work_pair(); + let alias = p.elements[&oa].alias.clone(); + p = execute_batch( + &p, + &[Command::Rename(oa, "Completely new display name".into())], + ) + .unwrap(); + assert_eq!(p.elements[&oa].alias, alias); + let first = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None); + let first_alias = p.elements[&first].alias.clone(); + let second = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None); + assert_eq!(p.elements[&first].alias, first_alias); + assert_ne!(p.elements[&second].alias, first_alias); + accepted(&p); +} +#[test] +fn nested_work_is_independent_of_leaf_allocation() { + let mut p = Project::empty(); + let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None); + let parent = add(&mut p, Kind::Activity, Layer::Oa, "Parent", None); + let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(parent)); + p.relate(RelationKind::Allocation, "Performs", actor, child); + accepted(&p); + assert_eq!(validation::allocation_summary(&p, parent), Some(actor)); + let before = p.clone(); + assert!( + execute_batch( + &p, + &[Command::Connect( + RelationKind::Allocation, + "Invalid parent allocation".into(), + actor, + parent + )] + ) + .is_err() + ); + assert_eq!(p, before); + let other = add( + &mut p, + Kind::Activity, + Layer::Oa, + "Unallocated child", + Some(parent), + ); + assert!(p.elements.contains_key(&other)); + assert_eq!(validation::allocation_summary(&p, parent), None); +} +#[test] +fn parent_cycles_cross_layer_ownership_and_multiple_performers_reject() { + let (mut p, oa, sa) = work_pair(); + p.elements.get_mut(&oa).unwrap().parent = Some(sa); + rejected(&p, "invalid_parent"); + p.elements.get_mut(&oa).unwrap().parent = None; + let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa)); + p.elements.get_mut(&oa).unwrap().parent = Some(child); + rejected(&p, "containment_cycle"); + p.elements.get_mut(&oa).unwrap().parent = None; + let a = add(&mut p, Kind::Actor, Layer::Oa, "A", None); + let b = add(&mut p, Kind::Actor, Layer::Oa, "B", None); + p.relate(RelationKind::Allocation, "A performs", a, child); + p.relate(RelationKind::Allocation, "B performs", b, child); + rejected(&p, "multiple_performers"); +} +#[test] +fn parent_and_endpoint_queries_match_creation_rules() { + let mut p = Project::empty(); + let system = add(&mut p, Kind::System, Layer::Sa, "System", None); + let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None); + assert!(validation::can_parent( + &p, + Kind::Port, + Layer::Sa, + Some(system) + )); + assert!(!validation::can_parent(&p, Kind::Port, Layer::Sa, None)); + assert!(!validation::can_parent( + &p, + Kind::Port, + Layer::Oa, + Some(system) + )); + assert!(validation::can_connect( + &p, + RelationKind::Allocation, + system, + function + )); + p.relate(RelationKind::Allocation, "Performs", system, function); + assert!(!validation::can_connect( + &p, + RelationKind::Allocation, + system, + function + )); +} +#[test] +fn create_occurrence_attaches_once_and_repeats_exchange_by_distinct_ids() { + let p = Project::demo(); + let process = p + .elements + .values() + .find(|e| e.kind() == Kind::Process) + .unwrap() + .id; + let exchange = p + .relations + .values() + .find(|r| r.kind() == RelationKind::Interaction) + .unwrap() + .id; + let p = execute_batch( + &p, + &[ + Command::AddStep(process, exchange), + Command::AddStep(process, exchange), + ], + ) + .unwrap(); + let steps = p.elements[&process].steps().unwrap(); + assert_eq!(steps.len(), 3); + assert_ne!(steps[0], steps[1]); + assert_ne!(steps[1], steps[2]); + accepted(&p); + let mut bad = p.clone(); + bad.elements + .get_mut(&process) + .unwrap() + .steps_mut() + .unwrap() + .push(steps[0]); + rejected(&bad, "duplicate_reference"); +} +#[test] +fn scenario_lifelines_and_occurrences_require_same_context() { + let mut p = Project::empty(); + let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None); + let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Scenario", None); + let other = add(&mut p, Kind::Scenario, Layer::Oa, "Other scenario", None); + let line = add(&mut p, Kind::Lifeline, Layer::Oa, "Actor line", Some(other)); + *p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(actor), + }; + let action = add(&mut p, Kind::Activity, Layer::Oa, "Action", None); + let mut occurrence = p + .make_element( + Kind::Occurrence, + Layer::Oa, + "Action occurrence", + Some(scenario), + ) + .unwrap(); + *occurrence.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Action { + action: Some(action), + lifeline: Some(line), + }, + precondition: "Before".into(), + postcondition: "After".into(), + }; + p.elements.insert(occurrence.id, occurrence); + rejected(&p, "lifeline_context"); +} +#[test] +fn items_ports_interfaces_and_exchange_allocation_are_typed() { + let mut p = Project::empty(); + let system = add(&mut p, Kind::System, Layer::Sa, "System", None); + let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None); + let f1 = add(&mut p, Kind::Function, Layer::Sa, "Send", None); + let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None); + let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None); + let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None); + *p.elements + .get_mut(&interface) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Interface { items: vec![item] }; + let port = |p: &mut Project, name: &str, parent, direction| { + let id = add(p, Kind::Port, Layer::Sa, name, Some(parent)); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port { + direction, + interface: Some(interface), + }; + id + }; + let out = port(&mut p, "System output", system, PortDirection::Out); + let input = port(&mut p, "Actor input", actor, PortDirection::In); + let fp1 = port(&mut p, "Function output", f1, PortDirection::Out); + let fp2 = port(&mut p, "Function input", f2, PortDirection::In); + let boundary = p.relate(RelationKind::BoundaryExchange, "Boundary link", out, input); + if let RelationBody::BoundaryExchange { items, .. } = + &mut p.relations.get_mut(&boundary).unwrap().body + { + items.push(item); + } + let exchange = p.relate(RelationKind::FunctionalExchange, "Message exchange", f1, f2); + p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange { + source: f1, + target: f2, + source_port: Some(fp1), + target_port: Some(fp2), + items: vec![item], + }; + p.relate( + RelationKind::ExchangeAllocation, + "Carried on", + boundary, + exchange, + ); + p.relate( + RelationKind::InterfaceProvision, + "Provides", + system, + interface, + ); + p.relate( + RelationKind::InterfaceRequirement, + "Requires", + actor, + interface, + ); + accepted(&p); + if let RelationBody::FunctionalExchange { source_port, .. } = + &mut p.relations.get_mut(&exchange).unwrap().body + { + *source_port = Some(input); + } + rejected(&p, "functional_port"); +} +#[test] +fn state_regions_initial_final_and_typed_actions_validate() { + let mut p = Project::empty(); + let sys = add(&mut p, Kind::System, Layer::Sa, "System", None); + let machine = add( + &mut p, + Kind::StateMachine, + Layer::Sa, + "Lifecycle", + Some(sys), + ); + let region = add( + &mut p, + Kind::Region, + Layer::Sa, + "Main region", + Some(machine), + ); + let initial = add( + &mut p, + Kind::Initial, + Layer::Sa, + "Initial point", + Some(region), + ); + let state = add(&mut p, Kind::State, Layer::Sa, "Ready", Some(region)); + let final_state = add(&mut p, Kind::Final, Layer::Sa, "Finished", Some(region)); + let work = add(&mut p, Kind::Function, Layer::Sa, "Initialize", None); + p.relate(RelationKind::StateTransition, "Begin", initial, state); + p.relate(RelationKind::StateTransition, "Finish", state, final_state); + p.relate(RelationKind::StateAction, "Entry action", state, work); + p.relate(RelationKind::Availability, "Available", work, state); + accepted(&p); + p.relate( + RelationKind::StateTransition, + "Invalid restart", + final_state, + state, + ); + rejected(&p, "pseudostate_direction"); +} +#[test] +fn artifacts_preserve_arbitrary_uri_and_revision_without_fetch() { + let (mut p, oa, _) = work_pair(); + let id = artifact(&mut p, oa); + accepted(&p); + let encoded = serde_json::to_vec(&p).unwrap(); + let decoded: Project = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded.artifact_links[&id], p.artifact_links[&id]); + p.artifact_links.get_mut(&id).unwrap().uri = "file:///C:/Engineering/REQ%2042.md".into(); + accepted(&p); + p.artifact_links.get_mut(&id).unwrap().uri = "broken%XZ".into(); + rejected(&p, "invalid_uri"); +} +#[test] +fn reidentify_updates_containment_sequences_traces_artifacts_and_views_atomically() { + let (mut p, oa, sa) = work_pair(); + let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa)); + let trace = make_trace(&mut p, oa, sa); + let link = artifact(&mut p, oa); + let diagram = p.ensure_diagram(Layer::Oa); + let node = p.place(diagram, oa, Position { x: 1.25, y: -0. }); + let new = Id::new_v4(); + let mut s = Session::new(p.clone()); + s.execute(Command::Reidentify(oa, new)).unwrap(); + assert_eq!(s.project().elements[&child].parent, Some(new)); + assert_eq!(s.project().traces[&trace].oa, new); + assert_eq!(s.project().artifact_links[&link].owner, new); + assert_eq!(s.project().diagrams[&diagram].nodes[&node].element, new); + assert_eq!(s.project().elements[&new].alias, p.elements[&oa].alias); + s.undo(); + assert_eq!(s.project(), &p); +} +#[test] +fn deleting_source_cascades_owned_records_but_keeps_other_layer_counterpart() { + let (mut p, oa, sa) = work_pair(); + let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa)); + let trace = make_trace(&mut p, oa, sa); + let link = artifact(&mut p, child); + let mut s = Session::new(p.clone()); + s.execute(Command::Delete(oa)).unwrap(); + assert!(!s.project().elements.contains_key(&child)); + assert!(!s.project().traces.contains_key(&trace)); + assert!(!s.project().artifact_links.contains_key(&link)); + assert!(s.project().elements.contains_key(&sa)); + s.undo(); + assert_eq!(s.project(), &p); +} +#[test] +fn relation_deletion_prunes_owned_occurrences_and_undo_restores_order() { + let p = Project::demo(); + let exchange = p + .relations + .values() + .find(|r| r.kind() == RelationKind::Interaction) + .unwrap() + .id; + let process = p + .elements + .values() + .find(|e| e.kind() == Kind::Process) + .unwrap() + .id; + let mut s = Session::new(p.clone()); + s.execute(Command::Delete(exchange)).unwrap(); + assert!(s.project().elements[&process].steps().unwrap().is_empty()); + s.undo(); + assert_eq!(s.project(), &p); +} +#[test] +fn nested_projection_encloses_children_and_moves_only_selected_diagram() { + let mut p = Project::empty(); + let root = add(&mut p, Kind::Entity, Layer::Oa, "Organization", None); + let child = add(&mut p, Kind::Entity, Layer::Oa, "Team", Some(root)); + let d = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Decomposition; + let parent_node = p.place(d, root, Position { x: 100., y: 100. }); + let child_node = p.place(d, child, Position { x: 500., y: 400. }); + let view = projection::project_view(&p, d); + let parent = view.nodes.iter().find(|n| n.id == parent_node).unwrap(); + let nested = view.nodes.iter().find(|n| n.id == child_node).unwrap(); + assert_eq!(nested.visual_parent, Some(parent_node)); + assert!(parent.position.x + parent.size.width >= nested.position.x + nested.size.width + 24.); + let moved = execute_batch( + &p, + &[Command::MoveNode { + diagram: d, + node: parent_node, + position: Position { x: 125., y: 150. }, + with_descendants: true, + }], + ) + .unwrap(); + assert_eq!( + moved.diagrams[&d].nodes[&child_node].position, + Position { x: 525., y: 450. } + ); + assert_eq!(moved.elements, p.elements); + let arranged = execute_batch(&p, &[Command::Layout(d)]).unwrap(); + let pn = &arranged.diagrams[&d].nodes[&parent_node]; + let cn = &arranged.diagrams[&d].nodes[&child_node]; + assert!(cn.position.x > pn.position.x); + assert!(cn.position.y > pn.position.y); + assert!(cn.position.y + cn.size.height < pn.position.y + pn.size.height); +} +#[test] +fn invalid_geometry_and_new_payload_fields_are_rejected() { + let (mut p, oa, _) = work_pair(); + let d = p.ensure_diagram(Layer::Oa); + let node = p.place(d, oa, Position { x: f32::NAN, y: 0. }); + rejected(&p, "invalid_bounds"); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .position + .x = 0.; + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .size + .width = -1.; + rejected(&p, "invalid_bounds"); + assert!( + serde_json::from_str::( + r#"{"Port":{"direction":"In","interface":null,"typo":true}}"# + ) + .is_err() + ); + assert!(serde_json::from_str::(r#"{"Reparent":{"element":"00000000-0000-0000-0000-000000000001","parent":null,"typo":true}}"#).is_err()); +} +#[test] +fn bidirectional_transition_creates_reuses_and_updates_one_history_transaction() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None); + p.elements.get_mut(&oa).unwrap().description = "Operational intent".into(); + let request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]); + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let target = plan.changes[0].target; + let migrated = apply_transition(&p, &plan).unwrap(); + assert_eq!(migrated.elements[&target].kind(), Kind::Function); + assert_eq!(migrated.traces.len(), 1); + let again = plan_transition(&migrated, &request).unwrap(); + assert!(again.conflicts.is_empty()); + assert!(again.commands.is_empty()); + let edited = execute_batch( + &migrated, + &[Command::Describe(target, "Updated system intent".into())], + ) + .unwrap(); + let reverse = plan_transition( + &edited, + &TransitionRequest::new(TransitionDirection::SaToOa, vec![target]), + ) + .unwrap(); + assert!(reverse.conflicts.is_empty(), "{:?}", reverse.conflicts); + let restored = apply_transition(&edited, &reverse).unwrap(); + assert_eq!(restored.elements[&oa].description, "Updated system intent"); + assert_eq!(restored.traces.len(), 1); + let mut s = Session::new(p.clone()); + s.execute(Command::Replace(migrated)).unwrap(); + s.undo(); + assert_eq!(s.project(), &p); +} +#[test] +fn both_side_transition_conflicts_require_explicit_field_resolution() { + let (mut p, oa, sa) = work_pair(); + p.elements.get_mut(&sa).unwrap().name = p.elements[&oa].name.clone(); + let trace = make_trace(&mut p, oa, sa); + p.elements.get_mut(&oa).unwrap().name = "New OA name".into(); + p.elements.get_mut(&sa).unwrap().name = "New SA name".into(); + assert_eq!(trace_drift(&p, &p.traces[&trace]), Drift::BothChanged); + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]); + let plan = plan_transition(&p, &request).unwrap(); + assert!( + plan.conflicts + .iter() + .any(|c| c.field == Some(TransitionField::Name)) + ); + assert!(apply_transition(&p, &plan).is_err()); + request.resolutions.push(FieldResolution { + source: oa, + field: TransitionField::Name, + decision: FieldDecision::KeepTarget, + }); + let accepted_plan = plan_transition(&p, &request).unwrap(); + assert!( + accepted_plan.conflicts.is_empty(), + "{:?}", + accepted_plan.conflicts + ); + let updated = apply_transition(&p, &accepted_plan).unwrap(); + assert_eq!(updated.elements[&sa].name, "New SA name"); + assert_eq!( + trace_drift(&updated, &updated.traces[&trace]), + Drift::Unchanged + ); +} +#[test] +fn participant_transition_requires_boundary_decision_and_stale_plans_reject() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Entity, Layer::Oa, "Operations", None); + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]); + assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty()); + request.mappings.push(TransitionMapping { + source: oa, + target: None, + target_kind: Some(Kind::System), + }); + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + p.name = "Changed project".into(); + assert!(apply_transition(&p, &plan).unwrap_err().0.contains("stale")); +} +#[test] +fn explicit_dependencies_map_chain_order_and_repeated_occurrences() { + let mut p = Project::demo(); + p.traces.clear(); + let process = p + .elements + .values() + .find(|e| e.kind() == Kind::Process) + .unwrap() + .id; + let exchange = p + .relations + .values() + .find(|r| r.kind() == RelationKind::Interaction) + .unwrap() + .id; + let p = execute_batch(&p, &[Command::AddStep(process, exchange)]).unwrap(); + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![process]); + assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty()); + request.scope = DependencyScope::Required; + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let result = apply_transition(&p, &plan).unwrap(); + let chain = plan + .changes + .iter() + .find(|c| c.source == process) + .unwrap() + .target; + let steps = result.elements[&chain].steps().unwrap(); + assert_eq!(steps.len(), 2); + assert_ne!(steps[0], steps[1]); + let relation = |id: Id| match result.elements[&id].shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(r), .. + }, + .. + }) => *r, + _ => panic!("exchange occurrence"), + }; + assert_eq!(relation(steps[0]), relation(steps[1])); +} +#[test] +fn many_traces_need_explicit_counterpart_and_layout_does_not_cause_drift() { + let (mut p, oa, sa) = work_pair(); + let first = make_trace(&mut p, oa, sa); + let second = add(&mut p, Kind::Function, Layer::Sa, "Another function", None); + make_trace(&mut p, oa, second); + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]); + assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty()); + request.mappings.push(TransitionMapping { + source: oa, + target: Some(sa), + target_kind: None, + }); + let plan = plan_transition(&p, &request).unwrap(); + assert!(plan.conflicts.is_empty()); + let d = p.ensure_diagram(Layer::Oa); + p.place(d, oa, Position { x: 400., y: 500. }); + assert_eq!(trace_drift(&p, &p.traces[&first]), Drift::Unchanged); +} +#[test] +fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() { + let mut p = Project::empty(); + let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Item", None); + let dtype = add(&mut p, Kind::DataType, Layer::Sa, "Count type", None); + let field = add(&mut p, Kind::DataField, Layer::Sa, "Count", Some(item)); + *p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField { + type_ref: TypeRef::Named(dtype), + minimum: 0, + maximum: None, + unit: "items".into(), + }; + let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None); + let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None); + let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function)); + let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other); + if let RelationBody::FunctionalExchange { + source_port, items, .. + } = &mut p.relations.get_mut(&rel).unwrap().body + { + *source_port = Some(port); + items.push(item); + } + let dtype_new = Id::new_v4(); + let port_new = Id::new_v4(); + let result = execute_batch( + &p, + &[ + Command::Reidentify(dtype, dtype_new), + Command::Reidentify(port, port_new), + ], + ) + .unwrap(); + assert!( + matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new) + ); + assert!( + matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new) + ); +} + +#[test] +fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references() { + let p = Project::demo(); + for (id, _) in p.identities() { + let replacement = Id::new_v4(); + let remapped = execute_batch(&p, &[Command::Reidentify(id, replacement)]) + .unwrap_or_else(|err| panic!("Reidentify {id}: {err}")); + let restored = execute_batch(&remapped, &[Command::Reidentify(replacement, id)]).unwrap(); + assert_eq!( + restored, p, + "Reidentification must preserve all values and order" + ); + if id != p.id { + let deleted = execute_batch(&p, &[Command::Delete(id)]) + .unwrap_or_else(|err| panic!("Delete {id}: {err}")); + accepted(&deleted); + } + } +} +#[test] +fn shared_roles_remain_valid_without_an_invented_unique_performer() { + let mut p = Project::empty(); + let a = add(&mut p, Kind::Entity, Layer::Oa, "Team A", None); + let b = add(&mut p, Kind::Entity, Layer::Oa, "Team B", None); + let role = add(&mut p, Kind::Role, Layer::Oa, "Responder", None); + let work = add(&mut p, Kind::Activity, Layer::Oa, "Respond", None); + p.relate(RelationKind::RoleRealization, "A realizes", a, role); + p.relate( + RelationKind::RoleAssignment, + "Role responsibility", + role, + work, + ); + assert_eq!(validation::effective_performer(&p, work), Some(a)); + assert!( + !validation::validate(&p) + .iter() + .any(|i| i.code == "unallocated_leaf") + ); + p.relate(RelationKind::RoleRealization, "B realizes", b, role); + accepted(&p); + assert_eq!(validation::effective_performer(&p, work), None); + assert!( + validation::validate(&p) + .iter() + .any(|i| i.code == "ambiguous_role_performer" && i.severity == Severity::Warning) + ); +} +#[test] +fn automatic_transition_does_not_accept_arbitrary_target_kinds() { + let (p, oa, sa) = work_pair(); + let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]); + request.mappings.push(TransitionMapping { + source: oa, + target: None, + target_kind: Some(Kind::System), + }); + assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty()); + let mut p = p; + let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None); + request.mappings[0].target = Some(actor); + request.mappings[0].target_kind = None; + assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty()); + request.mappings[0].target = Some(sa); + request.resolutions.push(FieldResolution { + source: oa, + field: TransitionField::Name, + decision: FieldDecision::TakeSource, + }); + assert!(plan_transition(&p, &request).unwrap().conflicts.is_empty()); +} +#[test] +fn demo_has_both_layers_complete_contracts_and_no_checks() { + let p = Project::demo(); + assert_eq!(p.elements.len(), 41); + for layer in [Layer::Oa, Layer::Sa] { + for projection in [ + ProjectionKind::Allocation, + ProjectionKind::FunctionalDataflow, + ProjectionKind::Capabilities, + ] { + assert!( + p.diagrams + .values() + .any(|d| d.layer == layer && d.projection == projection) + ); + } + } + assert_eq!(p.traces.len(), 10); + assert_eq!(p.artifact_links.len(), 2); + assert!( + validation::validate(&p).is_empty(), + "{:?}", + validation::validate(&p) + ); + assert!(p.elements.values().any(|e| e.kind() == Kind::System)); + assert!(p.elements.values().any(|e| matches!( + e.shared(), + Some(SharedBody::ExchangeItem { + kind: ExchangeItemKind::Material + }) + ))); + assert!(p.elements.values().any(|e| matches!( + e.shared(), + Some(SharedBody::ExchangeItem { + kind: ExchangeItemKind::Energy + }) + ))); +} diff --git a/crates/oa-desktop/Cargo.toml b/crates/oa-desktop/Cargo.toml new file mode 100644 index 00000000..7c508ff1 --- /dev/null +++ b/crates/oa-desktop/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "oa-desktop" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "arcadia-studio" +path = "src/main.rs" + +[dependencies] +oa-core = { path = "../oa-core" } +iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] } +rfd = "0.15" +serde_json.workspace = true +tokio = { version = "1", features = ["rt", "time"] } diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs new file mode 100644 index 00000000..507b192e --- /dev/null +++ b/crates/oa-desktop/src/app.rs @@ -0,0 +1,1820 @@ +use crate::forms::Draft; +use iced::{Element, Subscription, Task, keyboard, widget::text_editor, window}; +use oa_core::{projection::project_view, validation::validate, *}; +use serde_json::{Value, json}; +use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; + +type FileSnapshot = (PathBuf, Project, Arc<[u8]>); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Page { + Diagram, + Catalog, + Behavior, + Data, + Traces, + Text, + Validation, + Guide, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Choice { + pub id: Id, + pub label: String, +} +impl std::fmt::Display for Choice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.label) + } +} +#[derive(Debug, Clone, Copy)] +pub enum Pending { + New, + Demo, + Open, + Close(window::Id), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileOperation { + Open, + Save, + Export, +} +pub struct FilePrompt { + pub operation: FileOperation, + pub path: String, + pub overwrite: bool, +} + +#[derive(Debug, Clone)] +pub enum Message { + ExactStrings(bool), + Page(Page), + Guide(crate::guide::Event), + Behavior(crate::behavior::Event), + Layer(Layer), + Select(Id), + EditProperties(Id), + SelectNode(Id), + SelectProject, + Search(String), + StartTransition, + Workflow(crate::workflow::Event), + CollapseExplorer(Id), + CreateKind(Kind), + Add(bool), + AddRelation, + AddRelationKind(RelationKind, bool), + InspectorTab(crate::navigation::InspectorTab), + RevealInDiagram(Id, Id), + AddDiagram, + AddArtifact, + AddTrace, + Field(String, String), + FieldText(String, text_editor::Action), + FieldShape(String, Value), + FieldVariant(String, Value), + FieldArray(String, Option, i32), + Apply, + RevertProperties, + ElementKind(Kind), + RelationKind(RelationKind), + Command(Command), + Delete, + ConfirmDelete, + CancelDelete, + Undo, + Redo, + Diagram(Choice), + Projection(ProjectionKind), + PlaceSelected, + HideSelected, + ToggleCollapse, + MoveNode(Id, Position), + Zoom(f32), + Pan(iced::Vector), + Fit, + SourceEdit(text_editor::Action), + ApplySource, + RevertSource, + SourceUndo, + SourceRedo, + Lint(u64), + Linted(u64, Result, String>), + SourceDiagnostic(usize), + SourceViewport, + SourceForSelection, + SourceNavigation(crate::source_navigation::Event), + Save(bool), + Saved(Result, String>), + Loaded(Result, String>), + Request(Pending), + Discard, + Cancel, + SaveAndContinue, + FilePath(String), + SubmitFile, + CancelFile, + Export, + Exported(Result), + WindowResized(iced::Size), + CanvasResized(iced::Size), + ShortcutUndo(bool), + Escape, +} + +pub struct App { + pub exact_strings: bool, + pub session: Session, + pub page: Page, + pub guide: crate::guide::State, + pub behavior: crate::behavior::State, + pub inspector_tab: crate::navigation::InspectorTab, + pub layer: Layer, + pub selected: Id, + pub selected_node: Option, + pub draft: Option, + pub active_diagram: Option, + pub search: String, + pub create_kind: Kind, + pub status: String, + pub busy: bool, + pub pending: Option, + pub delete_pending: bool, + pub path: Option, + file_snapshot: Option>, + pub file_prompt: Option, + continue_after_save: bool, + pub zoom: f32, + pub pan: iced::Vector, + pub window_size: iced::Size, + canvas_size: Option, + fit_to_view: bool, + pub source_editor: text_editor::Content, + source_baseline: String, + pub source_snapshot: Arc, + pub source_diagnostics: Vec, + pub source_jump: Option, + pub source_navigation: crate::source_navigation::State, + pub model_issues: Vec, + source_past: Vec, + source_future: Vec, + source_version: u64, + lint_running: Option, + lint_requested: Option, + pub explorer_collapsed: BTreeSet, + pub workflow: Option, +} + +impl App { + pub fn new() -> Self { + let mut app = Self::with_project(Project::demo()); + app.fit(); + app + } + pub fn with_project(project: Project) -> Self { + let source = language::format(&project); + let model_issues = validate(&project); + let selected = project.id; + let active_diagram = project + .diagrams + .values() + .filter(|d| d.layer == Layer::Oa) + .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) + .map(|d| d.id); + let draft = Draft::from_project(&project, selected); + Self { + exact_strings: false, + session: Session::new(project), + page: Page::Diagram, + guide: crate::guide::State::default(), + behavior: Default::default(), + inspector_tab: Default::default(), + layer: Layer::Oa, + selected, + selected_node: None, + draft, + active_diagram, + search: String::new(), + create_kind: Kind::Activity, + status: "Coastal rescue example · explore both engineering perspectives".into(), + busy: false, + pending: None, + delete_pending: false, + path: None, + file_snapshot: None, + file_prompt: None, + continue_after_save: false, + zoom: 0.65, + pan: iced::Vector::new(24., 48.), + window_size: iced::Size::new(1440., 900.), + canvas_size: None, + fit_to_view: true, + source_editor: text_editor::Content::with_text(&source), + source_baseline: source.clone(), + source_snapshot: Arc::from(source), + source_diagnostics: vec![], + source_jump: None, + source_navigation: crate::source_navigation::State::default(), + model_issues, + source_past: vec![], + source_future: vec![], + source_version: 0, + lint_running: None, + lint_requested: None, + explorer_collapsed: BTreeSet::new(), + workflow: None, + } + } + pub fn project(&self) -> &Project { + self.session.project() + } + pub fn title(&self) -> String { + format!( + "{}{} — Arcadia Studio", + self.project() + .name + .split_whitespace() + .collect::>() + .join(" "), + if self.dirty() { " *" } else { "" } + ) + } + pub fn source_dirty(&self) -> bool { + self.source_editor.text() != self.source_baseline + } + pub fn draft_dirty(&self) -> bool { + self.draft.as_ref().is_some_and(Draft::dirty) + } + pub fn dirty(&self) -> bool { + self.session.dirty() || self.draft_dirty() || self.source_dirty() + } + fn sync_source(&mut self) { + self.source_jump = None; + let source = language::format(self.project()); + self.source_navigation.refresh(&source); + self.source_editor = text_editor::Content::with_text(&source); + self.source_baseline = source.clone(); + self.source_snapshot = Arc::from(source); + self.source_diagnostics.clear(); + self.source_past.clear(); + self.source_future.clear(); + self.source_version += 1; + self.lint_requested = None; + } + fn refresh_draft(&mut self) { + self.selected_node = self + .active_diagram + .and_then(|id| self.project().diagrams.get(&id)) + .and_then(|d| { + d.nodes + .values() + .find(|n| n.id == self.selected || n.element == self.selected) + }) + .map(|n| n.id); + self.draft = Draft::from_project(self.project(), self.selected); + if self.exact_strings + && let Some(d) = &mut self.draft + { + let _ = d.set_exact(true); + } + } + fn sync(&mut self) { + self.model_issues = validate(self.project()); + self.normalize_creation(); + if !self.project().contains(self.selected) { + self.selected = self.project().id; + self.selected_node = None; + } + if self.active_diagram.is_none_or(|id| { + self.project() + .diagrams + .get(&id) + .is_none_or(|d| d.layer != self.layer) + }) { + self.active_diagram = self + .project() + .diagrams + .values() + .filter(|d| d.layer == self.layer) + .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) + .map(|d| d.id); + } + self.refresh_draft(); + self.sync_source(); + } + fn normalize_creation(&mut self) { + if !self.create_kind.supports(self.layer) { + self.create_kind = if self.layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + } + } + pub fn apply_source(&mut self) -> bool { + if !self.source_dirty() { + return true; + } + match language::parse(&self.source_editor.text()) { + Ok(project) => match self.session.execute(Command::Replace(project)) { + Ok(_) => { + self.sync(); + self.status = "Text applied as one model change".into(); + true + } + Err(e) => { + self.status = e.to_string(); + false + } + }, + Err(issues) => { + self.source_diagnostics = issues; + self.status = "Source contains errors. Your draft is preserved.".into(); + self.page = Page::Text; + false + } + } + } + pub fn apply(&mut self) -> bool { + if !self.apply_source() { + return false; + } + if !self.draft_dirty() { + return true; + } + let original = self.selected; + let result = self + .draft + .as_ref() + .ok_or("No selection.".to_string()) + .and_then(|d| Ok((d.command()?, d.resulting_id()?))); + match result.and_then(|(command, id)| { + self.session + .execute(command) + .map(|_| id) + .map_err(|e| e.to_string()) + }) { + Ok(id) => { + if self.active_diagram == Some(original) { + self.active_diagram = Some(id); + } + if self.selected_node == Some(original) { + self.selected_node = Some(id); + } + if self.explorer_collapsed.remove(&original) { + self.explorer_collapsed.insert(id); + } + self.selected = id; + if let Some(layer) = self.project().layer_of(id) { + self.layer = layer; + } + if let Some((diagram, layer, node)) = self + .project() + .diagrams + .values() + .find(|d| d.id == id || d.nodes.contains_key(&id)) + .map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id))) + { + self.layer = layer; + self.active_diagram = Some(diagram); + self.selected_node = node; + } + self.sync(); + self.status = "Properties applied".into(); + true + } + Err(e) => { + self.status = e; + false + } + } + } + fn execute(&mut self, command: Command) -> bool { + match self.session.execute(command) { + Ok(selected) => { + if let Some(id) = selected { + self.selected = id; + } + self.sync(); + self.status = "Model updated".into(); + true + } + Err(e) => { + self.status = e.to_string(); + false + } + } + } + fn reset(&mut self, project: Project, path: Option) { + let window_size = self.window_size; + let canvas_size = self.canvas_size; + let source_version = self.source_version + 1; + let lint_running = self.lint_running; + let guide = std::mem::take(&mut self.guide); + *self = Self::with_project(project); + self.window_size = window_size; + self.canvas_size = canvas_size; + self.source_version = source_version; + self.lint_running = lint_running; + self.guide = guide; + self.path = path; + self.status = "Project ready".into(); + self.fit(); + } + fn diagram_for(&self, layer: Layer, element: Id) -> Option { + self.project() + .diagrams + .values() + .filter(|d| d.layer == layer) + .min_by_key(|d| { + ( + !self + .project() + .elements + .get(&element) + .is_none_or(|e| d.projection.includes(e.kind())), + !d.nodes.values().any(|n| n.element == element), + d.projection != ProjectionKind::Allocation, + ) + }) + .map(|d| d.id) + } + fn select(&mut self, id: Id) { + if self.selected == id { + return; + } + if !self.apply() { + return; + } + if !self.project().contains(id) { + return; + } + self.selected = id; + self.selected_node = None; + if let Some(layer) = self.project().layer_of(id) + && layer != self.layer + { + self.layer = layer; + self.active_diagram = self.diagram_for(layer, id); + self.fit(); + } + self.refresh_draft(); + } + pub fn view(&self) -> Element<'_, Message> { + crate::views::view(self) + } + pub fn subscription(&self) -> Subscription { + Subscription::batch([ + window::close_requests().map(|id| Message::Request(Pending::Close(id))), + window::resize_events().map(|(_, size)| Message::WindowResized(size)), + iced::event::listen_with(|event, _, _| { + let iced::Event::Keyboard(keyboard::Event::KeyPressed { + key, + modifiers: mods, + .. + }) = event + else { + return None; + }; + if key == keyboard::Key::Named(keyboard::key::Named::Escape) { + return Some(Message::Escape); + } + if key == keyboard::Key::Named(keyboard::key::Named::F1) { + return Some(Message::Page(Page::Guide)); + } + if key == keyboard::Key::Named(keyboard::key::Named::F3) { + return Some(Message::SourceNavigation( + crate::source_navigation::Event::Next(mods.shift()), + )); + } + if !mods.command() { + return None; + } + match key.as_ref() { + keyboard::Key::Character("s") => Some(Message::Save(mods.shift())), + keyboard::Key::Character("o") => Some(Message::Request(Pending::Open)), + keyboard::Key::Character("n") => Some(Message::Request(Pending::New)), + keyboard::Key::Character("z") => Some(Message::ShortcutUndo(mods.shift())), + keyboard::Key::Character("y") => Some(Message::ShortcutUndo(true)), + keyboard::Key::Character("f") => Some(Message::SourceNavigation( + crate::source_navigation::Event::Open(false), + )), + keyboard::Key::Character("g") => Some(Message::SourceNavigation( + crate::source_navigation::Event::Open(true), + )), + _ => None, + } + }), + ]) + } + pub fn update(&mut self, message: Message) -> Task { + if self.busy + && !matches!( + message, + Message::Saved(_) + | Message::Loaded(_) + | Message::Linted(_, _) + | Message::Exported(_) + | Message::WindowResized(_) + | Message::CanvasResized(_) + | Message::SourceViewport + ) + { + return Task::none(); + } + // A modal owns input until it is dismissed. This also protects against + // global shortcuts and controls that were focused before it opened. + if matches!( + message, + Message::Escape | Message::Request(Pending::Close(_)) + ) { + if self.workflow.take().is_some() { + return Task::none(); + } + if self.file_prompt.take().is_some() { + self.continue_after_save = false; + return Task::none(); + } + if self.delete_pending { + self.delete_pending = false; + return Task::none(); + } + if self.pending.take().is_some() { + self.continue_after_save = false; + return Task::none(); + } + } + let modal_event = matches!( + message, + Message::WindowResized(_) + | Message::CanvasResized(_) + | Message::SourceViewport + | Message::Lint(_) + | Message::Linted(_, _) + | Message::Saved(_) + | Message::Loaded(_) + | Message::Exported(_) + ); + if !modal_event + && ((self.workflow.is_some() && !matches!(message, Message::Workflow(_))) + || (self.file_prompt.is_some() + && !matches!( + message, + Message::FilePath(_) | Message::SubmitFile | Message::CancelFile + )) + || (self.delete_pending + && !matches!(message, Message::ConfirmDelete | Message::CancelDelete)) + || (self.pending.is_some() + && self.file_prompt.is_none() + && !matches!( + message, + Message::Cancel | Message::Discard | Message::SaveAndContinue + ))) + { + return Task::none(); + } + let previous = self.selected; + let task = self.handle(message); + self.normalize_creation(); + if self.selected != previous { + task.chain(iced::widget::operation::snap_to( + "inspector", + iced::widget::scrollable::RelativeOffset::START, + )) + } else { + task + } + } + fn handle(&mut self, message: Message) -> Task { + match message { + Message::Escape => self.source_navigation.open = false, + Message::Guide(event) => self.guide.update(event), + Message::ExactStrings(enabled) => { + if let Some(d) = &mut self.draft { + match d.set_exact(enabled) { + Ok(()) => self.exact_strings = enabled, + Err(e) => self.status = e, + } + } + } + Message::Field(path, value) => { + if let Some(d) = &mut self.draft { + d.input(&path, value) + } + } + Message::FieldText(path, action) => { + if let Some(d) = &mut self.draft { + d.edit_text(&path, action) + } + } + Message::FieldShape(path, value) => { + if let Some(d) = &mut self.draft + && let Err(e) = d.shape(&path, value) + { + self.status = e; + } + } + Message::FieldVariant(path, value) => { + if let Some(d) = &mut self.draft { + let result = d.materialize().and_then(|current| { + if current + .pointer(&path) + .is_some_and(|v| crate::forms::same_variant(v, &value)) + { + Ok(()) + } else { + d.shape(&path, value) + } + }); + if let Err(e) = result { + self.status = e; + } + } + } + Message::FieldArray(path, index, direction) => { + if let Some(d) = &mut self.draft + && let Err(e) = d.array(&path, index, direction) + { + self.status = e; + } + } + Message::Apply => { + self.apply(); + } + Message::RevertProperties => { + self.refresh_draft(); + self.status = "Property draft reverted".into(); + } + Message::Select(id) => self.select(id), + Message::EditProperties(id) => { + self.select(id); + self.inspector_tab = crate::navigation::InspectorTab::Properties; + } + Message::SelectProject => self.select(self.project().id), + Message::SelectNode(id) => { + if self.selected_node == Some(id) || self.selected == id { + return Task::none(); + } + let original = self.selected; + if !self.apply() { + return Task::none(); + } + let id = if id == original { self.selected } else { id }; + if let Some(d) = self + .active_diagram + .and_then(|d| self.project().diagrams.get(&d)) + && let Some(node) = d.nodes.get(&id) + { + self.selected = node.element; + self.selected_node = Some(id); + self.refresh_draft(); + } + } + Message::Search(value) => self.search = value, + Message::Page(page) => { + if page == Page::Guide + || (if page == Page::Text { + self.apply() + } else { + self.apply_source() + }) + { + self.page = page; + if page == Page::Text { + return self.request_lint(); + } + } + } + Message::CollapseExplorer(id) => { + if !self.explorer_collapsed.remove(&id) { + self.explorer_collapsed.insert(id); + } + } + Message::StartTransition => { + if self.apply() { + match crate::workflow::Workflow::transition(self.project(), self.selected) { + Ok(w) => self.workflow = Some(w), + Err(e) => self.status = e, + } + } + } + Message::Workflow(event) => { + if matches!(event, crate::workflow::Event::Cancel) { + self.workflow = None; + return Task::none(); + } + if let Some(mut workflow) = self.workflow.take() { + let transition_focus = match &workflow { + crate::workflow::Workflow::Transition(t) => { + t.plan.changes.first().map(|c| c.target) + } + _ => None, + }; + match workflow.update(self.project(), event) { + Ok(Some(candidate)) => { + let focus = transition_focus + .or_else(|| { + candidate + .relations + .keys() + .find(|id| !self.project().relations.contains_key(id)) + .copied() + }) + .or_else(|| { + candidate + .traces + .keys() + .find(|id| !self.project().traces.contains_key(id)) + .copied() + }); + if self.execute(Command::Replace(candidate)) { + if let Some(id) = focus { + self.selected = id; + if let Some(layer) = self.project().layer_of(id) { + self.layer = layer; + self.active_diagram = self.diagram_for(layer, id); + } + self.refresh_draft(); + } + self.page = Page::Diagram; + self.fit(); + self.status="Reviewed change applied · Undo restores the complete operation".into(); + } else { + self.workflow = Some(workflow); + } + } + Ok(None) => self.workflow = Some(workflow), + Err(e) => { + self.status = e; + self.workflow = Some(workflow); + } + } + } + } + Message::Layer(layer) => { + if self.apply() { + self.layer = layer; + self.create_kind = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + self.active_diagram = self + .project() + .diagrams + .values() + .filter(|d| d.layer == layer) + .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) + .map(|d| d.id); + self.selected = self.project().id; + self.selected_node = None; + self.refresh_draft(); + self.fit(); + } + } + Message::CreateKind(kind) => self.create_kind = kind, + Message::Add(child) => { + if self.apply() { + self.add_element(child); + } + } + Message::ElementKind(kind) => { + if let Some(d) = &mut self.draft { + let result = d + .materialize() + .and_then(|v| { + serde_json::from_value::(v).map_err(|e| e.to_string()) + }) + .and_then(|element| { + if element.kind() == kind { + return Ok(()); + } + let body = ElementBody::new(kind, element.layer()) + .ok_or("Kind is unavailable in this layer.")?; + d.shape("/body", json!(body)) + }); + if let Err(e) = result { + self.status = e; + } + } + } + Message::RelationKind(kind) => { + if let Some(d) = &mut self.draft { + let result = d + .materialize() + .and_then(|v| { + serde_json::from_value::(v).map_err(|e| e.to_string()) + }) + .and_then(|relation| { + if relation.kind() == kind { + return Ok(()); + } + d.shape( + "/body", + json!(RelationBody::new( + kind, + relation.source(), + relation.target() + )), + ) + }); + if let Err(e) = result { + self.status = e; + } + } + } + Message::AddRelation => { + if self.apply() { + self.add_relation(); + } + } + Message::AddRelationKind(kind, incoming) => { + if self.apply() { + self.workflow = Some(crate::workflow::Workflow::relationship_kind( + self.project(), + self.selected, + self.layer, + kind, + incoming, + )); + } + } + Message::InspectorTab(tab) => self.inspector_tab = tab, + Message::Behavior(event) => { + if event.is_submit() && !self.apply() { + return Task::none(); + } + let mut state = std::mem::take(&mut self.behavior); + let result = state.update(self.project(), event); + self.behavior = state; + match result { + Ok(Some(command)) => return self.handle(Message::Command(command)), + Ok(None) => {} + Err(error) => self.status = error, + } + } + Message::RevealInDiagram(diagram, selected) => { + let original = self.selected; + if !self.apply() { + return Task::none(); + } + let selected = if selected == original { + self.selected + } else { + selected + }; + let Some(view) = self.project().diagrams.get(&diagram) else { + return Task::none(); + }; + let layer = view.layer; + let endpoints = self + .project() + .relations + .get(&selected) + .map(|r| vec![r.source(), r.target()]) + .unwrap_or_else(|| vec![selected]); + let nodes = view + .nodes + .values() + .filter(|n| endpoints.contains(&n.element) || n.id == selected) + .map(|n| n.id) + .collect::>(); + let commands = view + .nodes + .values() + .filter(|n| { + n.collapsed && { + let children = oa_core::projection::descendant_nodes( + self.project(), + diagram, + n.id, + ); + nodes.iter().any(|id| children.contains(id)) + } + }) + .map(|n| { + let mut node = n.clone(); + node.collapsed = false; + Command::EditNode { + diagram, + original: node.id, + node, + } + }) + .collect::>(); + if !commands.is_empty() && !self.execute(Command::Batch(commands)) { + return Task::none(); + } + self.active_diagram = Some(diagram); + self.layer = layer; + self.selected = selected; + self.page = Page::Diagram; + self.refresh_draft(); + self.fit(); + } + Message::AddDiagram => { + if self.apply() { + let id = Id::new_v4(); + let p = self.project(); + let d = Diagram { + id, + alias: p.fresh_alias(&format!("{}_view", self.layer), "view", id), + name: format!("{} architecture", self.layer), + description: String::new(), + layer: self.layer, + projection: ProjectionKind::Allocation, + nodes: Default::default(), + }; + if self.execute(Command::CreateDiagram(d)) { + self.active_diagram = Some(id); + self.page = Page::Diagram; + } + } + } + Message::AddArtifact => { + if self.apply() { + let id = Id::new_v4(); + let p = self.project(); + let a = ArtifactLink { + id, + alias: p.fresh_alias("artifact", "artifact", id), + name: "Development artifact".into(), + description: String::new(), + owner: self.selected, + relation: ArtifactRelation::References, + uri: "./artifacts/design.md".into(), + revision: None, + fragment: None, + }; + self.execute(Command::CreateArtifact(a)); + } + } + Message::AddTrace => { + if self.apply() { + self.add_trace(); + } + } + Message::Command(command) => { + let original = self.selected; + if self.apply() { + let command = match command { + Command::Layout(id) if id == original => Command::Layout(self.selected), + Command::MoveStep(id, from, to) if id == original => { + Command::MoveStep(self.selected, from, to) + } + command => command, + }; + self.execute(command); + } + } + Message::Delete => { + if self.selected != self.project().id && self.apply() { + self.delete_pending = true; + } + } + Message::ConfirmDelete => { + self.delete_pending = false; + self.execute(Command::Delete(self.selected)); + } + Message::CancelDelete => self.delete_pending = false, + Message::Undo | Message::Redo => { + let redo = matches!(message, Message::Redo); + if self.apply() { + if redo { + self.session.redo(); + } else { + self.session.undo(); + } + self.sync(); + self.status = if redo { + "Model change redone" + } else { + "Model change undone" + } + .into(); + } + } + Message::ShortcutUndo(redo) => { + return self.handle(if self.page == Page::Text { + if redo { + Message::SourceRedo + } else { + Message::SourceUndo + } + } else if redo { + Message::Redo + } else { + Message::Undo + }); + } + Message::Diagram(choice) => { + if self.apply() { + self.active_diagram = Some(choice.id); + if let Some(d) = self.project().diagrams.get(&choice.id) { + self.layer = d.layer; + } + self.selected_node = None; + self.refresh_draft(); + self.fit(); + } + } + Message::Projection(projection) => { + if self.apply() + && let Some(mut d) = self + .active_diagram + .and_then(|id| self.project().diagrams.get(&id)) + .cloned() + { + d.projection = projection; + self.execute(Command::EditDiagram(d.id, d)); + self.fit(); + } + } + Message::PlaceSelected => { + if self.apply() + && let Some(diagram) = self.active_diagram + { + if let Some(node) = self + .project() + .diagrams + .get(&diagram) + .and_then(|d| d.nodes.values().find(|n| n.element == self.selected)) + .map(|n| n.id) + { + self.selected_node = Some(node); + self.status = "Element is already shown in this diagram".into(); + return Task::none(); + } + let mut p = self.project().clone(); + let node = p.place(diagram, self.selected, Position { x: 60., y: 60. }); + if let Some(n) = p + .diagrams + .get(&diagram) + .and_then(|d| d.nodes.get(&node)) + .cloned() + && self.execute(Command::CreateNode { diagram, node: n }) + { + self.selected_node = Some(node); + } + } + } + Message::HideSelected => { + if self.apply() + && let Some(node) = self.selected_node + { + self.execute(Command::Delete(node)); + self.selected_node = None; + } + } + Message::ToggleCollapse => { + if self.apply() + && let Some(diagram) = self.active_diagram + && let Some(mut n) = self + .selected_node + .and_then(|id| self.project().diagrams.get(&diagram)?.nodes.get(&id)) + .cloned() + { + n.collapsed = !n.collapsed; + self.execute(Command::EditNode { + diagram, + original: n.id, + node: n, + }); + } + } + Message::MoveNode(node, position) => { + let original = self.selected; + if self.apply() + && let Some(diagram) = self.active_diagram + { + let node = if node == original { + self.selected + } else { + node + }; + self.execute(Command::MoveNode { + diagram, + node, + position, + with_descendants: true, + }); + } + } + Message::Zoom(factor) => { + self.fit_to_view = false; + self.zoom = (self.zoom * factor).clamp(0.0001, 3.); + } + Message::Pan(delta) => { + self.fit_to_view = false; + self.pan += delta; + } + Message::Fit => self.fit(), + Message::SourceEdit(action) => { + self.source_jump = None; + let before = self.source_editor.text(); + self.source_editor.perform(action); + let after = self.source_editor.text(); + if after != before { + self.source_navigation.refresh(&after); + self.source_past.push(before); + if self.source_past.len() > 100 { + self.source_past.remove(0); + } + self.source_future.clear(); + self.source_snapshot = Arc::from(after); + self.source_version += 1; + self.source_diagnostics.clear(); + let version = self.source_version; + return Task::perform( + async move { + tokio::time::sleep(std::time::Duration::from_millis(350)).await; + version + }, + Message::Lint, + ); + } + } + Message::Lint(version) => { + if version == self.source_version { + return self.request_lint(); + } + } + Message::Linted(version, result) => { + if self.lint_running != Some(version) { + return Task::none(); + } + self.lint_running = None; + if version == self.source_version { + match result { + Ok(diagnostics) => self.source_diagnostics = diagnostics, + Err(error) => self.status = format!("Source checks failed: {error}"), + } + } + return self.start_pending_lint(); + } + Message::SourceDiagnostic(index) => { + if let Some(diagnostic) = self.source_diagnostics.get(index) { + let line = diagnostic.line.saturating_sub(1); + let column = self.source_editor.line(line).map_or(0, |text| { + text.text + .char_indices() + .nth(diagnostic.column.saturating_sub(1)) + .map_or(text.text.len(), |(offset, _)| offset) + }); + self.source_jump = Some(text_editor::Cursor { + position: text_editor::Position { line, column }, + selection: None, + }); + } + } + Message::SourceViewport => { + if let Some(cursor) = self.source_jump.take() { + self.source_editor.move_to(cursor); + if cursor.selection.is_none() { + self.source_editor.perform(text_editor::Action::SelectLine); + } + return iced::widget::operation::focus("source-editor"); + } + } + Message::ApplySource => { + self.apply_source(); + } + Message::RevertSource => { + self.sync_source(); + self.status = "Source draft reverted".into(); + } + Message::SourceUndo | Message::SourceRedo => { + let redo = matches!(message, Message::SourceRedo); + let value = if redo { + self.source_future.pop() + } else { + self.source_past.pop() + }; + if let Some(value) = value { + let current = self.source_editor.text(); + if redo { + self.source_past.push(current); + } else { + self.source_future.push(current); + } + self.source_editor = text_editor::Content::with_text(&value); + self.source_navigation.refresh(&value); + self.source_jump = None; + self.source_snapshot = Arc::from(value); + self.source_version += 1; + self.source_diagnostics.clear(); + return self.request_lint(); + } + } + Message::SourceForSelection => { + if self.apply() { + self.page = Page::Text; + let alias = self + .project() + .identities() + .into_iter() + .find(|(id, _)| *id == self.selected) + .map(|(_, a)| a.to_string()); + if let Some(alias) = alias { + let source = self.source_editor.text(); + if let Some(declaration) = language::analyze(&source) + .declarations + .iter() + .find(|d| d.alias == alias) + { + let prefix = &source[..declaration.span.start.min(source.len())]; + let line = prefix.bytes().filter(|b| *b == b'\n').count(); + self.source_jump = Some(text_editor::Cursor { + position: text_editor::Position { line, column: 0 }, + selection: None, + }); + } + self.status = format!("Source declaration: @{alias}"); + } + return self.request_lint(); + } + } + Message::SourceNavigation(event) => { + use crate::source_navigation::Event; + match event { + Event::Open(go_to_line) => { + if self.page != Page::Text && !self.apply() { + return Task::none(); + } + self.page = Page::Text; + self.source_navigation.open = true; + self.source_navigation.refresh(&self.source_editor.text()); + return iced::widget::operation::focus(if go_to_line { + "source-line" + } else { + "source-find" + }); + } + Event::Close => self.source_navigation.open = false, + Event::Query(query) => { + self.source_navigation.query = query; + self.source_navigation.refresh(&self.source_editor.text()); + } + Event::Next(backwards) if self.page == Page::Text => { + self.source_jump = self.source_navigation.next(backwards); + if self.source_jump.is_none() { + self.status = "No matching source text.".into(); + } + } + Event::Line(line) => self.source_navigation.line = line, + Event::Go => match self + .source_navigation + .destination(self.source_editor.line_count()) + { + Ok(cursor) => self.source_jump = Some(cursor), + Err(error) => self.status = error, + }, + _ => {} + } + } + Message::Request(pending) => { + if self.dirty() { + self.pending = Some(pending); + } else { + return self.proceed(pending); + } + } + Message::Cancel => { + self.pending = None; + self.continue_after_save = false; + } + Message::Discard => { + if let Some(p) = self.pending.take() { + return self.proceed(p); + } + } + Message::SaveAndContinue => { + self.continue_after_save = true; + return self.handle(Message::Save(false)); + } + Message::Save(save_as) => { + if !self.apply() { + self.continue_after_save = false; + self.pending = None; + return Task::none(); + } + if !save_as && let Some(path) = self.path.clone() { + return self.save_to(path); + } + return self.choose_file(FileOperation::Save); + } + Message::Saved(result) => { + self.busy = false; + match result { + Ok(Some((path, project, bytes))) => { + self.file_snapshot = Some(bytes); + self.path = Some(path); + if self.project() == &project { + self.session.mark_saved(project); + } + self.status = "Project saved".into(); + if self.continue_after_save { + self.continue_after_save = false; + if let Some(p) = self.pending.take() { + return self.proceed(p); + } + } + } + Ok(None) => { + self.status = "Save cancelled".into(); + self.continue_after_save = false; + } + Err(e) => { + self.status = e; + self.continue_after_save = false; + } + } + } + Message::Loaded(result) => { + self.busy = false; + match result { + Ok(Some((path, p, bytes))) => { + self.reset(p, Some(path)); + self.file_snapshot = Some(bytes); + } + Ok(None) => self.status = "Open cancelled".into(), + Err(e) => self.status = e, + } + } + Message::FilePath(value) => { + if let Some(p) = &mut self.file_prompt { + p.path = value; + p.overwrite = false; + } + } + Message::CancelFile => { + self.file_prompt = None; + self.continue_after_save = false; + } + Message::SubmitFile => { + if let Some(prompt) = &mut self.file_prompt { + if prompt.path.trim().is_empty() { + self.status = "Enter a file path.".into(); + return Task::none(); + } + let path = PathBuf::from(prompt.path.trim()); + if prompt.operation != FileOperation::Open && path.exists() && !prompt.overwrite + { + prompt.overwrite = true; + return Task::none(); + } + let operation = prompt.operation; + self.file_prompt = None; + return match operation { + FileOperation::Open => self.load_from(path), + FileOperation::Save => self.save_to(path), + FileOperation::Export => self.export_to(path), + }; + } + } + Message::Export => { + if self.apply() { + return self.choose_file(FileOperation::Export); + } + } + Message::Exported(result) => { + self.busy = false; + self.status = match result { + Ok(true) => "Diagram exported as SVG".into(), + Ok(false) => "Export cancelled".into(), + Err(e) => e, + }; + } + Message::WindowResized(size) => self.window_size = size, + Message::CanvasResized(size) => { + let first_measurement = self.canvas_size.is_none(); + self.canvas_size = Some(size); + if first_measurement || self.fit_to_view { + self.fit(); + } + } + } + Task::none() + } + fn add_element(&mut self, child: bool) { + let allocated = child + && self.create_kind.is_work() + && self + .project() + .elements + .get(&self.selected) + .is_some_and(|e| e.layer() == self.layer && e.kind().is_performer()); + let parent = if child && !allocated { + Some(self.selected) + } else { + None + }; + let Some(e) = self.project().make_element( + self.create_kind, + self.layer, + &format!("New {}", self.create_kind.label().to_lowercase()), + parent, + ) else { + return; + }; + let id = e.id; + let mut commands = vec![Command::CreateElement(e)]; + let mut diagram_target = self.active_diagram; + if self.page == Page::Diagram + && self + .active_diagram + .and_then(|id| self.project().diagrams.get(&id)) + .is_some_and(|d| !d.projection.includes(self.create_kind)) + && ProjectionKind::Capabilities.includes(self.create_kind) + { + diagram_target = self + .project() + .diagrams + .values() + .find(|d| d.layer == self.layer && d.projection == ProjectionKind::Capabilities) + .map(|d| d.id); + if diagram_target.is_none() { + let view = Id::new_v4(); + commands.push(Command::CreateDiagram(Diagram { + id: view, + alias: self.project().fresh_alias("capabilities", "view", view), + name: format!("{} missions and capabilities", self.layer), + description: String::new(), + layer: self.layer, + projection: ProjectionKind::Capabilities, + nodes: Default::default(), + })); + diagram_target = Some(view); + } + } + if allocated { + commands.push(Command::Connect( + RelationKind::Allocation, + "Performs".into(), + self.selected, + id, + )); + } + if self.create_kind == Kind::Occurrence + && let Some(owner) = parent.and_then(|id| self.project().elements.get(&id)) + && let Some(steps) = owner.steps() + { + let mut steps = steps.clone(); + steps.push(id); + commands.push(Command::SetSteps(owner.id, steps)); + } + if self.page == Page::Diagram + && self.create_kind != Kind::Port + && let Some(diagram) = diagram_target + && self + .project() + .diagrams + .get(&diagram) + .is_none_or(|d| d.projection.includes(self.create_kind)) + { + let mut p = self.project().clone(); + for command in &commands { + if let Command::CreateDiagram(d) = command { + p.diagrams.insert(d.id, d.clone()); + } + } + p.elements.insert( + id, + match &commands[0] { + Command::CreateElement(e) => e.clone(), + _ => unreachable!(), + }, + ); + let count = p.diagrams[&diagram].nodes.len(); + let node = p.place( + diagram, + id, + Position { + x: 60. + (count % 3) as f32 * 280., + y: 60. + (count / 3) as f32 * 150., + }, + ); + commands.push(Command::CreateNode { + diagram, + node: p.diagrams[&diagram].nodes[&node].clone(), + }); + } + if self.execute(Command::Batch(commands)) { + self.active_diagram = diagram_target; + self.selected = id; + self.inspector_tab = crate::navigation::InspectorTab::Properties; + self.refresh_draft(); + self.status = "Element created · enter its engineering name and properties".into(); + } + } + fn add_relation(&mut self) { + self.workflow = Some(crate::workflow::Workflow::relationship( + self.project(), + self.selected, + self.layer, + )); + } + fn add_trace(&mut self) { + match crate::workflow::Workflow::manual_trace(self.project(), self.selected) { + Ok(workflow) => self.workflow = Some(workflow), + Err(e) => self.status = e, + } + } + fn proceed(&mut self, pending: Pending) -> Task { + self.pending = None; + match pending { + Pending::New => { + self.reset(Project::empty(), None); + Task::none() + } + Pending::Demo => { + self.reset(Project::demo(), None); + Task::none() + } + Pending::Open => self.choose_file(FileOperation::Open), + Pending::Close(id) => window::close(id), + } + } + fn choose_file(&mut self, operation: FileOperation) -> Task { + if cfg!(target_os = "linux") || operation == FileOperation::Export { + self.file_prompt = Some(FilePrompt { + operation, + path: self + .path + .clone() + .unwrap_or_else(|| { + PathBuf::from(if operation == FileOperation::Export { + "architecture.svg" + } else { + "system.arcadia" + }) + }) + .with_extension(if operation == FileOperation::Export { + "svg" + } else { + "arcadia" + }) + .to_string_lossy() + .into_owned(), + overwrite: false, + }); + return Task::none(); + } + self.busy = true; + if operation == FileOperation::Open { + return Task::perform( + async { + let file = rfd::AsyncFileDialog::new() + .add_filter("Arcadia model", &["arcadia", "json"]) + .pick_file() + .await; + match file { + None => Ok(None), + Some(file) => { + let path = file.path().to_path_buf(); + tokio::task::spawn_blocking(move || read_snapshot(path).map(Some)) + .await + .map_err(|e| e.to_string())? + } + } + }, + Message::Loaded, + ); + } + let p = self.project().clone(); + Task::perform( + async move { + let file = rfd::AsyncFileDialog::new() + .add_filter("Arcadia source", &["arcadia"]) + .add_filter("Model JSON", &["json"]) + .set_file_name("system.arcadia") + .save_file() + .await; + match file { + None => Ok(None), + Some(file) => { + let path = file.path().to_path_buf(); + tokio::task::spawn_blocking(move || { + persistence::save_checked(&path, &p, None) + .map(|bytes| Some((path, p, Arc::from(bytes)))) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? + } + } + }, + Message::Saved, + ) + } + fn load_from(&mut self, path: PathBuf) -> Task { + self.busy = true; + Task::perform( + async move { + tokio::task::spawn_blocking(move || read_snapshot(path).map(Some)) + .await + .map_err(|e| e.to_string())? + }, + Message::Loaded, + ) + } + fn save_to(&mut self, path: PathBuf) -> Task { + let expected = if self.path.as_ref() == Some(&path) { + self.file_snapshot.clone() + } else { + None + }; + self.busy = true; + let p = self.project().clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + persistence::save_checked(&path, &p, expected.as_deref()) + .map(|bytes| Some((path, p, Arc::from(bytes)))) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? + }, + Message::Saved, + ) + } + fn export_to(&mut self, path: PathBuf) -> Task { + let Some(diagram) = self.active_diagram else { + self.status = "Create a diagram first.".into(); + return Task::none(); + }; + let svg = crate::diagram::export_svg(self.project(), diagram); + self.busy = true; + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + std::fs::write(path, svg) + .map(|()| true) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? + }, + Message::Exported, + ) + } + pub fn issue_count(&self) -> usize { + self.model_issues.len() + } + + fn request_lint(&mut self) -> Task { + self.lint_requested = Some(self.source_version); + self.start_pending_lint() + } + + fn start_pending_lint(&mut self) -> Task { + if self.lint_running.is_some() { + return Task::none(); + } + let Some(version) = self.lint_requested.take() else { + return Task::none(); + }; + if version != self.source_version { + return Task::none(); + } + self.lint_running = Some(version); + let source = self.source_snapshot.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || language::lint(&source)) + .await + .map_err(|error| error.to_string()) + }, + move |result| Message::Linted(version, result), + ) + } + pub fn fit(&mut self) { + self.fit_to_view = true; + let view = self + .active_diagram + .map(|id| project_view(self.project(), id)) + .unwrap_or_default(); + if view.nodes.is_empty() { + self.zoom = 1.; + self.pan = iced::Vector::new(30., 30.); + return; + } + let left = view + .nodes + .iter() + .map(|n| n.position.x) + .fold(f32::INFINITY, f32::min); + let top = view + .nodes + .iter() + .map(|n| n.position.y) + .fold(f32::INFINITY, f32::min); + let right = view + .nodes + .iter() + .map(|n| n.position.x + n.size.width) + .fold(f32::NEG_INFINITY, f32::max); + let bottom = view + .nodes + .iter() + .map(|n| n.position.y + n.size.height) + .fold(f32::NEG_INFINITY, f32::max); + let size = self.canvas_size.unwrap_or_else(|| { + iced::Size::new( + self.window_size.width - 230. - 320., + self.window_size.height - 284., + ) + }); + let width = (size.width - 48.).max(1.); + let height = (size.height - 48.).max(1.); + self.zoom = (width / (right - left).max(1.)) + .min(height / (bottom - top).max(1.)) + .clamp(0.0001, 1.5); + self.pan = iced::Vector::new( + 24. + (width - (right - left) * self.zoom) / 2. - left * self.zoom, + 24. + (height - (bottom - top) * self.zoom) / 2. - top * self.zoom, + ); + } +} + +fn read_snapshot(path: PathBuf) -> Result { + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + let project = persistence::decode(&bytes).map_err(|e| e.to_string())?; + Ok((path, project, Arc::from(bytes))) +} + +#[cfg(test)] +mod lint_tests { + use super::*; + + fn edit(app: &mut App, value: &str) { + let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(value.into())), + ))); + } + + #[test] + fn lint_coalesces_work_and_rejects_obsolete_results() { + let mut app = App::with_project(Project::empty()); + let _ = app.request_lint(); + let first = app.source_version; + edit(&mut app, "?"); + let latest = app.source_version; + let _ = app.update(Message::Lint(latest)); + assert_eq!(app.lint_running, Some(first)); + assert_eq!(app.lint_requested, Some(latest)); + + let errors = language::lint("?"); + let _ = app.update(Message::Linted(first, Ok(errors.clone()))); + assert!(app.source_diagnostics.is_empty()); + assert_eq!(app.lint_running, Some(latest)); + let _ = app.update(Message::Linted(latest, Ok(errors.clone()))); + assert_eq!(app.source_diagnostics, errors); + assert_eq!(app.lint_running, None); + assert_eq!(app.source_editor.text(), "?"); + } + + #[test] + fn prior_document_lint_cannot_enter_reopened_model_or_block_next_checks() { + let mut app = App::with_project(Project::empty()); + edit(&mut app, "?"); + let _ = app.request_lint(); + let old_version = app.source_version; + app.reset(Project::empty(), None); + assert!(app.source_version > old_version); + let _ = app.request_lint(); + app.busy = true; + let _ = app.update(Message::Linted(old_version, Ok(language::lint("?")))); + assert!(app.source_diagnostics.is_empty()); + assert_eq!(app.lint_running, Some(app.source_version)); + let _ = app.update(Message::Linted(app.source_version, Ok(vec![]))); + assert_eq!(app.lint_running, None); + } + + #[test] + fn committed_check_cache_follows_edit_and_undo_while_drafts_stay_separate() { + let mut app = App::with_project(Project::empty()); + let original = app.issue_count(); + let element = app + .project() + .make_element(Kind::Function, Layer::Sa, "Assess request", None) + .unwrap(); + let _ = app.update(Message::Command(Command::CreateElement(element))); + assert!( + app.model_issues + .iter() + .any(|issue| issue.code == "missing_system") + ); + assert_eq!(app.issue_count(), validate(app.project()).len()); + let after = app.issue_count(); + edit(&mut app, "?"); + assert_eq!(app.issue_count(), after); + let _ = app.update(Message::RevertSource); + let _ = app.update(Message::Undo); + assert_eq!(app.issue_count(), original); + assert!(app.project().elements.is_empty()); + } +} diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs new file mode 100644 index 00000000..0867bb7d --- /dev/null +++ b/crates/oa-desktop/src/behavior.rs @@ -0,0 +1,761 @@ +//! Readable behavioral projections. Selection edits the same typed records as text. +use crate::{ + app::{App, Choice, Message}, + style, + views::{action, heading, label, short}, +}; +use iced::{ + Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse, + widget::{ + Action, button, + canvas::{self, Frame, Path, Stroke, Text}, + column, container, pick_list, row, scrollable, text, + }, +}; +use oa_core::*; +use std::collections::BTreeMap; +mod scenario; +mod state_scene; +mod states; +mod topology; +#[derive(Debug, Clone)] +pub enum Event { + Topology(topology::Event), + Scenario(scenario::Event), + States(states::Event), + Submit, +} +impl Event { + pub fn is_submit(&self) -> bool { + matches!( + self, + Self::Submit + | Self::Topology(topology::Event::Submit) + | Self::Scenario(scenario::Event::Submit) + | Self::States(states::Event::Submit) + ) + } +} +#[derive(Default)] +pub struct State { + topology: topology::State, + scenario: scenario::State, + states: states::State, +} +impl State { + pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + match event { + Event::Topology(event) => self.topology.update(p, event), + Event::Scenario(event) => self.scenario.update(p, event), + Event::States(event) => self.states.update(p, event), + Event::Submit => self.topology.update(p, topology::Event::Submit), + } + } +} + +pub fn view(app: &App) -> Element<'_, Message> { + let p = app.project(); + let choices = p + .elements + .values() + .filter(|e| { + e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) + }) + .map(|e| Choice { + id: e.id, + label: format!("{} · {}", e.kind(), e.name), + }) + .collect::>(); + let owner = selected_behavior(p, app.selected); + let selected = owner + .and_then(|id| choices.iter().find(|c| c.id == id)) + .or(choices.first()) + .cloned(); + let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14); + let Some(selected) = selected else { + 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]; + out = out.push( + pick_list(choices, Some(selected), |c| Message::Select(c.id)) + .width(Fill) + .text_size(13) + .padding(10), + ); + if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return topology::view(app, e.id, out); + } + if e.kind() == Kind::Scenario { + return scenario::view(app, e.id, out); + } + states::view(app, e.id, out) +} +/// Mirror the core's legacy-versus-explicit process interpretation. Existing +/// exchange-only lists still describe ordered paths until explicitly migrated. +fn uses_explicit_topology(p: &Project, owner: Id) -> bool { + let Some(e) = p.elements.get(&owner) else { + return false; + }; + if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return false; + } + p.relations.values().any(|r| { + r.kind() == RelationKind::SequenceLink + && p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner) + && p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner) + }) || e.steps().into_iter().flatten().any(|id| { + matches!( + p.elements.get(id).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + }) +} +fn selected_behavior(p: &Project, selected: Id) -> Option { + let mut current = Some( + p.relations + .get(&selected) + .filter(|r| { + matches!( + r.kind(), + RelationKind::StateTransition | RelationKind::SequenceLink + ) + }) + .map(|r| r.source()) + .unwrap_or(selected), + ); + while let Some(id) = current { + let e = p.elements.get(&id)?; + if e.kind().is_sequence() || e.kind() == Kind::StateMachine { + return Some(id); + } + current = e.parent; + } + None +} +fn in_machine(p: &Project, id: Id, machine: Id) -> bool { + let mut current = Some(id); + while let Some(id) = current { + if id == machine { + return true; + } + current = p.elements.get(&id).and_then(|e| e.parent); + } + false +} + +struct BoxItem { + id: Id, + rect: Rectangle, + title: String, + detail: String, + marker: Option, +} +struct Line { + id: Id, + points: Vec, + label: String, + label_at: Point, + dashed: bool, +} +struct Scene { + boxes: Vec, + lines: Vec, + size: Size, + selected: Id, + scale: f32, +} +impl Scene { + fn sequence(p: &Project, owner: Id, selected: Id) -> Self { + let mut scene = Self { + scale: 1., + boxes: vec![], + lines: vec![], + size: Size::new(700., 200.), + selected, + }; + let mut lanes = p + .children(owner) + .filter(|e| e.kind() == Kind::Lifeline) + .map(|e| { + ( + e.id, + e.name.clone(), + match e.shared() { + Some(SharedBody::Lifeline { + represented: Some(id), + }) => p.named(*id).to_owned(), + _ => "Unassigned participant".into(), + }, + ) + }) + .collect::>(); + let scenario = p.elements[&owner].kind() == Kind::Scenario; + if !scenario { + fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec) { + for id in steps { + if let Some(SharedBody::Occurrence { body, .. }) = + p.elements.get(id).and_then(|e| e.shared()) + { + match body { + OccurrenceBody::Exchange { + exchange: Some(id), .. + } => { + if let Some(r) = p.relations.get(id) { + for endpoint in [r.source(), r.target()] { + if !ids.contains(&endpoint) { + ids.push(endpoint); + } + } + } + } + OccurrenceBody::Fragment { branches, .. } => { + endpoints(p, branches, ids) + } + OccurrenceBody::Branch { occurrences, .. } => { + endpoints(p, occurrences, ids) + } + _ => {} + } + } + } + } + let mut ids = vec![]; + endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids); + lanes = ids + .into_iter() + .map(|id| { + ( + id, + p.named(id).to_owned(), + p.elements + .get(&id) + .map(|e| e.kind().label()) + .unwrap_or("Endpoint") + .into(), + ) + }) + .collect(); + } + let lane_x = lanes + .iter() + .enumerate() + .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) + .collect::>(); + scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); + let mut y = 120.; + scene.occurrences( + p, + p.elements[&owner].steps().map(Vec::as_slice).unwrap_or(&[]), + scenario, + &lane_x, + &mut y, + 0, + ); + scene.size.height = (y + 45.).max(210.); + for (id, name, detail) in lanes { + let x = lane_x[&id]; + scene.lines.insert( + 0, + Line { + id, + points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)], + label: String::new(), + label_at: Point::ORIGIN, + dashed: false, + }, + ); + scene.boxes.push(BoxItem { + id, + rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)), + title: name, + detail, + marker: None, + }); + } + scene + } + fn occurrences( + &mut self, + p: &Project, + steps: &[Id], + scenario: bool, + lanes: &BTreeMap, + y: &mut f32, + depth: usize, + ) { + for (index, id) in steps.iter().enumerate() { + let Some(e) = p.elements.get(id) else { + continue; + }; + let Some(SharedBody::Occurrence { body, .. }) = e.shared() else { + continue; + }; + match body { + OccurrenceBody::Fragment { operator, branches } => { + let top = *y; + *y += 55.; + self.occurrences(p, branches, scenario, lanes, y, depth + 1); + let rect = Rectangle::new( + Point::new(18. + depth as f32 * 10., top), + Size::new( + self.size.width - 36. - depth as f32 * 20., + (*y - top).max(95.), + ), + ); + self.boxes.insert( + 0, + BoxItem { + id: *id, + rect, + title: format!("{operator:?} · {}", e.name), + detail: String::new(), + marker: Some(Kind::Occurrence), + }, + ); + *y += 20.; + } + OccurrenceBody::Branch { guard, occurrences } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(36. + depth as f32 * 10., *y), + Size::new(self.size.width - 85. - depth as f32 * 20., 43.), + ), + title: format!( + "[{}]", + if guard.is_empty() { + "guard not specified" + } else { + guard + } + ), + detail: String::new(), + marker: Some(Kind::Occurrence), + }); + *y += 62.; + self.occurrences(p, occurrences, scenario, lanes, y, depth + 1); + } + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + message_kind, + .. + } => { + let r = exchange.and_then(|id| p.relations.get(&id)); + let source = if scenario { + *sender + } else { + r.map(|r| r.source()) + }; + let target = if scenario { + if matches!(message_kind, MessageKind::Timer { .. }) { + *sender + } else { + *receiver + } + } else { + r.map(|r| r.target()) + }; + if let (Some(a), Some(b)) = ( + source.and_then(|id| lanes.get(&id)), + target.and_then(|id| lanes.get(&id)), + ) { + let points = if a == b { + vec![ + Point::new(*a, *y), + Point::new(a + 75., *y), + Point::new(a + 75., *y + 24.), + Point::new(*a, *y + 24.), + ] + } else { + vec![Point::new(*a, *y), Point::new(*b, *y)] + }; + let kind = if !scenario { + "" + } else { + match message_kind { + MessageKind::Asynchronous => "signal · ", + MessageKind::Call => "call · ", + MessageKind::Reply => "reply · ", + MessageKind::Create => "create · ", + MessageKind::Delete => "delete · ", + MessageKind::Timer { .. } => "timer · ", + } + }; + let message_label = if let MessageKind::Timer { expression } = message_kind + { + expression.as_str() + } else { + r.map(|r| r.name.as_str()).unwrap_or(&e.name) + }; + self.lines.push(Line { + id: *id, + points, + label: format!("{}. {kind}{}", index + 1, message_label), + label_at: Point::new(a.min(*b) + 12., *y - 26.), + dashed: matches!(message_kind, MessageKind::Reply), + }); + if scenario && matches!(message_kind, MessageKind::Call) { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(*b - 4., *y), Size::new(8., 80.)), + title: String::new(), + detail: String::new(), + marker: None, + }); + } + } else { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(65., *y - 20.), + Size::new(self.size.width - 130., 65.), + ), + title: e.name.clone(), + detail: "Choose an exchange and its lifelines in Properties".into(), + marker: None, + }); + } + *y += 92.; + } + OccurrenceBody::Action { action, lifeline } + | OccurrenceBody::State { + state: action, + lifeline, + } => { + let x = lifeline + .and_then(|id| lanes.get(&id)) + .copied() + .unwrap_or(180.); + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)), + title: e.name.clone(), + detail: action + .map(|id| p.named(id).to_owned()) + .unwrap_or("Choose observed behavior".into()), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::Control { kind } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)), + title: format!("{kind:?} · {}", e.name), + detail: "Contextual process control".into(), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::SequenceReference { sequence } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)), + title: e.name.clone(), + detail: sequence + .map(|id| format!("Use {}", p.named(id))) + .unwrap_or("Choose a reusable process or chain".into()), + marker: None, + }); + *y += 95.; + } + OccurrenceBody::ScenarioUse { scenario, bindings } => { + self.boxes.push(BoxItem { + id: *id, + rect: Rectangle::new( + Point::new(65., *y - 20.), + Size::new(self.size.width - 130., 70.), + ), + title: format!("ref · {}", e.name), + detail: scenario + .map(|id| { + format!( + "{} · {} explicit lifeline bindings", + p.named(id), + bindings.len() + ) + }) + .unwrap_or("Choose a scenario".into()), + marker: Some(Kind::Occurrence), + }); + *y += 95.; + } + } + } + } +} +impl canvas::Program for Scene { + type State = (); + fn update( + &self, + _: &mut (), + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + if matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) && let Some(p) = cursor.position_in(bounds) + { + let p = Point::new(p.x / self.scale, p.y / self.scale); + if let Some(b) = self + .boxes + .iter() + .rev() + .find(|b| b.rect.contains(p) && (!self.is_container(b) || p.y < b.rect.y + 38.)) + { + return Some(Action::publish(Message::Select(b.id)).and_capture()); + } + if let Some(l) = self.lines.iter().find(|l| { + !l.label.is_empty() + && (Rectangle::new( + l.label_at, + Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25.), + ) + .contains(p) + || l.points + .windows(2) + .any(|v| crate::diagram::distance_to_segment(p, v[0], v[1]) < 8.)) + }) { + return Some(Action::publish(Message::Select(l.id)).and_capture()); + } + } + None + } + fn draw( + &self, + _: &(), + renderer: &Renderer, + _: &Theme, + bounds: Rectangle, + _: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.scale(self.scale); + for b in &self.boxes { + let outline = matches!( + b.marker, + Some(Kind::Region | Kind::StateMachine | Kind::Occurrence) + ); + let color = if b.id == self.selected { + style::TEAL + } else { + style::LINE + }; + if outline { + f.stroke( + &Path::rectangle(b.rect.position(), b.rect.size()), + Stroke::default().with_color(color), + ); + } else if !b + .marker + .is_some_and(|k| k.is_state() && !k.is_stable_state()) + { + let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into()); + f.fill(&path, Color::from_rgb8(243, 248, 248)); + f.stroke( + &path, + Stroke::default() + .with_color(color) + .with_width(if b.id == self.selected { 2.5 } else { 1. }), + ); + } + } + for l in &self.lines { + let color = if l.id == self.selected { + style::TEAL + } else { + style::MUTED + }; + f.stroke( + &Path::new(|p| { + p.move_to(l.points[0]); + for point in &l.points[1..] { + p.line_to(*point); + } + }), + Stroke { + line_dash: canvas::LineDash { + segments: if l.dashed { &[6., 4.] } else { &[] }, + offset: 0, + }, + ..Stroke::default() + .with_color(color) + .with_width(if l.id == self.selected { 2.5 } else { 1.2 }) + }, + ); + if !l.label.is_empty() { + let end = l.points[l.points.len() - 1]; + let prev = l.points[l.points.len() - 2]; + let delta = end - prev; + let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.); + let d = delta / len; + f.stroke( + &Path::new(|p| { + p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.); + p.line_to(end); + p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.); + }), + Stroke::default().with_color(color), + ); + f.fill_rectangle( + l.label_at, + Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.), + Color::WHITE, + ); + f.fill_text(Text { + content: short(&l.label, 32), + position: l.label_at, + color, + size: 12.into(), + ..Default::default() + }); + } + } + for b in &self.boxes { + let pseudo = b + .marker + .is_some_and(|k| k.is_state() && !k.is_stable_state()); + if pseudo { + state_scene::draw_symbol( + &mut f, + b, + if b.id == self.selected { + style::TEAL + } else { + style::INK + }, + ); + } + let prefix = match b.marker { + Some(Kind::Initial) => "INITIAL · ", + Some(Kind::Final) => "FINAL · ", + Some(Kind::Mode) => "MODE · ", + Some(Kind::State) => "STATE · ", + Some(Kind::Choice) => "CHOICE · ", + Some(Kind::Junction) => "JUNCTION · ", + Some(Kind::Fork) => "FORK · ", + Some(Kind::Join) => "JOIN · ", + Some(Kind::ShallowHistory) => "H · ", + Some(Kind::DeepHistory) => "H* · ", + Some(Kind::EntryPoint) => "ENTRY · ", + Some(Kind::ExitPoint) => "EXIT · ", + Some(Kind::Terminate) => "TERMINATE · ", + _ => "", + }; + f.fill_text(Text { + content: format!( + "{prefix}{}", + short(&b.title, ((b.rect.width - 25.) / 7.) as usize) + ), + position: Point::new( + b.rect.x + if pseudo { 38. } else { 12. }, + b.rect.y + if pseudo { 18. } else { 12. }, + ), + size: 13.into(), + color: if b.id == self.selected { + style::TEAL + } else { + style::INK + }, + ..Default::default() + }); + if !b.detail.is_empty() { + f.fill_text(Text { + content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize), + position: Point::new(b.rect.x + 12., b.rect.y + 39.), + size: 11.into(), + color: style::MUTED, + ..Default::default() + }); + } + } + vec![f.into_geometry()] + } + fn mouse_interaction( + &self, + _: &(), + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.is_over(bounds) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn legacy_process_and_chain_keep_their_ordered_exchange_scene() { + let p = Project::demo(); + let owners: Vec<_> = p + .elements + .values() + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .collect(); + assert!(owners.iter().any(|e| e.kind() == Kind::Process)); + assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain)); + for owner in owners { + assert!(!uses_explicit_topology(&p, owner.id)); + let expected: Vec<_> = owner + .steps() + .unwrap() + .iter() + .enumerate() + .map(|(index, id)| { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(exchange), + .. + }, + .. + }) = p.elements[id].shared() + else { + panic!("Demo legacy sequence should contain exchange occurrences"); + }; + format!("{}. {}", index + 1, p.relations[exchange].name) + }) + .collect(); + assert!(!expected.is_empty()); + let scene = Scene::sequence(&p, owner.id, owner.id); + let actual: Vec<_> = scene + .lines + .iter() + .filter(|line| !line.label.is_empty()) + .map(|line| line.label.clone()) + .collect(); + assert_eq!( + actual, expected, + "Legacy rendering must preserve the model's ordered exchange path" + ); + } + } + #[test] + fn selecting_state_transition_keeps_its_behavior_context() { + let p = Project::demo(); + for r in p + .relations + .values() + .filter(|r| r.kind() == RelationKind::StateTransition) + { + let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine"); + assert_eq!(p.elements[&machine].kind(), Kind::StateMachine); + assert!(in_machine(&p, r.source(), machine)); + assert!(in_machine(&p, r.target(), machine)); + } + } +} diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs new file mode 100644 index 00000000..800fd881 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -0,0 +1,1176 @@ +//! Staged scenario authoring. UI picks semantic identities; the core validates +//! the complete transaction, including correlation, lifecycle and reuse rules. +use super::topology::{occurrence, unique_batch}; +use super::*; +use iced::widget::{Column, column, text_input}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tool { + Lifeline, + Message, + Reuse, + Action, + Observation, + Fragment, +} +impl Tool { + fn label(self) -> &'static str { + match self { + Self::Lifeline => "Add lifeline", + Self::Message => "Add message", + Self::Reuse => "Reuse scenario", + Self::Action => "Add action", + Self::Observation => "Observe state", + Self::Fragment => "Add fragment", + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageStyle { + Asynchronous, + Call, + CallReply, + Reply, + Create, + Delete, + Timer, +} +impl std::fmt::Display for MessageStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Asynchronous => "Asynchronous message", + Self::Call => "Synchronous call", + Self::CallReply => "Call + correlated reply", + Self::Reply => "Reply to existing call", + Self::Create => "Create participant", + Self::Delete => "Delete participant", + Self::Timer => "Local timer", + }) + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Operator(pub FragmentOperator); +impl std::fmt::Display for Operator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self.0 { + FragmentOperator::Alternative => "ALT · alternatives", + FragmentOperator::Parallel => "PAR · parallel", + FragmentOperator::Loop => "LOOP · repetition", + FragmentOperator::Optional => "OPT · optional", + }) + } +} +#[derive(Debug, Clone)] +pub enum Event { + Start(Id, Tool), + Cancel, + Submit, + Name(String), + Target(Choice), + Exchange(Choice), + Sender(Choice), + Receiver(Choice), + Call(Choice), + Style(MessageStyle), + Parent(Choice), + Expression(String), + SecondGuard(String), + Operator(Operator), + Binding(Id, Choice), + Value(Id, String), +} +#[derive(Default)] +pub struct State { + draft: Option, +} +#[derive(Clone)] +struct Draft { + owner: Id, + parent: Id, + tool: Tool, + name: String, + target: Option, + exchange: Option, + sender: Option, + receiver: Option, + call: Option, + style: MessageStyle, + expression: String, + second_guard: String, + operator: FragmentOperator, + bindings: BTreeMap, + values: BTreeMap, +} +impl State { + pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + match event { + Event::Start(owner, tool) => { + if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) { + return Err("Choose a scenario.".into()); + } + self.draft = Some(Draft { + owner, + parent: owner, + tool, + name: String::new(), + target: None, + exchange: None, + sender: None, + receiver: None, + call: None, + style: MessageStyle::CallReply, + expression: String::new(), + second_guard: "else".into(), + operator: FragmentOperator::Alternative, + bindings: BTreeMap::new(), + values: BTreeMap::new(), + }); + } + Event::Cancel => self.draft = None, + Event::Submit => { + let d = self.draft.as_ref().ok_or("Choose a scenario tool first.")?; + let command = d.command(p)?; + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + self.draft = None; + return Ok(Some(command)); + } + other => { + if let Some(d) = &mut self.draft { + match other { + Event::Name(s) => d.name = s, + Event::Expression(s) => d.expression = s, + Event::SecondGuard(s) => d.second_guard = s, + Event::Target(c) => { + d.target = Some(c.id); + d.bindings.clear(); + } + Event::Exchange(c) => { + d.exchange = Some(c.id); + d.values.clear(); + d.sender = None; + d.receiver = None; + } + Event::Sender(c) => d.sender = Some(c.id), + Event::Receiver(c) => d.receiver = Some(c.id), + Event::Parent(c) => d.parent = c.id, + Event::Style(style) => { + d.style = style; + d.call = None; + d.exchange = None; + d.sender = None; + d.receiver = None; + d.values.clear(); + } + Event::Call(c) => { + d.call = Some(c.id); + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange, + sender, + receiver, + .. + }, + .. + }) = p.elements.get(&c.id).and_then(|e| e.shared()) + { + d.exchange = *exchange; + d.sender = *receiver; + d.receiver = *sender; + } + } + Event::Operator(operator) => d.operator = operator.0, + Event::Binding(referenced, c) => { + d.bindings.insert(referenced, c.id); + } + Event::Value(id, s) => { + d.values.insert(id, s); + } + _ => {} + } + } + } + } + Ok(None) + } +} +fn viewpoint(p: &Project, owner: Id) -> ScenarioViewpoint { + match p.elements.get(&owner).and_then(|e| e.shared()) { + Some(SharedBody::Scenario { viewpoint, .. }) => *viewpoint, + _ => ScenarioViewpoint::MixedLegacy, + } +} +fn represented(p: &Project, line: Id) -> Option { + match p.elements.get(&line)?.shared()? { + SharedBody::Lifeline { represented } => *represented, + _ => None, + } +} +fn named_choice(p: &Project, id: Id) -> Choice { + Choice { + id, + label: format!( + "{} · {}", + p.named(id), + p.elements + .get(&id) + .map(|e| e.alias.as_str()) + .or_else(|| p.relations.get(&id).map(|r| r.alias.as_str())) + .unwrap_or("") + ), + } +} +fn lines(p: &Project, owner: Id) -> Vec { + p.children(owner) + .filter(|e| e.kind() == Kind::Lifeline) + .map(|e| named_choice(p, e.id)) + .collect() +} +fn parents(p: &Project, owner: Id) -> Vec { + let mut out = vec![Choice { + id: owner, + label: "Main scenario sequence".into(), + }]; + out.extend( + p.elements + .values() + .filter(|e| { + matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) + ) && validation::ancestor_of_kind(p, e.id, Kind::Scenario) == Some(owner) + }) + .map(|e| named_choice(p, e.id)), + ); + out +} +fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool { + represented(p, referenced) + .zip(represented(p, local)) + .is_some_and(|(a, b)| validation::semantic_representation_compatible(p, b, a) == Some(true)) +} +fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool { + let e = occurrence(p, owner, "Scenario selection preview", body); + execute_batch(p, &[Command::CreateElement(e)]).is_ok() +} +fn sources(p: &Project, d: &Draft) -> Vec { + let layer = p.elements[&d.owner].layer(); + let mut values: Vec<_> = p + .elements + .values() + .filter(|e| { + e.layer() == layer + && match d.tool { + Tool::Lifeline => { + validation::viewpoint_represents(viewpoint(p, d.owner), e.kind()) + } + Tool::Action => e.kind().is_work(), + Tool::Observation => e.kind().is_state(), + Tool::Reuse => { + e.kind() == Kind::Scenario + && valid_body_choice( + p, + d.owner, + OccurrenceBody::ScenarioUse { + scenario: Some(e.id), + bindings: vec![], + }, + ) + } + _ => false, + } + }) + .map(|e| named_choice(p, e.id)) + .collect(); + values.sort_by(|a, b| a.label.cmp(&b.label)); + values +} +fn exchanges(p: &Project, d: &Draft) -> Vec { + p.relations + .values() + .filter(|r| { + p.layer_of(r.id) == p.layer_of(d.owner) + && matches!( + r.kind(), + RelationKind::Interaction + | RelationKind::FunctionalExchange + | RelationKind::BoundaryExchange + ) + && valid_body_choice( + p, + d.owner, + OccurrenceBody::Exchange { + exchange: Some(r.id), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }, + ) + }) + .map(|r| Choice { + id: r.id, + label: format!( + "{} · {} → {}", + r.name, + p.named(r.source()), + p.named(r.target()) + ), + }) + .collect() +} +fn calls(p: &Project, d: &Draft) -> Vec { + p.elements + .values() + .filter(|e| validation::ancestor_of_kind(p, e.id, Kind::Scenario) == Some(d.owner)) + .filter_map(|e| { + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + exchange, + sender, + receiver, + .. + }, + .. + }) = e.shared() + else { + return None; + }; + let mut candidate = d.clone(); + candidate.style = MessageStyle::Reply; + candidate.call = Some(e.id); + candidate.exchange = *exchange; + candidate.sender = *receiver; + candidate.receiver = *sender; + candidate + .command(p) + .ok() + .and_then(|command| execute_batch(p, &[command]).ok()) + .map(|_| named_choice(p, e.id)) + }) + .collect() +} +impl Draft { + fn kind(&self) -> MessageKind { + match self.style { + MessageStyle::Asynchronous => MessageKind::Asynchronous, + MessageStyle::Call | MessageStyle::CallReply => MessageKind::Call, + MessageStyle::Reply => MessageKind::Reply, + MessageStyle::Create => MessageKind::Create, + MessageStyle::Delete => MessageKind::Delete, + MessageStyle::Timer => MessageKind::Timer { + expression: self.expression.clone(), + }, + } + } + fn message_body(&self) -> OccurrenceBody { + OccurrenceBody::Exchange { + exchange: if self.style == MessageStyle::Timer { + None + } else { + self.exchange + }, + sender: self.sender, + receiver: if self.style == MessageStyle::Timer { + None + } else { + self.receiver + }, + message_kind: self.kind(), + reply_to: self.call, + values: self + .values + .iter() + .filter(|(_, v)| !v.trim().is_empty()) + .map(|(field, expression)| MessageValue { + field: *field, + expression: expression.clone(), + }) + .collect(), + } + } + fn line_choices(&self, p: &Project, sender: bool) -> Vec { + let endpoint = validation::message_endpoints(p, &self.message_body()) + .map(|(a, b)| if sender { a } else { b }); + lines(p, self.owner) + .into_iter() + .filter(|line| { + endpoint.is_none_or(|endpoint| { + validation::lifeline_matches_endpoint(p, line.id, endpoint) == Some(true) + }) + }) + .collect() + } + fn append(&self, p: &Project, name: &str, body: OccurrenceBody) -> oa_core::Element { + let mut e = occurrence(p, self.owner, name, body); + e.parent = Some(self.parent); + e + } + fn command(&self, p: &Project) -> Result { + if !parents(p, self.owner).iter().any(|c| c.id == self.parent) { + return Err("Choose this scenario or one of its operands as destination.".into()); + } + let default = self + .target + .or(self.exchange) + .map(|id| p.named(id)) + .unwrap_or(self.tool.label()); + let name = if self.name.trim().is_empty() { + default + } else { + self.name.trim() + }; + let mut commands = vec![]; + match self.tool { + Tool::Lifeline => { + let target = self + .target + .ok_or("Choose the work or participant represented by this lifeline.")?; + if !sources(p, self).iter().any(|c| c.id == target) { + return Err( + "Choose a representation compatible with the scenario viewpoint.".into(), + ); + } + let mut e = p + .make_element( + Kind::Lifeline, + p.elements[&self.owner].layer(), + name, + Some(self.owner), + ) + .unwrap(); + *e.shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(target), + }; + commands.push(Command::CreateElement(e)); + } + Tool::Message => { + let sender = self.sender.ok_or("Choose the sending lifeline.")?; + if !self.line_choices(p, true).iter().any(|c| c.id == sender) { + return Err("The sender must represent the exchange source.".into()); + } + if self.style == MessageStyle::Timer { + if self.expression.trim().is_empty() { + return Err("Enter the timer duration or deadline.".into()); + } + } else { + if self.exchange.is_none() { + return Err("Choose the exchange carried by this message.".into()); + } + let receiver = self.receiver.ok_or("Choose the receiving lifeline.")?; + if !self.line_choices(p, false).iter().any(|c| c.id == receiver) { + return Err("The receiver must represent the exchange target.".into()); + } + } + if self.style == MessageStyle::Reply && self.call.is_none() { + return Err("Choose the earlier call answered by this reply.".into()); + } + let call = self.append(p, name, self.message_body()); + let call_id = call.id; + commands.push(Command::CreateElement(call)); + if self.style == MessageStyle::CallReply { + commands.push(Command::CreateElement(self.append( + p, + &format!("Reply · {name}"), + OccurrenceBody::Exchange { + exchange: self.exchange, + sender: self.receiver, + receiver: self.sender, + message_kind: MessageKind::Reply, + reply_to: Some(call_id), + values: vec![], + }, + ))); + } + } + Tool::Reuse => { + let target = self.target.ok_or("Choose a scenario to reuse.")?; + if !sources(p, self).iter().any(|c| c.id == target) { + return Err("Choose a compatible scenario without recursive reuse.".into()); + } + let mut used = BTreeSet::new(); + let mut bindings = vec![]; + for line in lines(p, target) { + let local = *self + .bindings + .get(&line.id) + .ok_or("Bind every referenced lifeline to a local lifeline.")?; + if !lines(p, self.owner).iter().any(|c| c.id == local) + || !compatible_binding(p, line.id, local) + || !used.insert(local) + { + return Err( + "Each binding needs a distinct compatible local lifeline.".into() + ); + } + bindings.push(LifelineBinding { + referenced: line.id, + local, + }); + } + commands.push(Command::CreateElement(self.append( + p, + name, + OccurrenceBody::ScenarioUse { + scenario: Some(target), + bindings, + }, + ))); + } + Tool::Action | Tool::Observation => { + let target = self + .target + .ok_or("Choose the action or state to observe.")?; + if !sources(p, self).iter().any(|c| c.id == target) { + return Err("Choose compatible work or a state in this layer.".into()); + } + let line = self.sender.ok_or("Choose the observed lifeline.")?; + if !lines(p, self.owner).iter().any(|c| c.id == line) { + return Err("Choose a lifeline in this scenario.".into()); + } + let body = if self.tool == Tool::Action { + OccurrenceBody::Action { + action: Some(target), + lifeline: Some(line), + } + } else { + OccurrenceBody::State { + state: Some(target), + lifeline: Some(line), + } + }; + commands.push(Command::CreateElement(self.append(p, name, body))); + } + Tool::Fragment => { + let two = matches!( + self.operator, + FragmentOperator::Alternative | FragmentOperator::Parallel + ); + if self.operator != FragmentOperator::Parallel && self.expression.trim().is_empty() + { + return Err("Describe the operand condition or loop guard.".into()); + } + let fragment = self.append( + p, + name, + OccurrenceBody::Fragment { + operator: self.operator, + branches: vec![], + }, + ); + let id = fragment.id; + commands.push(Command::CreateElement(fragment)); + for (i, guard) in [&self.expression, &self.second_guard] + .into_iter() + .take(if two { 2 } else { 1 }) + .enumerate() + { + let mut branch = self.append( + p, + &format!("{} · operand {}", name, i + 1), + OccurrenceBody::Branch { + guard: if self.operator == FragmentOperator::Parallel { + String::new() + } else { + guard.clone() + }, + occurrences: vec![], + }, + ); + branch.parent = Some(id); + commands.push(Command::CreateElement(branch)); + } + } + } + Ok(unique_batch(p, commands)) + } +} +fn event(e: Event) -> Message { + Message::Behavior(super::Event::Scenario(e)) +} +fn picker<'a>( + choices: Vec, + selected: Option, + placeholder: &'a str, + change: impl Fn(Choice) -> Event + 'a, +) -> Element<'a, Message> { + let selected = selected + .and_then(|id| choices.iter().find(|c| c.id == id)) + .cloned(); + pick_list(choices, selected, move |c| event(change(c))) + .placeholder(placeholder) + .width(Fill) + .padding(9) + .text_size(12) + .into() +} +pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { + let p = app.project(); + out = out.push(label(match viewpoint(p, owner) { + ScenarioViewpoint::OperationalActivity => { + "Operational activity scenario · lifelines represent activities" + } + ScenarioViewpoint::Functional => { + "Functional scenario · lifelines represent system functions" + } + ScenarioViewpoint::ParticipantInteraction => { + "Participant scenario · lifelines represent entities, actors or roles" + } + ScenarioViewpoint::BoundaryInterface => { + "Interface scenario · lifelines represent the system and external actors" + } + ScenarioViewpoint::MixedLegacy => { + "Mixed scenario · lifelines may represent work or participants" + } + })); + out = out + .push( + row![ + action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))), + action("+ Message", event(Event::Start(owner, Tool::Message))), + action("Reuse…", event(Event::Start(owner, Tool::Reuse))) + ] + .spacing(7), + ) + .push( + row![ + action("+ Fragment", event(Event::Start(owner, Tool::Fragment))), + action("+ Action", event(Event::Start(owner, Tool::Action))), + action( + "Observe state", + event(Event::Start(owner, Tool::Observation)) + ), + action("Properties", Message::EditProperties(owner)) + ] + .spacing(7), + ); + if let Some(d) = app + .behavior + .scenario + .draft + .as_ref() + .filter(|d| d.owner == owner) + { + let mut form = column![ + text(d.tool.label()).size(16), + text_input("Name (defaults to selected source)", &d.name) + .on_input(|s| event(Event::Name(s))) + .padding(9) + .size(12) + ] + .spacing(9); + if d.tool != Tool::Lifeline { + form = form.push(picker( + parents(p, owner), + Some(d.parent), + "Destination sequence / operand…", + Event::Parent, + )); + } + if matches!( + d.tool, + Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation + ) { + form = form.push(picker( + sources(p, d), + d.target, + "Choose a named source…", + Event::Target, + )); + } + if d.tool == Tool::Message { + form = form.push( + pick_list( + vec![ + MessageStyle::Asynchronous, + MessageStyle::Call, + MessageStyle::CallReply, + MessageStyle::Reply, + MessageStyle::Create, + MessageStyle::Delete, + MessageStyle::Timer, + ], + Some(d.style), + |style| event(Event::Style(style)), + ) + .width(Fill) + .padding(9) + .text_size(12), + ); + if d.style == MessageStyle::Reply { + form = form.push(picker( + calls(p, d), + d.call, + "Choose the earlier call…", + Event::Call, + )); + if d.call.is_some() { + form = form.push(label( + "Reply reuses the call exchange in reverse and retains its correlation.", + )); + } + } else if d.style != MessageStyle::Timer { + form = form.push(picker( + exchanges(p, d), + d.exchange, + "Choose the exchange…", + Event::Exchange, + )); + } + form = form.push(picker( + d.line_choices(p, true), + d.sender, + "Sending lifeline…", + Event::Sender, + )); + if d.style != MessageStyle::Timer { + form = form.push(picker( + d.line_choices(p, false), + d.receiver, + "Receiving lifeline…", + Event::Receiver, + )); + } + if d.style == MessageStyle::Timer { + form = form.push( + text_input("Duration / deadline, e.g. 30 s", &d.expression) + .on_input(|s| event(Event::Expression(s))) + .padding(9) + .size(12), + ); + } + if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) { + for field in validation::carried_fields(p, exchange) { + form = form.push( + row![ + label(p.named(field)), + text_input( + "Optional value expression", + d.values.get(&field).map(String::as_str).unwrap_or("") + ) + .on_input(move |s| event(Event::Value(field, s))) + .padding(8) + .size(12) + ] + .spacing(7), + ); + } + } + if d.style == MessageStyle::CallReply { + form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation.")); + } + } + if matches!(d.tool, Tool::Action | Tool::Observation) { + form = form.push(picker( + lines(p, owner), + d.sender, + "Observed lifeline…", + Event::Sender, + )); + } + if d.tool == Tool::Reuse + && let Some(target) = d.target + { + for line in lines(p, target) { + let referenced = line.id; + let compatible: Vec<_> = lines(p, owner) + .into_iter() + .filter(|c| { + compatible_binding(p, referenced, c.id) + && !d + .bindings + .iter() + .any(|(r, l)| *r != referenced && *l == c.id) + }) + .collect(); + if compatible.is_empty() { + form = form.push(label(format!( + "Add a compatible local lifeline before binding '{}'.", + p.named(referenced) + ))); + } + form = form + .push(label(format!("Bind {}", p.named(referenced)))) + .push(picker( + compatible, + d.bindings.get(&referenced).copied(), + "Compatible local lifeline…", + move |c| Event::Binding(referenced, c), + )); + } + form = form.push(label( + "Bindings are explicit, one-to-one and refer to existing lifelines.", + )); + } + if d.tool == Tool::Fragment { + form = form.push( + pick_list( + vec![ + Operator(FragmentOperator::Alternative), + Operator(FragmentOperator::Parallel), + Operator(FragmentOperator::Loop), + Operator(FragmentOperator::Optional), + ], + Some(Operator(d.operator)), + |o| event(Event::Operator(o)), + ) + .padding(9) + .text_size(12) + .width(Fill), + ); + if d.operator != FragmentOperator::Parallel { + form = form.push( + text_input("Condition / loop guard", &d.expression) + .on_input(|s| event(Event::Expression(s))) + .padding(9) + .size(12), + ); + } + if d.operator == FragmentOperator::Alternative { + form = form.push( + text_input("Second alternative guard", &d.second_guard) + .on_input(|s| event(Event::SecondGuard(s))) + .padding(9) + .size(12), + ); + } + form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions.")); + } + let readiness = d.command(p); + if let Err(error) = &readiness { + form = form.push(label(error.clone())); + } + form = form.push( + row![ + button("Create") + .padding(9) + .on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))), + action("Cancel", event(Event::Cancel)) + ] + .spacing(7), + ); + out = out.push(container(form).padding(14).style(style::card)); + } + if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) { + return scrollable(out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card)).padding(24)).into(); + } + let scene = Scene::sequence(p, owner, app.selected); + let size = scene.size; + out = out.push( + container( + scrollable( + canvas::Canvas::new(scene) + .width(size.width) + .height(size.height), + ) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .height(size.height.clamp(200., 460.)), + ) + .padding(8) + .style(style::card), + ); + out = sequence_rows(p, owner, out); + scrollable(out.padding(24)).into() +} +fn sequence_rows<'a>( + p: &'a Project, + owner: Id, + mut out: Column<'a, Message>, +) -> Column<'a, Message> { + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) = p.elements.get(&owner).and_then(|e| e.shared()) + { + for branch in branches { + out = out.push(action( + &format!("Operand: {}", p.named(*branch)), + Message::Select(*branch), + )); + out = sequence_rows(p, *branch, out); + } + } + if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) { + for (i, id) in steps.iter().enumerate() { + out = + out.push( + row![ + action( + &format!("{}. {}", i + 1, p.named(*id)), + Message::Select(*id) + ) + .width(Fill), + button("↑") + .padding(8) + .on_press_maybe((i > 0).then_some(Message::Command( + Command::MoveStep(owner, i, i.saturating_sub(1)) + ))), + button("↓").padding(8).on_press_maybe( + (i + 1 < steps.len()).then_some(Message::Command(Command::MoveStep( + owner, + i, + i + 1 + ))) + ) + ] + .spacing(6), + ); + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::ScenarioUse { + scenario: Some(target), + .. + }, + .. + }) = p.elements[id].shared() + { + out = out.push(action( + &format!("Open {} →", p.named(*target)), + Message::Select(*target), + )); + } + out = sequence_rows(p, *id, out); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + fn scenario(p: &mut Project, name: &str) -> Id { + let mut e = p + .make_element(Kind::Scenario, Layer::Oa, name, None) + .unwrap(); + let id = e.id; + if let Some(SharedBody::Scenario { viewpoint, .. }) = e.shared_mut() { + *viewpoint = ScenarioViewpoint::OperationalActivity; + } + *p = execute_batch(p, &[Command::CreateElement(e)]).unwrap(); + id + } + fn lifeline(p: &mut Project, owner: Id, represented: Id) -> Id { + let mut state = State::default(); + state + .update(p, Event::Start(owner, Tool::Lifeline)) + .unwrap(); + state + .update(p, Event::Target(named_choice(p, represented))) + .unwrap(); + let command = state.update(p, Event::Submit).unwrap().unwrap(); + *p = execute_batch(p, &[command]).unwrap(); + p.children(owner).find(|e|matches!(e.shared(),Some(SharedBody::Lifeline {represented:Some(id)}) if *id==represented)).unwrap().id + } + fn fixture() -> (Project, Id, Id, Id, Id) { + let mut p = Project::demo(); + let relation = p + .relations + .values() + .find(|r| r.kind() == RelationKind::Interaction) + .unwrap(); + let (exchange, a, b) = (relation.id, relation.source(), relation.target()); + let owner = scenario(&mut p, "Scenario authoring test"); + let sender = lifeline(&mut p, owner, a); + let receiver = lifeline(&mut p, owner, b); + (p, owner, exchange, sender, receiver) + } + fn message(p: &Project, owner: Id, exchange: Id, sender: Id, receiver: Id) -> State { + let mut state = State::default(); + state.update(p, Event::Start(owner, Tool::Message)).unwrap(); + state + .update(p, Event::Exchange(named_choice(p, exchange))) + .unwrap(); + state + .update(p, Event::Sender(named_choice(p, sender))) + .unwrap(); + state + .update(p, Event::Receiver(named_choice(p, receiver))) + .unwrap(); + state + } + #[test] + fn graphical_call_reply_is_atomic_correlated_and_source_equivalent() { + let (p, owner, exchange, sender, receiver) = fixture(); + let mut state = message(&p, owner, exchange, sender, receiver); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + let mut session = Session::new(p.clone()); + session.execute(command).unwrap(); + let result = session.project(); + let steps = result.elements[&owner].steps().unwrap(); + assert_eq!(steps.len(), 2); + assert!( + matches!(result.elements[&steps[1]].shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {message_kind:MessageKind::Reply,reply_to:Some(call),sender:Some(from),receiver:Some(to),..},..}) if *call==steps[0]&&*from==receiver&&*to==sender) + ); + assert_eq!(language::parse(&language::format(result)).unwrap(), *result); + let scene = Scene::sequence(result, owner, owner); + assert!( + scene + .lines + .iter() + .any(|l| l.id == steps[1] && l.dashed && l.label.contains("reply")) + ); + assert!( + scene + .boxes + .iter() + .any(|b| b.id == steps[0] && b.rect.width == 8.) + ); + let mut reply = State::default(); + reply + .update(result, Event::Start(owner, Tool::Message)) + .unwrap(); + reply + .update(result, Event::Style(MessageStyle::Reply)) + .unwrap(); + assert!(calls(result, reply.draft.as_ref().unwrap()).is_empty()); + session.undo(); + assert_eq!(session.project(), &p); + } + #[test] + fn scenario_reuse_requires_explicit_compatible_bindings() { + let (mut p, owner, _, sender, receiver) = fixture(); + let a = represented(&p, sender).unwrap(); + let b = represented(&p, receiver).unwrap(); + let target = scenario(&mut p, "Reusable scenario"); + let remote_a = lifeline(&mut p, target, a); + let remote_b = lifeline(&mut p, target, b); + let mut state = State::default(); + state.update(&p, Event::Start(owner, Tool::Reuse)).unwrap(); + state + .update(&p, Event::Target(named_choice(&p, target))) + .unwrap(); + assert!(state.update(&p, Event::Submit).is_err()); + assert!(state.draft.is_some()); + state + .update(&p, Event::Binding(remote_a, named_choice(&p, receiver))) + .unwrap(); + state + .update(&p, Event::Binding(remote_b, named_choice(&p, sender))) + .unwrap(); + assert!(state.update(&p, Event::Submit).is_err()); + assert!(state.draft.is_some()); + state + .update(&p, Event::Binding(remote_a, named_choice(&p, sender))) + .unwrap(); + state + .update(&p, Event::Binding(remote_b, named_choice(&p, receiver))) + .unwrap(); + let result = + execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + let use_id = result.elements[&owner].steps().unwrap()[0]; + assert!( + matches!(result.elements[&use_id].shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::ScenarioUse {scenario:Some(id),bindings},..}) if *id==target&&bindings.len()==2) + ); + assert_eq!(result.children(target).count(), 2); + assert_eq!(language::parse(&language::format(&result)).unwrap(), result); + } + #[test] + fn fragment_operands_accept_paired_messages_through_normal_destination_picker() { + let (mut p, owner, exchange, sender, receiver) = fixture(); + let mut state = State::default(); + state + .update(&p, Event::Start(owner, Tool::Fragment)) + .unwrap(); + state + .update(&p, Event::Name("Choose outcome".into())) + .unwrap(); + state + .update(&p, Event::Expression("accepted".into())) + .unwrap(); + p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + let fragment = p.elements[&owner].steps().unwrap()[0]; + let branches = p.children(fragment).map(|e| e.id).collect::>(); + assert_eq!(branches.len(), 2); + let mut state = message(&p, owner, exchange, sender, receiver); + state + .update(&p, Event::Parent(named_choice(&p, branches[0]))) + .unwrap(); + p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + assert_eq!(p.elements[&branches[0]].steps().unwrap().len(), 2); + assert_eq!( + Scene::sequence(&p, owner, owner) + .lines + .iter() + .filter(|l| !l.label.is_empty()) + .count(), + 2 + ); + } + #[test] + fn invalid_lifecycle_edit_keeps_draft_and_timer_has_no_global_exchange() { + let (mut p, owner, exchange, sender, receiver) = fixture(); + let mut state = message(&p, owner, exchange, sender, receiver); + p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + let mut state = message(&p, owner, exchange, sender, receiver); + state.draft.as_mut().unwrap().style = MessageStyle::Create; + assert!(state.update(&p, Event::Submit).is_err()); + assert!(state.draft.is_some()); + state.update(&p, Event::Style(MessageStyle::Timer)).unwrap(); + state + .update(&p, Event::Sender(named_choice(&p, sender))) + .unwrap(); + state.update(&p, Event::Expression("30 s".into())).unwrap(); + p = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + let timer = *p.elements[&owner].steps().unwrap().last().unwrap(); + assert!( + matches!(p.elements[&timer].shared(),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {exchange:None,receiver:None,message_kind:MessageKind::Timer {expression},..},..}) if expression=="30 s") + ); + assert!( + Scene::sequence(&p, owner, owner) + .lines + .iter() + .any(|l| l.id == timer && l.label.contains("30 s")) + ); + } + #[test] + fn tagged_scenario_commit_is_not_redirected_by_other_editor_events() { + let (p, owner, exchange, sender, receiver) = fixture(); + let mut state = crate::behavior::State::default(); + for e in [ + Event::Start(owner, Tool::Message), + Event::Exchange(named_choice(&p, exchange)), + Event::Sender(named_choice(&p, sender)), + Event::Receiver(named_choice(&p, receiver)), + ] { + state + .update(&p, crate::behavior::Event::Scenario(e)) + .unwrap(); + } + state + .update( + &p, + crate::behavior::Event::Topology(super::super::topology::Event::Cancel), + ) + .unwrap(); + let submit = crate::behavior::Event::Scenario(Event::Submit); + assert!(submit.is_submit()); + let command = state.update(&p, submit).unwrap().unwrap(); + let result = execute_batch(&p, &[command]).unwrap(); + assert_eq!(result.elements[&owner].steps().unwrap().len(), 2); + } + #[test] + fn lifeline_picker_uses_core_viewpoint_contract() { + let (p, owner, ..) = fixture(); + let mut state = State::default(); + state + .update(&p, Event::Start(owner, Tool::Lifeline)) + .unwrap(); + let choices = sources(&p, state.draft.as_ref().unwrap()); + assert!(!choices.is_empty()); + assert!( + choices + .iter() + .all(|c| p.elements[&c.id].kind() == Kind::Activity) + ); + } +} diff --git a/crates/oa-desktop/src/behavior/state_scene.rs b/crates/oa-desktop/src/behavior/state_scene.rs new file mode 100644 index 00000000..38875fc3 --- /dev/null +++ b/crates/oa-desktop/src/behavior/state_scene.rs @@ -0,0 +1,287 @@ +//! State geometry follows semantic containment; presentation never reparents records. +use super::*; + +fn internal_transitions(p: &Project, id: Id) -> impl Iterator { + p.relations.values().filter(move |r| { + matches!(&r.body, RelationBody::StateTransition { + source, transition_kind: TransitionKind::Internal, .. + } if *source == id) + }) +} + +fn content_width(p: &Project, owner: Id) -> f32 { + p.children(owner) + .map(|e| match e.kind() { + Kind::Region => content_width(p, e.id) + 120., + k if k.is_stable_state() => content_width(p, e.id) + 32., + _ => 300., + }) + .fold(300., f32::max) +} + +impl Scene { + pub(super) fn states(p: &Project, owner: Id, selected: Id) -> Self { + let mut scene = Self { + scale: 1., + boxes: vec![], + lines: vec![], + size: Size::new(400., 180.), + selected, + }; + if !p.children(owner).any(|e| e.kind() == Kind::Region) { + return scene; + } + let width = content_width(p, owner) + 40.; + scene.boxes.push(BoxItem { + id: owner, + rect: Rectangle::new(Point::new(24., 20.), Size::new(width, 100.)), + title: p.named(owner).into(), + detail: String::new(), + marker: Some(Kind::StateMachine), + }); + let mut y = 64.; + for region in p.children(owner).filter(|e| e.kind() == Kind::Region) { + y = scene.state_region(p, region.id, 44., y, width - 40.) + 14.; + } + scene.boxes[0].rect.height = y - 20.; + + // Entry/exit symbols are region-owned but attach to the containing boundary. + let rectangles: BTreeMap<_, _> = scene.boxes.iter().map(|b| (b.id, b.rect)).collect(); + for b in &mut scene.boxes { + if !matches!(b.marker, Some(Kind::EntryPoint | Kind::ExitPoint)) { + continue; + } + let boundary = p + .elements + .get(&b.id) + .and_then(|e| e.parent) + .and_then(|id| p.elements.get(&id)) + .and_then(|e| e.parent); + if let Some(rect) = boundary.and_then(|id| rectangles.get(&id)) { + // The symbol is centered 16 px into its 32 px hit target. + b.rect.x = if b.marker == Some(Kind::EntryPoint) { + rect.x - 16. + } else { + rect.x + rect.width - 16. + }; + } + } + let vertices: BTreeMap<_, _> = scene + .boxes + .iter() + .filter(|b| b.marker.is_some_and(|k| k.is_state())) + .map(|b| (b.id, (b.rect, b.marker.unwrap()))) + .collect(); + for (index, r) in p + .relations + .values() + .filter(|r| { + r.kind() == RelationKind::StateTransition + && vertices.contains_key(&r.source()) + && vertices.contains_key(&r.target()) + }) + .enumerate() + { + if matches!( + &r.body, + RelationBody::StateTransition { + transition_kind: TransitionKind::Internal, + .. + } + ) { + continue; + } + let (a, ak) = vertices[&r.source()]; + let (b, bk) = vertices[&r.target()]; + let ay = a.y + if ak.is_stable_state() { 25. } else { 28. }; + let by = if r.source() == r.target() { + ay + 28. + } else { + b.y + if bk.is_stable_state() { 25. } else { 28. } + }; + let ax = if ak.is_stable_state() { + a.x + a.width + } else { + a.x + 32. + }; + let bx = if bk.is_stable_state() { + b.x + b.width + } else { + b.x + 32. + }; + let lane = (ax.max(bx) + 28. + (index % 4) as f32 * 20.).max(360.); + let activation = states::transition_summary(p, r); + scene.lines.push(Line { + id: r.id, + points: vec![ + Point::new(ax, ay), + Point::new(lane, ay), + Point::new(lane, by), + Point::new(bx, by), + ], + label: format!("{}. {}", index + 1, short(&activation, 24)), + label_at: Point::new(lane + 5., (ay + by) / 2.), + dashed: matches!( + &r.body, + RelationBody::StateTransition { + transition_kind: TransitionKind::Local, + .. + } + ), + }); + scene.size.width = scene.size.width.max(lane + 220.); + } + scene.size.width = scene.size.width.max(width + 180.); + scene.size.height = (y + 24.).max(180.); + scene + } + + fn state_region(&mut self, p: &Project, id: Id, x: f32, top: f32, width: f32) -> f32 { + let index = self.boxes.len(); + self.boxes.push(BoxItem { + id, + rect: Rectangle::new(Point::new(x, top), Size::new(width, 90.)), + title: format!("REGION · {}", p.named(id)), + detail: String::new(), + marker: Some(Kind::Region), + }); + let mut y = top + 42.; + for state in p.children(id).filter(|e| e.kind().is_state()) { + let stable = state.kind().is_stable_state(); + let inner_width = if stable { width - 100. } else { 300. }; + let index = self.boxes.len(); + let detail = match state.shared() { + Some(SharedBody::State { + entry, + exit, + do_activity, + .. + }) => [("entry", entry), ("do", do_activity), ("exit", exit)] + .into_iter() + .filter(|(_, value)| !value.is_empty()) + .map(|(label, value)| format!("{label} / {value}")) + .collect::>() + .join(" · "), + _ => String::new(), + }; + self.boxes.push(BoxItem { + id: state.id, + rect: Rectangle::new( + Point::new(x + 18., y), + Size::new(inner_width, if stable { 76. } else { 56. }), + ), + title: state.name.clone(), + detail, + marker: Some(state.kind()), + }); + let start = y; + y += if stable { 76. } else { 56. }; + for r in internal_transitions(p, state.id) { + self.boxes.push(BoxItem { + id: r.id, + rect: Rectangle::new(Point::new(x + 28., y), Size::new(inner_width - 20., 55.)), + title: format!("INTERNAL · {}", r.name), + detail: states::transition_summary(p, r), + marker: None, + }); + y += 61.; + } + for region in p.children(state.id).filter(|e| e.kind() == Kind::Region) { + y = self.state_region(p, region.id, x + 34., y, inner_width - 32.) + 12.; + } + self.boxes[index].rect.height = y - start; + y += 18.; + } + self.boxes[index].rect.height = (y - top).max(90.); + top + self.boxes[index].rect.height + } + + pub(super) fn is_container(&self, item: &BoxItem) -> bool { + matches!( + item.marker, + Some(Kind::Region | Kind::StateMachine | Kind::Occurrence) + ) || (item.marker.is_some_and(|kind| kind.is_stable_state()) + && self.boxes.iter().any(|other| { + other.id != item.id + && item.rect.contains(other.rect.position()) + && item.rect.contains(Point::new( + other.rect.x + other.rect.width, + other.rect.y + other.rect.height, + )) + })) + } +} + +/// Pseudostates use actual vector shapes, with a full row as their hit target. +pub(super) fn draw_symbol(f: &mut Frame, b: &BoxItem, color: Color) { + let center = Point::new(b.rect.x + 16., b.rect.y + 28.); + let stroke = Stroke::default().with_color(color).with_width(1.8); + let circle = Path::circle(center, 11.); + match b.marker.unwrap() { + Kind::Initial | Kind::Junction => f.fill( + &Path::circle( + center, + if b.marker == Some(Kind::Initial) { + 8. + } else { + 5. + }, + ), + color, + ), + Kind::Final => { + f.stroke(&circle, stroke); + f.fill(&Path::circle(center, 7.), color); + } + Kind::Choice => f.stroke( + &Path::new(|p| { + p.move_to(Point::new(center.x, center.y - 12.)); + p.line_to(Point::new(center.x + 12., center.y)); + p.line_to(Point::new(center.x, center.y + 12.)); + p.line_to(Point::new(center.x - 12., center.y)); + p.close(); + }), + stroke, + ), + Kind::Fork | Kind::Join => f.fill_rectangle( + Point::new(center.x - 3., center.y - 18.), + Size::new(6., 36.), + color, + ), + Kind::ShallowHistory | Kind::DeepHistory => { + f.stroke(&circle, stroke); + f.fill_text(Text { + content: if b.marker == Some(Kind::DeepHistory) { + "H*" + } else { + "H" + } + .into(), + position: Point::new(center.x - 8., center.y - 9.), + color, + size: 13.into(), + ..Default::default() + }); + } + Kind::EntryPoint => { + f.fill(&circle, Color::WHITE); + f.stroke(&circle, stroke); + } + Kind::ExitPoint | Kind::Terminate => { + if b.marker == Some(Kind::ExitPoint) { + f.fill(&circle, Color::WHITE); + f.stroke(&circle, stroke); + } + f.stroke( + &Path::new(|p| { + p.move_to(Point::new(center.x - 7., center.y - 7.)); + p.line_to(Point::new(center.x + 7., center.y + 7.)); + p.move_to(Point::new(center.x - 7., center.y + 7.)); + p.line_to(Point::new(center.x + 7., center.y - 7.)); + }), + stroke, + ); + } + _ => {} + } +} diff --git a/crates/oa-desktop/src/behavior/states.rs b/crates/oa-desktop/src/behavior/states.rs new file mode 100644 index 00000000..4a459425 --- /dev/null +++ b/crates/oa-desktop/src/behavior/states.rs @@ -0,0 +1,895 @@ +//! State authoring through the same validated commands as source and REST. +use super::*; +use iced::widget::{Column, checkbox, column, text_input}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tool { + Vertex, + Region, + Transition, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TransitionChoice(pub TransitionKind); +impl std::fmt::Display for TransitionChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self.0 { + TransitionKind::External => "External", + TransitionKind::Local => "Local", + TransitionKind::Internal => "Internal", + }) + } +} +#[derive(Debug, Clone)] +pub enum Event { + Start(Id, Tool, Id), + Edit(Id), + Cancel, + Submit, + Name(String), + Parent(Choice), + Vertex(Kind), + Source(Choice), + Target(Choice), + Kind(TransitionChoice), + Trigger(Choice), + Effect(Choice), + RemoveTrigger(Id), + RemoveEffect(Id), + TriggerNotes(String), + Guard(String), + EffectNotes(String), + Timed(bool), + TimeKind(String), + Expression(String), + Viewport(Size), + Zoom(f32), + Fit, +} +#[derive(Default)] +pub struct State { + draft: Option, + viewport: Option, + zoom: Option, +} +struct Draft { + machine: Id, + tool: Tool, + original: Option, + name: String, + parent: Option, + vertex: Kind, + source: Option, + target: Option, + kind: TransitionKind, + triggers: Vec, + effects: Vec, + trigger: String, + guard: String, + effect: String, + timed: bool, + time_kind: TimeEventKind, + expression: String, +} +impl Draft { + fn new(machine: Id, tool: Tool) -> Self { + Self { + machine, + tool, + original: None, + name: String::new(), + parent: None, + vertex: Kind::State, + source: None, + target: None, + kind: TransitionKind::External, + triggers: vec![], + effects: vec![], + trigger: String::new(), + guard: String::new(), + effect: String::new(), + timed: false, + time_kind: TimeEventKind::After, + expression: String::new(), + } + } + fn command(&self, p: &Project) -> Result { + let layer = p + .elements + .get(&self.machine) + .filter(|e| e.kind() == Kind::StateMachine) + .ok_or("Choose a state machine.")? + .layer(); + if self.tool != Tool::Transition { + let kind = if self.tool == Tool::Region { + Kind::Region + } else { + self.vertex + }; + let parent = self + .parent + .ok_or("Choose the owning region or composite state.")?; + if parent != self.machine && !in_machine(p, parent, self.machine) { + return Err("Choose an owner in this machine.".into()); + } + let name = if self.name.trim().is_empty() { + kind.label() + } else { + &self.name + }; + let element = p + .make_element(kind, layer, name, Some(parent)) + .ok_or("This owner cannot contain the selected kind.")?; + return Ok(Command::CreateElement(element)); + } + let source = self.source.ok_or("Choose the source state.")?; + let target = self.target.ok_or("Choose the destination state.")?; + if !in_machine(p, source, self.machine) || !in_machine(p, target, self.machine) { + return Err("Both endpoints belong to this machine.".into()); + } + let mut commands = vec![]; + let mut triggers = self.triggers.clone(); + if self.timed { + if self.expression.trim().is_empty() { + return Err("Enter a time expression, such as 30 s.".into()); + } + let mut event = p + .make_element( + Kind::Event, + layer, + &format!("{:?} {}", self.time_kind, self.expression), + None, + ) + .ok_or("Cannot create event.")?; + *event.shared_mut().unwrap() = SharedBody::Event { + body: EventBody::Time { + kind: self.time_kind, + expression: self.expression.clone(), + }, + }; + triggers.push(event.id); + commands.push(Command::CreateElement(event)); + } + let name = if self.name.trim().is_empty() { + format!("{} → {}", p.named(source), p.named(target)) + } else { + self.name.clone() + }; + let mut relation = if let Some(id) = self.original { + p.relations + .get(&id) + .cloned() + .ok_or("The transition no longer exists.")? + } else { + let id = Id::new_v4(); + Relation { + id, + alias: p.fresh_alias(&name, "transition", id), + name: name.clone(), + description: String::new(), + body: RelationBody::new(RelationKind::StateTransition, source, target), + } + }; + relation.name = name; + relation.body = RelationBody::StateTransition { + source, + target, + transition_kind: self.kind, + trigger: self.trigger.clone(), + guard: self.guard.clone(), + effect: self.effect.clone(), + triggers, + effects: self.effects.clone(), + }; + commands.push(if let Some(id) = self.original { + Command::EditRelation(id, relation) + } else { + Command::CreateRelation(relation) + }); + Ok(Command::Batch(commands)) + } +} +impl State { + pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + match event { + Event::Start(machine, tool, selected) => { + let mut d = Draft::new(machine, tool); + let selected = p + .elements + .get(&selected) + .filter(|e| in_machine(p, e.id, machine)); + d.parent = if tool == Tool::Region { + selected + .filter(|e| e.kind().is_stable_state()) + .map(|e| e.id) + .or(Some(machine)) + } else { + selected + .and_then(|e| validation::ancestor_of_kind(p, e.id, Kind::Region)) + .or_else(|| { + p.children(machine) + .find(|e| e.kind() == Kind::Region) + .map(|e| e.id) + }) + }; + d.source = selected.filter(|e| e.kind().is_state()).map(|e| e.id); + if d.source + .is_some_and(|id| p.elements[&id].kind() == Kind::EntryPoint) + { + d.kind = TransitionKind::Local; + } + self.draft = Some(d); + } + Event::Edit(id) => { + let r = p.relations.get(&id).ok_or("Select a transition.")?; + let machine = validation::ancestor_of_kind(p, r.source(), Kind::StateMachine) + .ok_or("Choose a state-machine transition.")?; + let RelationBody::StateTransition { + source, + target, + transition_kind, + triggers, + effects, + trigger, + guard, + effect, + } = &r.body + else { + return Err("Select a state transition.".into()); + }; + let mut d = Draft::new(machine, Tool::Transition); + d.original = Some(id); + d.name = r.name.clone(); + d.source = Some(*source); + d.target = Some(*target); + d.kind = *transition_kind; + d.triggers = triggers.clone(); + d.effects = effects.clone(); + d.trigger = trigger.clone(); + d.guard = guard.clone(); + d.effect = effect.clone(); + self.draft = Some(d); + } + Event::Cancel => self.draft = None, + Event::Submit => { + let command = self + .draft + .as_ref() + .ok_or("Choose a state modeling tool.")? + .command(p)?; + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + self.draft = None; + return Ok(Some(command)); + } + Event::Viewport(size) => self.viewport = Some(size), + Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)), + Event::Fit => self.zoom = None, + event => { + if let Some(d) = &mut self.draft { + match event { + Event::Name(s) => d.name = s, + Event::Parent(c) => d.parent = Some(c.id), + Event::Vertex(k) => d.vertex = k, + Event::Source(c) => { + d.source = Some(c.id); + d.target = None; + d.kind = if p.elements[&c.id].kind() == Kind::EntryPoint { + TransitionKind::Local + } else { + TransitionKind::External + }; + } + Event::Target(c) => d.target = Some(c.id), + Event::Kind(k) => { + d.kind = k.0; + if k.0 == TransitionKind::Internal { + d.target = d.source; + } + } + Event::Trigger(c) => { + if !d.triggers.contains(&c.id) { + d.triggers.push(c.id); + } + } + Event::Effect(c) => { + if !d.effects.contains(&c.id) { + d.effects.push(c.id); + } + } + Event::RemoveTrigger(id) => d.triggers.retain(|v| *v != id), + Event::RemoveEffect(id) => d.effects.retain(|v| *v != id), + Event::TriggerNotes(s) => d.trigger = s, + Event::Guard(s) => d.guard = s, + Event::EffectNotes(s) => d.effect = s, + Event::Timed(b) => d.timed = b, + Event::Expression(s) => d.expression = s, + Event::TimeKind(s) => { + d.time_kind = if s == "At" { + TimeEventKind::At + } else { + TimeEventKind::After + } + } + _ => {} + } + } + } + } + Ok(None) + } +} +fn event(e: Event) -> Message { + Message::Behavior(super::Event::States(e)) +} +fn choice(p: &Project, id: Id) -> Choice { + Choice { + id, + label: format!( + "{} · {}", + p.named(id), + p.elements + .get(&id) + .map(|e| e.kind().to_string()) + .unwrap_or_default() + ), + } +} +fn picker<'a>( + choices: Vec, + selected: Option, + placeholder: &str, + change: fn(Choice) -> Event, +) -> Element<'a, Message> { + let current = choices.iter().find(|c| Some(c.id) == selected).cloned(); + pick_list(choices, current, move |c| event(change(c))) + .placeholder(placeholder) + .padding(9) + .text_size(12) + .width(Fill) + .into() +} +pub(super) fn transition_summary(p: &Project, r: &Relation) -> String { + let RelationBody::StateTransition { + transition_kind, + triggers, + trigger, + guard, + effects, + effect, + .. + } = &r.body + else { + return r.name.clone(); + }; + let activation = if !triggers.is_empty() { + triggers + .iter() + .map(|id| p.named(*id)) + .collect::>() + .join(" OR ") + } else if !trigger.trim().is_empty() { + format!("Unresolved trigger: {trigger}") + } else if p + .elements + .get(&r.source()) + .is_some_and(|e| e.kind().is_stable_state()) + { + "Completion".into() + } else { + "Transient routing".into() + }; + let mut result = format!("{transition_kind:?} · {activation}"); + if !triggers.is_empty() && !trigger.is_empty() { + result.push_str(&format!(" · {trigger}")); + } + if !guard.is_empty() { + result.push_str(&format!(" [{guard}]")); + } + if !effects.is_empty() { + result.push_str(&format!( + " / {}", + effects + .iter() + .map(|id| p.named(*id)) + .collect::>() + .join(", ") + )); + } + if !effect.is_empty() { + result.push_str(&format!(" / {effect}")); + } + result +} +pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { + let p = app.project(); + let state = &app.behavior.states; + out = out.push( + row![ + action( + "+ Vertex", + event(Event::Start(machine, Tool::Vertex, app.selected)) + ), + action( + "+ Region", + event(Event::Start(machine, Tool::Region, app.selected)) + ), + action( + "+ Transition", + event(Event::Start(machine, Tool::Transition, app.selected)) + ), + action("Properties", Message::EditProperties(machine)), + ] + .spacing(5), + ); + if let Some(d) = state.draft.as_ref().filter(|d| d.machine == machine) { + let mut form = column![ + text(if d.original.is_some() { + "Edit transition" + } else { + match d.tool { + Tool::Vertex => "Add state or pseudostate", + Tool::Region => "Add region", + Tool::Transition => "Connect states", + } + }) + .size(17), + text_input("Engineering name (optional)", &d.name) + .on_input(|s| event(Event::Name(s))) + .padding(9) + .size(12), + ] + .spacing(9); + if d.tool != Tool::Transition { + if d.tool == Tool::Vertex { + form = form.push( + pick_list( + Kind::ALL + .into_iter() + .filter(|k| k.is_state()) + .collect::>(), + Some(d.vertex), + |k| event(Event::Vertex(k)), + ) + .padding(9) + .text_size(12) + .width(Fill), + ); + } + let parents = p + .elements + .values() + .filter(|e| { + in_machine(p, e.id, machine) + && if d.tool == Tool::Vertex { + e.kind() == Kind::Region + } else { + e.kind() == Kind::StateMachine || e.kind().is_stable_state() + } + }) + .map(|e| choice(p, e.id)) + .collect(); + form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent)); + form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." })); + } else { + let mut context = p.clone(); + if let Some(id) = d.original { + context.relations.remove(&id); + } + let vertices: Vec<_> = p + .elements + .values() + .filter(|e| e.kind().is_state() && in_machine(p, e.id, machine)) + .map(|e| choice(p, e.id)) + .collect(); + let targets = vertices + .iter() + .filter(|c| { + d.source.is_some_and(|source| { + if d.kind == TransitionKind::Internal { + return c.id == source && p.elements[&source].kind().is_stable_state(); + } + if d.kind == TransitionKind::Local + && p.elements[&source].kind().is_stable_state() + { + return c.id != source && in_machine(p, c.id, source); + } + validation::can_connect( + &context, + RelationKind::StateTransition, + source, + c.id, + ) + }) + }) + .cloned() + .collect(); + form = form + .push(picker(vertices, d.source, "From state…", Event::Source)) + .push(picker(targets, d.target, "To state…", Event::Target)) + .push( + pick_list( + [ + TransitionChoice(TransitionKind::External), + TransitionChoice(TransitionKind::Local), + TransitionChoice(TransitionKind::Internal), + ], + Some(TransitionChoice(d.kind)), + |k| event(Event::Kind(k)), + ) + .padding(9) + .text_size(12) + .width(Fill), + ); + let layer = p.elements[&machine].layer(); + let triggers = p + .elements + .values() + .filter(|e| { + e.layer() == layer + && (e.kind() == Kind::Event || e.kind().is_work()) + && !d.triggers.contains(&e.id) + }) + .map(|e| choice(p, e.id)) + .collect(); + form = form.push(label("Trigger events / work (alternatives)")); + for id in &d.triggers { + form = form.push( + row![ + text(p.named(*id)).size(12).width(Fill), + action("Remove", event(Event::RemoveTrigger(*id))) + ] + .spacing(5), + ); + } + form = form + .push(picker( + triggers, + None, + "Add existing trigger…", + Event::Trigger, + )) + .push( + checkbox(d.timed) + .label("Add a time event with this transition") + .on_toggle(|b| event(Event::Timed(b))), + ); + if d.timed { + form = form.push( + row![ + pick_list( + ["After".to_string(), "At".to_string()], + Some(format!("{:?}", d.time_kind)), + |s| event(Event::TimeKind(s)) + ) + .padding(9) + .text_size(12), + text_input("Time expression, e.g. 30 s", &d.expression) + .on_input(|s| event(Event::Expression(s))) + .padding(9) + .size(12), + ] + .spacing(6), + ); + } + form = form + .push( + text_input("Trigger notes (optional)", &d.trigger) + .on_input(|s| event(Event::TriggerNotes(s))) + .padding(9) + .size(12), + ) + .push( + text_input("Guard condition (optional)", &d.guard) + .on_input(|s| event(Event::Guard(s))) + .padding(9) + .size(12), + ); + let effects = p + .elements + .values() + .filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id)) + .map(|e| choice(p, e.id)) + .collect(); + for id in &d.effects { + form = form.push( + row![ + text(p.named(*id)).size(12).width(Fill), + action("Remove", event(Event::RemoveEffect(*id))) + ] + .spacing(5), + ); + } + form = form + .push(picker(effects, None, "Add effect work…", Event::Effect)) + .push( + text_input("Effect notes (optional)", &d.effect) + .on_input(|s| event(Event::EffectNotes(s))) + .padding(9) + .size(12), + ); + if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() { + form=form.push(label("No event trigger: completion from a state, transient routing from a pseudostate.")); + } + } + let candidate = d.command(p).and_then(|command| { + execute_batch(p, &[command]) + .map(|_| ()) + .map_err(|e| e.to_string()) + }); + if let Err(error) = &candidate { + form = form.push(text(error.clone()).size(12).color(style::MUTED)); + } + form = form.push( + row![ + button("Apply") + .padding(9) + .on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))), + action("Cancel", event(Event::Cancel)) + ] + .spacing(7), + ); + out = out.push(container(form).padding(14).style(style::card)); + } + let mut scene = Scene::states(p, machine, app.selected); + if scene.boxes.is_empty() { + out=out.push(container(column![text("Start with a region").size(18),label("Add a region, then an Initial vertex and a State or Mode. Connect them to describe the default entry.")].spacing(12)).padding(24).style(style::card)); + } else { + let width = state + .viewport + .map_or((app.window_size.width - 600.).max(200.), |s| s.width); + let scale = state.zoom.unwrap_or_else(|| { + ((width - 24.).max(1.) / scene.size.width) + .min(440. / scene.size.height) + .clamp(0.05, 1.) + }); + scene.scale = scale; + let size = Size::new(scene.size.width * scale, scene.size.height * scale); + out = out + .push( + row![ + action("Fit", event(Event::Fit)), + action("−", event(Event::Zoom(scale / 1.25))), + label(format!("{:.0}%", scale * 100.)), + action("+", event(Event::Zoom(scale * 1.25))), + action("100%", event(Event::Zoom(1.))) + ] + .spacing(8) + .align_y(iced::Center), + ) + .push( + iced::widget::sensor( + container( + scrollable( + canvas::Canvas::new(scene) + .width(size.width) + .height(size.height), + ) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .height(size.height.clamp(180., 460.)), + ) + .padding(8) + .width(Fill) + .style(style::card), + ) + .on_show(|s| event(Event::Viewport(s))) + .on_resize(|s| event(Event::Viewport(s))), + ); + } + for r in p + .relations + .values() + .filter(|r| r.kind() == RelationKind::StateTransition && in_machine(p, r.source(), machine)) + { + out = out.push( + container( + column![ + action( + &format!("{} → {}", p.named(r.source()), p.named(r.target())), + event(Event::Edit(r.id)) + ) + .width(Fill), + text(transition_summary(p, r)).size(12), + action("Properties", Message::EditProperties(r.id)) + ] + .spacing(5), + ) + .padding(12) + .style(style::card), + ); + } + scrollable(out.padding(24)).into() +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (Project, Id, Id, Id, Id) { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let machine = p + .add_element( + Kind::StateMachine, + Layer::Sa, + "Operating modes", + Some(owner), + ) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Sa, "Operation", Some(machine)) + .unwrap(); + let ready = p + .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) + .unwrap(); + let degraded = p + .add_element(Kind::State, Layer::Sa, "Degraded", Some(region)) + .unwrap(); + (p, machine, region, ready, degraded) + } + #[test] + fn timeout_and_work_trigger_commit_together_roundtrip_and_undo() { + let (mut p, machine, _, ready, degraded) = fixture(); + let work = p + .add_element(Kind::Function, Layer::Sa, "Monitor link", None) + .unwrap(); + let mut state = State::default(); + for e in [ + Event::Start(machine, Tool::Transition, ready), + Event::Target(choice(&p, degraded)), + Event::Trigger(choice(&p, work)), + Event::Timed(true), + Event::Expression("30 s".into()), + Event::Guard("link unavailable".into()), + Event::Effect(choice(&p, work)), + ] { + state.update(&p, e).unwrap(); + } + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + let mut session = Session::new(p.clone()); + session.execute(command).unwrap(); + let result = session.project(); + let r = result + .relations + .values() + .find(|r| r.kind() == RelationKind::StateTransition) + .unwrap(); + let RelationBody::StateTransition { + triggers, + effects, + guard, + .. + } = &r.body + else { + panic!() + }; + assert_eq!(triggers.len(), 2); + assert!(triggers.contains(&work)); + assert_eq!(effects, &[work]); + assert_eq!(guard, "link unavailable"); + assert!(triggers.iter().any(|id| matches!(result.elements[id].shared(), Some(SharedBody::Event { body: EventBody::Time { kind:TimeEventKind::After, expression }}) if expression == "30 s"))); + assert_eq!(&language::parse(&language::format(result)).unwrap(), result); + assert!(transition_summary(result, r).contains(" OR ")); + session.undo(); + assert_eq!(session.project(), &p); + } + #[test] + fn invalid_pseudo_trigger_keeps_draft_and_does_not_create_orphan_event() { + let (mut p, machine, region, ready, _) = fixture(); + let initial = p + .add_element(Kind::Initial, Layer::Sa, "Start", Some(region)) + .unwrap(); + let mut state = State::default(); + for e in [ + Event::Start(machine, Tool::Transition, initial), + Event::Target(choice(&p, ready)), + Event::Timed(true), + Event::Expression("30 s".into()), + ] { + state.update(&p, e).unwrap(); + } + assert!(state.update(&p, Event::Submit).is_err()); + assert!(state.draft.is_some()); + assert!(!p.elements.values().any(|e| e.kind() == Kind::Event)); + state.update(&p, Event::Timed(false)).unwrap(); + let result = + execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + assert_eq!(result.relations.len(), 1); + } + #[test] + fn internal_choice_targets_self_and_edit_preserves_identity_and_description() { + let (p, machine, _, ready, _) = fixture(); + let mut state = State::default(); + for e in [ + Event::Start(machine, Tool::Transition, ready), + Event::Kind(TransitionChoice(TransitionKind::Internal)), + ] { + state.update(&p, e).unwrap(); + } + let mut result = + execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + let r = result.relations.values_mut().next().unwrap(); + r.description = "Retain configuration".into(); + let (id, alias) = (r.id, r.alias.clone()); + state.update(&result, Event::Edit(id)).unwrap(); + state + .update(&result, Event::Guard("healthy".into())) + .unwrap(); + let result = execute_batch( + &result, + &[state.update(&result, Event::Submit).unwrap().unwrap()], + ) + .unwrap(); + assert_eq!(result.relations[&id].alias, alias); + assert_eq!(result.relations[&id].description, "Retain configuration"); + assert_eq!( + result.relations[&id].source(), + result.relations[&id].target() + ); + let scene = Scene::states(&result, machine, id); + assert!( + scene + .boxes + .iter() + .any(|b| b.id == id && b.title.starts_with("INTERNAL")) + ); + assert!(!scene.lines.iter().any(|l| l.id == id)); + } + #[test] + fn selected_composite_owns_new_region_and_history_uses_selected_region() { + let (p, machine, _, ready, _) = fixture(); + let mut state = State::default(); + state + .update(&p, Event::Start(machine, Tool::Region, ready)) + .unwrap(); + let result = + execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); + let region = result + .children(ready) + .find(|e| e.kind() == Kind::Region) + .unwrap() + .id; + state + .update(&result, Event::Start(machine, Tool::Vertex, region)) + .unwrap(); + state + .update(&result, Event::Vertex(Kind::DeepHistory)) + .unwrap(); + let result = execute_batch( + &result, + &[state.update(&result, Event::Submit).unwrap().unwrap()], + ) + .unwrap(); + assert!( + result + .children(region) + .any(|e| e.kind() == Kind::DeepHistory) + ); + let mut scene = Scene::states(&result, machine, ready); + let container = scene.boxes.iter().find(|b| b.id == ready).unwrap().rect; + let child = scene.boxes.iter().find(|b| b.id == region).unwrap().rect; + assert!(container.contains(child.position())); + assert!(container.contains(Point::new(child.x + child.width, child.y + child.height))); + let history = scene + .boxes + .iter() + .find(|b| b.marker == Some(Kind::DeepHistory)) + .unwrap(); + let id = history.id; + let point = Point::new((history.rect.x + 16.) * 0.5, (history.rect.y + 28.) * 0.5); + scene.scale = 0.5; + let action = canvas::Program::update( + &scene, + &mut (), + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Rectangle::new(Point::ORIGIN, scene.size), + mouse::Cursor::Available(point), + ) + .unwrap() + .into_inner() + .0; + assert!(matches!(action, Some(Message::Select(selected)) if selected == id)); + } +} diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs new file mode 100644 index 00000000..409660a1 --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -0,0 +1,1855 @@ +//! Process/chain authoring and projection use contextual occurrences and explicit +//! SequenceLinks. Presentation order never invents control-flow relationships. +use super::*; +use iced::widget::{Column, column, text_input}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tool { + Work, + Exchange, + Reuse, + Control, + Connect, + Branch, +} +impl Tool { + fn title(self) -> &'static str { + match self { + Self::Work => "Involve work", + Self::Exchange => "Involve exchange", + Self::Reuse => "Reuse process / chain", + Self::Control => "Add control", + Self::Connect => "Connect contributions", + Self::Branch => "Insert two paths", + } + } +} +#[derive(Debug, Clone)] +pub enum Event { + Start(Id, Tool, Option), + Cancel, + Submit, + Name(String), + Target(Choice), + Alternate(Choice), + Before(Choice), + After(Choice), + ClearBefore, + ClearAfter, + Guard(String), + AlternateGuard(String), + Control(ControlChoice), + Viewport(Size), + Zoom(f32), + Fit, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ControlChoice(pub ControlKind); +impl std::fmt::Display for ControlChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self.0 { + ControlKind::Choice => "OR · alternative paths", + ControlKind::Merge => "OR · merge alternatives", + ControlKind::Fork => "AND · parallel paths", + ControlKind::Join => "AND · join parallel paths", + ControlKind::Loop => "IT · repeat / exit", + }) + } +} +#[derive(Default)] +pub struct State { + draft: Option, + viewport: Option, + zoom: Option, +} +struct Draft { + owner: Id, + tool: Tool, + name: String, + target: Option, + alternate: Option, + before: Option, + after: Option, + guard: String, + alternate_guard: String, + control: ControlKind, +} +impl State { + pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + match event { + Event::Viewport(size) => self.viewport = Some(size), + Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)), + Event::Fit => self.zoom = None, + Event::Start(owner, tool, selected) => { + let e = p + .elements + .get(&owner) + .ok_or("This process or chain no longer exists.")?; + if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { + return Err("Choose an operational process or system functional chain.".into()); + } + let before = selected.filter(|id| is_contribution(p, owner, *id)); + let after = before.and_then(|id| { + let outgoing: Vec<_> = sequence_links(p, owner) + .into_iter() + .filter(|r| r.source() == id) + .map(Relation::target) + .collect(); + if outgoing.len() == 1 { + outgoing.first().copied() + } else { + legacy_order(p, owner) + .windows(2) + .find(|pair| pair[0] == id) + .map(|pair| pair[1]) + } + }); + self.draft = Some(Draft { + owner, + tool, + name: String::new(), + target: None, + alternate: None, + before, + after, + guard: String::new(), + alternate_guard: "else".into(), + control: ControlKind::Choice, + }); + } + Event::Cancel => self.draft = None, + Event::Submit => { + let d = self + .draft + .as_ref() + .ok_or("Choose a contribution tool first.")?; + let command = d.command(p)?; + // Validate before clearing the user's inputs. The ordinary command + // handler remains responsible for the single undoable transaction. + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + self.draft = None; + return Ok(Some(command)); + } + other => { + if let Some(d) = &mut self.draft { + match other { + Event::Name(value) => d.name = value, + Event::Target(value) => d.target = Some(value.id), + Event::Alternate(value) => d.alternate = Some(value.id), + Event::Before(value) => d.before = Some(value.id), + Event::After(value) => d.after = Some(value.id), + Event::ClearBefore => d.before = None, + Event::ClearAfter => d.after = None, + Event::Guard(value) => d.guard = value, + Event::AlternateGuard(value) => d.alternate_guard = value, + Event::Control(value) => d.control = value.0, + _ => {} + } + } + } + } + Ok(None) + } + + fn scale(&self, graph: Size, fallback_width: f32) -> f32 { + self.zoom.unwrap_or_else(|| { + 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.)) + .clamp(0.05, 1.) + }) + } +} +fn is_contribution(p: &Project, owner: Id, id: Id) -> bool { + p.elements.get(&id).is_some_and(|e| { + e.parent == Some(owner) + && matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { .. } + | OccurrenceBody::Exchange { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + }) +} +fn reference_reaches(p: &Project, from: Id, to: Id) -> bool { + let mut pending = vec![from]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if id == to { + return true; + } + if seen.insert(id) { + pending.extend(p.children(id).filter_map(|e| match e.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence }, + .. + }) => *sequence, + _ => None, + })); + } + } + false +} +fn targets(p: &Project, owner: Id, tool: Tool) -> Vec { + let Some(e) = p.elements.get(&owner) else { + return vec![]; + }; + let mut choices: Vec<_> = if tool == Tool::Exchange { + p.relations + .values() + .filter(|r| { + p.layer_of(r.id) == Some(e.layer()) + && r.kind() + == if e.layer() == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + } + }) + .map(|r| Choice { + id: r.id, + label: format!( + "{} · {} → {}", + r.name, + p.named(r.source()), + p.named(r.target()) + ), + }) + .collect() + } else { + p.elements + .values() + .filter(|candidate| { + candidate.layer() == e.layer() + && match tool { + Tool::Work | Tool::Branch => candidate.kind().is_work(), + Tool::Reuse => { + candidate.kind() == e.kind() + && !reference_reaches(p, candidate.id, owner) + } + _ => false, + } + }) + .map(|e| Choice { + id: e.id, + label: format!("{} · {}", e.name, e.alias), + }) + .collect() + }; + choices.sort_by(|a, b| a.label.cmp(&b.label)); + choices +} +fn contributions(p: &Project, owner: Id) -> Vec { + p.elements + .get(&owner) + .and_then(|e| e.steps()) + .into_iter() + .flatten() + .filter(|id| is_contribution(p, owner, **id)) + .enumerate() + .map(|(i, id)| Choice { + id: *id, + label: format!("{}. {}", i + 1, p.named(*id)), + }) + .collect() +} +fn legacy_order(p: &Project, owner: Id) -> Vec { + if uses_explicit_topology(p, owner) { + return vec![]; + } + let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) else { + return vec![]; + }; + if steps.iter().all(|id| { + matches!( + p.elements.get(id).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) + ) + }) { + steps.clone() + } else { + vec![] + } +} +fn preserved_order(p: &Project, owner: Id, replace: Option<(Id, Id)>) -> Vec { + legacy_order(p, owner) + .windows(2) + .filter(|pair| replace != Some((pair[0], pair[1]))) + .map(|pair| link(p, pair[0], pair[1], "")) + .collect() +} +fn contribution_work(p: &Project, id: Id) -> Option { + match p.elements.get(&id)?.shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::Action { action, .. }, + .. + } => *action, + _ => None, + } +} +fn contribution_exchange(p: &Project, id: Id) -> Option<&Relation> { + match p.elements.get(&id)?.shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + } => exchange.and_then(|id| p.relations.get(&id)), + _ => None, + } +} +fn compatible_source(p: &Project, draft: &Draft, id: Id) -> bool { + match draft.tool { + Tool::Work => { + draft + .before + .and_then(|id| contribution_exchange(p, id)) + .is_none_or(|r| r.target() == id) + && draft + .after + .and_then(|id| contribution_exchange(p, id)) + .is_none_or(|r| r.source() == id) + } + Tool::Exchange => { + let Some(exchange) = p.relations.get(&id) else { + return false; + }; + let preceding = draft.before.and_then(|id| { + contribution_work(p, id) + .or_else(|| contribution_exchange(p, id).map(Relation::target)) + }); + let following = draft.after.and_then(|id| { + contribution_work(p, id) + .or_else(|| contribution_exchange(p, id).map(Relation::source)) + }); + preceding.is_none_or(|id| exchange.source() == id) + && following.is_none_or(|id| exchange.target() == id) + } + _ => true, + } +} +pub(super) fn occurrence( + p: &Project, + owner: Id, + name: &str, + body: OccurrenceBody, +) -> oa_core::Element { + let mut e = p + .make_element( + Kind::Occurrence, + p.elements[&owner].layer(), + name, + Some(owner), + ) + .expect("Occurrence layer"); + if let Some(SharedBody::Occurrence { body: value, .. }) = e.shared_mut() { + *value = body; + } + e +} +fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command { + let id = Id::new_v4(); + Command::CreateRelation(Relation { + id, + alias: p.fresh_alias("sequence_link", "relation", id), + name: "Sequence link".into(), + description: String::new(), + body: RelationBody::SequenceLink { + source, + target, + guard: guard.to_owned(), + }, + }) +} +impl Draft { + fn command(&self, p: &Project) -> Result { + let owner = p + .elements + .get(&self.owner) + .ok_or("The process or chain no longer exists.")?; + if !matches!(owner.kind(), Kind::Process | Kind::FunctionalChain) { + return Err("Choose a process or chain.".into()); + } + 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()); + } + } + if self.before.is_some() && self.before == self.after { + return Err("Choose different preceding and following contributions.".into()); + } + let mut commands = preserved_order( + p, + self.owner, + if self.tool == Tool::Connect { + None + } else { + self.before.zip(self.after) + }, + ); + if self.tool == Tool::Connect { + let source = self.before.ok_or("Choose the preceding contribution.")?; + let target = self.after.ok_or("Choose the following contribution.")?; + if sequence_links(p, self.owner) + .iter() + .any(|r| r.source() == source && r.target() == target) + { + return Err("These contributions already have a sequence link; select it to edit its guard.".into()); + } + if legacy_order(p, self.owner) + .windows(2) + .any(|pair| pair == [source, target]) + { + return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into()); + } + require_guard(p, source, &self.guard)?; + commands.push(link(p, source, target, &self.guard)); + return Ok(unique_batch(p, commands)); + } + let target = if matches!(self.tool, Tool::Control) { + None + } else { + let id = self + .target + .ok_or("Choose the existing work, exchange or process to involve.")?; + if !targets(p, self.owner, self.tool).iter().any(|c| c.id == id) + || !compatible_source(p, self, id) + { + return Err( + "The selected source is incompatible or would introduce recursive reuse." + .into(), + ); + } + Some(id) + }; + let default_name = target.map(|id| p.named(id)).unwrap_or(match self.control { + ControlKind::Choice => "Choose path", + ControlKind::Merge => "Merge paths", + ControlKind::Fork => "Start parallel paths", + ControlKind::Join => "Join paths", + ControlKind::Loop => "Repeat or exit", + }); + let name = if self.name.trim().is_empty() { + default_name + } else { + self.name.trim() + }; + let (first, last) = if self.tool == Tool::Branch { + let alternate = self.alternate.ok_or("Choose work for the second path.")?; + if !targets(p, self.owner, Tool::Work) + .iter() + .any(|c| c.id == alternate) + { + return Err("Choose same-layer work for the second path.".into()); + } + let parallel = self.control == ControlKind::Fork; + if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty()) + { + return Err( + "Describe both alternative guards; use 'else' for the fallback path.".into(), + ); + } + let split = occurrence( + p, + self.owner, + if parallel { + "Start parallel paths" + } else { + "Choose path" + }, + OccurrenceBody::Control { + kind: if parallel { + ControlKind::Fork + } else { + ControlKind::Choice + }, + }, + ); + let merge = occurrence( + p, + self.owner, + if parallel { + "Join paths" + } else { + "Merge alternatives" + }, + OccurrenceBody::Control { + kind: if parallel { + ControlKind::Join + } else { + ControlKind::Merge + }, + }, + ); + let a = occurrence( + p, + self.owner, + name, + OccurrenceBody::Action { + action: target, + lifeline: None, + }, + ); + let b = occurrence( + p, + self.owner, + p.named(alternate), + OccurrenceBody::Action { + action: Some(alternate), + lifeline: None, + }, + ); + let ids = (split.id, a.id, b.id, merge.id); + commands.extend([split, a, b, merge].into_iter().map(Command::CreateElement)); + commands.extend([ + link(p, ids.0, ids.1, if parallel { "" } else { &self.guard }), + link( + p, + ids.0, + ids.2, + if parallel { "" } else { &self.alternate_guard }, + ), + link(p, ids.1, ids.3, ""), + link(p, ids.2, ids.3, ""), + ]); + (ids.0, ids.3) + } else { + let body = match self.tool { + Tool::Work => OccurrenceBody::Action { + action: target, + lifeline: None, + }, + Tool::Exchange => OccurrenceBody::Exchange { + exchange: target, + sender: None, + receiver: None, + message_kind: MessageKind::default(), + reply_to: None, + values: vec![], + }, + Tool::Reuse => OccurrenceBody::SequenceReference { sequence: target }, + Tool::Control => OccurrenceBody::Control { kind: self.control }, + _ => unreachable!(), + }; + let e = occurrence(p, self.owner, name, body); + let id = e.id; + commands.push(Command::CreateElement(e)); + (id, id) + }; + if let Some(before) = self.before { + // Branch guards belong to the split's two outgoing paths. A branch + // inserted after another choice must first use an explicit link. + let guard = if self.tool == Tool::Branch { + "" + } else { + &self.guard + }; + require_guard(p, before, guard)?; + commands.push(link(p, before, first, guard)); + } + if let Some(after) = self.after { + commands.push(link(p, last, after, "")); + } + // Inserting between two selected contributions replaces their direct + // precedence while preserving other links and their semantic identities. + if let (Some(before), Some(after)) = (self.before, self.after) { + commands.extend( + sequence_links(p, self.owner) + .into_iter() + .filter(|r| r.source() == before && r.target() == after) + .map(|r| Command::Delete(r.id)), + ); + } + Ok(unique_batch(p, commands)) + } +} +pub(super) fn unique_batch(p: &Project, mut commands: Vec) -> Command { + let mut aliases: BTreeSet = p + .identities() + .into_iter() + .map(|(_, alias)| alias.to_owned()) + .collect(); + for command in &mut commands { + let (id, alias) = match command { + Command::CreateElement(e) => (e.id, &mut e.alias), + Command::CreateRelation(r) => (r.id, &mut r.alias), + _ => continue, + }; + if !aliases.insert(alias.clone()) { + *alias = format!("{}_{}", alias, id.simple()); + aliases.insert(alias.clone()); + } + } + Command::Batch(commands) +} +fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> { + if guard.trim().is_empty() + && matches!( + p.elements.get(&source).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Control { + kind: ControlKind::Choice | ControlKind::Loop + }, + .. + }) + ) + { + Err("Describe this choice / repeat condition, or enter 'else'.".into()) + } else { + Ok(()) + } +} +fn event(e: Event) -> Message { + Message::Behavior(if matches!(e, Event::Submit) { + super::Event::Submit + } else { + super::Event::Topology(e) + }) +} +fn picker<'a>( + choices: Vec, + value: Option, + placeholder: &'a str, + change: fn(Choice) -> Event, +) -> Element<'a, Message> { + let selected = value + .and_then(|id| choices.iter().find(|c| c.id == id)) + .cloned(); + pick_list(choices, selected, move |v| event(change(v))) + .placeholder(placeholder) + .padding(9) + .text_size(12) + .width(Fill) + .into() +} +pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { + let p = app.project(); + let selected = is_contribution(p, owner, app.selected).then_some(app.selected); + if !p.elements[&owner].description.is_empty() { + out = out.push( + text(&p.elements[&owner].description) + .size(12) + .color(style::MUTED), + ); + } + out = out + .push( + row![ + action("+ Work", event(Event::Start(owner, Tool::Work, selected))), + action( + "+ Exchange", + event(Event::Start(owner, Tool::Exchange, selected)) + ), + action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))), + ] + .spacing(7), + ) + .push( + row![ + action( + "+ Control", + event(Event::Start(owner, Tool::Control, selected)) + ), + action( + "Branch / parallel…", + event(Event::Start(owner, Tool::Branch, selected)) + ), + action( + "Connect…", + event(Event::Start(owner, Tool::Connect, selected)) + ), + action("Properties", Message::EditProperties(owner)), + ] + .spacing(7), + ); + if let Some(d) = app + .behavior + .topology + .draft + .as_ref() + .filter(|d| d.owner == owner) + { + let mut form = column![text(d.tool.title()).size(16)].spacing(9); + let legacy = legacy_order(p, owner); + if legacy.len() > 1 { + form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len()))); + } + if matches!( + d.tool, + Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch + ) { + let choices: Vec<_> = targets(p, owner, d.tool) + .into_iter() + .filter(|choice| compatible_source(p, d, choice.id)) + .collect(); + if choices.is_empty() { + form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first.")); + } + form = form.push(picker( + choices, + d.target, + "Choose an existing source…", + Event::Target, + )); + } + if d.tool != Tool::Connect { + form = form.push( + text_input("Contribution name (defaults to source name)", &d.name) + .on_input(|s| event(Event::Name(s))) + .padding(9) + .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(9) + .text_size(12) + .width(Fill), + ); + } + if d.tool == Tool::Branch { + form = form.push(picker( + targets(p, owner, Tool::Work), + d.alternate, + "Work on the second path…", + Event::Alternate, + )); + if d.control != ControlKind::Fork { + form = form.push( + row![ + text_input("First path condition", &d.guard) + .on_input(|s| event(Event::Guard(s))) + .padding(9) + .size(12), + text_input("Second path condition", &d.alternate_guard) + .on_input(|s| event(Event::AlternateGuard(s))) + .padding(9) + .size(12), + ] + .spacing(7), + ); + } + form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation.")); + } + form = form + .push( + row![ + picker( + contributions(p, owner), + d.before, + if d.tool == Tool::Connect { + "From contribution…" + } else { + "After contribution (optional)…" + }, + Event::Before + ), + action("Clear", event(Event::ClearBefore)), + ] + .spacing(7), + ) + .push( + row![ + picker( + contributions(p, owner), + d.after, + if d.tool == Tool::Connect { + "To contribution…" + } else { + "Before contribution (optional)…" + }, + Event::After + ), + action("Clear", event(Event::ClearAfter)), + ] + .spacing(7), + ); + if d.tool != Tool::Branch { + form = form.push( + text_input("Sequence guard (for a choice or loop)", &d.guard) + .on_input(|s| event(Event::Guard(s))) + .padding(9) + .size(12), + ); + } + if let Err(problem) = d.command(p) { + form = form.push(label(problem)); + } + form = form.push( + row![ + button("Create") + .padding(9) + .on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))), + action("Cancel", event(Event::Cancel)) + ] + .spacing(7), + ); + out = out.push(container(form).padding(14).style(style::card)); + } + let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner) + || p.elements[&owner].steps().is_none_or(Vec::is_empty) + { + let mut graph = Graph::new(p, owner, app.selected); + if graph.nodes.is_empty() { + out = out.push(container(column![ + text("Start this process").size(18), + label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."), + ].spacing(12)).padding(24).width(Fill).style(style::card)); + return scrollable(out.padding(24)).into(); + } else if graph.edges.is_empty() { + out = out.push(label( + "No explicit sequence links. Connect contributions to describe topology.", + )); + } + graph.scale = app + .behavior + .topology + .scale(graph.size, (app.window_size.width - 600.).max(200.)); + let scale = graph.scale; + let size = Size::new(graph.size.width * scale, graph.size.height * scale); + out = out.push( + row![ + action("Fit", event(Event::Fit)), + action("−", event(Event::Zoom(scale / 1.25))), + label(format!("{:.0}%", scale * 100.)), + action("+", event(Event::Zoom(scale * 1.25))), + action("100%", event(Event::Zoom(1.))), + ] + .spacing(8) + .align_y(iced::Center), + ); + ( + canvas::Canvas::new(graph) + .width(size.width) + .height(size.height) + .into(), + size, + ) + } else { + let scene = Scene::sequence(p, owner, app.selected); + let size = scene.size; + let conversion = preserved_order(p, owner, None); + out = out.push(label( + "Ordered exchange path · the stored contribution order defines this sequence.", + )); + if !conversion.is_empty() { + out = out.push(action( + "Convert ordered path to explicit links", + Message::Command(unique_batch(p, conversion)), + )); + } + ( + canvas::Canvas::new(scene) + .width(size.width) + .height(size.height) + .into(), + size, + ) + }; + out = out.push( + iced::widget::sensor( + container( + scrollable(canvas) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .height(size.height.clamp(200., 470.)), + ) + .padding(8) + .width(Fill) + .style(style::card), + ) + .on_show(|size| event(Event::Viewport(size))) + .on_resize(|size| event(Event::Viewport(size))), + ); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { + sequence: Some(target), + }, + .. + }) = p.elements.get(&app.selected).and_then(|e| e.shared()) + { + out = out.push(action( + &format!("Open source: {} →", p.named(*target)), + Message::Select(*target), + )); + } + for r in sequence_links(p, owner) { + let guard = match &r.body { + RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"), + _ => String::new(), + }; + out = out.push( + action( + &format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard), + Message::Select(r.id), + ) + .width(Fill), + ); + } + scrollable(out.padding(24)).into() +} + +fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> { + p.relations + .values() + .filter(|r| { + r.kind() == RelationKind::SequenceLink + && is_contribution(p, owner, r.source()) + && is_contribution(p, owner, r.target()) + }) + .collect() +} +struct GraphNode { + id: Id, + rect: Rectangle, + title: String, + detail: String, + control: Option, + source: Option, + preview: Vec<(Id, String)>, + preview_links: Vec<(Id, Id)>, +} +struct GraphEdge { + id: Id, + source: Id, + target: Id, + guard: String, + points: Vec, + label: Rectangle, +} +struct Graph { + nodes: Vec, + edges: Vec, + size: Size, + selected: Id, + scale: f32, +} +impl Graph { + fn new(p: &Project, owner: Id, selected: Id) -> Self { + let choices = contributions(p, owner); + let links = sequence_links(p, owner); + let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect(); + let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect(); + let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect(); + for r in &links { + *incoming.entry(r.target()).or_default() += 1; + } + let mut queue: Vec<_> = choices + .iter() + .filter(|c| incoming[&c.id] == 0) + .map(|c| c.id) + .collect(); + let mut done = BTreeSet::new(); + let mut offset = 0; + while offset < queue.len() { + let id = queue[offset]; + offset += 1; + if !done.insert(id) { + continue; + } + for r in links.iter().filter(|r| r.source() == id) { + let next = r.target(); + ranks.insert(next, ranks[&next].max(ranks[&id] + 1)); + let count = incoming.get_mut(&next).expect("Graph endpoint"); + *count = count.saturating_sub(1); + if *count == 0 { + queue.push(next); + } + } + } + // Cycles remain explicit edges; deterministic fallback placement must + // terminate even while users are building a loop or incomplete graph. + let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1; + for c in &choices { + if !done.contains(&c.id) { + ranks.insert(c.id, fallback); + fallback += 1; + } + } + let mut rows: BTreeMap = BTreeMap::new(); + let mut nodes = vec![]; + let mut size: Size = Size::new(640., 220.); + for c in choices { + let e = &p.elements[&c.id]; + let rank = ranks[&c.id]; + let slot = rows.entry(rank).or_default(); + let mut node = GraphNode { + id: e.id, + rect: Rectangle::new( + Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.), + Size::new(270., 104.), + ), + title: e.name.clone(), + detail: String::new(), + control: None, + source: None, + preview: vec![], + preview_links: vec![], + }; + *slot += 1; + if let Some(SharedBody::Occurrence { body, .. }) = e.shared() { + match body { + OccurrenceBody::Action { action, .. } => { + node.detail = action + .map(|id| format!("Work · {}", p.named(id))) + .unwrap_or("Choose work in Properties".into()) + } + OccurrenceBody::Exchange { exchange, .. } => { + node.detail = exchange + .and_then(|id| p.relations.get(&id)) + .map(|r| { + format!( + "Exchange · {} → {}", + p.named(r.source()), + p.named(r.target()) + ) + }) + .unwrap_or("Choose exchange in Properties".into()) + } + OccurrenceBody::Control { kind } => { + node.control = Some(*kind); + node.detail = ControlChoice(*kind).to_string(); + } + OccurrenceBody::SequenceReference { sequence } => { + node.source = *sequence; + node.rect.height = 218.; + node.detail = sequence + .map(|id| format!("ref · {}", p.named(id))) + .unwrap_or("Choose source in Properties".into()); + if let Some(source) = sequence { + node.preview = contributions(p, *source) + .into_iter() + .take(4) + .map(|c| (c.id, p.named(c.id).to_owned())) + .collect(); + let preview_ids: BTreeSet<_> = + node.preview.iter().map(|(id, _)| *id).collect(); + node.preview_links = sequence_links(p, *source) + .into_iter() + .filter(|r| { + preview_ids.contains(&r.source()) + && preview_ids.contains(&r.target()) + }) + .map(|r| (r.source(), r.target())) + .collect(); + } + } + _ => {} + } + } + size.width = size.width.max(node.rect.x + node.rect.width + 28.); + size.height = size.height.max(node.rect.y + node.rect.height + 42.); + nodes.push(node); + } + let mut edges = vec![]; + for (i, r) in links.into_iter().enumerate() { + let a = nodes + .iter() + .find(|n| n.id == r.source()) + .expect("Graph source") + .rect; + let b = nodes + .iter() + .find(|n| n.id == r.target()) + .expect("Graph target") + .rect; + let start = Point::new(a.x + a.width, a.center_y()); + let end = Point::new(b.x, b.center_y()); + let points = if end.x > start.x { + let mid = (start.x + end.x) / 2.; + vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end] + } else { + let below = size.height + 24. + i as f32 * 30.; + vec![ + start, + Point::new(start.x + 18., start.y), + Point::new(start.x + 18., below), + Point::new(end.x - 16., below), + Point::new(end.x - 16., end.y), + end, + ] + }; + let guard = match &r.body { + RelationBody::SequenceLink { guard, .. } => guard.clone(), + _ => String::new(), + }; + let label_at = if end.x > start.x { + Point::new(end.x - 138., end.y - 43.) + } else { + Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.) + }; + let label = Rectangle::new(label_at, Size::new(132., 36.)); + edges.push(GraphEdge { + id: r.id, + source: r.source(), + target: r.target(), + guard, + points, + label, + }); + } + for e in &edges { + for pt in &e.points { + size.height = size.height.max(pt.y + 30.); + } + } + Self { + nodes, + edges, + size, + selected, + scale: 1., + } + } +} +fn wrapped(value: &str, width: usize, lines: usize) -> String { + let mut output = Vec::new(); + let mut current = String::new(); + let words: Vec = value + .split_whitespace() + .flat_map(|word| { + word.chars() + .collect::>() + .chunks(width.max(1)) + .map(|chunk| chunk.iter().collect::()) + .collect::>() + }) + .collect(); + for word in &words { + if !current.is_empty() && current.chars().count() + word.chars().count() + 1 > width { + output.push(current); + current = String::new(); + } + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } + if !current.is_empty() { + output.push(current); + } + let more = output.len() > lines; + output.truncate(lines); + if more && let Some(last) = output.last_mut() { + last.push('…'); + } + output.join("\n") +} +fn draw_text(f: &mut Frame, content: String, position: Point, size: f32, color: Color) { + f.fill_text(Text { + content, + position, + size: size.into(), + color, + ..Default::default() + }); +} +fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) { + if points.len() < 2 { + return; + } + f.stroke( + &Path::new(|p| { + p.move_to(points[0]); + for pt in &points[1..] { + p.line_to(*pt); + } + }), + Stroke::default().with_color(color).with_width(width), + ); + let end = points[points.len() - 1]; + let delta = end - points[points.len() - 2]; + let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.); + let d = delta / length; + f.stroke( + &Path::new(|p| { + p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.); + p.line_to(end); + p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.); + }), + Stroke::default().with_color(color).with_width(width), + ); +} +impl canvas::Program for Graph { + type State = (); + fn update( + &self, + _: &mut (), + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + if matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) && let Some(point) = cursor.position_in(bounds) + { + let point = Point::new(point.x / self.scale, point.y / self.scale); + if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) { + let id = if point.y > n.rect.y + 75. { + n.source.unwrap_or(n.id) + } else { + n.id + }; + return Some(Action::publish(Message::Select(id)).and_capture()); + } + if let Some(e) = self.edges.iter().find(|e| { + (!e.guard.is_empty() && e.label.contains(point)) + || e.points + .windows(2) + .any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.) + }) { + return Some(Action::publish(Message::Select(e.id)).and_capture()); + } + } + None + } + fn draw( + &self, + _: &(), + renderer: &Renderer, + _: &Theme, + bounds: Rectangle, + _: mouse::Cursor, + ) -> Vec { + 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" + } 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, + "Open source →".into(), + 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., + ), + 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 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, + ); + } + } + vec![f.into_geometry()] + } + fn mouse_interaction( + &self, + _: &(), + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if cursor.position_in(bounds).is_some_and(|p| { + let p = Point::new(p.x / self.scale, p.y / self.scale); + self.nodes.iter().any(|n| n.rect.contains(p)) + || self.edges.iter().any(|e| { + e.label.contains(p) + || e.points + .windows(2) + .any(|v| crate::diagram::distance_to_segment(p, v[0], v[1]) < 8.) + }) + }) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (Project, Id, Id, Id, Id, Id) { + let mut p = Project::demo(); + let owner = p + .make_element(Kind::Process, Layer::Oa, "Branch test", None) + .unwrap(); + let owner_id = owner.id; + p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap(); + let a = p + .make_element(Kind::Activity, Layer::Oa, "Shared display name", None) + .unwrap(); + let a_id = a.id; + p = execute_batch(&p, &[Command::CreateElement(a)]).unwrap(); + let b = p + .make_element(Kind::Activity, Layer::Oa, "Shared display name", None) + .unwrap(); + let b_id = b.id; + p = execute_batch(&p, &[Command::CreateElement(b)]).unwrap(); + let before = occurrence( + &p, + owner_id, + "Start work", + OccurrenceBody::Action { + action: Some(a_id), + lifeline: None, + }, + ); + let after = occurrence( + &p, + owner_id, + "Finish work", + OccurrenceBody::Action { + action: Some(b_id), + lifeline: None, + }, + ); + let before_id = before.id; + let after_id = after.id; + p = execute_batch( + &p, + &[ + Command::CreateElement(before), + Command::CreateElement(after), + ], + ) + .unwrap(); + (p, owner_id, a_id, b_id, before_id, after_id) + } + fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft { + Draft { + owner, + tool: Tool::Branch, + name: String::new(), + target: Some(a), + alternate: Some(b), + before: Some(before), + after: Some(after), + guard: "request accepted".into(), + alternate_guard: "else".into(), + control: ControlKind::Choice, + } + } + fn legacy_fixture() -> (Project, Id) { + let mut p = Project::demo(); + let owner = p + .make_element(Kind::Process, Layer::Oa, "Existing ordered path", None) + .unwrap(); + let owner_id = owner.id; + p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap(); + let mut work = vec![]; + for index in 0..4 { + let e = p + .make_element( + Kind::Activity, + Layer::Oa, + &format!("Ordered work {index}"), + None, + ) + .unwrap(); + work.push(e.id); + p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap(); + } + let mut exchanges = vec![]; + for (index, pair) in work.windows(2).enumerate() { + let id = Id::new_v4(); + let name = format!("Ordered exchange {index}"); + let r = Relation { + id, + alias: p.fresh_alias(&name, "relation", id), + name, + description: String::new(), + body: RelationBody::new(RelationKind::Interaction, pair[0], pair[1]), + }; + p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap(); + exchanges.push(id); + } + let commands = (0..3) + .map(|index| { + Command::CreateElement(occurrence( + &p, + owner_id, + &format!("Ordered contribution {index}"), + OccurrenceBody::Exchange { + exchange: Some(exchanges[index % exchanges.len()]), + sender: None, + receiver: None, + message_kind: MessageKind::default(), + reply_to: None, + values: vec![], + }, + )) + }) + .collect(); + p = execute_batch(&p, &[unique_batch(&p, commands)]).unwrap(); + (p, owner_id) + } + #[test] + fn empty_process_can_start_through_normal_authoring_events() { + let mut p = Project::demo(); + let owner = p + .make_element(Kind::Process, Layer::Oa, "New operational process", None) + .unwrap(); + let owner_id = owner.id; + p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap(); + let source = targets(&p, owner_id, Tool::Work).first().unwrap().clone(); + let mut state = State::default(); + state + .update(&p, Event::Start(owner_id, Tool::Work, None)) + .unwrap(); + state.update(&p, Event::Target(source.clone())).unwrap(); + state + .update(&p, Event::Name("First contribution".into())) + .unwrap(); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + let result = execute_batch(&p, &[command]).unwrap(); + let steps = result.elements[&owner_id].steps().unwrap(); + assert_eq!(steps.len(), 1); + assert!(uses_explicit_topology(&result, owner_id)); + assert!( + matches!(result.elements[&steps[0]].shared(), Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { action: Some(id), .. }, .. }) if *id == source.id) + ); + assert_eq!(Graph::new(&result, owner_id, owner_id).nodes.len(), 1); + } + #[test] + fn legacy_edit_preserves_order_and_new_work_in_one_undo() { + let (p, owner) = legacy_fixture(); + let original_order = legacy_order(&p, owner); + let source = targets(&p, owner, Tool::Work).first().unwrap().clone(); + let mut state = State::default(); + state + .update(&p, Event::Start(owner, Tool::Work, None)) + .unwrap(); + state.update(&p, Event::Target(source)).unwrap(); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + let mut session = Session::new(p.clone()); + session.execute(command).unwrap(); + let links = sequence_links(session.project(), owner); + for pair in original_order.windows(2) { + assert!( + links + .iter() + .any(|r| r.source() == pair[0] && r.target() == pair[1]) + ); + } + assert_eq!( + session.project().elements[&owner].steps().unwrap().len(), + original_order.len() + 1 + ); + assert!(uses_explicit_topology(session.project(), owner)); + session.undo(); + assert_eq!(session.project(), &p); + } + #[test] + fn legacy_branch_insertion_replaces_only_the_selected_implied_link() { + let (p, owner) = legacy_fixture(); + let order = legacy_order(&p, owner); + let sources = targets(&p, owner, Tool::Work); + let mut state = State::default(); + state + .update(&p, Event::Start(owner, Tool::Branch, Some(order[0]))) + .unwrap(); + assert_eq!(state.draft.as_ref().unwrap().after, Some(order[1])); + state.update(&p, Event::Target(sources[0].clone())).unwrap(); + state + .update(&p, Event::Alternate(sources[1].clone())) + .unwrap(); + state.update(&p, Event::Guard("ready".into())).unwrap(); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + let result = execute_batch(&p, &[command]).unwrap(); + let links = sequence_links(&result, owner); + assert!( + !links + .iter() + .any(|r| r.source() == order[0] && r.target() == order[1]) + ); + for pair in order.windows(2).skip(1) { + assert!( + links + .iter() + .any(|r| r.source() == pair[0] && r.target() == pair[1]) + ); + } + assert_eq!(links.len(), order.len() - 2 + 6); + assert_eq!( + result.elements[&owner].steps().unwrap().len(), + order.len() + 4 + ); + } + #[test] + fn insertion_picker_only_offers_work_matching_adjacent_exchanges() { + let (p, owner) = legacy_fixture(); + let order = legacy_order(&p, owner); + let expected = contribution_exchange(&p, order[0]).unwrap().target(); + let mut state = State::default(); + state + .update(&p, Event::Start(owner, Tool::Work, Some(order[0]))) + .unwrap(); + let d = state.draft.as_ref().unwrap(); + let choices: Vec<_> = targets(&p, owner, Tool::Work) + .into_iter() + .filter(|choice| compatible_source(&p, d, choice.id)) + .collect(); + assert_eq!(choices.len(), 1); + assert_eq!(choices[0].id, expected); + state.update(&p, Event::Target(choices[0].clone())).unwrap(); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + assert!(execute_batch(&p, &[command]).is_ok()); + } + #[test] + fn guarded_branch_is_one_transaction_with_distinct_source_identities() { + let (mut p, owner, a, b, before, after) = fixture(); + p = execute_batch(&p, &[link(&p, before, after, "")]).unwrap(); + let old_link = sequence_links(&p, owner)[0].id; + let mut session = Session::new(p.clone()); + let command = branch(owner, a, b, before, after).command(&p).unwrap(); + session.execute(command).unwrap(); + let result = session.project(); + assert!(!result.relations.contains_key(&old_link)); + assert_eq!(result.elements[&owner].steps().unwrap().len(), 6); + assert_eq!(sequence_links(result, owner).len(), 6); + assert_eq!( + result + .elements + .values() + .filter(|e| e.kind() == Kind::Activity) + .count(), + p.elements + .values() + .filter(|e| e.kind() == Kind::Activity) + .count() + ); + let involved: Vec<_> = result + .children(owner) + .filter(|e| !p.elements.contains_key(&e.id)) + .filter_map(|e| match e.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { action, .. }, + .. + }) => *action, + _ => None, + }) + .collect(); + assert!(involved.contains(&a) && involved.contains(&b)); + let graph = Graph::new(result, owner, owner); + assert_eq!(graph.edges.len(), 6); + assert!(graph.edges.iter().any(|e| e.guard == "request accepted")); + assert!(graph.edges.iter().any(|e| e.guard == "else")); + for edge in graph.edges.iter().filter(|e| !e.guard.is_empty()) { + assert!( + !graph + .nodes + .iter() + .any(|node| node.rect.contains(edge.label.center())), + "Guard must not cover a contribution" + ); + } + let issues = validation::validate(result); + assert!(!issues.iter().any(|i| { + matches!( + i.code.as_str(), + "control_cardinality" | "ambiguous_sequence_branch" | "missing_sequence_guard" + ) && i.element.is_some_and(|id| { + id == owner + || result + .elements + .get(&id) + .is_some_and(|e| e.parent == Some(owner)) + }) + })); + session.undo(); + assert_eq!(session.project(), &p); + } + #[test] + fn unlinked_contributions_do_not_gain_fictional_edges() { + let (p, owner, ..) = fixture(); + let graph = Graph::new(&p, owner, owner); + assert_eq!(graph.nodes.len(), 2); + assert!(graph.edges.is_empty()); + } + #[test] + fn reusable_preview_preserves_identity_and_filters_recursive_sources() { + let (mut p, owner, a, _, before, after) = fixture(); + let source = p + .make_element(Kind::Process, Layer::Oa, "Reusable source", None) + .unwrap(); + let source_id = source.id; + p = execute_batch(&p, &[Command::CreateElement(source)]).unwrap(); + let inside = occurrence( + &p, + source_id, + "Existing contribution", + OccurrenceBody::Action { + action: Some(a), + lifeline: None, + }, + ); + let inside_id = inside.id; + p = execute_batch(&p, &[Command::CreateElement(inside)]).unwrap(); + let mut d = branch(owner, a, a, before, after); + d.tool = Tool::Reuse; + d.target = Some(source_id); + let result = execute_batch(&p, &[d.command(&p).unwrap()]).unwrap(); + let graph = Graph::new(&result, owner, owner); + let reference = graph + .nodes + .iter() + .find(|n| n.source == Some(source_id)) + .unwrap(); + assert_eq!(reference.preview[0].0, inside_id); + assert_eq!(result.children(source_id).count(), 1); + assert!( + !targets(&result, source_id, Tool::Reuse) + .iter() + .any(|c| c.id == owner) + ); + assert!( + !targets(&result, owner, Tool::Reuse) + .iter() + .any(|c| c.id == owner) + ); + } + #[test] + fn stale_or_invalid_submit_preserves_inputs_and_creates_nothing() { + let (p, owner, a, b, before, after) = fixture(); + let mut state = State { + draft: Some(branch(owner, a, b, before, after)), + ..State::default() + }; + state.update(&p, Event::Guard(String::new())).unwrap(); + assert!(state.update(&p, Event::Submit).is_err()); + assert!(state.draft.is_some()); + state.update(&p, Event::Guard("ready".into())).unwrap(); + let command = state.update(&p, Event::Submit).unwrap().unwrap(); + assert!(state.draft.is_none()); + assert!(matches!(command, Command::Batch(_))); + assert_eq!(p.elements[&owner].steps().unwrap().len(), 2); + } + #[test] + fn cycle_projection_terminates_and_retains_actual_links() { + let (mut p, owner, _, _, before, after) = fixture(); + for command in [ + link(&p, before, after, "continue"), + link(&p, after, before, "retry"), + ] { + if let Command::CreateRelation(mut r) = command { + r.alias = format!("cycle_{}", r.id.simple()); + p.relations.insert(r.id, r); + } + } + let graph = Graph::new(&p, owner, owner); + assert_eq!(graph.edges.len(), 2); + assert!(graph.size.width.is_finite() && graph.size.height.is_finite()); + assert!(graph.edges.iter().any(|e| e.guard == "retry")); + assert!( + graph + .edges + .iter() + .all(|e| e.label.x >= 0. && e.label.y >= 0.) + ); + } + + #[test] + fn long_engineering_identifiers_stay_inside_label_width() { + let label = wrapped( + "HandlePassengerServiceAvailabilityWhileAircraftIsDecompressing", + 19, + 2, + ); + assert!(label.ends_with('…')); + assert_eq!(label.lines().count(), 2); + assert!(label.lines().all(|line| line.chars().count() <= 20)); + } + + #[test] + fn fitting_a_branch_keeps_all_nodes_visible_and_selection_uses_scaled_coordinates() { + let (p, owner, a, b, before, after) = fixture(); + let p = execute_batch( + &p, + &[branch(owner, a, b, before, after).command(&p).unwrap()], + ) + .unwrap(); + let mut graph = Graph::new(&p, owner, owner); + let mut state = State::default(); + state + .update(&p, Event::Viewport(Size::new(486., 460.))) + .unwrap(); + graph.scale = state.scale(graph.size, 500.); + assert!(graph.size.width * graph.scale <= 462.1); + assert!(graph.size.height * graph.scale <= 450.1); + let node = graph.nodes.last().unwrap(); + let click = Point::new( + (node.rect.x + 20.) * graph.scale, + (node.rect.y + 20.) * graph.scale, + ); + let result = canvas::Program::update( + &graph, + &mut (), + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Rectangle::new(Point::ORIGIN, Size::new(486., 460.)), + mouse::Cursor::Available(click), + ) + .unwrap() + .into_inner() + .0; + assert!(matches!(result, Some(Message::Select(id)) if id == node.id)); + state.update(&p, Event::Zoom(1.)).unwrap(); + state + .update(&p, Event::Viewport(Size::new(800., 460.))) + .unwrap(); + assert_eq!(state.scale(graph.size, 500.), 1.); + state.update(&p, Event::Fit).unwrap(); + assert!(state.scale(graph.size, 500.) < 1.); + } +} diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs new file mode 100644 index 00000000..04130d54 --- /dev/null +++ b/crates/oa-desktop/src/diagram.rs @@ -0,0 +1,702 @@ +use crate::{app::Message, style, views::short}; +use iced::{ + Color, Point, Rectangle, Renderer, Size, Theme, Vector, mouse, + widget::{ + Action, + canvas::{self, Frame, Path, Stroke, Text}, + }, +}; +use oa_core::{ + projection::{ProjectedEdge, ProjectedNode, ProjectedView, project_view}, + *, +}; +use std::collections::BTreeSet; + +pub struct DiagramCanvas<'a> { + pub project: &'a Project, + pub diagram: Id, + pub selected: Id, + pub zoom: f32, + pub pan: Vector, +} +#[derive(Default)] +pub struct State { + drag: Option<(Id, Point, Position)>, + preview: Option, + pan_start: Option, +} +impl DiagramCanvas<'_> { + fn world(&self, p: Point) -> Point { + Point::new( + (p.x - self.pan.x) / self.zoom, + (p.y - self.pan.y) / self.zoom, + ) + } + fn hit(&self, view: &ProjectedView, p: Point) -> Option { + let p = self.world(p); + view.nodes + .iter() + .rev() + .find(|n| { + Rectangle::new( + Point::new(n.position.x, n.position.y), + Size::new(n.size.width, n.size.height), + ) + .contains(p) + && (!view + .nodes + .iter() + .any(|child| child.visual_parent == Some(n.id)) + || p.y < n.position.y + 62.) + }) + .map(|n| n.id) + } + fn hit_edge(&self, view: &ProjectedView, p: Point) -> Option { + let p = self.world(p); + view.edges.iter().rev().find_map(|edge| { + let (start, end) = edge_endpoints(view, edge)?; + let tolerance = 7. / self.zoom; + let mut previous = start; + for i in 1..=24 { + let next = curve_point(start, end, i as f32 / 24.); + if distance_to_segment(p, previous, next) <= tolerance { + return Some(edge.id); + } + previous = next; + } + None + }) + } + fn preview_view(&self, state: &State) -> ProjectedView { + let mut view = project_view(self.project, self.diagram); + if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) { + let dx = preview.x - original.x; + let dy = preview.y - original.y; + let mut moved = BTreeSet::from([id]); + for n in &view.nodes { + if n.visual_parent.is_some_and(|p| moved.contains(&p)) { + moved.insert(n.id); + } + } + for n in &mut view.nodes { + if moved.contains(&n.id) { + n.position.x += dx; + n.position.y += dy; + } + } + for port in &mut view.ports { + if moved.contains(&port.owner) { + port.position.x += dx; + port.position.y += dy; + } + } + } + view + } +} +impl canvas::Program for DiagramCanvas<'_> { + type State = State; + fn update( + &self, + state: &mut State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + let local = cursor + .position() + .map(|p| Point::new(p.x - bounds.x, p.y - bounds.y)); + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + if cursor.is_over(bounds) => + { + let p = local?; + let view = project_view(self.project, self.diagram); + let world = self.world(p); + if let Some(port) = view.ports.iter().rev().find(|port| { + let radius = (10. / self.zoom).max(10.); + (world.x - port.position.x).abs() <= radius + && (world.y - port.position.y).abs() <= radius + }) { + return Some(Action::publish(Message::Select(port.element)).and_capture()); + } + if let Some(id) = self.hit(&view, p) { + let position = self + .project + .diagrams + .get(&self.diagram)? + .nodes + .get(&id)? + .position; + state.drag = Some((id, p, position)); + state.preview = Some(position); + Some(Action::publish(Message::SelectNode(id)).and_capture()) + } else if let Some(id) = self.hit_edge(&view, p) { + Some(Action::publish(Message::Select(id)).and_capture()) + } else { + state.pan_start = Some(p); + Some(Action::capture()) + } + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + let p = local?; + if let Some((_, start, original)) = state.drag { + let delta = p - start; + state.preview = Some(if delta.x * delta.x + delta.y * delta.y > 9. { + Position { + x: original.x + delta.x / self.zoom, + y: original.y + delta.y / self.zoom, + } + } else { + original + }); + return Some(Action::request_redraw()); + } + if let Some(start) = state.pan_start { + state.pan_start = Some(p); + return Some(Action::publish(Message::Pan(p - start)).and_capture()); + } + None + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + state.pan_start = None; + if let Some((id, _, original)) = state.drag.take() { + let pos = state.preview.take()?; + if pos == original { + return Some(Action::capture()); + } + Some( + Action::publish(Message::MoveNode( + id, + Position { + x: (pos.x / 10.).round() * 10., + y: (pos.y / 10.).round() * 10., + }, + )) + .and_capture(), + ) + } else { + None + } + } + canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) + if cursor.is_over(bounds) => + { + let y = match delta { + mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { + *y + } + }; + if y == 0. { + None + } else { + Some( + Action::publish(Message::Zoom(if y > 0. { 1.1 } else { 1. / 1.1 })) + .and_capture(), + ) + } + } + _ => None, + } + } + fn draw( + &self, + state: &State, + renderer: &Renderer, + _: &Theme, + bounds: Rectangle, + _: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + f.fill_rectangle( + Point::ORIGIN, + bounds.size(), + Color::from_rgb8(246, 248, 251), + ); + let step = (24. * self.zoom).max(14.); + let ox = self.pan.x.rem_euclid(step); + let oy = self.pan.y.rem_euclid(step); + for x in 0..(bounds.width / step) as i32 + 1 { + for y in 0..(bounds.height / step) as i32 + 1 { + f.fill( + &Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65), + style::LINE, + ); + } + } + f.translate(self.pan); + f.scale(self.zoom); + let view = self.preview_view(state); + let containers = view + .nodes + .iter() + .filter_map(|n| n.visual_parent) + .collect::>(); + for n in &view.nodes { + if containers.contains(&n.id) { + paint_node(&mut f, self.project, n, self.selected, true); + } + } + let highlighted = highlighted_exchanges(self.project, self.selected); + for edge in &view.edges { + let Some((start, end)) = edge_endpoints(&view, edge) else { + continue; + }; + let active = edge.id == self.selected || highlighted.contains(&edge.id); + let color = if active { + style::TEAL + } else if edge.trace { + Color::from_rgb8(134, 107, 168) + } else { + Color::from_rgb8(144, 158, 173) + }; + let capability_label = + self.project + .relations + .get(&edge.id) + .and_then(|r| match r.kind() { + RelationKind::CapabilityInclude => Some("«include»"), + RelationKind::CapabilityExtend => Some("«extend»"), + RelationKind::CapabilityGeneralization => Some("specializes"), + RelationKind::MissionCapability => Some("exploits"), + RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => { + Some("participates") + } + _ => None, + }); + let mut stroke = + Stroke::default() + .with_color(color) + .with_width(if active { 3. } else { 1.4 }); + if matches!(capability_label, Some("«include»" | "«extend»")) { + stroke.line_dash = canvas::LineDash { + segments: &[6., 4.], + offset: 0, + }; + } + f.stroke(&curve(start, end), stroke); + let previous = curve_point(start, end, 0.96); + let direction = end - previous; + let length = (direction.x * direction.x + direction.y * direction.y) + .sqrt() + .max(0.001); + let direction = direction / length; + let tip1 = end - direction * 9. + Vector::new(-direction.y, direction.x) * 4.; + let tip2 = end - direction * 9. - Vector::new(-direction.y, direction.x) * 4.; + f.stroke( + &Path::new(|p| { + p.move_to(tip1); + p.line_to(end); + p.line_to(tip2); + }), + Stroke::default().with_color(color).with_width(1.7), + ); + if edge.id == self.selected + || capability_label.is_some() + || matches!( + self.project.relations.get(&edge.id).map(|r| r.kind()), + Some( + RelationKind::Interaction + | RelationKind::FunctionalExchange + | RelationKind::BoundaryExchange + ) + ) + { + let center = curve_point(start, end, 0.5); + f.fill_rectangle( + Point::new(center.x - 112., center.y - 30.), + Size::new(224., 24.), + Color::WHITE, + ); + f.fill_text(Text { + content: if edge.id == self.selected || capability_label.is_none() { + short(self.project.named(edge.id), 32) + } else { + capability_label.unwrap_or_default().into() + }, + position: Point::new(center.x, center.y - 8.), + color: if active { style::TEAL } else { style::MUTED }, + size: 13.into(), + align_x: iced::widget::text::Alignment::Center, + align_y: iced::alignment::Vertical::Bottom, + max_width: f32::INFINITY, + ..Default::default() + }); + } + } + for n in &view.nodes { + if !containers.contains(&n.id) { + paint_node(&mut f, self.project, n, self.selected, false); + } else { + // Container headers stay readable when relationships traverse + // the allocation enclosure. Children remain on top. + let mut header = n.clone(); + header.size.height = 62.; + paint_node(&mut f, self.project, &header, self.selected, true); + } + } + for port in &view.ports { + let color = if self.selected == port.element { + style::TEAL + } else { + style::kind_color( + self.project.elements[&port.element] + .parent + .and_then(|id| self.project.elements.get(&id)) + .map(|e| e.kind()) + .unwrap_or(Kind::Port), + ) + }; + let origin = Point::new(port.position.x - 7., port.position.y - 7.); + let path = Path::rectangle(origin, Size::new(14., 14.)); + f.fill(&path, Color::WHITE); + f.stroke( + &path, + Stroke::default() + .with_color(color) + .with_width(if self.selected == port.element { + 2.5 + } else { + 1.5 + }), + ); + let center = Point::new(port.position.x, port.position.y); + f.stroke( + &Path::new(|p| { + p.move_to(center - Vector::new(4., 0.)); + p.line_to(center + Vector::new(4., 0.)); + p.move_to(center + Vector::new(1., -3.)); + p.line_to(center + Vector::new(4., 0.)); + p.line_to(center + Vector::new(1., 3.)); + if port.direction == PortDirection::InOut { + p.move_to(center + Vector::new(-1., -3.)); + p.line_to(center - Vector::new(4., 0.)); + p.line_to(center + Vector::new(-1., 3.)); + } + }), + Stroke::default().with_color(color), + ); + if self.selected == port.element { + f.fill_text(Text { + content: short(self.project.named(port.element), 40), + position: center + Vector::new(12., 9.), + color, + size: 12.into(), + ..Default::default() + }); + } + } + vec![f.into_geometry()] + } + fn mouse_interaction( + &self, + state: &State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if state.drag.is_some() || state.pan_start.is_some() { + mouse::Interaction::Grabbing + } else if cursor.is_over(bounds) { + mouse::Interaction::Grab + } else { + mouse::Interaction::default() + } + } +} +fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, container: bool) { + let Some(e) = p.elements.get(&n.element) else { + return; + }; + let color = style::kind_color(e.kind()); + let origin = Point::new(n.position.x, n.position.y); + let size = Size::new(n.size.width, n.size.height); + let path = Path::rounded_rectangle(origin, size, 10.into()); + let tint = Color { + r: 0.94 + color.r * 0.06, + g: 0.94 + color.g * 0.06, + b: 0.94 + color.b * 0.06, + a: 1., + }; + f.fill(&path, if container { tint } else { Color::WHITE }); + f.stroke( + &path, + Stroke::default() + .with_color(if e.id == selected { color } else { style::LINE }) + .with_width(if e.id == selected { 2.6 } else { 1.2 }), + ); + f.fill_rectangle( + Point::new(origin.x + 14., origin.y + 15.), + Size::new(4., 12.), + color, + ); + f.fill_text(Text { + content: format!( + "{}{}", + e.kind().label().to_uppercase(), + if n.collapsed { " +" } else { "" } + ), + position: Point::new(origin.x + 25., origin.y + 14.), + color, + size: 11.into(), + max_width: f32::INFINITY, + ..Default::default() + }); + let max_chars = ((size.width - 28.) / 8.0).max(8.) as usize; + f.fill_text(Text { + content: short(&e.name, max_chars), + position: Point::new(origin.x + 14., origin.y + 35.), + color: style::INK, + size: 16.into(), + max_width: f32::INFINITY, + ..Default::default() + }); + if !container && size.height >= 86. { + f.fill_text(Text { + content: format!("@{}", short(&e.alias, max_chars + 6)), + position: Point::new(origin.x + 14., origin.y + 66.), + color: style::MUTED, + size: 10.5.into(), + max_width: f32::INFINITY, + ..Default::default() + }); + } +} +fn highlighted_exchanges(p: &Project, selected: Id) -> BTreeSet { + p.elements + .get(&selected) + .and_then(|e| e.steps()) + .into_iter() + .flatten() + .filter_map(|id| match p.elements.get(id)?.shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { exchange, .. }, + .. + } => *exchange, + _ => None, + }) + .collect() +} +fn edge_endpoints(view: &ProjectedView, edge: &ProjectedEdge) -> Option<(Point, Point)> { + let a = view.nodes.iter().find(|n| n.id == edge.source)?; + let b = view.nodes.iter().find(|n| n.id == edge.target)?; + let (start, end) = endpoints(a, b); + let port_point = |id: Option| { + id.and_then(|id| view.ports.iter().find(|p| p.element == id)) + .map(|p| Point::new(p.position.x, p.position.y)) + }; + Some(( + port_point(edge.source_port).unwrap_or(start), + port_point(edge.target_port).unwrap_or(end), + )) +} +fn endpoints(a: &ProjectedNode, b: &ProjectedNode) -> (Point, Point) { + let ax = a.position.x + a.size.width / 2.; + let ay = a.position.y + a.size.height / 2.; + let bx = b.position.x + b.size.width / 2.; + let by = b.position.y + b.size.height / 2.; + if (bx - ax).abs() > (by - ay).abs() { + if bx >= ax { + ( + Point::new(a.position.x + a.size.width, ay), + Point::new(b.position.x, by), + ) + } else { + ( + Point::new(a.position.x, ay), + Point::new(b.position.x + b.size.width, by), + ) + } + } else if by >= ay { + ( + Point::new(ax, a.position.y + a.size.height), + Point::new(bx, b.position.y), + ) + } else { + ( + Point::new(ax, a.position.y), + Point::new(bx, b.position.y + b.size.height), + ) + } +} +fn curve(a: Point, b: Point) -> Path { + let mid = (a.x + b.x) / 2.; + Path::new(|p| { + p.move_to(a); + p.bezier_curve_to(Point::new(mid, a.y), Point::new(mid, b.y), b); + }) +} +fn curve_point(a: Point, b: Point, t: f32) -> Point { + let mid = (a.x + b.x) / 2.; + let u = 1. - t; + Point::new( + u * u * u * a.x + 3. * u * u * t * mid + 3. * u * t * t * mid + t * t * t * b.x, + u * u * u * a.y + 3. * u * u * t * a.y + 3. * u * t * t * b.y + t * t * t * b.y, + ) +} +pub fn distance_to_segment(p: Point, a: Point, b: Point) -> f32 { + let ab = b - a; + let ap = p - a; + let len = ab.x * ab.x + ab.y * ab.y; + let t = if len > 0. { + ((ap.x * ab.x + ap.y * ab.y) / len).clamp(0., 1.) + } else { + 0. + }; + let delta = p - (a + ab * t); + (delta.x * delta.x + delta.y * delta.y).sqrt() +} +fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} +fn hex(c: Color) -> String { + format!( + "#{:02x}{:02x}{:02x}", + (c.r * 255.) as u8, + (c.g * 255.) as u8, + (c.b * 255.) as u8 + ) +} +pub fn export_svg(p: &Project, diagram: Id) -> String { + let view = project_view(p, diagram); + let left = view.nodes.iter().map(|n| n.position.x).fold(0., f32::min) - 30.; + let top = view.nodes.iter().map(|n| n.position.y).fold(0., f32::min) - 30.; + let right = view + .nodes + .iter() + .map(|n| n.position.x + n.size.width) + .fold(800., f32::max) + + 30.; + let bottom = view + .nodes + .iter() + .map(|n| n.position.y + n.size.height) + .fold(500., f32::max) + + 30.; + let mut svg = format!( + "{}", + right - left, + bottom - top, + escape(p.named(diagram)), + right - left, + bottom - top + ); + let containers = view + .nodes + .iter() + .filter_map(|n| n.visual_parent) + .collect::>(); + let node_svg = |n: &ProjectedNode| { + let e = &p.elements[&n.element]; + let color = hex(style::kind_color(e.kind())); + let x = n.position.x; + let y = n.position.y; + format!( + "{}{}", + n.size.width, + n.size.height, + if containers.contains(&n.id) { + "#edf3f7" + } else { + "white" + }, + x + 14., + y + 25., + escape(e.kind().label()), + x + 14., + y + 51., + escape(&short( + &e.name, + ((n.size.width - 28.) / 8.).max(8.) as usize + )) + ) + }; + for n in &view.nodes { + if containers.contains(&n.id) { + svg.push_str(&node_svg(n)); + } + } + for edge in &view.edges { + if let Some((a, b)) = edge_endpoints(&view, edge) { + let mid = (a.x + b.x) / 2.; + svg.push_str(&format!("{}",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id)))); + } + } + for n in &view.nodes { + if !containers.contains(&n.id) { + svg.push_str(&node_svg(n)); + } + } + for port in &view.ports { + svg.push_str(&format!("{}",escape(p.named(port.element)),port.position.x-7.,port.position.y-7.,port.position.x-4.,port.position.y)); + } + svg.push_str(""); + svg +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn implicit_port_click_selects_semantics_and_drag_preview_svg_share_anchors() { + let p = Project::demo(); + let diagram = p + .diagrams + .values() + .find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation) + .unwrap() + .id; + let canvas = DiagramCanvas { + project: &p, + diagram, + selected: p.id, + zoom: 0.6, + pan: Vector::new(10., 20.), + }; + let view = project_view(&p, diagram); + let port = &view.ports[0]; + let click = Point::new(port.position.x * 0.6 + 10., port.position.y * 0.6 + 20.); + let mut state = State::default(); + let selected = canvas::Program::update( + &canvas, + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Rectangle::new(Point::ORIGIN, Size::new(1600., 1000.)), + mouse::Cursor::Available(click), + ) + .unwrap() + .into_inner() + .0; + assert!(matches!(selected,Some(Message::Select(id)) if id==port.element)); + assert!( + state.drag.is_none(), + "Port selection must not drag its parent" + ); + let original = p.diagrams[&diagram].nodes[&port.owner].position; + state.drag = Some((port.owner, Point::ORIGIN, original)); + state.preview = Some(Position { + x: original.x + 100., + y: original.y + 70., + }); + let preview = canvas.preview_view(&state); + let moved = preview + .ports + .iter() + .find(|p| p.element == port.element) + .unwrap(); + assert_eq!(moved.position.x, port.position.x + 100.); + assert_eq!(moved.position.y, port.position.y + 70.); + let svg = export_svg(&p, diagram); + for port in &view.ports { + assert!(svg.contains(&format!("{}", escape(p.named(port.element))))); + } + for edge in &view.edges { + let (start, end) = edge_endpoints(&view, edge).unwrap(); + assert!(svg.contains(&format!("M {} {} C", start.x, start.y))); + assert!(svg.contains(&format!("{}, {} {}", end.y, end.x, end.y))); + } + } +} diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs new file mode 100644 index 00000000..0f17c4c2 --- /dev/null +++ b/crates/oa-desktop/src/forms.rs @@ -0,0 +1,1612 @@ +//! A small property-form adapter over the closed core records. The model remains +//! typed; temporary strings belong only to drafts so incomplete numeric edits +//! never enter accepted state. References are displayed as named choices. +use crate::{ + app::{Choice, Message}, + style, +}; +use iced::{ + Element as View, Fill, + widget::{ + self, button, checkbox, column, container, pick_list, row, text, text_editor, text_input, + }, +}; +use oa_core::*; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordKind { + Project, + Element, + Relation, + Diagram, + Node(Id), + Artifact, + Trace, +} + +pub struct Input { + pub raw: String, + pub editor: text_editor::Content, +} +pub struct Draft { + pub kind: RecordKind, + pub original: Id, + pub value: Value, + baseline: Value, + pub inputs: BTreeMap, + pub exact_strings: bool, +} + +impl Draft { + pub fn from_project(project: &Project, selected: Id) -> Option { + let (kind, value) = if selected == project.id { + ( + RecordKind::Project, + json!({"id":project.id,"alias":project.alias,"name":project.name,"description":project.description}), + ) + } else if let Some(v) = project.elements.get(&selected) { + (RecordKind::Element, json!(v)) + } else if let Some(v) = project.relations.get(&selected) { + (RecordKind::Relation, json!(v)) + } else if let Some(v) = project.diagrams.get(&selected) { + (RecordKind::Diagram, json!(v)) + } else if let Some(v) = project.artifact_links.get(&selected) { + (RecordKind::Artifact, json!(v)) + } else if let Some(v) = project.traces.get(&selected) { + (RecordKind::Trace, json!(v)) + } else { + let (diagram, node) = project + .diagrams + .values() + .find_map(|d| d.nodes.get(&selected).map(|n| (d.id, n)))?; + let mut value = json!(node); + value["diagram"] = json!(diagram); + (RecordKind::Node(diagram), value) + }; + let mut draft = Self { + kind, + original: selected, + baseline: value.clone(), + value, + inputs: BTreeMap::new(), + exact_strings: false, + }; + draft.rebuild(); + Some(draft) + } + fn rebuild(&mut self) { + self.inputs.clear(); + fn visit(value: &Value, path: &str, out: &mut BTreeMap) { + match value { + Value::Object(map) => { + for (key, value) in map { + if key != "nodes" { + visit(value, &format!("{path}/{key}"), out); + } + } + } + Value::Array(items) => { + for (index, value) in items.iter().enumerate() { + visit(value, &format!("{path}/{index}"), out); + } + } + Value::String(s) => { + out.insert( + path.into(), + Input { + raw: s.clone(), + editor: text_editor::Content::with_text(s), + }, + ); + } + Value::Number(n) => { + let s = n.to_string(); + out.insert( + path.into(), + Input { + raw: s.clone(), + editor: text_editor::Content::with_text(&s), + }, + ); + } + _ => {} + } + } + visit(&self.value, "", &mut self.inputs); + if self.exact_strings { + for (path, input) in &mut self.inputs { + if free_string(path) { + input.raw = serde_json::to_string(&input.raw).expect("String serialization"); + input.editor = text_editor::Content::with_text(&input.raw); + } + } + } + } + pub fn materialize(&self) -> Result { + let mut value = self.value.clone(); + for (path, input) in &self.inputs { + let Some(target) = value.pointer_mut(path) else { + continue; + }; + if target.is_number() { + let parsed: Value = serde_json::from_str(&input.raw).map_err(|_| { + format!( + "{} must be a finite number.", + friendly(path.rsplit('/').next().unwrap_or("Value")) + ) + })?; + if !parsed.is_number() { + return Err("Enter a finite number.".into()); + } + *target = parsed; + } else if self.exact_strings && free_string(path) { + let decoded: String = serde_json::from_str(&input.raw).map_err(|_| { + format!( + "{} must be a quoted JSON string while escaped strings are enabled.", + friendly(path.rsplit('/').next().unwrap_or("Text")) + ) + })?; + *target = Value::String(decoded); + } else { + *target = Value::String(input.raw.clone()); + } + } + Ok(value) + } + pub fn dirty(&self) -> bool { + self.materialize() + .map_or(true, |value| value != self.baseline) + } + pub fn set_exact(&mut self, enabled: bool) -> Result<(), String> { + self.value = self.materialize()?; + self.exact_strings = enabled; + self.rebuild(); + Ok(()) + } + pub fn input(&mut self, path: &str, value: String) { + if let Some(input) = self.inputs.get_mut(path) { + input.editor = text_editor::Content::with_text(&value); + input.raw = value; + } + } + pub fn edit_text(&mut self, path: &str, action: text_editor::Action) { + if let Some(input) = self.inputs.get_mut(path) { + input.editor.perform(action); + input.raw = input.editor.text(); + } + } + pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> { + let mut candidate = self.materialize()?; + *candidate + .pointer_mut(path) + .ok_or("Property no longer exists.")? = value; + self.value = candidate; + self.rebuild(); + Ok(()) + } + pub fn array( + &mut self, + path: &str, + index: Option, + direction: i32, + ) -> Result<(), String> { + let mut candidate = self.materialize()?; + let items = candidate + .pointer_mut(path) + .and_then(Value::as_array_mut) + .ok_or("List no longer exists.")?; + match index { + None => items.push(if path.ends_with("literals") { + json!("New value") + } else if path.ends_with("bindings") { + json!({"referenced": Id::nil(), "local": Id::nil()}) + } else if path.ends_with("values") { + json!({"field": Id::nil(), "expression": ""}) + } else { + json!(Id::nil()) + }), + Some(i) if i < items.len() => { + if direction == 0 { + items.remove(i); + } else { + let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize; + items.swap(i, target); + } + } + _ => return Err("List item no longer exists.".into()), + } + self.value = candidate; + self.rebuild(); + Ok(()) + } + pub fn command(&self) -> Result { + let mut value = self.materialize()?; + let decode = |e: serde_json::Error| e.to_string(); + Ok(match self.kind { + RecordKind::Project => Command::EditProject { + id: serde_json::from_value(value["id"].clone()).map_err(decode)?, + alias: value["alias"].as_str().unwrap_or_default().into(), + name: value["name"].as_str().unwrap_or_default().into(), + description: value["description"].as_str().unwrap_or_default().into(), + }, + RecordKind::Element => Command::EditElement( + self.original, + serde_json::from_value(value).map_err(decode)?, + ), + RecordKind::Relation => Command::EditRelation( + self.original, + serde_json::from_value(value).map_err(decode)?, + ), + RecordKind::Diagram => Command::EditDiagram( + self.original, + serde_json::from_value(value).map_err(decode)?, + ), + RecordKind::Node(diagram) => { + let destination: Id = serde_json::from_value( + value + .as_object_mut() + .ok_or("Invalid placement draft.")? + .remove("diagram") + .ok_or("Choose a diagram.")?, + ) + .map_err(decode)?; + let node: NodePlacement = serde_json::from_value(value).map_err(decode)?; + let id = node.id; + let edit = Command::EditNode { + diagram, + original: self.original, + node, + }; + if destination == diagram { + edit + } else { + Command::Batch(vec![ + edit, + Command::RelocateNode { + node: id, + diagram: destination, + }, + ]) + } + } + RecordKind::Artifact => Command::EditArtifact( + self.original, + serde_json::from_value(value).map_err(decode)?, + ), + RecordKind::Trace => Command::EditTrace( + self.original, + serde_json::from_value(value).map_err(decode)?, + ), + }) + } + pub fn resulting_id(&self) -> Result { + self.inputs + .get("/id") + .ok_or("Missing identity.")? + .raw + .parse() + .map_err(|_| "Identity must be a UUID.".into()) + } +} + +fn friendly(name: &str) -> String { + match name { + "id" => "UUID identity".into(), + "alias" => "Stable source alias".into(), + "parent" => "Semantic parent".into(), + "oa" => "Operational counterpart".into(), + "sa" => "System counterpart".into(), + "do_activity" => "Do behavior (description)".into(), + "source_port" => "Output port".into(), + "target_port" => "Input port".into(), + "minimum" => "Minimum count".into(), + "maximum" => "Maximum count".into(), + "type_ref" => "Value type".into(), + "Named" => "Data type".into(), + "body" => "Occurrence type".into(), + "diagram" => "Diagram".into(), + "uri" => "Artifact URI".into(), + "definition" => "Data type definition".into(), + "steps" | "occurrences" => "Ordered occurrences".into(), + "represented" => "Represents".into(), + "reply_to" => "Reply to call".into(), + "referenced" => "Referenced scenario lifeline".into(), + "local" => "Local lifeline".into(), + "field" => "Carried data field".into(), + "OperationalActivity" => "Operational activities".into(), + "ParticipantInteraction" => "Operational participants".into(), + "Functional" => "System functions".into(), + "BoundaryInterface" => "System boundary / interfaces".into(), + "MixedLegacy" => "Mixed viewpoints (imported model)".into(), + "ShallowHistory" => "Shallow history".into(), + "DeepHistory" => "Deep history".into(), + "EntryPoint" => "Entry point".into(), + "ExitPoint" => "Exit point".into(), + "triggers" => "Trigger events / work".into(), + "effects" => "Effect work".into(), + "trigger" => "Trigger notes".into(), + "effect" => "Effect notes".into(), + "Oa" => "Operational Analysis".into(), + "Sa" => "System Analysis".into(), + "InOut" => "Input / output".into(), + "In" => "Input".into(), + "Out" => "Output".into(), + "DerivedFrom" => "Derived from".into(), + _ => { + let text = name.replace('_', " "); + let mut chars = text.chars(); + chars + .next() + .map(|c| c.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default() + } + } +} +fn reference(path: &str) -> bool { + let parts: Vec<_> = path.split('/').collect(); + let field = parts.last().copied().unwrap_or_default(); + let parent = parts.iter().rev().nth(1).copied().unwrap_or_default(); + matches!( + field, + "parent" + | "source" + | "target" + | "source_port" + | "target_port" + | "interface" + | "context" + | "represented" + | "exchange" + | "sender" + | "receiver" + | "action" + | "sequence" + | "scenario" + | "reply_to" + | "referenced" + | "local" + | "field" + | "lifeline" + | "state" + | "element" + | "owner" + | "oa" + | "sa" + | "Named" + | "diagram" + ) || matches!( + parent, + "items" | "steps" | "branches" | "occurrences" | "constrained" | "triggers" | "effects" + ) +} +fn multiline(path: &str) -> bool { + matches!( + path.rsplit('/').next().unwrap_or_default(), + "description" + | "precondition" + | "postcondition" + | "expression" + | "entry" + | "exit" + | "do_activity" + | "trigger" + | "guard" + | "condition" + | "effect" + ) +} +fn free_string(path: &str) -> bool { + multiline(path) + || matches!( + path.rsplit('/').next(), + Some("name" | "uri" | "revision" | "fragment" | "unit") + ) + || path.split('/').rev().nth(1) == Some("literals") +} + +// These are choices for the closed model, not a second validation boundary. +// A current reference is always retained, even while other draft fields make it +// incompatible. Applying the complete draft remains the core validator's job. +fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec { + let identities = project.identities().into_iter().collect::>(); + let mut objects = reference_ids(draft, project, path) + .into_iter() + .chain((!current.is_nil()).then_some(current)) + .collect::>() + .into_iter() + .map(|id| { + let name = project + .diagrams + .values() + .find_map(|diagram| { + diagram.nodes.get(&id).map(|node| { + format!( + "{} · placement in {}", + project.named(node.element), + diagram.name + ) + }) + }) + .unwrap_or_else(|| project.named(id).to_owned()); + let label = identities + .get(&id) + .map(|alias| format!("{name} · @{alias}")) + .unwrap_or_else(|| format!("Missing reference · {id}")); + Choice { id, label } + }) + .collect::>(); + objects.sort_by(|a, b| a.label.cmp(&b.label)); + objects.insert( + 0, + Choice { + id: Id::nil(), + label: "Not assigned".into(), + }, + ); + objects +} + +fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec { + let value = draft.materialize().unwrap_or_else(|_| draft.value.clone()); + let element = (draft.kind == RecordKind::Element) + .then(|| serde_json::from_value::(value.clone()).ok()) + .flatten() + .or_else(|| project.elements.get(&draft.original).cloned()); + let relation = (draft.kind == RecordKind::Relation) + .then(|| serde_json::from_value::(value.clone()).ok()) + .flatten() + .or_else(|| project.relations.get(&draft.original).cloned()); + let layer = element + .as_ref() + .map(Element::layer) + .or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source()))); + let field = path.rsplit('/').next().unwrap_or_default(); + let list = path.split('/').rev().nth(1).unwrap_or_default(); + let semantic = |id| { + project + .layer_of(id) + .is_some_and(|l| layer.is_none_or(|own| own == l)) + }; + let same_layer = |e: &Element| layer.is_none_or(|l| e.layer() == l); + let owned = |e: &Element| { + e.parent == Some(draft.original) + || element + .as_ref() + .is_some_and(|owner| e.parent == Some(owner.id)) + }; + + if field == "diagram" { + return project.diagrams.keys().copied().collect(); + } + if field == "owner" || list == "constrained" { + return project + .identities() + .into_iter() + .map(|(id, _)| id) + .filter(|id| field == "owner" || semantic(*id)) + .collect(); + } + if matches!(field, "oa" | "sa") && draft.kind == RecordKind::Trace { + let target = if field == "oa" { Layer::Oa } else { Layer::Sa }; + return project + .identities() + .into_iter() + .map(|(id, _)| id) + .filter(|id| project.layer_of(*id) == Some(target)) + .collect(); + } + if matches!(field, "source" | "target") + && let Some(r) = &relation + { + // can_connect treats allocations/initial exits as new links, so exclude + // the relation being edited before asking it for endpoint choices. + let mut context = project.clone(); + context.relations.remove(&draft.original); + let other = if field == "source" { + r.target() + } else { + r.source() + }; + let other_exists = context.contains(other); + let identities = project + .identities() + .into_iter() + .map(|(id, _)| id) + .collect::>(); + let permits = |id, other| { + let (source, target) = if field == "source" { + (id, other) + } else { + (other, id) + }; + validation::can_connect(&context, r.kind(), source, target) + }; + return identities + .iter() + .copied() + .filter(|id| { + if other_exists { + permits(*id, other) + } else { + identities.iter().any(|other| permits(*id, *other)) + } + }) + .collect(); + } + if field == "exchange" { + let owner = element + .as_ref() + .and_then(|e| e.parent) + .and_then(|id| project.elements.get(&id)); + return project + .relations + .values() + .filter(|r| { + semantic(r.id) + && match owner.map(Element::kind) { + Some(Kind::Process) => r.kind() == RelationKind::Interaction, + Some(Kind::FunctionalChain) => r.kind() == RelationKind::FunctionalExchange, + _ => matches!( + r.kind(), + RelationKind::Interaction + | RelationKind::FunctionalExchange + | RelationKind::BoundaryExchange + ), + } + }) + .map(|r| r.id) + .collect(); + } + if field == "field" { + return element + .as_ref() + .and_then(|e| match e.shared()? { + SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + exchange: Some(id), .. + }, + .. + } => project.relations.get(id), + _ => None, + }) + .map(|r| validation::carried_fields(project, r).into_iter().collect()) + .unwrap_or_default(); + } + let scenario = element + .as_ref() + .and_then(|e| e.parent) + .and_then(|id| validation::ancestor_of_kind(project, id, Kind::Scenario)); + let binding_row = path + .split('/') + .rev() + .nth(1) + .and_then(|s| s.parse::().ok()); + project.elements.values().filter(|candidate| { + if field == "element" && let RecordKind::Node(diagram) = draft.kind { + let diagram = value.get("diagram").and_then(Value::as_str) + .and_then(|id| id.parse().ok()).unwrap_or(diagram); + return project.diagrams.get(&diagram).is_some_and(|d| + (d.projection == ProjectionKind::Traceability || d.layer == candidate.layer()) + && !d.nodes.values().any(|n| n.id != draft.original && n.element == candidate.id)); + } + if !same_layer(candidate) { return false; } + match field { + "parent" => element.as_ref().is_some_and(|child| { + if candidate.id == draft.original || candidate.id == child.id + || !validation::can_parent(project, child.kind(), child.layer(), Some(candidate.id)) { + return false; + } + let mut parent = candidate.parent; + let mut seen = BTreeSet::new(); + while let Some(id) = parent { + if id == draft.original || id == child.id || !seen.insert(id) { return false; } + parent = project.elements.get(&id).and_then(|e| e.parent); + } + match child.shared() { + Some(SharedBody::Occurrence { body, .. }) => match candidate.shared() { + Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { .. }, .. }) => matches!(body, OccurrenceBody::Branch { .. }), + Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) => !matches!(body, OccurrenceBody::Branch { .. }), + _ if matches!(candidate.kind(), Kind::Process | Kind::FunctionalChain) => matches!(body, OccurrenceBody::Exchange { .. } | OccurrenceBody::Action { .. } | OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. }), + _ => !matches!(body, OccurrenceBody::Branch { .. }), + }, + _ => true, + } + }), + "Named" => candidate.kind() == Kind::DataType, + "interface" => candidate.kind() == Kind::Interface, + "context" => candidate.kind().is_sequence() || candidate.kind().is_work() + || candidate.kind().is_performer() + || matches!(candidate.kind(), Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission), + "represented" => candidate.kind().is_performer() || candidate.kind().is_work() || candidate.kind() == Kind::Role, + "action" => candidate.kind().is_work(), + "sequence" => element.as_ref().and_then(|e| e.parent).and_then(|id| project.elements.get(&id)) + .is_some_and(|owner| candidate.kind() == owner.kind() && candidate.id != owner.id), + "scenario" => candidate.kind() == Kind::Scenario && scenario.is_some_and(|owner| { + let viewpoint = |id| match project.elements.get(&id).and_then(Element::shared) { + Some(SharedBody::Scenario { viewpoint, .. }) => Some(*viewpoint), + _ => None, + }; + let a = viewpoint(owner); + let b = viewpoint(candidate.id); + (a == b || a == Some(ScenarioViewpoint::MixedLegacy) || b == Some(ScenarioViewpoint::MixedLegacy)) + && !scenario_reaches(project, candidate.id, owner) + }), + "reply_to" => candidate.id != draft.original + && scenario.is_some() + && validation::ancestor_of_kind(project, candidate.id, Kind::Scenario) == scenario + && matches!(candidate.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, .. }, .. })), + "referenced" | "local" => element.as_ref().is_some_and(|e| { + let Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: target, bindings }, .. }) = e.shared() else { return false; }; + if candidate.kind() != Kind::Lifeline + || candidate.parent != if field == "referenced" { *target } else { scenario } + || candidate.parent.is_none() { return false; } + if bindings.iter().enumerate().any(|(i,b)| Some(i) != binding_row + && candidate.id == if field == "referenced" { b.referenced } else { b.local }) { return false; } + let other = binding_row.and_then(|i| bindings.get(i)).map(|b| if field == "referenced" { b.local } else { b.referenced }); + let represented = |id| match project.elements.get(&id).and_then(Element::shared) { + Some(SharedBody::Lifeline { represented }) => *represented, + _ => None, + }; + match (represented(candidate.id), other.and_then(represented)) { + (Some(a), Some(b)) => validation::semantic_representation_compatible(project, + if field == "local" { a } else { b }, if field == "local" { b } else { a }) != Some(false), + _ => true, + } + }), + "state" => matches!(candidate.kind(), Kind::State | Kind::Mode), + "sender" | "receiver" | "lifeline" => element.as_ref().is_some_and(|e| { + let scenario = e.parent.and_then(|id| validation::ancestor_of_kind(project, id, Kind::Scenario)); + candidate.kind() == Kind::Lifeline && candidate.parent == scenario + && lifeline_matches(project, e, candidate, field) + }), + "source_port" | "target_port" => relation.as_ref().is_some_and(|r| { + let source = field == "source_port"; + candidate.parent == Some(if source { r.source() } else { r.target() }) + && matches!(candidate.shared(), Some(SharedBody::Port { direction, .. }) + if if source { *direction != PortDirection::In } else { *direction != PortDirection::Out }) + }), + _ => match list { + "items" => candidate.kind() == Kind::ExchangeItem, + "triggers" => candidate.kind().is_work() || candidate.kind() == Kind::Event, + "effects" => candidate.kind().is_work(), + "branches" => owned(candidate) && matches!(candidate.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. })), + "steps" | "occurrences" => owned(candidate) && candidate.kind() == Kind::Occurrence, + _ => true, + }, + } + }).map(|e| e.id).collect() +} + +fn lifeline_matches(project: &Project, occurrence: &Element, line: &Element, field: &str) -> bool { + let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared() else { + return true; + }; + let endpoint = match body { + OccurrenceBody::Exchange { .. } => validation::message_endpoints(project, body) + .map(|(source, target)| if field == "sender" { source } else { target }), + OccurrenceBody::Action { action, .. } => *action, + OccurrenceBody::State { + state: Some(state), .. + } => { + let owner = validation::ancestor_of_kind(project, *state, Kind::StateMachine) + .and_then(|id| project.elements.get(&id)) + .and_then(|e| e.parent); + return owner.is_none_or(|owner| match line.shared() { + Some(SharedBody::Lifeline { + represented: Some(represented), + }) => { + *represented == owner + || validation::effective_performer(project, *represented) == Some(owner) + } + _ => true, + }); + } + _ => None, + }; + let Some(endpoint) = endpoint else { + return true; + }; + validation::lifeline_matches_endpoint(project, line.id, endpoint) != Some(false) +} + +// A selector must not offer a recursive scenario use. This only traverses +// existing references; committing a complete draft still uses core validation. +fn scenario_reaches(project: &Project, from: Id, target: Id) -> bool { + let mut pending = vec![from]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if id == target { + return true; + } + if !seen.insert(id) { + continue; + } + for e in project.elements.values() { + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::ScenarioUse { + scenario: Some(next), + .. + }, + .. + }) = e.shared() + && validation::ancestor_of_kind(project, e.id, Kind::Scenario) == Some(id) + { + pending.push(*next); + } + } + } + false +} + +fn group_label(path: &str) -> Option<&'static str> { + match path { + "/position" => Some("Canvas position"), + "/size" => Some("Box size"), + "/baseline" => Some("Reviewed properties"), + "/baseline/oa" => Some("Operational baseline"), + "/baseline/sa" => Some("System baseline"), + _ => None, + } +} + +fn unit_body(path: &str, value: &Value) -> bool { + value.is_string() + && matches!( + path, + "/body/Oa" | "/body/Sa" | "/body/Oa/Shared" | "/body/Sa/Shared" + ) +} + +/// Closed choices for the concrete model enums. No runtime schema/plugin layer. +fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> { + let field = path.rsplit('/').next().unwrap_or_default(); + let words: &[&str] = match field { + "layer" => &["Oa", "Sa"], + "projection" => &[ + "Overview", + "Decomposition", + "Allocation", + "Traceability", + "FunctionalDataflow", + "Capabilities", + ], + "direction" => &["In", "Out", "InOut"], + "operator" => &["Alternative", "Parallel", "Loop", "Optional"], + "viewpoint" if path.contains("/Oa/") => &[ + "OperationalActivity", + "ParticipantInteraction", + "MixedLegacy", + ], + "viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"], + "phase" => &["Entry", "Do", "Exit"], + "kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"], + "kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"], + "kind" if path.contains("State") => &[ + "State", + "Mode", + "Initial", + "Final", + "Choice", + "Junction", + "Fork", + "Join", + "ShallowHistory", + "DeepHistory", + "EntryPoint", + "ExitPoint", + "Terminate", + ], + "kind" if path.contains("/Time/") => &["After", "At"], + "transition_kind" => &["External", "Local", "Internal"], + "kind" if kind == RecordKind::Trace => &[ + "Refinement", + "Participant", + "Capability", + "Mission", + "Exchange", + "Sequence", + "Item", + "Scenario", + "State", + "Manual", + ], + "relation" if kind == RecordKind::Artifact => &[ + "References", + "Satisfies", + "Verifies", + "Implements", + "DerivedFrom", + ], + _ => &[], + }; + if !words.is_empty() { + return words.iter().map(|s| (friendly(s), json!(s))).collect(); + } + if field == "body" && path.contains("/Event/") { + return vec![ + ("Signal event".into(), json!({"Signal":{"items":[]}})), + ( + "Time event".into(), + json!({"Time":{"kind":"After","expression":""}}), + ), + ("Change event".into(), json!({"Change":{"expression":""}})), + ]; + } + if field == "message_kind" { + return vec![ + ("Asynchronous message".into(), json!("Asynchronous")), + ("Synchronous call".into(), json!("Call")), + ("Reply".into(), json!("Reply")), + ("Create lifeline".into(), json!("Create")), + ("Delete lifeline".into(), json!("Delete")), + ("Timer".into(), json!({"Timer":{"expression":""}})), + ]; + } + if field == "definition" { + return vec![ + ("Boolean".into(), json!("Boolean")), + ("Integer".into(), json!("Integer")), + ("Real".into(), json!("Real")), + ("Text".into(), json!("String")), + ("Enumeration".into(), json!({"Enumeration":{"literals":[]}})), + ("Record".into(), json!("Record")), + ]; + } + if field == "type_ref" { + return vec![ + ("Boolean".into(), json!("Boolean")), + ("Integer".into(), json!("Integer")), + ("Real".into(), json!("Real")), + ("Text".into(), json!("String")), + ("Named data type".into(), json!({"Named":Id::nil()})), + ]; + } + if field == "body" && path.contains("Occurrence") { + return vec![ + ( + "Referenced scenario".into(), + json!({"ScenarioUse":{"scenario":null,"bindings":[]}}), + ), + ( + "Exchange".into(), + json!({"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}), + ), + ( + "Action".into(), + json!({"Action":{"action":null,"lifeline":null}}), + ), + ( + "Process control".into(), + json!({"Control":{"kind":"Choice"}}), + ), + ( + "Reusable process / chain".into(), + json!({"SequenceReference":{"sequence":null}}), + ), + ( + "State observation".into(), + json!({"State":{"state":null,"lifeline":null}}), + ), + ( + "Fragment".into(), + json!({"Fragment":{"operator":"Alternative","branches":[]}}), + ), + ( + "Branch".into(), + json!({"Branch":{"guard":"","occurrences":[]}}), + ), + ]; + } + let _ = value; + vec![] +} + +pub fn same_variant(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Object(a), Value::Object(b)) => a.keys().next() == b.keys().next(), + _ => a == b, + } +} + +pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> { + let mut content = column![ + checkbox(draft.exact_strings) + .label("Escaped strings (advanced)") + .text_size(11) + .on_toggle(Message::ExactStrings) + ] + .spacing(12); + if draft.kind == RecordKind::Element + && let Some(body) = draft + .materialize() + .unwrap_or_else(|_| draft.value.clone()) + .get("body") + && let Ok(body) = serde_json::from_value::(body.clone()) + && let Some(shared) = body.shared() + { + let shared = shared.clone(); + content = content.push(column![ + text("Engineering perspective").size(11).color(style::MUTED), + pick_list([Layer::Oa, Layer::Sa], Some(body.layer()), move |layer| + Message::FieldVariant("/body".into(), json!(ElementBody::shared_in(layer, shared.clone())))) + .text_size(12).padding(8).width(Fill), + text("Changing perspective keeps references; incompatible references must be updated before Apply.") + .size(10).color(style::MUTED), + ].spacing(5)); + } + for key in [ + "name", + "alias", + "description", + "parent", + "body", + "owner", + "relation", + "uri", + "revision", + "fragment", + "oa", + "sa", + "kind", + "baseline", + "layer", + "projection", + "element", + "diagram", + "position", + "size", + "collapsed", + "id", + ] { + if let Some(value) = draft.value.get(key) { + content = content.push(property( + draft, + project, + value, + &format!("/{key}"), + &friendly(key), + false, + )); + } + } + content.into() +} + +fn property<'a>( + draft: &'a Draft, + project: &'a Project, + value: &'a Value, + path: &str, + label: &str, + skip_variant: bool, +) -> View<'a, Message> { + let label_view = || text(label.to_owned()).size(11).color(style::MUTED); + let enum_choices = if skip_variant { + vec![] + } else { + variants(path, value, draft.kind) + }; + if !enum_choices.is_empty() { + let labels = enum_choices + .iter() + .map(|(name, _)| name.clone()) + .collect::>(); + let selected = enum_choices + .iter() + .find(|(_, v)| same_variant(v, value)) + .map(|(name, _)| name.clone()); + let owned_path = path.to_string(); + let choices = enum_choices.clone(); + let picker = pick_list(labels, selected, move |selected| { + Message::FieldVariant( + owned_path.clone(), + choices + .iter() + .find(|(name, _)| *name == selected) + .unwrap() + .1 + .clone(), + ) + }) + .text_size(12) + .padding(8) + .width(Fill); + let mut out = column![label_view(), picker].spacing(5); + if value.is_object() { + out = out.push(property(draft, project, value, path, "", true)); + } + return out.into(); + } + if reference(path) && (value.is_string() || value.is_null()) { + let raw = draft + .inputs + .get(path) + .map(|v| v.raw.as_str()) + .or_else(|| value.as_str()); + let current = raw.and_then(|s| s.parse::().ok()).unwrap_or(Id::nil()); + let choices = reference_choices(draft, project, path, current); + let selected = choices.iter().find(|c| c.id == current).cloned(); + let owned_path = path.to_string(); + let optional = value.is_null() + || matches!( + path.rsplit('/').next().unwrap_or_default(), + "parent" + | "interface" + | "context" + | "represented" + | "exchange" + | "sender" + | "receiver" + | "action" + | "sequence" + | "scenario" + | "reply_to" + | "lifeline" + | "state" + | "source_port" + | "target_port" + ); + return column![ + label_view(), + pick_list(choices, selected, move |choice| Message::FieldShape( + owned_path.clone(), + if choice.id.is_nil() && optional { + Value::Null + } else { + json!(choice.id) + } + )) + .text_size(12) + .padding(8) + .width(Fill) + ] + .spacing(5) + .into(); + } + match value { + Value::Object(map) => { + let mut out = column![].spacing(10); + if let Some(group) = group_label(path) { + out = out.push(text(group).size(12).color(style::INK)); + } + if path.ends_with("/baseline") { + out = out.push( + button(text("Clear review baseline").size(11)) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), Value::Null)), + ); + } + for (key, child) in map { + if key == "nodes" { + continue; + } + let child_path = format!("{path}/{key}"); + if unit_body(&child_path, child) { + continue; + } + out = out.push(property( + draft, + project, + child, + &child_path, + &friendly(key), + false, + )); + } + out.into() + } + Value::Array(items) => { + let owned_path = path.to_string(); + let mut out = column![ + row![ + label_view(), + widget::Space::new().width(Fill), + button(text("+ Add").size(11)) + .style(style::nav) + .on_press(Message::FieldArray(owned_path, None, 0)) + ] + .align_y(iced::Center) + ] + .spacing(6); + for (index, item) in items.iter().enumerate() { + out = out.push( + container( + column![ + property( + draft, + project, + item, + &format!("{path}/{index}"), + &format!("{} {}", label, index + 1), + false + ), + row![ + button(text("↑").size(12)) + .style(style::nav) + .on_press(Message::FieldArray(path.into(), Some(index), -1)), + button(text("↓").size(12)) + .style(style::nav) + .on_press(Message::FieldArray(path.into(), Some(index), 1)), + button(text("Remove").size(11)) + .style(style::nav) + .on_press(Message::FieldArray(path.into(), Some(index), 0)) + ] + .spacing(4) + ] + .spacing(3), + ) + .padding(8) + .style(style::subtle), + ); + } + out.into() + } + Value::Bool(checked) => { + let path = path.to_owned(); + checkbox(*checked) + .label(label.to_owned()) + .on_toggle(move |v| Message::FieldShape(path.clone(), json!(v))) + .text_size(12) + .into() + } + Value::Null => { + let initial = if path.ends_with("maximum") { + json!(1) + } else if path.ends_with("baseline") { + json!({"oa":{"name":"","description":""},"sa":{"name":"","description":""}}) + } else { + json!("") + }; + column![ + label_view(), + button( + text(if path.ends_with("maximum") { + "Unlimited · set maximum" + } else { + "Not set · add value" + }) + .size(12) + ) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), initial)) + ] + .spacing(4) + .into() + } + _ if unit_body(path, value) => text("").into(), + _ => { + let Some(input) = draft.inputs.get(path) else { + return text("").into(); + }; + let owned_path = path.to_string(); + let field: View<'_, Message> = if multiline(path) && !draft.exact_strings { + text_editor(&input.editor) + .on_action(move |a| Message::FieldText(owned_path.clone(), a)) + .size(12) + .height(100) + .padding(8) + .into() + } else { + text_input("", &input.raw) + .on_input(move |v| Message::Field(owned_path.clone(), v)) + .size(12) + .padding(8) + .into() + }; + let mut out = column![label_view(), field].spacing(5); + if matches!( + path.rsplit('/').next().unwrap_or_default(), + "revision" | "fragment" | "maximum" + ) { + out = out.push( + button(text("Clear optional value").size(10)) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), Value::Null)), + ); + } + out.into() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(draft: &Draft, project: &Project, path: &str) -> Vec { + reference_ids(draft, project, path) + } + + #[test] + fn editing_allocation_offers_other_performers_without_counting_itself_twice() { + let mut p = Project::empty(); + let first = p.add(Kind::Entity, "First team", 0., 0.); + let second = p.add(Kind::Entity, "Second team", 300., 0.); + let work = p.add(Kind::Activity, "Coordinate", 600., 0.); + let allocation = p.relate(RelationKind::Allocation, "Performs", first, work); + let draft = Draft::from_project(&p, allocation).unwrap(); + let choices = ids(&draft, &p, "/body/Allocation/source"); + assert!(choices.contains(&first)); + assert!(choices.contains(&second)); + assert!(!choices.contains(&work)); + assert!(!choices.contains(&p.id)); + } + + #[test] + fn data_and_port_choices_are_typed_but_retain_invalid_current_values() { + let mut p = Project::empty(); + let function = p + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let receiver = p + .add_element(Kind::Function, Layer::Sa, "Receive", None) + .unwrap(); + let item = p + .add_element(Kind::ExchangeItem, Layer::Sa, "Message", None) + .unwrap(); + let wrong_layer = p + .add_element(Kind::ExchangeItem, Layer::Oa, "Operational item", None) + .unwrap(); + let interface = p + .add_element(Kind::Interface, Layer::Sa, "Contract", None) + .unwrap(); + let output = p + .add_element(Kind::Port, Layer::Sa, "Output", Some(function)) + .unwrap(); + let input = p + .add_element(Kind::Port, Layer::Sa, "Input", Some(receiver)) + .unwrap(); + let exchange = p.relate( + RelationKind::FunctionalExchange, + "Message", + function, + receiver, + ); + let draft = Draft::from_project(&p, exchange).unwrap(); + let choices = ids(&draft, &p, "/body/FunctionalExchange/items/0"); + assert_eq!(choices, vec![item]); + assert!(!choices.contains(&wrong_layer)); + assert_eq!( + ids(&draft, &p, "/body/FunctionalExchange/source_port"), + vec![output] + ); + assert_eq!( + ids(&draft, &p, "/body/FunctionalExchange/target_port"), + vec![input] + ); + let retained = + reference_choices(&draft, &p, "/body/FunctionalExchange/items/0", wrong_layer); + assert!(retained.iter().any(|c| c.id == wrong_layer)); + let missing = Id::new_v4(); + assert!( + reference_choices(&draft, &p, "/body/FunctionalExchange/items/0", missing) + .iter() + .any(|c| c.id == missing && c.label.starts_with("Missing reference")) + ); + let port = Draft::from_project(&p, output).unwrap(); + assert_eq!( + ids(&port, &p, "/body/Sa/Shared/Port/interface"), + vec![interface] + ); + } + + #[test] + fn scenario_choices_respect_owner_and_exchange_layer() { + let mut p = Project::empty(); + let scenario = p + .add_element(Kind::Scenario, Layer::Sa, "Main scenario", None) + .unwrap(); + let other = p + .add_element(Kind::Scenario, Layer::Sa, "Other scenario", None) + .unwrap(); + let line = p + .add_element(Kind::Lifeline, Layer::Sa, "Participant", Some(scenario)) + .unwrap(); + p.add_element(Kind::Lifeline, Layer::Sa, "Other participant", Some(other)) + .unwrap(); + let occurrence = p + .add_element(Kind::Occurrence, Layer::Sa, "Message", Some(scenario)) + .unwrap(); + let wrong_owner = p + .add_element(Kind::Occurrence, Layer::Sa, "Other message", Some(other)) + .unwrap(); + let draft = Draft::from_project(&p, occurrence).unwrap(); + assert_eq!( + ids( + &draft, + &p, + "/body/Sa/Shared/Occurrence/body/Exchange/sender" + ), + vec![line] + ); + let scenario_draft = Draft::from_project(&p, scenario).unwrap(); + assert_eq!( + ids(&scenario_draft, &p, "/body/Sa/Shared/Scenario/steps/0"), + vec![occurrence] + ); + assert!( + !ids(&scenario_draft, &p, "/body/Sa/Shared/Scenario/steps/0").contains(&wrong_owner) + ); + } + + #[test] + fn reply_and_reuse_forms_offer_semantically_compatible_references() { + let mut p = Project::empty(); + let send = p + .add_element(Kind::Activity, Layer::Oa, "Request", None) + .unwrap(); + let receive = p + .add_element(Kind::Activity, Layer::Oa, "Respond", None) + .unwrap(); + let exchange = p.relate(RelationKind::Interaction, "Request data", send, receive); + let item = p + .add_element(Kind::ExchangeItem, Layer::Oa, "Request payload", None) + .unwrap(); + let field = p + .add_element(Kind::DataField, Layer::Oa, "Request number", Some(item)) + .unwrap(); + if let RelationBody::Interaction { items, .. } = + &mut p.relations.get_mut(&exchange).unwrap().body + { + items.push(item); + } + let scenario = p + .add_element(Kind::Scenario, Layer::Oa, "Request flow", None) + .unwrap(); + let reused = p + .add_element(Kind::Scenario, Layer::Oa, "Authentication", None) + .unwrap(); + let caller = p + .add_element(Kind::Lifeline, Layer::Oa, "Caller", Some(scenario)) + .unwrap(); + let callee = p + .add_element(Kind::Lifeline, Layer::Oa, "Callee", Some(scenario)) + .unwrap(); + let ref_line = p + .add_element(Kind::Lifeline, Layer::Oa, "Reused caller", Some(reused)) + .unwrap(); + for (id, represented) in [(caller, send), (callee, receive), (ref_line, send)] { + p.elements.get_mut(&id).unwrap().body = ElementBody::shared_in( + Layer::Oa, + SharedBody::Lifeline { + represented: Some(represented), + }, + ); + } + let call = p + .add_element(Kind::Occurrence, Layer::Oa, "Call", Some(scenario)) + .unwrap(); + let reply = p + .add_element(Kind::Occurrence, Layer::Oa, "Reply", Some(scenario)) + .unwrap(); + for (id, sender, receiver, message_kind, reply_to) in [ + (call, caller, callee, MessageKind::Call, None), + (reply, callee, caller, MessageKind::Reply, Some(call)), + ] { + p.elements.get_mut(&id).unwrap().body = ElementBody::shared_in( + Layer::Oa, + SharedBody::Occurrence { + precondition: String::new(), + postcondition: String::new(), + body: OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: Some(sender), + receiver: Some(receiver), + message_kind, + reply_to, + values: vec![], + }, + }, + ); + } + let draft = Draft::from_project(&p, reply).unwrap(); + let prefix = "/body/Oa/Shared/Occurrence/body/Exchange"; + assert_eq!(ids(&draft, &p, &format!("{prefix}/sender")), vec![callee]); + assert_eq!(ids(&draft, &p, &format!("{prefix}/receiver")), vec![caller]); + assert_eq!(ids(&draft, &p, &format!("{prefix}/reply_to")), vec![call]); + assert_eq!( + ids(&draft, &p, &format!("{prefix}/values/0/field")), + vec![field] + ); + let use_id = p + .add_element( + Kind::Occurrence, + Layer::Oa, + "Reuse authentication", + Some(scenario), + ) + .unwrap(); + *p.elements.get_mut(&scenario).unwrap().steps_mut().unwrap() = vec![call, reply, use_id]; + let mut draft = Draft::from_project(&p, use_id).unwrap(); + draft.shape("/body/Oa/Shared/Occurrence/body", json!({"ScenarioUse":{"scenario":reused,"bindings":[{"referenced":ref_line,"local":caller}]}})).unwrap(); + let prefix = "/body/Oa/Shared/Occurrence/body/ScenarioUse"; + assert_eq!(ids(&draft, &p, &format!("{prefix}/scenario")), vec![reused]); + assert_eq!( + ids(&draft, &p, &format!("{prefix}/bindings/0/referenced")), + vec![ref_line] + ); + assert_eq!( + ids(&draft, &p, &format!("{prefix}/bindings/0/local")), + vec![caller] + ); + let mut session = Session::new(p.clone()); + session.execute(draft.command().unwrap()).unwrap(); + assert_eq!( + language::parse(&language::format(session.project())).unwrap(), + *session.project() + ); + session.undo(); + assert_eq!(*session.project(), p); + } + + #[test] + fn parent_choices_reject_cycles_without_removing_clear_or_current_choice() { + let mut p = Project::empty(); + let parent = p + .add_element(Kind::Activity, Layer::Oa, "Parent", None) + .unwrap(); + let child = p + .add_element(Kind::Activity, Layer::Oa, "Child", Some(parent)) + .unwrap(); + let draft = Draft::from_project(&p, parent).unwrap(); + assert!(!ids(&draft, &p, "/parent").contains(&child)); + assert!(!ids(&draft, &p, "/parent").contains(&parent)); + let selected = reference_choices(&draft, &p, "/parent", child); + assert!(selected.iter().any(|choice| choice.id == child)); + assert!(selected.iter().any(|choice| choice.id.is_nil())); + let machine = p + .add_element(Kind::StateMachine, Layer::Oa, "Modes", None) + .unwrap(); + let machine_draft = Draft::from_project(&p, machine).unwrap(); + assert!( + reference_choices(&machine_draft, &p, "/parent", Id::nil()) + .iter() + .any(|c| c.id.is_nil()) + ); + assert!(unit_body("/body/Oa/Shared", &json!("StateMachine"))); + assert!(unit_body("/body/Sa/Shared", &json!("Region"))); + assert!(!unit_body( + "/body/Sa/Shared/DataType/definition", + &json!("Record") + )); + } + + #[test] + fn state_observation_picker_keeps_work_lifelines_allocated_to_machine_owner() { + let mut p = Project::empty(); + let system = p + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let work = p + .add_element(Kind::Function, Layer::Sa, "Monitor", None) + .unwrap(); + p.relate(RelationKind::Allocation, "Performs", system, work); + let machine = p + .add_element( + Kind::StateMachine, + Layer::Sa, + "Operating modes", + Some(system), + ) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Sa, "Main", Some(machine)) + .unwrap(); + let state = p + .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) + .unwrap(); + let scenario = p + .add_element(Kind::Scenario, Layer::Sa, "Monitoring", None) + .unwrap(); + let line = p + .add_element(Kind::Lifeline, Layer::Sa, "Monitor", Some(scenario)) + .unwrap(); + p.elements.get_mut(&line).unwrap().body = ElementBody::shared_in( + Layer::Sa, + SharedBody::Lifeline { + represented: Some(work), + }, + ); + let occurrence = p + .add_element(Kind::Occurrence, Layer::Sa, "Ready check", Some(scenario)) + .unwrap(); + p.elements.get_mut(&occurrence).unwrap().body = ElementBody::shared_in( + Layer::Sa, + SharedBody::Occurrence { + precondition: String::new(), + postcondition: String::new(), + body: OccurrenceBody::State { + state: Some(state), + lifeline: Some(line), + }, + }, + ); + let draft = Draft::from_project(&p, occurrence).unwrap(); + assert_eq!( + ids(&draft, &p, "/body/Sa/Shared/Occurrence/body/State/lifeline"), + vec![line] + ); + assert!(execute_batch(&p, &[draft.command().unwrap()]).is_ok()); + } + + #[test] + fn moving_placement_and_changing_identity_is_one_lossless_undoable_edit() { + let mut p = Project::empty(); + let work = p.add(Kind::Activity, "Observe", 120., 240.); + let original_diagram = p.diagrams.values().next().unwrap().id; + let original_node = p.diagrams[&original_diagram] + .nodes + .values() + .next() + .unwrap() + .id; + let target = Id::new_v4(); + p.diagrams.insert( + target, + Diagram { + id: target, + alias: "second_view".into(), + name: "Second view".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Overview, + nodes: BTreeMap::new(), + }, + ); + let mut draft = Draft::from_project(&p, original_node).unwrap(); + assert!(!draft.dirty()); + let replacement = Id::new_v4(); + draft.input("/id", replacement.to_string()); + draft.shape("/diagram", json!(target)).unwrap(); + let mut session = Session::new(p.clone()); + session.execute(draft.command().unwrap()).unwrap(); + assert!( + session.project().diagrams[&original_diagram] + .nodes + .is_empty() + ); + let node = &session.project().diagrams[&target].nodes[&replacement]; + assert_eq!(node.element, work); + assert_eq!(node.position, Position { x: 120., y: 240. }); + assert!(!json!(node).as_object().unwrap().contains_key("diagram")); + session.undo(); + assert_eq!(*session.project(), p); + } + + #[test] + fn shared_perspective_change_keeps_payload_and_rejects_incompatible_dependencies() { + let mut p = Project::empty(); + let item = p + .add_element(Kind::ExchangeItem, Layer::Oa, "Power", None) + .unwrap(); + p.elements.get_mut(&item).unwrap().body = ElementBody::shared_in( + Layer::Oa, + SharedBody::ExchangeItem { + kind: ExchangeItemKind::Energy, + }, + ); + let mut draft = Draft::from_project(&p, item).unwrap(); + let shared = p.elements[&item].shared().unwrap().clone(); + draft + .shape( + "/body", + json!(ElementBody::shared_in(Layer::Sa, shared.clone())), + ) + .unwrap(); + let moved = execute_batch(&p, &[draft.command().unwrap()]).unwrap(); + assert_eq!(moved.elements[&item].layer(), Layer::Sa); + assert_eq!(moved.elements[&item].shared(), Some(&shared)); + let interface = p + .add_element(Kind::Interface, Layer::Oa, "Power contract", None) + .unwrap(); + p.elements.get_mut(&interface).unwrap().body = + ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] }); + assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err()); + assert!(draft.dirty()); + assert_eq!(p.elements[&item].layer(), Layer::Oa); + } +} diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs new file mode 100644 index 00000000..ee654280 --- /dev/null +++ b/crates/oa-desktop/src/guide.rs @@ -0,0 +1,956 @@ +//! Offline engineering help. Topics describe the shared model and real UI controls. +//! +//! This module owns only reading/navigation state. Application navigation and demo +//! loading use normal App messages, so help never bypasses the model's draft guards. +use crate::{ + app::{Message, Page, Pending}, + style, +}; +use iced::{ + Element, Fill, Font, + widget::{button, column, container, keyed_column, row, scrollable, text, text_input}, +}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Topic { + #[default] + FirstProject, + Perspectives, + Participants, + Nesting, + Capabilities, + Exchanges, + Chains, + Scenarios, + States, + Interfaces, + Transitions, + Artifacts, + Source, + Recovery, + Files, + Server, +} + +#[derive(Debug, Clone, Default)] +pub struct State { + pub query: String, + pub topic: Topic, +} + +#[derive(Debug, Clone)] +pub enum Event { + Search(String), + Topic(Topic), +} + +impl State { + pub fn update(&mut self, event: Event) { + match event { + Event::Search(query) => { + self.query = query; + let matches = search(&self.query); + if !matches.contains(&self.topic) + && let Some(first) = matches.first() + { + self.topic = *first; + } + } + Event::Topic(topic) => { + self.topic = topic; + // Related/contextual links always reveal the requested topic. + self.query.clear(); + } + } + } +} + +impl Topic { + const ALL: [Self; 16] = [ + Self::FirstProject, + Self::Perspectives, + Self::Participants, + Self::Nesting, + Self::Capabilities, + Self::Exchanges, + Self::Chains, + Self::Scenarios, + Self::States, + Self::Interfaces, + Self::Transitions, + Self::Artifacts, + Self::Source, + Self::Recovery, + Self::Files, + Self::Server, + ]; + + pub fn title(self) -> &'static str { + match self { + Self::FirstProject => "Build your first model", + Self::Perspectives => "Operational & System Analysis", + Self::Participants => "Participants, roles & work", + Self::Nesting => "Ownership, allocation & views", + Self::Capabilities => "Missions & capabilities", + Self::Exchanges => "Exchanges & dataflow", + Self::Chains => "Processes & functional chains", + Self::Scenarios => "Scenarios & alternatives", + Self::States => "States, modes & transitions", + Self::Interfaces => "Data types, ports & interfaces", + Self::Transitions => "Move between OA & SA", + Self::Artifacts => "Connect engineering evidence", + Self::Source => "Textual editing & Git", + Self::Recovery => "Checks & error recovery", + Self::Files => "Files & keyboard shortcuts", + Self::Server => "REST API & automation", + } + } + + fn category(self) -> &'static str { + match self { + Self::FirstProject | Self::Perspectives => "START HERE", + Self::Participants | Self::Nesting | Self::Capabilities | Self::Exchanges => { + "MODEL THE SYSTEM" + } + Self::Chains | Self::Scenarios | Self::States | Self::Interfaces => "DESCRIBE BEHAVIOR", + Self::Transitions | Self::Artifacts | Self::Source => "CONNECT YOUR WORK", + Self::Recovery | Self::Files | Self::Server => "REFERENCE", + } + } +} + +struct Article { + summary: &'static str, + keywords: &'static str, + sections: &'static [Section], + related: &'static [Topic], + destination: Option<(Page, &'static str)>, +} + +struct Section { + title: &'static str, + body: &'static str, + steps: &'static [&'static str], + code: Option<&'static str>, +} + +const fn explain(title: &'static str, body: &'static str) -> Section { + Section { + title, + body, + steps: &[], + code: None, + } +} + +const fn walkthrough(title: &'static str, steps: &'static [&'static str]) -> Section { + Section { + title, + body: "", + steps, + code: None, + } +} + +const fn example(title: &'static str, body: &'static str, code: &'static str) -> Section { + Section { + title, + body, + steps: &[], + code: Some(code), + } +} + +fn article(topic: Topic) -> Article { + match topic { + Topic::FirstProject => { + const { + Article { + summary: "Create a small operational model, then derive the system contribution. Use concrete names for work and keep your first diagram focused.", + keywords: "getting started tutorial beginner quick start new example rescue coordinator allocation", + sections: &[ + walkthrough( + "Create an operational context", + &[ + "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. The initial Allocation projection 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.", + "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 project 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.", + ), + explain( + "Explore a worked model", + "Open the coastal rescue example to inspect allocation, decomposition, repeated scenario messages, operating modes, typed interfaces and artifact references. Loading the example uses the normal unsaved-work prompt. It replaces the current project only after that workflow completes.", + ), + ], + related: &[ + Topic::Perspectives, + Topic::Participants, + Topic::Nesting, + Topic::Transitions, + ], + destination: Some((Page::Diagram, "Open Architecture")), + } + } + } + Topic::Perspectives => { + const { + Article { + summary: "Operational Analysis asks what people and organizations need to accomplish. System Analysis asks what the system must do within that operation.", + keywords: "oa sa arcadia capella system architecture operational need boundary abstraction actor system of interest", + sections: &[ + explain( + "Operational Analysis: understand the need", + "Describe entities and actors, their activities, roles, interactions and operational capabilities without assuming a particular technical solution. An activity such as Assess incident states work in the operation; it does not imply which software module implements it.", + ), + explain( + "System Analysis: define the contribution", + "Describe one System under study, external System actors, Functions, functional exchanges, missions and system capabilities. A participant's operational activity may be supported by several system functions or may remain outside the system boundary. Review that engineering decision explicitly.", + ), + 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.", + ), + explain( + "Keep the boundary explicit", + "Separate work performed by the System from work performed by external actors. Allocate leaf functions to the responsible participant. Logical and Physical Architecture belong to later design layers; a system-level function is not a software class or a physical component.", + ), + ], + related: &[Topic::FirstProject, Topic::Capabilities, Topic::Transitions], + destination: Some((Page::Catalog, "Open Model explorer")), + } + } + } + Topic::Participants => { + const { + Article { + summary: "Represent who participates separately from the work they perform. Decomposition and responsibility answer different engineering questions.", + keywords: "entity actor participant role realization assignment leaf activity function performer decomposition responsibility", + sections: &[ + walkthrough( + "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.", + "Open the Allocation projection to review responsibilities. Open Decomposition to review the activity/function hierarchy independently.", + ], + ), + explain( + "Operational roles", + "A Role describes a responsibility independently of a particular entity. Role realization links an Entity or Actor to a Role. Role assignment links a Role to an Activity. These relations express role responsibilities; an Allocation identifies the concrete performer of leaf work.", + ), + explain( + "Name work as an action", + "Prefer Assess incident or Deliver electrical power for work, and Rescue coordinator or Power controller for participants. A meaningful description records assumptions, input/output expectations and the decision behind an allocation.", + ), + explain( + "Creation choices are constrained", + "+ In selected is enabled only for a legal semantic parent. + Allocated appears when the selected participant can perform the chosen work. Pickers show compatible endpoints. If a control is unavailable, check the active perspective, selected owner and element kind.", + ), + ], + related: &[Topic::Nesting, Topic::Exchanges, Topic::Capabilities], + destination: Some((Page::Catalog, "Open Model explorer")), + } + } + } + Topic::Nesting => { + const { + Article { + summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.", + keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize", + sections: &[ + explain( + "Three things to distinguish", + "Semantic parent records ownership: an activity contains subactivities, or a scenario owns occurrences. Allocation records who performs leaf work. A placement records where one element appears in a diagram. One element can have placements in several views without becoming several semantic elements.", + ), + walkthrough( + "Choose and edit the relationship you mean", + &[ + "Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective and projection.", + "Choose Decomposition to nest semantic children, or Allocation to nest work under its performer. Overview keeps a broader view; OA → SA displays counterpart traces.", + "Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.", + "Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.", + "Select a diagram node to access Placement, Fold / unfold and Hide. Hide removes its view placement; Delete from model removes model records and requires review of the affected references.", + ], + ), + explain( + "Keep diagrams readable", + "Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.", + ), + explain( + "What nesting does not imply", + "Containment in an allocation projection expresses a performer relationship. It is not a physical deployment statement. Physical deployment is a different architectural concern; do not infer hardware installation from an OA/SA box layout.", + ), + ], + related: &[Topic::Participants, Topic::Perspectives, Topic::Artifacts], + destination: Some((Page::Diagram, "Open Architecture")), + } + } + } + Topic::Capabilities => { + const { + Article { + summary: "Capabilities describe an ability to achieve an outcome. Missions explain the purpose served by those abilities. Both Operational and System Analysis have missions, capabilities and participants; activities and functions describe supporting work.", + keywords: "capability mission outcome operational capability system capability involvement contribution trace coverage use case", + sections: &[ + walkthrough( + "Connect outcomes to the work", + &[ + "Create an OA Capability such as Protect life at sea. Describe the operational outcome and the conditions in which it matters.", + "Select a relevant participant, role or activity. Use + Relationship → Capability involvement and choose the capability as the target.", + "Create an Operational mission in OA, or a Mission in SA. Select it and choose Exploit capability to connect it to a capability in the same perspective. Involve participant associates an entity or actor with the mission.", + "Connect System participants/functions to the capability using Capability involvement. Connect a Process or Functional chain to a capability using Chain involvement.", + "Review OA/SA counterpart links in Traceability and read Model checks for incomplete coverage. A named capability alone does not demonstrate that the necessary behavior exists.", + ], + ), + walkthrough( + "Include, extend or specialize a capability", + &[ + "Select a capability in the diagram or explorer, then open Modeling actions at the top of its properties. Include capability identifies behavior that is always part of it; choose the included capability as the target.", + "Extend capability connects this extending capability to the extended capability. Enter the condition under which the extension adds behavior. An empty condition remains editable and appears in Model checks.", + "Specialize capability connects a specific capability to a more general one. Inclusion and specialization cannot form cycles.", + "The relationship preview names both ends and generates a readable name. Apply adds one undoable change; Cancel preserves the model. Include and extend labels remain visible in the diagram.", + ], + ), + explain( + "Use scenarios as concrete evidence", + "A scenario illustrates one path through the behavior: normal response, degraded operation or an exception. Keep its intended capability and assumptions clear in its engineering description and available model relationships. Scenarios complement the capability definition; they do not replace the outcome.", + ), + explain( + "Avoid implementation names at this stage", + "Protect life at sea is a capability. Coordinate rescue is work. Send a JSON request is usually a design choice. Start with the engineering obligation and add technical detail when the perspective calls for it.", + ), + ], + related: &[ + Topic::Perspectives, + Topic::Chains, + Topic::Scenarios, + Topic::Transitions, + ], + destination: Some((Page::Catalog, "Open Model explorer")), + } + } + } + Topic::Exchanges => { + const { + Article { + summary: "An exchange says what passes between work or across a participant boundary. Its payload, endpoints and allocation make that statement precise.", + keywords: "interaction functional exchange boundary exchange flow information material energy event source target port exchange allocation", + sections: &[ + walkthrough( + "Create a typed exchange", + &[ + "Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.", + "Select the source, choose + Relationship, then Interaction for OA activities or Functional exchange for SA functions. Choose the compatible target and apply the relationship.", + "Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.", + "Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.", + "For a participant boundary, create compatible ports and a Boundary exchange. Exchange allocation associates that boundary exchange with the functional exchange it carries.", + ], + ), + explain( + "Flow and sequence are different", + "A dataflow graph shows possible dependencies between activities/functions. It does not by itself impose an execution order. Use a Process, Functional chain or Scenario when you need to describe an ordered path, repeated exchanges or alternatives.", + ), + explain( + "Use engineering payloads", + "Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.", + ), + ], + related: &[ + Topic::Interfaces, + Topic::Chains, + Topic::Scenarios, + Topic::Artifacts, + ], + destination: Some((Page::Catalog, "Open Model explorer")), + } + } + } + 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", + 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. Use + Work or + Exchange to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.", + "Select a contribution, then add another after it or use Connect to wire existing contributions. Branch / parallel creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.", + "Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.", + ], + ), + explain( + "Choose the right behavioral description", + "A chain concentrates on a route through existing exchanges. A scenario can also carry actions, state observations, lifelines and nested fragments. The architecture diagram remains a view of the possible relationships rather than an execution trace.", + ), + 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. Imported ordered exchange paths retain their meaning; the first topology edit explains and preserves their implied links in the same transaction.", + ), + ], + related: &[Topic::Exchanges, Topic::Capabilities, Topic::Scenarios], + destination: Some((Page::Behavior, "Open Behavior & scenarios")), + } + } + } + Topic::Scenarios => { + 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", + sections: &[ + walkthrough( + "Create a scenario", + &[ + "Open Behavior & scenarios, choose Scenario under Create element and add it at a legal root/owner. Select the scenario in the behavior selector.", + "Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.", + "Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.", + "Use Action, Observe state or Fragment for other behavior. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.", + "Use the ordered list's up/down controls to change sequence order. Click a message or list entry to inspect that occurrence rather than the reusable exchange definition.", + ], + ), + walkthrough( + "Describe alternatives", + &[ + "Choose Fragment and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.", + "Use the Destination selector in subsequent message or action forms to choose the intended operand. New occurrences append to that operand's ordered behavior.", + "Use Reuse scenario to refer to existing behavior. Bind every referenced lifeline to a compatible local lifeline explicitly. Open source follows the original scenario without copying its contents.", + "Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.", + ], + ), + 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.", + ), + ], + related: &[ + Topic::Chains, + Topic::States, + Topic::Exchanges, + Topic::Artifacts, + ], + destination: Some((Page::Behavior, "Open Behavior & scenarios")), + } + } + } + Topic::States => { + const { + Article { + summary: "Describe the conditions in which the operation or system behaves differently. States and modes belong to regions within a state machine.", + keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal", + sections: &[ + walkthrough( + "Build a state machine", + &[ + "In Behavior & scenarios, create a State machine and select it. Use + Region to give its state structure an owner.", + "Use + State for conditions such as Nominal and Degraded, and + Initial for the starting pseudostate. Select a region and use Create element for other supported state kinds.", + "Select a source state and choose + Relationship → State transition. Pick a compatible target in the same state machine.", + "Edit the transition's Trigger, Guard and Effect in Properties. These distinguish what initiates the transition, what must be true, and what follows.", + "Review the rendered state view and numbered transition list. Model checks calls out unfinished structures so they can be corrected during authoring.", + ], + ), + explain( + "Availability and state actions", + "Availability links a participant or behavior to a State/Mode in which it is available. State action links a State/Mode to an Activity/Function and records its entry, do or exit phase. Use these typed relationships when work depends on operating conditions.", + ), + explain( + "Keep conditions testable", + "Prefer a guard such as battery_level < reserve_threshold over a vague phrase such as low battery, and define the quantities elsewhere in the model or linked evidence. A state diagram documents the intended behavior; it is not a running state-machine simulator.", + ), + ], + related: &[ + Topic::Scenarios, + Topic::Participants, + Topic::Recovery, + Topic::Artifacts, + ], + destination: Some((Page::Behavior, "Open Behavior & scenarios")), + } + } + } + Topic::Interfaces => { + const { + Article { + summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.", + keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum", + sections: &[ + walkthrough( + "Define a reusable payload", + &[ + "Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.", + "Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.", + "For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.", + "Select the exchange and reference the payload items. Apply changes and check the result in Model checks.", + ], + ), + walkthrough( + "Connect participant boundaries", + &[ + "Create an Interface that names the contract, then ports under the participants or work that owns them. Set each port's direction and compatible contract references.", + "Use Interface provision and Interface requirement relationships to state who provides or needs the contract.", + "Create a Boundary exchange from an output-capable port to an input-capable port. Connect carried functional exchanges using Exchange allocation.", + "Link the detailed interface-control document, CAD connector or schematic through an Artifact reference when an external artifact supplies additional evidence.", + ], + ), + explain( + "Preserve meaning across perspectives", + "Shared kinds such as Data type and Interface still belong to an engineering perspective. Their Engineering perspective property is explicit. Use counterpart traces for separate OA and SA definitions that correspond; do not assume that similar names create a trace.", + ), + ], + related: &[Topic::Exchanges, Topic::Artifacts, Topic::Transitions], + destination: Some((Page::Data, "Open Data & interfaces")), + } + } + } + Topic::Transitions => { + const { + Article { + summary: "Create or reuse counterpart records across OA and SA through a reviewable preview. Both perspectives keep their identities and remain linked.", + keywords: "transition counterpart bidirectional mapping realization drift conflict merge keep target take source baseline scope dependency reverse update trace reuse", + sections: &[ + walkthrough( + "Review a counterpart transition", + &[ + "Select an operational element and choose Create / update counterpart. Selecting a system element starts the reverse System → Operational workflow.", + "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.", + "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", + "Traceability marks descriptive links Reviewed or Review needed by comparing both endpoints with their last review baseline. Independent edits may be intentional. Keeping a target value records that reviewed decision; it does not delete the operational need. A manual trace records correspondence without propagating properties.", + ), + explain( + "Know what reconciliation changes", + "The current counterpart workflow reviews names and descriptions of existing targets. It can create structural dependencies for new counterparts. Reparenting, reallocating, removing or restructuring an existing counterpart remains an explicit engineering edit; do not assume Refresh preview automatically synchronizes every structural change.", + ), + explain( + "Avoid duplicate counterparts", + "Check an existing trace before requesting a new counterpart. Several engineering records can correspond, but each mapping should have a reason. Reverse transitions also use the trace to reuse the intended record rather than silently replacing the original.", + ), + ], + related: &[ + Topic::Perspectives, + Topic::Participants, + Topic::Capabilities, + Topic::Recovery, + ], + destination: Some((Page::Traces, "Open Traceability")), + } + } + } + Topic::Artifacts => { + const { + Article { + summary: "Connect model decisions to external engineering evidence using stable references to code, CAD, schematics, documents and other artifacts.", + keywords: "artifact traceability evidence code cad schematic electrical docx requirements revision fragment uri git commit owner placement document", + sections: &[ + walkthrough( + "Link an artifact", + &[ + "Select the model record the evidence concerns. This may be an element, relationship, diagram, placement or other supported persistent record.", + "Choose + Artifact reference in Properties, or + Artifact in Traceability. Name the reference so another engineer understands its purpose.", + "Enter the Artifact URI, optional external Revision and Fragment. Use a commit or document revision when a reproducible reference matters; the fragment identifies a particular part of the artifact.", + "Add a description explaining whether the artifact specifies, implements or verifies the engineering decision. Apply changes, then inspect the owner and reference in Traceability.", + ], + ), + 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.", + ), + explain( + "Choose the right owner", + "Link semantic evidence to the element or relationship it supports. Link view-specific evidence to a diagram or placement. A diagram node's placement identity remains distinct from its represented element, so the reference can express the intended scope.", + ), + explain( + "Automation uses the same records", + "A CAD or version-control plugin can query and edit artifact links through the REST API. Its update should preserve stable model IDs and use the server's conditional write contract to avoid overwriting another engineer's work.", + ), + ], + related: &[Topic::Source, Topic::Server, Topic::Nesting], + destination: Some((Page::Traces, "Open Traceability")), + } + } + } + Topic::Source => { + const { + Article { + summary: "The text editor and graphical forms edit the same model. Canonical Arcadia source is intended for human review, automation and version control.", + keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity", + sections: &[ + walkthrough( + "Edit and review source", + &[ + "Open Textual model for the full canonical source, or choose Source in Properties to locate the selected record.", + "Use Find / go to, Ctrl+F or Ctrl+G to navigate. Literal search is case sensitive. Enter/F3 selects the next match, Shift+F3 selects the previous match, and results wrap. Go to line accepts the displayed one-based line number.", + "Edit the draft. Syntax highlighting distinguishes source structure; live lint reports errors with line and column information.", + "Choose Apply to model to parse and validate the complete candidate. A successful application is one model change; graphical views and Properties then reflect it.", + "If parsing or validation fails, the accepted model remains unchanged and the source draft is preserved. Correct the reported location or use Revert draft to restore the accepted source.", + "Save as .arcadia for canonical UTF-8 text. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.", + ], + ), + explain( + "Names, aliases and IDs serve different purposes", + "The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.", + ), + example( + "Readable references", + "For example, renaming an activity does not require renaming @assess_incident. A related record can still refer to that alias.", + "source @receive_distress;\ntarget @assess_incident;", + ), + explain( + "Use one contract", + "The normative specification is docs/language.rst in the distribution. It defines the source grammar and supported graphical semantics together. Text can preserve semantic records, diagram placements, ordered occurrences, traces and artifact references; it is not merely an export of visible boxes. Applying source normalizes formatting and removes comments; put durable engineering rationale in model descriptions.", + ), + explain( + "Resolve source-control conflicts before applying", + "Ordinary Git conflict markers are not Arcadia syntax. Resolve the text merge, preserve intended UUIDs/references, then apply and inspect Model checks. The editor does not decide which engineer's model change is correct.", + ), + ], + related: &[ + Topic::Recovery, + Topic::Files, + Topic::Artifacts, + Topic::Server, + ], + destination: Some((Page::Text, "Open Textual model")), + } + } + } + Topic::Recovery => { + const { + Article { + summary: "Structural errors protect the accepted model. Warnings keep incomplete engineering work visible without preventing you from developing it incrementally.", + keywords: "validation checks error warning invalid alias missing reference undo redo revert recover draft unsaved structural external conflict lint", + sections: &[ + walkthrough( + "Resolve a model issue", + &[ + "Open Model checks and read the issue's message and severity. Use its Open action, where available, to inspect the affected record.", + "Correct its properties or missing relationships. Check the active perspective and compatible parent/endpoints before changing record kinds.", + "Apply changes and revisit Model checks. A warning about missing allocation or coverage may describe unfinished work; a rejected structural error must be corrected before that candidate can become the model.", + ], + ), + explain( + "A property draft is not yet the model", + "Apply changes validates the candidate. When it is rejected, correct the retained draft or use Revert. The canvas continues to show accepted values. Navigation that needs to apply an invalid draft may remain on the current record so your work is not lost. You can read this guide without applying or discarding that draft.", + ), + explain( + "Aliases and references", + "Stable source aliases must follow the language's identifier rules and avoid reserved words. Use a simple descriptive identifier such as assess_incident. Spaces belong in the displayed Name, not the alias. Reference pickers offer compatible model records; creating the required target first is often the missing step.", + ), + explain( + "Undo and recover deliberately", + "Ctrl+Z and redo restore accepted model changes. While the source editor is active, they operate on the source draft instead. Revert restores the accepted source or properties. If a file changed externally, save refuses a blind overwrite: preserve your work with Save as, then compare the files.", + ), + explain( + "Save work before leaving", + "New, Open, loading the example and Close use the unsaved-work prompt. Save and continue, discard or cancel is your decision. There is no autosave recovery journal in the current desktop; save meaningful milestones and use version control or backups.", + ), + ], + related: &[Topic::Source, Topic::Files, Topic::Transitions], + destination: Some((Page::Validation, "Open Model checks")), + } + } + } + Topic::Files => { + const { + Article { + summary: "Keep editable model files and exported pictures distinct. Save the model to retain its semantics, identities and traceability.", + keywords: "file open save save as keyboard shortcuts ctrl control shift export svg json arcadia backup external changes migration", + sections: &[ + explain( + "Editable formats", + ".arcadia is canonical text suitable for Git. .json is the typed model exchange format. Both preserve the supported persistent model, diagrams and traceability. Export SVG creates a diagram image for reports; it is not an editable model backup.", + ), + example( + "Keyboard reference", + "Shortcuts act on the current project. In Textual model, undo and redo affect the source draft.", + "Ctrl+N New project\nCtrl+O Open model\nCtrl+S Save project\nCtrl+Shift+S Save as\nCtrl+Z Undo\nCtrl+Shift+Z Redo\nCtrl+Y Redo\nEscape Cancel the active dialog", + ), + walkthrough( + "Save and share a model", + &[ + "Use Save project to apply valid drafts and save to the current path. For a new project, select a destination and format in the file dialog.", + "Use Save as for a new path or explicit format conversion. Check the filename extension when selecting text or JSON.", + "After saving, check All changes saved. Share the model file with its necessary external evidence locations, or commit it to version control.", + "If another tool changed or deleted the open file, preserve your candidate using a new Save as destination. Compare and reconcile the versions before replacing either one.", + ], + ), + explain( + "Open and migration", + "Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.", + ), + ], + related: &[Topic::Source, Topic::Recovery, Topic::Artifacts], + destination: None, + } + } + } + Topic::Server => { + const { + Article { + summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.", + keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token", + sections: &[ + example( + "Start a local service", + "Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.", + "arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json", + ), + walkthrough( + "Build a safe editing client", + &[ + "Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.", + "Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.", + "Submit typed commands to /transactions, or a complete candidate to /source or /model, using If-Match. Use transaction dry-run or validation before committing substantial generated changes.", + "A stale write returns 412. Keep the user's draft, reload the current head and reconcile deliberately. After an uncertain write outcome, read the server state before retrying.", + "Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.", + ], + ), + explain( + "Desktop files and hosted models", + "The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.", + ), + explain( + "Deployment and persistence", + "Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.", + ), + explain( + "Use the precise contract", + "docs/api.rst and docs/openapi.json define routes, command shapes, limits and errors. The running server also serves OpenAPI. No network request or service mutation is triggered by reading this guide.", + ), + ], + related: &[ + Topic::Artifacts, + Topic::Source, + Topic::Transitions, + Topic::Recovery, + ], + destination: None, + } + } + } + } +} + +/// Help search is independent of model search. All query words must occur in +/// the article, title or retrieval keywords; order and case are insignificant. +fn search(query: &str) -> Vec { + let query = query.to_lowercase(); + let words = query.split_whitespace().collect::>(); + Topic::ALL + .into_iter() + .filter(|topic| { + let article = article(*topic); + let mut haystack = + format!("{} {} {}", topic.title(), article.summary, article.keywords); + for section in article.sections { + haystack.push_str(section.title); + haystack.push(' '); + haystack.push_str(section.body); + for step in section.steps { + haystack.push(' '); + haystack.push_str(step); + } + if let Some(code) = section.code { + haystack.push(' '); + haystack.push_str(code); + } + } + let haystack = haystack.to_lowercase(); + words.iter().all(|word| haystack.contains(word)) + }) + .collect() +} + +fn topic_button(topic: Topic, selected: bool) -> iced::widget::Button<'static, Message> { + button(text(topic.title()).size(13)) + .padding([10, 11]) + .width(Fill) + .style(if selected { + style::selected + } else { + style::nav + }) + .on_press(Message::Guide(Event::Topic(topic))) +} + +fn article_view(topic: Topic) -> Element<'static, Message> { + let article = article(topic); + let mut content = column![ + text(topic.category()).size(11).color(style::MUTED), + text(topic.title()).size(25), + text(article.summary).size(15).color(style::MUTED), + ] + .spacing(14) + .width(Fill); + for section in article.sections { + let mut body = column![text(section.title).size(18)] + .spacing(12) + .width(Fill); + if !section.body.is_empty() { + body = body.push(text(section.body).size(14)); + } + for (index, step) in section.steps.iter().enumerate() { + body = body.push( + row![ + text(format!("{:02}", index + 1)) + .size(13) + .color(style::TEAL) + .width(28), + text(*step).size(14).width(Fill), + ] + .spacing(9), + ); + } + if let Some(code) = section.code { + body = body.push( + container(text(code).font(Font::MONOSPACE).size(12).width(Fill)) + .padding(14) + .width(Fill) + .style(style::subtle), + ); + } + content = content.push(container(body).padding(20).width(Fill).style(style::card)); + } + if let Some((page, label)) = article.destination { + content = content.push( + button(text(label).size(13)) + .padding([11, 15]) + .on_press(Message::Page(page)), + ); + } + if topic == Topic::FirstProject { + content = content.push( + button(text("Open coastal rescue example").size(13)) + .padding([11, 15]) + .style(style::nav) + .on_press(Message::Request(Pending::Demo)), + ); + } + content = content.push(text("CONTINUE EXPLORING").size(11).color(style::MUTED)); + for related in article.related { + content = content.push(topic_button(*related, false)); + } + scrollable(content.padding(iced::Padding { + right: 12., + bottom: 24., + ..Default::default() + })) + .width(Fill) + .height(Fill) + .into() +} + +pub fn view(state: &State) -> Element<'_, Message> { + let matches = search(&state.query); + let mut navigation = column![].spacing(5).width(Fill); + let mut category = ""; + for topic in &matches { + if category != topic.category() { + category = topic.category(); + navigation = navigation.push( + container(text(category).size(10).color(style::MUTED)).padding(iced::Padding { + top: 12., + bottom: 4., + left: 8., + right: 4., + }), + ); + } + navigation = navigation.push(topic_button(*topic, state.topic == *topic)); + } + let topic_list = container(scrollable(navigation.padding(iced::Padding { + right: 8., + ..Default::default() + }))) + .width(200) + .height(Fill); + let article: Element<'_, Message> = if matches.is_empty() { + container(column![ + text("No help topics found").size(23), + text("Try a modeling concept such as allocation, scenario, data type, Git or API. Help search does not search your model.").size(14).color(style::MUTED), + button(text("Clear search").size(13)).padding([10, 14]).on_press(Message::Guide(Event::Search(String::new()))), + ].spacing(16).max_width(520)).padding(24).width(Fill).into() + } else { + // A different article gets a fresh scroll state and starts at its title. + keyed_column([(state.topic, article_view(state.topic))]) + .width(Fill) + .height(Fill) + .into() + }; + column![ + column![ + text("Engineering guide").size(25), + text("Practical workflows, modeling concepts and reference · available offline") + .size(12) + .color(style::MUTED), + ] + .spacing(5), + row![ + text_input("Search help: allocation, scenario, Git…", &state.query) + .on_input(|query| Message::Guide(Event::Search(query))) + .padding(11) + .size(14) + .width(Fill), + button(text("Clear").size(12)) + .padding(11) + .style(style::nav) + .on_press_maybe( + (!state.query.is_empty()).then(|| Message::Guide(Event::Search(String::new()))) + ), + ] + .spacing(8), + text(format!("{} topics", matches.len())) + .size(11) + .color(style::MUTED), + row![topic_list, article].spacing(20).height(Fill), + ] + .spacing(14) + .padding(22) + .width(Fill) + .height(Fill) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_finds_concepts_in_walkthroughs_and_api_reference() { + assert!(search(" ALLOCATED rescue ").contains(&Topic::FirstProject)); + assert!(search("sender lifeline").contains(&Topic::Scenarios)); + assert!(search("If-Match 412").contains(&Topic::Server)); + assert!(search("Ctrl+Shift+S").contains(&Topic::Files)); + assert!(search("no-such-modeling-topic-987").is_empty()); + assert_eq!(search(" \n\t "), Topic::ALL); + } + + #[test] + fn filtering_and_related_navigation_keep_selection_visible() { + let mut state = State::default(); + state.update(Event::Search("If-Match 412".into())); + assert_eq!(state.topic, Topic::Server); + state.update(Event::Topic(Topic::Nesting)); + assert!(state.query.is_empty()); + assert_eq!(state.topic, Topic::Nesting); + state.update(Event::Search("nonexistent-topic-987".into())); + assert_eq!(state.topic, Topic::Nesting); + state.update(Event::Search(String::new())); + assert!(search(&state.query).contains(&state.topic)); + } +} diff --git a/crates/oa-desktop/src/highlight.rs b/crates/oa-desktop/src/highlight.rs new file mode 100644 index 00000000..b1889181 --- /dev/null +++ b/crates/oa-desktop/src/highlight.rs @@ -0,0 +1,76 @@ +//! Iced adapter for the core language lexer. Tokens are computed once per draft, +//! then split into line-local UTF-8 ranges (including multiline comments). +use iced::{ + Color, Font, Theme, + advanced::text::{Highlighter, highlighter::Format}, +}; +use oa_core::language::{TokenClass, tokenize}; +use std::{ops::Range, sync::Arc}; + +pub struct ArcadiaHighlighter { + lines: Vec, TokenClass)>>, + current: usize, +} + +impl Highlighter for ArcadiaHighlighter { + type Settings = Arc; + type Highlight = TokenClass; + type Iterator<'a> = std::vec::IntoIter<(Range, TokenClass)>; + + fn new(source: &Self::Settings) -> Self { + let mut offsets = vec![0]; + offsets.extend(source.match_indices('\n').map(|(offset, _)| offset + 1)); + let mut lines = vec![Vec::new(); offsets.len()]; + for token in tokenize(source) { + if matches!(token.class, TokenClass::Whitespace) { + continue; + } + let first = offsets + .partition_point(|offset| *offset <= token.span.start) + .saturating_sub(1); + for line in first..offsets.len() { + let start = offsets[line]; + if start >= token.span.end { + break; + } + let end = offsets.get(line + 1).copied().unwrap_or(source.len()); + let local = token.span.start.max(start) - start..token.span.end.min(end) - start; + if !local.is_empty() { + lines[line].push((local, token.class)); + } + } + } + Self { lines, current: 0 } + } + + fn update(&mut self, source: &Self::Settings) { + *self = Self::new(source); + } + fn change_line(&mut self, line: usize) { + self.current = line; + } + fn highlight_line(&mut self, _line: &str) -> Self::Iterator<'_> { + let spans = self.lines.get(self.current).cloned().unwrap_or_default(); + self.current += 1; + spans.into_iter() + } + fn current_line(&self) -> usize { + self.current + } +} + +pub fn format(class: &TokenClass, _theme: &Theme) -> Format { + let color = match class { + TokenClass::Keyword => Color::from_rgb8(118, 62, 160), + TokenClass::Identifier => Color::from_rgb8(20, 93, 139), + TokenClass::String => Color::from_rgb8(24, 113, 79), + TokenClass::Number => Color::from_rgb8(163, 78, 30), + TokenClass::Comment => Color::from_rgb8(111, 121, 132), + TokenClass::Error => Color::from_rgb8(186, 38, 62), + TokenClass::Punctuation | TokenClass::Whitespace => Color::from_rgb8(91, 101, 117), + }; + Format { + color: Some(color), + font: None, + } +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs new file mode 100644 index 00000000..88610fd6 --- /dev/null +++ b/crates/oa-desktop/src/main.rs @@ -0,0 +1,38 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod app; +mod behavior; +mod diagram; +mod forms; +mod guide; +mod highlight; +mod navigation; +mod source_navigation; +mod style; +#[cfg(test)] +mod tests; +mod views; +mod workflow; + +fn main() -> iced::Result { + // Small headless entry points support reproducible examples and CI validation. + let args: Vec<_> = std::env::args().collect(); + if args.get(1).is_some_and(|s| s == "--write-example") { + let path = args + .get(2) + .expect("usage: arcadia-studio --write-example PATH"); + oa_core::persistence::save(std::path::Path::new(path), &oa_core::Project::demo()) + .expect("write example"); + return Ok(()); + } + iced::application(app::App::new, app::App::update, app::App::view) + .title(app::App::title) + .theme(|_: &app::App| style::theme()) + .subscription(app::App::subscription) + .window(iced::window::Settings { + size: iced::Size::new(1440., 900.), + min_size: Some(iced::Size::new(1100., 700.)), + exit_on_close_request: false, + ..Default::default() + }) + .run() +} diff --git a/crates/oa-desktop/src/navigation.rs b/crates/oa-desktop/src/navigation.rs new file mode 100644 index 00000000..23b61260 --- /dev/null +++ b/crates/oa-desktop/src/navigation.rs @@ -0,0 +1,293 @@ +//! Read-only semantic navigation derived from the core's reference visitors. +use crate::{ + app::Message, + style, + views::{action, label, short}, +}; +use iced::{ + Element, Fill, + widget::{column, container, text}, +}; +use oa_core::{ + references::{element_references, relation_references}, + *, +}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum InspectorTab { + #[default] + Properties, + Connections, +} + +struct Group { + title: &'static str, + ids: BTreeSet, +} + +fn groups(p: &Project, selected: Id) -> Vec { + let parent = p.elements.get(&selected).and_then(|e| e.parent); + let children = p.children(selected).map(|e| e.id).collect::>(); + let mut direct = p + .elements + .get(&selected) + .map(element_references) + .or_else(|| p.relations.get(&selected).map(relation_references)) + .unwrap_or_default() + .into_iter() + .collect::>(); + direct.retain(|id| Some(*id) != parent && !children.contains(id)); + let outgoing = p + .relations + .values() + .filter(|r| r.source() == selected) + .map(|r| r.id) + .collect(); + let incoming = p + .relations + .values() + .filter(|r| r.target() == selected) + .map(|r| r.id) + .collect(); + let users = p + .elements + .values() + .filter(|e| { + e.id != selected + && Some(e.id) != parent + && e.parent != Some(selected) + && element_references(e).contains(&selected) + }) + .map(|e| e.id) + .chain( + p.relations + .values() + .filter(|r| { + r.source() != selected + && r.target() != selected + && relation_references(r).contains(&selected) + }) + .map(|r| r.id), + ) + .collect(); + let counterparts = p + .traces + .values() + .filter_map(|t| { + if t.oa == selected { + Some(t.sa) + } else if t.sa == selected { + Some(t.oa) + } else { + None + } + }) + .collect(); + let artifacts = p + .artifact_links + .values() + .filter(|a| a.owner == selected) + .map(|a| a.id) + .collect(); + vec![ + Group { + title: "OWNED BY", + ids: parent.into_iter().collect(), + }, + Group { + title: "CONTAINS", + ids: children, + }, + Group { + title: "REFERENCES", + ids: direct, + }, + Group { + title: "OUTGOING RELATIONSHIPS", + ids: outgoing, + }, + Group { + title: "INCOMING RELATIONSHIPS", + ids: incoming, + }, + Group { + title: "USED BY BEHAVIOR & CONTRACTS", + ids: users, + }, + Group { + title: "COUNTERPARTS", + ids: counterparts, + }, + Group { + title: "ENGINEERING ARTIFACTS", + ids: artifacts, + }, + ] +} + +pub fn diagrams_containing(p: &Project, selected: Id) -> Vec { + let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]); + p.diagrams + .values() + .filter(|d| { + let placed = |id| { + let e = p.elements.get(&id); + let owner = e + .filter(|e| e.kind() == Kind::Port) + .and_then(|e| e.parent) + .unwrap_or(id); + let kind = p.elements.get(&owner).map(|e| e.kind()); + kind.is_none_or(|kind| d.projection.includes(kind)) + && d.nodes.values().any(|n| n.element == owner || n.id == id) + }; + placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed)) + }) + .map(|d| d.id) + .collect() +} + +pub fn view(p: &Project, selected: Id) -> Element<'_, Message> { + let mut content = column![ + text(short(p.named(selected), 50)).size(18), + text("Follow ownership, usage and engineering relationships.") + .size(12) + .color(style::MUTED) + ] + .spacing(12); + let mut count = 0; + for group in groups(p, selected) { + if group.ids.is_empty() { + continue; + } + count += group.ids.len(); + let mut entries = group.ids.into_iter().collect::>(); + entries.sort_by_key(|id| p.named(*id).to_lowercase()); + content = content.push(label(group.title)); + for id in entries { + let detail = p + .elements + .get(&id) + .map(|e| format!("{} · {}", e.kind(), e.layer())) + .or_else(|| { + p.relations.get(&id).map(|r| { + format!( + "{} · {} → {}", + r.kind(), + p.named(r.source()), + p.named(r.target()) + ) + }) + }) + .or_else(|| p.artifact_links.get(&id).map(|a| a.uri.clone())) + .unwrap_or_default(); + let mut entry = column![ + action(&short(p.named(id), 42), Message::Select(id)).width(Fill), + text(detail).size(11).color(style::MUTED) + ] + .spacing(3); + if let Some(r) = p.relations.get(&id) { + let other = if r.source() == selected { + r.target() + } else { + r.source() + }; + entry = entry.push( + action( + &format!("Open {}", short(p.named(other), 32)), + Message::Select(other), + ) + .width(Fill), + ); + } + content = content.push(container(entry).padding(8).style(style::subtle)); + } + } + let diagrams = diagrams_containing(p, selected); + if !diagrams.is_empty() { + content = content.push(label("SHOWN IN DIAGRAMS")); + for id in diagrams { + content = content.push( + action( + &short(p.named(id), 42), + Message::RevealInDiagram(id, selected), + ) + .width(Fill), + ); + } + } else if p.elements.contains_key(&selected) { + content = content.push( + text("This element has no diagram placement yet.") + .size(12) + .color(style::MUTED), + ); + } + if count == 0 { + content = content.push(text("No semantic connections yet. Add relationships from Properties as the model develops.").size(12).color(style::MUTED)); + } + content.into() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn semantic_browser_finds_behavior_users_without_confusing_containment() { + let mut p = Project::empty(); + let work = p + .add_element(Kind::Activity, Layer::Oa, "Respond", None) + .unwrap(); + let process = p + .add_element(Kind::Process, Layer::Oa, "Response route", None) + .unwrap(); + let occurrence = p + .add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(process)) + .unwrap(); + *p.elements + .get_mut(&occurrence) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + precondition: String::new(), + postcondition: String::new(), + }; + let result = groups(&p, work); + assert!( + result + .iter() + .find(|g| g.title == "USED BY BEHAVIOR & CONTRACTS") + .unwrap() + .ids + .contains(&occurrence) + ); + let result = groups(&p, occurrence); + assert!( + result + .iter() + .find(|g| g.title == "OWNED BY") + .unwrap() + .ids + .contains(&process) + ); + assert!( + result + .iter() + .find(|g| g.title == "REFERENCES") + .unwrap() + .ids + .contains(&work) + ); + assert!( + !result + .iter() + .find(|g| g.title == "REFERENCES") + .unwrap() + .ids + .contains(&process) + ); + } +} diff --git a/crates/oa-desktop/src/source_navigation.rs b/crates/oa-desktop/src/source_navigation.rs new file mode 100644 index 00000000..4b7c50b1 --- /dev/null +++ b/crates/oa-desktop/src/source_navigation.rs @@ -0,0 +1,132 @@ +//! Literal source search and line navigation, independent of accepted model state. +use iced::widget::text_editor::{Cursor, Position}; + +#[derive(Debug, Clone)] +pub enum Event { + Open(bool), + Close, + Query(String), + Next(bool), + Line(String), + Go, +} + +#[derive(Default)] +pub struct State { + pub open: bool, + pub query: String, + pub line: String, + matches: Vec, + selected: Option, +} + +impl State { + pub fn refresh(&mut self, source: &str) { + self.matches.clear(); + self.selected = None; + if self.query.is_empty() { + return; + } + let starts: Vec<_> = std::iter::once(0) + .chain(source.match_indices('\n').map(|(i, _)| i + 1)) + .collect(); + let position = |offset: usize| { + let line = starts + .partition_point(|start| *start <= offset) + .saturating_sub(1); + // Iced's text renderer uses UTF-8 byte columns, not scalar counts. + Position { + line, + column: offset - starts[line], + } + }; + self.matches = source + .match_indices(&self.query) + .map(|(offset, matched)| Cursor { + position: position(offset), + selection: Some(position(offset + matched.len())), + }) + .collect(); + } + + pub fn next(&mut self, backwards: bool) -> Option { + let count = self.matches.len(); + if count == 0 { + return None; + } + let selected = match self.selected { + None => { + if backwards { + count - 1 + } else { + 0 + } + } + Some(i) => { + if backwards { + (i + count - 1) % count + } else { + (i + 1) % count + } + } + }; + self.selected = Some(selected); + Some(self.matches[selected]) + } + + pub fn label(&self) -> String { + if self.query.is_empty() { + return "Case sensitive".into(); + } + match self.selected { + Some(i) => format!("{} / {}", i + 1, self.matches.len()), + None => format!("{} matches", self.matches.len()), + } + } + + pub fn destination(&self, line_count: usize) -> Result { + let line: usize = self + .line + .trim() + .parse() + .map_err(|_| "Enter a line number.".to_string())?; + if line == 0 || line > line_count { + return Err(format!("Choose a line from 1 to {line_count}.")); + } + Ok(Cursor { + position: Position { + line: line - 1, + column: 0, + }, + selection: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn search_uses_utf8_columns_wraps_and_refreshes_after_edits() { + let mut state = State { + query: "救助".into(), + ..State::default() + }; + state.refresh("é 救助\n救助"); + let first = state.next(false).unwrap(); + assert_eq!(first.position, Position { line: 0, column: 3 }); + assert_eq!(first.selection, Some(Position { line: 0, column: 9 })); + assert_eq!(state.next(false).unwrap().position.line, 1); + assert_eq!(state.next(false), Some(first)); + assert_eq!(state.next(true).unwrap().position.line, 1); + state.refresh("no matches"); + assert!(state.next(false).is_none()); + assert_eq!(state.label(), "0 matches"); + state.line = "0".into(); + assert!(state.destination(2).is_err()); + state.line = "2".into(); + assert_eq!(state.destination(2).unwrap().position.line, 1); + state.line = "3".into(); + assert!(state.destination(2).is_err()); + } +} diff --git a/crates/oa-desktop/src/style.rs b/crates/oa-desktop/src/style.rs new file mode 100644 index 00000000..a9a5d6a5 --- /dev/null +++ b/crates/oa-desktop/src/style.rs @@ -0,0 +1,99 @@ +use iced::{ + Border, Color, Theme, + widget::{button, container}, +}; +use oa_core::Kind; +pub const BG: Color = Color::from_rgb(0.945, 0.956, 0.969); +pub const INK: Color = Color::from_rgb(0.13, 0.18, 0.25); +pub const MUTED: Color = Color::from_rgb(0.43, 0.48, 0.55); +pub const TEAL: Color = Color::from_rgb(0.0, 0.48, 0.43); +pub const LINE: Color = Color::from_rgb(0.86, 0.89, 0.91); +pub fn theme() -> Theme { + Theme::custom( + "Arcadia Light", + iced::theme::Palette { + background: BG, + text: INK, + primary: TEAL, + success: TEAL, + warning: Color::from_rgb(0.72, 0.47, 0.10), + danger: Color::from_rgb(0.78, 0.22, 0.24), + }, + ) +} +pub fn card(_: &Theme) -> container::Style { + container::Style { + background: Some(Color::WHITE.into()), + border: Border { + color: LINE, + width: 1., + radius: 10.into(), + }, + ..Default::default() + } +} +pub fn panel(_: &Theme) -> container::Style { + container::Style { + background: Some(Color::WHITE.into()), + border: Border { + color: LINE, + width: 1., + radius: 0.into(), + }, + ..Default::default() + } +} +pub fn subtle(_: &Theme) -> container::Style { + container::Style { + background: Some(BG.into()), + border: Border { + radius: 8.into(), + ..Default::default() + }, + ..Default::default() + } +} +pub fn nav(theme: &Theme, status: button::Status) -> button::Style { + let mut s = button::text(theme, status); + if status != button::Status::Disabled { + s.text_color = INK; + } + s.border.radius = 7.into(); + if matches!(status, button::Status::Hovered | button::Status::Pressed) { + s.background = Some(BG.into()); + } + s +} +pub fn selected(theme: &Theme, status: button::Status) -> button::Style { + let mut s = nav(theme, status); + s.background = Some(Color::from_rgb8(218, 238, 235).into()); + s.text_color = INK; + s.border = Border { + color: Color::from_rgb8(151, 203, 195), + width: 1., + radius: 7.into(), + }; + s +} +pub fn kind_color(kind: Kind) -> Color { + match kind { + Kind::Entity | Kind::System => Color::from_rgb(0.16, 0.40, 0.71), + Kind::Actor | Kind::SystemActor | Kind::Role => Color::from_rgb(0.58, 0.35, 0.70), + Kind::Activity | Kind::Function => TEAL, + Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission => { + Color::from_rgb(0.72, 0.47, 0.10) + } + Kind::Process + | Kind::FunctionalChain + | Kind::Scenario + | Kind::Occurrence + | Kind::Lifeline => Color::from_rgb(0.65, 0.33, 0.40), + Kind::StateMachine + | Kind::Region + | Kind::State + | Kind::Mode + | Kind::Initial + | Kind::Final => Color::from_rgb(0.38, 0.39, 0.68), + _ => Color::from_rgb(0.25, 0.48, 0.56), + } +} diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs new file mode 100644 index 00000000..9a828417 --- /dev/null +++ b/crates/oa-desktop/src/tests.rs @@ -0,0 +1,938 @@ +use crate::{ + app::{App, Message, Page, Pending}, + forms::Draft, + workflow::{Event, Workflow}, +}; +use iced::widget::text_editor; +use oa_core::*; +use serde_json::json; + +fn send(app: &mut App, m: Message) { + let _ = app.update(m); +} +fn fixture() -> Project { + let mut p = Project::empty(); + let owner = p.add(Kind::Entity, "Rescue team", 40., 40.); + let work = p.add(Kind::Activity, "Assess incident", 340., 40.); + p.relate(RelationKind::Allocation, "Performs", owner, work); + p +} +fn source(app: &mut App, text: &str) { + send(app, Message::Page(Page::Text)); + send(app, Message::SourceEdit(text_editor::Action::SelectAll)); + send( + app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( + std::sync::Arc::new(text.into()), + ))), + ); +} + +#[test] +fn opening_and_creating_models_preserve_the_actual_window_size_for_fit() { + let size = iced::Size::new(1100., 700.); + let project = fixture(); + let mut expected = App::with_project(project.clone()); + send(&mut expected, Message::WindowResized(size)); + send(&mut expected, Message::Fit); + + let mut app = App::with_project(Project::empty()); + send(&mut app, Message::WindowResized(size)); + send( + &mut app, + Message::Loaded(Ok(Some(( + std::path::PathBuf::from("resized-model.arcadia"), + project.clone(), + std::sync::Arc::from(language::format(&project).into_bytes()), + )))), + ); + assert_eq!(app.window_size, size); + assert_eq!(app.zoom, expected.zoom); + assert_eq!(app.pan, expected.pan); + send(&mut app, Message::Fit); + assert_eq!(app.zoom, expected.zoom); + + send(&mut app, Message::Request(Pending::New)); + assert!(app.project().elements.is_empty()); + assert_eq!(app.window_size, size); + send(&mut app, Message::Request(Pending::Demo)); + assert_eq!(app.window_size, size); + let mut expected_demo = App::with_project(app.project().clone()); + send(&mut expected_demo, Message::WindowResized(size)); + send(&mut expected_demo, Message::Fit); + assert_eq!(app.zoom, expected_demo.zoom); + assert_eq!(app.pan, expected_demo.pan); +} + +#[test] +fn every_demo_record_has_a_lossless_graphical_draft() { + let p = Project::demo(); + for (id, _) in p.identities() { + let draft = Draft::from_project(&p, id).expect("Graphical record"); + assert!(!draft.dirty()); + let candidate = execute_batch(&p, &[draft.command().unwrap()]).unwrap(); + assert_eq!(candidate, p, "untouched property form changed {id}"); + } +} + +#[test] +fn fit_uses_measured_canvas_bounds_and_preserves_them_on_open() { + let p = fixture(); + let mut app = App::with_project(p.clone()); + let canvas = iced::Size::new(510., 275.); + send(&mut app, Message::CanvasResized(canvas)); + send( + &mut app, + Message::Loaded(Ok(Some(( + std::path::PathBuf::from("measured.arcadia"), + p.clone(), + std::sync::Arc::from(language::format(&p).into_bytes()), + )))), + ); + send(&mut app, Message::Fit); + for node in projection::project_view(app.project(), app.active_diagram.unwrap()).nodes { + let x = app.pan.x + node.position.x * app.zoom; + let y = app.pan.y + node.position.y * app.zoom; + assert!(x >= 23.9 && y >= 23.9); + assert!(x + node.size.width * app.zoom <= canvas.width - 23.9); + assert!(y + node.size.height * app.zoom <= canvas.height - 23.9); + } +} + +#[test] +fn fit_adapts_to_canvas_returning_after_help_but_preserves_manual_navigation() { + let p = fixture(); + let mut app = App::with_project(p); + send( + &mut app, + Message::CanvasResized(iced::Size::new(900., 600.)), + ); + send(&mut app, Message::Page(Page::Guide)); + send( + &mut app, + Message::WindowResized(iced::Size::new(1100., 700.)), + ); + send(&mut app, Message::Page(Page::Diagram)); + let compact = iced::Size::new(500., 270.); + send(&mut app, Message::CanvasResized(compact)); + let fitted = (app.zoom, app.pan); + send(&mut app, Message::Fit); + assert_eq!((app.zoom, app.pan), fitted); + send(&mut app, Message::Zoom(1.2)); + let manual = (app.zoom, app.pan); + send( + &mut app, + Message::CanvasResized(iced::Size::new(650., 400.)), + ); + assert_eq!((app.zoom, app.pan), manual); +} + +#[test] +fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() { + let p = fixture(); + let id = p + .elements + .values() + .find(|e| e.kind() == Kind::Activity) + .unwrap() + .id; + let mut app = App::with_project(p); + send(&mut app, Message::Select(id)); + send(&mut app, Message::SourceForSelection); + assert_eq!(app.page, Page::Text); + assert!(app.source_jump.is_some()); + send(&mut app, Message::SourceViewport); + assert!(app.source_jump.is_none()); + send(&mut app, Message::SourceForSelection); + assert!(app.source_jump.is_some()); + send( + &mut app, + Message::SourceEdit(text_editor::Action::SelectAll), + ); + assert!(app.source_jump.is_none()); +} + +#[test] +fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() { + use crate::source_navigation::Event; + let mut app = App::with_project(fixture()); + let accepted = app.project().clone(); + source(&mut app, "é 救助\nthis is an unfinished model: 救助"); + send(&mut app, Message::SourceNavigation(Event::Open(false))); + send( + &mut app, + Message::SourceNavigation(Event::Query("救助".into())), + ); + send(&mut app, Message::SourceNavigation(Event::Next(false))); + let cursor = app.source_jump.unwrap(); + assert_eq!( + cursor.position, + text_editor::Position { line: 0, column: 3 } + ); + assert_eq!( + cursor.selection, + Some(text_editor::Position { line: 0, column: 9 }) + ); + send(&mut app, Message::SourceViewport); + assert_eq!(app.source_editor.cursor(), cursor); + send( + &mut app, + Message::SourceNavigation(Event::Line("99".into())), + ); + send(&mut app, Message::SourceNavigation(Event::Go)); + assert!(app.status.contains("1 to 2")); + assert_eq!(*app.project(), accepted); + assert!(app.source_dirty()); +} +#[test] +fn graphical_rename_keeps_aliases_and_references_and_undo() { + let mut app = App::with_project(fixture()); + let before = app.project().clone(); + let id = before + .elements + .values() + .find(|e| e.kind() == Kind::Activity) + .unwrap() + .id; + send(&mut app, Message::Select(id)); + send( + &mut app, + Message::Field("/name".into(), "Coordinate rescue".into()), + ); + send(&mut app, Message::Apply); + assert_eq!( + app.project().elements[&id].alias, + before.elements[&id].alias + ); + assert_eq!( + language::parse(&app.source_editor.text()).unwrap(), + *app.project() + ); + send(&mut app, Message::Undo); + assert_eq!(*app.project(), before); +} +#[test] +fn identity_property_change_is_atomic_and_rewrites_all_live_references() { + let mut app = App::with_project(fixture()); + let before = app.project().clone(); + let old = before + .elements + .values() + .find(|e| e.kind() == Kind::Activity) + .unwrap() + .id; + let new = Id::new_v4(); + send(&mut app, Message::Select(old)); + send(&mut app, Message::Field("/id".into(), new.to_string())); + send( + &mut app, + Message::Field("/name".into(), "New identity".into()), + ); + send(&mut app, Message::Apply); + assert!(!app.project().contains(old)); + assert!(app.project().relations.values().any(|r| r.target() == new)); + assert!( + app.project() + .diagrams + .values() + .any(|d| d.nodes.values().any(|n| n.element == new)) + ); + send(&mut app, Message::Undo); + assert_eq!(*app.project(), before); +} +#[test] +fn invalid_numeric_draft_survives_navigation_save_and_help() { + let mut app = App::with_project(fixture()); + let p = app.project().clone(); + let node = p + .diagrams + .values() + .next() + .unwrap() + .nodes + .values() + .next() + .unwrap() + .id; + send(&mut app, Message::Select(node)); + send(&mut app, Message::Field("/position/x".into(), "-".into())); + send(&mut app, Message::Save(false)); + assert!(!app.busy); + assert_eq!(*app.project(), p); + send(&mut app, Message::Page(Page::Guide)); + send(&mut app, Message::Page(Page::Diagram)); + assert_eq!(app.page, Page::Diagram); + assert_eq!(app.draft.as_ref().unwrap().inputs["/position/x"].raw, "-"); + send(&mut app, Message::SelectProject); + assert_eq!(app.selected, node); +} +#[test] +fn malformed_source_preserves_model_and_is_recoverable() { + let mut app = App::with_project(fixture()); + let p = app.project().clone(); + source(&mut app, "arcadia 2; ?"); + send(&mut app, Message::ApplySource); + assert_eq!(*app.project(), p); + assert!(!app.source_diagnostics.is_empty()); + send(&mut app, Message::Request(Pending::New)); + assert!(app.pending.is_some()); + send(&mut app, Message::Cancel); + assert_eq!(app.source_editor.text(), "arcadia 2; ?"); + send(&mut app, Message::RevertSource); + assert_eq!(language::parse(&app.source_editor.text()).unwrap(), p); +} +#[test] +fn escaped_string_mode_preserves_exact_control_characters() { + let mut app = App::with_project(fixture()); + send(&mut app, Message::ExactStrings(true)); + let exact = "Plan\r\nRescue\t\"North\""; + send( + &mut app, + Message::Field("/name".into(), serde_json::to_string(exact).unwrap()), + ); + send(&mut app, Message::Apply); + assert_eq!(app.project().name, exact); + assert!(!app.draft_dirty()); + send( + &mut app, + Message::Field("/name".into(), "not quoted".into()), + ); + send(&mut app, Message::Apply); + assert_eq!(app.project().name, exact); + assert!(app.draft_dirty()); + send(&mut app, Message::RevertProperties); + assert!(!app.draft_dirty()); +} +#[test] +fn explicit_relationship_builder_never_mutates_on_preview() { + let mut p = Project::empty(); + let owner = p.add(Kind::Entity, "Team", 0., 0.); + let work = p.add(Kind::Activity, "Respond", 300., 0.); + let mut workflow = Workflow::relationship(&p, owner, Layer::Oa); + let before = p.clone(); + workflow + .update(&p, Event::RelationName("Performs".into())) + .unwrap(); + workflow + .update( + &p, + Event::Target(crate::app::Choice { + id: work, + label: "Respond".into(), + }), + ) + .unwrap(); + assert_eq!(p, before); + let result = workflow.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(result.relations.len(), 1); +} + +#[test] +fn explorer_and_creation_selection_follow_their_own_placement() { + let p = fixture(); + let owner = p + .elements + .values() + .find(|e| e.kind() == Kind::Entity) + .unwrap() + .id; + let work = p + .elements + .values() + .find(|e| e.kind() == Kind::Activity) + .unwrap() + .id; + let mut app = App::with_project(p); + send(&mut app, Message::Select(owner)); + let owner_node = app.selected_node.expect("placed explorer selection"); + send(&mut app, Message::Select(work)); + let work_node = app.selected_node.expect("placed work selection"); + assert_ne!(owner_node, work_node); + let before = app.project().clone(); + send(&mut app, Message::PlaceSelected); + assert_eq!( + *app.project(), + before, + "showing an existing placement does not create a duplicate" + ); + send(&mut app, Message::CreateKind(Kind::Capability)); + send(&mut app, Message::Add(false)); + let diagram = &app.project().diagrams[&app.active_diagram.unwrap()]; + let new_node = &diagram.nodes[&app.selected_node.expect("new element placement")]; + assert_eq!(new_node.element, app.selected); + assert_ne!( + new_node.id, work_node, + "placement actions cannot target previously selected work" + ); +} +#[test] +fn bidirectional_transition_ui_commits_once_and_preserves_other_diagrams() { + let mut p = Project::empty(); + let activity = p.add(Kind::Activity, "Coordinate rescue", 120.5, 85.25); + let original = p.clone(); + let mut w = Workflow::transition(&p, activity).unwrap(); + let next = w.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!( + next.diagrams[&original.diagrams.keys().copied().next().unwrap()], + *original.diagrams.values().next().unwrap() + ); + let function = next + .elements + .values() + .find(|e| e.kind() == Kind::Function) + .unwrap() + .id; + let mut reverse = Workflow::transition(&next, function).unwrap(); + let again = reverse.update(&next, Event::Apply).unwrap().unwrap(); + assert_eq!(again.elements.len(), next.elements.len()); + assert_eq!(again.traces.len(), 1); + let mut app = App::with_project(original.clone()); + send(&mut app, Message::Command(Command::Replace(next))); + send(&mut app, Message::Undo); + assert_eq!(*app.project(), original); +} + +#[test] +fn mission_capability_authoring_is_contextual_atomic_and_text_equivalent() { + for (layer, mission_kind, capability_kind, participant_kind) in [ + ( + Layer::Oa, + Kind::OperationalMission, + Kind::Capability, + Kind::Actor, + ), + ( + Layer::Sa, + Kind::Mission, + Kind::SystemCapability, + Kind::SystemActor, + ), + ] { + let mut p = Project::empty(); + let mission = p + .add_element(mission_kind, layer, "Protect life", None) + .unwrap(); + let base = p + .add_element(capability_kind, layer, "Rescue people", None) + .unwrap(); + let extension = p + .add_element(capability_kind, layer, "Rescue in heavy weather", None) + .unwrap(); + let actor = p + .add_element(participant_kind, layer, "Rescue operator", None) + .unwrap(); + let mut app = App::with_project(p); + send(&mut app, Message::Select(mission)); + send(&mut app, Message::AddRelation); + assert!( + matches!(app.workflow.as_ref(), Some(Workflow::Relation(d)) if d.kind == RelationKind::MissionCapability && d.source.as_ref().unwrap().id == mission) + ); + send( + &mut app, + Message::Workflow(Event::Target(crate::app::Choice { + id: base, + label: "Rescue people".into(), + })), + ); + send(&mut app, Message::Workflow(Event::Apply)); + assert_eq!(app.project().relations.len(), 1); + assert!( + app.project() + .relations + .values() + .any(|r| r.name == "Protect life exploits Rescue people") + ); + send(&mut app, Message::Select(mission)); + send( + &mut app, + Message::AddRelationKind(RelationKind::MissionInvolvement, true), + ); + assert!( + matches!(app.workflow.as_ref(), Some(Workflow::Relation(d)) if d.target.as_ref().unwrap().id == mission && d.source.is_none()) + ); + send( + &mut app, + Message::Workflow(Event::Source(crate::app::Choice { + id: actor, + label: "Rescue operator".into(), + })), + ); + send(&mut app, Message::Workflow(Event::Apply)); + let before = app.project().clone(); + send(&mut app, Message::Select(extension)); + send( + &mut app, + Message::AddRelationKind(RelationKind::CapabilityExtend, false), + ); + send( + &mut app, + Message::Workflow(Event::Target(crate::app::Choice { + id: base, + label: "Rescue people".into(), + })), + ); + let condition = "Sea state >= 6\nAND crew is qualified"; + send( + &mut app, + Message::Workflow(Event::Condition(text_editor::Action::Edit( + text_editor::Edit::Paste(std::sync::Arc::new(condition.into())), + ))), + ); + assert_eq!( + *app.project(), + before, + "preview must preserve committed model" + ); + send(&mut app, Message::Workflow(Event::Apply)); + assert!(app.project().relations.values().any(|r| matches!(&r.body, RelationBody::CapabilityExtend {source,target,condition: value} if *source == extension && *target == base && value == condition))); + assert_eq!( + language::parse(&app.source_editor.text()).unwrap(), + *app.project() + ); + send(&mut app, Message::Undo); + assert_eq!(*app.project(), before); + send(&mut app, Message::Redo); + assert_eq!(app.project().relations.len(), 3); + } +} +#[test] +fn property_enum_and_optional_reference_roundtrip() { + let mut p = Project::empty(); + let item = p + .add_element(Kind::DataType, Layer::Oa, "Alarm severity", None) + .unwrap(); + let mut d = Draft::from_project(&p, item).unwrap(); + d.shape( + "/body/Oa/Shared/DataType/definition", + json!({"Enumeration":{"literals":["minor","major"]}}), + ) + .unwrap(); + let p = execute_batch(&p, &[d.command().unwrap()]).unwrap(); + assert_eq!(language::parse(&language::format(&p)).unwrap(), p); +} + +#[test] +fn modal_owns_background_commands_and_shortcuts() { + let mut app = App::with_project(fixture()); + send(&mut app, Message::AddRelation); + let before = app.project().clone(); + send(&mut app, Message::Layer(Layer::Sa)); + send(&mut app, Message::Page(Page::Text)); + send(&mut app, Message::ShortcutUndo(false)); + send(&mut app, Message::Save(false)); + assert_eq!(app.layer, Layer::Oa); + assert_eq!(app.page, Page::Diagram); + assert!(!app.busy); + assert_eq!(*app.project(), before); + send(&mut app, Message::Escape); + assert!(app.workflow.is_none()); +} +#[test] +fn selecting_current_constructors_preserves_payloads() { + let mut app = App::with_project(Project::demo()); + let before = app.project().clone(); + for e in before.elements.values() { + send(&mut app, Message::Select(e.id)); + send(&mut app, Message::ElementKind(e.kind())); + assert!(!app.draft_dirty()); + } + for r in before.relations.values() { + send(&mut app, Message::Select(r.id)); + send(&mut app, Message::RelationKind(r.kind())); + assert!(!app.draft_dirty()); + } + let mut p = Project::empty(); + let id = p + .add_element(Kind::DataType, Layer::Oa, "Severity", None) + .unwrap(); + let mut app = App::with_project(p); + send(&mut app, Message::Select(id)); + let path = "/body/Oa/Shared/DataType/definition"; + send( + &mut app, + Message::FieldVariant( + path.into(), + json!({"Enumeration":{"literals":["minor","major"]}}), + ), + ); + send(&mut app, Message::Apply); + let accepted = app.project().clone(); + send( + &mut app, + Message::FieldVariant(path.into(), json!({"Enumeration":{"literals":[]}})), + ); + send(&mut app, Message::Apply); + assert_eq!(*app.project(), accepted); +} +#[test] +fn placement_identity_edit_then_drag_and_diagram_identity_then_arrange() { + let mut app = App::with_project(fixture()); + let diagram = app.active_diagram.unwrap(); + let node = *app.project().diagrams[&diagram] + .nodes + .keys() + .next() + .unwrap(); + send(&mut app, Message::Select(node)); + let changed = Id::new_v4(); + send(&mut app, Message::Field("/id".into(), changed.to_string())); + send(&mut app, Message::SelectNode(node)); + send( + &mut app, + Message::MoveNode(node, Position { x: 200., y: 150. }), + ); + assert_eq!( + app.project().diagrams[&diagram].nodes[&changed].position, + Position { x: 200., y: 150. } + ); + send(&mut app, Message::Select(diagram)); + let changed_diagram = Id::new_v4(); + send( + &mut app, + Message::Field("/id".into(), changed_diagram.to_string()), + ); + send(&mut app, Message::Command(Command::Layout(diagram))); + assert_eq!(app.active_diagram, Some(changed_diagram)); + assert!(!app.project().contains(diagram)); +} +#[test] +fn placement_relocation_follows_cross_layer_traceability_view() { + let mut p = fixture(); + let original = p.ensure_diagram(Layer::Oa); + let target = p.ensure_diagram(Layer::Sa); + p.diagrams.get_mut(&target).unwrap().projection = ProjectionKind::Traceability; + let node = *p.diagrams[&original].nodes.keys().next().unwrap(); + let mut app = App::with_project(p); + send(&mut app, Message::Select(node)); + send( + &mut app, + Message::Field("/diagram".into(), target.to_string()), + ); + assert!(app.apply(), "{}", app.status); + assert_eq!(app.layer, Layer::Sa); + assert_eq!(app.active_diagram, Some(target)); + assert_eq!(app.selected_node, Some(node)); + assert!(app.project().diagrams[&target].nodes.contains_key(&node)); + assert!(!app.project().diagrams[&original].nodes.contains_key(&node)); + send(&mut app, Message::Select(target)); + send(&mut app, Message::Field("/layer".into(), "Oa".into())); + assert!(app.apply(), "{}", app.status); + assert_eq!(app.layer, Layer::Oa); + assert_eq!(app.active_diagram, Some(target)); +} +#[test] +fn manual_trace_requires_target_and_selection_normalizes_creation_kind() { + let p = Project::demo(); + let a = p + .elements + .values() + .find(|e| e.kind() == Kind::Activity) + .unwrap() + .id; + let b = p + .elements + .values() + .find(|e| e.kind() == Kind::Function) + .unwrap() + .id; + let mut app = App::with_project(p.clone()); + send(&mut app, Message::Select(b)); + assert_eq!(app.create_kind, Kind::Function); + send(&mut app, Message::AddTrace); + send(&mut app, Message::Workflow(Event::Apply)); + assert_eq!(*app.project(), p); + send( + &mut app, + Message::Workflow(Event::Target(crate::app::Choice { + id: a, + label: "OA".into(), + })), + ); + send(&mut app, Message::Workflow(Event::Apply)); + assert_eq!(app.project().traces.len(), p.traces.len() + 1); + send(&mut app, Message::Select(a)); + assert_eq!(app.create_kind, Kind::Activity); +} + +#[test] +fn invalid_save_and_continue_returns_to_repairable_draft() { + let mut app = App::with_project(fixture()); + source(&mut app, "arcadia 2; ?"); + send(&mut app, Message::Request(Pending::New)); + send(&mut app, Message::SaveAndContinue); + assert!(app.pending.is_none()); + assert_eq!(app.page, Page::Text); + send(&mut app, Message::RevertSource); + assert!(!app.source_dirty()); +} + +#[test] +fn counterpart_creation_focuses_visible_target_and_reuse_keeps_views() { + let mut app = App::with_project(fixture()); + let source = app + .project() + .elements + .values() + .find(|e| e.kind() == Kind::Activity) + .unwrap() + .id; + send(&mut app, Message::Select(source)); + send(&mut app, Message::StartTransition); + send( + &mut app, + Message::Workflow(Event::Scope("Selected only".into())), + ); + send(&mut app, Message::Workflow(Event::Apply)); + let target = app.selected; + assert_eq!(app.project().elements[&target].kind(), Kind::Function); + assert!( + app.project().diagrams[&app.active_diagram.unwrap()] + .nodes + .values() + .any(|n| n.element == target) + ); + let before = app.project().clone(); + send(&mut app, Message::StartTransition); + send( + &mut app, + Message::Workflow(Event::Scope("Selected only".into())), + ); + send(&mut app, Message::Workflow(Event::Apply)); + assert_eq!(app.project().diagrams, before.diagrams); + assert_eq!(app.selected, source); + assert!( + app.project().diagrams[&app.active_diagram.unwrap()] + .nodes + .values() + .any(|n| n.element == source) + ); +} + +#[test] +fn transition_resolutions_belong_to_the_effective_target_and_kind() { + use oa_core::transitions::{FieldDecision, TransitionField}; + fn transition(workflow: &Workflow) -> &crate::workflow::Transition { + let Workflow::Transition(transition) = workflow else { + panic!("Transition workflow") + }; + transition + } + let mut p = Project::empty(); + let source = p + .add_element(Kind::Activity, Layer::Oa, "Source work", None) + .unwrap(); + let first = p + .add_element(Kind::Function, Layer::Sa, "First target", None) + .unwrap(); + let second = p + .add_element(Kind::Function, Layer::Sa, "Second target", None) + .unwrap(); + let trace = Id::new_v4(); + p.traces.insert( + trace, + Trace { + id: trace, + alias: "work_trace".into(), + name: "Work trace".into(), + description: String::new(), + oa: source, + sa: first, + kind: TraceKind::Refinement, + baseline: None, + }, + ); + let mut workflow = Workflow::transition(&p, source).unwrap(); + workflow + .update( + &p, + Event::Resolve( + source, + TransitionField::Name, + FieldDecision::Value("Merged for first target".into()), + ), + ) + .unwrap(); + assert!( + transition(&workflow).request.mappings.is_empty(), + "First target was inferred" + ); + let choose = |id| crate::app::Choice { + id, + label: String::new(), + }; + workflow + .update(&p, Event::MappingTarget(source, choose(first))) + .unwrap(); + assert_eq!( + transition(&workflow).request.resolutions.len(), + 1, + "Same inferred target preserves merged draft" + ); + workflow + .update(&p, Event::MappingTarget(source, choose(second))) + .unwrap(); + assert!(transition(&workflow).request.resolutions.is_empty()); + assert!( + transition(&workflow) + .plan + .conflicts + .iter() + .any(|c| c.source == Some(source) + && c.target == Some(second) + && c.field == Some(TransitionField::Name)) + ); + assert!( + workflow.update(&p, Event::Apply).is_err(), + "Old resolution cannot overwrite second target" + ); + workflow + .update( + &p, + Event::Resolve( + source, + TransitionField::Name, + FieldDecision::Value("Reviewed second target".into()), + ), + ) + .unwrap(); + workflow + .update(&p, Event::MappingTarget(source, choose(second))) + .unwrap(); + assert_eq!(transition(&workflow).request.resolutions.len(), 1); + let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(accepted.elements[&second].name, "Reviewed second target"); + assert_eq!(accepted.elements[&first].name, "First target"); + + workflow + .update(&p, Event::MappingTarget(source, choose(Id::nil()))) + .unwrap(); + assert!( + transition(&workflow).request.resolutions.is_empty(), + "Creation is a different target context" + ); + workflow + .update( + &p, + Event::Resolve( + source, + TransitionField::Description, + FieldDecision::Value("Draft for inferred function kind".into()), + ), + ) + .unwrap(); + workflow + .update(&p, Event::MappingKind(source, Kind::Function)) + .unwrap(); + assert_eq!( + transition(&workflow).request.resolutions.len(), + 1, + "Same inferred kind preserves draft" + ); + + let participant = p + .add_element(Kind::Entity, Layer::Oa, "Participant", None) + .unwrap(); + let mut kinds = Workflow::transition(&p, participant).unwrap(); + kinds + .update(&p, Event::MappingKind(participant, Kind::SystemActor)) + .unwrap(); + kinds + .update( + &p, + Event::Resolve( + participant, + TransitionField::Description, + FieldDecision::Value("Actor review".into()), + ), + ) + .unwrap(); + kinds + .update(&p, Event::MappingKind(participant, Kind::SystemActor)) + .unwrap(); + assert_eq!(transition(&kinds).request.resolutions.len(), 1); + kinds + .update(&p, Event::MappingKind(participant, Kind::System)) + .unwrap(); + assert!( + transition(&kinds).request.resolutions.is_empty(), + "Changed kind requires a new decision" + ); +} + +#[test] +fn behavior_and_data_authoring_do_not_place_nodes_in_unrelated_architecture_views() { + let p = Project::demo(); + let scenario = p + .elements + .values() + .find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Scenario) + .unwrap() + .id; + let mut app = App::with_project(p); + send(&mut app, Message::Layer(Layer::Sa)); + send(&mut app, Message::Page(Page::Behavior)); + let diagrams = app.project().diagrams.clone(); + send(&mut app, Message::Select(scenario)); + send(&mut app, Message::CreateKind(Kind::Occurrence)); + send(&mut app, Message::Add(true)); + let fragment = app.selected; + send( + &mut app, + Message::FieldVariant( + "/body/Sa/Shared/Occurrence/body".into(), + json!({"Fragment":{"operator":"Optional","branches":[]}}), + ), + ); + assert!(app.apply(), "{}", app.status); + send(&mut app, Message::Select(fragment)); + send(&mut app, Message::Add(true)); + let branch = app.selected; + send(&mut app, Message::Select(branch)); + send(&mut app, Message::Add(true)); + let occurrence = app.selected; + assert_eq!(app.project().elements[&fragment].parent, Some(scenario)); + assert!( + app.project().elements[&scenario] + .steps() + .unwrap() + .contains(&fragment) + ); + assert!(matches!(app.project().elements[&fragment].shared(), + Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { branches, .. }, .. }) + if branches == &vec![branch])); + assert_eq!(app.project().elements[&occurrence].parent, Some(branch)); + assert_eq!( + app.project().elements[&branch].steps().unwrap(), + &vec![occurrence] + ); + assert_eq!(app.project().diagrams, diagrams); + + send(&mut app, Message::Page(Page::Data)); + send(&mut app, Message::CreateKind(Kind::ExchangeItem)); + send(&mut app, Message::Add(false)); + assert_eq!( + app.project().elements[&app.selected].kind(), + Kind::ExchangeItem + ); + assert_eq!(app.project().diagrams, diagrams); + + send(&mut app, Message::Page(Page::Diagram)); + let active = app.active_diagram.unwrap(); + send(&mut app, Message::CreateKind(Kind::Function)); + send(&mut app, Message::Add(false)); + assert_eq!( + app.project().diagrams[&active].nodes.len(), + diagrams[&active].nodes.len() + 1 + ); + assert!( + app.project().diagrams[&active] + .nodes + .values() + .any(|node| node.element == app.selected) + ); + for (id, before) in &diagrams { + if *id != active { + assert_eq!(&app.project().diagrams[id], before); + } + } +} diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs new file mode 100644 index 00000000..3dc71441 --- /dev/null +++ b/crates/oa-desktop/src/views.rs @@ -0,0 +1,1133 @@ +use crate::{ + app::{App, Choice, FileOperation, Message, Page, Pending}, + diagram::DiagramCanvas, + forms, style, +}; +use iced::{ + Element as View, Fill, Font, + widget::{ + self, Space, button, column, container, pick_list, row, scrollable, text, text_input, + }, +}; +use oa_core::*; + +pub fn label<'a>(s: impl Into) -> widget::Text<'a> { + text(s.into()).size(11).color(style::MUTED) +} +pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> { + button(text(s.to_owned()).size(12)) + .padding([8, 10]) + .on_press(m) + .style(style::nav) +} +pub fn short(s: &str, limit: usize) -> String { + let s = s.split_whitespace().collect::>().join(" "); + if s.chars().count() > limit { + format!( + "{}…", + s.chars().take(limit.saturating_sub(1)).collect::() + ) + } else { + s + } +} +pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> { + column![ + text(title.to_owned()).size(24).color(style::INK), + label(subtitle) + ] + .spacing(6) + .into() +} + +pub fn view(app: &App) -> View<'_, Message> { + let p = app.project(); + let header = container( + row![ + row![ + text("◈").size(28).color(style::TEAL), + column![ + text("ARCADIA STUDIO").size(14), + label("SYSTEMS ENGINEERING · V2") + ] + .spacing(3) + ] + .spacing(10) + .width(220), + column![ + text(short(&p.name, 46)).size(17), + label(if app.dirty() { + "Unsaved changes" + } else if app.path.is_some() { + "All changes saved" + } else { + "Local project" + }) + ] + .spacing(3) + .width(Fill), + action("New", Message::Request(Pending::New)), + action("Open", Message::Request(Pending::Open)), + action("Save as", Message::Save(true)), + button(text("Save project").size(12)) + .padding([10, 16]) + .on_press(Message::Save(false)) + ] + .spacing(8) + .align_y(iced::Center), + ) + .padding([14, 18]) + .style(style::panel); + let mut nav = column![ + label("ENGINEERING PERSPECTIVE"), + row![ + button(text("01 Operational").size(11)) + .padding(9) + .width(Fill) + .on_press(Message::Layer(Layer::Oa)) + .style(if app.layer == Layer::Oa { + button::primary + } else { + style::nav + }), + button(text("02 System").size(11)) + .padding(9) + .width(Fill) + .on_press(Message::Layer(Layer::Sa)) + .style(if app.layer == Layer::Sa { + button::primary + } else { + style::nav + }) + ] + .spacing(3), + Space::new().height(5) + ] + .spacing(8); + let mut task_nav = row![].spacing(2); + for (page, name) in [ + (Page::Diagram, "Architecture"), + (Page::Catalog, "Model explorer"), + (Page::Behavior, "Behavior & scenarios"), + (Page::Data, "Data & interfaces"), + (Page::Traces, "Traceability"), + (Page::Text, "Textual model"), + (Page::Validation, "Model checks"), + (Page::Guide, "Engineering guide"), + ] { + task_nav = task_nav.push( + button(text(name).size(12)) + .padding([9, 11]) + .on_press(Message::Page(page)) + .style(if app.page == page { + style::selected + } else { + style::nav + }), + ); + } + let task_nav = container(task_nav) + .width(Fill) + .padding([4, 14]) + .style(style::panel); + let mut creation = column![label("CREATE ELEMENT")].spacing(8); + let kinds = Kind::ALL + .into_iter() + .filter(|k| k.supports(app.layer)) + .collect::>(); + creation = creation.push( + pick_list(kinds, Some(app.create_kind), Message::CreateKind) + .text_size(12) + .padding(8) + .width(Fill), + ); + let allocated = app.create_kind.is_work() + && p.elements + .get(&app.selected) + .is_some_and(|e| e.layer() == app.layer && e.kind().is_performer()); + creation = creation.push( + row![ + button(text("+ At root").size(11)) + .padding(8) + .style(style::nav) + .width(Fill) + .on_press_maybe( + validation::can_parent(p, app.create_kind, app.layer, None) + .then_some(Message::Add(false)) + ), + button( + text(if allocated { + "+ Allocated" + } else { + "+ In selected" + }) + .size(11) + ) + .padding(8) + .style(style::nav) + .width(Fill) + .on_press_maybe( + (allocated + || validation::can_parent(p, app.create_kind, app.layer, Some(app.selected))) + .then_some(Message::Add(true)) + ) + ] + .spacing(3), + ); + nav = nav.push( + text_input("Search names or @aliases…", &app.search) + .on_input(Message::Search) + .size(12) + .padding(9), + ); + let query = app.search.to_lowercase(); + let mut elements = p + .elements + .values() + .filter(|e| { + e.layer() == app.layer + && (query.is_empty() + || e.name.to_lowercase().contains(&query) + || e.alias + .to_lowercase() + .contains(query.trim_start_matches('@'))) + }) + .collect::>(); + elements.sort_by_key(|e| (tree_path(p, e.id), e.alias.clone())); + let mut explorer = column![].spacing(2); + if elements.is_empty() { + explorer = explorer.push(label(if query.is_empty() { + "No elements in this perspective. Create your first element below." + } else { + "No matching elements in this perspective." + })); + } + for e in elements { + if query.is_empty() && tree_hidden(app, e.id) { + continue; + } + let depth = ancestors(p, e.id).min(5); + let children = p.children(e.id).next().is_some(); + explorer = explorer.push(row![ + button( + text(if children { + if app.explorer_collapsed.contains(&e.id) { + "›" + } else { + "⌄" + } + } else { + "·" + }) + .size(14) + .color(style::kind_color(e.kind())) + ) + .style(style::nav) + .padding([6, 3]) + .on_press_maybe(children.then_some(Message::CollapseExplorer(e.id))), + button(column![text(short(&e.name, 29)).size(11), label(e.kind().label())].spacing(2)) + .padding(iced::Padding { + left: 3. + depth as f32 * 8., + right: 6., + top: 6., + bottom: 6. + }) + .width(Fill) + .on_press(Message::Select(e.id)) + .style(if app.selected == e.id { + style::selected + } else { + style::nav + }) + ]); + } + nav = nav + .push( + scrollable(explorer.padding(iced::Padding { + right: 10., + ..Default::default() + })) + .id("model-explorer") + .height(Fill), + ) + .push(creation) + .push(action("Project properties", Message::SelectProject).width(Fill)); + let sidebar = container(nav) + .padding(14) + .width(230) + .height(Fill) + .style(style::panel); + let main = match app.page { + Page::Diagram => diagram(app), + Page::Catalog | Page::Data => catalog(app), + Page::Behavior => crate::behavior::view(app), + Page::Traces => traces(app), + Page::Text => source(app), + Page::Validation => checks(app), + Page::Guide => crate::guide::view(&app.guide), + }; + let mut body = row![sidebar, container(main).width(Fill).height(Fill)].height(Fill); + if !matches!(app.page, Page::Text | Page::Guide) { + body = body.push(inspector(app)); + } + let footer = container( + row![ + text(short(if app.busy { "Working…" } else { &app.status }, 145)) + .size(11) + .width(Fill), + label(format!( + "{} objects · {} links", + p.elements.len(), + p.relations.len() + )), + action( + &format!("{} checks", app.issue_count()), + Message::Page(Page::Validation) + ) + ] + .spacing(12) + .align_y(iced::Center), + ) + .padding([4, 16]) + .style(style::panel); + let root: View<'_, Message> = column![header, task_nav, body, footer].into(); + if let Some(workflow) = &app.workflow { + return modal(root, workflow.view(p)); + } + if let Some(p) = &app.file_prompt { + let title = match p.operation { + FileOperation::Open => "Open model", + FileOperation::Save => "Save model", + FileOperation::Export => "Export diagram", + }; + return modal( + root, + column![ + text(title).size(21), + label( + "Use .arcadia for canonical source, .json for the model, or .svg for diagrams." + ), + text_input("File path", &p.path) + .on_input(Message::FilePath) + .on_submit(Message::SubmitFile) + .padding(10) + .size(13), + text(if p.overwrite { + "This file exists. Replace it with the current model?" + } else { + "Your model includes both layers, diagrams and traceability." + }) + .size(12), + row![ + action("Cancel", Message::CancelFile), + button(text(if p.overwrite { "Replace file" } else { title }).size(12)) + .padding(10) + .on_press(Message::SubmitFile) + ] + .spacing(10) + ] + .spacing(16) + .into(), + ); + } + if app.delete_pending { + return modal(root,column![text("Delete from model?").size(21),text(format!("{} and its owned elements will be removed. Incident relationships, occurrences and view placements are updated together. Counterparts in the other layer remain.",p.named(app.selected))).size(13),label("Undo restores the complete change. Use Hide for diagram-only removal."),row![action("Cancel",Message::CancelDelete),button("Delete from model").style(button::danger).padding(10).on_press(Message::ConfirmDelete)].spacing(10)].spacing(16).into()); + } + if app.pending.is_some() { + return modal(root,column![text("Keep your changes?").size(21),text("Save the model and pending edits before continuing. Invalid drafts stay available for correction.").size(13),row![action("Cancel",Message::Cancel),action("Discard changes",Message::Discard),button("Save & continue").padding(10).on_press(Message::SaveAndContinue)].spacing(8)].spacing(16).into()); + } + root +} +fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> { + widget::stack![ + base, + widget::opaque( + container(container(content).padding(26).width(800).style(style::card)) + .center(Fill) + .style(|_| container::Style { + background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()), + ..Default::default() + }) + ) + ] + .into() +} +fn ancestors(p: &Project, id: Id) -> usize { + let mut current = p.elements.get(&id).and_then(|e| e.parent); + let mut depth = 0; + while let Some(id) = current { + depth += 1; + if depth > 64 { + break; + } + current = p.elements.get(&id).and_then(|e| e.parent); + } + depth +} +fn tree_hidden(app: &App, id: Id) -> bool { + let mut current = app.project().elements.get(&id).and_then(|e| e.parent); + let mut seen = std::collections::BTreeSet::new(); + while let Some(id) = current { + if !seen.insert(id) { + return true; + } + if app.explorer_collapsed.contains(&id) { + return true; + } + current = app.project().elements.get(&id).and_then(|e| e.parent); + } + false +} +fn tree_path(p: &Project, id: Id) -> String { + let mut chain = vec![]; + let mut current = Some(id); + while let Some(id) = current { + if chain.len() > 64 { + break; + } + let Some(e) = p.elements.get(&id) else { + break; + }; + chain.push(e.name.clone()); + current = e.parent; + } + chain.reverse(); + chain.join("/") +} + +fn diagram(app: &App) -> View<'_, Message> { + let 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 + }, + ), + }) + .collect::>(); + let selected = diagrams + .iter() + .find(|c| Some(c.id) == app.active_diagram) + .cloned(); + let mut chooser = row![ + pick_list(diagrams, selected, Message::Diagram) + .placeholder("Choose diagram") + .text_size(12) + .padding(8) + .width(Fill), + action("+ View", Message::AddDiagram) + ] + .spacing(7) + .align_y(iced::Center); + let mut controls = row![ + action("Fit", Message::Fit), + action("−", Message::Zoom(1. / 1.2)), + label(format!("{:.0}%", app.zoom * 100.)), + action("+", Message::Zoom(1.2)), + action("Export SVG", Message::Export) + ] + .spacing(7) + .align_y(iced::Center); + if let Some(id) = app.active_diagram { + chooser = chooser.push(action("View properties", Message::Select(id))); + controls = controls.push(action("Arrange", Message::Command(Command::Layout(id)))); + } + let toolbar = column![chooser, controls].spacing(6); + let projection = app + .active_diagram + .and_then(|id| app.project().diagrams.get(&id)) + .map(|d| d.projection); + let modes = row![ + label("View content"), + pick_list( + [ + ProjectionKind::Allocation, + ProjectionKind::FunctionalDataflow, + ProjectionKind::Capabilities, + ProjectionKind::Decomposition, + ProjectionKind::Overview, + ProjectionKind::Traceability, + ], + projection, + Message::Projection + ) + .padding(8) + .text_size(12) + ] + .spacing(10) + .align_y(iced::Center); + let canvas: View<'_, Message> = if let Some(id) = app.active_diagram { + widget::sensor( + widget::canvas(DiagramCanvas { + project: app.project(), + diagram: id, + selected: app.selected, + zoom: app.zoom, + pan: app.pan, + }) + .width(Fill) + .height(Fill), + ) + .on_show(Message::CanvasResized) + .on_resize(Message::CanvasResized) + .into() + } else { + container( + column![ + text(if app.layer == Layer::Oa { + "Define the operational context" + } else { + "Define your system boundary" + }) + .size(23), + text("Create a view, then add participants and the work they perform.") + .size(13) + .color(style::MUTED), + button("Create architecture view") + .padding(12) + .on_press(Message::AddDiagram) + ] + .spacing(16), + ) + .center(Fill) + .into() + }; + column![container(column![toolbar,modes,label("Drag to arrange · scroll to zoom · nesting expresses parent or allocation · select for properties")].spacing(8)).padding(14),canvas].into() +} + +fn inspector(app: &App) -> View<'_, Message> { + let p = app.project(); + let tabs = row![ + action( + "Properties", + Message::InspectorTab(crate::navigation::InspectorTab::Properties) + ) + .style( + if app.inspector_tab == crate::navigation::InspectorTab::Properties { + style::selected + } else { + style::nav + } + ), + action( + "Connections", + Message::InspectorTab(crate::navigation::InspectorTab::Connections) + ) + .style( + if app.inspector_tab == crate::navigation::InspectorTab::Connections { + style::selected + } else { + style::nav + } + ), + ] + .spacing(4); + if app.inspector_tab == crate::navigation::InspectorTab::Connections { + return container( + column![ + tabs, + scrollable(crate::navigation::view(p, app.selected)).height(Fill) + ] + .spacing(14), + ) + .padding(14) + .width(320) + .height(Fill) + .style(style::panel) + .into(); + } + let mut body = column![ + row![ + label("PROPERTIES"), + Space::new().width(Fill), + action("Source", Message::SourceForSelection) + ] + .align_y(iced::Center) + ] + .spacing(12); + if let Some(e) = p.elements.get(&app.selected) { + body = body.push( + row![ + text(e.kind().label()) + .size(17) + .color(style::kind_color(e.kind())), + Space::new().width(Fill), + label(e.layer().to_string()) + ] + .align_y(iced::Center), + ); + body = body.push( + pick_list( + Kind::ALL + .into_iter() + .filter(|k| k.supports(e.layer())) + .collect::>(), + Some(e.kind()), + Message::ElementKind, + ) + .text_size(12) + .padding(8) + .width(Fill), + ); + if let Some(parent) = e.parent { + body = body.push( + action( + &format!("Parent: {}", short(p.named(parent), 27)), + Message::Select(parent), + ) + .width(Fill), + ); + } + for r in p + .relations + .values() + .filter(|r| r.kind() == RelationKind::Allocation && r.target() == e.id) + { + body = body.push( + action( + &format!("Performer: {}", short(p.named(r.source()), 24)), + Message::Select(r.source()), + ) + .width(Fill), + ); + } + if e.kind() == Kind::Port { + body = body.push(label( + "Shown automatically on the boundary of its visible owner.", + )); + } else if app + .active_diagram + .and_then(|id| p.diagrams.get(&id)) + .is_some_and(|d| !d.projection.includes(e.kind())) + { + body = body.push(label( + "This element is outside the selected view's content.", + )); + body = body.push(action( + "Use all-relationships view", + Message::Projection(ProjectionKind::Overview), + )); + } else if let Some(node) = app.selected_node { + body = body.push( + row![ + action("Placement", Message::Select(node)), + action("Fold / unfold", Message::ToggleCollapse), + action("Hide", Message::HideSelected) + ] + .spacing(2), + ); + } else { + body = body.push(action("Show in this diagram", Message::PlaceSelected)); + } + } + let kind = p.elements.get(&app.selected).map(Element::kind); + let contextual: &[(&str, RelationKind, bool)] = match kind { + Some(Kind::OperationalMission | Kind::Mission) => &[ + ( + "Exploit capability…", + RelationKind::MissionCapability, + false, + ), + ( + "Involve participant…", + RelationKind::MissionInvolvement, + true, + ), + ], + Some(Kind::Capability | Kind::SystemCapability) => &[ + ( + "Include capability…", + RelationKind::CapabilityInclude, + false, + ), + ("Extend capability…", RelationKind::CapabilityExtend, false), + ( + "Specialize capability…", + RelationKind::CapabilityGeneralization, + false, + ), + ( + "Involve participant or work…", + RelationKind::CapabilityInvolvement, + true, + ), + ], + _ => &[], + }; + if !contextual.is_empty() { + let choices = contextual + .iter() + .map(|(label, kind, incoming)| crate::workflow::RelationAction { + label, + kind: *kind, + incoming: *incoming, + }) + .collect::>(); + body = body.push( + pick_list(choices, None::, |choice| { + Message::AddRelationKind(choice.kind, choice.incoming) + }) + .placeholder("Modeling actions…") + .padding(9) + .text_size(12) + .width(Fill), + ); + } + 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) + .text_size(12) + .padding(8) + .width(Fill), + ); + } + body = body.push( + row![ + button(text("Apply changes").size(12)) + .padding([9, 12]) + .on_press(Message::Apply) + .width(Fill), + action("Revert", Message::RevertProperties) + ] + .spacing(5), + ); + if let Some(d) = &app.draft { + body = body.push(forms::view(d, p)); + } + let links = p + .relations + .values() + .filter(|r| r.source() == app.selected || r.target() == app.selected) + .collect::>(); + if !links.is_empty() { + body = body + .push(Space::new().height(6)) + .push(label("RELATED ELEMENTS")); + for r in links { + let other = if r.source() == app.selected { + r.target() + } else { + r.source() + }; + body = body.push( + container( + column![ + action( + &format!("{} · {}", r.kind(), short(&r.name, 24)), + Message::Select(r.id) + ) + .width(Fill), + action( + &format!("→ {}", short(p.named(other), 34)), + Message::Select(other) + ) + .width(Fill) + ] + .spacing(2), + ) + .style(style::subtle), + ); + } + } + for t in p + .traces + .values() + .filter(|t| t.oa == app.selected || t.sa == app.selected) + { + let other = if t.oa == app.selected { t.sa } else { t.oa }; + body = body.push( + action( + &format!( + "{}: {}", + if t.oa == app.selected { + "Realized by" + } else { + "Realizes" + }, + short(p.named(other), 29) + ), + Message::Select(other), + ) + .width(Fill), + ); + } + if p.layer_of(app.selected).is_some() { + body = body + .push(action("+ Relationship", Message::AddRelation).width(Fill)) + .push( + button(text("Create / update counterpart…").size(12)) + .padding(10) + .on_press(Message::StartTransition) + .width(Fill), + ); + } + body = body.push(action("+ Artifact reference", Message::AddArtifact).width(Fill)); + if app.selected != p.id { + body = body.push( + button(text("Delete from model…").size(11)) + .padding(9) + .style(button::danger) + .on_press(Message::Delete), + ); + } + container( + column![ + tabs, + scrollable(body.padding(iced::Padding { + right: 14., + ..Default::default() + })) + .id("inspector") + .height(Fill) + ] + .spacing(12), + ) + .padding(14) + .width(320) + .height(Fill) + .style(style::panel) + .into() +} + +fn catalog(app: &App) -> View<'_, Message> { + let data = app.page == Page::Data; + let p = app.project(); + let mut content = column![heading( + if data { + "Data & interfaces" + } else { + "Model explorer" + }, + if data { + "Define information, material and energy at the system boundary." + } else { + "A shared model across every diagram, editor and automation." + } + )] + .spacing(15); + for kind in Kind::ALL { + let is_data = matches!( + kind, + Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port + ); + if data && !is_data { + continue; + } + let elements = p + .elements + .values() + .filter(|e| e.layer() == app.layer && e.kind() == kind) + .collect::>(); + if elements.is_empty() { + continue; + } + content = content.push( + text(format!("{} · {}", kind.label(), elements.len())) + .size(15) + .color(style::kind_color(kind)), + ); + for e in elements { + content = content.push( + button( + row![ + column![ + text(&e.name).size(14), + label(format!( + "@{}{}", + e.alias, + e.parent + .map(|id| format!(" · in {}", p.named(id))) + .unwrap_or_default() + )), + text(short(&e.description, 125)) + .size(12) + .color(style::MUTED) + ] + .spacing(5) + .width(Fill), + text("→").size(17) + ] + .align_y(iced::Center), + ) + .padding(14) + .width(Fill) + .style(if e.id == app.selected { + style::selected + } else { + style::nav + }) + .on_press(Message::Select(e.id)), + ); + } + } + if !data { + content = content.push(text("Relationships").size(18)); + for r in p + .relations + .values() + .filter(|r| p.layer_of(r.id) == Some(app.layer)) + { + content = content.push( + button( + column![ + text(format!( + "{} → {}", + short(p.named(r.source()), 33), + short(p.named(r.target()), 33) + )) + .size(12), + label(format!("{} · {}", r.kind(), r.name)) + ] + .spacing(5), + ) + .padding(12) + .width(Fill) + .style(style::nav) + .on_press(Message::Select(r.id)), + ); + } + } + scrollable(content.padding(24)).into() +} + +fn traces(app: &App) -> View<'_, Message> { + let p = app.project(); + let mut out = column![ + heading( + "Traceability", + "Connect operational needs, system responsibilities and development evidence." + ), + row![ + button(text("Create / update counterpart…").size(12)) + .padding(9) + .on_press(Message::StartTransition), + action("+ Manual trace", Message::AddTrace), + action("+ Artifact", Message::AddArtifact) + ] + .spacing(8) + ] + .spacing(16); + for t in p.traces.values() { + 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", + _ => "No review baseline", + }; + out = out.push( + container( + column![ + row![ + action(p.named(t.oa), Message::Select(t.oa)).width(Fill), + text("→").color(style::TEAL), + action(p.named(t.sa), Message::Select(t.sa)).width(Fill) + ] + .spacing(8) + .align_y(iced::Center), + row![ + label(format!("{:?} · {current}", t.kind)), + Space::new().width(Fill), + action("Review link", Message::Select(t.id)) + ], + text(short(&t.description, 130)).size(12) + ] + .spacing(10), + ) + .padding(16) + .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()); + out = out.push( + container( + column![ + row![ + action(&a.name, Message::Select(a.id)).width(Fill), + label(format!("{:?}", a.relation)) + ], + text(&a.uri).size(12).font(Font::MONOSPACE), + label(format!( + "{}{}", + owner, + a.revision + .as_ref() + .map(|r| format!(" · revision {r}")) + .unwrap_or_default() + )) + ] + .spacing(8), + ) + .padding(16) + .style(style::card), + ); + } + 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)); + } + scrollable(out.padding(24)).into() +} + +fn source(app: &App) -> View<'_, Message> { + use crate::source_navigation::Event as SourceEvent; + let navigation: View<'_, Message> = if app.source_navigation.open { + column![ + row![ + text_input("Find literal text…", &app.source_navigation.query) + .id("source-find") + .on_input(|query| Message::SourceNavigation(SourceEvent::Query(query))) + .on_submit(Message::SourceNavigation(SourceEvent::Next(false))) + .padding(8) + .size(12) + .width(Fill), + action( + "Previous", + Message::SourceNavigation(SourceEvent::Next(true)) + ), + action("Next", Message::SourceNavigation(SourceEvent::Next(false))), + label(app.source_navigation.label()), + action("Close", Message::SourceNavigation(SourceEvent::Close)), + ] + .spacing(6) + .align_y(iced::Center), + row![ + text_input("Line", &app.source_navigation.line) + .id("source-line") + .on_input(|line| Message::SourceNavigation(SourceEvent::Line(line))) + .on_submit(Message::SourceNavigation(SourceEvent::Go)) + .padding(8) + .size(12) + .width(80), + action("Go to line", Message::SourceNavigation(SourceEvent::Go)), + label("Enter or F3: next match · Shift F3: previous · Esc: close"), + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(6) + .into() + } else { + column![].into() + }; + let mut diagnostics = column![].spacing(5); + for (index, issue) in app.source_diagnostics.iter().enumerate() { + diagnostics = diagnostics.push( + button( + text(format!( + "{:?} · {}:{} {}", + issue.severity, issue.line, issue.column, issue.message + )) + .size(12) + .color(match issue.severity { + validation::Severity::Error => iced::Color::from_rgb8(174, 46, 64), + validation::Severity::Warning => iced::Color::from_rgb8(145, 93, 23), + }), + ) + .width(Fill) + .padding([4, 6]) + .style(style::nav) + .on_press(Message::SourceDiagnostic(index)), + ); + } + let editor = widget::text_editor(&app.source_editor) + .id("source-editor") + .on_action(Message::SourceEdit) + .font(Font::MONOSPACE) + .size(14) + .padding(16) + .height(Fill) + .highlight_with::( + app.source_snapshot.clone(), + crate::highlight::format, + ); + column![ + container( + column![ + row![ + container(heading( + "Textual model", + "One typed model · stable @aliases · canonical source for Git" + )) + .width(Fill), + button(text("Apply to model").size(12)) + .padding(10) + .on_press(Message::ApplySource), + action("Revert draft", Message::RevertSource) + ] + .spacing(8) + .align_y(iced::Center), + row![ + action("Undo text", Message::SourceUndo), + action("Redo text", Message::SourceRedo), + action("Undo model", Message::Undo), + action("Find / go to…", Message::SourceNavigation(SourceEvent::Open(false))), + label(if app.source_dirty() { + "Draft · changes are not applied yet" + } else { + "In sync with graphical model" + }) + ] + .spacing(7) + .align_y(iced::Center), + label("Apply normalizes formatting and removes comments. Model names, descriptions and identifiers are preserved."), + navigation, + scrollable(diagnostics) + .height((app.source_diagnostics.len() as f32 * 32.).min(132.)) + ] + .spacing(12) + ) + .padding(18), + widget::sensor(editor).key(app.source_jump) + .on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport) + ] + .into() +} +fn checks(app: &App) -> View<'_, Message> { + let issues = &app.model_issues; + let mut out = column![heading( + "Model checks", + "Structural validity and engineering completeness, with direct navigation." + )] + .spacing(15); + if issues.is_empty() { + out = out.push( + container( + column![ + text("Model checks passed").size(19).color(style::TEAL), + label("No structural errors or completeness warnings.") + ] + .spacing(8), + ) + .padding(20) + .style(style::card), + ); + } + for issue in issues { + let mut card = + column![text(format!("{:?} · {}", issue.severity, issue.message)).size(13)].spacing(8); + if let Some(id) = issue.element { + card = card.push(action( + &format!("Open {}", app.project().named(id)), + Message::Select(id), + )); + } + out = out.push(container(card).padding(15).style(style::card)); + } + scrollable(out.padding(24)).into() +} diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs new file mode 100644 index 00000000..3cd8e042 --- /dev/null +++ b/crates/oa-desktop/src/workflow.rs @@ -0,0 +1,900 @@ +//! Explicit, reviewable relationship and cross-perspective operations. +use crate::{ + app::{Choice, Message}, + style, + views::short, +}; +use iced::{ + Element, Fill, + widget::{Space, button, column, pick_list, row, scrollable, text, text_editor, text_input}, +}; +use oa_core::{transitions::*, *}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RelationAction { + pub label: &'static str, + pub kind: RelationKind, + pub incoming: bool, +} +impl std::fmt::Display for RelationAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label) + } +} + +#[derive(Debug, Clone)] +pub enum Event { + Cancel, + Apply, + RelationName(String), + RelationKind(RelationKind), + Condition(text_editor::Action), + Source(Choice), + Target(Choice), + Scope(String), + MappingTarget(Id, Choice), + MappingKind(Id, Kind), + Resolve(Id, TransitionField, FieldDecision), + Preview, +} +pub enum Workflow { + Relation(Relationship), + ManualTrace { + name: String, + source: Choice, + target: Option, + }, + Transition(Box), +} +pub struct Relationship { + pub name: String, + pub kind: RelationKind, + pub source: Option, + pub target: Option, + pub condition: text_editor::Content, + layer: Layer, + sources: Vec, + targets: Vec, +} + +impl Relationship { + fn accepts_source(&self, p: &Project, id: Id) -> bool { + if p.layer_of(id) != Some(self.layer) { + return false; + } + if self.kind == RelationKind::ExchangeAllocation { + return p + .relations + .get(&id) + .is_some_and(|r| r.kind() == RelationKind::BoundaryExchange); + } + p.elements.get(&id).is_some_and(|e| { + Kind::ALL + .into_iter() + .any(|target| target.supports(self.layer) && self.kind.permits(e.kind(), target)) + }) + } + + fn refresh_choices(&mut self, p: &Project) { + self.sources = p + .elements + .keys() + .chain(p.relations.keys()) + .copied() + .filter(|id| self.accepts_source(p, *id)) + .map(|id| choice(p, id)) + .collect(); + self.sources.sort_by(|a, b| a.label.cmp(&b.label)); + self.targets = p + .elements + .keys() + .chain(p.relations.keys()) + .copied() + .filter(|id| { + self.source + .as_ref() + .is_some_and(|source| validation::can_connect(p, self.kind, source.id, *id)) + }) + .map(|id| choice(p, id)) + .collect(); + self.targets.sort_by(|a, b| a.label.cmp(&b.label)); + } + + fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String { + if !self.name.trim().is_empty() { + return self.name.clone(); + } + let verb = match self.kind { + RelationKind::CapabilityInclude => "includes", + RelationKind::CapabilityExtend => "extends", + RelationKind::CapabilityGeneralization => "specializes", + RelationKind::MissionCapability => "exploits", + RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => { + "participates in" + } + RelationKind::Allocation => "performs", + _ => return format!("{}: {} → {}", self.kind, p.named(source), p.named(target)), + }; + format!("{} {verb} {}", p.named(source), p.named(target)) + } +} +pub struct Transition { + pub request: TransitionRequest, + pub plan: TransitionPlan, +} +impl Workflow { + pub fn manual_trace(p: &Project, selected: Id) -> Result { + if p.layer_of(selected).is_none() + || !(p.elements.contains_key(&selected) || p.relations.contains_key(&selected)) + { + return Err("Select an OA or SA element or relationship to trace.".into()); + } + Ok(Self::ManualTrace { + name: "Engineering correspondence".into(), + source: choice(p, selected), + target: None, + }) + } + pub fn relationship(p: &Project, selected: Id, layer: Layer) -> Self { + let kind = match p.elements.get(&selected).map(oa_core::Element::kind) { + Some(Kind::OperationalMission | Kind::Mission) => RelationKind::MissionCapability, + Some(Kind::Capability | Kind::SystemCapability) => RelationKind::CapabilityInclude, + Some(Kind::Role) => RelationKind::RoleAssignment, + Some(Kind::Process | Kind::FunctionalChain) => RelationKind::ChainInvolvement, + Some(kind) if kind.is_performer() => RelationKind::Allocation, + Some(kind) if kind.is_state() => RelationKind::StateTransition, + _ if layer == Layer::Oa => RelationKind::Interaction, + _ => RelationKind::FunctionalExchange, + }; + Self::relationship_kind(p, selected, layer, kind, false) + } + + pub fn relationship_kind( + p: &Project, + selected: Id, + layer: Layer, + kind: RelationKind, + incoming: bool, + ) -> Self { + let mut draft = Relationship { + name: String::new(), + kind, + source: (!incoming).then(|| choice(p, selected)), + target: incoming.then(|| choice(p, selected)), + condition: text_editor::Content::new(), + layer, + sources: vec![], + targets: vec![], + }; + if draft + .source + .as_ref() + .is_some_and(|s| !draft.accepts_source(p, s.id)) + { + draft.source = None; + } + draft.refresh_choices(p); + Self::Relation(draft) + } + pub fn transition(p: &Project, selected: Id) -> Result { + let layer = p + .layer_of(selected) + .ok_or("Select an element or relationship in OA or SA.")?; + let direction = if layer == Layer::Oa { + TransitionDirection::OaToSa + } else { + TransitionDirection::SaToOa + }; + let mut request = TransitionRequest::new(direction, vec![selected]); + request.scope = DependencyScope::Required; + let plan = plan_transition(p, &request).map_err(|e| e.to_string())?; + Ok(Self::Transition(Box::new(Transition { request, plan }))) + } + /// Return a complete accepted candidate only on explicit Apply. Intermediate + /// choices and previews never mutate the Session. + pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + match self { + Self::ManualTrace { + name, + source, + target, + } => match event { + Event::RelationName(value) => *name = value, + Event::Target(value) => *target = Some(value), + Event::Apply => { + let target = target + .as_ref() + .ok_or("Choose a counterpart in the other perspective.")? + .id; + if name.trim().is_empty() { + return Err("Give the trace an engineering name.".into()); + } + let (oa, sa) = if p.layer_of(source.id) == Some(Layer::Oa) { + (source.id, target) + } else { + (target, source.id) + }; + let id = Id::new_v4(); + let trace = Trace { + id, + alias: p.fresh_alias("correspondence", "trace", id), + name: name.clone(), + description: String::new(), + oa, + sa, + kind: TraceKind::Manual, + baseline: None, + }; + return execute_batch(p, &[Command::CreateTrace(trace)]) + .map(Some) + .map_err(|e| e.to_string()); + } + _ => {} + }, + Self::Relation(d) => match event { + Event::RelationName(name) => d.name = name, + Event::RelationKind(kind) => { + d.kind = kind; + d.target = None; + if d.source + .as_ref() + .is_some_and(|s| !d.accepts_source(p, s.id)) + { + d.source = None; + } + d.refresh_choices(p); + } + Event::Source(source) => { + d.source = Some(source); + if d.target.as_ref().is_some_and(|target| { + !validation::can_connect( + p, + d.kind, + d.source.as_ref().unwrap().id, + target.id, + ) + }) { + d.target = None; + } + d.refresh_choices(p); + } + Event::Target(target) => d.target = Some(target), + Event::Condition(action) => d.condition.perform(action), + Event::Apply => { + let a = d.source.as_ref().ok_or("Choose a source.")?.id; + let b = d.target.as_ref().ok_or("Choose a target.")?.id; + let id = Id::new_v4(); + let name = d.engineering_name(p, a, b); + let mut body = RelationBody::new(d.kind, a, b); + if let RelationBody::CapabilityExtend { condition, .. } = &mut body { + *condition = d.condition.text(); + } + let relation = Relation { + id, + alias: p.fresh_alias(&name, "relation", id), + name, + description: String::new(), + body, + }; + return execute_batch(p, &[Command::CreateRelation(relation)]) + .map(Some) + .map_err(|e| e.to_string()); + } + _ => {} + }, + Self::Transition(d) => { + match event { + Event::Scope(scope) => { + d.request.scope = match scope.as_str() { + "Required references" => DependencyScope::Required, + "Whole subtree" => DependencyScope::Subtree, + _ => DependencyScope::SelectedOnly, + } + } + Event::MappingTarget(source, target) => { + let target = (!target.id.is_nil()).then_some(target.id); + let previous = d + .request + .mappings + .iter() + .find(|m| m.source == source) + .map(|m| m.target) + .unwrap_or_else(|| { + d.plan + .changes + .iter() + .find(|c| c.source == source) + .map(|c| c.target) + .or_else(|| { + d.plan + .conflicts + .iter() + .find(|c| c.source == Some(source)) + .and_then(|c| c.target) + }) + // Planned new identities are not an existing counterpart. + .filter(|id| p.contains(*id)) + }); + if previous != target { + d.request.resolutions.retain(|r| r.source != source); + } + if let Some(mapping) = + d.request.mappings.iter_mut().find(|m| m.source == source) + { + mapping.target = target; + } else { + d.request.mappings.push(TransitionMapping { + source, + target, + target_kind: None, + }); + } + } + Event::MappingKind(source, kind) => { + let mapping = d.request.mappings.iter().find(|m| m.source == source); + let planned_target = d + .plan + .changes + .iter() + .find(|c| c.source == source) + .map(|c| c.target); + let previous = mapping + .and_then(|m| m.target_kind) + .or_else(|| { + planned_target.and_then(|target| { + p.elements.get(&target).map(|e| e.kind()).or_else(|| { + d.plan.commands.iter().find_map(|command| match command { + Command::CreateElement(e) if e.id == target => { + Some(e.kind()) + } + _ => None, + }) + }) + }) + }) + .or_else(|| { + p.elements.get(&source).and_then(|e| { + let kinds = counterpart_kinds(e.kind(), d.request.direction); + (kinds.len() == 1).then(|| kinds[0]) + }) + }); + // Adding a kind mapping explicitly requests creation. If + // it replaces an inferred existing target, that is also + // a new review context even when its kind is identical. + let replaces_existing = mapping.is_none() + && planned_target.is_some_and(|target| p.contains(target)); + if previous != Some(kind) || replaces_existing { + d.request.resolutions.retain(|r| r.source != source); + } + if let Some(mapping) = + d.request.mappings.iter_mut().find(|m| m.source == source) + { + mapping.target_kind = Some(kind); + } else { + d.request.mappings.push(TransitionMapping { + source, + target: None, + target_kind: Some(kind), + }); + } + } + Event::Resolve(source, field, decision) => { + d.request + .resolutions + .retain(|r| r.source != source || r.field != field); + d.request.resolutions.push(FieldResolution { + source, + field, + decision, + }); + } + Event::Apply => { + let mut candidate = + apply_transition(p, &d.plan).map_err(|e| e.to_string())?; + 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., + }, + ); + } + } + return execute_batch(&candidate, &[]) + .map(Some) + .map_err(|e| e.to_string()); + } + _ => {} + } + d.plan = plan_transition(p, &d.request).map_err(|e| e.to_string())?; + } + } + Ok(None) + } + pub fn title(&self) -> &'static str { + match self { + Self::ManualTrace { .. } => "Link existing counterparts", + Self::Relation(_) => "Create relationship", + Self::Transition(t) => { + if t.request.direction == TransitionDirection::OaToSa { + "Operational → System" + } else { + "System → Operational" + } + } + } + } + pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> { + let mut content = column![text(self.title()).size(23)].spacing(14); + match self { + Self::ManualTrace { + name, + source, + target, + } => { + let mut choices = p + .elements + .keys() + .chain(p.relations.keys()) + .filter(|id| { + p.layer_of(**id) + .is_some_and(|layer| Some(layer) != p.layer_of(source.id)) + }) + .map(|id| choice(p, *id)) + .collect::>(); + choices.sort_by(|a, b| a.label.cmp(&b.label)); + content = content.push(text("Record an explicit engineering correspondence between existing records. This link preserves both records and does not propagate properties.").size(13).color(style::MUTED)) + .push(text(format!("From: {}",source.label)).size(14)) + .push(text_input("Trace name",name).padding(10).on_input(|v|Message::Workflow(Event::RelationName(v)))) + .push(pick_list(choices,target.clone(),|v|Message::Workflow(Event::Target(v))).placeholder("Choose counterpart in the other perspective").width(Fill).padding(10).text_size(13)); + } + Self::Relation(d) => { + let kinds = RelationKind::ALL + .into_iter() + .filter(|kind| { + *kind == RelationKind::ExchangeAllocation + || Kind::ALL.into_iter().any(|source| { + source.supports(d.layer) + && Kind::ALL.into_iter().any(|target| { + target.supports(d.layer) && kind.permits(source, target) + }) + }) + }) + .collect::>(); + let (source_label, target_label) = match d.kind { + RelationKind::CapabilityInclude => { + ("Including capability", "Included capability") + } + RelationKind::CapabilityExtend => { + ("Extending capability", "Extended capability") + } + RelationKind::CapabilityGeneralization => { + ("Specific capability", "General capability") + } + RelationKind::MissionCapability => ("Mission", "Exploited capability"), + RelationKind::MissionInvolvement => ("Participant", "Mission"), + RelationKind::CapabilityInvolvement => ("Participant or work", "Capability"), + _ => ("Source", "Target"), + }; + content = content + .push(text(d.kind.hint()).size(13).color(style::MUTED)) + .push( + pick_list(kinds, Some(d.kind), |v| { + Message::Workflow(Event::RelationKind(v)) + }) + .text_size(13) + .padding(9) + .width(Fill), + ) + .push(text(source_label).size(12)) + .push( + pick_list(d.sources.as_slice(), d.source.as_ref(), |v| { + Message::Workflow(Event::Source(v)) + }) + .placeholder("Choose source") + .text_size(13) + .padding(9) + .width(Fill), + ) + .push(text(target_label).size(12)) + .push( + pick_list(d.targets.as_slice(), d.target.as_ref(), |v| { + Message::Workflow(Event::Target(v)) + }) + .placeholder("Choose a compatible target") + .text_size(13) + .padding(9) + .width(Fill), + ); + if d.source.is_some() && d.targets.is_empty() { + content = content.push(text("No compatible target yet. Create the needed model element, then connect it here.").size(12).color(style::MUTED)); + } + if d.kind == RelationKind::CapabilityExtend { + content = content.push(text("Extension condition").size(12)).push( + text_editor(&d.condition) + .placeholder("When does the extending capability add behavior?") + .on_action(|action| Message::Workflow(Event::Condition(action))) + .height(72) + .padding(9), + ); + } + content = content.push( + text_input("Name (generated from endpoints when blank)", &d.name) + .on_input(|v| Message::Workflow(Event::RelationName(v))) + .padding(10) + .size(13), + ); + if let (Some(source), Some(target)) = (&d.source, &d.target) { + content = content.push( + text(d.engineering_name(p, source.id, target.id)) + .size(13) + .color(style::TEAL), + ); + } + } + 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)); + let scope = match d.request.scope { + DependencyScope::SelectedOnly => "Selected only", + DependencyScope::Required => "Required references", + DependencyScope::Subtree => "Whole subtree", + } + .to_string(); + content = content.push( + row![ + text("Include").size(12), + pick_list( + vec![ + "Selected only".to_string(), + "Required references".to_string(), + "Whole subtree".to_string() + ], + Some(scope), + |v| Message::Workflow(Event::Scope(v)) + ) + .text_size(12) + .padding(8), + Space::new().width(Fill), + button(text("Refresh preview").size(12)) + .style(style::nav) + .on_press(Message::Workflow(Event::Preview)) + ] + .spacing(10) + .align_y(iced::Center), + ); + let sources = d + .request + .sources + .iter() + .copied() + .chain(d.plan.changes.iter().map(|c| c.source)) + .chain(d.plan.conflicts.iter().filter_map(|c| c.source)) + .collect::>(); + for source in sources { + let mapping = d.request.mappings.iter().find(|m| m.source == source); + let existing = p + .traces + .values() + .filter_map(|t| { + if d.request.direction == TransitionDirection::OaToSa && t.oa == source + { + Some(t.sa) + } else if d.request.direction == TransitionDirection::SaToOa + && t.sa == source + { + Some(t.oa) + } else { + None + } + }) + .collect::>(); + let target = mapping.map(|m| m.target).unwrap_or_else(|| { + if existing.len() == 1 { + Some(existing[0]) + } else { + None + } + }); + let mut targets = vec![Choice { + id: Id::nil(), + label: "Create new counterpart".into(), + }]; + targets.extend( + p.elements + .keys() + .chain(p.relations.keys()) + .filter(|id| { + p.layer_of(**id) == Some(d.request.direction.target_layer()) + && p.elements.contains_key(&source) + == p.elements.contains_key(id) + }) + .map(|id| choice(p, *id)), + ); + let selected = targets + .iter() + .find(|c| Some(c.id) == target.or(Some(Id::nil()))) + .cloned(); + let mut item = column![ + text(format!( + "{} · {}", + short(p.named(source), 70), + p.elements + .get(&source) + .map(|e| e.kind().label()) + .unwrap_or("Relationship") + )) + .size(14), + pick_list(targets, selected, move |v| Message::Workflow( + Event::MappingTarget(source, v) + )) + .text_size(12) + .padding(8) + .width(Fill) + ] + .spacing(7); + if target.is_none() + && let Some(e) = p.elements.get(&source) + { + let kinds = counterpart_kinds(e.kind(), d.request.direction); + if !kinds.is_empty() { + let selected = mapping.and_then(|m| m.target_kind).or_else(|| { + if kinds.len() == 1 { + Some(kinds[0]) + } else { + None + } + }); + item = item.push( + pick_list(kinds, selected, move |kind| { + Message::Workflow(Event::MappingKind(source, kind)) + }) + .placeholder("Choose counterpart kind / system boundary") + .text_size(12) + .padding(8) + .width(Fill), + ); + } + } + content = content.push( + iced::widget::container(item) + .padding(12) + .style(style::subtle), + ); + } + let mut changes = column![].spacing(8); + for change in &d.plan.changes { + changes = changes.push( + text(format!("{:?} · {}", change.action, p.named(change.source))) + .size(12) + .color(style::TEAL), + ); + for field in &change.fields { + changes = changes.push( + text(format!( + "{:?}: {} → {}", + field.field, + short(&field.before, 90), + short(&field.after, 90) + )) + .size(12), + ); + } + } + content = content.push(changes); + let mut fields = d + .plan + .conflicts + .iter() + .filter_map(|c| Some((c.source?, c.field?))) + .chain(d.request.resolutions.iter().map(|r| (r.source, r.field))) + .fold(Vec::new(), |mut out, field| { + if !out.contains(&field) { + out.push(field); + } + out + }); + // Keep property widgets in the same order while a resolution + // changes the preview, so continuous typing retains focus. + 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() { + general_conflicts = general_conflicts.push( + text(&conflict.message) + .size(12) + .color(iced::Color::from_rgb8(178, 54, 58)), + ); + } + } + content = content.push(general_conflicts); + for (source, field) in fields { + let target = d + .plan + .conflicts + .iter() + .find(|c| c.source == Some(source) && c.field == Some(field)) + .and_then(|c| c.target) + .or_else(|| { + d.request + .mappings + .iter() + .find(|m| m.source == source) + .and_then(|m| m.target) + }) + .or_else(|| { + d.plan + .changes + .iter() + .find(|c| c.source == source) + .map(|c| c.target) + }) + .or_else(|| { + p.traces.values().find_map(|t| { + if d.request.direction == TransitionDirection::OaToSa + && t.oa == source + { + Some(t.sa) + } else if d.request.direction == TransitionDirection::SaToOa + && t.sa == source + { + Some(t.oa) + } else { + None + } + }) + }); + let value = |id| { + p.properties(id) + .map(|v| match field { + TransitionField::Name => v.name, + TransitionField::Description => v.description, + }) + .unwrap_or_default() + }; + let resolution = d + .request + .resolutions + .iter() + .find(|r| r.source == source && r.field == field); + let merged = resolution + .and_then(|r| { + if let FieldDecision::Value(v) = &r.decision { + Some(v.as_str()) + } else { + None + } + }) + .unwrap_or(""); + let status = resolution + .map(|r| match &r.decision { + FieldDecision::KeepTarget => "Keep target", + FieldDecision::TakeSource => "Take source", + FieldDecision::Value(_) => "Use merged text", + }) + .unwrap_or("Choose how to resolve this conflict"); + let card = column![ + text(format!( + "{:?} · {} → {}", + field, + p.named(source), + target.map(|id| p.named(id)).unwrap_or("New counterpart") + )) + .size(14), + text(format!( + "Current target: {}", + target.map(value).unwrap_or_default() + )) + .size(12), + text(format!("Proposed source: {}", value(source))).size(12), + text(status).size(12).color(style::TEAL), + row![ + button(text("Keep target").size(12)) + .padding(8) + .style(style::nav) + .on_press(Message::Workflow(Event::Resolve( + source, + field, + FieldDecision::KeepTarget + ))), + button(text("Take source").size(12)) + .padding(8) + .style(style::nav) + .on_press(Message::Workflow(Event::Resolve( + source, + field, + FieldDecision::TakeSource + ))) + ] + .spacing(8), + text_input("Or enter merged text", merged) + .padding(9) + .size(12) + .on_input(move |v| Message::Workflow(Event::Resolve( + source, + field, + FieldDecision::Value(v) + ))) + ] + .spacing(8); + content = content.push( + iced::widget::container(card) + .padding(12) + .style(style::subtle), + ); + } + for warning in &d.plan.warnings { + content = content.push( + text(format!("Modeling warning · {warning}")) + .size(12) + .color(style::MUTED), + ); + } + content = content.push( + text(format!( + "{} changes · {} unresolved conflicts · {} modeling warnings", + d.plan.changes.len(), + d.plan.conflicts.len(), + d.plan.warnings.len() + )) + .size(12), + ); + } + } + let enabled = match self { + Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(), + Self::Relation(d) => d + .source + .as_ref() + .zip(d.target.as_ref()) + .is_some_and(|(a, b)| validation::can_connect(p, d.kind, a.id, b.id)), + Self::Transition(d) => d.plan.conflicts.is_empty(), + }; + column![ + scrollable(content.padding(iced::Padding { + right: 14., + ..Default::default() + })) + .height(480), + row![ + Space::new().width(Fill), + button(text("Cancel").size(12)) + .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))) + ] + .spacing(10) + ] + .spacing(18) + .into() + } +} +fn choice(p: &Project, id: Id) -> Choice { + Choice { + id, + label: format!( + "{} · {}", + p.named(id), + p.layer_of(id).map(|l| l.to_string()).unwrap_or_default() + ), + } +} diff --git a/crates/oa-server/Cargo.toml b/crates/oa-server/Cargo.toml new file mode 100644 index 00000000..d429783c --- /dev/null +++ b/crates/oa-server/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "oa-server" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "arcadia-server" +path = "src/main.rs" + +[dependencies] +oa-core = { path = "../oa-core" } +axum = { version = "0.8", default-features = false, features = ["http1", "json", "query", "tokio"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "io-util"] } +tower-http = { version = "0.6", features = ["limit"] } +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +thiserror.workspace = true +tempfile = "3" +subtle = "2" + +[dev-dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/crates/oa-server/src/error.rs b/crates/oa-server/src/error.rs new file mode 100644 index 00000000..d5251f4b --- /dev/null +++ b/crates/oa-server/src/error.rs @@ -0,0 +1,111 @@ +use axum::{ + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +pub type ApiResult = Result; + +#[derive(Debug, Serialize)] +pub struct ApiError { + #[serde(rename = "type")] + problem_type: Box, + pub title: &'static str, + pub status: u16, + pub code: &'static str, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_revision: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub issues: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IssueDto { + pub severity: &'static str, + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column: Option, +} + +impl ApiError { + pub fn new(status: StatusCode, code: &'static str, detail: impl Into) -> Self { + Self { + problem_type: format!("urn:arcadia:problem:{code}").into_boxed_str(), + title: status.canonical_reason().unwrap_or("Request failed"), + status: status.as_u16(), + code, + detail: detail.into(), + current_revision: None, + issues: vec![], + } + } + + pub fn bad_request(detail: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, "invalid_request", detail) + } + + pub fn missing() -> Self { + Self::new( + StatusCode::NOT_FOUND, + "project_not_found", + "Project does not exist.", + ) + } + + pub fn storage(error: std::io::Error) -> Self { + eprintln!("arcadia-server storage error: {error}"); + Self::new( + StatusCode::SERVICE_UNAVAILABLE, + "storage_unavailable", + "The storage operation failed. Reload the project before retrying a write.", + ) + } + + pub fn internal(detail: &str) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", detail) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut response = (status, axum::Json(self)).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/problem+json"), + ); + if status == StatusCode::UNAUTHORIZED { + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + header::HeaderValue::from_static("Bearer realm=\"arcadia\""), + ); + } + response + } +} + +pub fn issues(model: &oa_core::Project) -> Vec { + oa_core::validation::validate(model) + .into_iter() + .map(|issue| IssueDto { + severity: match issue.severity { + oa_core::validation::Severity::Error => "error", + oa_core::validation::Severity::Warning => "warning", + }, + message: issue.message, + code: issue.code, + field: issue.field, + object_id: issue.element, + line: None, + column: None, + }) + .collect() +} diff --git a/crates/oa-server/src/lib.rs b/crates/oa-server/src/lib.rs new file mode 100644 index 00000000..f2e99754 --- /dev/null +++ b/crates/oa-server/src/lib.rs @@ -0,0 +1,209 @@ +//! Headless REST service over the shared Arcadia model and command boundary. +pub mod error; +mod routes; +pub mod store; + +use axum::{ + Router, + extract::{DefaultBodyLimit, Request, State}, + http::{StatusCode, header}, + middleware::{self, Next}, + response::{IntoResponse, Response}, +}; +use error::{ApiError, ApiResult}; +use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use store::Store; +use subtle::ConstantTimeEq; +use tokio::sync::Semaphore; +use tower_http::limit::RequestBodyLimitLayer; + +#[derive(Clone)] +pub(crate) struct AppState { + store: Arc, + token: Option>, + requests: Arc, + workers: Arc, +} + +/// Authentication is optional on loopback; it is mandatory on any other bind. +pub struct ServerConfig { + pub bind: SocketAddr, + pub data_dir: PathBuf, + pub bearer_token: Option, +} + +impl ServerConfig { + pub fn validate(&self) -> Result<(), String> { + if let Some(token) = &self.bearer_token + && (token.len() < 32 || !token.bytes().all(|byte| byte.is_ascii_graphic())) + { + return Err("Bearer token must contain at least 32 printable ASCII characters without whitespace.".into()); + } + if !self.bind.ip().is_loopback() && self.bearer_token.is_none() { + return Err( + "A non-loopback bind requires --token-file (or ARCADIA_SERVER_TOKEN).".into(), + ); + } + Ok(()) + } +} + +/// Build a router for an already opened, exclusively owned store. +/// Call `ServerConfig::validate` before binding a network listener. +pub fn app(store: Arc, bearer_token: Option) -> Router { + let state = AppState { + store, + token: bearer_token.map(Arc::from), + requests: Arc::new(Semaphore::new(8)), + workers: Arc::new(Semaphore::new(8)), + }; + routes::router() + .with_state(state.clone()) + .layer(DefaultBodyLimit::max(store::MAX_MODEL_BYTES)) + .layer(RequestBodyLimitLayer::new(store::MAX_MODEL_BYTES)) + .layer(middleware::from_fn_with_state(state, guard)) +} + +async fn guard(State(state): State, request: Request, next: Next) -> Response { + let request_id = oa_core::Id::new_v4().to_string(); + let public = matches!( + request.uri().path(), + "/health" | "/health/live" | "/health/ready" | "/openapi.json" | "/api/v1/openapi.json" + ); + let mut response = if public { + next.run(request).await + } else if request.headers().contains_key(header::ORIGIN) { + ApiError::new( + StatusCode::FORBIDDEN, + "browser_origin_denied", + "Browser origins are disabled; use a native client.", + ) + .into_response() + } else if !authorized(&state, request.headers()) { + ApiError::new( + StatusCode::UNAUTHORIZED, + "unauthorized", + "A valid bearer token is required.", + ) + .into_response() + } else if state.token.is_none() && !loopback_host(request.headers()) { + ApiError::new( + StatusCode::FORBIDDEN, + "host_not_allowed", + "Unauthenticated loopback access requires a localhost or loopback-IP Host header.", + ) + .into_response() + } else if request.uri().query().is_some() && !allows_query(&request) { + ApiError::bad_request( + "This route does not accept query parameters; historical revisions are not retained.", + ) + .into_response() + } else if request.headers().contains_key(header::CONTENT_ENCODING) { + ApiError::new( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "content_encoding_unsupported", + "Compressed or encoded request bodies are not supported.", + ) + .into_response() + } else { + match state.requests.clone().try_acquire_owned() { + Err(_) => ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "overloaded", "All request slots are busy. Retry later.").into_response(), + Ok(_permit) => match tokio::time::timeout(Duration::from_secs(30), next.run(request)).await { + Ok(response) => response, + Err(_) => ApiError::new(StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", "Request deadline exceeded. An admitted write may still complete; read the project before retrying.").into_response(), + }, + } + }; + // Normalize middleware/framework rejections too, including body limits. + if response.status().is_client_error() || response.status().is_server_error() { + let is_problem = response + .headers() + .get(header::CONTENT_TYPE) + .is_some_and(|value| value == "application/problem+json"); + if !is_problem { + response = ApiError::new( + response.status(), + "request_rejected", + "The HTTP request was rejected.", + ) + .into_response(); + } + } + if response.status() == StatusCode::SERVICE_UNAVAILABLE { + response + .headers_mut() + .insert(header::RETRY_AFTER, header::HeaderValue::from_static("1")); + } + response.headers_mut().insert( + "x-request-id", + request_id.parse().expect("UUID is a header value"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-cache"), + ); + response +} + +fn loopback_host(headers: &header::HeaderMap) -> bool { + let mut hosts = headers.get_all(header::HOST).iter(); + let authority = hosts + .next() + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + if hosts.next().is_some() { + return false; + } + authority.is_some_and(|authority| { + let host = authority.host().trim_matches(['[', ']']); + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }) +} + +fn allows_query(request: &Request) -> bool { + request.method() == axum::http::Method::GET + && (request.uri().path() == "/api/v1/projects" + || ["/elements", "/relations", "/artifact-links", "/traces"] + .iter() + .any(|suffix| request.uri().path().ends_with(suffix))) +} + +fn authorized(state: &AppState, headers: &header::HeaderMap) -> bool { + let Some(expected) = &state.token else { + return true; + }; + let mut values = headers.get_all(header::AUTHORIZATION).iter(); + let supplied = values + .next() + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + if values.next().is_some() { + return false; + } + supplied.is_some_and(|supplied| bool::from(supplied.as_bytes().ct_eq(expected.as_bytes()))) +} + +pub(crate) async fn blocking( + state: AppState, + work: impl FnOnce(&Store) -> ApiResult + Send + 'static, +) -> ApiResult { + let permit = state.workers.clone().try_acquire_owned().map_err(|_| { + ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "overloaded", + "All model workers are busy. Retry later.", + ) + })?; + tokio::task::spawn_blocking(move || { + // Retain the work permit even if the HTTP future is cancelled. + let _permit = permit; + // Parsing, queries and validation are worker work, not catalog-lock + // work. Store accessors capture snapshots and recheck writes at commit. + work(&state.store) + }) + .await + .map_err(|_| ApiError::internal("Model worker failed."))? +} diff --git a/crates/oa-server/src/main.rs b/crates/oa-server/src/main.rs new file mode 100644 index 00000000..7d9bc73a --- /dev/null +++ b/crates/oa-server/src/main.rs @@ -0,0 +1,110 @@ +use oa_server::{ServerConfig, app, store::Store}; +use std::{net::SocketAddr, path::PathBuf, sync::Arc}; + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("arcadia-server: {error}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), Box> { + let Some(config) = tokio::task::spawn_blocking(configuration).await?? else { + return Ok(()); + }; + config.validate()?; + let data_dir = config.data_dir.clone(); + let store = Arc::new(tokio::task::spawn_blocking(move || Store::open(data_dir)).await??); + let listener = tokio::net::TcpListener::bind(config.bind).await?; + eprintln!( + "arcadia-server {} listening on {}; data {}", + env!("CARGO_PKG_VERSION"), + listener.local_addr()?, + config.data_dir.display() + ); + axum::serve(listener, app(store, config.bearer_token)) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +fn configuration() -> Result, Box> { + let mut bind: SocketAddr = "127.0.0.1:8787".parse()?; + let mut data_dir = default_data_dir(); + let mut token_file = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--help" | "-h" => { + println!( + "Arcadia model REST service\n\nUsage: arcadia-server [--bind IP:PORT] [--data-dir PATH] [--token-file PATH]\n\nDefaults: 127.0.0.1:8787; platform-local ArcadiaStudio/server directory.\nToken required for non-loopback binds. ARCADIA_SERVER_TOKEN is an alternative.\nKeep the data directory on local storage. See docs/server.md for deployment and API limits." + ); + return Ok(None); + } + "--version" | "-V" => { + println!("arcadia-server {}", env!("CARGO_PKG_VERSION")); + return Ok(None); + } + "--bind" => bind = args.next().ok_or("--bind requires IP:PORT")?.parse()?, + "--data-dir" => { + data_dir = PathBuf::from(args.next().ok_or("--data-dir requires a path")?) + } + "--token-file" => { + token_file = Some(PathBuf::from( + args.next().ok_or("--token-file requires a path")?, + )) + } + _ => return Err(format!("Unknown option {arg}; use --help.").into()), + } + } + let bearer_token = if let Some(path) = token_file { + let token = std::fs::read_to_string(path)?; + Some(token.trim_end_matches(['\r', '\n']).to_owned()) + } else { + std::env::var("ARCADIA_SERVER_TOKEN").ok() + }; + Ok(Some(ServerConfig { + bind, + data_dir, + bearer_token, + })) +} + +fn default_data_dir() -> PathBuf { + #[cfg(windows)] + if let Some(path) = std::env::var_os("LOCALAPPDATA") { + return PathBuf::from(path).join("ArcadiaStudio").join("server"); + } + if let Some(path) = std::env::var_os("XDG_DATA_HOME") { + return PathBuf::from(path).join("arcadia-studio").join("server"); + } + if let Some(path) = std::env::var_os("HOME") { + return PathBuf::from(path).join(".local/share/arcadia-studio/server"); + } + std::env::temp_dir().join("arcadia-studio-server") +} + +async fn shutdown_signal() { + let ctrl_c = async { + if let Err(error) = tokio::signal::ctrl_c().await { + eprintln!("Could not listen for Ctrl+C: {error}"); + } + }; + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(error) => { + eprintln!("Could not listen for SIGTERM: {error}"); + std::future::pending::<()>().await; + } + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { _ = ctrl_c => {}, _ = terminate => {} } + eprintln!("arcadia-server stopping admission and draining requests"); +} diff --git a/crates/oa-server/src/routes.rs b/crates/oa-server/src/routes.rs new file mode 100644 index 00000000..87e44291 --- /dev/null +++ b/crates/oa-server/src/routes.rs @@ -0,0 +1,1015 @@ +use crate::{ + AppState, blocking, + error::{ApiError, ApiResult, IssueDto, issues}, + store::{MODEL_LIMITS, StoredProject, check_model_size, validate_model}, +}; +use axum::{ + Json, Router, + body::Bytes, + extract::{ + Path, Query, State, + rejection::{BytesRejection, PathRejection, QueryRejection}, + }, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use oa_core::{Command, Id, Kind, Layer, Project, RelationKind}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +type ProjectPath = Result, PathRejection>; +type Body = Result; + +pub(crate) fn router() -> Router { + Router::new() + .route("/health", get(health)) + .route("/health/live", get(health)) + .route("/health/ready", get(health)) + .route("/openapi.json", get(openapi)) + .route("/api/v1/openapi.json", get(openapi)) + .route("/api/v1/projects", get(list_projects).post(create_project)) + .route("/api/v1/projects/import", post(import_project)) + .route( + "/api/v1/projects/{id}", + get(get_project) + .patch(rename_project) + .delete(delete_project), + ) + .route("/api/v1/projects/{id}/model", get(get_model).put(put_model)) + .route( + "/api/v1/projects/{id}/source", + get(get_source).put(put_source), + ) + .route("/api/v1/projects/{id}/elements", get(list_elements)) + .route("/api/v1/projects/{id}/relations", get(list_relations)) + .route("/api/v1/projects/{id}/artifact-links", get(list_artifacts)) + .route("/api/v1/projects/{id}/traces", get(list_traces)) + .route("/api/v1/projects/{id}/query", post(neighborhood)) + .route( + "/api/v1/projects/{id}/validation", + get(get_validation).post(validate_candidate), + ) + .route("/api/v1/projects/{id}/transactions", post(transactions)) + .route( + "/api/v1/projects/{id}/transitions/preview", + post(preview_transition), + ) + .route( + "/api/v1/projects/{id}/transitions/apply", + post(commit_transition), + ) + .fallback(|| async { + ApiError::new( + StatusCode::NOT_FOUND, + "route_not_found", + "API route does not exist.", + ) + }) + .method_not_allowed_fallback(|| async { + ApiError::new( + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + "HTTP method is not supported by this route.", + ) + }) +} + +async fn health() -> Json { + Json(json!({"status":"ok"})) +} + +async fn openapi() -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + include_str!("../../../docs/openapi.json"), + ) + .into_response() +} + +fn path(value: ProjectPath) -> ApiResult { + value + .map(|Path(id)| id) + .map_err(|error| ApiError::bad_request(error.body_text())) +} + +fn query(value: Result, QueryRejection>) -> ApiResult { + value + .map(|Query(value)| value) + .map_err(|error| ApiError::bad_request(error.body_text())) +} + +fn bytes(value: Body) -> ApiResult { + value.map_err(|error| ApiError::new(error.status(), "invalid_body", error.body_text())) +} + +fn json_body(body: &[u8]) -> ApiResult { + serde_json::from_slice(body) + .map_err(|error| ApiError::bad_request(format!("Invalid JSON: {error}"))) +} + +fn media(headers: &HeaderMap, expected: &str) -> ApiResult<()> { + let actual = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim); + if actual != Some(expected) { + return Err(ApiError::new( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "unsupported_media_type", + format!("Content-Type must be {expected}."), + )); + } + Ok(()) +} + +fn expected(headers: &HeaderMap) -> ApiResult { + let mut values = headers.get_all(header::IF_MATCH).iter(); + let value = values + .next() + .ok_or_else(|| { + ApiError::new( + StatusCode::PRECONDITION_REQUIRED, + "precondition_required", + "Supply the project's ETag in If-Match.", + ) + })? + .to_str() + .map_err(|_| ApiError::bad_request("Invalid If-Match header."))?; + if values.next().is_some() + || value.len() < 2 + || !value.starts_with('"') + || !value.ends_with('"') + || value[1..value.len() - 1].contains(['"', ',']) + { + return Err(ApiError::bad_request( + "If-Match must contain exactly one strong, quoted ETag; wildcard/list/weak tags are unsupported.", + )); + } + Ok(value.to_owned()) +} + +fn response(stored: &StoredProject, status: StatusCode, value: impl Serialize) -> Response { + let mut response = (status, Json(value)).into_response(); + response + .headers_mut() + .insert(header::ETAG, stored.etag().parse().expect("ETag is ASCII")); + response +} + +fn not_modified(stored: &StoredProject, headers: &HeaderMap) -> Option { + let etag = stored.etag(); + let matched = headers + .get_all(header::IF_NONE_MATCH) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .any(|value| { + value == "*" || value == etag || value.strip_prefix("W/") == Some(etag.as_str()) + }); + matched.then(|| { + let mut response = StatusCode::NOT_MODIFIED.into_response(); + response + .headers_mut() + .insert(header::ETAG, etag.parse().expect("ETag is ASCII")); + response + }) +} + +fn summary(stored: &StoredProject) -> Value { + json!({"resource_id":stored.resource_id,"revision":stored.revision,"model_id":stored.model.id, + "schema_version":stored.model.schema_version,"name":stored.model.name, + "elements":stored.model.elements.len(),"relations":stored.model.relations.len()}) +} + +fn parse_source(body: &[u8]) -> ApiResult { + let source = + std::str::from_utf8(body).map_err(|_| ApiError::bad_request("Source must be UTF-8."))?; + oa_core::language::parse_limited(source, MODEL_LIMITS).map_err(|diagnostics| { + let code = if diagnostics.iter().any(|d| d.code == "model_limit") { + "model_limit" + } else { + "invalid_source" + }; + let mut error = ApiError::new( + StatusCode::UNPROCESSABLE_ENTITY, + code, + "Arcadia source could not be applied.", + ); + error.issues = diagnostics + .into_iter() + .map(|issue| IssueDto { + severity: "error", + code: issue.code, + message: issue.message, + field: issue.field, + object_id: issue.object_id, + line: Some(issue.line), + column: Some(issue.column), + }) + .collect(); + error + }) +} + +fn import_json(body: &[u8]) -> ApiResult { + // Format is chosen by HTTP. The shared decoder owns explicit schema-1 migration. + let _: Value = json_body(body)?; + oa_core::persistence::decode_limited(body, MODEL_LIMITS).map_err(|error| match error { + oa_core::persistence::FileError::Limit(detail) => { + ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail) + } + oa_core::persistence::FileError::Json(error) => { + ApiError::bad_request(format!("Invalid model JSON: {error}")) + } + error => ApiError::new( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_model", + error.to_string(), + ), + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NameRequest { + name: String, +} + +async fn create_project( + State(state): State, + headers: HeaderMap, + body: Body, +) -> ApiResult { + media(&headers, "application/json")?; + let body = bytes(body)?; + blocking(state, move |store| { + let request: NameRequest = json_body(&body)?; + let mut model = Project::empty(); + model.name = request.name; + created(store.create(model)?) + }) + .await +} + +fn created(stored: StoredProject) -> ApiResult { + let mut result = response(&stored, StatusCode::CREATED, summary(&stored)); + result.headers_mut().insert( + header::LOCATION, + format!("/api/v1/projects/{}", stored.resource_id) + .parse() + .expect("UUID URL is ASCII"), + ); + Ok(result) +} + +async fn import_project( + State(state): State, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let is_json = media(&headers, "application/json").is_ok(); + if !is_json { + media(&headers, "text/plain")?; + } + let body = bytes(body)?; + blocking(state, move |store| { + let model = if is_json { + import_json(&body)? + } else { + parse_source(&body)? + }; + created(store.create(model)?) + }) + .await +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct Page { + limit: Option, + after: Option, + q: Option, +} + +impl Page { + fn limit(&self) -> ApiResult { + let limit = self.limit.unwrap_or(100); + if !(1..=1000).contains(&limit) { + return Err(ApiError::bad_request("limit must be between 1 and 1000.")); + } + Ok(limit) + } +} + +async fn list_projects( + State(state): State, + pagination: Result, QueryRejection>, +) -> ApiResult { + let page = query(pagination)?; + let limit = page.limit()?; + blocking(state, move |store| { + let needle = page.q.as_deref().unwrap_or("").to_lowercase(); + let catalog = store.catalog()?; + let mut matches = catalog.iter().filter(|stored| { + page.after.is_none_or(|id| stored.resource_id > id) + && stored.name.to_lowercase().contains(&needle) + }); + let items: Vec<_> = matches.by_ref().take(limit).collect(); + let next = if matches.next().is_some() { + items.last().map(|stored| stored.resource_id) + } else { + None + }; + Ok(Json(json!({"items":items,"next_after":next})).into_response()) + }) + .await +} + +async fn get_project( + State(state): State, + id: ProjectPath, + headers: HeaderMap, +) -> ApiResult { + let id = path(id)?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + Ok(not_modified(stored, &headers) + .unwrap_or_else(|| response(stored, StatusCode::OK, stored))) + }) + .await +} + +async fn rename_project( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let id = path(id)?; + let expected = expected(&headers)?; + media(&headers, "application/json")?; + let body = bytes(body)?; + blocking(state, move |store| { + let request: NameRequest = json_body(&body)?; + let snapshot = store.checked(id, &expected)?; + let current = &snapshot; + let model = oa_core::execute_batch( + ¤t.model, + &[Command::EditProject { + id: current.model.id, + alias: current.model.alias.clone(), + name: request.name, + description: current.model.description.clone(), + }], + ) + .map_err(|error| { + ApiError::new( + StatusCode::UNPROCESSABLE_ENTITY, + "command_rejected", + error.to_string(), + ) + })?; + let (stored, changed) = store.replace(id, &expected, model)?; + Ok(response( + &stored, + StatusCode::OK, + json!({"revision":stored.revision,"changed":changed}), + )) + }) + .await +} + +async fn delete_project( + State(state): State, + id: ProjectPath, + headers: HeaderMap, +) -> ApiResult { + let id = path(id)?; + let expected = expected(&headers)?; + blocking(state, move |store| { + store.delete(id, &expected)?; + Ok(StatusCode::NO_CONTENT.into_response()) + }) + .await +} + +async fn get_model( + State(state): State, + id: ProjectPath, + headers: HeaderMap, +) -> ApiResult { + let id = path(id)?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + Ok(not_modified(stored, &headers) + .unwrap_or_else(|| response(stored, StatusCode::OK, &stored.model))) + }) + .await +} + +async fn get_source( + State(state): State, + id: ProjectPath, + headers: HeaderMap, +) -> ApiResult { + let id = path(id)?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + if let Some(result) = not_modified(stored, &headers) { + return Ok(result); + } + let mut result = ( + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + oa_core::language::format(&stored.model), + ) + .into_response(); + result + .headers_mut() + .insert(header::ETAG, stored.etag().parse().expect("ETag is ASCII")); + Ok(result) + }) + .await +} + +async fn put_model( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + replace_model(state, id, headers, body, true).await +} + +async fn put_source( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + replace_model(state, id, headers, body, false).await +} + +async fn replace_model( + state: AppState, + id: ProjectPath, + headers: HeaderMap, + body: Body, + is_json: bool, +) -> ApiResult { + let id = path(id)?; + let expected = expected(&headers)?; + media( + &headers, + if is_json { + "application/json" + } else { + "text/plain" + }, + )?; + let body = bytes(body)?; + blocking(state, move |store| { + store.checked(id, &expected)?; + let model = if is_json { + json_body(&body)? + } else { + parse_source(&body)? + }; + let (stored, changed) = store.replace(id, &expected, model)?; + Ok(response( + &stored, + StatusCode::OK, + json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}), + )) + }) + .await +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct ElementQuery { + limit: Option, + after: Option, + revision: Option, + kind: Option, + layer: Option, + alias: Option, + q: Option, +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RelationQuery { + limit: Option, + after: Option, + revision: Option, + kind: Option, + source: Option, + target: Option, + incident_to: Option, +} + +fn check_read_revision(stored: &StoredProject, revision: Option) -> ApiResult<()> { + if revision.is_some_and(|revision| revision != stored.revision) { + let mut error = ApiError::new( + StatusCode::CONFLICT, + "snapshot_changed", + "Only the current revision is retained. Restart pagination from the current snapshot.", + ); + error.current_revision = Some(stored.revision); + return Err(error); + } + Ok(()) +} + +async fn list_elements( + State(state): State, + id: ProjectPath, + parameters: Result, QueryRejection>, +) -> ApiResult { + let id = path(id)?; + let query = query(parameters)?; + let limit = Page { + limit: query.limit, + ..Page::default() + } + .limit()?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + check_read_revision(stored, query.revision)?; + let needle = query.q.as_deref().unwrap_or("").to_lowercase(); + let mut items = Vec::new(); + let mut next = None; + for element in stored.model.elements.values() { + if query.after.is_some_and(|id| element.id <= id) + || query.kind.is_some_and(|kind| element.kind() != kind) + || !element.name.to_lowercase().contains(&needle) + || query + .alias + .as_ref() + .is_some_and(|alias| element.alias != *alias) + || query.layer.is_some_and(|layer| element.layer() != layer) + { + continue; + } + let value = serde_json::to_value(element) + .map_err(|_| ApiError::internal("Element serialization failed."))?; + if items.len() == limit { + next = items + .last() + .and_then(|item: &Value| item.get("id")) + .cloned(); + break; + } + items.push(value); + } + Ok(response( + stored, + StatusCode::OK, + json!({"revision":stored.revision,"items":items,"next_after":next}), + )) + }) + .await +} + +async fn list_relations( + State(state): State, + id: ProjectPath, + parameters: Result, QueryRejection>, +) -> ApiResult { + let id = path(id)?; + let query = query(parameters)?; + let limit = Page { + limit: query.limit, + ..Page::default() + } + .limit()?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + check_read_revision(stored, query.revision)?; + let mut matches = stored.model.relations.values().filter(|relation| { + query.after.is_none_or(|id| relation.id > id) + && query.kind.is_none_or(|kind| relation.kind() == kind) + && query.source.is_none_or(|id| relation.source() == id) + && query.target.is_none_or(|id| relation.target() == id) + && query + .incident_to + .is_none_or(|id| relation.source() == id || relation.target() == id) + }); + let items: Vec<_> = matches.by_ref().take(limit).collect(); + let next = if matches.next().is_some() { + items.last().map(|relation| relation.id) + } else { + None + }; + Ok(response( + stored, + StatusCode::OK, + json!({"revision":stored.revision,"items":items,"next_after":next}), + )) + }) + .await +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Direction { + Incoming, + Outgoing, + #[default] + Both, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Neighborhood { + roots: Vec, + #[serde(default)] + direction: Direction, + #[serde(default)] + relation_kinds: Vec, + #[serde(default = "default_depth")] + max_depth: usize, + #[serde(default = "default_objects")] + max_objects: usize, + revision: Option, +} +fn default_depth() -> usize { + 1 +} +fn default_objects() -> usize { + 200 +} + +async fn neighborhood( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let id = path(id)?; + media(&headers, "application/json")?; + let body = bytes(body)?; + blocking(state, move |store| { + let query: Neighborhood = json_body(&body)?; + if query.roots.is_empty() || query.roots.len() > 100 || query.max_depth > 4 || !(1..=1000).contains(&query.max_objects) || query.roots.len() > query.max_objects { + return Err(ApiError::bad_request("Use 1..100 roots, max_depth 0..4, and max_objects 1..1000 (at least the root count).")); + } + let snapshot = store.get(id)?; + let stored = &snapshot; + check_read_revision(stored, query.revision)?; + if query.roots.iter().any(|id| !stored.model.elements.contains_key(id) && !stored.model.relations.contains_key(id)) { + return Err(ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "missing_root", "Every root must name an existing element or relation.")); + } + let mut objects = BTreeSet::new(); + let mut traversed: BTreeSet<_> = query.roots.into_iter().collect(); + let mut queue: VecDeque<_> = traversed.iter().map(|id| (*id, 0)).collect(); + for root in &traversed { endpoint_closure(&stored.model, *root, &mut objects); } + if objects.len() > query.max_objects { + return Err(ApiError::bad_request("Root relation endpoint closure exceeds max_objects.")); + } + let mut adjacency: BTreeMap> = BTreeMap::new(); + for relation in stored.model.relations.values() { + if !query.relation_kinds.is_empty() && !query.relation_kinds.contains(&relation.kind()) { continue; } + if matches!(query.direction, Direction::Outgoing | Direction::Both) { + adjacency.entry(relation.source()).or_default().push((relation.id, relation.target())); + } + if matches!(query.direction, Direction::Incoming | Direction::Both) { + adjacency.entry(relation.target()).or_default().push((relation.id, relation.source())); + } + } + let mut truncated = false; + while let Some((current, depth)) = queue.pop_front() { + if depth == query.max_depth { continue; } + for &(relation, neighbor) in adjacency.get(¤t).into_iter().flatten() { + let mut additions = BTreeSet::new(); + endpoint_closure(&stored.model, relation, &mut additions); + let cost = additions.difference(&objects).count(); + if objects.len() + cost > query.max_objects { truncated = true; continue; } + objects.extend(additions); + if traversed.insert(neighbor) { queue.push_back((neighbor, depth + 1)); } + } + } + Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision, + "elements":objects.iter().filter_map(|id| stored.model.elements.get(id)).collect::>(), + "relations":objects.iter().filter_map(|id| stored.model.relations.get(id)).collect::>(),"truncated":truncated}))) + }).await +} + +fn endpoint_closure(model: &Project, root: Id, objects: &mut BTreeSet) { + let mut pending = vec![root]; + while let Some(id) = pending.pop() { + if !objects.insert(id) { + continue; + } + if let Some(relation) = model.relations.get(&id) { + pending.extend([relation.source(), relation.target()]); + } + } +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct LinkQuery { + limit: Option, + after: Option, + revision: Option, + object: Option, +} + +async fn list_artifacts( + State(state): State, + id: ProjectPath, + parameters: Result, QueryRejection>, +) -> ApiResult { + list_links(state, id, parameters, true).await +} + +async fn list_traces( + State(state): State, + id: ProjectPath, + parameters: Result, QueryRejection>, +) -> ApiResult { + list_links(state, id, parameters, false).await +} + +async fn list_links( + state: AppState, + id: ProjectPath, + parameters: Result, QueryRejection>, + artifacts: bool, +) -> ApiResult { + let id = path(id)?; + let query = query(parameters)?; + let limit = Page { + limit: query.limit, + ..Page::default() + } + .limit()?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + check_read_revision(stored, query.revision)?; + let mut records = Vec::new(); + if artifacts { + for link in stored + .model + .artifact_links + .values() + .filter(|link| { + query.after.is_none_or(|id| link.id > id) + && query.object.is_none_or(|id| link.owner == id) + }) + .take(limit + 1) + { + records.push( + serde_json::to_value(link) + .map_err(|_| ApiError::internal("Artifact serialization failed."))?, + ); + } + } else { + for link in stored + .model + .traces + .values() + .filter(|link| { + query.after.is_none_or(|id| link.id > id) + && query.object.is_none_or(|id| link.oa == id || link.sa == id) + }) + .take(limit + 1) + { + records.push( + serde_json::to_value(link) + .map_err(|_| ApiError::internal("Trace serialization failed."))?, + ); + } + } + let next = if records.len() > limit { + records.truncate(limit); + records.last().and_then(|record| record.get("id")).cloned() + } else { + None + }; + Ok(response( + stored, + StatusCode::OK, + json!({"revision":stored.revision,"items":records,"next_after":next}), + )) + }) + .await +} + +async fn get_validation(State(state): State, id: ProjectPath) -> ApiResult { + let id = path(id)?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + Ok(response( + stored, + StatusCode::OK, + json!({"revision":stored.revision,"valid":true,"issues":issues(&stored.model)}), + )) + }) + .await +} + +#[derive(Deserialize)] +#[serde(tag = "format", rename_all = "snake_case", deny_unknown_fields)] +enum ValidationRequest { + Json { model: Project }, + Arcadia { source: String }, +} + +async fn validate_candidate( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let id = path(id)?; + media(&headers, "application/json")?; + let body = bytes(body)?; + blocking(state, move |store| { + let snapshot = store.get(id)?; + let stored = &snapshot; + let request: ValidationRequest = json_body(&body)?; + let parsed = match request { + ValidationRequest::Json { model } => Ok(model), + ValidationRequest::Arcadia { source } => parse_source(source.as_bytes()), + }; + let (valid, issues) = match parsed.and_then(|model| { + validate_model(&model)?; + Ok(model) + }) { + Ok(model) => (true, issues(&model)), + Err(error) => { + let mut diagnostics = error.issues; + if diagnostics.is_empty() { + diagnostics.push(IssueDto { + severity: "error", + code: error.code.into(), + message: error.detail, + field: None, + object_id: None, + line: None, + column: None, + }); + } + (false, diagnostics) + } + }; + Ok(response( + stored, + StatusCode::OK, + json!({"revision":stored.revision,"valid":valid,"issues":issues}), + )) + }) + .await +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TransactionRequest { + commands: Vec, + #[serde(default)] + dry_run: bool, +} + +async fn transactions( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let id = path(id)?; + let expected = expected(&headers)?; + media(&headers, "application/json")?; + let body = bytes(body)?; + blocking(state, move |store| { + let request: TransactionRequest = json_body(&body)?; + check_commands(&request.commands)?; + let snapshot = store.checked(id, &expected)?; + let current = &snapshot; + let model = oa_core::execute_batch(¤t.model, &request.commands) + .map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "command_rejected", error.to_string()))?; + validate_model(&model)?; + if request.dry_run { + return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":model != current.model,"issues":issues(&model),"model":model}))); + } + let (stored, changed) = store.replace(id, &expected, model)?; + Ok(response(&stored, StatusCode::OK, json!({"revision":stored.revision,"dry_run":false,"changed":changed,"issues":issues(&stored.model)}))) + }).await +} + +fn check_commands(commands: &[Command]) -> ApiResult<()> { + let mut pending: Vec<_> = commands.iter().collect(); + let mut count = 0; + while let Some(command) = pending.pop() { + count += 1; + if count > 200 { + return Err(ApiError::bad_request( + "A transaction may contain at most 200 commands, including nested batches.", + )); + } + if let Command::Batch(batch) = command { + pending.extend(batch); + } + if let Command::Replace(model) = command { + check_model_size(model)?; + } + } + if count == 0 { + return Err(ApiError::bad_request( + "A transaction requires at least one command.", + )); + } + Ok(()) +} + +async fn preview_transition( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let id = path(id)?; + let expected = expected(&headers)?; + media(&headers, "application/json")?; + let body = bytes(body)?; + blocking(state, move |store| { + let request: oa_core::transitions::TransitionRequest = json_body(&body)?; + if request.sources.len() > 100 || request.mappings.len() > 200 || request.resolutions.len() > 400 { + return Err(ApiError::bad_request("Transition requests allow at most 100 sources, 200 mappings and 400 field resolutions.")); + } + let snapshot = store.checked(id, &expected)?; + let current = &snapshot; + let plan = oa_core::transitions::plan_transition(¤t.model, &request) + .map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?; + if !plan.commands.is_empty() { check_commands(&plan.commands)?; } + if plan.conflicts.is_empty() { + let candidate = oa_core::transitions::apply_transition(¤t.model, &plan) + .map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?; + validate_model(&candidate)?; + } + Ok(response(current, StatusCode::OK, json!({"base_revision":current.revision,"plan":plan}))) + }).await +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TransitionApplyRequest { + base_revision: u64, + plan: oa_core::transitions::TransitionPlan, +} + +async fn commit_transition( + State(state): State, + id: ProjectPath, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let id = path(id)?; + let expected = expected(&headers)?; + media(&headers, "application/json")?; + let body = bytes(body)?; + blocking(state, move |store| { + let request: TransitionApplyRequest = json_body(&body)?; + let snapshot = store.checked(id, &expected)?; + let current = &snapshot; + if current.revision != request.base_revision { + let mut error = ApiError::new( + StatusCode::PRECONDITION_FAILED, + "plan_revision_conflict", + "Transition preview revision changed. Create a new preview.", + ); + error.current_revision = Some(current.revision); + return Err(error); + } + if !request.plan.commands.is_empty() { + check_commands(&request.plan.commands)?; + } + let candidate = oa_core::transitions::apply_transition(¤t.model, &request.plan) + .map_err(|error| { + ApiError::new( + StatusCode::UNPROCESSABLE_ENTITY, + "transition_rejected", + error.to_string(), + ) + })?; + let (stored, changed) = store.replace(id, &expected, candidate)?; + Ok(response( + &stored, + StatusCode::OK, + json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}), + )) + }) + .await +} diff --git a/crates/oa-server/src/store.rs b/crates/oa-server/src/store.rs new file mode 100644 index 00000000..b91216c9 --- /dev/null +++ b/crates/oa-server/src/store.rs @@ -0,0 +1,434 @@ +//! Single-owner on-disk envelopes. All methods run on a blocking worker. +use crate::error::{ApiError, ApiResult, issues}; +use axum::http::StatusCode; +use oa_core::{Id, Project}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{Mutex, MutexGuard}, +}; + +pub const MAX_MODEL_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_OBJECTS: usize = 5_000; +pub const MAX_PROJECTS: usize = 128; +pub const MAX_STORE_BYTES: usize = 256 * 1024 * 1024; +pub(crate) const MODEL_LIMITS: oa_core::language::ParseLimits = oa_core::language::ParseLimits { + max_source_bytes: MAX_MODEL_BYTES, + max_graph_objects: MAX_OBJECTS, + max_records: 20_000, + max_step_references: 50_000, +}; +const MAX_ENVELOPE_BYTES: usize = MAX_MODEL_BYTES + 512; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StoredProject { + pub resource_id: Id, + pub revision: u64, + pub model: Project, +} + +impl StoredProject { + pub fn etag(&self) -> String { + format!("\"p-{}-r{}\"", self.resource_id, self.revision) + } +} + +#[derive(Serialize)] +pub(crate) struct ProjectSummary { + pub resource_id: Id, + pub revision: u64, + pub model_id: Id, + pub schema_version: u32, + pub name: String, + pub elements: usize, + pub relations: usize, +} +impl From<&StoredProject> for ProjectSummary { + fn from(stored: &StoredProject) -> Self { + Self { + resource_id: stored.resource_id, + revision: stored.revision, + model_id: stored.model.id, + schema_version: stored.model.schema_version, + name: stored.model.name.clone(), + elements: stored.model.elements.len(), + relations: stored.model.relations.len(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("Cannot open data directory: {0}")] + Io(#[from] std::io::Error), + #[error("Data directory is already owned or cannot be locked: {0}")] + Locked(String), + #[error("Invalid stored project {path}: {detail}")] + Corrupt { path: PathBuf, detail: String }, + #[error("Store capacity exceeded: {0}")] + Capacity(String), +} + +pub struct Store { + inner: Mutex, + // Keep the OS lock alive for the store's entire lifetime, including requests. + _directory_lock: File, +} + +struct StoreInner { + directory: PathBuf, + pub projects: BTreeMap, + byte_sizes: BTreeMap, + total_bytes: usize, +} + +/// Constructed only after complete candidate validation, outside the catalog +/// mutex. Commit methods cannot accidentally accept an unchecked candidate. +struct PreparedModel(Project); +impl PreparedModel { + fn new(model: Project) -> ApiResult { + validate_model(&model)?; + Ok(Self(model)) + } +} + +impl Store { + fn lock(&self) -> ApiResult> { + self.inner.lock().map_err(|_| { + ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "storage_unavailable", + "Storage worker needs a restart.", + ) + }) + } + + pub(crate) fn get(&self, id: Id) -> ApiResult { + Ok(self.lock()?.get(id)?.clone()) + } + + pub(crate) fn checked(&self, id: Id, expected: &str) -> ApiResult { + Ok(self.lock()?.checked(id, expected)?.clone()) + } + + pub(crate) fn catalog(&self) -> ApiResult> { + Ok(self + .lock()? + .projects + .values() + .map(ProjectSummary::from) + .collect()) + } + + pub(crate) fn create(&self, model: Project) -> ApiResult { + let prepared = PreparedModel::new(model)?; + self.lock()?.create(prepared) + } + + pub(crate) fn replace( + &self, + id: Id, + expected: &str, + model: Project, + ) -> ApiResult<(StoredProject, bool)> { + let prepared = PreparedModel::new(model)?; + self.lock()?.replace(id, expected, prepared) + } + + pub(crate) fn delete(&self, id: Id, expected: &str) -> ApiResult<()> { + self.lock()?.delete(id, expected) + } + + /// The caller must keep this synchronous disk work off async runtime threads. + pub fn open(directory: impl AsRef) -> Result { + fs::create_dir_all(directory.as_ref())?; + let directory = fs::canonicalize(directory.as_ref())?; + let directory_lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(directory.join(".arcadia.lock"))?; + directory_lock + .try_lock() + .map_err(|error| StoreError::Locked(error.to_string()))?; + let mut projects = BTreeMap::new(); + let mut byte_sizes = BTreeMap::new(); + let mut total_bytes: usize = 0; + for entry in fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + // Aborted sibling temporary files are not committed envelopes. + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let corrupt = |detail: String| StoreError::Corrupt { + path: path.clone(), + detail, + }; + if !entry.file_type()?.is_file() { + return Err(corrupt( + "Envelope must be a regular file, not a link/directory.".into(), + )); + } + let filename_id: Id = path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| corrupt("Filename is not a UUID.".into()))? + .parse() + .map_err(|_| corrupt("Filename is not a UUID.".into()))?; + if path.file_stem().and_then(|stem| stem.to_str()) + != Some(filename_id.to_string().as_str()) + { + return Err(corrupt( + "Filename must use the canonical lowercase hyphenated resource UUID.".into(), + )); + } + let mut bytes = Vec::new(); + File::open(&path)? + .take((MAX_ENVELOPE_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_ENVELOPE_BYTES { + return Err(corrupt("Envelope exceeds the size limit.".into())); + } + total_bytes = total_bytes.saturating_add(bytes.len()); + if projects.len() >= MAX_PROJECTS || total_bytes > MAX_STORE_BYTES { + return Err(StoreError::Capacity( + "At most 128 models and 256 MiB of serialized envelopes are supported.".into(), + )); + } + let stored: StoredProject = + serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?; + if stored.resource_id != filename_id || stored.revision == 0 { + return Err(corrupt( + "Resource ID/filename mismatch or zero revision.".into(), + )); + } + validate_model(&stored.model).map_err(|error| corrupt(error.detail))?; + byte_sizes.insert(stored.resource_id, bytes.len()); + if projects.insert(stored.resource_id, stored).is_some() { + return Err(corrupt("Duplicate hosted resource ID.".into())); + } + } + Ok(Self { + inner: Mutex::new(StoreInner { + directory, + projects, + byte_sizes, + total_bytes, + }), + _directory_lock: directory_lock, + }) + } +} + +impl StoreInner { + pub fn get(&self, id: Id) -> ApiResult<&StoredProject> { + self.projects.get(&id).ok_or_else(ApiError::missing) + } + + pub fn checked(&self, id: Id, expected: &str) -> ApiResult<&StoredProject> { + let current = self.get(id)?; + if current.etag() != expected { + let mut error = ApiError::new( + StatusCode::PRECONDITION_FAILED, + "revision_conflict", + "Project changed. Reload and review the current model before retrying.", + ); + error.current_revision = Some(current.revision); + return Err(error); + } + Ok(current) + } + + fn create(&mut self, prepared: PreparedModel) -> ApiResult { + if self.projects.len() >= MAX_PROJECTS { + return Err(capacity_error()); + } + let model = prepared.0; + let stored = StoredProject { + resource_id: Id::new_v4(), + revision: 1, + model, + }; + let size = self.write(&stored, true)?; + self.adopt(stored.clone(), size); + self.sync_directory()?; + Ok(stored) + } + + pub fn replace( + &mut self, + id: Id, + expected: &str, + prepared: PreparedModel, + ) -> ApiResult<(StoredProject, bool)> { + let current = self.checked(id, expected)?; + let model = prepared.0; + if current.model == model { + return Ok((current.clone(), false)); + } + let revision = current.revision.checked_add(1).ok_or_else(|| { + ApiError::new( + StatusCode::CONFLICT, + "revision_exhausted", + "Project revision counter is exhausted.", + ) + })?; + let stored = StoredProject { + resource_id: id, + revision, + model, + }; + let size = self.write(&stored, false)?; + self.adopt(stored.clone(), size); + self.sync_directory()?; + Ok((stored, true)) + } + + pub fn delete(&mut self, id: Id, expected: &str) -> ApiResult<()> { + self.checked(id, expected)?; + fs::remove_file(self.path(id)).map_err(ApiError::storage)?; + self.projects.remove(&id); + self.total_bytes -= self.byte_sizes.remove(&id).unwrap_or(0); + self.sync_directory() + } + + fn path(&self, id: Id) -> PathBuf { + self.directory.join(format!("{id}.json")) + } + + fn adopt(&mut self, stored: StoredProject, size: usize) { + let previous = self + .byte_sizes + .insert(stored.resource_id, size) + .unwrap_or(0); + self.total_bytes = self.total_bytes - previous + size; + self.projects.insert(stored.resource_id, stored); + } + + fn write(&self, stored: &StoredProject, create: bool) -> ApiResult { + let bytes = serde_json::to_vec(stored) + .map_err(|_| ApiError::internal("Could not serialize model."))?; + if bytes.len() > MAX_ENVELOPE_BYTES { + return Err(ApiError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "model_too_large", + "Stored model exceeds 8 MiB.", + )); + } + let previous = self + .byte_sizes + .get(&stored.resource_id) + .copied() + .unwrap_or(0); + if self.total_bytes - previous + bytes.len() > MAX_STORE_BYTES { + return Err(capacity_error()); + } + let mut temporary = tempfile::Builder::new() + .prefix(".arcadia-write-") + .tempfile_in(&self.directory) + .map_err(ApiError::storage)?; + temporary.write_all(&bytes).map_err(ApiError::storage)?; + temporary.as_file().sync_all().map_err(ApiError::storage)?; + if create { + temporary.persist_noclobber(self.path(stored.resource_id)) + } else { + temporary.persist(self.path(stored.resource_id)) + } + .map_err(|error| ApiError::storage(error.error))?; + Ok(bytes.len()) + } + + fn sync_directory(&self) -> ApiResult<()> { + // The installed file and in-memory head already agree if this sync fails. + // Clients receive an uncertain write outcome and must reload. + #[cfg(unix)] + File::open(&self.directory) + .and_then(|file| file.sync_all()) + .map_err(ApiError::storage)?; + Ok(()) + } +} + +fn capacity_error() -> ApiError { + ApiError::new( + StatusCode::INSUFFICIENT_STORAGE, + "store_capacity", + "Store supports at most 128 models and 256 MiB of serialized envelopes. Export/delete unneeded models before adding more data.", + ) +} + +pub(crate) fn check_model_size(model: &Project) -> ApiResult<()> { + MODEL_LIMITS + .check_model(model) + .map_err(|detail| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail))?; + let bytes = serde_json::to_vec(model) + .map_err(|_| ApiError::bad_request("Model cannot be serialized."))?; + if bytes.len() > MAX_MODEL_BYTES { + return Err(ApiError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "model_too_large", + "Serialized model exceeds 8 MiB.", + )); + } + Ok(()) +} + +pub(crate) fn validate_model(model: &Project) -> ApiResult<()> { + check_model_size(model)?; + let issues = issues(model); + if issues.iter().any(|issue| issue.severity == "error") { + let mut error = ApiError::new( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_model", + "Model violates structural constraints.", + ); + error.issues = issues; + return Err(error); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prepared_candidates_recheck_revision_at_commit_and_failed_loser_is_not_installed() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::open(directory.path()).unwrap(); + let first = store.create(Project::empty()).unwrap(); + let expected = first.etag(); + let mut winner = store.checked(first.resource_id, &expected).unwrap().model; + let mut loser = winner.clone(); + winner.name = "Winning review".into(); + loser.name = "Losing stale review".into(); + // Both requests completed preparation against the same original head. + let winner = PreparedModel::new(winner).unwrap(); + let loser = PreparedModel::new(loser).unwrap(); + let committed = store + .lock() + .unwrap() + .replace(first.resource_id, &expected, winner) + .unwrap() + .0; + let error = store + .lock() + .unwrap() + .replace(first.resource_id, &expected, loser) + .unwrap_err(); + assert_eq!(error.status, StatusCode::PRECONDITION_FAILED.as_u16()); + assert_eq!(error.current_revision, Some(2)); + let installed: StoredProject = serde_json::from_slice( + &fs::read(directory.path().join(format!("{}.json", first.resource_id))).unwrap(), + ) + .unwrap(); + assert_eq!(installed.model, committed.model); + assert_eq!(installed.revision, 2); + } +} diff --git a/crates/oa-server/tests/http.rs b/crates/oa-server/tests/http.rs new file mode 100644 index 00000000..23d1fc5f --- /dev/null +++ b/crates/oa-server/tests/http.rs @@ -0,0 +1,1626 @@ +use oa_core::{Command, Id, Kind, Layer, Project, RelationKind}; +use oa_server::{ + ServerConfig, app, + store::{Store, StoredProject}, +}; +use reqwest::{Client, StatusCode}; +use serde_json::{Value, json}; +use std::{path::Path, sync::Arc}; +use tokio::{sync::oneshot, task::JoinHandle}; + +struct TestServer { + base: String, + client: Client, + stop: oneshot::Sender<()>, + task: JoinHandle<()>, +} + +impl TestServer { + async fn start(directory: &Path, token: Option) -> Self { + let directory = directory.to_owned(); + let store = Arc::new( + tokio::task::spawn_blocking(move || Store::open(directory)) + .await + .unwrap() + .unwrap(), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let (stop, done) = oneshot::channel(); + let router = app(store, token); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = done.await; + }) + .await + .unwrap(); + }); + Self { + base, + client: Client::new(), + stop, + task, + } + } + + fn url(&self, path: &str) -> String { + format!("{}/api/v1{path}", self.base) + } + + async fn create(&self) -> (Id, String) { + let response = self + .client + .post(self.url("/projects")) + .json(&json!({"name":"Test mission"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let etag = response.headers()["etag"].to_str().unwrap().to_owned(); + let summary: Value = response.json().await.unwrap(); + ( + summary["resource_id"].as_str().unwrap().parse().unwrap(), + etag, + ) + } + + async fn import(&self, project: &Project) -> (Id, String) { + let response = self + .client + .post(self.url("/projects/import")) + .json(project) + .send() + .await + .unwrap(); + let status = response.status(); + let etag = response + .headers() + .get("etag") + .map(|value| value.to_str().unwrap().to_owned()); + let body: Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::CREATED, "{body}"); + ( + body["resource_id"].as_str().unwrap().parse().unwrap(), + etag.unwrap(), + ) + } + + async fn get(&self, id: Id) -> StoredProject { + self.client + .get(self.url(&format!("/projects/{id}"))) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap() + } + + async fn stop(self) { + let _ = self.stop.send(()); + drop(self.client); + tokio::time::timeout(std::time::Duration::from_secs(5), self.task) + .await + .unwrap() + .unwrap(); + } +} + +fn graph() -> Project { + let mut model = Project::empty(); + model.name = "Harbor assistance".into(); + let actor = model + .add_element(Kind::Actor, Layer::Oa, "Vessel", None) + .unwrap(); + let first = model + .add_element(Kind::Activity, Layer::Oa, "Report distress", None) + .unwrap(); + let second = model + .add_element(Kind::Activity, Layer::Oa, "Assess distress", None) + .unwrap(); + model.relate(RelationKind::Allocation, "Reports", actor, first); + model.relate(RelationKind::Interaction, "Distress signal", first, second); + model +} + +#[tokio::test] +async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let (existing, etag) = server.create().await; + let before = server.get(existing).await; + let mut source = + String::from("arcadia 2; project oversized \"Oversized\" { oa_activity a0 \"Root\" {}\n"); + let mut model = Project::empty(); + for i in 0..12_000u128 { + if i > 0 { + source.push_str(&format!( + "oa_activity a{i} \"Activity {i}\" {{ parent @a{}; }}\n", + i - 1 + )); + } + let id = Id::from_u128(i + 1); + model.elements.insert( + id, + oa_core::Element { + id, + alias: format!("a{i}"), + name: format!("Activity {i}"), + description: String::new(), + parent: (i > 0).then(|| Id::from_u128(i)), + body: oa_core::ElementBody::Oa(oa_core::OaBody::Activity), + }, + ); + } + source.push('}'); + let native_json = serde_json::to_vec(&model).unwrap(); + let mut legacy_elements = serde_json::Map::new(); + for (id, e) in &model.elements { + legacy_elements.insert(id.to_string(), json!({"id":id,"kind":"Activity","name":e.name,"description":"","position":{"x":0,"y":0},"steps":[]})); + } + let legacy_json = serde_json::to_vec(&json!({"schema_version":1,"id":model.id,"name":"Oversized","elements":legacy_elements,"relations":{}})).unwrap(); + for (media, body) in [ + ("text/plain", source.as_bytes().to_vec()), + ("application/json", native_json.clone()), + ("application/json", legacy_json), + ] { + let client = server.client.clone(); + let url = server.url("/projects/import"); + let upload = tokio::spawn(async move { + client + .post(url) + .header("content-type", media) + .body(body) + .send() + .await + .unwrap() + }); + // The large request is real transport traffic. This deliberately generous + // liveness bound catches the former multi-second quadratic validation; + // exact wall times belong to the separate release measurement record. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let read = tokio::time::timeout( + std::time::Duration::from_secs(5), + server.client.get(server.url("/projects")).send(), + ) + .await + .expect("catalog stalled by rejected import") + .unwrap(); + assert_eq!(read.status(), StatusCode::OK); + let rejected = tokio::time::timeout(std::time::Duration::from_secs(5), upload) + .await + .expect("oversized import not rejected promptly") + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + rejected.json::().await.unwrap()["code"], + "model_limit" + ); + assert_eq!(server.get(existing).await.model, before.model); + } + // The same budgets apply to replacement and non-mutating candidate checks; + // neither endpoint may run expensive structural validation before admission. + for (suffix, media, body) in [ + ("source", "text/plain", source.as_bytes().to_vec()), + ("model", "application/json", native_json), + ] { + let rejected = tokio::time::timeout( + std::time::Duration::from_secs(5), + server + .client + .put(server.url(&format!("/projects/{existing}/{suffix}"))) + .header("if-match", &etag) + .header("content-type", media) + .body(body) + .send(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + rejected.json::().await.unwrap()["code"], + "model_limit" + ); + } + for candidate in [ + json!({"format":"arcadia", "source":source}), + json!({"format":"json", "model":model}), + ] { + let checked = tokio::time::timeout( + std::time::Duration::from_secs(5), + server + .client + .post(server.url(&format!("/projects/{existing}/validation"))) + .json(&candidate) + .send(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(checked.status(), StatusCode::OK); + let checked: Value = checked.json().await.unwrap(); + assert_eq!(checked["valid"], false); + assert_eq!(checked["issues"][0]["code"], "model_limit"); + } + assert_eq!(server.get(existing).await.model, before.model); + assert_eq!(server.get(existing).await.revision, before.revision); + let catalog: Value = server + .client + .get(server.url("/projects")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(catalog["items"].as_array().unwrap().len(), 1); + 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. + for i in 1..2_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); + let loser = tokio::time::timeout(std::time::Duration::from_secs(25), proposed) + .await + .unwrap() + .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(); + let server = TestServer::start(directory.path(), None).await; + let source = r#"arcadia 2; project release_example "Release modeling" { + oa_entity team "Rescue team" {} + oa_mission need "Protect mariners" {} + oa_capability required "Assist mariners" {} + oa_capability routine "Routine assistance" {} + oa_capability extended "Extended assistance" {} + mission_involvement participant "Mission participant" { source @team; target @need; } + mission_capability mission_support "Mission support" { source @need; target @required; } + capability_include included "Required assistance" { source @required; target @routine; } + capability_extend extra "Conditional assistance" { source @extended; target @required; condition "sea_state > 4"; } + capability_generalization specialized "Specialized assistance" { source @extended; target @routine; } + sa_system system_of_interest "Rescue support" {} + sa_mission response "Support rescue" {} + trace mission_mapping "Mission realization" { oa @need; sa @response; kind Mission; } + sa_function assess "Assess conditions" {} + sa_function notify_operator "Notify operator" {} + sa_functional_chain routine_chain "Routine chain" { steps [@routine_action]; } + sa_occurrence routine_action "Routine notification" { parent @routine_chain; body { Action { action @notify_operator; lifeline none; }; }; } + sa_functional_chain chain "Rescue decision" { steps [@entry, @choice, @routine_path, @extended_path, @merge, @reused, @finish]; } + sa_occurrence entry "Assess" { parent @chain; body { Action { action @assess; lifeline none; }; }; } + sa_occurrence choice "Select response" { parent @chain; body { Control { kind Choice; }; }; } + sa_occurrence routine_path "Routine response" { parent @chain; body { Action { action @notify_operator; lifeline none; }; }; } + sa_occurrence extended_path "Review conditions" { parent @chain; body { Action { action @assess; lifeline none; }; }; } + sa_occurrence merge "Continue response" { parent @chain; body { Control { kind Merge; }; }; } + sa_occurrence reused "Reused notification" { parent @chain; body { SequenceReference { sequence @routine_chain; }; }; } + sa_occurrence finish "Finish" { parent @chain; body { Action { action @notify_operator; lifeline none; }; }; } + sequence_link start_choice "Choose" { source @entry; target @choice; } + sequence_link ordinary "Ordinary path" { source @choice; target @routine_path; guard "sea_state <= 4"; } + sequence_link exceptional "Extended path" { source @choice; target @extended_path; guard "else"; } + sequence_link first_merge "Continue" { source @routine_path; target @merge; } + sequence_link second_merge "Continue" { source @extended_path; target @merge; } + sequence_link call_routine "Reuse notification" { source @merge; target @reused; } + sequence_link conclude "Conclude" { source @reused; target @finish; } + }"#; + let imported = server + .client + .post(server.url("/projects/import")) + .header("content-type", "text/plain") + .body(source) + .send() + .await + .unwrap(); + assert_eq!(imported.status(), StatusCode::CREATED); + let etag = imported.headers()["etag"].to_str().unwrap().to_owned(); + let id: Id = imported.json::().await.unwrap()["resource_id"] + .as_str() + .unwrap() + .parse() + .unwrap(); + let baseline = server.get(id).await; + let mut changed = baseline + .model + .relations + .values() + .find(|r| r.alias == "ordinary") + .unwrap() + .clone(); + if let oa_core::RelationBody::SequenceLink { guard, .. } = &mut changed.body { + *guard = "sea_state <= 4 && operator == \"Ready\"".into(); + } else { + panic!("wrong persisted relation body"); + } + let commit = server + .client + .post(server.url(&format!("/projects/{id}/transactions"))) + .header("if-match", etag) + .json(&json!({"commands":[Command::EditRelation(changed.id, changed.clone())]})) + .send() + .await + .unwrap(); + assert_eq!(commit.status(), StatusCode::OK); + let current = server.get(id).await; + assert_eq!(current.revision, 2); + assert_eq!(current.model.relations[&changed.id], changed); + let exported = server + .client + .get(server.url(&format!("/projects/{id}/source"))) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(exported.contains("sequence @routine_chain;")); + assert_eq!(oa_core::language::parse(&exported).unwrap(), current.model); + let filtered: Value = server + .client + .get(server.url(&format!("/projects/{id}/relations?kind=SequenceLink"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(filtered["items"].as_array().unwrap().len(), 7); + server.stop().await; + let server = TestServer::start(directory.path(), None).await; + assert_eq!(server.get(id).await.model, current.model); + server.stop().await; +} + +#[tokio::test] +async fn legacy_v2_scenarios_and_typed_message_edits_use_the_same_http_model() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let legacy_json = include_bytes!("../../oa-core/tests/fixtures/v2-coastal-rescue.json"); + let imported = server + .client + .post(server.url("/projects/import")) + .header("content-type", "application/json") + .body(legacy_json.to_vec()) + .send() + .await + .unwrap(); + assert_eq!(imported.status(), StatusCode::CREATED); + let legacy_id: Id = imported.json::().await.unwrap()["resource_id"] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert_eq!( + server.get(legacy_id).await.model, + oa_core::persistence::decode(legacy_json).unwrap() + ); + let source = r#"arcadia 2; project messages "Synchronous request" { + sa_system controller "Controller" {} + sa_function request "Request" {} + sa_function respond "Respond" {} + sa_exchange_item payload "Payload" {} + sa_data_field value_field "Requested value" { parent @payload; } + functional_exchange request_data "Request data" { source @request; target @respond; items [@payload]; } + sa_scenario story "Request and response" { viewpoint Functional; steps [@called, @replied]; } + sa_lifeline requester "Requester" { parent @story; represented @request; } + sa_lifeline responder "Responder" { parent @story; represented @respond; } + sa_occurrence called "Call" { parent @story; body { Exchange { exchange @request_data; sender @requester; receiver @responder; message_kind Call; reply_to none; values [{ field @value_field; expression "requested_value"; }]; }; }; } + sa_occurrence replied "Reply" { parent @story; body { Exchange { exchange @request_data; sender @responder; receiver @requester; receiver @requester; message_kind Reply; reply_to @called; values []; }; }; } + }"#; + // Source errors remain atomic even with the richer typed constructors. + let rejected = server + .client + .put(server.url(&format!("/projects/{legacy_id}/source"))) + .header("if-match", server.get(legacy_id).await.etag()) + .header("content-type", "text/plain") + .body(source) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(server.get(legacy_id).await.revision, 1); + let source = source.replace( + "receiver @requester; receiver @requester;", + "receiver @requester;", + ); + let model = oa_core::language::parse(&source).unwrap(); + let (id, etag) = server.import(&model).await; + let mut message = model + .elements + .values() + .find(|element| element.alias == "called") + .unwrap() + .clone(); + if let Some(oa_core::SharedBody::Occurrence { + body: oa_core::OccurrenceBody::Exchange { values, .. }, + .. + }) = message.shared_mut() + { + values[0].expression = "requested_value + \"checked\"".into(); + } else { + panic!("expected call payload"); + } + let accepted = server + .client + .post(server.url(&format!("/projects/{id}/transactions"))) + .header("if-match", etag) + .json(&json!({"commands":[Command::EditElement(message.id, message.clone())]})) + .send() + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::OK); + let committed = server.get(id).await; + assert_eq!(committed.model.elements[&message.id], message); + let exported = server + .client + .get(server.url(&format!("/projects/{id}/source"))) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(exported.contains("message_kind Call;")); + assert!(exported.contains("reply_to @called;")); + assert!(exported.contains("field @value_field;")); + assert_eq!( + oa_core::language::parse(&exported).unwrap(), + committed.model + ); + server.stop().await; + let server = TestServer::start(directory.path(), None).await; + assert_eq!(server.get(id).await.model, committed.model); + assert_eq!(server.get(legacy_id).await.revision, 1); + server.stop().await; +} + +#[tokio::test] +async fn typed_state_events_batch_reidentification_and_deletion_use_real_http() { + use oa_core::{EventBody, RelationBody, SharedBody, TimeEventKind, TransitionKind}; + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let source = r#"arcadia 2; project recovery "Typed recovery" { + oa_entity team "Operational team" {} + oa_activity operational_work "Operational assessment" {} + oa_event operational_event "Operational change" { body { Change { expression "visibility > 100"; }; }; } + sa_system controller "Controller" {} + sa_function assess "Assess recovery" {} + sa_exchange_item report_data "Report data" {} + sa_event report "Report arrived" { body { Signal { items [@report_data]; }; }; } + sa_event deadline "Recovery timeout" { body { Time { kind After; expression "30 s"; }; }; } + sa_state_machine machine "Recovery" { parent @controller; } + sa_region region "Main region" { parent @machine; } + sa_state idle "Idle" { parent @region; } + sa_state active "Active" { parent @region; } + state_transition recover "Recover" { source @idle; target @active; triggers [@deadline]; effects [@assess]; trigger ""; guard "operator == \"Ready\""; effect "Record assessment\nНе выполнять автоматически"; } + }"#; + let imported = server + .client + .post(server.url("/projects/import")) + .header("content-type", "text/plain") + .body(source) + .send() + .await + .unwrap(); + assert_eq!(imported.status(), StatusCode::CREATED); + let first_etag = imported.headers()["etag"].to_str().unwrap().to_owned(); + let id: Id = imported.json::().await.unwrap()["resource_id"] + .as_str() + .unwrap() + .parse() + .unwrap(); + let original = server.get(id).await; + let find = |alias: &str| { + original + .model + .elements + .values() + .find(|e| e.alias == alias) + .unwrap() + .id + }; + let deadline = find("deadline"); + let work = find("assess"); + let payload = find("report_data"); + let signal = find("report"); + let mut change = original.model.elements[&deadline].clone(); + change.id = Id::new_v4(); + change.alias = "recovery_changed".into(); + change.name = "Recovery accepted".into(); + *change.shared_mut().unwrap() = SharedBody::Event { + body: EventBody::Change { + expression: "pressure > 4 && status == \"Ready\"".into(), + }, + }; + let mut transition = original + .model + .relations + .values() + .find(|r| r.alias == "recover") + .unwrap() + .clone(); + let RelationBody::StateTransition { + triggers, + transition_kind, + .. + } = &mut transition.body + else { + panic!("expected transition") + }; + *triggers = vec![deadline, change.id, work]; + *transition_kind = TransitionKind::External; + let changed_deadline = Id::new_v4(); + let changed_payload = Id::new_v4(); + let commands = vec![ + Command::CreateElement(change.clone()), + Command::EditRelation(transition.id, transition.clone()), + Command::Reidentify(deadline, changed_deadline), + Command::Reidentify(payload, changed_payload), + Command::Rename(work, "Assess accepted recovery".into()), + ]; + let url = server.url(&format!("/projects/{id}/transactions")); + let accepted = server + .client + .post(&url) + .header("if-match", &first_etag) + .json(&json!({"commands":commands})) + .send() + .await + .unwrap(); + assert_eq!( + accepted.status(), + StatusCode::OK, + "{}", + accepted.text().await.unwrap() + ); + let committed = server.get(id).await; + assert_eq!(committed.revision, 2); + assert_eq!( + committed.model, + oa_core::execute_batch(&original.model, &commands).unwrap() + ); + assert_eq!(committed.model.elements[&change.id], change); + assert!(!committed.model.elements.contains_key(&deadline)); + let Some(SharedBody::Event { + body: EventBody::Time { kind, expression }, + }) = committed.model.elements[&changed_deadline].shared() + else { + panic!("expected time event") + }; + assert_eq!((*kind, expression.as_str()), (TimeEventKind::After, "30 s")); + let Some(SharedBody::Event { + body: EventBody::Signal { items }, + }) = committed.model.elements[&signal].shared() + else { + panic!("expected signal") + }; + assert_eq!(items, &[changed_payload]); + let RelationBody::StateTransition { + triggers, + effects, + guard, + effect, + .. + } = &committed.model.relations[&transition.id].body + else { + panic!("expected transition") + }; + assert_eq!(triggers, &[changed_deadline, change.id, work]); + assert_eq!(effects, &[work]); + assert_eq!(guard, "operator == \"Ready\""); + assert_eq!(effect, "Record assessment\nНе выполнять автоматически"); + let stale = server + .client + .post(&url) + .header("if-match", &first_etag) + .json(&json!({"commands":[Command::Delete(change.id)]})) + .send() + .await + .unwrap(); + assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED); + let mut invalid = committed.model.relations[&transition.id].clone(); + let RelationBody::StateTransition { triggers, .. } = &mut invalid.body else { + unreachable!() + }; + *triggers = vec![find("operational_event")]; + let rejected = server.client.post(&url).header("if-match", committed.etag()) + .json(&json!({"commands":[Command::Rename(work, "Must roll back".into()), Command::EditRelation(invalid.id, invalid)]})) + .send().await.unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(server.get(id).await.model, committed.model); + assert_eq!(server.get(id).await.etag(), committed.etag()); + let exported = server + .client + .get(server.url(&format!("/projects/{id}/source"))) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(exported.contains("triggers [@deadline, @recovery_changed, @assess];")); + assert!(exported.contains("effects [@assess];")); + assert_eq!( + oa_core::language::parse(&exported).unwrap(), + committed.model + ); + let events: Value = server + .client + .get(server.url(&format!("/projects/{id}/elements?kind=Event"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(events["items"].as_array().unwrap().len(), 4); + let deleted = server.client.post(&url).header("if-match", committed.etag()) + .json(&json!({"commands":[Command::Delete(changed_deadline),Command::Delete(change.id),Command::Delete(work),Command::Delete(changed_payload)]})) + .send().await.unwrap(); + assert_eq!( + deleted.status(), + StatusCode::OK, + "{}", + deleted.text().await.unwrap() + ); + let remaining = server.get(id).await; + assert_eq!(remaining.revision, 3); + let RelationBody::StateTransition { + triggers, + effects, + trigger, + guard: retained_guard, + effect: retained_effect, + .. + } = &remaining.model.relations[&transition.id].body + else { + panic!("expected transition") + }; + assert!(triggers.is_empty() && effects.is_empty()); + assert!( + !trigger.trim().is_empty(), + "deleting the last trigger must not introduce completion semantics" + ); + assert_eq!((retained_guard, retained_effect), (guard, effect)); + let Some(SharedBody::Event { + body: EventBody::Signal { items }, + }) = remaining.model.elements[&signal].shared() + else { + panic!("expected signal") + }; + assert!(items.is_empty()); + assert_eq!( + oa_core::language::parse(&oa_core::language::format(&remaining.model)).unwrap(), + remaining.model + ); + server.stop().await; + let server = TestServer::start(directory.path(), None).await; + assert_eq!(server.get(id).await.model, remaining.model); + assert_eq!(server.get(id).await.etag(), remaining.etag()); + server.stop().await; +} + +#[tokio::test] +async fn conditional_writes_are_atomic_and_survive_restart() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let (id, etag) = server.create().await; + let url = server.url(&format!("/projects/{id}")); + let a = server + .client + .patch(&url) + .header("if-match", &etag) + .json(&json!({"name":"Writer A"})) + .send(); + let b = server + .client + .patch(&url) + .header("if-match", &etag) + .json(&json!({"name":"Writer B"})) + .send(); + let (a, b) = tokio::join!(a, b); + let statuses = [a.unwrap().status(), b.unwrap().status()]; + assert_eq!( + statuses + .iter() + .filter(|status| **status == StatusCode::OK) + .count(), + 1 + ); + assert_eq!( + statuses + .iter() + .filter(|status| **status == StatusCode::PRECONDITION_FAILED) + .count(), + 1 + ); + let committed = server.get(id).await; + assert_eq!(committed.revision, 2); + let stale = server + .client + .delete(&url) + .header("if-match", &etag) + .send() + .await + .unwrap(); + assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED); + let conflict: Value = stale.json().await.unwrap(); + assert_eq!(conflict["current_revision"], 2); + server.stop().await; + + let server = TestServer::start(directory.path(), None).await; + let restored = server.get(id).await; + assert_eq!(restored.model, committed.model); + assert_eq!(restored.revision, 2); + let cached = server + .client + .get(server.url(&format!("/projects/{id}"))) + .header("if-none-match", restored.etag()) + .send() + .await + .unwrap(); + assert_eq!(cached.status(), StatusCode::NOT_MODIFIED); + let deleted = server + .client + .delete(server.url(&format!("/projects/{id}"))) + .header("if-match", restored.etag()) + .send() + .await + .unwrap(); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + assert!(!directory.path().join(format!("{id}.json")).exists()); + assert_eq!( + server + .client + .get(server.url(&format!("/projects/{id}"))) + .send() + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + assert_eq!( + server + .client + .delete(server.url(&format!("/projects/{id}"))) + .header("if-match", restored.etag()) + .send() + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + server.stop().await; +} + +#[tokio::test] +async fn invalid_candidates_and_bad_headers_leave_snapshot_unchanged() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let (id, etag) = server.import(&graph()).await; + let original = server.get(id).await; + let file = directory.path().join(format!("{id}.json")); + let original_bytes = std::fs::read(&file).unwrap(); + let model_url = server.url(&format!("/projects/{id}/model")); + let mut invalid = original.model.clone(); + invalid.alias = "project".into(); + let rejected = server + .client + .put(&model_url) + .header("if-match", &etag) + .json(&invalid) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!( + rejected.headers()["content-type"], + "application/problem+json" + ); + assert!( + !rejected.json::().await.unwrap()["issues"] + .as_array() + .unwrap() + .is_empty() + ); + + let malformed = server + .client + .put(server.url(&format!("/projects/{id}/source"))) + .header("if-match", &etag) + .header("content-type", "text/plain") + .body("arcadia garbage") + .send() + .await + .unwrap(); + assert_eq!(malformed.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert!(malformed.json::().await.unwrap()["issues"][0]["line"].is_number()); + let missing = server + .client + .put(&model_url) + .json(&original.model) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::PRECONDITION_REQUIRED); + for bad in ["\"", "*", "W/\"old\"", "\"first\",\"second\""] { + let rejected = server + .client + .put(&model_url) + .header("if-match", bad) + .json(&original.model) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST); + } + assert_eq!(server.get(id).await.model, original.model); + assert_eq!(std::fs::read(file).unwrap(), original_bytes); + server.stop().await; +} + +#[tokio::test] +async fn batches_dry_run_noop_and_source_share_the_core() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let (id, etag) = server.import(&graph()).await; + let original = server.get(id).await; + let first = original + .model + .elements + .values() + .find(|element| element.kind() == Kind::Activity) + .unwrap() + .id; + let commands = vec![ + Command::Rename(first, "Updated activity".into()), + Command::Describe(first, "Reviewed by API".into()), + ]; + let transaction_url = server.url(&format!("/projects/{id}/transactions")); + let rejected = server.client.post(&transaction_url).header("if-match", &etag) + .json(&json!({"commands":[Command::Rename(first, "Must roll back".into()), Command::Delete(Id::new_v4())]})).send().await.unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(server.get(id).await.model, original.model); + let excessive = Command::Batch(vec![Command::Rename(first, "Never applied".into()); 200]); + assert_eq!( + server + .client + .post(&transaction_url) + .header("if-match", &etag) + .json(&json!({"commands":[excessive]})) + .send() + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + let preview = server + .client + .post(&transaction_url) + .header("if-match", &etag) + .json(&json!({"commands":commands,"dry_run":true})) + .send() + .await + .unwrap(); + assert_eq!(preview.status(), StatusCode::OK); + let preview: Value = preview.json().await.unwrap(); + assert_eq!(preview["dry_run"], true); + assert_eq!(server.get(id).await.model, original.model); + + let commit = server + .client + .post(&transaction_url) + .header("if-match", &etag) + .json(&json!({"commands":commands})) + .send() + .await + .unwrap(); + assert_eq!(commit.status(), StatusCode::OK); + let new_etag = commit.headers()["etag"].to_str().unwrap().to_owned(); + let stored = server.get(id).await; + assert_eq!(stored.revision, 2); + assert_eq!( + stored.model, + oa_core::execute_batch(&original.model, &commands).unwrap() + ); + let no_op = server + .client + .post(&transaction_url) + .header("if-match", &new_etag) + .json(&json!({"commands":commands})) + .send() + .await + .unwrap(); + assert_eq!(no_op.status(), StatusCode::OK); + assert_eq!(no_op.json::().await.unwrap()["changed"], false); + + let source = server + .client + .get(server.url(&format!("/projects/{id}/source"))) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert_eq!( + server + .client + .get(server.url(&format!("/projects/{id}/source?revision=1"))) + .send() + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model); + let imported = server + .client + .post(server.url("/projects/import")) + .header("content-type", "text/plain; charset=utf-8") + .body(source) + .send() + .await + .unwrap(); + assert_eq!(imported.status(), StatusCode::CREATED); + let imported: Value = imported.json().await.unwrap(); + let copy = server + .get(imported["resource_id"].as_str().unwrap().parse().unwrap()) + .await; + assert_ne!(copy.resource_id, stored.resource_id); + assert_eq!(copy.model, stored.model); + server.stop().await; +} + +#[tokio::test] +async fn graph_filters_pagination_and_bounded_neighborhoods() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let model = graph(); + let (id, _) = server.import(&model).await; + let page = server + .client + .get(server.url(&format!( + "/projects/{id}/elements?kind=Activity&layer=Oa&limit=1" + ))) + .send() + .await + .unwrap(); + assert_eq!(page.status(), StatusCode::OK); + let page: Value = page.json().await.unwrap(); + assert_eq!(page["items"].as_array().unwrap().len(), 1); + let after = page["next_after"].as_str().unwrap(); + let next: Value = server + .client + .get(server.url(&format!( + "/projects/{id}/elements?kind=Activity&layer=Oa&limit=1&after={after}&revision=1" + ))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_ne!(page["items"][0]["id"], next["items"][0]["id"]); + let actor = model + .elements + .values() + .find(|element| element.kind() == Kind::Actor) + .unwrap() + .id; + let url = server.url(&format!("/projects/{id}/query")); + let limited: Value = server + .client + .post(&url) + .json(&json!({"roots":[actor],"max_depth":4,"max_objects":2})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(limited["truncated"], true); + assert_eq!(limited["elements"].as_array().unwrap().len(), 1); + assert!(limited["relations"].as_array().unwrap().is_empty()); + let complete: Value = server + .client + .post(&url) + .json(&json!({"roots":[actor],"max_depth":4,"max_objects":100})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(complete["truncated"], false); + assert_eq!(complete["elements"].as_array().unwrap().len(), 3); + assert_eq!(complete["relations"].as_array().unwrap().len(), 2); + let root_relation = *model.relations.keys().next().unwrap(); + let relation_root: Value = server + .client + .post(&url) + .json(&json!({"roots":[root_relation],"max_depth":0,"max_objects":100})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(relation_root["relations"].as_array().unwrap().len(), 1); + assert_eq!(relation_root["elements"].as_array().unwrap().len(), 2); + assert_eq!( + server + .client + .get(server.url(&format!("/projects/{id}/elements?revision=2"))) + .send() + .await + .unwrap() + .status(), + StatusCode::CONFLICT + ); + assert_eq!( + server + .client + .post(url) + .json(&json!({"roots":[actor],"max_depth":99})) + .send() + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + server.stop().await; +} + +#[tokio::test] +async fn auth_origins_media_and_body_limits_are_enforced_over_http() { + let directory = tempfile::tempdir().unwrap(); + let token = "test-only-0123456789abcdef0123456789abcdef"; + let server = TestServer::start(directory.path(), Some(token.into())).await; + let url = server.url("/projects"); + assert_eq!( + server.client.get(&url).send().await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + server + .client + .get(&url) + .bearer_auth("wrong-token") + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + server + .client + .get(&url) + .bearer_auth(token) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert_eq!( + server + .client + .get(format!("{}/health", server.base)) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + let openapi: Value = server + .client + .get(server.url("/openapi.json")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(openapi["openapi"], "3.1.0"); + // Public automation vocabulary must expose every accepted engineering kind. + let schemas = &openapi["components"]["schemas"]; + for kind in Kind::ALL { + assert!( + schemas["Kind"]["enum"] + .as_array() + .unwrap() + .contains(&serde_json::to_value(kind).unwrap()) + ); + } + for kind in RelationKind::ALL { + let name = serde_json::to_value(kind).unwrap(); + assert!( + schemas["RelationKind"]["enum"] + .as_array() + .unwrap() + .contains(&name) + ); + assert!( + schemas["RelationBody"]["oneOf"] + .as_array() + .unwrap() + .iter() + .any(|variant| variant["properties"].get(name.as_str().unwrap()).is_some()) + ); + } + assert_eq!( + server + .client + .post(&url) + .bearer_auth(token) + .header("origin", "https://unrelated.example") + .json(&json!({"name":"bad"})) + .send() + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + server + .client + .post(&url) + .bearer_auth(token) + .header("content-type", "text/plain") + .body("{}") + .send() + .await + .unwrap() + .status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE + ); + // Send the oversized Content-Length without uploading its rejected payload. + // Continuing an 8 MiB upload after an early 413 can produce a Windows TCP reset. + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut socket = tokio::net::TcpStream::connect(server.base.trim_start_matches("http://")) + .await + .unwrap(); + socket.write_all(format!("POST /api/v1/projects HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", 8 * 1024 * 1024 + 1).as_bytes()).await.unwrap(); + let mut reply = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.read_to_string(&mut reply), + ) + .await + .unwrap() + .unwrap(); + assert!(reply.starts_with("HTTP/1.1 413"), "{reply}"); + assert!(reply.contains("application/problem+json"), "{reply}"); + assert_eq!( + server + .client + .get(server.url("/missing")) + .bearer_auth(token) + .send() + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + server.stop().await; +} + +#[tokio::test] +async fn failed_atomic_replacement_preserves_the_accepted_model() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let (id, etag) = server.create().await; + let before = server.get(id).await; + let envelope = directory.path().join(format!("{id}.json")); + let bytes = std::fs::read(&envelope).unwrap(); + // Deliberately make the atomic rename target unusable in this isolated store. + std::fs::remove_file(&envelope).unwrap(); + std::fs::create_dir(&envelope).unwrap(); + let failed = server + .client + .patch(server.url(&format!("/projects/{id}"))) + .header("if-match", etag) + .json(&json!({"name":"Must not be adopted"})) + .send() + .await + .unwrap(); + assert_eq!(failed.status(), StatusCode::SERVICE_UNAVAILABLE); + let after = server.get(id).await; + assert_eq!(after.revision, before.revision); + assert_eq!(after.model, before.model); + std::fs::remove_dir(&envelope).unwrap(); + std::fs::write(&envelope, bytes).unwrap(); + server.stop().await; +} + +#[test] +fn storage_rejects_second_owners_corruption_and_identity_mismatch() { + let directory = tempfile::tempdir().unwrap(); + let first = Store::open(directory.path()).unwrap(); + assert!(Store::open(directory.path()).is_err()); + drop(first); + let filename = Id::new_v4(); + let path = directory.path().join(format!("{filename}.json")); + std::fs::write(&path, "{broken").unwrap(); + assert!(Store::open(directory.path()).is_err()); + let mismatched = StoredProject { + resource_id: Id::new_v4(), + revision: 1, + model: Project::empty(), + }; + std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap(); + assert!(Store::open(directory.path()).is_err()); + let invalid = StoredProject { + resource_id: filename, + revision: 0, + model: Project::empty(), + }; + std::fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert!(Store::open(directory.path()).is_err()); +} + +#[test] +fn non_loopback_requires_a_token() { + let mut config = ServerConfig { + bind: "0.0.0.0:8787".parse().unwrap(), + data_dir: "unused".into(), + bearer_token: None, + }; + assert!(config.validate().is_err()); + config.bearer_token = Some("test-only-0123456789abcdef0123456789abcdef".into()); + assert!(config.validate().is_ok()); + config.bearer_token = None; + config.bind = "[::1]:8787".parse().unwrap(); + assert!(config.validate().is_ok()); +} + +#[tokio::test] +async fn transition_preview_apply_and_artifact_links_preserve_revision_boundaries() { + use oa_core::transitions::{TransitionDirection, TransitionRequest}; + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let model = graph(); + let source = model + .elements + .values() + .find(|element| element.kind() == Kind::Activity) + .unwrap() + .id; + let (id, etag) = server.import(&model).await; + let request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]); + let preview_url = server.url(&format!("/projects/{id}/transitions/preview")); + let preview = server + .client + .post(&preview_url) + .header("if-match", &etag) + .json(&request) + .send() + .await + .unwrap(); + let status = preview.status(); + let plan: Value = preview.json().await.unwrap(); + assert_eq!(status, StatusCode::OK, "{plan}"); + assert!( + plan["plan"]["conflicts"].as_array().unwrap().is_empty(), + "{plan}" + ); + assert_eq!(server.get(id).await.model, model); + let apply_url = server.url(&format!("/projects/{id}/transitions/apply")); + let applied = server + .client + .post(&apply_url) + .header("if-match", &etag) + .json(&plan) + .send() + .await + .unwrap(); + let status = applied.status(); + let result: Value = applied.json().await.unwrap(); + assert_eq!(status, StatusCode::OK, "{result}"); + let current = server.get(id).await; + assert_eq!(current.revision, 2); + assert!( + current + .model + .elements + .values() + .any(|element| element.kind() == Kind::Function) + ); + assert_eq!(current.model.traces.len(), 1); + assert_eq!( + server + .client + .post(&apply_url) + .header("if-match", &etag) + .json(&plan) + .send() + .await + .unwrap() + .status(), + StatusCode::PRECONDITION_FAILED + ); + assert_eq!( + server + .client + .post(&apply_url) + .header("if-match", current.etag()) + .json(&plan) + .send() + .await + .unwrap() + .status(), + StatusCode::PRECONDITION_FAILED + ); + + let artifact = oa_core::ArtifactLink { + id: Id::new_v4(), + alias: "evidence_report".into(), + name: "Evidence report".into(), + description: "Produced by an external tool".into(), + owner: source, + relation: oa_core::ArtifactRelation::Verifies, + uri: "https://example.invalid/report".into(), + revision: Some("external:revision-42".into()), + fragment: Some("section-7".into()), + }; + let committed = server + .client + .post(server.url(&format!("/projects/{id}/transactions"))) + .header("if-match", current.etag()) + .json(&json!({"commands":[Command::CreateArtifact(artifact.clone())]})) + .send() + .await + .unwrap(); + assert_eq!(committed.status(), StatusCode::OK); + let records: Value = server + .client + .get(server.url(&format!("/projects/{id}/artifact-links?object={source}"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + records["items"][0], + serde_json::to_value(&artifact).unwrap() + ); + let traces: Value = server + .client + .get(server.url(&format!("/projects/{id}/traces?object={source}"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(traces["items"].as_array().unwrap().len(), 1); + server.stop().await; + let server = TestServer::start(directory.path(), None).await; + assert_eq!( + server.get(id).await.model.artifact_links[&artifact.id], + artifact + ); + server.stop().await; +} + +#[tokio::test] +async fn fixed_store_capacity_is_enforced_on_http_and_restart() { + let directory = tempfile::tempdir().unwrap(); + let mut last = None; + for _ in 0..oa_server::store::MAX_PROJECTS { + let envelope = StoredProject { + resource_id: Id::new_v4(), + revision: 1, + model: Project::empty(), + }; + std::fs::write( + directory + .path() + .join(format!("{}.json", envelope.resource_id)), + serde_json::to_vec(&envelope).unwrap(), + ) + .unwrap(); + last = Some(envelope); + } + let server = TestServer::start(directory.path(), None).await; + let rejected = server + .client + .post(server.url("/projects")) + .json(&json!({"name":"One too many"})) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::INSUFFICIENT_STORAGE); + let last = last.unwrap(); + assert_eq!( + server + .client + .delete(server.url(&format!("/projects/{}", last.resource_id))) + .header("if-match", last.etag()) + .send() + .await + .unwrap() + .status(), + StatusCode::NO_CONTENT + ); + server.create().await; + server.stop().await; + std::fs::write( + directory.path().join(format!("{}.json", last.resource_id)), + serde_json::to_vec(&last).unwrap(), + ) + .unwrap(); + assert!(Store::open(directory.path()).is_err()); +} + +#[tokio::test] +async fn chunked_upload_limits_and_loopback_host_check_use_the_real_transport() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + assert_eq!( + server + .client + .get(server.url("/projects")) + .header("host", "untrusted.example") + .send() + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + let socket = tokio::net::TcpStream::connect(server.base.trim_start_matches("http://")) + .await + .unwrap(); + let (mut reader, mut writer) = socket.into_split(); + let upload = tokio::spawn(async move { + writer.write_all(b"POST /api/v1/projects HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await?; + let chunk = vec![b' '; 65_536]; + for _ in 0..128 { + writer.write_all(b"10000\r\n").await?; + writer.write_all(&chunk).await?; + writer.write_all(b"\r\n").await?; + } + writer.write_all(b"1\r\nx\r\n0\r\n\r\n").await + }); + let mut reply = Vec::new(); + let read = tokio::time::timeout( + std::time::Duration::from_secs(10), + reader.read_to_end(&mut reply), + ) + .await; + assert!( + read.is_ok(), + "Server did not reject oversized chunked body promptly." + ); + // TCP can reset after a rejection while a writer is still sending its tail. + // Any delivered response still must carry the explicit body-limit status. + let _ = upload.await.unwrap(); + let reply = String::from_utf8_lossy(&reply); + assert!(reply.starts_with("HTTP/1.1 413"), "{reply}"); + assert!(reply.contains("application/problem+json"), "{reply}"); + server.stop().await; +} + +#[tokio::test] +async fn legacy_json_import_and_model_reidentification_keep_the_service_address() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let model_id = Id::new_v4(); + let legacy = json!({"schema_version":1,"id":model_id,"name":"Migrated mission","elements":{},"relations":{}}); + let imported = server + .client + .post(server.url("/projects/import")) + .json(&legacy) + .send() + .await + .unwrap(); + let status = imported.status(); + let value: Value = imported.json().await.unwrap(); + assert_eq!(status, StatusCode::CREATED, "{value}"); + let resource_id: Id = value["resource_id"].as_str().unwrap().parse().unwrap(); + let current = server.get(resource_id).await; + assert_eq!(current.model.id, model_id); + assert_eq!(current.model.schema_version, oa_core::SCHEMA_VERSION); + let new_model_id = Id::new_v4(); + let changed = server + .client + .post(server.url(&format!("/projects/{resource_id}/transactions"))) + .header("if-match", current.etag()) + .json(&json!({"commands":[Command::Reidentify(model_id,new_model_id)]})) + .send() + .await + .unwrap(); + assert_eq!(changed.status(), StatusCode::OK); + let after = server.get(resource_id).await; + assert_eq!(after.resource_id, resource_id); + assert_eq!(after.model.id, new_model_id); + assert!( + directory + .path() + .join(format!("{resource_id}.json")) + .is_file() + ); + assert!( + !directory + .path() + .join(format!("{new_model_id}.json")) + .exists() + ); + server.stop().await; +} diff --git a/deployment/Dockerfile b/deployment/Dockerfile new file mode 100644 index 00000000..113baac0 --- /dev/null +++ b/deployment/Dockerfile @@ -0,0 +1,18 @@ +FROM rust:1.90-bookworm AS builder +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY docs/openapi.json ./docs/openapi.json +RUN cargo build --release --locked -p oa-server + +FROM debian:bookworm-slim +RUN groupadd --gid 10001 arcadia \ + && useradd --uid 10001 --gid 10001 --no-create-home --home-dir /data arcadia \ + && mkdir /data \ + && chown 10001:10001 /data +COPY --from=builder /src/target/release/arcadia-server /usr/local/bin/arcadia-server +USER 10001:10001 +VOLUME ["/data"] +EXPOSE 8787 +ENTRYPOINT ["/usr/local/bin/arcadia-server"] +CMD ["--bind", "0.0.0.0:8787", "--data-dir", "/data", "--token-file", "/run/secrets/arcadia-token"] diff --git a/deployment/Dockerfile.dockerignore b/deployment/Dockerfile.dockerignore new file mode 100644 index 00000000..d4695eba --- /dev/null +++ b/deployment/Dockerfile.dockerignore @@ -0,0 +1,9 @@ +.git +target +dist +harness +**/target +**/*.sqlite3 +**/*.log +.codex +.openai diff --git a/deployment/arcadia-server.service b/deployment/arcadia-server.service new file mode 100644 index 00000000..bde982f9 --- /dev/null +++ b/deployment/arcadia-server.service @@ -0,0 +1,24 @@ +[Unit] +Description=Arcadia Studio model REST service +After=network.target + +[Service] +Type=simple +User=arcadia +Group=arcadia +StateDirectory=arcadia +StateDirectoryMode=0700 +ExecStart=/usr/local/bin/arcadia-server --bind 127.0.0.1:8787 --data-dir /var/lib/arcadia --token-file /etc/arcadia/token +Restart=on-failure +RestartSec=2 +TimeoutStopSec=45 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 00000000..41c575d7 --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,19 @@ +# V2 acceptance audit — 0.2.0 + +The delivered target is an independent Arcadia-inspired Operational Analysis and System Analysis prototype. The normative [language and editing contract](language.rst) defines its exact profile; [scope.md](scope.md) states its boundaries. This audit does not imply full Capella or SysML conformance. + +| Requirement | Acceptance evidence | +|---|---| +| Rust/Iced Windows and Linux | Optimized native desktop/server builds, formatting and strict Clippy pass. Current 93 tests covered on both platforms (one final Windows suite; Linux full baseline plus focused desktop refresh). Both final staged native executables launched and inspected. | +| OA and SA semantics | 27 typed element kinds, 15 relation kinds, System/external actors, decomposition/leaf allocation/roles, capabilities/missions, sequences, data/interfaces/ports, states/modes and constraints. Core semantic/history/identity/migration tests and actual graphical workflows pass. | +| Graphical/text equivalence | One typed model/validator; persistent records and fields available in properties and canonical source. Exact strings, aliases, UUID/reference maintenance and undo verified. Native shared-perspective, placement, identity and artifact controls exercised. | +| First-class text | Syntax highlighting/live lint; invalid drafts retained; explicit Apply and separate text/model histories. Lead/reviewer operated located errors, rejected Apply, Revert and declaration navigation. | +| Nested views | Shared decomposition/allocation projection feeds canvas, Fit and SVG. Final native inspection confirms readable parent/child headers at 150%; drag/relocation and independent architecture placement behavior verified. | +| Behavioral views | Repeated occurrences, lifelines/messages/actions and explicit fragments/branches; states/regions/modes with selectable numbered transition routes/details. Native graphical creation, selection and save/reopen exercised. | +| Bidirectional transitions | Create/link/reviewed update, explicit scope and boundary choices, current/proposed conflicts, Keep/Take/merged values, stable baselines and atomic undo. Native forward/reverse creation, manual link, merge typing, retarget invalidation and reverse reuse verified. | +| Artifacts and REST | Arbitrary URI/revision/fragment/model-owner metadata; conditional transactions/query/source/transition APIs. Actual HTTP/storage tests, process/restart/auth checks and source→REST→desktop transfer pass. | +| File safety | Validated atomic saves, exact-byte external conflict detection and dirty/draft guards. Native save refusal preserved externally changed bytes; saved Windows fixtures reopened successfully in Linux. | +| Independent review | Requirements, correctness, UI/runtime and maintainability reviews completed. Valid material findings fixed and reverified; no unresolved blocking/high-impact finding. | +| Reproducibility | README build/run/test instructions, locked dependencies and CI matrix; warning-free rendered reST; equal V2 examples; Windows/Linux bundles with recorded hashes and contents. | + +Detailed observed evidence is in [validation.md](validation.md), [current state](../harness/STATE.md), and the final review/native/Linux/package reports under harness. Remote CI, actual Rust 1.90, Docker/remote installation, broad GPU coverage, accessibility certification and production-scale performance remain untested; those limits are not presented as completed checks. diff --git a/docs/api.html b/docs/api.html new file mode 100644 index 00000000..6b903430 --- /dev/null +++ b/docs/api.html @@ -0,0 +1,986 @@ + + + + + + +Arcadia Studio model REST API + + + + +
+

Arcadia Studio model REST API

+ +

This is the HTTP contract for oa-server. The normative engineering model +and source language belong to language.rst. openapi.json describes the +JSON records and closed command vocabulary. The server uses the same Rust +Project, Command, parser/printer and validation implementation as the +desktop.

+
+

Identity, revision and writes

+

All model routes begin with /api/v1. A hosted project's immutable service +resource_id is different from its editable model.id. Each stored file +contains {resource_id, revision, model}. The revision starts at 1, increments +once per changed accepted transaction, and does not change for a no-op.

+

Existing-project writes require the exact quoted ETag returned by a project +read, for example:

+
If-Match: "p-6f7162cc-094c-4f30-9b70-3f6f355f0c7f-r1"
+

Missing preconditions return 428; stale preconditions return 412. Weak ETags, +wildcards and lists are not accepted on writes. GET of the envelope, model and +source supports If-None-Match and returns an empty 304 when unchanged. +Use ETags as opaque strings; revisions are unsigned integer counters in JSON.

+

Every accepted changed write is persisted before the response. A worker captures +a revision-qualified snapshot, then parses and validates its candidate outside +the catalog lock. Commit rechecks the exact ETag and atomically installs the +envelope under the lock. Another accepted edit during preparation therefore +returns 412 even when the original snapshot passed its initial revision check. +Malformed or rejected inputs preserve the accepted model. Incomplete-model +warnings permit work and are returned in issues.

+

There is no retained revision history, idempotency receipt, tombstone or +server undo. DELETE permanently removes the hosted envelope. Source/JSON +exports contain the current model only. They preserve the model's persistent +aliases, diagrams, artifact references and OA/SA trace data. Artifact revision +strings refer to external artifacts, not past server snapshots.

+

A lost connection or 504 can occur after an admitted write has begun. That +write may still complete. Read current state before choosing a new action. +Do not automatically retry POST create/import or regenerate random IDs and +assume the original request failed.

+
+
+

Routes

+

Health and schema routes are public. All model routes require the configured +bearer token, if any. Non-loopback binding requires a configured token.

+ + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Endpoints

Method

Path

Behavior

GET

/health, /health/live, /health/ready

Responding process with successfully opened store.

GET

/openapi.json, /api/v1/openapi.json

OpenAPI 3.1 contract.

POST

/api/v1/projects

Create from {"name":"..."}; 201 + Location/ETag.

GET

/api/v1/projects

Summaries; limit, after, name substring q.

GET

/api/v1/projects/{id}

Complete current stored envelope.

PATCH

/api/v1/projects/{id}

Conditional rename using {"name":"..."}.

DELETE

/api/v1/projects/{id}

Conditional permanent removal; 204.

POST

/api/v1/projects/import

Raw model JSON or Arcadia source; new service ID.

GET / PUT

/api/v1/projects/{id}/model

Export or conditionally replace native model JSON.

GET / PUT

/api/v1/projects/{id}/source

Export or conditionally replace canonical UTF-8 source.

GET

/api/v1/projects/{id}/elements

Filter by kind, layer, alias, name q.

GET

/api/v1/projects/{id}/relations

Filter by kind, source, target, incident_to.

GET

/api/v1/projects/{id}/artifact-links

Artifact links; optional owner object filter.

GET

/api/v1/projects/{id}/traces

OA/SA traces; optional endpoint object filter.

POST

/api/v1/projects/{id}/query

Bounded directed neighborhood, no mutation.

GET / POST

/api/v1/projects/{id}/validation

Validate current model or a submitted candidate.

POST

/api/v1/projects/{id}/transactions

Conditional typed command batch, optional dry run.

POST

/api/v1/projects/{id}/transitions/preview

Preview a core transition against If-Match; return plan/base_revision.

POST

/api/v1/projects/{id}/transitions/apply

Conditionally apply that client-held plan and base_revision.

+

Content-Type: application/json is required for JSON requests. +text/plain; charset=utf-8 is used for Arcadia source. The service does not +guess formats from a filename. Unknown DTO fields, invalid UUIDs/enums, duplicate +model map keys and unsupported schemas are rejected. Maximum body size is +8 MiB. Compressed or encoded request bodies and browser Origin requests are +not supported.

+

Model admission permits at most 5,000 elements plus relations, 20,000 total +persistent records (including the project, views and placements), and 50,000 +ordered step references. Source declaration counts and decoded JSON record +counts are checked before structural validation. Legacy import counts the +occurrences and placements migration will generate before validating or +migrating that model. Exceeding these budgets returns 422 model_limit; +candidate validation reports the same diagnostic in its 200 response. +Parsing, validation and query processing use snapshot workers, so this work +does not hold the catalog lock or serialize unrelated model preparation.

+

Import accepts schema-1 JSON through the shared explicit migration adapter, +as well as schema-2 native JSON. Source import uses the shared language parser +and its supported migrations. Export and native JSON replacement use schema 2. +Only documented query parameters are accepted. In particular, appending +?revision=... to model/source export is an error, not historical retrieval.

+
+
+

Queries and pagination

+

List filters combine using AND. Enum spelling is case-sensitive and comes +from the shared schema: examples include kind=Activity and layer=Oa. +Aliases match exactly; q is a Unicode-lowercase substring match on the +display name. Empty or invalid enum values are request errors.

+

Lists are ordered by UUID. limit defaults to 100 and accepts 1 through +1000. after is an exclusive last UUID. next_after is null at the end. +Project-scoped lists return a revision. Include that revision on following +pages to detect a changed model; 409 snapshot_changed means restart the +query. Supplying a past revision does not retrieve history. The project catalog +is a live list and does not have a multi-page snapshot guarantee.

+

A neighborhood request is:

+
{
+  "roots": ["1b37b9d7-9a95-4a51-8707-010878a82419"],
+  "direction": "both",
+  "relation_kinds": ["Allocation", "Interaction"],
+  "max_depth": 2,
+  "max_objects": 200,
+  "revision": 3
+}
+

Roots may identify elements or relations. The service traverses relation +source/target adjacency in the selected direction (incoming/outgoing/both), +preserves distinct parallel relations, and returns elements, relations, +revision and truncated. Empty kind filters permit all relation kinds. +Every returned relation includes its endpoint closure, including the endpoints +of a referenced relation. Closure records count against the object bound but +do not add traversal depth. Model ownership, process occurrence order and +non-endpoint references remain available in the returned records and complete +model; they are not silently treated as additional graph edges.

+

Use 1–100 roots, depth 0–4, and 1–1000 total returned objects. The cap must fit +the roots and their endpoint closure. A root closure that cannot fit is an +error; further omitted edges set truncated: true. A missing root is 422. +Deterministic UUID iteration makes repeated queries of one snapshot stable.

+
+
+

Transactions

+

The commands array contains the shared externally tagged Serde command +representation. It is not RFC 6902 JSON Patch. For example:

+
{
+  "commands": [
+    {"Rename": ["1b37b9d7-9a95-4a51-8707-010878a82419", "Assess assistance"]},
+    {"Describe": ["1b37b9d7-9a95-4a51-8707-010878a82419", "Review the request."]}
+  ],
+  "dry_run": true
+}
+

One-field variants use {"Delete":"uuid"}; tuple variants use arrays; +named-field variants use objects. For a new typed OA activity:

+
{
+  "CreateElement": {
+    "id": "1b37b9d7-9a95-4a51-8707-010878a82419",
+    "alias": "assess",
+    "name": "Assess assistance",
+    "description": "Review the request.",
+    "parent": null,
+    "body": {"Oa": "Activity"}
+  }
+}
+

CreateArtifact/EditArtifact and CreateTrace/EditTrace use the same +transaction boundary as engineering edits. Artifact URI, revision and fragment +strings are opaque references. No URI is fetched or verified by the service. +Model references and alias uniqueness are checked by the core.

+

At most 200 commands are allowed, counting nested Batch wrappers. Explicit +creation identities allow later commands in the batch to reference earlier +creations. The shared execute_batch applies all operations to one candidate +and validates the final state. A rejected operation or final structural error +rejects the whole request. Reference rewrites/deletion cleanup follow the same +core logic as desktop editing.

+

Dry run still requires If-Match. Its response includes revision, +dry_run, changed, issues and the proposed model. It neither writes +nor reserves IDs. A committed response has the same fields except the model is +omitted. Commit must check the revision again. A no-op is successful with +changed: false and the original ETag.

+
+
+

Contextual process and chain topology

+

Operational processes and system functional chains can combine work +contributions, exchange contributions, explicit controls and references to +reusable sequences. Each contribution is an ordinary Occurrence element +owned directly by the process/chain, with its stable ID in that owner's +steps list. OccurrenceBody includes, for example:

+
{"Action":{"action":"<work UUID>","lifeline":null}}
+{"Control":{"kind":"Choice"}}
+{"SequenceReference":{"sequence":"<process or chain UUID>"}}
+

ControlKind is Choice, Merge, Fork, Join or Loop. +RelationBody.SequenceLink connects contribution IDs in one owner and +contains source, target and a string guard. Conditions belong to +choice/loop paths; an explicit else guard can describe the other path. +The readable order of the steps list remains persistent, while links +express the explicit alternative, parallel and loop topology. The core checks +owner context, work/exchange endpoint agreement and recursive sequence reuse; +engineering diagnostics identify incomplete branch cardinality or paths. +Old exchange-only sequences without explicit links retain their linear meaning.

+

These records use the same create/edit transaction commands and source/model +replacement routes as other engineering records. For example, +GET /api/v1/projects/{id}/relations?kind=SequenceLink queries the links. +The source representation uses sequence @alias references. The service +does not execute conditions or simulate a process.

+
+
+

Typed scenarios and message values

+

Scenario bodies store an explicit viewpoint. Occurrence Exchange bodies +store message_kind, optional correlated-call reply_to and contextual +values. Message kinds are Asynchronous, Call, Reply, Create, Delete, or +{"Timer":{"expression":"after 5 s"}}. Each value record contains a +carried DataField UUID in field and its textual expression; expressions +do not change the shared exchange item's type or evaluate server-side.

+

ScenarioUse contains a referenced scenario and one-to-one lifeline +bindings records with referenced and local UUIDs. The core validates +viewpoint/layer compatibility, call/reply order and reversal, lifetime order, +carried fields, binding identity and recursive reuse through the same candidate +boundary used by the desktop. The normative details are in language.rst.

+

Existing schema-2 files may omit the new fields: missing viewpoint maps to +MixedLegacy and missing message fields to Asynchronous, null and []. Source +exports make those compatibility defaults explicit. This preserves original V2 +models while the release schema is being integrated; it does not silently +reinterpret old mixed lifelines as a strict new viewpoint.

+
+
+

Validation

+

GET returns current-model diagnostics. POST accepts either:

+
{"format":"json", "model": {"...":"complete native Project record"}}
+

or:

+
{"format":"arcadia", "source":"complete source document"}
+

A well-formed validation request returns 200 with valid and issues even +if its candidate is invalid. A malformed request envelope or JSON model record +is a 400. Source diagnostics include one-based line and column according to the +shared language parser. Candidate validation does not replace current state.

+
+
+

Typed state machines and events

+

OA and SA Event records use the same SharedBody::Event and EventBody +contract as source and graphical editing. Signal carries same-layer item +references; Time specifies After/At and an expression; Change specifies a +condition expression. State vertices include Choice, Junction, Fork, Join, +ShallowHistory, DeepHistory, EntryPoint, ExitPoint and Terminate, as well as +State, Mode, Initial and Final. Query these through the ordinary kind +filters. No timer or executable state-machine service is implied.

+

StateTransition keeps trigger, guard and effect prose and adds +transition_kind (External/Local/Internal), triggers (Event or work UUIDs) +and effects (work UUIDs). The reference lists use UUIDs in JSON and aliases +in source. Missing new properties in staged schema-2 input hydrate as +External/[]/[] without changing prior text. These defaults are compatibility +behavior pending the distinct release schema, not a versionless expansion of +every future input contract.

+

Use one ordinary transaction to create an event and edit its referring +transition. Reidentification updates references, and deletion prunes signal +items and effect references. Removing the last typed trigger with no prior +trigger prose leaves a descriptive replacement reminder; it does not silently +introduce a completion transition. Invalid typed references reject the whole +candidate and preserve revision/ETag. All writes retain the same conditional +commit and disk persistence contract as other semantic records.

+
+
+

Cross-layer transitions

+

Preview accepts the shared TransitionRequest record, including direction +(OaToSa or SaToOa), sources, dependency scope, mappings, selected fields, +conflict resolutions and optional destination diagram. All fields are explicit +in OpenAPI. At most 100 sources, 200 mappings and 400 field resolutions are +accepted. A conflict-free plan must fit the same 200-command transaction limit +and model-capacity limits.

+

The preview response is {"base_revision":1,"plan":{...}}. It contains +planned changes, conflicts, warnings and exact commands/IDs. It does not write +the model or persist a server-side plan. Apply accepts that entire response and +requires the original ETag in If-Match. It checks both base_revision and the +core's snapshot fingerprint before executing the shared transition validator. +Stale revisions fail with 412 and unresolved conflicts with 422. Successful +apply increments the revision once, preserving the planned identities.

+

A supplied plan is a client-owned command proposal. It is not cryptographically +authenticated as an untouched server preview; the same authenticated client +can submit ordinary typed commands. The core fingerprint is a change detector, +not a security digest. There is no plan token, expiration store or receipt.

+
+
+

Error contract

+

All errors have Content-Type: application/problem+json. Example:

+
{
+  "type":"urn:arcadia:problem:revision_conflict",
+  "title":"Precondition Failed",
+  "status":412,
+  "code":"revision_conflict",
+  "detail":"Project changed. Reload and review the current model before retrying.",
+  "current_revision":3
+}
+

issues contain severity, code and message, and may include field, +object_id, line and column. Use +code for client decisions, not the English message. X-Request-ID is a +response correlation UUID. This is not an idempotency key or receipt address.

+
    +
  • 400: malformed JSON/envelope/query/UUID or unsupported ETag form.

  • +
  • 401: missing or incorrect configured bearer token; includes WWW-Authenticate.

  • +
  • 403: browser Origin is unsupported, or unauthenticated Host is not loopback.

  • +
  • 404: missing project or route.

  • +
  • 405: unsupported HTTP method.

  • +
  • 409: requested query revision changed or revision counter exhausted.

  • +
  • 412: stale write precondition.

  • +
  • 413: body or serialized model exceeds size limit.

  • +
  • 415: unsupported Content-Type or Content-Encoding.

  • +
  • 422: rejected source/model/command, model count limit, or missing graph root.

  • +
  • 428: missing If-Match.

  • +
  • 503: work slots full or filesystem/storage unavailable; Retry-After is present.

  • +
  • 504: request deadline; an admitted write outcome may be unknown.

  • +
  • 507: fixed store cap of 128 models or 256 MiB serialized envelopes reached.

  • +
  • 500: unexpected server/worker failure.

  • +
+

Deployment and persistence limitations are documented in server.md.

+
+
+ + diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..84a44433 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,380 @@ +Arcadia Studio model REST API +============================= + +This is the HTTP contract for ``oa-server``. The normative engineering model +and source language belong to ``language.rst``. ``openapi.json`` describes the +JSON records and closed command vocabulary. The server uses the same Rust +``Project``, ``Command``, parser/printer and validation implementation as the +desktop. + +Identity, revision and writes +----------------------------- + +All model routes begin with ``/api/v1``. A hosted project's immutable service +``resource_id`` is different from its editable ``model.id``. Each stored file +contains ``{resource_id, revision, model}``. The revision starts at 1, increments +once per changed accepted transaction, and does not change for a no-op. + +Existing-project writes require the exact quoted ETag returned by a project +read, for example:: + + If-Match: "p-6f7162cc-094c-4f30-9b70-3f6f355f0c7f-r1" + +Missing preconditions return 428; stale preconditions return 412. Weak ETags, +wildcards and lists are not accepted on writes. GET of the envelope, model and +source supports ``If-None-Match`` and returns an empty 304 when unchanged. +Use ETags as opaque strings; revisions are unsigned integer counters in JSON. + +Every accepted changed write is persisted before the response. A worker captures +a revision-qualified snapshot, then parses and validates its candidate outside +the catalog lock. Commit rechecks the exact ETag and atomically installs the +envelope under the lock. Another accepted edit during preparation therefore +returns 412 even when the original snapshot passed its initial revision check. +Malformed or rejected inputs preserve the accepted model. Incomplete-model +warnings permit work and are returned in ``issues``. + +There is no retained revision history, idempotency receipt, tombstone or +server undo. DELETE permanently removes the hosted envelope. Source/JSON +exports contain the current model only. They preserve the model's persistent +aliases, diagrams, artifact references and OA/SA trace data. Artifact revision +strings refer to external artifacts, not past server snapshots. + +A lost connection or 504 can occur after an admitted write has begun. That +write may still complete. Read current state before choosing a new action. +Do not automatically retry POST create/import or regenerate random IDs and +assume the original request failed. + +Routes +------ + +Health and schema routes are public. All model routes require the configured +bearer token, if any. Non-loopback binding requires a configured token. + +.. list-table:: Endpoints + :header-rows: 1 + :widths: 12 41 47 + + * - Method + - Path + - Behavior + * - GET + - ``/health``, ``/health/live``, ``/health/ready`` + - Responding process with successfully opened store. + * - GET + - ``/openapi.json``, ``/api/v1/openapi.json`` + - OpenAPI 3.1 contract. + * - POST + - ``/api/v1/projects`` + - Create from ``{"name":"..."}``; 201 + Location/ETag. + * - GET + - ``/api/v1/projects`` + - Summaries; ``limit``, ``after``, name substring ``q``. + * - GET + - ``/api/v1/projects/{id}`` + - Complete current stored envelope. + * - PATCH + - ``/api/v1/projects/{id}`` + - Conditional rename using ``{"name":"..."}``. + * - DELETE + - ``/api/v1/projects/{id}`` + - Conditional permanent removal; 204. + * - POST + - ``/api/v1/projects/import`` + - Raw model JSON or Arcadia source; new service ID. + * - GET / PUT + - ``/api/v1/projects/{id}/model`` + - Export or conditionally replace native model JSON. + * - GET / PUT + - ``/api/v1/projects/{id}/source`` + - Export or conditionally replace canonical UTF-8 source. + * - GET + - ``/api/v1/projects/{id}/elements`` + - Filter by ``kind``, ``layer``, ``alias``, name ``q``. + * - GET + - ``/api/v1/projects/{id}/relations`` + - Filter by ``kind``, ``source``, ``target``, ``incident_to``. + * - GET + - ``/api/v1/projects/{id}/artifact-links`` + - Artifact links; optional owner ``object`` filter. + * - GET + - ``/api/v1/projects/{id}/traces`` + - OA/SA traces; optional endpoint ``object`` filter. + * - POST + - ``/api/v1/projects/{id}/query`` + - Bounded directed neighborhood, no mutation. + * - GET / POST + - ``/api/v1/projects/{id}/validation`` + - Validate current model or a submitted candidate. + * - POST + - ``/api/v1/projects/{id}/transactions`` + - Conditional typed command batch, optional dry run. + * - POST + - ``/api/v1/projects/{id}/transitions/preview`` + - Preview a core transition against If-Match; return plan/base_revision. + * - POST + - ``/api/v1/projects/{id}/transitions/apply`` + - Conditionally apply that client-held plan and base_revision. + +``Content-Type: application/json`` is required for JSON requests. +``text/plain; charset=utf-8`` is used for Arcadia source. The service does not +guess formats from a filename. Unknown DTO fields, invalid UUIDs/enums, duplicate +model map keys and unsupported schemas are rejected. Maximum body size is +8 MiB. Compressed or encoded request bodies and browser Origin requests are +not supported. + +Model admission permits at most 5,000 elements plus relations, 20,000 total +persistent records (including the project, views and placements), and 50,000 +ordered step references. Source declaration counts and decoded JSON record +counts are checked before structural validation. Legacy import counts the +occurrences and placements migration will generate before validating or +migrating that model. Exceeding these budgets returns ``422 model_limit``; +candidate validation reports the same diagnostic in its 200 response. +Parsing, validation and query processing use snapshot workers, so this work +does not hold the catalog lock or serialize unrelated model preparation. + +Import accepts schema-1 JSON through the shared explicit migration adapter, +as well as schema-2 native JSON. Source import uses the shared language parser +and its supported migrations. Export and native JSON replacement use schema 2. +Only documented query parameters are accepted. In particular, appending +``?revision=...`` to model/source export is an error, not historical retrieval. + +Queries and pagination +---------------------- + +List filters combine using AND. Enum spelling is case-sensitive and comes +from the shared schema: examples include ``kind=Activity`` and ``layer=Oa``. +Aliases match exactly; ``q`` is a Unicode-lowercase substring match on the +display name. Empty or invalid enum values are request errors. + +Lists are ordered by UUID. ``limit`` defaults to 100 and accepts 1 through +1000. ``after`` is an exclusive last UUID. ``next_after`` is null at the end. +Project-scoped lists return a revision. Include that revision on following +pages to detect a changed model; 409 ``snapshot_changed`` means restart the +query. Supplying a past revision does not retrieve history. The project catalog +is a live list and does not have a multi-page snapshot guarantee. + +A neighborhood request is:: + + { + "roots": ["1b37b9d7-9a95-4a51-8707-010878a82419"], + "direction": "both", + "relation_kinds": ["Allocation", "Interaction"], + "max_depth": 2, + "max_objects": 200, + "revision": 3 + } + +Roots may identify elements or relations. The service traverses relation +source/target adjacency in the selected direction (incoming/outgoing/both), +preserves distinct parallel relations, and returns ``elements``, ``relations``, +``revision`` and ``truncated``. Empty kind filters permit all relation kinds. +Every returned relation includes its endpoint closure, including the endpoints +of a referenced relation. Closure records count against the object bound but +do not add traversal depth. Model ownership, process occurrence order and +non-endpoint references remain available in the returned records and complete +model; they are not silently treated as additional graph edges. + +Use 1–100 roots, depth 0–4, and 1–1000 total returned objects. The cap must fit +the roots and their endpoint closure. A root closure that cannot fit is an +error; further omitted edges set ``truncated: true``. A missing root is 422. +Deterministic UUID iteration makes repeated queries of one snapshot stable. + +Transactions +------------ + +The ``commands`` array contains the shared externally tagged Serde command +representation. It is not RFC 6902 JSON Patch. For example:: + + { + "commands": [ + {"Rename": ["1b37b9d7-9a95-4a51-8707-010878a82419", "Assess assistance"]}, + {"Describe": ["1b37b9d7-9a95-4a51-8707-010878a82419", "Review the request."]} + ], + "dry_run": true + } + +One-field variants use ``{"Delete":"uuid"}``; tuple variants use arrays; +named-field variants use objects. For a new typed OA activity:: + + { + "CreateElement": { + "id": "1b37b9d7-9a95-4a51-8707-010878a82419", + "alias": "assess", + "name": "Assess assistance", + "description": "Review the request.", + "parent": null, + "body": {"Oa": "Activity"} + } + } + +``CreateArtifact``/``EditArtifact`` and ``CreateTrace``/``EditTrace`` use the same +transaction boundary as engineering edits. Artifact URI, revision and fragment +strings are opaque references. No URI is fetched or verified by the service. +Model references and alias uniqueness are checked by the core. + +At most 200 commands are allowed, counting nested Batch wrappers. Explicit +creation identities allow later commands in the batch to reference earlier +creations. The shared ``execute_batch`` applies all operations to one candidate +and validates the final state. A rejected operation or final structural error +rejects the whole request. Reference rewrites/deletion cleanup follow the same +core logic as desktop editing. + +Dry run still requires ``If-Match``. Its response includes ``revision``, +``dry_run``, ``changed``, ``issues`` and the proposed ``model``. It neither writes +nor reserves IDs. A committed response has the same fields except the model is +omitted. Commit must check the revision again. A no-op is successful with +``changed: false`` and the original ETag. + +Contextual process and chain topology +------------------------------------- + +Operational processes and system functional chains can combine work +contributions, exchange contributions, explicit controls and references to +reusable sequences. Each contribution is an ordinary ``Occurrence`` element +owned directly by the process/chain, with its stable ID in that owner's +``steps`` list. ``OccurrenceBody`` includes, for example:: + + {"Action":{"action":"","lifeline":null}} + {"Control":{"kind":"Choice"}} + {"SequenceReference":{"sequence":""}} + +``ControlKind`` is ``Choice``, ``Merge``, ``Fork``, ``Join`` or ``Loop``. +``RelationBody.SequenceLink`` connects contribution IDs in one owner and +contains ``source``, ``target`` and a string ``guard``. Conditions belong to +choice/loop paths; an explicit ``else`` guard can describe the other path. +The readable order of the ``steps`` list remains persistent, while links +express the explicit alternative, parallel and loop topology. The core checks +owner context, work/exchange endpoint agreement and recursive sequence reuse; +engineering diagnostics identify incomplete branch cardinality or paths. +Old exchange-only sequences without explicit links retain their linear meaning. + +These records use the same create/edit transaction commands and source/model +replacement routes as other engineering records. For example, +``GET /api/v1/projects/{id}/relations?kind=SequenceLink`` queries the links. +The source representation uses ``sequence @alias`` references. The service +does not execute conditions or simulate a process. + +Typed scenarios and message values +---------------------------------- + +Scenario bodies store an explicit ``viewpoint``. Occurrence Exchange bodies +store ``message_kind``, optional correlated-call ``reply_to`` and contextual +``values``. Message kinds are Asynchronous, Call, Reply, Create, Delete, or +``{"Timer":{"expression":"after 5 s"}}``. Each value record contains a +carried DataField UUID in ``field`` and its textual ``expression``; expressions +do not change the shared exchange item's type or evaluate server-side. + +``ScenarioUse`` contains a referenced ``scenario`` and one-to-one lifeline +``bindings`` records with ``referenced`` and ``local`` UUIDs. The core validates +viewpoint/layer compatibility, call/reply order and reversal, lifetime order, +carried fields, binding identity and recursive reuse through the same candidate +boundary used by the desktop. The normative details are in ``language.rst``. + +Existing schema-2 files may omit the new fields: missing viewpoint maps to +MixedLegacy and missing message fields to Asynchronous, null and []. Source +exports make those compatibility defaults explicit. This preserves original V2 +models while the release schema is being integrated; it does not silently +reinterpret old mixed lifelines as a strict new viewpoint. + +Validation +---------- + +GET returns current-model diagnostics. POST accepts either:: + + {"format":"json", "model": {"...":"complete native Project record"}} + +or:: + + {"format":"arcadia", "source":"complete source document"} + +A well-formed validation request returns 200 with ``valid`` and ``issues`` even +if its candidate is invalid. A malformed request envelope or JSON model record +is a 400. Source diagnostics include one-based line and column according to the +shared language parser. Candidate validation does not replace current state. + +Typed state machines and events +------------------------------- + +OA and SA Event records use the same ``SharedBody::Event`` and ``EventBody`` +contract as source and graphical editing. Signal carries same-layer item +references; Time specifies After/At and an expression; Change specifies a +condition expression. State vertices include Choice, Junction, Fork, Join, +ShallowHistory, DeepHistory, EntryPoint, ExitPoint and Terminate, as well as +State, Mode, Initial and Final. Query these through the ordinary ``kind`` +filters. No timer or executable state-machine service is implied. + +StateTransition keeps ``trigger``, ``guard`` and ``effect`` prose and adds +``transition_kind`` (External/Local/Internal), ``triggers`` (Event or work UUIDs) +and ``effects`` (work UUIDs). The reference lists use UUIDs in JSON and aliases +in source. Missing new properties in staged schema-2 input hydrate as +External/[]/[] without changing prior text. These defaults are compatibility +behavior pending the distinct release schema, not a versionless expansion of +every future input contract. + +Use one ordinary transaction to create an event and edit its referring +transition. Reidentification updates references, and deletion prunes signal +items and effect references. Removing the last typed trigger with no prior +trigger prose leaves a descriptive replacement reminder; it does not silently +introduce a completion transition. Invalid typed references reject the whole +candidate and preserve revision/ETag. All writes retain the same conditional +commit and disk persistence contract as other semantic records. + +Cross-layer transitions +----------------------- + +Preview accepts the shared ``TransitionRequest`` record, including direction +(``OaToSa`` or ``SaToOa``), sources, dependency scope, mappings, selected fields, +conflict resolutions and optional destination diagram. All fields are explicit +in OpenAPI. At most 100 sources, 200 mappings and 400 field resolutions are +accepted. A conflict-free plan must fit the same 200-command transaction limit +and model-capacity limits. + +The preview response is ``{"base_revision":1,"plan":{...}}``. It contains +planned changes, conflicts, warnings and exact commands/IDs. It does not write +the model or persist a server-side plan. Apply accepts that entire response and +requires the original ETag in ``If-Match``. It checks both base_revision and the +core's snapshot fingerprint before executing the shared transition validator. +Stale revisions fail with 412 and unresolved conflicts with 422. Successful +apply increments the revision once, preserving the planned identities. + +A supplied plan is a client-owned command proposal. It is not cryptographically +authenticated as an untouched server preview; the same authenticated client +can submit ordinary typed commands. The core fingerprint is a change detector, +not a security digest. There is no plan token, expiration store or receipt. + +Error contract +-------------- + +All errors have ``Content-Type: application/problem+json``. Example:: + + { + "type":"urn:arcadia:problem:revision_conflict", + "title":"Precondition Failed", + "status":412, + "code":"revision_conflict", + "detail":"Project changed. Reload and review the current model before retrying.", + "current_revision":3 + } + +``issues`` contain severity, code and message, and may include field, +object_id, line and column. Use +``code`` for client decisions, not the English message. ``X-Request-ID`` is a +response correlation UUID. This is not an idempotency key or receipt address. + +* 400: malformed JSON/envelope/query/UUID or unsupported ETag form. +* 401: missing or incorrect configured bearer token; includes WWW-Authenticate. +* 403: browser Origin is unsupported, or unauthenticated Host is not loopback. +* 404: missing project or route. +* 405: unsupported HTTP method. +* 409: requested query revision changed or revision counter exhausted. +* 412: stale write precondition. +* 413: body or serialized model exceeds size limit. +* 415: unsupported Content-Type or Content-Encoding. +* 422: rejected source/model/command, model count limit, or missing graph root. +* 428: missing If-Match. +* 503: work slots full or filesystem/storage unavailable; Retry-After is present. +* 504: request deadline; an admitted write outcome may be unknown. +* 507: fixed store cap of 128 models or 256 MiB serialized envelopes reached. +* 500: unexpected server/worker failure. + +Deployment and persistence limitations are documented in ``server.md``. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..a40e20a8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,68 @@ +# Architecture and extension guide + +The normative contract is [language.rst](language.rst); [scope.md](scope.md) explains deliberate differences from the complete Arcadia/Capella ecosystem. V2 uses one typed model and one validated mutation boundary across the desktop, text editor and REST service. + +## Crate boundaries + +| Crate | Responsibility | Does not depend on | +|---|---|---| +| `oa-core` | Model, structural/completeness rules, commands/history, reference maintenance, diagram projection, OA/SA transitions, text and file persistence | Iced, renderer, HTTP or native UI | +| `oa-desktop` | Iced events/state, property and source drafts, graphical views, file interactions | Server availability or an account | +| `oa-server` | HTTP contract, authentication/admission, resource revisions and atomic current-model storage | Iced or a desktop session | + +The server reuses the same core library as the desktop. A new automation endpoint must produce a core command/candidate rather than implement a second version of domain rules. Desktop and server are separate storage clients: there is currently no desktop HTTP adapter. Canonical source/JSON import/export is the explicit integration path. + +## Model, identity and references + +`model.rs` defines Project maps for typed Elements, Relations, Diagrams, Traces and ArtifactLinks. Each diagram owns NodePlacements. All records share one UUID/alias namespace. UUIDs are durable reference identity; stored aliases are stable text labels; display names can change independently or repeat. + +Element kind and layer derive from closed OA/SA bodies. Shared typed bodies represent data, interfaces, scenarios, state machines and constraints; payload variants cannot disagree with an independent kind tag. Relations are separate typed records, including relation-to-relation exchange allocation. Sequence occurrences have their own element identity; repeating an exchange uses distinct occurrences. Semantic parent, performer allocation and diagram placement are independent. + +`references.rs` visits stored references for reidentification, deletion cleanup and transition mapping. New reference fields must join these visitors and their regression cases. Reidentification updates map keys, owned records, endpoints, occurrence lists, placement targets, traces and artifact owners atomically. Deleting a sequence does not delete its referenced semantic exchanges. Deleting an OA/SA element does not own or delete its cross-layer counterpart. + +## Validated transactions and history + +`session.rs` clones a candidate, applies a command or batch, validates the complete result, and adopts it only when valid and changed. `Replace(Project)` supports full source replacement. Record edits apply drafted properties together; typed operations handle reparenting, placement relocation, grouped movement, ordering and layout. A rejected batch leaves accepted state and history intact. Public builders construct candidates and fixtures; they are not a substitute for final validation. + +`validation.rs` separates structural errors from incomplete-model warnings. Dangling references, wrong kinds/layers, ownership cycles, incompatible ports and invalid uniqueness reject a candidate. Missing allocations, incomplete references and coverage gaps allow continued authoring. Small predicates support parent/relationship choices; they must agree with the committed-model policy. + +The desktop retains up to 100 full-model history snapshots and compares against a saved snapshot to compute dirty state. This favors straightforward correctness for modest models. The cost is proportional to model size; indexed validation, persistent collections or delta history should follow profiling, not speculative infrastructure. + +## Nesting and graphical views + +`projection.rs` derives visible nodes, visual parents and edges from a stored diagram's projection. Decomposition uses semantic ownership; allocation uses direct/effective leaf performers and computed parent summaries. Node positions/sizes/collapse belong to each view, so multiple diagrams can represent the same semantic element independently. + +`diagram.rs` uses the shared projection for drawing, hit testing, Fit, grouped movement and SVG. A placement move never implicitly reparents or reallocates an element. `RelocateNode` moves a placement between diagrams while retaining its UUID, alias, geometry, collapse and references; validation rejects an incompatible destination or duplicate placement. `behavior.rs` presents sequence/scenario and state-machine content from the same typed records. UI acceptance is recorded separately from these implementation boundaries. + +## Counterpart planning + +`transitions.rs` builds a candidate plan from source IDs, direction, dependency scope, explicit mappings and reviewed field decisions. Participant/system-boundary choices remain explicit. Shared content and ownership references map when creating counterparts; ambiguous or incompatible mappings produce conflicts. OA-to-SA traces retain canonical endpoints regardless of which perspective starts the operation. + +Existing counterparts receive reviewed name/description changes, not structural synchronization. Per-trace baselines record those reviewed values, including intentional divergence. Ordinary editing never propagates automatically. Apply rejects a stale project fingerprint or unresolved conflict, then uses the same batch validator. Desktop application creates one history entry; the service additionally checks resource revision/ETag before committing. The fingerprint is a deterministic change token, not a security signature. + +Keep domain mapping choices in the core or derive UI choices from its queries. Independent desktop match tables can drift from accepted semantics; the V2 review found and corrected this kind of mismatch for fragment construction and capability/mission transition options. + +## Text and draft state + +`language.rs` provides a lexer/parser, deterministic formatter, tolerant source analysis and explicit V1 adapters. It collects declaration aliases before resolving UUID references, accepts forward references, rejects unknown/duplicate fields and typed-constructor errors, and validates the complete candidate. Canonical `arcadia 2` text preserves every persistent field, alias, UUID and ordered occurrence. Comments/whitespace are lexical trivia and normalize on Apply. V1 migration preserves existing identities and derives new placement/occurrence IDs deterministically. + +`forms.rs` is a closed adapter over typed records. JSON values are temporary form structure; incomplete numbers, references and escaped strings stay in drafts until decoding a typed Command. This is not an extensible runtime schema or an alternate persisted metamodel. Every added field needs a usable control and meaningful authoring coverage; a no-op serialization test alone cannot establish graphical reachability. + +`app.rs` coordinates the Session, selection, draft, source buffer/history, active view/camera, workflow dialogs and file state. `views.rs` renders task navigation and inspectors; `workflow.rs` handles explicit relationship/manual-trace/counterpart choices. `highlight.rs` translates tolerant core tokens to source coloring. Source lint runs after a short debounce and ignores obsolete results. Valid Apply commits once; rejected text stays editable with located diagnostics. Text-buffer history is distinct from committed-model history. + +## File and service persistence + +Core persistence detects JSON by content and otherwise parses Arcadia text. `.arcadia` saves canonical text; other suffixes save strict JSON. A validated snapshot is written to a sibling temporary file, flushed and atomically installed. Normal desktop saves compare the previously read bytes to detect external changes before replacement. This is an optimistic local-file check, not a guarantee against every concurrent writer or power-loss scenario. File tasks run on blocking workers; the desktop preserves drafts and pending New/Open/Close continuations on failure or cancellation. + +The service stores one `{resource_id, revision, model}` envelope per current hosted project. Service identity is immutable and independent of editable model identity. A directory lock enforces one local owner; a store mutex serializes revision checks and writes. Disk replacement precedes in-memory adoption. Authentication, body/admission limits, object/query limits and total store capacity are explicit. No distributed database, retained history, idempotency ledger or automatic artifact fetching is hidden behind this interface. See [server.md](server.md) and [api.rst](api.rst) for deployment, error and concurrency behavior. + +## Extending a supported concept + +1. Define the semantics, field types, references and both editing obligations in `language.rst`; choose migration behavior if the stored schema changes. +2. Update the closed core model, reference visitors, validation and applicable command/transition/projection behavior. +3. Extend parser and formatter together; verify exact round-trip, invalid-input preservation and identity/reference maintenance. +4. Expose graphical creation/editing for each field/variant, including optional values and ordering. Keep draft and modal failure paths intact. +5. Update the REST/OpenAPI contract for new command or data variants; exercise actual HTTP candidate acceptance/rejection and persistence. +6. Run focused domain/interaction tests, strict workspace checks, and native Windows/Linux workflows with visual inspection at supported sizes. Update acceptance evidence only after observing the result. + +No runtime plugin ABI, general extension framework or later-layer infrastructure is present. The documented core and REST boundaries provide practical development seams without requiring speculative abstractions. diff --git a/docs/language.html b/docs/language.html new file mode 100644 index 00000000..713ee486 --- /dev/null +++ b/docs/language.html @@ -0,0 +1,1552 @@ + + + + + + +Arcadia Studio OA / SA Language and Editing Contract + + + + +
+

Arcadia Studio OA / SA Language and Editing Contract

+
+
Version:
+
2
+
Status:
+
Normative current implementation contract; first-release integration
+
Core implementation:
+

crates/oa-core

+
+
Text suffix:
+

.arcadia

+
+
+
+

1. Scope and conformance

+

This specification defines the shared Operational Analysis (OA) and System +Analysis (SA) model, its textual language, graphical editing obligations, +validation, persistence, and explicit migration from version 1. MUST, MUST NOT, +and MAY indicate requirements, prohibitions, and permitted choices.

+

The first-release capability/mission, contextual process/chain, typed scenario +and state/event additions +are temporarily integrated under schema 2. This does not declare the first +release complete or waive its remaining scenario, state, data, transition and +usability requirements. A distinct release schema and explicit V2 migration +will be specified after those interfaces are frozen. This document describes +the current implemented records, rather than inventing compatibility rules for +that future schema.

+

OA describes stakeholders, operational work and needs. SA describes the +selected system boundary, external actors, functions, services and expected +behavior. SA permits at most one System of Interest; its absence is an +incomplete-model warning when other SA elements exist. The SA System MUST NOT +contain internal components. Functional decomposition is independent of that +structural boundary. Logical Architecture, Physical Architecture, deployment, +Capella file interchange, and executable behavioral simulation are outside +this specification. This independent language does not claim complete Arcadia, +Eclipse Capella, or SysML conformance.

+

An implementation conforms only when each supported field and relationship is +reachable in both graphical and textual editing, preserves identity through +save/open and undo, and uses the same structural validation. Merely displaying +a name or storing an opaque payload does not establish graphical support. +Actual verification evidence is separate from this contract.

+
+
+

2. One authoritative model

+

A Project stores schema_version (2), id, alias, name, +description, and maps named elements, relations, diagrams, +artifact_links, and traces. Each map is keyed by the UUID contained in +its record. Duplicate map keys and mismatching keys/identities are errors.

+

All objects share one UUID and alias namespace: Project, Element, Relation, +Diagram, NodePlacement, ArtifactLink, and Trace. UUIDs are durable identity; +aliases are durable, editable source labels. Display-name changes MUST NOT +regenerate existing aliases. Different objects MAY have the same display name.

+

Every Element has id, alias, name, description, parent +(optional element reference), and a typed body. Its kind and OA/SA layer +are derived from its body. A Relation has id, alias, name, +description, and a typed body; it has no independently editable kind tag +that can disagree with its payload.

+

Diagram placement does not determine semantic ownership. NodePlacement +records belong to diagrams and reference semantic elements. Multiple diagrams +can show the same element with independent placement and collapse state. +Within one diagram, each element appears at most once.

+

All stored strings MUST contain only XML 1.0 characters: U+0009, U+000A, +U+000D, U+0020–U+D7FF, U+E000–U+FFFD, or U+10000–U+10FFFF. Strings are not +trimmed or Unicode-normalized. Escaping a forbidden character does not permit +it. Coordinates and sizes are finite IEEE-754 binary32 values with absolute +value at most 1000000. Width and height MUST be positive. Signed zero and +subnormal coordinates are retained.

+
+
+

3. Element declarations and typed fields

+

Each element declaration starts with its layer and kind, for example +oa_activity or sa_function. Every element body accepts +description (default "") and parent (default none). The table +lists all additional fields. ref means @alias; ref? also accepts +none. Lists preserve order. Unknown or duplicate fields are errors.

+ + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Element body field catalog

Declarations

Additional fields

Defaults

oa_entity, oa_actor, oa_role, oa_activity, oa_capability, oa_mission

None

Empty body

sa_system, sa_actor, sa_function, sa_capability, sa_mission

None

Empty body

oa_process, sa_functional_chain

steps: occurrence refs

[]

oa_exchange_item, sa_exchange_item

kind: Information, Material, Energy, Event

Information

oa_data_type, sa_data_type

definition: Boolean, Integer, Real, String, Record, or Enumeration constructor

String

oa_data_field, sa_data_field

type_ref; minimum: u32; maximum: optional u32; unit: string

String; 1; 1; ""

oa_interface, sa_interface

items: exchange-item refs

[]

oa_port, sa_port

direction: In, Out, InOut; interface: ref?

InOut; none

oa_scenario, sa_scenario

viewpoint: ScenarioViewpoint; context: ref?; precondition, postcondition: strings; steps: occurrence refs

MixedLegacy for omitted schema-2 source; none; ""; ""; []

oa_lifeline, sa_lifeline

represented: participant, work, or role ref?

none

oa_occurrence, sa_occurrence

body: occurrence constructor; precondition, postcondition: strings

Exchange with none references, Asynchronous message kind and no values; two empty strings

oa_state_machine, sa_state_machine, oa_region, sa_region

None

Empty body

oa_state, sa_state, oa_mode, sa_mode, oa_initial, sa_initial, oa_final, sa_final

kind: StateKind; entry, exit, do_activity: strings

Kind selected by declaration; three empty strings

oa_choice, oa_junction, oa_fork, oa_join, oa_shallow_history, oa_deep_history, oa_entry_point, oa_exit_point, oa_terminate and corresponding sa_ declarations

Same State body; StateKind is Choice, Junction, Fork, Join, ShallowHistory, DeepHistory, EntryPoint, ExitPoint or Terminate

Kind selected by declaration; three empty strings

oa_event, sa_event

body: Signal, Time or Change constructor

Signal with items []

oa_constraint, sa_constraint

expression: string; constrained: semantic record refs

""; []

+

The explicit state kind field MUST agree with its declaration keyword. +u32 is an integer from 0 through 4294967295. A DataField's maximum MUST +be at least its minimum; none means unbounded. Primitive TypeRefs are +Boolean, Integer, Real, and String. A named TypeRef uses a DataType reference. +An Enumeration contains unique string literals. For example:

+
definition { Enumeration { literals ["Clear", "Fog"]; }; };
+type_ref { Named @weather_classification; };
+

These are typed constructors with named fields, not embedded JSON documents. +Constructor field names and enum spellings are case-sensitive. When replacing +a constructor, all of its fields MUST be supplied; the containing declaration +defaults do not silently fill a different constructor's missing fields. +During schema-2 compatibility, the new Exchange fields message_kind, +reply_to and values are exceptions: omitted values hydrate as +Asynchronous, none and [] respectively. Canonical output writes them explicitly. +This preserves older schema-2 source/JSON without inventing call correlation or +value annotations. It is not a rule for an unspecified future schema.

+ + ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Occurrence body constructors

Constructor

Required fields

Exchange

exchange: exchange relation ref?; sender, receiver: lifeline refs?; message_kind: MessageKind; reply_to: occurrence ref?; values: MessageValue records

Action

action: activity/function ref?; lifeline: ref?

Control

kind: Choice, Merge, Fork, Join, Loop

SequenceReference

sequence: process/functional-chain ref?

ScenarioUse

scenario: scenario ref?; bindings: LifelineBinding records

State

state: state/mode ref?; lifeline: ref?

Fragment

operator: Alternative, Parallel, Loop, Optional; branches: branch occurrence refs

Branch

guard: string; occurrences: occurrence refs

+

For example, body { Action { action @assess; lifeline @operator_line; }; }; +selects an Action occurrence. Processes and functional chains contain Exchange, +Action, Control and SequenceReference occurrences. Process/chain Actions are +contextual work contributions and MUST have lifeline none. A Control or +SequenceReference MUST belong directly to a Process or FunctionalChain, not a +Scenario. Scenarios contain Exchange, Action, State, Fragment, Branch and ScenarioUse +occurrences according to their enclosing ownership rules. Each +listed occurrence belongs to its enclosing sequence or branch through +parent. A Branch belongs to a Fragment; the Fragment's branches list +references the branches it owns.

+

An exchange and an occurrence of that exchange have different identities. +Repeating an exchange requires distinct occurrence records. Repeating one +occurrence identity within an ordered list is invalid. Conditions, guards, +entry/exit/do text, and constraint expressions are descriptive strings; this +version does not parse or execute them as a programming language.

+
+
+

4. Containment, allocation, and relationships

+

Containment MUST be acyclic and stay within one layer. Entities may contain +entities, actors, and roles. Actors are not structural containers. Activities +contain activities; functions contain functions. Capability grouping uses +same-layer capability parents; it does not mean inheritance or inclusion. +Operational and system missions are root semantic records; they do not own +capabilities by containment. MissionCapability expresses that association. +DataFields belong to ExchangeItems or Record DataTypes. Ports belong to +participants or work. Lifelines belong to scenarios. State-machine ownership +uses participant → machine → region → state/mode; a state or mode can contain +a nested region. All other state vertices belong to Regions as well. Events +are root semantic records and do not acquire a machine owner when referenced.

+

DataFields, ports, lifelines, occurrences, Regions and all state vertices +require an owner. An unowned StateMachine can be saved as incomplete +work and produces a warning. The System cannot own internal components. +The present Studio profile does not decompose SA Actors.

+

All relations have a header identity, alias, display name, optional +description (default ""), and required source and target +references. Their kind is the declaration keyword:

+ + +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Relation catalog

Declaration

Endpoint meaning

Additional fields / defaults

allocation

Same-layer participant → leaf activity/function

None

interaction

OA activity → OA activity

items refs / []

functional_exchange

SA function → SA function

source_port, target_port / none; items / []

boundary_exchange

Participant-owned output-compatible port → input-compatible port

items refs / []

capability_involvement

Participant, activity/function, or OA role → capability

None

chain_involvement

Process/functional chain → capability

None

role_realization

OA entity/actor → OA role

None

role_assignment

OA role → leaf OA activity

None

mission_capability

OA mission → OA capability, or SA mission → SA capability

None

mission_involvement

OA entity/actor → OA mission, or SA System/Actor → SA mission

None

capability_include

Including capability → included capability in one layer

None

capability_extend

Extending capability → extended capability in one layer

condition string / ""

capability_generalization

Specific capability → general capability in one layer

None

sequence_link

Preceding contribution → following contribution in one process/chain

guard string / ""

state_transition

State vertices in one machine, subject to section 4.4

trigger, guard, effect strings / ""; transition_kind External; triggers, effects reference lists / []

availability

Work, participant, capability, or process/chain → state/mode

None

exchange_allocation

Boundary-exchange relation → SA functional-exchange relation

None

interface_provision, interface_requirement

Participant/port → provided/required interface

None

state_action

State/mode → activity/function

phase: Entry, Do, Exit / Do

+

Relation endpoints and carried ExchangeItems stay in the same layer. Parallel +exchanges with distinct identities are permitted. Self-relations are invalid +except for a state transition that returns to its source state.

+

Direct Allocation has at most one performer per leaf. Role sharing is a +different relationship: several OA participants can realize a Role. A direct +allocation inconsistent with its role's realized performers produces an +advisory warning; it does not prevent saving the model. Shared roles do not +imply a unique performer. +Allocation of a non-leaf work element is invalid. A computed graphical summary +of its allocated leaves MUST NOT create an extra semantic allocation.

+

Functional ports, when selected, belong to the corresponding endpoint function +and have compatible direction. Boundary ports belong to participants and have +compatible direction. An interface groups ExchangeItems; an exchange using +that port interface must carry items supported by it. Studio's OA boundary +exchange is a simplified representation of operational communication; it is +not a claim of complete Capella CommunicationMean equivalence.

+

An exclusive region has at most one initial state. Missing initial state is +a completeness warning. Initial states have no incoming transitions and at +most one outgoing transition; their outgoing transition has no trigger/guard. +Final states have no outgoing transitions. Mixing State and Mode entries is +permitted by this Studio profile. StateAction links provide typed work +references alongside the descriptive entry/exit/do strings.

+
+

4.1. Capabilities and missions

+

Include means the including capability requires the included capability's +behavior. Extend means the source capability adds conditional behavior to the +target; its condition records when that behavior applies. Generalization +means a specific source capability specializes a general target capability. +Each relationship stays within OA capabilities or within SA capabilities. +Include and Generalization graphs MUST each be acyclic, including transitive +cycles. Extend does not introduce an additional cycle restriction; the ordinary +no-self rule still applies. An empty Extend condition is an engineering warning +so an incomplete capability map can be authored and saved.

+

MissionCapability and MissionInvolvement express need and stakeholder/system +participation. They MUST NOT create a capability parent, a performer allocation, +or a cross-layer realization. These relationships remain distinct when the +same objects appear in a diagram or share names.

+
+
+

4.2. Contextual process and chain topology

+

A Process or FunctionalChain owns contribution occurrences with stable +identities. steps is their persistent membership and presentation order; +an identity MUST occur at most once in that list. A work contribution points to +an existing same-layer Activity or Function. Reusing the same work or exchange +requires separate occurrence identities and MUST NOT duplicate the referenced +engineering element. A SequenceReference points to another same-layer Process +or FunctionalChain of the owner's kind. Its target may be none while the +draft is incomplete. Direct and transitive recursive sequence reuse are errors.

+

SequenceLinks connect distinct occurrences directly owned by the same process +or chain. Their source is the predecessor and target is the successor. A link +from work to an exchange requires that work to be the referenced exchange's +source; an exchange-to-work link requires the target work to be its endpoint. +Two adjacent exchange contributions require the first exchange's target to +match the second exchange's source. A mismatch is a structural error. Other +links express contextual precedence without manufacturing a global exchange.

+

When links or work/control/reuse contributions exist, topology is explicit. +Reordering steps MUST NOT add, remove or reorder SequenceLinks. The editor +MUST retain the difference between alternative Choice/Merge and parallel +Fork/Join contributions. Choice/Fork expect one incoming and at least two +outgoing links; Merge/Join expect at least two incoming and one outgoing link. +Loop expects at least one incoming and two outgoing links for repetition and +exit. These cardinalities are completeness warnings, not barriers to building +an incomplete flow. Choice and Loop outgoing paths with empty guards warn; +an explicit else guard is permitted. Guards remain descriptive strings.

+

Validation warns about missing/ambiguous entry and exit, isolated or unreachable +contributions, contributions with no reachable exit, and splits/merges that +lack explicit controls. Topology cycles are permitted for loops; the recursive +SequenceReference prohibition is a different rule. A legacy exchange-only +sequence without explicit links retains its ordered linear meaning and +adjacent-exchange gap diagnostics. Implementations MUST NOT silently invent +topology links when loading or reordering such a sequence.

+
+
+

4.3. Typed scenarios and reusable behavior

+

ScenarioViewpoint is one of OperationalActivity (OA activity lifelines), +ParticipantInteraction (OA entity/actor/role lifelines), Functional (SA function +lifelines), BoundaryInterface (SA System/Actor lifelines), or MixedLegacy. +The first two are OA viewpoints; the next two are SA viewpoints. Lifeline +references MUST agree with that perspective. OperationalActivity messages use +Interactions; Functional messages use FunctionalExchanges. Participant views +may use their layer's work exchanges through effective performer allocation, +or participant boundary exchanges through the relevant ports. Corresponding +lifelines and endpoints MUST agree; a viewpoint is not a label that suppresses +reference checks.

+

Omitted schema-2 viewpoints hydrate as MixedLegacy in both source and JSON. +This preserves pre-extension mixed work/participant scenes without discarding +their lifelines or silently assigning a stricter meaning. MixedLegacy emits a +review warning. New graphical factories explicitly select OperationalActivity +or Functional by layer. Authors can select a strict viewpoint after reviewing +the legacy scene; canonical source then records that choice.

+

MessageKind is Asynchronous, Call, Reply, Create, Delete, or +{ Timer { expression "after 5 s"; }; }. A process/chain exchange +contribution MUST remain Asynchronous with no reply correlation. Call/reply, +creation/deletion and timer semantics belong to scenarios. Reply's reply_to +references a Call occurrence in the same scenario. The call MUST precede the +reply on a guaranteed common sequence/enclosing branch path. Reply lifelines +reverse the call's sender/receiver; it may reuse the call's exchange in reverse +or use an explicit reverse-direction exchange. A second reply to the same call +on that guaranteed path is an error. Missing reply correlation is a draft +warning; non-Reply messages MUST NOT have reply_to.

+

Create identifies the receiver lifeline's creation. An earlier guaranteed use +of that lifeline is an error. Delete identifies its destruction; a later +guaranteed use is an error. Ordering checks respect branch ownership; they MUST +NOT flatten alternative branches into a false unconditional order. A Timer is +local to its sender lifeline, has no global exchange or values, and has either +no receiver or the same receiver as sender. Its nonempty expression describes +the delay/deadline; an empty expression warns. Conditions and expressions are +stored engineering annotations, not an executable simulation language.

+

Each MessageValue is { field @data_field; expression "value expression"; }. +The field MUST be a DataField carried by the message exchange, including fields +inside named nested records. At most one annotation for a given field is +allowed in one message. Values do not overwrite the referenced type or global +exchange item. Expression text is preserved exactly; an empty expression warns.

+

ScenarioUse references an existing same-layer scenario and maps its lifelines +with bindings [ { referenced @callee_line; local @caller_line; } ];. +Each binding is one-to-one and references a direct lifeline of the appropriate +scenario. Bound lifelines represent the same semantic object, or an explicitly +realized operational role. Strict viewpoints MUST agree; MixedLegacy retains +the compatibility case until reviewed. Missing bindings produce warnings. +Direct/transitive recursive scenario use is invalid; repetition uses a Loop +fragment instead. ScenarioUse owns neither its referenced scenario nor its +lifelines; deleting a use does not delete the reusable definition.

+
+
+

4.4. State vertices, events and transition contracts

+

StateKind comprises State, Mode, Initial, Final, Choice, Junction, Fork, Join, +ShallowHistory, DeepHistory, EntryPoint, ExitPoint and Terminate. Every vertex +belongs to a Region. Only State and Mode are stable states: only these may own +nested Regions, typed StateAction links, availability and scenario state +observations. Multiple sibling Regions describe orthogonal configurations. +EntryPoint and ExitPoint retain Region ownership; their boundary is that +Region's owning State/Mode or StateMachine. Their graphical symbols belong on +that boundary. This is Studio's explicit containment encoding.

+

Events are ordinary same-layer semantic elements, independent of diagram +placements. EventBody has exactly these constructors:

+ + + + + + + + + + + + + + + + + + +
Event definitions

Constructor

Fields and meaning

Signal

items is a unique list of same-layer ExchangeItem references; empty is valid

Time

kind is After or At; expression is descriptive time text

Change

expression describes the Boolean condition becoming true

+

After measures relative time from activation of a transition's source state; +At describes an absolute instant. Empty Time/Change expressions are located +completeness warnings. The application does not execute expressions, fire +timers or transport signals. For example:

+
sa_event timeout "Recovery timeout" {
+    body { Time { kind After; expression "30 s"; }; };
+}
+sa_event recovered "Pressure recovered" {
+    body { Change { expression "pressure > 4 && status == \"Ready\""; }; };
+}
+

StateTransition's triggers references Events or layer-appropriate work +(OA Activity / SA Function). Referenced work need not have the machine owner's +performer: external work can cause the observed transition. Multiple triggers +are alternatives. effects references work describing effects, without +executing it. Duplicate, missing, wrong-layer or wrong-kind references are +errors. The existing trigger, guard and effect strings remain +authored prose; they MUST NOT be renamed, parsed as UUIDs or inferred by name.

+

With typed triggers, trigger adds descriptive information. With no typed +triggers and nonblank trigger prose, the transition has an unresolved +descriptive trigger and emits a warning. Both empty denotes completion from +a stable state or transient routing from a pseudostate. Deleting the final +typed trigger when prose is empty MUST leave an explicit descriptive reminder; +it MUST NOT silently create a completion transition. Guards/effect prose remain +unchanged. Signal payload references and typed effect references are pruned +when their targets are removed.

+

TransitionKind is External, Local or Internal. External leaves/re-enters the +affected configuration; a stable self-transition includes exit and entry +behavior. Internal requires the same stable source and target, preserves nested +configuration, and does not imply entry/exit. Local requires a composite stable +source and proper descendant target, preserving the source activation; an +EntryPoint also routes locally into its boundary. Local self/outside targets +are invalid. Other pseudostates route externally. Endpoints MUST share a layer +and StateMachine; crossing nested Regions within that machine is not generally +forbidden. A pseudostate self-transition is invalid.

+

A simple state's completion means entry/do behavior is complete. A composite +completes when its active Regions complete. Final completes its containing +Region; Terminate ends the containing machine's activation. These are modeling +meanings, not evidence of a running state-machine engine.

+

Missing connections/guards and unfinished initial/history paths produce +warnings, so an engineer can insert vertices before wiring them. Contradictory +upper cardinalities, forbidden directions/kinds and invalid references reject.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Vertex connection constraints

Vertex

Structural restrictions

Incomplete-model warnings

Initial

At most one per Region; no incoming; at most one outgoing; no outgoing trigger or guard

Missing initial/default entry or outgoing

Final / Terminate

No outgoing or nested Region

No incoming

Choice

At most one incoming; outgoing transient alternatives

Missing incoming, fewer than two outgoing, missing guard or else

Junction

Transient routing, no implicit concurrency

Missing side or ambiguous unguarded alternatives

Fork

At most one incoming; outgoing stable targets in distinct orthogonal Regions

Missing incoming or fewer than two outgoing

Join

At most one outgoing; incoming stable sources in distinct orthogonal Regions

Fewer than two incoming or missing outgoing

ShallowHistory

At most one per Region; at most one fallback to a direct stable child of that Region; no fallback trigger/guard

Missing fallback/default entry

DeepHistory

At most one per Region; at most one fallback to a stable descendant of that Region; no fallback trigger/guard

Missing fallback/default entry

EntryPoint

Incoming from outside boundary, outgoing Local into it; at most one outgoing per destination Region

Missing outgoing or incomplete affected-Region entry

ExitPoint

Incoming from inside boundary; at most one outgoing External to outside

Missing input/output; top-level exit is an exit endpoint, not implicit termination

+

Fork outputs and Join inputs MUST NOT carry guards or typed/descriptive +triggers. Their distinct orthogonal Regions are the first Regions beneath the +nearest common composite or machine, not merely each endpoint's immediate +parent. Choice/Junction routing expresses conditional alternatives. Retained +pseudostate entry/exit/do prose produces a warning and remains editable; it +does not become a typed state behavior. StateAction remains the maintained +reference for entry/do/exit work on stable states.

+

Other pseudostate outgoing segments MUST NOT introduce event/work triggers +or trigger prose. A Choice has at most one literal else guard; the tool +does not prove arbitrary guard expressions disjoint or exhaustive. Choice +expresses a dynamic decision after incoming effects; Junction expresses a +compound path selected by guards before traversing it. ShallowHistory remembers +the immediate state and uses default entry for deeper configuration; +DeepHistory remembers nested configuration. Fallback applies when no resumable +history exists. History is per Region, including top-level Regions in this +Studio profile; no running history state is persisted or synthesized.

+
+
+
+

5. Diagrams, artifacts, and cross-layer traceability

+

diagram has name, identity, alias, description, layer (Oa by default), +and projection (Overview by default). Other projections are Decomposition, +Allocation, and Traceability. Traceability diagrams can show both layers; +other diagrams show their chosen layer.

+

node has an alias and identity but no display-name string. Required fields +are diagram and element references. Optional fields are position +(record with x/y, default 0/0), size (width/height, default 224/94), and +collapsed (false). Node identity is independent of its semantic element. +Coordinates are absolute diagram coordinates. Placement does not alter parent, +allocation, or realization. Hiding an element from a diagram must remain +distinguishable from deleting it from the model. +Changing a node's diagram preserves the placement identity, alias, coordinates, +size, collapse state, and attached artifacts. The destination must accept the +element's layer and must not already contain another placement of that element.

+

artifact has name, identity, alias, description, required owner ref, +relation (References, Satisfies, Verifies, Implements, DerivedFrom; +default References), uri (default empty), revision (optional string), +and fragment (optional string). The two optional strings default to none. +URI must be nonempty, have no whitespace/backslash/control characters, and +use valid percent escapes. The owner is another existing model object. +Validation MUST NOT fetch the URI. Revision and fragment are retained verbatim.

+

trace has name, identity, alias, description, required oa and sa +endpoint references, kind (default Refinement), and baseline (default +none). Endpoints are canonical: SA realizes OA, regardless of which side +initiated creation. Trace does not change endpoint identity or containment. +Multiple distinct counterparts are permitted; duplicate endpoint/kind triples +are invalid.

+ + ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Trace kinds

Kind

OA → SA endpoint types

Refinement

Activity → Function

Participant

Entity/Actor → System/Actor

Capability

Operational Capability → System Capability

Mission

Operational Mission → System Mission

Exchange

Interaction relation → FunctionalExchange relation

Sequence

Process → FunctionalChain, or paired occurrence contributions directly owned by Process / FunctionalChain

Item

Same shared kind: ExchangeItem, DataType, DataField, Interface, or Port

Scenario

Same shared kind: Scenario, Lifeline, or Occurrence

State

Same shared kind: StateMachine, Region, any StateKind vertex, or Event

Manual

Engineer-defined relationship between OA/SA elements or relations

+

A nonempty baseline is { oa { name "..."; description "..."; }; sa { name "..."; description "..."; }; }. It records last-reviewed names +and descriptions, not a full structural snapshot. Implementations MUST NOT +present it as structural synchronization or a lossless inverse transition. +Explicit counterpart creation, linking, comparison, and reviewed property +update may start in either layer. Ordinary edits MUST NOT automatically +overwrite the counterpart. Deletion MUST NOT silently cascade across layers.

+

Operational Capability normally creates a System Capability. Operational Mission +and System Mission are default counterparts in both directions and use Mission +traces. An explicit Operational Capability ↔ System Mission mapping remains +supported with a Manual trace; a uniquely linked such counterpart is reused +instead of silently creating a different mapping. Required/subtree process and +chain transitions include their contextual links, owned contributions and +referenced dependencies. New process/chain contribution mappings use Sequence +traces; older Scenario-kind occurrence mappings remain accepted. The current +name/description baseline does not reconcile later topology changes.

+

Name and description are compared independently against each endpoint's +last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted +different target value. A source-only change proposes an update; different +changes on both sides require an explicit decision to take the source, keep +the target, or enter a merged value. An explicit decision records both current +values as reviewed, even when they differ. A target-only edit is preserved +without advancing that field's baseline, so it remains eligible to propagate +in the reverse direction. Equal current values can be recorded as converged.

+
+
+

6. Text grammar and resolution

+

The input is UTF-8 without a byte-order mark. LF, CR, space, and tab separate +tokens. // starts a comment through the next LF or end of input. Quoted +strings use JSON string escaping, including \n, \r, \t, \\, +and Unicode escapes. Literal unescaped newlines inside strings are invalid. +Numbers use JSON decimal/exponent syntax; NaN, infinity, hexadecimal numbers, +leading plus signs, and leading zeroes are invalid.

+

Aliases match [A-Za-z_][A-Za-z0-9_]*. The following aliases are reserved: +arcadia, project, id, oa, sa, element, relation, diagram, node, artifact, +trace, body, alias, name, description, parent, from, to, entity, actor, +activity, capability, process, allocation, interaction, contribution, +realization, position, steps, true, false, null. Other field/constructor +spellings can be used in alias positions because references begin with @.

+

The grammar below uses * for repetition and ? for optional syntax. +kind is exactly an element/relation keyword listed above. Typed field +sets and enum constructors are closed by sections 3–5.

+
document    = "arcadia" "2" ";" project
+project     = "project" alias string identity? "{" project-entry* "}"
+project-entry = "description" string ";" | declaration
+declaration = (kind | "diagram" | "artifact" | "trace")
+              alias string identity? fields
+            | "node" alias identity? fields
+identity    = "id" uuid-string
+fields      = "{" (field-name value ";")* "}"
+value       = string | number | "true" | "false" | "none"
+            | enum-word | "@" alias | list | fields
+list        = "[" (value ("," value)*)? "]"
+

The project description defaults to empty and may appear once. There is no +semicolon after a declaration or project closing brace. Every field inside a +record ends with a semicolon, including fields whose values are nested records. +Trailing list commas are invalid. Fields may appear in any order.

+

UUID strings use 8-4-4-4-12 hexadecimal spelling, with uppercase or lowercase +hexadecimal accepted. Canonical output uses lowercase. Omitting an identity +creates a fresh UUID for that parse. After Apply, canonical text includes the +generated identity. Authors must keep identity clauses to preserve existing +objects across subsequent edits.

+

All declaration aliases are collected before references resolve, so forward +references are legal, including references to nodes and diagrams. Unknown +@alias values are errors at their reference occurrence. Quoted UUIDs and +bare words cannot substitute for reference-valued fields. Alias resolution +produces UUID references; the alias itself remains stored on its declaration. +Changing an alias in source requires updating its uses. Display names never +resolve references.

+

Unknown fields, duplicate fields, incompatible constructor payloads, unsupported +enum spellings, duplicate aliases/identities, and structural validation errors +reject the entire candidate. No declaration silently replaces another. +Ordinary string fields require quoted strings and cannot contain an unquoted +@alias reference. Enum fields require unquoted named values or their typed +constructor records. Typed values MUST NOT nest more than 64 levels; malformed +deeply nested drafts produce a diagnostic instead of exhausting the parser stack.

+
+
+

7. Canonical output and source tooling

+

For every structurally valid Project P, parsing its canonical output MUST +reproduce P, and canonicalizing that result MUST be idempotent:

+
parse(format(P)) == P
+format(parse(format(P))) == format(P)
+

Equality includes all persistent fields, stored aliases, body variants, +references, placements, artifacts, trace baselines, and ordered occurrences. +Map order is irrelevant. The writer preserves signed coordinate zero and +emits decimals that recover the original binary32 value.

+

Canonical output uses LF, four-space indentation, explicit identities, +defaults, and JSON-escaped strings. Elements are emitted in UUID order, then +relations; diagrams and their nodes follow, then artifacts, then traces. +Fields use deterministic name order. Output ends with LF. Alias values come +from the model, never from regenerating display names.

+

Comments and input whitespace are lexical trivia. Canonicalizing or applying +source does not preserve them; the editor MUST explain that behavior. +Camera position, selection, source cursor, and undo history are not stored +model fields. Layout and collapse state inside NodePlacement are model data.

+

parse returns a complete structurally validated candidate or error +diagnostics and no model. Engineering warnings alone MUST NOT reject a valid +candidate. Parsing may stop at the first syntax/conversion error. Diagnostics +contain severity (Error or Warning), a code identifying the diagnostic category, +optional object UUID and field path, a message, one-based line and Unicode-scalar +column, and a half-open UTF-8 byte Span. Tabs count as one scalar; LF advances +the line. Structural errors and engineering warnings point to a relevant +declaration or field when available. Clients MUST use severity/code rather than +parse the human message; diagnostic wording is not a stable machine API.

+

parse_limited and decode_limited accept caller admission budgets for +input bytes, graph objects, total persistent records and ordered step references. +Counts MUST be checked before expensive structural validation; migration budgets +include generated records before migration. Exceeding a budget does not mutate +the model. Admission limits are host capacity policy, not hidden restrictions +on otherwise valid language semantics; ordinary parse has no such model +size policy. The REST service's fixed policy is specified in api.rst.

+

tokenize is tolerant: it covers every input byte with nonoverlapping +TokenSpans, including incomplete strings and invalid characters. Token classes +are Keyword, Identifier, String, Number, Comment, Punctuation, Whitespace, +and Error. @ is punctuation followed by the referenced alias token. +lint returns syntax, structural and engineering warning diagnostics for the +current draft without adopting a model. A valid unallocated SA function, for +example, can report missing System and allocation warnings before Apply. +analyze +combines tokens, diagnostics, and declaration spans with alias, optional +explicit UUID, and declaration kind. A malformed later declaration does not +erase earlier declaration spans. None of these APIs mutates a session.

+
+
+

8. Graphical parity and transactional editing

+

The graphical editor MUST expose all persistent properties through typed +controls, including optional references, lists, variant constructors, aliases, +IDs, nested parent changes, diagram placements, artifact metadata, and trace +baselines. Inspector controls may provide operations unavailable as a canvas +gesture. Exact strings, including tabs and line breaks in names, must remain +editable through an appropriate escaped-string input when ordinary widgets +cannot represent them.

+

A move into a functional parent, an allocation onto a performer, and a diagram +placement move are different transactions. The UI MUST identify which operation +will occur. Reparenting work must not silently change its performer. A computed +parent summary must remain distinguishable from direct allocation. A collapse +operation hides presentation detail without deleting the children.

+

Applying complete source is one model transaction. Graphical edits regenerate +the matching canonical source. Both paths validate their candidate before +adoption; failure preserves the committed model, save baseline, and history. +Incomplete but structurally safe references and coverage gaps produce warnings +that allow continued authoring and saving.

+

Source and property drafts contribute to unsaved-work state. Invalid drafts +MUST NOT be replaced by generated text or lost on ordinary navigation. Undoing +source-buffer edits and undoing committed model changes are distinct operations. +Successful source Apply creates one model-history entry. Undo/redo restores +the full stored model, including aliases, trace links, and occurrence identity.

+

Changing a graphical UUID must atomically update map keys and every reference. +Alias changes preserve UUID identity. Deletion must account for owned elements, +incident relationships, ordered occurrences, and diagram placements without +leaving dangling references. Deleting a sequence does not delete the semantic +exchanges it references. Cross-layer counterparts are not owned descendants. +Text edits describe a complete candidate and must remove or repair dependencies +explicitly before Apply succeeds.

+
+
+

9. Persistence and V1 migration

+

Loading detects JSON from its first non-whitespace {; other input is parsed +as Arcadia source regardless of filename. Saving an .arcadia suffix uses +canonical V2 text; other suffixes use V2 JSON. Both forms preserve the same +complete model. JSON rejects unknown fields throughout typed payloads, +duplicate maps, unsupported versions, and structural errors.

+

During first-release schema-2 integration, old Scenario bodies without a +viewpoint retain MixedLegacy, and old Exchange occurrence bodies without +message_kind, reply_to or values retain Asynchronous, none and []. +Old StateTransition bodies without transition_kind, triggers or +effects retain External, [] and []; existing trigger, guard and +effect strings are preserved exactly. Source declaration defaults and JSON +compatibility defaults MUST agree; no event or work reference is inferred from +legacy prose. +This compatibility applies equally to source and JSON. It adds explicit +defaults without changing any prior identity, name, reference, order, geometry, +artifact or trace field. The original V2 golden source/JSON in +crates/oa-core/tests/fixtures MUST NOT be regenerated from the newer demo; +they verify that every old persistent field survives. Canonical output writes +the added defaults. A future schema-3 adapter remains a separate required +migration and is not implied by this temporary compatibility behavior.

+

Save validates a snapshot, writes a sibling temporary file, flushes it, and +atomically replaces the destination. Failed serialization or validation must +not truncate a previous file. External concurrent-writer conflict resolution +and crash recovery are outside this storage contract.

+

Version 1 text and JSON are accepted through an explicit adapter. Migration +preserves project, element, and relation UUIDs; names and descriptions; relation +endpoints; all coordinates; and exact process exchange order and repetition. +Allocation and Interaction remain their corresponding V2 relations. +Contribution becomes CapabilityInvolvement; the old Process-to-Capability +Realization becomes ChainInvolvement, retaining its ID and name.

+

Legacy positions become nodes in one OA Overview diagram. Each old process +step becomes a distinct occurrence element owned by its process. Generated +diagram, node, and occurrence identities use UUID v5 with the old Project ID +as namespace and deterministic role/index names. If such an identity collides, +a deterministic suffix is tried. Reimporting the same fully identified V1 +input therefore yields the same V2 result, including repeated occurrences. +No System or SA content is invented during an OA-only migration.

+

V1 source aliases are retained when valid in V2. JSON imports generate the old +canonical aliases deterministically. Any alias newly reserved in V2 is changed +by a deterministic suffix; aliases were not persistent V1 semantic data. +A project alias and aliases for new records are generated without colliding +with retained aliases. The existing bundled V1 text and JSON migrate to equal +V2 projects. Future versions remain errors until a separate migration exists.

+
+
+

10. Complete authoring example

+

This document intentionally omits IDs for new authoring. Apply and Save write +the assigned UUIDs explicitly.

+
arcadia 2;
+project harbor "Harbor assistance" {
+    oa_entity control "Harbor control" {}
+    oa_activity assess "Assess assistance" {}
+    allocation performs "Performs" {
+        source @control;
+        target @assess;
+    }
+    sa_system service "Harbor assistance system" {}
+    sa_function evaluate "Evaluate assistance request" {}
+    allocation system_performs "Performs" {
+        source @service;
+        target @evaluate;
+    }
+    trace assessment_trace "Operational realization" {
+        oa @assess;
+        sa @evaluate;
+        kind Refinement;
+    }
+    diagram operational_view "Operational allocation" {
+        layer Oa;
+        projection Allocation;
+    }
+    node assessment_card {
+        diagram @operational_view;
+        element @assess;
+        position { x 360; y 70; };
+    }
+}
+
+
+

11. Verification and primary references

+

cargo test -p oa-core --test language --locked verifies typed round-trips, +stable aliases, strict syntax/reference failures, Unicode source spans, +deterministic V1 migration, occurrence identity, coordinate bits, persistence, +and model-history integration. Core validation and session tests cover domain +mutation rules; native UI and API verification are separate evidence.

+

The research record in docs/research-v2-semantics.md distinguishes method +requirements from Studio design choices and links to original UI evidence. +The following sources inform this language without extending its scope:

+ +
+
+ + diff --git a/docs/language.rst b/docs/language.rst new file mode 100644 index 00000000..400a05a7 --- /dev/null +++ b/docs/language.rst @@ -0,0 +1,924 @@ +Arcadia Studio OA / SA Language and Editing Contract +==================================================== + +:Version: 2 +:Status: Normative current implementation contract; first-release integration +:Core implementation: ``crates/oa-core`` +:Text suffix: ``.arcadia`` + +1. Scope and conformance +------------------------ + +This specification defines the shared Operational Analysis (OA) and System +Analysis (SA) model, its textual language, graphical editing obligations, +validation, persistence, and explicit migration from version 1. MUST, MUST NOT, +and MAY indicate requirements, prohibitions, and permitted choices. + +The first-release capability/mission, contextual process/chain, typed scenario +and state/event additions +are temporarily integrated under schema 2. This does not declare the first +release complete or waive its remaining scenario, state, data, transition and +usability requirements. A distinct release schema and explicit V2 migration +will be specified after those interfaces are frozen. This document describes +the current implemented records, rather than inventing compatibility rules for +that future schema. + +OA describes stakeholders, operational work and needs. SA describes the +selected system boundary, external actors, functions, services and expected +behavior. SA permits at most one System of Interest; its absence is an +incomplete-model warning when other SA elements exist. The SA System MUST NOT +contain internal components. Functional decomposition is independent of that +structural boundary. Logical Architecture, Physical Architecture, deployment, +Capella file interchange, and executable behavioral simulation are outside +this specification. This independent language does not claim complete Arcadia, +Eclipse Capella, or SysML conformance. + +An implementation conforms only when each supported field and relationship is +reachable in both graphical and textual editing, preserves identity through +save/open and undo, and uses the same structural validation. Merely displaying +a name or storing an opaque payload does not establish graphical support. +Actual verification evidence is separate from this contract. + +2. One authoritative model +-------------------------- + +A Project stores ``schema_version`` (2), ``id``, ``alias``, ``name``, +``description``, and maps named ``elements``, ``relations``, ``diagrams``, +``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in +its record. Duplicate map keys and mismatching keys/identities are errors. + +All objects share one UUID and alias namespace: Project, Element, Relation, +Diagram, NodePlacement, ArtifactLink, and Trace. UUIDs are durable identity; +aliases are durable, editable source labels. Display-name changes MUST NOT +regenerate existing aliases. Different objects MAY have the same display name. + +Every Element has ``id``, ``alias``, ``name``, ``description``, ``parent`` +(optional element reference), and a typed ``body``. Its kind and OA/SA layer +are derived from its body. A Relation has ``id``, ``alias``, ``name``, +``description``, and a typed body; it has no independently editable kind tag +that can disagree with its payload. + +Diagram placement does not determine semantic ownership. NodePlacement +records belong to diagrams and reference semantic elements. Multiple diagrams +can show the same element with independent placement and collapse state. +Within one diagram, each element appears at most once. + +All stored strings MUST contain only XML 1.0 characters: U+0009, U+000A, +U+000D, U+0020–U+D7FF, U+E000–U+FFFD, or U+10000–U+10FFFF. Strings are not +trimmed or Unicode-normalized. Escaping a forbidden character does not permit +it. Coordinates and sizes are finite IEEE-754 binary32 values with absolute +value at most 1000000. Width and height MUST be positive. Signed zero and +subnormal coordinates are retained. + +3. Element declarations and typed fields +---------------------------------------- + +Each element declaration starts with its layer and kind, for example +``oa_activity`` or ``sa_function``. Every element body accepts +``description`` (default ``""``) and ``parent`` (default ``none``). The table +lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts +``none``. Lists preserve order. Unknown or duplicate fields are errors. + +.. list-table:: Element body field catalog + :header-rows: 1 + :widths: 27 43 30 + + * - Declarations + - Additional fields + - Defaults + * - ``oa_entity``, ``oa_actor``, ``oa_role``, ``oa_activity``, ``oa_capability``, ``oa_mission`` + - None + - Empty body + * - ``sa_system``, ``sa_actor``, ``sa_function``, ``sa_capability``, ``sa_mission`` + - None + - Empty body + * - ``oa_process``, ``sa_functional_chain`` + - ``steps``: occurrence refs + - ``[]`` + * - ``oa_exchange_item``, ``sa_exchange_item`` + - ``kind``: Information, Material, Energy, Event + - Information + * - ``oa_data_type``, ``sa_data_type`` + - ``definition``: Boolean, Integer, Real, String, Record, or Enumeration constructor + - String + * - ``oa_data_field``, ``sa_data_field`` + - ``type_ref``; ``minimum``: u32; ``maximum``: optional u32; ``unit``: string + - String; 1; 1; ``""`` + * - ``oa_interface``, ``sa_interface`` + - ``items``: exchange-item refs + - ``[]`` + * - ``oa_port``, ``sa_port`` + - ``direction``: In, Out, InOut; ``interface``: ref? + - InOut; none + * - ``oa_scenario``, ``sa_scenario`` + - ``viewpoint``: ScenarioViewpoint; ``context``: ref?; ``precondition``, ``postcondition``: strings; ``steps``: occurrence refs + - MixedLegacy for omitted schema-2 source; none; ``""``; ``""``; ``[]`` + * - ``oa_lifeline``, ``sa_lifeline`` + - ``represented``: participant, work, or role ref? + - none + * - ``oa_occurrence``, ``sa_occurrence`` + - ``body``: occurrence constructor; ``precondition``, ``postcondition``: strings + - Exchange with none references, Asynchronous message kind and no values; two empty strings + * - ``oa_state_machine``, ``sa_state_machine``, ``oa_region``, ``sa_region`` + - None + - Empty body + * - ``oa_state``, ``sa_state``, ``oa_mode``, ``sa_mode``, ``oa_initial``, ``sa_initial``, ``oa_final``, ``sa_final`` + - ``kind``: StateKind; ``entry``, ``exit``, ``do_activity``: strings + - Kind selected by declaration; three empty strings + * - ``oa_choice``, ``oa_junction``, ``oa_fork``, ``oa_join``, ``oa_shallow_history``, ``oa_deep_history``, ``oa_entry_point``, ``oa_exit_point``, ``oa_terminate`` and corresponding ``sa_`` declarations + - Same State body; StateKind is Choice, Junction, Fork, Join, ShallowHistory, DeepHistory, EntryPoint, ExitPoint or Terminate + - Kind selected by declaration; three empty strings + * - ``oa_event``, ``sa_event`` + - ``body``: Signal, Time or Change constructor + - Signal with ``items []`` + * - ``oa_constraint``, ``sa_constraint`` + - ``expression``: string; ``constrained``: semantic record refs + - ``""``; ``[]`` + +The explicit state ``kind`` field MUST agree with its declaration keyword. +``u32`` is an integer from 0 through 4294967295. A DataField's maximum MUST +be at least its minimum; ``none`` means unbounded. Primitive TypeRefs are +Boolean, Integer, Real, and String. A named TypeRef uses a DataType reference. +An Enumeration contains unique string literals. For example: + +.. code-block:: text + + definition { Enumeration { literals ["Clear", "Fog"]; }; }; + type_ref { Named @weather_classification; }; + +These are typed constructors with named fields, not embedded JSON documents. +Constructor field names and enum spellings are case-sensitive. When replacing +a constructor, all of its fields MUST be supplied; the containing declaration +defaults do not silently fill a different constructor's missing fields. +During schema-2 compatibility, the new Exchange fields ``message_kind``, +``reply_to`` and ``values`` are exceptions: omitted values hydrate as +Asynchronous, none and [] respectively. Canonical output writes them explicitly. +This preserves older schema-2 source/JSON without inventing call correlation or +value annotations. It is not a rule for an unspecified future schema. + +.. list-table:: Occurrence body constructors + :header-rows: 1 + :widths: 22 78 + + * - Constructor + - Required fields + * - Exchange + - ``exchange``: exchange relation ref?; ``sender``, ``receiver``: lifeline refs?; ``message_kind``: MessageKind; ``reply_to``: occurrence ref?; ``values``: MessageValue records + * - Action + - ``action``: activity/function ref?; ``lifeline``: ref? + * - Control + - ``kind``: Choice, Merge, Fork, Join, Loop + * - SequenceReference + - ``sequence``: process/functional-chain ref? + * - ScenarioUse + - ``scenario``: scenario ref?; ``bindings``: LifelineBinding records + * - State + - ``state``: state/mode ref?; ``lifeline``: ref? + * - Fragment + - ``operator``: Alternative, Parallel, Loop, Optional; ``branches``: branch occurrence refs + * - Branch + - ``guard``: string; ``occurrences``: occurrence refs + +For example, ``body { Action { action @assess; lifeline @operator_line; }; };`` +selects an Action occurrence. Processes and functional chains contain Exchange, +Action, Control and SequenceReference occurrences. Process/chain Actions are +contextual work contributions and MUST have ``lifeline none``. A Control or +SequenceReference MUST belong directly to a Process or FunctionalChain, not a +Scenario. Scenarios contain Exchange, Action, State, Fragment, Branch and ScenarioUse +occurrences according to their enclosing ownership rules. Each +listed occurrence belongs to its enclosing sequence or branch through +``parent``. A Branch belongs to a Fragment; the Fragment's ``branches`` list +references the branches it owns. + +An exchange and an occurrence of that exchange have different identities. +Repeating an exchange requires distinct occurrence records. Repeating one +occurrence identity within an ordered list is invalid. Conditions, guards, +entry/exit/do text, and constraint expressions are descriptive strings; this +version does not parse or execute them as a programming language. + +4. Containment, allocation, and relationships +--------------------------------------------- + +Containment MUST be acyclic and stay within one layer. Entities may contain +entities, actors, and roles. Actors are not structural containers. Activities +contain activities; functions contain functions. Capability grouping uses +same-layer capability parents; it does not mean inheritance or inclusion. +Operational and system missions are root semantic records; they do not own +capabilities by containment. MissionCapability expresses that association. +DataFields belong to ExchangeItems or Record DataTypes. Ports belong to +participants or work. Lifelines belong to scenarios. State-machine ownership +uses participant → machine → region → state/mode; a state or mode can contain +a nested region. All other state vertices belong to Regions as well. Events +are root semantic records and do not acquire a machine owner when referenced. + +DataFields, ports, lifelines, occurrences, Regions and all state vertices +require an owner. An unowned StateMachine can be saved as incomplete +work and produces a warning. The System cannot own internal components. +The present Studio profile does not decompose SA Actors. + +All relations have a header identity, alias, display name, optional +``description`` (default ``""``), and required ``source`` and ``target`` +references. Their kind is the declaration keyword: + +.. list-table:: Relation catalog + :header-rows: 1 + :widths: 24 49 27 + + * - Declaration + - Endpoint meaning + - Additional fields / defaults + * - ``allocation`` + - Same-layer participant → leaf activity/function + - None + * - ``interaction`` + - OA activity → OA activity + - ``items`` refs / [] + * - ``functional_exchange`` + - SA function → SA function + - ``source_port``, ``target_port`` / none; ``items`` / [] + * - ``boundary_exchange`` + - Participant-owned output-compatible port → input-compatible port + - ``items`` refs / [] + * - ``capability_involvement`` + - Participant, activity/function, or OA role → capability + - None + * - ``chain_involvement`` + - Process/functional chain → capability + - None + * - ``role_realization`` + - OA entity/actor → OA role + - None + * - ``role_assignment`` + - OA role → leaf OA activity + - None + * - ``mission_capability`` + - OA mission → OA capability, or SA mission → SA capability + - None + * - ``mission_involvement`` + - OA entity/actor → OA mission, or SA System/Actor → SA mission + - None + * - ``capability_include`` + - Including capability → included capability in one layer + - None + * - ``capability_extend`` + - Extending capability → extended capability in one layer + - ``condition`` string / "" + * - ``capability_generalization`` + - Specific capability → general capability in one layer + - None + * - ``sequence_link`` + - Preceding contribution → following contribution in one process/chain + - ``guard`` string / "" + * - ``state_transition`` + - State vertices in one machine, subject to section 4.4 + - ``trigger``, ``guard``, ``effect`` strings / ""; ``transition_kind`` External; ``triggers``, ``effects`` reference lists / [] + * - ``availability`` + - Work, participant, capability, or process/chain → state/mode + - None + * - ``exchange_allocation`` + - Boundary-exchange relation → SA functional-exchange relation + - None + * - ``interface_provision``, ``interface_requirement`` + - Participant/port → provided/required interface + - None + * - ``state_action`` + - State/mode → activity/function + - ``phase``: Entry, Do, Exit / Do + +Relation endpoints and carried ExchangeItems stay in the same layer. Parallel +exchanges with distinct identities are permitted. Self-relations are invalid +except for a state transition that returns to its source state. + +Direct Allocation has at most one performer per leaf. Role sharing is a +different relationship: several OA participants can realize a Role. A direct +allocation inconsistent with its role's realized performers produces an +advisory warning; it does not prevent saving the model. Shared roles do not +imply a unique performer. +Allocation of a non-leaf work element is invalid. A computed graphical summary +of its allocated leaves MUST NOT create an extra semantic allocation. + +Functional ports, when selected, belong to the corresponding endpoint function +and have compatible direction. Boundary ports belong to participants and have +compatible direction. An interface groups ExchangeItems; an exchange using +that port interface must carry items supported by it. Studio's OA boundary +exchange is a simplified representation of operational communication; it is +not a claim of complete Capella CommunicationMean equivalence. + +An exclusive region has at most one initial state. Missing initial state is +a completeness warning. Initial states have no incoming transitions and at +most one outgoing transition; their outgoing transition has no trigger/guard. +Final states have no outgoing transitions. Mixing State and Mode entries is +permitted by this Studio profile. StateAction links provide typed work +references alongside the descriptive entry/exit/do strings. + +4.1. Capabilities and missions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Include means the including capability requires the included capability's +behavior. Extend means the source capability adds conditional behavior to the +target; its ``condition`` records when that behavior applies. Generalization +means a specific source capability specializes a general target capability. +Each relationship stays within OA capabilities or within SA capabilities. +Include and Generalization graphs MUST each be acyclic, including transitive +cycles. Extend does not introduce an additional cycle restriction; the ordinary +no-self rule still applies. An empty Extend condition is an engineering warning +so an incomplete capability map can be authored and saved. + +MissionCapability and MissionInvolvement express need and stakeholder/system +participation. They MUST NOT create a capability parent, a performer allocation, +or a cross-layer realization. These relationships remain distinct when the +same objects appear in a diagram or share names. + +4.2. Contextual process and chain topology +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A Process or FunctionalChain owns contribution occurrences with stable +identities. ``steps`` is their persistent membership and presentation order; +an identity MUST occur at most once in that list. A work contribution points to +an existing same-layer Activity or Function. Reusing the same work or exchange +requires separate occurrence identities and MUST NOT duplicate the referenced +engineering element. A SequenceReference points to another same-layer Process +or FunctionalChain of the owner's kind. Its target may be ``none`` while the +draft is incomplete. Direct and transitive recursive sequence reuse are errors. + +SequenceLinks connect distinct occurrences directly owned by the same process +or chain. Their source is the predecessor and target is the successor. A link +from work to an exchange requires that work to be the referenced exchange's +source; an exchange-to-work link requires the target work to be its endpoint. +Two adjacent exchange contributions require the first exchange's target to +match the second exchange's source. A mismatch is a structural error. Other +links express contextual precedence without manufacturing a global exchange. + +When links or work/control/reuse contributions exist, topology is explicit. +Reordering ``steps`` MUST NOT add, remove or reorder SequenceLinks. The editor +MUST retain the difference between alternative Choice/Merge and parallel +Fork/Join contributions. Choice/Fork expect one incoming and at least two +outgoing links; Merge/Join expect at least two incoming and one outgoing link. +Loop expects at least one incoming and two outgoing links for repetition and +exit. These cardinalities are completeness warnings, not barriers to building +an incomplete flow. Choice and Loop outgoing paths with empty guards warn; +an explicit ``else`` guard is permitted. Guards remain descriptive strings. + +Validation warns about missing/ambiguous entry and exit, isolated or unreachable +contributions, contributions with no reachable exit, and splits/merges that +lack explicit controls. Topology cycles are permitted for loops; the recursive +SequenceReference prohibition is a different rule. A legacy exchange-only +sequence without explicit links retains its ordered linear meaning and +adjacent-exchange gap diagnostics. Implementations MUST NOT silently invent +topology links when loading or reordering such a sequence. + +4.3. Typed scenarios and reusable behavior +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +ScenarioViewpoint is one of OperationalActivity (OA activity lifelines), +ParticipantInteraction (OA entity/actor/role lifelines), Functional (SA function +lifelines), BoundaryInterface (SA System/Actor lifelines), or MixedLegacy. +The first two are OA viewpoints; the next two are SA viewpoints. Lifeline +references MUST agree with that perspective. OperationalActivity messages use +Interactions; Functional messages use FunctionalExchanges. Participant views +may use their layer's work exchanges through effective performer allocation, +or participant boundary exchanges through the relevant ports. Corresponding +lifelines and endpoints MUST agree; a viewpoint is not a label that suppresses +reference checks. + +Omitted schema-2 viewpoints hydrate as MixedLegacy in both source and JSON. +This preserves pre-extension mixed work/participant scenes without discarding +their lifelines or silently assigning a stricter meaning. MixedLegacy emits a +review warning. New graphical factories explicitly select OperationalActivity +or Functional by layer. Authors can select a strict viewpoint after reviewing +the legacy scene; canonical source then records that choice. + +MessageKind is Asynchronous, Call, Reply, Create, Delete, or +``{ Timer { expression "after 5 s"; }; }``. A process/chain exchange +contribution MUST remain Asynchronous with no reply correlation. Call/reply, +creation/deletion and timer semantics belong to scenarios. Reply's ``reply_to`` +references a Call occurrence in the same scenario. The call MUST precede the +reply on a guaranteed common sequence/enclosing branch path. Reply lifelines +reverse the call's sender/receiver; it may reuse the call's exchange in reverse +or use an explicit reverse-direction exchange. A second reply to the same call +on that guaranteed path is an error. Missing reply correlation is a draft +warning; non-Reply messages MUST NOT have ``reply_to``. + +Create identifies the receiver lifeline's creation. An earlier guaranteed use +of that lifeline is an error. Delete identifies its destruction; a later +guaranteed use is an error. Ordering checks respect branch ownership; they MUST +NOT flatten alternative branches into a false unconditional order. A Timer is +local to its sender lifeline, has no global exchange or values, and has either +no receiver or the same receiver as sender. Its nonempty expression describes +the delay/deadline; an empty expression warns. Conditions and expressions are +stored engineering annotations, not an executable simulation language. + +Each MessageValue is ``{ field @data_field; expression "value expression"; }``. +The field MUST be a DataField carried by the message exchange, including fields +inside named nested records. At most one annotation for a given field is +allowed in one message. Values do not overwrite the referenced type or global +exchange item. Expression text is preserved exactly; an empty expression warns. + +ScenarioUse references an existing same-layer scenario and maps its lifelines +with ``bindings [ { referenced @callee_line; local @caller_line; } ];``. +Each binding is one-to-one and references a direct lifeline of the appropriate +scenario. Bound lifelines represent the same semantic object, or an explicitly +realized operational role. Strict viewpoints MUST agree; MixedLegacy retains +the compatibility case until reviewed. Missing bindings produce warnings. +Direct/transitive recursive scenario use is invalid; repetition uses a Loop +fragment instead. ScenarioUse owns neither its referenced scenario nor its +lifelines; deleting a use does not delete the reusable definition. + +4.4. State vertices, events and transition contracts +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +StateKind comprises State, Mode, Initial, Final, Choice, Junction, Fork, Join, +ShallowHistory, DeepHistory, EntryPoint, ExitPoint and Terminate. Every vertex +belongs to a Region. Only State and Mode are stable states: only these may own +nested Regions, typed StateAction links, availability and scenario state +observations. Multiple sibling Regions describe orthogonal configurations. +EntryPoint and ExitPoint retain Region ownership; their boundary is that +Region's owning State/Mode or StateMachine. Their graphical symbols belong on +that boundary. This is Studio's explicit containment encoding. + +Events are ordinary same-layer semantic elements, independent of diagram +placements. EventBody has exactly these constructors: + +.. list-table:: Event definitions + :header-rows: 1 + + * - Constructor + - Fields and meaning + * - Signal + - ``items`` is a unique list of same-layer ExchangeItem references; empty is valid + * - Time + - ``kind`` is After or At; ``expression`` is descriptive time text + * - Change + - ``expression`` describes the Boolean condition becoming true + +After measures relative time from activation of a transition's source state; +At describes an absolute instant. Empty Time/Change expressions are located +completeness warnings. The application does not execute expressions, fire +timers or transport signals. For example:: + + sa_event timeout "Recovery timeout" { + body { Time { kind After; expression "30 s"; }; }; + } + sa_event recovered "Pressure recovered" { + body { Change { expression "pressure > 4 && status == \"Ready\""; }; }; + } + +StateTransition's ``triggers`` references Events or layer-appropriate work +(OA Activity / SA Function). Referenced work need not have the machine owner's +performer: external work can cause the observed transition. Multiple triggers +are alternatives. ``effects`` references work describing effects, without +executing it. Duplicate, missing, wrong-layer or wrong-kind references are +errors. The existing ``trigger``, ``guard`` and ``effect`` strings remain +authored prose; they MUST NOT be renamed, parsed as UUIDs or inferred by name. + +With typed triggers, ``trigger`` adds descriptive information. With no typed +triggers and nonblank trigger prose, the transition has an unresolved +descriptive trigger and emits a warning. Both empty denotes completion from +a stable state or transient routing from a pseudostate. Deleting the final +typed trigger when prose is empty MUST leave an explicit descriptive reminder; +it MUST NOT silently create a completion transition. Guards/effect prose remain +unchanged. Signal payload references and typed effect references are pruned +when their targets are removed. + +TransitionKind is External, Local or Internal. External leaves/re-enters the +affected configuration; a stable self-transition includes exit and entry +behavior. Internal requires the same stable source and target, preserves nested +configuration, and does not imply entry/exit. Local requires a composite stable +source and proper descendant target, preserving the source activation; an +EntryPoint also routes locally into its boundary. Local self/outside targets +are invalid. Other pseudostates route externally. Endpoints MUST share a layer +and StateMachine; crossing nested Regions within that machine is not generally +forbidden. A pseudostate self-transition is invalid. + +A simple state's completion means entry/do behavior is complete. A composite +completes when its active Regions complete. Final completes its containing +Region; Terminate ends the containing machine's activation. These are modeling +meanings, not evidence of a running state-machine engine. + +Missing connections/guards and unfinished initial/history paths produce +warnings, so an engineer can insert vertices before wiring them. Contradictory +upper cardinalities, forbidden directions/kinds and invalid references reject. + +.. list-table:: Vertex connection constraints + :header-rows: 1 + + * - Vertex + - Structural restrictions + - Incomplete-model warnings + * - Initial + - At most one per Region; no incoming; at most one outgoing; no outgoing trigger or guard + - Missing initial/default entry or outgoing + * - Final / Terminate + - No outgoing or nested Region + - No incoming + * - Choice + - At most one incoming; outgoing transient alternatives + - Missing incoming, fewer than two outgoing, missing guard or else + * - Junction + - Transient routing, no implicit concurrency + - Missing side or ambiguous unguarded alternatives + * - Fork + - At most one incoming; outgoing stable targets in distinct orthogonal Regions + - Missing incoming or fewer than two outgoing + * - Join + - At most one outgoing; incoming stable sources in distinct orthogonal Regions + - Fewer than two incoming or missing outgoing + * - ShallowHistory + - At most one per Region; at most one fallback to a direct stable child of that Region; no fallback trigger/guard + - Missing fallback/default entry + * - DeepHistory + - At most one per Region; at most one fallback to a stable descendant of that Region; no fallback trigger/guard + - Missing fallback/default entry + * - EntryPoint + - Incoming from outside boundary, outgoing Local into it; at most one outgoing per destination Region + - Missing outgoing or incomplete affected-Region entry + * - ExitPoint + - Incoming from inside boundary; at most one outgoing External to outside + - Missing input/output; top-level exit is an exit endpoint, not implicit termination + +Fork outputs and Join inputs MUST NOT carry guards or typed/descriptive +triggers. Their distinct orthogonal Regions are the first Regions beneath the +nearest common composite or machine, not merely each endpoint's immediate +parent. Choice/Junction routing expresses conditional alternatives. Retained +pseudostate entry/exit/do prose produces a warning and remains editable; it +does not become a typed state behavior. StateAction remains the maintained +reference for entry/do/exit work on stable states. + +Other pseudostate outgoing segments MUST NOT introduce event/work triggers +or trigger prose. A Choice has at most one literal ``else`` guard; the tool +does not prove arbitrary guard expressions disjoint or exhaustive. Choice +expresses a dynamic decision after incoming effects; Junction expresses a +compound path selected by guards before traversing it. ShallowHistory remembers +the immediate state and uses default entry for deeper configuration; +DeepHistory remembers nested configuration. Fallback applies when no resumable +history exists. History is per Region, including top-level Regions in this +Studio profile; no running history state is persisted or synthesized. + +5. Diagrams, artifacts, and cross-layer traceability +---------------------------------------------------- + +``diagram`` has name, identity, alias, description, ``layer`` (Oa by default), +and ``projection`` (Overview by default). Other projections are Decomposition, +Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers; +other diagrams show their chosen layer. + +Allocation is an architecture view: it shows participants and work, nests +allocated work under its performer, and displays interactions, functional +exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy +and its exchanges. Capabilities shows participants, roles, missions and +capabilities with their involvement, exploitation, include, extend and +generalization links. Decomposition and Overview retain general containment; +Overview also exposes all relationships. Changing the projection hides +inapplicable placements without deleting them or changing semantic records. + +Ports are calculated boundary anchors of visible owners, with their existing +semantic identity. An explicitly placed port is not rendered as a separate +large node. Functional exchanges use their declared source/target ports; +boundary exchanges attach to their participant ports even if those ports have +no separate placement. Collapsing an owner hides its interior; projected links +may terminate at a visible ancestor without creating a new port or exchange. + +``node`` has an alias and identity but no display-name string. Required fields +are ``diagram`` and ``element`` references. Optional fields are ``position`` +(record with x/y, default 0/0), ``size`` (width/height, default 224/94), and +``collapsed`` (false). Node identity is independent of its semantic element. +Coordinates are absolute diagram coordinates. Placement does not alter parent, +allocation, or realization. Hiding an element from a diagram must remain +distinguishable from deleting it from the model. +Changing a node's diagram preserves the placement identity, alias, coordinates, +size, collapse state, and attached artifacts. The destination must accept the +element's layer and must not already contain another placement of that element. + +``artifact`` has name, identity, alias, description, required ``owner`` ref, +``relation`` (References, Satisfies, Verifies, Implements, DerivedFrom; +default References), ``uri`` (default empty), ``revision`` (optional string), +and ``fragment`` (optional string). The two optional strings default to none. +URI must be nonempty, have no whitespace/backslash/control characters, and +use valid percent escapes. The owner is another existing model object. +Validation MUST NOT fetch the URI. Revision and fragment are retained verbatim. + +``trace`` has name, identity, alias, description, required ``oa`` and ``sa`` +endpoint references, ``kind`` (default Refinement), and ``baseline`` (default +none). Endpoints are canonical: SA realizes OA, regardless of which side +initiated creation. Trace does not change endpoint identity or containment. +Multiple distinct counterparts are permitted; duplicate endpoint/kind triples +are invalid. + +.. list-table:: Trace kinds + :header-rows: 1 + :widths: 23 77 + + * - Kind + - OA → SA endpoint types + * - Refinement + - Activity → Function + * - Participant + - Entity/Actor → System/Actor + * - Capability + - Operational Capability → System Capability + * - Mission + - Operational Mission → System Mission + * - Exchange + - Interaction relation → FunctionalExchange relation + * - Sequence + - Process → FunctionalChain, or paired occurrence contributions directly owned by Process / FunctionalChain + * - Item + - Same shared kind: ExchangeItem, DataType, DataField, Interface, or Port + * - Scenario + - Same shared kind: Scenario, Lifeline, or Occurrence + * - State + - Same shared kind: StateMachine, Region, any StateKind vertex, or Event + * - Manual + - Engineer-defined relationship between OA/SA elements or relations + +A nonempty baseline is ``{ oa { name "..."; description "..."; }; +sa { name "..."; description "..."; }; }``. It records last-reviewed names +and descriptions, not a full structural snapshot. Implementations MUST NOT +present it as structural synchronization or a lossless inverse transition. +Explicit counterpart creation, linking, comparison, and reviewed property +update may start in either layer. Ordinary edits MUST NOT automatically +overwrite the counterpart. Deletion MUST NOT silently cascade across layers. + +Operational Capability normally creates a System Capability. Operational Mission +and System Mission are default counterparts in both directions and use Mission +traces. An explicit Operational Capability ↔ System Mission mapping remains +supported with a Manual trace; a uniquely linked such counterpart is reused +instead of silently creating a different mapping. Required/subtree process and +chain transitions include their contextual links, owned contributions and +referenced dependencies. New process/chain contribution mappings use Sequence +traces; older Scenario-kind occurrence mappings remain accepted. The current +name/description baseline does not reconcile later topology changes. + +Name and description are compared independently against each endpoint's +last-reviewed baseline. An unchanged source MUST NOT overwrite an accepted +different target value. A source-only change proposes an update; different +changes on both sides require an explicit decision to take the source, keep +the target, or enter a merged value. An explicit decision records both current +values as reviewed, even when they differ. A target-only edit is preserved +without advancing that field's baseline, so it remains eligible to propagate +in the reverse direction. Equal current values can be recorded as converged. + +6. Text grammar and resolution +------------------------------ + +The input is UTF-8 without a byte-order mark. LF, CR, space, and tab separate +tokens. ``//`` starts a comment through the next LF or end of input. Quoted +strings use JSON string escaping, including ``\n``, ``\r``, ``\t``, ``\\``, +and Unicode escapes. Literal unescaped newlines inside strings are invalid. +Numbers use JSON decimal/exponent syntax; NaN, infinity, hexadecimal numbers, +leading plus signs, and leading zeroes are invalid. + +Aliases match ``[A-Za-z_][A-Za-z0-9_]*``. The following aliases are reserved: +arcadia, project, id, oa, sa, element, relation, diagram, node, artifact, +trace, body, alias, name, description, parent, from, to, entity, actor, +activity, capability, process, allocation, interaction, contribution, +realization, position, steps, true, false, null. Other field/constructor +spellings can be used in alias positions because references begin with ``@``. + +The grammar below uses ``*`` for repetition and ``?`` for optional syntax. +``kind`` is exactly an element/relation keyword listed above. Typed field +sets and enum constructors are closed by sections 3–5. + +.. code-block:: text + + document = "arcadia" "2" ";" project + project = "project" alias string identity? "{" project-entry* "}" + project-entry = "description" string ";" | declaration + declaration = (kind | "diagram" | "artifact" | "trace") + alias string identity? fields + | "node" alias identity? fields + identity = "id" uuid-string + fields = "{" (field-name value ";")* "}" + value = string | number | "true" | "false" | "none" + | enum-word | "@" alias | list | fields + list = "[" (value ("," value)*)? "]" + +The project description defaults to empty and may appear once. There is no +semicolon after a declaration or project closing brace. Every field inside a +record ends with a semicolon, including fields whose values are nested records. +Trailing list commas are invalid. Fields may appear in any order. + +UUID strings use 8-4-4-4-12 hexadecimal spelling, with uppercase or lowercase +hexadecimal accepted. Canonical output uses lowercase. Omitting an identity +creates a fresh UUID for that parse. After Apply, canonical text includes the +generated identity. Authors must keep identity clauses to preserve existing +objects across subsequent edits. + +All declaration aliases are collected before references resolve, so forward +references are legal, including references to nodes and diagrams. Unknown +``@alias`` values are errors at their reference occurrence. Quoted UUIDs and +bare words cannot substitute for reference-valued fields. Alias resolution +produces UUID references; the alias itself remains stored on its declaration. +Changing an alias in source requires updating its uses. Display names never +resolve references. + +Unknown fields, duplicate fields, incompatible constructor payloads, unsupported +enum spellings, duplicate aliases/identities, and structural validation errors +reject the entire candidate. No declaration silently replaces another. +Ordinary string fields require quoted strings and cannot contain an unquoted +``@alias`` reference. Enum fields require unquoted named values or their typed +constructor records. Typed values MUST NOT nest more than 64 levels; malformed +deeply nested drafts produce a diagnostic instead of exhausting the parser stack. + +7. Canonical output and source tooling +-------------------------------------- + +For every structurally valid Project P, parsing its canonical output MUST +reproduce P, and canonicalizing that result MUST be idempotent: + +.. code-block:: text + + parse(format(P)) == P + format(parse(format(P))) == format(P) + +Equality includes all persistent fields, stored aliases, body variants, +references, placements, artifacts, trace baselines, and ordered occurrences. +Map order is irrelevant. The writer preserves signed coordinate zero and +emits decimals that recover the original binary32 value. + +Canonical output uses LF, four-space indentation, explicit identities, +defaults, and JSON-escaped strings. Elements are emitted in UUID order, then +relations; diagrams and their nodes follow, then artifacts, then traces. +Fields use deterministic name order. Output ends with LF. Alias values come +from the model, never from regenerating display names. + +Comments and input whitespace are lexical trivia. Canonicalizing or applying +source does not preserve them; the editor MUST explain that behavior. +Camera position, selection, source cursor, and undo history are not stored +model fields. Layout and collapse state inside NodePlacement are model data. + +``parse`` returns a complete structurally validated candidate or error +diagnostics and no model. Engineering warnings alone MUST NOT reject a valid +candidate. Parsing may stop at the first syntax/conversion error. Diagnostics +contain severity (Error or Warning), a code identifying the diagnostic category, +optional object UUID and field path, a message, one-based line and Unicode-scalar +column, and a half-open UTF-8 byte Span. Tabs count as one scalar; LF advances +the line. Structural errors and engineering warnings point to a relevant +declaration or field when available. Clients MUST use severity/code rather than +parse the human message; diagnostic wording is not a stable machine API. + +``parse_limited`` and ``decode_limited`` accept caller admission budgets for +input bytes, graph objects, total persistent records and ordered step references. +Counts MUST be checked before expensive structural validation; migration budgets +include generated records before migration. Exceeding a budget does not mutate +the model. Admission limits are host capacity policy, not hidden restrictions +on otherwise valid language semantics; ordinary ``parse`` has no such model +size policy. The REST service's fixed policy is specified in ``api.rst``. + +``tokenize`` is tolerant: it covers every input byte with nonoverlapping +TokenSpans, including incomplete strings and invalid characters. Token classes +are Keyword, Identifier, String, Number, Comment, Punctuation, Whitespace, +and Error. ``@`` is punctuation followed by the referenced alias token. +``lint`` returns syntax, structural and engineering warning diagnostics for the +current draft without adopting a model. A valid unallocated SA function, for +example, can report missing System and allocation warnings before Apply. +``analyze`` +combines tokens, diagnostics, and declaration spans with alias, optional +explicit UUID, and declaration kind. A malformed later declaration does not +erase earlier declaration spans. None of these APIs mutates a session. + +8. Graphical parity and transactional editing +--------------------------------------------- + +The graphical editor MUST expose all persistent properties through typed +controls, including optional references, lists, variant constructors, aliases, +IDs, nested parent changes, diagram placements, artifact metadata, and trace +baselines. Inspector controls may provide operations unavailable as a canvas +gesture. Exact strings, including tabs and line breaks in names, must remain +editable through an appropriate escaped-string input when ordinary widgets +cannot represent them. + +A move into a functional parent, an allocation onto a performer, and a diagram +placement move are different transactions. The UI MUST identify which operation +will occur. Reparenting work must not silently change its performer. A computed +parent summary must remain distinguishable from direct allocation. A collapse +operation hides presentation detail without deleting the children. + +Applying complete source is one model transaction. Graphical edits regenerate +the matching canonical source. Both paths validate their candidate before +adoption; failure preserves the committed model, save baseline, and history. +Incomplete but structurally safe references and coverage gaps produce warnings +that allow continued authoring and saving. + +Source and property drafts contribute to unsaved-work state. Invalid drafts +MUST NOT be replaced by generated text or lost on ordinary navigation. Undoing +source-buffer edits and undoing committed model changes are distinct operations. +Successful source Apply creates one model-history entry. Undo/redo restores +the full stored model, including aliases, trace links, and occurrence identity. + +Changing a graphical UUID must atomically update map keys and every reference. +Alias changes preserve UUID identity. Deletion must account for owned elements, +incident relationships, ordered occurrences, and diagram placements without +leaving dangling references. Deleting a sequence does not delete the semantic +exchanges it references. Cross-layer counterparts are not owned descendants. +Text edits describe a complete candidate and must remove or repair dependencies +explicitly before Apply succeeds. + +9. Persistence and V1 migration +------------------------------- + +Loading detects JSON from its first non-whitespace ``{``; other input is parsed +as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses +canonical V2 text; other suffixes use V2 JSON. Both forms preserve the same +complete model. JSON rejects unknown fields throughout typed payloads, +duplicate maps, unsupported versions, and structural errors. + +During first-release schema-2 integration, old Scenario bodies without a +``viewpoint`` retain MixedLegacy, and old Exchange occurrence bodies without +``message_kind``, ``reply_to`` or ``values`` retain Asynchronous, none and []. +Old StateTransition bodies without ``transition_kind``, ``triggers`` or +``effects`` retain External, [] and []; existing ``trigger``, ``guard`` and +``effect`` strings are preserved exactly. Source declaration defaults and JSON +compatibility defaults MUST agree; no event or work reference is inferred from +legacy prose. +This compatibility applies equally to source and JSON. It adds explicit +defaults without changing any prior identity, name, reference, order, geometry, +artifact or trace field. The original V2 golden source/JSON in +``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo; +they verify that every old persistent field survives. Canonical output writes +the added defaults. A future schema-3 adapter remains a separate required +migration and is not implied by this temporary compatibility behavior. + +Save validates a snapshot, writes a sibling temporary file, flushes it, and +atomically replaces the destination. Failed serialization or validation must +not truncate a previous file. External concurrent-writer conflict resolution +and crash recovery are outside this storage contract. + +Version 1 text and JSON are accepted through an explicit adapter. Migration +preserves project, element, and relation UUIDs; names and descriptions; relation +endpoints; all coordinates; and exact process exchange order and repetition. +Allocation and Interaction remain their corresponding V2 relations. +Contribution becomes CapabilityInvolvement; the old Process-to-Capability +Realization becomes ChainInvolvement, retaining its ID and name. + +Legacy positions become nodes in one OA Overview diagram. Each old process +step becomes a distinct occurrence element owned by its process. Generated +diagram, node, and occurrence identities use UUID v5 with the old Project ID +as namespace and deterministic role/index names. If such an identity collides, +a deterministic suffix is tried. Reimporting the same fully identified V1 +input therefore yields the same V2 result, including repeated occurrences. +No System or SA content is invented during an OA-only migration. + +V1 source aliases are retained when valid in V2. JSON imports generate the old +canonical aliases deterministically. Any alias newly reserved in V2 is changed +by a deterministic suffix; aliases were not persistent V1 semantic data. +A project alias and aliases for new records are generated without colliding +with retained aliases. The existing bundled V1 text and JSON migrate to equal +V2 projects. Future versions remain errors until a separate migration exists. + +10. Complete authoring example +------------------------------ + +This document intentionally omits IDs for new authoring. Apply and Save write +the assigned UUIDs explicitly. + +.. code-block:: text + + arcadia 2; + project harbor "Harbor assistance" { + oa_entity control "Harbor control" {} + oa_activity assess "Assess assistance" {} + allocation performs "Performs" { + source @control; + target @assess; + } + sa_system service "Harbor assistance system" {} + sa_function evaluate "Evaluate assistance request" {} + allocation system_performs "Performs" { + source @service; + target @evaluate; + } + trace assessment_trace "Operational realization" { + oa @assess; + sa @evaluate; + kind Refinement; + } + diagram operational_view "Operational allocation" { + layer Oa; + projection Allocation; + } + node assessment_card { + diagram @operational_view; + element @assess; + position { x 360; y 70; }; + } + } + +11. Verification and primary references +--------------------------------------- + +``cargo test -p oa-core --test language --locked`` verifies typed round-trips, +stable aliases, strict syntax/reference failures, Unicode source spans, +deterministic V1 migration, occurrence identity, coordinate bits, persistence, +and model-history integration. Core validation and session tests cover domain +mutation rules; native UI and API verification are separate evidence. + +The research record in ``docs/research-v2-semantics.md`` distinguishes method +requirements from Studio design choices and links to original UI evidence. +The following sources inform this language without extending its scope: + +* `Arcadia Q&A `_ +* `Arcadia language reference `_ +* `Capella allocation guide `_ +* `Capella iterative transitions `_ +* `W3C XML 1.0 characters `_ diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 00000000..37745c15 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,5444 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Arcadia Studio model API", + "version": "1.0.0", + "description": "Current schema-2 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag." + }, + "servers": [ + { + "url": "http://127.0.0.1:8787" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/health": { + "get": { + "operationId": "_health", + "summary": "Store was opened and HTTP service is responding", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "const": "ok" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "security": [] + } + }, + "/health/live": { + "get": { + "operationId": "_health_live", + "summary": "Store was opened and HTTP service is responding", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "const": "ok" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "security": [] + } + }, + "/health/ready": { + "get": { + "operationId": "_health_ready", + "summary": "Store was opened and HTTP service is responding", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "const": "ok" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "security": [] + } + }, + "/openapi.json": { + "get": { + "operationId": "_openapi_json", + "summary": "OpenAPI contract", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "security": [] + } + }, + "/api/v1/openapi.json": { + "get": { + "operationId": "_api_v1_openapi_json", + "summary": "OpenAPI contract", + "parameters": [], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "security": [] + } + }, + "/api/v1/projects": { + "get": { + "operationId": "listProjects", + "summary": "List current hosted projects in UUID order", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "after", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPage" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + }, + "post": { + "operationId": "createProject", + "summary": "Create and persist an empty model", + "parameters": [], + "responses": { + "201": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectSummary" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NameRequest" + } + } + } + } + } + }, + "/api/v1/projects/import": { + "post": { + "operationId": "importProject", + "summary": "Import JSON or UTF-8 Arcadia source as a new hosted resource", + "parameters": [], + "responses": { + "201": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectSummary" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "description": "Schema-2 native JSON is described below. Schema-1 JSON is also accepted via the core's explicit migration adapter; see docs/language.rst for the legacy contract. Export emits schema 2." + } + }, + "/api/v1/projects/{id}": { + "get": { + "operationId": "getProject", + "summary": "Get complete current envelope", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-None-Match", + "in": "header", + "required": false, + "description": "Return 304 when the current representation's revision matches.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredProject" + } + } + } + }, + "304": { + "description": "Unchanged; empty body." + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + }, + "patch": { + "operationId": "renameProject", + "summary": "Rename the project atomically", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": true, + "description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MutationResult" + } + } + } + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NameRequest" + } + } + } + } + }, + "delete": { + "operationId": "deleteProject", + "summary": "Permanently delete the current hosted envelope", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": true, + "description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + } + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/api/v1/projects/{id}/model": { + "get": { + "operationId": "getModel", + "summary": "Export native model JSON", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-None-Match", + "in": "header", + "required": false, + "description": "Return 304 when the current representation's revision matches.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "304": { + "description": "Unchanged; empty body." + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + }, + "put": { + "operationId": "replaceModel", + "summary": "Conditionally replace model JSON", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": true, + "description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MutationResult" + } + } + } + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + } + }, + "/api/v1/projects/{id}/source": { + "get": { + "operationId": "getSource", + "summary": "Export canonical Arcadia source", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-None-Match", + "in": "header", + "required": false, + "description": "Return 304 when the current representation's revision matches.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Unchanged; empty body." + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + }, + "put": { + "operationId": "replaceSource", + "summary": "Conditionally replace from UTF-8 Arcadia source", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": true, + "description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MutationResult" + } + } + } + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/projects/{id}/elements": { + "get": { + "operationId": "listElements", + "summary": "Filter current elements; AND semantics", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "after", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "revision", + "in": "query", + "required": false, + "description": "For project lists, pin current revision across pages; 409 if it changed. No historical snapshots.", + "schema": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "description": "", + "schema": { + "$ref": "#/components/schemas/Kind" + } + }, + { + "name": "layer", + "in": "query", + "required": false, + "description": "", + "schema": { + "$ref": "#/components/schemas/Layer" + } + }, + { + "name": "alias", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElementPage" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/api/v1/projects/{id}/relations": { + "get": { + "operationId": "listRelations", + "summary": "Filter relations by typed kind and endpoints", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "after", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "revision", + "in": "query", + "required": false, + "description": "For project lists, pin current revision across pages; 409 if it changed. No historical snapshots.", + "schema": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "description": "", + "schema": { + "$ref": "#/components/schemas/RelationKind" + } + }, + { + "name": "source", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "target", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "incident_to", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelationPage" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/api/v1/projects/{id}/artifact-links": { + "get": { + "operationId": "listArtifacts", + "summary": "List model artifact links without fetching URIs", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "after", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "revision", + "in": "query", + "required": false, + "description": "For project lists, pin current revision across pages; 409 if it changed. No historical snapshots.", + "schema": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + } + }, + { + "name": "object", + "in": "query", + "required": false, + "description": "Owner ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtifactPage" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/api/v1/projects/{id}/traces": { + "get": { + "operationId": "listTraces", + "summary": "List OA/SA traces", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "after", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "revision", + "in": "query", + "required": false, + "description": "For project lists, pin current revision across pages; 409 if it changed. No historical snapshots.", + "schema": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + } + }, + { + "name": "object", + "in": "query", + "required": false, + "description": "OA or SA endpoint ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TracePage" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/api/v1/projects/{id}/query": { + "post": { + "operationId": "queryNeighborhood", + "summary": "Bounded graph neighborhood including relation endpoint closure", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeighborhoodResult" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeighborhoodRequest" + } + } + } + } + } + }, + "/api/v1/projects/{id}/validation": { + "get": { + "operationId": "validateCurrent", + "summary": "Shared core validation of current snapshot", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationResult" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + } + }, + "post": { + "operationId": "validateCandidate", + "summary": "Check submitted model/source without mutation", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationResult" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationRequest" + } + } + } + } + } + }, + "/api/v1/projects/{id}/transactions": { + "post": { + "operationId": "executeTransaction", + "summary": "Atomic shared typed commands; optional dry run", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": true, + "description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "ETag": { + "schema": { + "type": "string" + }, + "description": "Project revision validator when scoped to a project." + }, + "X-Request-ID": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionResult" + } + } + } + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionRequest" + } + } + } + } + } + }, + "/api/v1/projects/{id}/transitions/preview": { + "post": { + "operationId": "previewTransition", + "summary": "Preview a shared core transition without persisting a plan", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": true, + "description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Validated result; preview may include unresolved conflicts.", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionEnvelope" + } + } + } + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionRequest" + } + } + } + } + } + }, + "/api/v1/projects/{id}/transitions/apply": { + "post": { + "operationId": "applyTransition", + "summary": "Apply client-held transition plan against its original revision", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Hosted resource UUID; distinct from editable model ID.", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "If-Match", + "in": "header", + "required": true, + "description": "One exact strong project ETag. Missing: 428; stale: 412. Weak/list/wildcard unsupported.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Validated result; preview may include unresolved conflicts.", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MutationResult" + } + } + } + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "default": { + "$ref": "#/components/responses/Problem" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionEnvelope" + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Optional for unconfigured loopback; required on non-loopback." + } + }, + "responses": { + "Problem": { + "description": "Structured problem response. See code/detail and docs/api.rst status mapping.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "schemas": { + "Id": { + "type": "string", + "format": "uuid" + }, + "Revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "Layer": { + "type": "string", + "enum": [ + "Oa", + "Sa" + ] + }, + "Kind": { + "type": "string", + "enum": [ + "Entity", + "Actor", + "Role", + "Activity", + "Capability", + "OperationalMission", + "Process", + "System", + "SystemActor", + "Function", + "SystemCapability", + "Mission", + "FunctionalChain", + "ExchangeItem", + "DataType", + "DataField", + "Interface", + "Port", + "Scenario", + "Lifeline", + "Occurrence", + "StateMachine", + "Region", + "State", + "Mode", + "Initial", + "Final", + "Choice", + "Junction", + "Fork", + "Join", + "ShallowHistory", + "DeepHistory", + "EntryPoint", + "ExitPoint", + "Terminate", + "Event", + "Constraint" + ] + }, + "RelationKind": { + "type": "string", + "enum": [ + "Allocation", + "Interaction", + "CapabilityInvolvement", + "ChainInvolvement", + "RoleRealization", + "RoleAssignment", + "FunctionalExchange", + "BoundaryExchange", + "MissionCapability", + "StateTransition", + "Availability", + "ExchangeAllocation", + "InterfaceProvision", + "InterfaceRequirement", + "StateAction", + "CapabilityInclude", + "CapabilityExtend", + "CapabilityGeneralization", + "MissionInvolvement", + "SequenceLink" + ] + }, + "Position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1000000, + "maximum": 1000000 + }, + "y": { + "type": "number", + "minimum": -1000000, + "maximum": 1000000 + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + }, + "Size": { + "type": "object", + "properties": { + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "width", + "height" + ], + "additionalProperties": false + }, + "DataTypeDefinition": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Boolean", + "Integer", + "Real", + "String", + "Record" + ] + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "Enumeration": { + "type": "object", + "properties": { + "literals": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "literals" + ], + "additionalProperties": false + } + }, + "required": [ + "Enumeration" + ], + "additionalProperties": false + } + ] + } + ] + }, + "TypeRef": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Boolean", + "Integer", + "Real", + "String" + ] + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "Named": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "Named" + ], + "additionalProperties": false + } + ] + } + ] + }, + "OccurrenceBody": { + "oneOf": [ + { + "type": "object", + "properties": { + "Exchange": { + "type": "object", + "properties": { + "exchange": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "sender": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "receiver": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "message_kind": { + "$ref": "#/components/schemas/MessageKind", + "default": "Asynchronous" + }, + "reply_to": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "default": null + }, + "values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageValue" + }, + "default": [] + } + }, + "required": [ + "exchange", + "sender", + "receiver" + ], + "additionalProperties": false, + "description": "Missing new message fields use schema-2 compatibility defaults; canonical output makes these fields explicit." + } + }, + "required": [ + "Exchange" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Action": { + "type": "object", + "properties": { + "action": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "lifeline": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "action", + "lifeline" + ], + "additionalProperties": false + } + }, + "required": [ + "Action" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "State": { + "type": "object", + "properties": { + "state": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "lifeline": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "state", + "lifeline" + ], + "additionalProperties": false + } + }, + "required": [ + "State" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Fragment": { + "type": "object", + "properties": { + "operator": { + "type": "string", + "enum": [ + "Alternative", + "Parallel", + "Loop", + "Optional" + ] + }, + "branches": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "operator", + "branches" + ], + "additionalProperties": false + } + }, + "required": [ + "Fragment" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Branch": { + "type": "object", + "properties": { + "guard": { + "type": "string" + }, + "occurrences": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "guard", + "occurrences" + ], + "additionalProperties": false + } + }, + "required": [ + "Branch" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Control": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/ControlKind" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + }, + "required": [ + "Control" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "SequenceReference": { + "type": "object", + "properties": { + "sequence": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sequence" + ], + "additionalProperties": false + } + }, + "required": [ + "SequenceReference" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ScenarioUse": { + "type": "object", + "properties": { + "scenario": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "bindings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LifelineBinding" + } + } + }, + "required": [ + "scenario", + "bindings" + ], + "additionalProperties": false + } + }, + "required": [ + "ScenarioUse" + ], + "additionalProperties": false + } + ] + }, + "SharedBody": { + "oneOf": [ + { + "type": "string", + "enum": [ + "StateMachine", + "Region" + ] + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "ExchangeItem": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "Information", + "Material", + "Energy", + "Event" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + }, + "required": [ + "ExchangeItem" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "DataType": { + "type": "object", + "properties": { + "definition": { + "$ref": "#/components/schemas/DataTypeDefinition" + } + }, + "required": [ + "definition" + ], + "additionalProperties": false + } + }, + "required": [ + "DataType" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "DataField": { + "type": "object", + "properties": { + "type_ref": { + "$ref": "#/components/schemas/TypeRef" + }, + "minimum": { + "type": "integer", + "minimum": 0 + }, + "maximum": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "unit": { + "type": "string" + } + }, + "required": [ + "type_ref", + "minimum", + "maximum", + "unit" + ], + "additionalProperties": false + } + }, + "required": [ + "DataField" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Interface": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "items" + ], + "additionalProperties": false + } + }, + "required": [ + "Interface" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Port": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": [ + "In", + "Out", + "InOut" + ] + }, + "interface": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "direction", + "interface" + ], + "additionalProperties": false + } + }, + "required": [ + "Port" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Scenario": { + "type": "object", + "properties": { + "context": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "precondition": { + "type": "string" + }, + "postcondition": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "viewpoint": { + "$ref": "#/components/schemas/ScenarioViewpoint", + "default": "MixedLegacy", + "description": "Omitted schema-2 viewpoint retains legacy mixed lifelines. New graphical factories select OperationalActivity or Functional explicitly." + } + }, + "required": [ + "context", + "precondition", + "postcondition", + "steps" + ], + "additionalProperties": false + } + }, + "required": [ + "Scenario" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Lifeline": { + "type": "object", + "properties": { + "represented": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "represented" + ], + "additionalProperties": false + } + }, + "required": [ + "Lifeline" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Occurrence": { + "type": "object", + "properties": { + "body": { + "$ref": "#/components/schemas/OccurrenceBody" + }, + "precondition": { + "type": "string" + }, + "postcondition": { + "type": "string" + } + }, + "required": [ + "body", + "precondition", + "postcondition" + ], + "additionalProperties": false + } + }, + "required": [ + "Occurrence" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "State": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/StateKind" + }, + "entry": { + "type": "string" + }, + "exit": { + "type": "string" + }, + "do_activity": { + "type": "string" + } + }, + "required": [ + "kind", + "entry", + "exit", + "do_activity" + ], + "additionalProperties": false + } + }, + "required": [ + "State" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Constraint": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "constrained": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "expression", + "constrained" + ], + "additionalProperties": false + } + }, + "required": [ + "Constraint" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Event": { + "type": "object", + "properties": { + "body": { + "$ref": "#/components/schemas/EventBody" + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + }, + "required": [ + "Event" + ], + "additionalProperties": false + } + ] + } + ] + }, + "OaBody": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Entity", + "Actor", + "Role", + "Activity", + "Capability", + "Mission" + ] + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "Process": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "steps" + ], + "additionalProperties": false + } + }, + "required": [ + "Process" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Shared": { + "$ref": "#/components/schemas/SharedBody" + } + }, + "required": [ + "Shared" + ], + "additionalProperties": false + } + ] + } + ] + }, + "SaBody": { + "oneOf": [ + { + "type": "string", + "enum": [ + "System", + "Actor", + "Function", + "Capability", + "Mission" + ] + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "FunctionalChain": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "steps" + ], + "additionalProperties": false + } + }, + "required": [ + "FunctionalChain" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Shared": { + "$ref": "#/components/schemas/SharedBody" + } + }, + "required": [ + "Shared" + ], + "additionalProperties": false + } + ] + } + ] + }, + "ElementBody": { + "oneOf": [ + { + "type": "object", + "properties": { + "Oa": { + "$ref": "#/components/schemas/OaBody" + } + }, + "required": [ + "Oa" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Sa": { + "$ref": "#/components/schemas/SaBody" + } + }, + "required": [ + "Sa" + ], + "additionalProperties": false + } + ] + }, + "Element": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parent": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "body": { + "$ref": "#/components/schemas/ElementBody" + } + }, + "required": [ + "id", + "alias", + "name", + "description", + "parent", + "body" + ], + "additionalProperties": false + }, + "RelationBody": { + "oneOf": [ + { + "type": "object", + "properties": { + "Allocation": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "Allocation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CapabilityInvolvement": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "CapabilityInvolvement" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ChainInvolvement": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "ChainInvolvement" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "RoleRealization": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "RoleRealization" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "RoleAssignment": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "RoleAssignment" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "MissionCapability": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "MissionCapability" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Availability": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "Availability" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "ExchangeAllocation": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "ExchangeAllocation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "InterfaceProvision": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "InterfaceProvision" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "InterfaceRequirement": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "InterfaceRequirement" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Interaction": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + }, + "items": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "source", + "target", + "items" + ], + "additionalProperties": false + } + }, + "required": [ + "Interaction" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "BoundaryExchange": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + }, + "items": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "source", + "target", + "items" + ], + "additionalProperties": false + } + }, + "required": [ + "BoundaryExchange" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "FunctionalExchange": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + }, + "source_port": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "target_port": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "items": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "source", + "target", + "source_port", + "target_port", + "items" + ], + "additionalProperties": false + } + }, + "required": [ + "FunctionalExchange" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "StateTransition": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + }, + "trigger": { + "type": "string" + }, + "guard": { + "type": "string" + }, + "effect": { + "type": "string" + }, + "transition_kind": { + "$ref": "#/components/schemas/TransitionKind", + "default": "External", + "description": "Omitted legacy schema-2 value is External." + }, + "triggers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Id" + }, + "uniqueItems": true, + "description": "Same-layer Event or Activity/Function references; alternatives, not implicit conjunction.", + "default": [] + }, + "effects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Id" + }, + "uniqueItems": true, + "description": "Same-layer Activity/Function references describing transition effects.", + "default": [] + } + }, + "required": [ + "source", + "target", + "trigger", + "guard", + "effect" + ], + "additionalProperties": false, + "description": "Existing trigger, guard and effect prose is preserved exactly. New typed properties are optional only for staged schema-2 compatibility. Removing the last typed trigger retains a descriptive unresolved-trigger placeholder instead of silently introducing completion semantics." + } + }, + "required": [ + "StateTransition" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "StateAction": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + }, + "phase": { + "type": "string", + "enum": [ + "Entry", + "Do", + "Exit" + ] + } + }, + "required": [ + "source", + "target", + "phase" + ], + "additionalProperties": false + } + }, + "required": [ + "StateAction" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CapabilityInclude": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "CapabilityInclude" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CapabilityExtend": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + }, + "condition": { + "type": "string", + "description": "Engineering condition under which the extending capability adds behavior." + } + }, + "required": [ + "source", + "target", + "condition" + ], + "additionalProperties": false + } + }, + "required": [ + "CapabilityExtend" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CapabilityGeneralization": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "CapabilityGeneralization" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "MissionInvolvement": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "source", + "target" + ], + "additionalProperties": false + } + }, + "required": [ + "MissionInvolvement" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "SequenceLink": { + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uuid" + }, + "target": { + "type": "string", + "format": "uuid" + }, + "guard": { + "type": "string", + "description": "Condition for a contextual process/chain path. Choice and loop paths should use explicit conditions or else." + } + }, + "required": [ + "source", + "target", + "guard" + ], + "additionalProperties": false + } + }, + "required": [ + "SequenceLink" + ], + "additionalProperties": false + } + ] + }, + "Relation": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "body": { + "$ref": "#/components/schemas/RelationBody" + } + }, + "required": [ + "id", + "alias", + "name", + "description", + "body" + ], + "additionalProperties": false + }, + "NodePlacement": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "element": { + "type": "string", + "format": "uuid" + }, + "position": { + "$ref": "#/components/schemas/Position" + }, + "size": { + "$ref": "#/components/schemas/Size" + }, + "collapsed": { + "type": "boolean" + } + }, + "required": [ + "id", + "alias", + "element", + "position", + "size", + "collapsed" + ], + "additionalProperties": false + }, + "Diagram": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "layer": { + "$ref": "#/components/schemas/Layer" + }, + "projection": { + "type": "string", + "enum": [ + "FunctionalDataflow", + "Capabilities", + "Overview", + "Decomposition", + "Allocation", + "Traceability" + ] + }, + "nodes": { + "type": "object", + "propertyNames": { + "format": "uuid" + }, + "additionalProperties": { + "$ref": "#/components/schemas/NodePlacement" + } + } + }, + "required": [ + "id", + "alias", + "name", + "description", + "layer", + "projection", + "nodes" + ], + "additionalProperties": false + }, + "ArtifactLink": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "owner": { + "type": "string", + "format": "uuid" + }, + "relation": { + "type": "string", + "enum": [ + "References", + "Satisfies", + "Verifies", + "Implements", + "DerivedFrom" + ] + }, + "uri": { + "type": "string", + "description": "Opaque external artifact reference. Server never fetches or executes it." + }, + "revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fragment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "alias", + "name", + "description", + "owner", + "relation", + "uri", + "revision", + "fragment" + ], + "additionalProperties": false + }, + "TracedProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "name", + "description" + ], + "additionalProperties": false + }, + "TraceBaseline": { + "type": "object", + "properties": { + "oa": { + "$ref": "#/components/schemas/TracedProperties" + }, + "sa": { + "$ref": "#/components/schemas/TracedProperties" + } + }, + "required": [ + "oa", + "sa" + ], + "additionalProperties": false + }, + "Trace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "oa": { + "type": "string", + "format": "uuid" + }, + "sa": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string", + "enum": [ + "Refinement", + "Participant", + "Capability", + "Mission", + "Exchange", + "Sequence", + "Item", + "Scenario", + "State", + "Manual" + ] + }, + "baseline": { + "anyOf": [ + { + "$ref": "#/components/schemas/TraceBaseline" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "alias", + "name", + "description", + "oa", + "sa", + "kind", + "baseline" + ], + "additionalProperties": false + }, + "Project": { + "type": "object", + "properties": { + "schema_version": { + "type": "integer", + "const": 2 + }, + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "elements": { + "type": "object", + "propertyNames": { + "format": "uuid" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Element" + } + }, + "relations": { + "type": "object", + "propertyNames": { + "format": "uuid" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Relation" + } + }, + "diagrams": { + "type": "object", + "propertyNames": { + "format": "uuid" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Diagram" + } + }, + "artifact_links": { + "type": "object", + "propertyNames": { + "format": "uuid" + }, + "additionalProperties": { + "$ref": "#/components/schemas/ArtifactLink" + } + }, + "traces": { + "type": "object", + "propertyNames": { + "format": "uuid" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Trace" + } + } + }, + "required": [ + "schema_version", + "id", + "alias", + "name", + "description", + "elements", + "relations", + "diagrams", + "artifact_links", + "traces" + ], + "additionalProperties": false + }, + "StoredProject": { + "type": "object", + "properties": { + "resource_id": { + "type": "string", + "format": "uuid" + }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "model": { + "$ref": "#/components/schemas/Project" + } + }, + "required": [ + "resource_id", + "revision", + "model" + ], + "additionalProperties": false + }, + "ProjectSummary": { + "type": "object", + "properties": { + "resource_id": { + "type": "string", + "format": "uuid" + }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "model_id": { + "type": "string", + "format": "uuid" + }, + "schema_version": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "elements": { + "type": "integer", + "minimum": 0 + }, + "relations": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "resource_id", + "revision", + "model_id", + "schema_version", + "name", + "elements", + "relations" + ], + "additionalProperties": false + }, + "Issue": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": [ + "error", + "warning" + ] + }, + "message": { + "type": "string" + }, + "object_id": { + "type": "string", + "format": "uuid" + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "column": { + "type": "integer", + "minimum": 1 + }, + "code": { + "type": "string" + }, + "field": { + "type": "string" + } + }, + "required": [ + "severity", + "message", + "code" + ], + "additionalProperties": false + }, + "Problem": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "current_revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + } + }, + "required": [ + "type", + "title", + "status", + "code", + "detail" + ], + "additionalProperties": false + }, + "NameRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "Command": { + "oneOf": [ + { + "type": "object", + "properties": { + "Replace": { + "$ref": "#/components/schemas/Project" + } + }, + "required": [ + "Replace" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Batch": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + } + } + }, + "required": [ + "Batch" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Add": { + "$ref": "#/components/schemas/Kind" + } + }, + "required": [ + "Add" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "AddElement": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/Kind" + }, + "layer": { + "$ref": "#/components/schemas/Layer" + }, + "name": { + "type": "string" + }, + "parent": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "layer", + "name", + "parent" + ], + "additionalProperties": false + } + }, + "required": [ + "AddElement" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Connect": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/components/schemas/RelationKind" + }, + { + "type": "string" + }, + { + "type": "string", + "format": "uuid" + }, + { + "type": "string", + "format": "uuid" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + "required": [ + "Connect" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "EditProject": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Unique persistent alias; core reserved words are forbidden." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "alias", + "name", + "description" + ], + "additionalProperties": false + } + }, + "required": [ + "EditProject" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Reparent": { + "type": "object", + "properties": { + "element": { + "type": "string", + "format": "uuid" + }, + "parent": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "element", + "parent" + ], + "additionalProperties": false + } + }, + "required": [ + "Reparent" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Reidentify": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "string", + "format": "uuid" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "Reidentify" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Rename": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "string" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "Rename" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Describe": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "string" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "Describe" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "SetAlias": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "string" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "SetAlias" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Delete": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "Delete" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "AddStep": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "string", + "format": "uuid" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "AddStep" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "RemoveStep": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "integer", + "minimum": 0 + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "RemoveStep" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "MoveStep": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "integer", + "minimum": 0 + }, + { + "type": "integer", + "minimum": 0 + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + "required": [ + "MoveStep" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "SetSteps": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "SetSteps" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Layout": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "Layout" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CreateNode": { + "type": "object", + "properties": { + "diagram": { + "type": "string", + "format": "uuid" + }, + "node": { + "$ref": "#/components/schemas/NodePlacement" + } + }, + "required": [ + "diagram", + "node" + ], + "additionalProperties": false + } + }, + "required": [ + "CreateNode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "EditNode": { + "type": "object", + "properties": { + "diagram": { + "type": "string", + "format": "uuid" + }, + "original": { + "type": "string", + "format": "uuid" + }, + "node": { + "$ref": "#/components/schemas/NodePlacement" + } + }, + "required": [ + "diagram", + "original", + "node" + ], + "additionalProperties": false + } + }, + "required": [ + "EditNode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "RelocateNode": { + "type": "object", + "description": "Move an existing placement to another diagram while preserving its UUID, alias, geometry, collapse state and dependent artifact references. The target diagram must accept the element and must not already contain its placement.", + "properties": { + "node": { + "type": "string", + "format": "uuid" + }, + "diagram": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "node", + "diagram" + ], + "additionalProperties": false + } + }, + "required": [ + "RelocateNode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "MoveNode": { + "type": "object", + "properties": { + "diagram": { + "type": "string", + "format": "uuid" + }, + "node": { + "type": "string", + "format": "uuid" + }, + "position": { + "$ref": "#/components/schemas/Position" + }, + "with_descendants": { + "type": "boolean" + } + }, + "required": [ + "diagram", + "node", + "position", + "with_descendants" + ], + "additionalProperties": false + } + }, + "required": [ + "MoveNode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CreateElement": { + "$ref": "#/components/schemas/Element" + } + }, + "required": [ + "CreateElement" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "EditElement": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "$ref": "#/components/schemas/Element" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "EditElement" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CreateRelation": { + "$ref": "#/components/schemas/Relation" + } + }, + "required": [ + "CreateRelation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "EditRelation": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "$ref": "#/components/schemas/Relation" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "EditRelation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CreateDiagram": { + "$ref": "#/components/schemas/Diagram" + } + }, + "required": [ + "CreateDiagram" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "EditDiagram": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "$ref": "#/components/schemas/Diagram" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "EditDiagram" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CreateArtifact": { + "$ref": "#/components/schemas/ArtifactLink" + } + }, + "required": [ + "CreateArtifact" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "EditArtifact": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "$ref": "#/components/schemas/ArtifactLink" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "EditArtifact" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "CreateTrace": { + "$ref": "#/components/schemas/Trace" + } + }, + "required": [ + "CreateTrace" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "EditTrace": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "format": "uuid" + }, + { + "$ref": "#/components/schemas/Trace" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "EditTrace" + ], + "additionalProperties": false + } + ] + }, + "TransactionRequest": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + }, + "minItems": 1, + "maxItems": 200 + }, + "dry_run": { + "type": "boolean", + "default": false + } + }, + "required": [ + "commands" + ], + "additionalProperties": false + }, + "TransactionResult": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "dry_run": { + "type": "boolean" + }, + "changed": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + }, + "model": { + "$ref": "#/components/schemas/Project" + } + }, + "required": [ + "revision", + "dry_run", + "changed", + "issues" + ], + "additionalProperties": false + }, + "MutationResult": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "changed": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + } + }, + "required": [ + "revision", + "changed" + ], + "additionalProperties": false + }, + "ValidationRequest": { + "oneOf": [ + { + "type": "object", + "properties": { + "format": { + "const": "json" + }, + "model": { + "$ref": "#/components/schemas/Project" + } + }, + "required": [ + "format", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "format": { + "const": "arcadia" + }, + "source": { + "type": "string" + } + }, + "required": [ + "format", + "source" + ], + "additionalProperties": false + } + ] + }, + "ValidationResult": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "valid": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + } + }, + "required": [ + "revision", + "valid", + "issues" + ], + "additionalProperties": false + }, + "NeighborhoodRequest": { + "type": "object", + "properties": { + "roots": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 100 + }, + "direction": { + "type": "string", + "enum": [ + "incoming", + "outgoing", + "both" + ], + "default": "both" + }, + "relation_kinds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RelationKind" + } + }, + "max_depth": { + "type": "integer", + "minimum": 0, + "maximum": 4, + "default": 1 + }, + "max_objects": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + } + }, + "required": [ + "roots" + ], + "additionalProperties": false + }, + "NeighborhoodResult": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Element" + } + }, + "relations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Relation" + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "revision", + "elements", + "relations", + "truncated" + ], + "additionalProperties": false + }, + "ElementPage": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Element" + } + }, + "next_after": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "revision", + "items", + "next_after" + ], + "additionalProperties": false + }, + "RelationPage": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Relation" + } + }, + "next_after": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "revision", + "items", + "next_after" + ], + "additionalProperties": false + }, + "ArtifactPage": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ArtifactLink" + } + }, + "next_after": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "revision", + "items", + "next_after" + ], + "additionalProperties": false + }, + "TracePage": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 1, + "description": "Current revision counter; use ETag as the opaque write validator." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trace" + } + }, + "next_after": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "revision", + "items", + "next_after" + ], + "additionalProperties": false + }, + "ProjectPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectSummary" + } + }, + "next_after": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "next_after" + ], + "additionalProperties": false + }, + "TransitionDirection": { + "type": "string", + "enum": [ + "OaToSa", + "SaToOa" + ] + }, + "TransitionField": { + "type": "string", + "enum": [ + "Name", + "Description" + ] + }, + "TransitionMapping": { + "type": "object", + "properties": { + "source": { + "$ref": "#/components/schemas/Id" + }, + "target": { + "anyOf": [ + { + "$ref": "#/components/schemas/Id" + }, + { + "type": "null" + } + ] + }, + "target_kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/Kind" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "source", + "target", + "target_kind" + ], + "additionalProperties": false + }, + "FieldDecision": { + "oneOf": [ + { + "type": "string", + "enum": [ + "TakeSource", + "KeepTarget" + ] + }, + { + "type": "object", + "properties": { + "Value": { + "type": "string" + } + }, + "required": [ + "Value" + ], + "additionalProperties": false + } + ] + }, + "FieldResolution": { + "type": "object", + "properties": { + "source": { + "$ref": "#/components/schemas/Id" + }, + "field": { + "$ref": "#/components/schemas/TransitionField" + }, + "decision": { + "$ref": "#/components/schemas/FieldDecision" + } + }, + "required": [ + "source", + "field", + "decision" + ], + "additionalProperties": false + }, + "TransitionRequest": { + "type": "object", + "properties": { + "direction": { + "$ref": "#/components/schemas/TransitionDirection" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Id" + }, + "maxItems": 100 + }, + "scope": { + "type": "string", + "enum": [ + "SelectedOnly", + "Required", + "Subtree" + ] + }, + "mappings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransitionMapping" + }, + "maxItems": 200 + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransitionField" + } + }, + "resolutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldResolution" + }, + "maxItems": 400 + }, + "destination_diagram": { + "anyOf": [ + { + "$ref": "#/components/schemas/Id" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "direction", + "sources", + "scope", + "mappings", + "fields", + "resolutions", + "destination_diagram" + ], + "additionalProperties": false + }, + "FieldChange": { + "type": "object", + "properties": { + "field": { + "$ref": "#/components/schemas/TransitionField" + }, + "before": { + "type": "string" + }, + "after": { + "type": "string" + } + }, + "required": [ + "field", + "before", + "after" + ], + "additionalProperties": false + }, + "TransitionChange": { + "type": "object", + "properties": { + "source": { + "$ref": "#/components/schemas/Id" + }, + "target": { + "$ref": "#/components/schemas/Id" + }, + "action": { + "type": "string", + "enum": [ + "Create", + "Update", + "Reuse" + ] + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldChange" + } + } + }, + "required": [ + "source", + "target", + "action", + "fields" + ], + "additionalProperties": false + }, + "TransitionConflict": { + "type": "object", + "properties": { + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/Id" + }, + { + "type": "null" + } + ] + }, + "target": { + "anyOf": [ + { + "$ref": "#/components/schemas/Id" + }, + { + "type": "null" + } + ] + }, + "field": { + "anyOf": [ + { + "$ref": "#/components/schemas/TransitionField" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "source", + "target", + "field", + "message" + ], + "additionalProperties": false + }, + "TransitionPlan": { + "type": "object", + "properties": { + "base_fingerprint": { + "type": "string" + }, + "request": { + "$ref": "#/components/schemas/TransitionRequest" + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransitionChange" + } + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransitionConflict" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "commands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + } + } + }, + "required": [ + "base_fingerprint", + "request", + "changes", + "conflicts", + "warnings", + "commands" + ], + "additionalProperties": false + }, + "TransitionEnvelope": { + "type": "object", + "properties": { + "base_revision": { + "$ref": "#/components/schemas/Revision" + }, + "plan": { + "$ref": "#/components/schemas/TransitionPlan" + } + }, + "required": [ + "base_revision", + "plan" + ], + "additionalProperties": false + }, + "ControlKind": { + "type": "string", + "enum": [ + "Choice", + "Merge", + "Fork", + "Join", + "Loop" + ] + }, + "ScenarioViewpoint": { + "type": "string", + "enum": [ + "OperationalActivity", + "ParticipantInteraction", + "Functional", + "BoundaryInterface", + "MixedLegacy" + ] + }, + "MessageKind": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Asynchronous", + "Call", + "Reply", + "Create", + "Delete" + ] + }, + { + "type": "object", + "properties": { + "Timer": { + "type": "object", + "properties": { + "expression": { + "type": "string" + } + }, + "required": [ + "expression" + ], + "additionalProperties": false + } + }, + "required": [ + "Timer" + ], + "additionalProperties": false + } + ] + }, + "MessageValue": { + "type": "object", + "properties": { + "field": { + "type": "string", + "format": "uuid" + }, + "expression": { + "type": "string" + } + }, + "required": [ + "field", + "expression" + ], + "additionalProperties": false + }, + "LifelineBinding": { + "type": "object", + "properties": { + "referenced": { + "type": "string", + "format": "uuid" + }, + "local": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "referenced", + "local" + ], + "additionalProperties": false + }, + "StateKind": { + "type": "string", + "enum": [ + "State", + "Mode", + "Initial", + "Final", + "Choice", + "Junction", + "Fork", + "Join", + "ShallowHistory", + "DeepHistory", + "EntryPoint", + "ExitPoint", + "Terminate" + ] + }, + "TimeEventKind": { + "type": "string", + "enum": [ + "After", + "At" + ], + "description": "After is relative to source-state activation; At describes an absolute instant. Expressions are descriptive, not executed." + }, + "TransitionKind": { + "type": "string", + "enum": [ + "External", + "Local", + "Internal" + ] + }, + "EventBody": { + "oneOf": [ + { + "type": "object", + "properties": { + "Signal": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Id" + }, + "uniqueItems": true, + "description": "Same-layer ExchangeItem identities carried by the signal." + } + }, + "required": [ + "items" + ], + "additionalProperties": false + } + }, + "required": [ + "Signal" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Time": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/TimeEventKind" + }, + "expression": { + "type": "string" + } + }, + "required": [ + "kind", + "expression" + ], + "additionalProperties": false + } + }, + "required": [ + "Time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Change": { + "type": "object", + "properties": { + "expression": { + "type": "string" + } + }, + "required": [ + "expression" + ], + "additionalProperties": false + } + }, + "required": [ + "Change" + ], + "additionalProperties": false + } + ] + } + } + } +} diff --git a/docs/research-v2-architecture.md b/docs/research-v2-architecture.md new file mode 100644 index 00000000..4a94c2b8 --- /dev/null +++ b/docs/research-v2-architecture.md @@ -0,0 +1,322 @@ +# V2 architecture research and proposed extension + +Status: implementation proposal, 2026-09-05. This document maps the current source and recommends a concrete OA/SA extension. It does not certify that V2 behavior exists, change the current normative language contract, or claim Capella interchange/conformance. No source code, harness, builds, or GUI sessions were changed for this investigation. + +The investigation began with `harness/STATE.md`, `harness/PLAN.md`, `harness/DECISIONS.md`, the architecture review, README, and `docs/{scope,architecture,acceptance,validation}.md`, followed by the normative `docs/language.rst` and core/desktop implementation and tests. Domain names below describe a proposed bounded dialect; the accompanying method research should settle precise OA/SA terminology before the V2 specification is frozen. + +## Recommended shape + +Keep one typed Rust project and one validated transaction boundary. Extend the existing core with layer-specific records, explicit semantic containment, separate diagram placements, stable stored aliases, artifact links, and first-class OA/SA traces. Both editors and a small REST service must use the same commands, transition planner, source analyzer, and validator. + +The smallest useful implementation is an extension of the current two-crate design plus a server adapter. It does not need a second metamodel, automatic continuous synchronization between layers, a general-purpose graph database, a runtime metamodel/plugin framework, or a physical deployment model disguised as System Analysis. Larger modules can be split when the concrete records below arrive. + +## Current implementation and concrete fault lines + +| Existing seam | What is implemented | What V2 must change | +|---|---|---| +| `crates/oa-core/src/model.rs:11` | Five closed OA element kinds; layer is implicit. | Use an explicit OA/SA kind distinction so a System Function cannot accidentally become an Operational Activity with an arbitrary layer tag. | +| `model.rs:54` | Every element has one absolute position and a `steps` vector, including kinds that cannot use steps. | Separate semantic ownership from placements; move process/chain steps into typed bodies. | +| `model.rs:78` | Relation admissibility is a predicate of two kinds only. | Check layer, ownership, endpoint roles, allocation cardinality, and any exchange/port contract in shared semantic functions. | +| `model.rs:118` | Project has only two UUID maps; strict decoding rejects unknown fields. | Introduce a schema-2 DTO and explicit schema-1 migration. New fields cannot simply be added and expected to load old JSON. | +| `session.rs:33,40` | `Session.project` is publicly mutable and `Session::new` trusts its caller. | Make accepted state private; `try_new` validates. This becomes necessary once REST and multiple frontends are callers. | +| `session.rs:70` | Clone, mutate, validate, adopt, and snapshot history form one atomic command. | Preserve this behavior; add batch preparation/commit, structured results, revision preconditions, and transition plans. | +| `session.rs:148,203` | Delete knows incident relations and process steps; reidentification knows endpoints and process-step IDs. | Cover containment, role realization, exchange items, lifelines, occurrences, state machines, traces, artifacts, and placements through one explicit reference visitor/index. | +| `session.rs:170` | Arrange groups every project element into five global kind columns. | Layout the active view and visible hierarchy; moving/arranging must not reposition a different layer or hidden diagram. | +| `validation.rs:13` | Structural errors and incomplete-model warnings share one pass. Allocation is direct and OA-only; process continuity compares adjacent exchange endpoints. | Add stable diagnostic codes/object/field paths, layer-aware rules and hierarchy-aware warnings. Preserve repeated process occurrences and warning-only incomplete models. | +| `language.rs:39,109` | Printer regenerates aliases from names in UUID-map order, with collision suffixes. | Persist aliases. A rename currently rewrites references and can renumber unrelated same-name aliases; a smaller UUID insertion can also change collision assignments. | +| `language.rs:27,203,401` | Lexer/parser are private; parsing usually returns the first error, source locations are starts only, comments are dropped. | Expose tolerant token spans and a noncommitting analysis result for highlighting/linting; keep strict Apply separate. | +| `persistence.rs:15,47` | Content-based text/JSON loading, shared validation, sibling-temp atomic save. | Version dispatch, deterministic migration, read/write conflict fingerprints, and server repository commit semantics. Atomic replacement alone does not detect external modifications. | +| `oa-desktop/src/app.rs:614,769,846,853,959` | One selection ID, one property draft, one source buffer, one camera; accepted commands regenerate canonical text. | Preserve single-draft ownership, add typed selection/context and source baseline revisions, and use command-result remaps instead of selected-ID-specific fixups. | +| `app.rs:1044`, `views.rs:617` | Pickers filter kinds; inspector handles only project/element/relation. | Use core candidate queries for legal parents/endpoints/allocations; add graphical controls for every new mutable record field. | +| `views.rs:329` | Monospaced text editor and Apply diagnostics; no highlighting or live linting. | Highlight the actual language and display debounced diagnostics without committing the draft. | +| `diagram.rs:10,31,322`, `app.rs:1532` | Fixed-size flat cards, reverse-map hit testing, global Fit and global SVG export. | Resolve active-view bounds, paint parents before children, hit-test deepest visible nodes, and share a view projection between canvas, Fit, and SVG. | + +The existing test suite supplies valuable foundations: exact parse/print equality and canonical idempotence, exact strings and floating-point coordinates, parallel interactions, repeated ordered steps, rejected-edit history preservation, identity remapping, strict JSON, atomic saves, and desktop draft/identity guards. The repository records 50 tests in the completed MVP; this investigation read the tests and did not rerun them. None of that historical evidence demonstrates V2 behavior. + +## Typed schema-2 records + +Retain UUID identity and ordered maps. Avoid opaque `properties: Map` as the representation of new semantics. The following is an API sketch, not compilable Rust or a finalized JSON grammar: + +```rust +enum Layer { Operational, System } +enum ElementKind { Operational(OaKind), System(SaKind) } +enum OaKind { Entity, Actor, Role, Activity, Capability, Process } +enum SaKind { System, Actor, Function, Capability, Mission, FunctionalChain } + +struct Header { id: Id, alias: Alias, name: String, description: String } +struct Element { + header: Header, + parent: Option, // semantic decomposition, not performer + body: ElementBody, // kind derived from this closed enum +} +enum ElementBody { + OperationalEntity, OperationalActor, OperationalRole, + OperationalActivity, OperationalCapability, + OperationalProcess { steps: Vec }, + System, SystemActor, SystemFunction, SystemCapability, SystemMission, + FunctionalChain { steps: Vec }, +} +struct ExchangeOccurrence { id: Id, alias: Alias, exchange: Id } +struct Relation { header: Header, body: RelationBody } + +struct Project { + schema_version: u32, + header: Header, + elements: BTreeMap, + relations: BTreeMap, + exchange_items: BTreeMap, + scenarios: BTreeMap, + state_machines: BTreeMap, + traces: BTreeMap, + artifact_links: BTreeMap, + diagrams: BTreeMap, +} +``` + +`ElementBody` is the source of its kind and layer. Do not store both `kind` and an independent contradictory body/layer. Existing method calls can expose `kind()` and `layer()` so UI matches remain exhaustive. An SA project may be incomplete: no system yet is a warning; more than one system of interest in the project's single SA analysis is an error. SA actors are outside that system boundary. Do not introduce logical components, physical nodes, or deployment as SA kinds. + +Use `ObjectRef` with explicit variants for project, element, relation, exchange item, scenario, occurrence, state machine/state/transition, trace, and diagram as needed. IDs still occupy one global namespace, including occurrences and diagram nodes; references also check their expected category. This makes artifact targets and diagnostic focus precise without making all ordinary relations accept arbitrary endpoints. Plain typed helpers (`visit_refs`, `remap_refs`, `dependencies`) suffice; a generalized runtime reflection system is unnecessary. + +### Containment and allocation + +Persist one `parent` reference per semantic element, with children derived. Do not persist both children lists and parent pointers. Canonical declarations remain flat with `parent alias;`, so moving a subtree changes one ownership line rather than indenting and relocating every descendant. The graphical explorer/canvas still presents a hierarchy. + +Recommended initial containment matrix: + +| Child | Allowed semantic parent | +|---|---| +| OA entity | OA entity, or layer root | +| OA actor | OA entity, or layer root; actor remains a distinct participant kind | +| OA activity | OA activity, or layer root | +| OA capability | OA capability, or layer root | +| OA role/process | Layer root initially; involvement is represented by relations | +| SA function | SA function, or layer root | +| SA capability | SA capability, or layer root | +| SA system/actor/mission/chain | Layer root initially | + +These are deliberately explicit product rules to reconcile with the method research. Adding an allowed parent is a small validator/spec change. Allowing every kind under every kind would lose meaningful validation. Cross-layer parents, self-parenting, multiple ownership, and cycles reject a transaction. Empty roots and incomplete allocations remain editable. Neither graphical overlap nor dragging inside a card silently changes semantic parent or performer: a dedicated nest/reparent/allocate action previews the semantic edit. + +Allocation is independent of decomposition. An activity may be decomposed into activities performed by different operational participants. A function's decomposition is independent of whether the system or an external actor performs it. Persist direct allocations only. The first release should not infer or materialize allocation inheritance: a parent allocation does not silently allocate descendants. Diagnose unallocated leaves; label any aggregate coverage as derived. If later requirements demand inheritance, define effective-performer resolution and explicit override rules before changing this behavior. + +Keep at most one direct performer per OA activity and SA function. OA roles organize responsibility through explicit participant-to-role realization and role-to-activity assignment; validate that resolving an assigned role does not create contradictory direct performers. A role may be unfilled or filled by multiple participants as a warning/incomplete modeling state only if the chosen dialect permits it; do not silently choose the first participant. The simplest initial policy is to keep role assignment separate from the existing authoritative direct allocation and report any mismatch. This preserves schema-1 meaning and avoids inventing implicit execution semantics. + +### Layer-specific relationships + +Use typed `RelationBody` variants with meaningful field names, even when several variants are stored in the same map. Each variant defines its live references, layer rule, and allowed endpoint query in core. + +| Relationship family | Endpoints and principal constraints | +|---|---| +| OA allocation | OA entity/actor → OA activity; one direct performer per activity | +| OA interaction | OA activity → OA activity; same layer, distinct endpoints; parallel distinct-ID exchanges remain legal | +| OA contribution / process realization | Preserve existing activity → capability and process → capability semantics; label these as the existing dialect's OA support links | +| Role realization / role assignment | OA entity/actor → OA role; OA role → OA activity; independent of containment | +| Capability involvement | OA participant → OA capability, or SA system/actor → SA capability; never an accidental cross-layer ordinary relation | +| SA allocation | SA system/actor → SA function; one direct performer per function | +| SA functional exchange | SA function → SA function; same layer, distinct endpoints; carries exchange-item references | +| SA capability support | SA function/functional chain → SA capability; distinct variant from an OA↔SA trace | +| SA mission capability link | SA mission → SA capability; exact label should follow the method vocabulary adopted for V2 | +| Exchange item carriage | OA interaction or SA functional exchange → same-layer exchange item; no deduplication that merges parallel exchanges | +| State/mode availability | Same-layer element → state/mode belonging to a relevant context; stored as an explicit typed association | + +Use distinct names for process-to-capability realization, function-to-activity traceability, and external requirement satisfaction. The existing `Realization` enum must not become a catch-all for unrelated semantics. Direction and admissibility must be testable without GUI code. + +### Practical behavioral records + +Add the requested practical fields as explicit records rather than free-form descriptions: + +* `ExchangeItem { header, layer, item_kind, fields: Vec }`, with stable field IDs/aliases, field name, a bounded type reference (primitive or another item), multiplicity bounds, and unit/description where needed. Use cases include information and material; do not fabricate a physical communication protocol. Validate field references, multiplicity, and item layer. A self-referential data type can be permitted explicitly without confusing it with forbidden containment cycles. +* `Scenario { header, layer, context: ObjectRef, lifelines, steps }`. Lifelines have stable IDs and reference same-layer participants or supported behavior elements. Each ordered occurrence has its own ID; exchange occurrences identify the exchange and sending/receiving lifelines; action occurrences identify the activity/function and lifeline; state observations reference a defined mode/state. Repeated exchanges remain separate occurrences. Bounded `Sequence`, `Alternative`, `Optional`, `Loop`, and `Parallel` fragments contain ordered branches, each with a guard and child occurrences. Guards/events remain expressions or text in a specified limited language; there is no claim of executable simulation merely because a scenario is drawn. +* `StateMachine { header, layer, owner, states, transitions }`. State/mode records have stable IDs, an explicit `State` versus `Mode` kind, parent/region membership, initial marker and entry/exit/do text. Transitions have source, target, trigger, guard, and effect. Start with hierarchical exclusive regions: at most one initial child per region, absence is a warning, cross-machine endpoints are errors. Orthogonal regions and executable guard semantics require an explicit later contract rather than pretending arbitrary nesting implements them. + +Scenario ordering and state-machine transitions are different structures. An ordered OA process/SA chain remains a lightweight exchange sequence and can coexist with richer scenarios. Do not coerce every scenario into the legacy `Element.steps` vector. GUI editors must be able to create/reorder repeated occurrences and edit all fragment/branch/guard, lifeline, state, and transition fields that source accepts. + +### Diagram records + +Introduce at least independent OA and SA views and a mixed traceability view. The smallest concrete record is `Diagram { header, kind, nodes: BTreeMap }`, with `NodePlacement { id, alias, element: Id, position: Position, size: Size, collapsed: bool }`. Initially permit at most one placement per element per diagram. The diagram kind is a closed enum naming its layer and projection (overview, decomposition, allocation, or mixed traceability); a second independently contradictory layer flag is unnecessary. Edge endpoints/routes are derived, so no edge-layout map is necessary until route editing is actually supported. The same element may appear in different views without duplicated semantics. Keep positions absolute in diagram coordinates initially to preserve exact existing values and avoid coordinate-frame ambiguity. + +A decomposition presentation derives visible container relationships from semantic parents; an allocation presentation derives performer/activity or system/function grouping from allocation relations. These are different projections of the same records, with fixed documented rules per diagram kind. No visual parent pointer is persisted. Moving a visible container translates its visible descendants in that view in one transaction. Collapsing hides descendants without deleting semantic records. Exact X/Y/width/height and collapse controls are available in the inspector and text. Fit and SVG use the same visible projection and container bounds as the canvas. Selection should distinguish semantic object and diagram occurrence, so changing an element's name affects all views while moving a node affects its selected view. Arbitrary grouping, editable routing, and a plugin view framework can wait. + +## Artifact links without vendor coupling + +Use a first-class record: + +```rust +struct ArtifactLink { + header: Header, + owner: ObjectRef, + relation: ArtifactRelation, // references, satisfies, verifies, implements, + // derived_from, or explicitly labeled custom + uri: String, + revision: Option, + fragment: Option, + rationale: String, +} +``` + +Store the exact URI, revision, and fragment supplied by the user. A revision is an opaque external version/commit/tag, not a project revision number or a Git-only SHA. Support arbitrary well-formed URI schemes and documented relative URI references resolved against project location. No HTTP-only restriction, Windows path coercion, lowercasing, eager dereferencing, or URL normalization should change identity. A native path may be accepted by a UI convenience action and converted explicitly to a `file:` URI. + +The validator checks syntax, text limits, and internal owner existence; external reachability is an optional nonblocking diagnostic. Loading, linting, previewing, and displaying a model must not fetch links. Opening a supported URI is a separate user action; unfamiliar schemes can remain copyable even if no opener is registered. This supports requirement tools, source repositories, documents, CAD/data artifacts, and proprietary systems without baking a provider into the metamodel. + +## Bidirectional transitions and traceability + +A transition is an explicit modeling operation that can create or update selected counterpart records in either direction. It does not change an existing object's layer/kind in place or imply that OA and SA are identical. Operational activities can be refined into several functions, and several operational concepts can support one system concept. + +Store traces canonically as `oa: ObjectRef` and `sa: ObjectRef`, independent of whether the user initiated OA→SA or SA→OA. A trace includes ID/alias/name, a typed mapping kind, rationale, and a last-reviewed synchronization baseline. Endpoint-kind rules allow appropriate element pairs, exchange pairs, process/chain pairs, item pairs, and scenario pairs. A generic explanatory trace may connect other supported pairs but does not make them eligible for automatic copying. Many-to-many semantic traceability is valid; duplicate identical mapping links should be rejected or explicitly distinguished by purpose. + +Minimum transition mappings: + +| Source/target pair | Creation and update behavior | +|---|---| +| OA activity ↔ SA function | Copy selected name/description fields; create/reuse counterpart and trace. Decomposition can be included using an explicit parent mapping. | +| OA participant ↔ SA actor or system | OA→SA requires the user to select system-of-interest versus external-actor treatment. Reverse mapping requires a chosen OA entity/actor kind; do not guess from labels. | +| OA capability ↔ SA capability | Copy selected descriptive fields and create/reuse trace; support/involvement relations require mapped endpoints. | +| OA interaction ↔ SA functional exchange | Require or explicitly create both endpoint mappings; items map by selected item traces; preserve parallel exchange identities. | +| OA process ↔ SA functional chain | Map each ordered occurrence, preserving order and repetition; unresolved exchange mappings block that proposed sequence, not the entire ability to model. | +| OA exchange item ↔ SA exchange item | Copy selected fields through an explicit item/type mapping; report unsupported types rather than dropping them. | +| OA scenario ↔ SA scenario | Map context, lifelines and occurrences/fragments through existing/selected traces; unresolved participants or behavior are shown as plan conflicts. | +| Modes/states and roles | Trace and manual mapping are always possible where valid. Automatic conversion needs an explicit mapping rule; OA roles do not automatically become SA components. | + +### Preview/commit algorithm + +1. `plan_transition(project, request)` reads an accepted snapshot and selected source IDs, direction, mapping choices, fields to copy, dependency scope, and requested destination view. It never mutates the active model. +2. Resolve eligible traces first. For one-to-many matches, require explicit selected targets or create-new choice. Display create, reuse, update, skipped, and conflicted records separately; never match solely by name. +3. Expand only the chosen dependency closure. For example, an exchange needs mapped endpoints, and a chain needs its mapped exchanges. Do not silently migrate the entire model or relocate an existing counterpart's semantic parent. +4. Produce proposed typed commands with new IDs/aliases generated once, source/destination field changes, trace creation/updates, unresolved choices, and structural diagnostics. Preview includes the base revision and a plan fingerprint. Retry uses the same planned IDs; apply does not regenerate a fresh duplicate model. +5. Commit verifies the base revision and accepted plan fingerprint, revalidates the complete candidate, and adopts the entire batch as one history operation. Any unresolved error, stale plan, or persistence failure rejects without changing accepted state. A repeated successful API request with the same idempotency key returns its original result. +6. Show both layers and their traces after commit. Reverse transition uses the same planner and canonical trace orientation. Repeated planning of an unchanged, already-linked projection proposes no duplicate creation or meaningless history entry. + +### Drift and conflicts + +Persist the last explicitly accepted pair of synchronization projections on an eligible trace. Projections contain only mapped semantic fields and typed references; aliases, layout, timestamps, and unrelated properties do not cause propagation. Baselines are historical values, not extra live domain objects. They are serialized, inspectable in text and GUI, and updated only by accepting a transition/reconciliation operation. A copied status flag alone cannot detect drift. + +Compare current OA and SA projections against their respective baselines. If neither changed, there is no update. A source-only change can propose copying the selected fields. A destination change, changes on both sides, missing baseline, changed mapping cardinality, or unsupported conversion requires a review choice per affected field/relationship. Present preserve destination, take proposed source, or supply an explicit merged value. Do not silently overwrite the destination because its trace happens to exist. A coarse pair of hashes is sufficient for conservative whole-object conflicts, but structured baseline fields enable a useful property diff and safe independent-field merging. + +One-to-many mappings do not imply broadcasting names, deleting surplus targets, or reversing a decomposition into a flat record. A transition may offer such changes only as visible, selected plan operations. Deleting an endpoint requires an impact plan for its traces; it must not automatically delete a counterpart in another layer. Removing a trace simply unlinks the objects. Reidentification rewrites live references and uses an explicit remap policy for baseline references; historical values must not masquerade as dangling live references. + +## Shared command and API boundary + +Extend `Session` incrementally rather than placing transition semantics in `app.rs`: + +```rust +struct CommandEnvelope { + expected_revision: Revision, + request_id: RequestId, + commands: Vec, +} +struct ChangeResult { + revision: Revision, + created: Vec, + changed: Vec, + deleted: Vec, + identity_remap: BTreeMap, + diagnostics: Vec, +} +// prepare applies to a candidate; commit adopts only that verified candidate. +fn prepare(&self, envelope: CommandEnvelope) -> Result; +fn commit(&mut self, prepared: PreparedChange) -> Result; +``` + +Add concrete create/edit commands for new records, `Reparent`, `Allocate`, occurrence insert/reorder/remove, diagram placement changes, trace/artifact CRUD, `Replace`, and transition-plan application. Whole-document Replace is still one candidate transaction. Use IDs for ordered occurrences; stale remote indices should not accidentally reorder a different step. Structural checks occur after the whole batch, so creating a child and parent or remapping all relations does not require a temporarily valid intermediate model. + +Make session state private and validate every accepted entry point. Keep revision and runtime history outside the persistent model to avoid one Git diff line changing on every command. An accepted revision changes for undo/redo too. Undo/redo remain session-local committed model operations; a shared server must not let one client unknowingly undo another client's work. The initial backend can expose one coordinated writer/session with revision checks instead of claiming collaborative multi-user undo. + +Deletion needs a core impact query. Offer explicit subtree-delete versus reparent-children behavior, show removed incident relations/occurrences/traces/artifact links/view nodes, and preserve other-layer counterparts. Both GUI and REST submit the selected policy. Text replacement naturally expresses the final candidate but receives the same dangling-reference errors. One shared reference traversal powers impact, reidentify, validation, trace queries, and test fixtures. + +### REST adapter + +Add `crates/oa-server` around core/application services. Desktop defaults to in-process calls; when connected to a server it uses that server's accepted model and revisions rather than independently mutating a duplicate authoritative session. Core must not depend on HTTP, Iced, sockets, dialogs, or provider APIs. + +Suggested initial routes: + +| Route | Contract | +|---|---| +| `GET /v1/projects/{id}` | Schema-2 snapshot and ETag/current revision | +| `GET /v1/projects/{id}/source` | Canonical `.arcadia` representation and same accepted revision | +| `POST /v1/analyze` | Noncommitting text analysis, tokens/diagnostics and optional candidate summary | +| `POST /v1/projects/{id}/commands` | Typed command batch, revision precondition, idempotency key; structured change result | +| `PUT /v1/projects/{id}/source` | Parse and Replace with revision precondition; same validators and history | +| `POST /v1/projects/{id}/transitions/preview` | Direction, source selection, explicit mapping/field choices → concrete plan | +| `POST /v1/projects/{id}/transitions/apply` | Plan/fingerprint, choices, revision and idempotency key → atomic change | +| `GET /v1/projects/{id}/diagnostics` | Stable codes, severity, ObjectRef/field, related references | +| `GET /v1/projects/{id}/traces?object=...` | Both incoming and outgoing traceability, drift status, counterpart references | + +Use structured serialization and publish an OpenAPI contract/example requests alongside the normative modeling specification. Invalid syntax is 400; invalid semantic candidates can use 422; stale revisions are 409/412 according to the documented conditional-request contract; missing projects/objects are 404. Never return a success status for a partial transition. Service revisions and external artifact revisions are distinct fields/types. + +A single-file repository is sufficient initially. Hold a project write lock while checking revision, preparing, atomically persisting the candidate, and adopting it; all fallible validation/serialization occurs before adoption. A failed write leaves accepted memory/history unchanged. Crash after a successful disk replace reloads the persisted candidate. Retain an external-file fingerprint on load/save and reject stale overwrites instead of relying on `temp.persist` alone. Do not let a backend request provide an arbitrary host filesystem destination; it acts on configured project IDs/locations. Local-only binding can be the initial launch mode; remote deployment/authentication is a separate configured service capability. + +## Language, stable diffs, highlighting, and linting + +Schema 2 must persist aliases for elements, relations, traces, artifacts, diagrams and other source-addressable records. UUID remains identity; alias is an explicit human-readable property. Generate an alias once at creation and keep it across rename, reparent, layout, and layer transitions. Renaming a display name must change that string only. Renaming an alias is an intentional refactoring that updates printed references; UUID references internally remain stable. + +Use a project-wide alias namespace initially. It avoids ambiguous references, path-dependent aliases that change when reparenting, and special quoting of cross-layer references. New generated collisions use a stable UUID-derived suffix; never rename existing objects to make room for an insertion. A user-supplied duplicate alias is an error. Keep UUID-based declaration ordering initially: it is stable on ordinary edits and does not reorder blocks when names/aliases change. Separate semantic records, links/traces, and diagram sections, with fixed field order and one independently editable property per line. + +The conceptual source shape is: + +```text +arcadia 2; +project mission "Coastal rescue" id "..." { + oa activity assess "Assess incident" id "..." { + description "Determine the response needed."; + parent coordinate; + } + sa function classify "Classify incident" id "..." { + description "Determine incident class."; + } + trace assess_classify "Function refinement" id "..." { + oa assess; + sa classify; + kind function_refinement; + rationale "Classification supports operational assessment."; + } + artifact assessment_requirement "REQ-42" id "..." { + owner classify; + relation satisfies; + uri "requirements://rescue/REQ-42"; + revision "baseline-2026-09"; + } + // Diagram node placements live in a separate diagram section. +} +``` + +This illustrates syntax direction only; `...` is not a valid UUID, and the excerpt omits the referenced parent declaration. Final grammar must define every record, field, default and rule before implementation. Keep JSON-compatible exact strings and finite-coordinate guarantees. Optional IDs remain useful for new authoring, but linting must not allocate new persistent IDs on each keystroke. Parse into syntax/semantic candidates with unresolved generated identities and materialize IDs once on successful Apply/creation. Saved text always includes IDs. Reparse of an existing ID-bearing document must preserve identity. + +Expose `analyze(source) -> Analysis { tokens, syntax, references, diagnostics }` independently from strict `parse/validate` and commit. Tokens carry byte ranges and classes for keywords, layer/kind names, declarations/references, strings, numbers, punctuation, and comments. A tolerant lexer must still color incomplete strings/documents. Synchronize parser recovery at declaration/field boundaries to provide multiple useful errors. Core diagnostic codes, severity, primary range, ObjectRef/field path and related ranges serve both the Iced editor and REST linting. Preserve one-based Unicode-scalar display columns; future LSP adapters must convert explicitly to the negotiated position encoding. + +Use debounced background analysis with a draft-generation number; discard stale results and never replace the committed model while typing. Warnings should include unallocated leaves, unsupported capabilities, process/scenario gaps, incomplete state initialization, and trace drift. Errors include illegal cross-layer ordinary relations, cycles, dangling references, duplicate identities/aliases, incompatible mappings, and bad field types. Quick fixes, if added, produce ordinary commands or explicit source edits. + +V1 discards comments; V2 must state its preservation policy clearly. The smallest Git-friendly extension can retain normalized formatting while persisting model documentation in description/rationale fields. If comments must survive graphical edits, preserve attached trivia in a document layer keyed by IDs and reconcile it on Replace, with orphan-comment behavior and tests. Do not promise lossless source editing while continuing to overwrite `source_editor` from a comment-free printer. Syntax highlighting and useful linting do not require a full language server or a second semantic store. + +## Deterministic schema-1 migration + +Dispatch on format and version before decoding the final model. Keep strict `ProjectV1`/text-v1 readers and validate with the old rules; do not temporarily disable current validation or silently deserialize unknown fields. Reject future versions with a located/actionable message. + +1. Preserve project, element, and relation UUIDs and every stored name/description exactly. +2. Map all five legacy kinds and four relation kinds to their explicit OA equivalents. Parent is absent; no SA system, actors, roles, or external requirements are invented. +3. Move each legacy element position into a generated OA overview node, preserving IEEE f32 values including signed zero in the representations where the existing contract requires it. +4. Preserve process step order and repeated interaction references. If occurrences gain identities, derive stable migration IDs from project UUID, process UUID, and original occurrence index. +5. Generate the OA overview and node IDs deterministically from a documented namespace; resolve even pathological collisions deterministically against the complete old/new identity set. Random migration IDs would make opening the same legacy file yield unrelated Git changes. +6. For legacy JSON, compute old canonical aliases once using the V1 algorithm and persist them. For V1 source, choose and specify one policy: retaining supplied aliases is more helpful to source authors; using V1 canonical aliases preserves the historical JSON/text equivalence baseline. Recommended compatibility policy is canonical V1 aliases for both, with an explicit import option to retain authored aliases if needed. Generate the newly introduced project/view/occurrence aliases after reserving all legacy object aliases so they cannot rename old references. No policy may silently change aliases on later saves. +7. Initialize new record collections empty, validate the complete V2 project, and return an import/migration report. Migration never overwrites the source merely because it was opened. +8. Save schema 2 explicitly. Do not label a V2 file schema 1 or silently drop SA/trace/artifact/behavior data on downgrade. A legacy export can exist only as a separately named, validated operation with a concrete loss report. + +Migration should be idempotent and deterministic: migrating identical input produces identical V2 project and canonical text. Tests must compare the old/new semantic projection as well as full migrated text/JSON round trips; equality alone between two incorrectly migrated results would not prove preservation. + +## Graphical/textual parity and acceptance gates + +Maintain a field matrix in the V2 specification: each persistent mutable field names its source syntax, graphical editor/control, core command, validator rule, and round-trip fixture. This includes aliases, parents, trace endpoints/mapping/rationale/baselines, arbitrary artifact URI/revision/fragment, scenario branches/guards/occurrences, states/transitions, item fields, and diagram coordinates. A read-only inspector does not meet editing parity. System-generated identifiers/baselines may have dedicated explicit operations, but both representations must expose their supported editing contract. + +Preserve the established draft guard: source and property drafts cannot coexist as competing writers. Layer/view switches, transition launch, artifact edits, hierarchy actions, and mode/scenario editors first apply or explicitly revert the active draft. Invalid source keeps its buffer and Help remains read-only/reachable. A source draft carries the base accepted revision, so remote changes produce an explicit conflict instead of being overwritten by Apply. Command results carry all identity remaps for selections, pickers, diagrams and transitions; do not extend the current handful of `Message::Command` selected-ID rewrites for each new feature. + +Meaningful new verification should cover: + +* Full-field V2 parse/print and JSON equality with OA+SA, hierarchy, repeated occurrences, branches, modes/states, items, artifacts and traces; canonical idempotence and exact strings/coordinates. +* Display rename changes only its field; inserting a colliding-name object preserves existing aliases/references; reparent changes only its parent line; arrangement changes only the active diagram section. +* Every legal/illegal endpoint and containment family, direct allocation cardinality, parent cycles, state/occurrence references, no accidental cross-layer ordinary relations, and same-kind cross-layer confusion rejection. +* Forward and reverse traced creation, reuse, update and manual merge; unchanged repeat is a no-op; one-to-many ambiguity, unmapped endpoints, stale plans, repeated API requests and failed writes cannot leave partial results or duplicate counterparts. +* Drift from either side, both sides, layout-only changes, alias rename, deletion and identity changes; deleting a traced source preserves the counterpart and provides an explicit trace impact result. +* Cascade/identity reference coverage over every reference-bearing record, with one Undo restoring the entire transaction and failed operations preserving redo. +* Deterministic migration from both bundled schema-1 formats, malformed/future input rejection, old-field preservation and collision handling for generated IDs. +* GUI editing for every mutable field and text→graph→text round trips, nested drag/reparent distinctions, multiple-view Fit/SVG, invalid drafts across layer switches, and actual OA→SA→OA preview/commit workflows. +* REST contract tests proving parity with in-process commands, stale ETag/revision rejection, structured diagnostics, arbitrary URI storage without fetch, and source Replace using the same validation. + +Recommended implementation order is schema/spec and migration; transaction/reference hardening; OA hierarchy/roles and SA semantic records; source/parser/printer parity; traced transition planner; graphical views/editors; shared analysis/highlighting/linting; REST adapter; then cross-platform builds and actual GUI/API acceptance. Ship no V2 feature as complete before both editing forms and its rejection/undo/migration paths are verified. diff --git a/docs/research-v2-semantics.md b/docs/research-v2-semantics.md new file mode 100644 index 00000000..ebada964 --- /dev/null +++ b/docs/research-v2-semantics.md @@ -0,0 +1,175 @@ +# V2 OA / SA semantics research + +Research date: 2026-09-05. This is an implementation decision aid, not a claim that the proposed features already exist. It distinguishes **Arcadia method**, **Capella implementation evidence**, and **recommended Studio behavior**. + +## Evidence and limits + +Sources are the official Arcadia reference site and the Eclipse Capella repository. Repository source inspection was pinned to commit `3153b930a4284f6c82952658f1e8d8d6d8c8f340` where indicated; user documentation links below use `master` and can change. Relevant original screenshots were also extracted from the already-installed Capella 7.0.1 documentation JARs and visually inspected. This researcher did not operate a live Capella session. Native runtime inspection is separate work, and old documentation screenshots must not be presented as screenshots of that runtime. + +The Ecore schemas expose inherited or permissive structures that are further constrained by validation rules. For example, `SystemComponent` structurally has a child-component reference, while the SA validation rules forbid internal decomposition of the System. Schema availability alone is not evidence that a concept is legal in an engineering perspective. + +No source code or visual assets were copied into Studio. The recommendations below are an independent synthesis. They do not establish Capella file compatibility, full metamodel conformance, simulation semantics, or support for Logical/Physical Architecture. + +## Perspective boundaries + +**OA describes stakeholder work and its operational context.** Identify what people, organizations, and other entities must accomplish, their capabilities, activities, interactions, and operational conditions. Avoid assuming the future System of Interest as the solution. **SA describes the selected system contribution:** its boundary, externally visible services, interactions with operators and external systems, and expected behavior. A single operational activity may require several system and actor functions; different solution alternatives can satisfy the same operational need. [Arcadia Q&A][arcadia-qna] + +An OA Actor is an undecomposable operational participant, usually human. An SA Actor is external to the System; it can be human or another system. Consequently, OA Entity → SA Actor is normal and does not mean that the stakeholder became a person. Missions express high-level goals; capabilities express abilities that support them. Activities/functions describe work, and interactions/exchanges describe dependencies between that work. [Arcadia language][arcadia-language] + +SA has exactly one System, alongside its external actors. It does not contain the System's internal logical components, software/hardware placement, or deployment nodes. Those belong to subsequent architectural perspectives. SA functions can still have deep decomposition: a functional breakdown does not decompose the System structurally. [Capella architecture diagrams][features], [component rules DWF_DC_36–38][validation-components] + +## Three meanings of nested boxes + +| Meaning | Semantic operation | OA / SA application | V2 editing contract | +|---|---|---|---| +| Decomposition / containment | Change an element's semantic parent | OA entity within entity; activity within activity; SA function within function; non-human external actor hierarchy where supported | Explicit **Move into / Reparent** command. One parent, same perspective, compatible types, no cycles. Move subtree as one undoable transaction. | +| Allocation presentation | Show work inside its performer | OA activities inside entities/actors; SA functions inside the System/actors | Explicit **Allocate to** command. Moving a function visually is insufficient to change its functional parent. Inspector shows both functional parent and performer. | +| Deployment / hosting | Allocate a behavioral implementation onto hosting resources | Physical Architecture, outside this OA/SA scope | Do not expose a generic SA deployment relation or reinterpret every nested box as a hardware node. | + +Capella allows direct allocation of **leaf** functions/activities. A non-leaf function may appear within a performer as a computed summary of its allocated leaves. That appearance is not a second direct allocation. The official allocation guide also distinguishes functions allocated on subcomponents using a dashed presentation and parent functions using italics. V2 can use its own accessible styling, but must preserve the distinction. [Allocation guide][allocation-guide] + +Implementation consequence: retain independent references for `semantic_parent`, allocation, and diagram containment/presentation. Dragging an activity into an entity should propose allocation; dragging it into another activity should propose decomposition. When splitting an allocated leaf, offer an atomic operation that moves its allocation to the new leaves; never silently leave an illegal direct allocation on their parent. This is a Studio design recommendation. + +The direct-allocation uniqueness rule counts `ComponentFunctionalAllocation` relationships. It does **not** count role-mediated `ActivityAllocation` relationships. Do not incorrectly outlaw OA role sharing by treating every role membership as a direct performer allocation. [Unicity implementation][allocation-unicity] + +## Prioritized V2 semantics matrix + +P0 means foundational work needed before the dependent rows. P1 means the next implementation group for a meaningful OA/SA workbench, not permission to claim complete V2 while omitting it. P2 covers richer variants that require explicit scope disclosure if absent. + +| Priority / concept | OA semantics | SA semantics | Concrete V2 minimum and validation | +|---|---|---|---| +| P0 — perspective and identity | Operational analysis root; typed entities, actors, activities, capabilities | System analysis root; exactly one System; external actors, functions, missions, capabilities | Every element/relation has stable identity and perspective. Reject cross-layer containment and same-layer work allocation to another layer. | +| P0 — structural hierarchy | Entities can own subentities; OA actors cannot decompose | No internal System component tree; non-human external actor composition is distinct from the System | Parent picker plus nested diagram and tree. Prevent cycles. Human participants cannot acquire children. [OA schema][schema-oa], [component rules][validation-components] | +| P0 — work breakdown | Activities have owned activities | System functions have owned functions; functions may be allocated to the System or an Actor | Leaf/non-leaf distinction; create child, reparent, collapse, expand, focus subtree. Preserve independent performer. [functional schema][schema-fa], [SA schema][schema-sa] | +| P0 — direct work allocation | Entity/Actor → leaf Activity | System/Actor → leaf System Function | At most one direct performer; reject wrong perspective and non-leaf direct allocation. Unallocated leaves are an actionable completeness issue while authoring. [allocation implementation][allocation-types] | +| P0 — exchanges | Directed Interaction between Activities; communication means connect Entities/Actors | Functional Exchange between output/input function ports; Component Exchange between System/Actor boundaries | Typed endpoints, direction, exchange-item references. An exchange is not an allocation or a sequence occurrence. Distinct parallel exchanges retain distinct IDs. [functional schema][schema-fa], [OA schema][schema-oa] | +| P0 — operational/system goals | Operational capabilities involve Entities/Actors and work/processes | Mission exploits Capability; Capability involves System/Actors, functions, chains/scenarios | Typed involvement and mission-capability links; browse from both endpoints. Do not label all of these relationships “realization.” [SA schema][schema-sa], [interaction schema][schema-interaction] | +| P0 — traced counterpart workflow | Can select work/participants/goals to realize in SA | Realizing elements retain links to operational sources | Explicit creation, linking, comparison, and update in either direction; stable many-to-many traces; atomic apply/undo. Detailed contract below. | +| P1 — Roles | A coherent activity group can be assigned to one or more Entities/Actors | No direct SA Role equivalent | Separate Entity→Role and Role→Activity relations; show effective assignments. Transition shared roles using contextual function instances or explicit assignment decisions, not multiple direct allocations of one SA function. [Role glossary][role], [OA schema][schema-oa] | +| P1 — Processes / Functional Chains | Operational Process references activities/interactions along a path | Functional Chain references functions/exchanges along a path | Ordered occurrence records with stable IDs, including repeated use of the same semantic exchange; continuity checks; capability involvement; highlight the path in diagrams. A flat list alone does not implement branching control semantics. [Arcadia language][arcadia-language], [chain rules][validation-dataflows] | +| P1 — Scenarios | Activity and Entity scenario views use operational interactions | Functional, exchange, and interface scenario views use appropriately typed lifelines/messages | Scenario belongs to a capability; explicit lifelines, ordered messages invoking existing exchanges/operations, pre/postconditions, and referenced states. Retain message identity separately from exchange identity. [scenario schema][schema-interaction], [scenario rules][validation-scenarios] | +| P1 — Modes / States | Owned by an operational participant | Owned by the System or an Actor | State-machine owner; regions; state/mode/initial/final kinds; directed transitions, guard, trigger, effect; entry/do/exit work references; availability of activities/functions, processes/chains, and capabilities. No machine execution claim. [state schema][schema-state] | +| P1 — Data and Exchange Items | Operational information conveyed by interactions | Typed information at the system boundary and functional exchanges | Exchange Item with ordered named typed elements, direction/multiplicity where supported; reusable primitive/enum/record types; exchange↔item links. Avoid hard-coding software-only request/response data. [information schema][schema-information] | +| P1 — Interfaces | Use operational communication means and exchanged information; do not import implementation contracts indiscriminately | System/Actor interfaces group Exchange Items; participants or their ports provide/require interfaces | Interface contains references to exchange items; explicit provided/required direction and port ownership. Functional exchange is not itself an interface. [Interface glossary][interface], [structural schema][schema-structure] | +| P1 — Communication support | Communication Mean can carry operational Interactions | Component Exchange can carry Functional Exchanges | Explicit exchange-allocation relation, compatible performer endpoints, same perspective; boundary exchange and work exchange remain separate records. [OA schema][schema-oa], [component rules DWF_DC_13/27/29][validation-components] | +| P1 — conditions and constraints | Stakeholder context, operational assumptions, performance and constraints | Required qualities, boundary constraints and acceptance conditions | Named text constraints with typed targets and units as data where numeric. Treat prose guards/constraints as descriptions unless an evaluator is actually implemented. [Arcadia perspective overview][arcadia] | +| P2 — richer behavior | Role context, ALT/PAR/LOOP scenarios, process control nodes | ALT/PAR/LOOP scenarios, chain control nodes, composite/concurrent state regions | Explicit control/operand structures and ordering; same-layer scenario references; reject malformed fragments. No flattened “sequence” advertised as equivalent. [diagram tools][diagrams], [scenario rules][validation-scenarios] | +| P2 — capability relations | Include, extend, generalize Operational Capabilities | Include, extend, generalize System Capabilities | Distinguish these relations from decomposition and realization; disallow self-relations and diagnose cycles according to the declared Studio profile. [capability rules][validation-capabilities] | + +Arcadia describes operational missions, but the inspected Capella OA schema has no matching `Mission` class; the class exists in SA. If V2 includes an OA Mission, describe it as support for the Arcadia method, with a Studio-defined representation, rather than claiming exact Capella OA metamodel equivalence. [Arcadia Q&A][arcadia-qna], [OA schema][schema-oa], [SA schema][schema-sa] + +The MVP's activity→capability “Contribution” and process→capability “Realization” are Studio concepts. For V2, use capability **involvement** for local work/process links, and reserve cross-perspective **realization** for traceability, or explicitly document any retained legacy terminology and migration. Capella has dedicated capability involvement records. [interaction schema][schema-interaction] + +## Transition mappings and their limits + +Capella's transition documentation describes separate entry points and dependency handling. It is not a universal clone-all command. Operational Entity can transition to Actor or be realized by the System. Operational Capability can transition to Capability or Mission. Transitioning an entity can also transition its allocated activities/interactions. The exact dependency closure must be shown before applying a Studio transition. [Capella transition entry points][transition-entry] + +| OA source | SA counterpart | Required Studio decision | +|---|---|---| +| Entity / Actor | External Actor, or the existing System | Decide scope deliberately. Several OA participants may contribute to defining one system boundary. Do not create a second System. | +| Activity | One or more System Functions, allocated to System and/or Actors | Select granularity and responsibility. Preserve independent decompositions. | +| Interaction | One or more Functional Exchanges | Resolve realizing source/target functions; create required ports; retain information-item links. No exchange with unmapped endpoints. | +| Communication Mean | Component Exchange | Require mapped participant endpoints and reconcile exchange allocations. | +| Operational Capability | System Capability or Mission | Choose ability versus high-level goal; do not infer solely from the label. | +| Process | Functional Chain | Map occurrence identities and their work/exchanges; preserve repeated occurrences. Branching and split mappings require explicit resolution. | +| Operational Scenario | Suitable SA scenario | Resolve lifeline/operation types, ordering and referenced states; carry a scenario realization link. | +| Modes/States and transitions | Separate SA-owned machine elements | Resolve owner first, then state/transition mappings, then availability/trigger links. Do not point an SA machine at OA-owned states. | +| Exchange Item / datatype | Shared upper-layer information, or transitioned counterpart | Make reuse versus creation visible. Capella permits upper-layer data/item reuse in downstream layers; it does not permit the same cross-layer reuse for states/modes. | +| Role | No automatic same-kind SA counterpart | Use its activity/performer context to produce suitable functions and actors. | + +The first five rows follow the documented entry points and inspected OA/SA/functional schemas; scenario/state/item rows are supported by their realization-bearing schemas and transition dependency documentation. This table is a Studio mapping contract proposal, not evidence that one Capella menu implements every row in both directions. [transition entry points][transition-entry], [functional schema][schema-fa], [scenario schema][schema-interaction], [state schema][schema-state] + +Capella's documented transition engine is conservative and iterative: traced targets can be updated; deleting a source does not automatically destroy the target; rerunning can recreate a deleted target. Its merge UI compares the candidate transition result with the existing target. This supports reviewed updates, but it is not evidence of automatic, lossless reverse transformation. [Transition concepts][transition-concepts] + +### Explicit bidirectional creation and update for Studio + +The user request calls for more than browsing. Implement the following in both OA→SA and SA→OA. These are recommended Studio behaviors; the reverse authoring workflow extends the documented Capella transition workflow. + +1. **Create counterpart / Link existing.** Start from either a diagram selection, the model tree, inspector, or source selection. Choose counterpart kind, destination parent, and involved performers. Reuse an existing trace when present; show multiple counterparts rather than silently choosing one. +2. **Keep canonical semantics.** The relation always means “SA element realizes OA element,” even when the command starts in SA. Command direction belongs to transition history, not the meaning of the relation. +3. **Make reverse creation explicit.** SA Function can propose an OA Activity; SA Actor can propose an OA Actor/Entity according to its human/context meaning; Capability can propose Operational Capability. An SA System must not automatically create an OA entity representing the solution under study. An SA interface or internal system-only function may have no useful direct OA counterpart. Let the engineer author or select the operational need and record rationale. +4. **Preview a dependency-complete change set.** Show new counterparts, existing matches, hierarchy changes, proposed allocations, exchanges/ports, process/scenario occurrences, reused information, and links that cannot map. Apply only when required endpoint/ownership choices are resolved. No implicit fallback to unrelated same-name elements. +5. **Separate propagation from editing.** Rename/edit either side normally. Mark affected traces “review needed.” An explicit **Compare / Update counterpart** action offers per-field Keep target, Apply source proposal, or Edit value. Descriptions and names commonly diverge legitimately between needs and system services. +6. **Detect concurrent semantic changes.** Store a last-reviewed semantic snapshot/signature per trace or transition group. Distinguish source-only, target-only, and both-changed cases. Exclude diagram layout from semantic drift. A previous review baseline is needed; comparing names alone is insufficient. +7. **Preserve identity and local work.** Repeated transition/update is idempotent when nothing changed. Selected changes commit as one undoable transaction. Deletion of one side removes/marks the trace appropriately and offers impact review; it never cascades into the other perspective without an explicit separate model deletion. +8. **Navigate both ways.** Each inspector exposes “Realizes” and “Realized by,” shows counterpart kinds and layers, and opens the counterpart diagram/source location. Back/forward navigation preserves context. Display a trace table for coverage and unresolved decisions. + +Many-to-many realization must remain possible. A deterministic update algorithm can safely propose changes for a simple one-to-one pair, but cannot decide how to merge multiple operational activities into one function, or split an exchange across several functions, without engineering choices. Recording an intentional divergence is a successful review outcome, not a synchronization failure. + +## Behavior rules worth implementing early + +The following constraints prevent plausible-looking diagrams from contradicting their model: + +- **Scenarios:** message endpoints must match the referenced interaction/exchange and lifeline types; starts precede corresponding finishes; message exchange items must be supported by the invoked exchange/operation; referenced scenarios stay in the same architecture layer. Capella distinguishes OA `INTERACTION` scenarios from SA `FUNCTIONAL`, `DATA_FLOW`, and `INTERFACE` kinds. User-facing names can be clearer. [scenario validation][validation-scenarios] +- **State machines:** validate initial-state cardinality and initial transition count; owner-relative entry/do/exit work and triggers; references to available functions; consistent regions. Capella makes mixed Mode/State hierarchies preference-dependent, so Studio must publish its chosen policy rather than call a stricter rule universally required by Arcadia. [state validation][validation-states] +- **Chains/processes:** sequence/control links must refer to the actual involvement records and have consistent direction. Displaying a parent function's computed exchange is not a new semantic exchange. [chain validation][validation-dataflows], [computed links][features] +- **Capabilities:** self-inclusion, self-extension, and self-generalization are errors. Mismatches between involved actors and actual scenario/process participants are coverage diagnostics. [capability validation][validation-capabilities] +- **Structural versus completeness issues:** reject dangling references, wrong kinds/layers, cycles, and invalid uniqueness; keep missing allocation, uncovered capability, incomplete scenario, or absent realization as visible modeling diagnostics when safe. Do not prevent engineers saving legitimate work in progress. + +## Original UI evidence and adaptation + +Four original screenshots were visually inspected from installed `org.polarsys.capella.ui.doc_7.0.1.202503211540.jar` and `org.polarsys.capella.transitions.doc_7.0.1.202503211540.jar`. Matching official repository URLs are listed here so the evidence remains reviewable: + +| Original evidence | Observed directly | Useful V2 adaptation | +|---|---|---| +| [Capella perspective screenshot][shot-perspective] | Left model hierarchy; central Activity Explorer with OA/SA tabs; properties, information, semantic browser areas | Keep OA/SA discoverable and provide task guidance; selection coordinates all editing views. | +| [Semantic browser screenshot][shot-semantic] | Three columns for referencing/current/referenced elements; explicit realizing and realized functions, parent, exchanges and related diagrams | A focused relationship inspector with both directions, parent, allocation, and direct diagram/source navigation. | +| [First transition example][shot-transition] | Separate OA/SA model trees; activity breakdown becomes a function breakdown; the target diagram needs representation setup | Never equate model transition with copying a picture. Offer to create/focus the target view after creating semantic elements. | +| [Allocated functions screenshot][shot-allocation] | Functions drawn inside component boxes while exchanging with functions allocated elsewhere | Nested allocation view; independent work hierarchy. This screenshot uses LA and is evidence of presentation only, not permission for SA internal components. | + +Documentation also confirms the semantic browser has navigation history and can follow selection, and that diagram insert/remove operations can alter graphical presence without deleting semantic elements. V2 should therefore separate **Hide from diagram** from **Delete from model**. [Semantic browser guide][semantic-browser], [diagram tools][diagrams] + +These images are documentation evidence, not a current-version usability benchmark. No video was played, no timing/accessibility claim was measured, and no claim is made that every depicted command is available unchanged in the installed runtime. + +## Graphical / textual parity and reviewable completion + +Recommended acceptance criterion: every implemented semantic field, reference, hierarchy, scenario occurrence, state-machine relation, interface declaration, and realization decision can be created and edited graphically and textually against one UUID-based model. Graphical diagrams are views, not a second source of truth. If layout/view filtering is persistent, its syntax and persistence belong to the same parity contract. + +Useful acceptance examples should cover a **general system**, such as a rail crossing, rescue service, or industrial water system, with humans, organizations, external systems, physical/information interactions, operating modes, and degraded behavior. Avoid making every function an API call or every item a software message. + +The most discriminating end-to-end cases are: + +1. Decompose OA entities and activities, allocate leaves, show a computed parent summary, and reparent a work subtree without changing its performer. +2. Realize one OA activity through both an SA System function and an Actor function; traverse the traces both ways. +3. Create an OA counterpart from an existing SA function, retain the SA→OA realization direction, edit both sides independently, and resolve an explicit update without losing either draft. +4. Transition a role shared by two participants without creating a multiply allocated SA function. +5. Model a process/chain and a scenario with two occurrences of one exchange; save/reopen and undo while preserving occurrence identity and order. +6. Link a degraded mode to available work and a guarded transition; show that incorrect owner references are rejected. +7. Convey a typed exchange item through a functional exchange and required/provided interface; show valid endpoint and item navigation in diagram, inspector, and source. +8. Apply text edits that change containment, allocation, and realization in one transaction; malformed input preserves the prior model. Round-trip both storage forms without dropping newly introduced fields. + +Completeness must be reported feature by feature. A new enum value, label, inspector field, or sample diagram is insufficient evidence that a semantics row works through editing, validation, persistence, undo, navigation, and both representations. + +## Primary references + +The inline references above link directly to the supporting material. The core source groups are the [Arcadia method overview][arcadia] and [language reference][arcadia-language], the [Capella OA][schema-oa] / [SA][schema-sa] schemas, and the official [transition][transition-entry], [allocation][allocation-guide], and [validation][validation-components] documentation. Schema links are pinned; documentation links describe the inspected repository state and may evolve. + +[arcadia]: https://mbse-capella.org/arcadia.html +[arcadia-qna]: https://mbse-capella.org/arcadia-qna.html +[arcadia-language]: https://mbse-capella.org/resources/arcadia-reference/Arcadia%20Language%20-%20MetaModel.pdf +[features]: https://mbse-capella.org/features.html +[schema-oa]: https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/m2/plugins/org.polarsys.capella.core.data.def/model/OperationalAnalysis.ecore +[schema-sa]: https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/m2/plugins/org.polarsys.capella.core.data.def/model/ContextArchitecture.ecore +[schema-fa]: https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/m2/plugins/org.polarsys.capella.core.data.def/model/FunctionalAnalysis.ecore +[schema-interaction]: https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/m2/plugins/org.polarsys.capella.core.data.def/model/Interaction.ecore +[schema-state]: https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/m2/plugins/org.polarsys.capella.core.data.def/model/CapellaCommon.ecore +[schema-information]: https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/m2/plugins/org.polarsys.capella.core.data.def/model/Information.ecore +[schema-structure]: https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/m2/plugins/org.polarsys.capella.core.data.def/model/CompositeStructure.ecore +[allocation-guide]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.ui.doc/html/05.%20Diagram%20Management/Allocating%20Functions%20on%20Components.mediawiki +[allocation-unicity]: https://github.com/eclipse-capella/capella/blob/master/core/plugins/org.polarsys.capella.core.data.fa.validation/src/org/polarsys/capella/core/data/fa/validation/function/MDCHK_Function_ComponentAllocationUnicity.java +[allocation-types]: https://github.com/eclipse-capella/capella/blob/master/core/plugins/org.polarsys.capella.core.data.cs.validation/src/org/polarsys/capella/core/data/cs/validation/component/MDCHK_Component_AllocatedFunctions_1.java +[role]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.glossary.doc/html/Glossary/78.%20Role.mediawiki +[interface]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.glossary.doc/html/Glossary/45.%20Interface.mediawiki +[diagrams]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.diagrams.doc/html/Diagrams/diagrams.mediawiki +[validation-components]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.validation.doc/html/Validation%20Rules/design/well-formedness/components/ValidationRules.mediawiki +[validation-dataflows]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.validation.doc/html/Validation%20Rules/design/well-formedness/dataflows/ValidationRules.mediawiki +[validation-scenarios]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.validation.doc/html/Validation%20Rules/design/well-formedness/scenarios/ValidationRules.mediawiki +[validation-states]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.validation.doc/html/Validation%20Rules/design/well-formedness/state_machines/ValidationRules.mediawiki +[validation-capabilities]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.validation.doc/html/Validation%20Rules/design/well-formedness/capabilities/ValidationRules.mediawiki +[transition-entry]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.transitions.doc/html/11.%20Iterative%20Transitions/11.2.%20Transitions%20Between%20Engineering%20Phases.mediawiki +[transition-concepts]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.transitions.doc/html/11.%20Iterative%20Transitions/11.1.%20Concepts.mediawiki +[semantic-browser]: https://github.com/eclipse-capella/capella/blob/master/doc/plugins/org.polarsys.capella.ui.doc/html/04.%20User%20Interface/4.07.%20Semantic%20Browser.mediawiki +[shot-perspective]: https://raw.githubusercontent.com/eclipse-capella/capella/master/doc/plugins/org.polarsys.capella.ui.doc/html/04.%20User%20Interface/Images/4.01_Capella_Perspective.png +[shot-semantic]: https://raw.githubusercontent.com/eclipse-capella/capella/master/doc/plugins/org.polarsys.capella.ui.doc/html/04.%20User%20Interface/Images/4.07.%20Semantic%20Browser_html_m2d24d060.png +[shot-transition]: https://raw.githubusercontent.com/eclipse-capella/capella/master/doc/plugins/org.polarsys.capella.transitions.doc/html/11.%20Iterative%20Transitions/Images/11.3_Exemple1.png +[shot-allocation]: https://raw.githubusercontent.com/eclipse-capella/capella/master/doc/plugins/org.polarsys.capella.ui.doc/html/05.%20Diagram%20Management/Images/AllocatingFunctions_FunctionAllocatedOnComponents.png diff --git a/docs/research-v2-service.md b/docs/research-v2-service.md new file mode 100644 index 00000000..d3511c6f --- /dev/null +++ b/docs/research-v2-service.md @@ -0,0 +1,290 @@ +# Arcadia Studio V2: deployable model service + +Research/design proposal, 2026-09-05. This document describes work to implement; it is not evidence that a server, V2 schema, or deployment currently exists. The V2 normative model contract must define the exact layer/kind/alias/transition fields before implementation. API examples below propose wire DTOs over that contract. + +## Recommendation and current implementation boundary + +Add one `oa-server` workspace crate producing `arcadia-server`, using Axum/Tokio for HTTP and the same `oa-core` model, typed commands, canonical language, transition planning, and validation as the desktop. Keep HTTP/authentication/storage code outside `oa-core`. Use one local SQLite database through `rusqlite` as the initial deployed store; do not add an ORM, plugin runtime, graph database, message broker, or distributed coordination system. + +The inspected code already supplies most of the domain foundation: + +| Existing code | Reuse / change needed | +| --- | --- | +| `crates/oa-core/src/model.rs` | UUID-addressed ordered element/relation maps, schema 1, closed OA kinds, ordered repeated process steps. V2 owns its new concepts and persistent aliases. | +| `session.rs`, `Session::execute` | Clones a candidate, applies one command, validates, then adopts it. Extract a reusable candidate reducer and final validation boundary for atomic batches. Preserve existing desktop undo semantics. | +| `session.rs`, `reidentify` and `Delete` | Maintains graph references. Extend in the core for V2 live references, aliases, and trace records; historical revision anchors remain immutable. | +| `language.rs` | Strict parsing and deterministic source printing. Schema 1 regenerates aliases and discards comments; V2 persistent aliases must survive through the shared V2 parser/printer and JSON, not a service-only alias map. | +| `persistence.rs` | Strict candidate loading and sibling-temp-file replacement. Useful for local files/export, but currently stores only `Project` and has no revisions or retry ledger. | +| `validation.rs` | Shared warnings/errors. Add stable issue codes and locations; currently `Issue` has a message and optional object ID, and `ModelError` is string-only. | + +Existing `Add`/`Connect` generate identities and use UI-oriented defaults. API create commands need explicit client UUIDs and complete records. Existing step commands silently ignore invalid indices, and deleting an unknown object is a no-op; the API contract should reject unintended missing objects/indices explicitly. Add those rules to shared typed commands rather than implementing a second mutation engine in HTTP handlers. + +### Why SQLite; viable file alternative + +A single `StoredProject { resource_id, revision, model: ProjectV2 }` envelope can atomically store model, artifacts, and trace links together using the existing temp-file mechanism. There is no inherent need for separate database tables for these model records. This is a good minimal option if the required guarantee is just replacement of the current snapshot. + +The service proposed here additionally promises retained revisions, atomic optimistic concurrency checks, durable idempotency receipts, and revision-bound transition previews. SQLite allows those records and the current-head pointer to commit in one transaction. With files, use immutable complete revision envelopes plus an atomically replaced head manifest containing the receipt information; never independently replace a model file and a metadata file and call the pair atomic. The file approach needs explicit orphan recovery, directory-sync/platform semantics, receipt retention, and cross-process locking. SQLite is the recommended tradeoff for the deployable V2 service. Its atomic-commit documentation describes both the guarantees and filesystem assumptions. [SQLite atomic commit](https://www.sqlite.org/atomiccommit.html). + +## Resource and version contract + +- Base URL: `/api/v1`. HTTP API version, database migration version, and model schema version are independent. +- Each hosted project has an immutable service `resource_id` UUID. `ProjectV2.id` remains a model identity and may be edited using the shared identity command. URLs, receipts, and historical trace anchors use `resource_id`; changing a model ID never changes its URL. Imported copies can share model IDs, so a model UUID alone is not a global service address. +- Generate and persist one `service_id` UUID when initializing the store. Qualify historical anchors with it so exported evidence cannot accidentally resolve against an unrelated server. Preserve it during disaster-recovery restore; creating an independent service copy requires an explicit new identity and retains imported anchors as external evidence. +- A persisted revision is a positive, monotonically increasing signed-64-bit integer, represented as a decimal **string** in JSON to avoid JavaScript precision loss. A create starts at `"1"`. Every changed model/metadata commit increments once; a no-op returns `changed: false` without a new revision. +- All project views expose the snapshot revision and a quoted project validator, for example `ETag: "p-6f7162cc-094c-4f30-9b70-3f6f355f0c7f-r1"`. API conditional writes compare that validator with the project's current head inside the storage transaction. Each URI has deterministic representation bytes at a given revision; negotiate any future encoded variants correctly. +- Require one exact `If-Match` project validator on every mutation of an existing project. Missing is `428`; stale is `412`; weak tags, lists, and `*` are rejected by this API. GET supports `If-None-Match`/`304`. The validator is opaque to clients. [HTTP conditional request semantics](https://www.rfc-editor.org/rfc/rfc9110.html#name-if-match). +- Require `Idempotency-Key: ` for committing requests, including project creation/import/deletion and transition commit. Authenticate first, then look up a successful receipt **before** checking `If-Match`. The same principal/key and same method, path, body, and precondition replay the recorded result, even when head has advanced; a different request under the same key is `409 idempotency_key_reused`. +- Retain successful receipts, including no-ops, for 30 days; publish this window in OpenAPI. Mutation provenance retains request IDs with revisions indefinitely. After expiry clients must read state before deciding whether to send a new intent. +- JSON rejects unknown fields, invalid enum values, duplicate object-map keys, invalid UUIDs, and unsupported schemas. All model import paths run the same structural validator. Warnings allow incomplete engineering work and are returned separately. + +## Minimum routes + +`{p}` below always means service `resource_id`; `{r}` means an immutable revision. Route-specific limits and all DTOs belong in an OpenAPI 3.1 document shipped with the binary and available at `/api/v1/openapi.json`. + +| Method/path | Contract | +| --- | --- | +| `GET /health/live` | `200` while the HTTP process can answer; no model details or credentials. | +| `GET /health/ready` | `200` after database/migrations/recovery succeed; `503` when shutting down or storage is unavailable. | +| `GET /api/v1/openapi.json` | Exact API schemas, supported enum vocabulary, limits, auth and error responses. | +| `POST /api/v1/projects` | Create an empty V2 model from `{"name":"Harbor assistance"}`; return `201`, `Location`, project metadata, and ETag. | +| `GET /api/v1/projects?limit=100&cursor=...&q=harbor` | Paged project summaries, not complete graphs. Exclude tombstones. | +| `GET /api/v1/projects/{p}` | Metadata: service/model IDs, name, head revision, model schema, counts, created/updated timestamps. | +| `PATCH /api/v1/projects/{p}` | Convenience rename with strict `{"name":"..."}` DTO; uses the same transaction service as commands. | +| `DELETE /api/v1/projects/{p}` | Conditional tombstone, one revision, `204`. Hide from normal reads; preserve audit/history. Physical purge is a separate future administrative operation. | +| `POST /api/v1/projects/import` | Create from raw canonical source or native model JSON, selected by `Content-Type`. Retain model identities and V2 aliases; assign a new service resource ID. | +| `GET /api/v1/projects/{p}/model[?revision={r}]` | Complete native `ProjectV2` JSON, suitable for lossless model export/import. | +| `PUT /api/v1/projects/{p}/model` | Conditional full-model JSON replacement, shared schema/validation, one revision. | +| `GET /api/v1/projects/{p}/source[?revision={r}]` | Canonical UTF-8 Arcadia source; lossless for every persistent V2 model field. | +| `PUT /api/v1/projects/{p}/source` | Conditional raw UTF-8 source replacement; preserve draft on the client if rejected. | +| `GET /api/v1/projects/{p}/elements` | Paged filter by `layer`, `kind`, `alias`, `q`, and optional `revision`; return complete selected records. | +| `GET /api/v1/projects/{p}/elements/{id}` | One record, optional `revision`. | +| `GET /api/v1/projects/{p}/relations` | Paged filter by `kind`, `source`, `target`, `incident_to`, optional `revision`. | +| `POST /api/v1/projects/{p}/query` | Read-only bounded graph neighborhood query; explicit roots, direction, kinds and depth. No SQL or executable expressions. | +| `GET /api/v1/projects/{p}/validation[?revision={r}]` | Structural issues and engineering warnings for a stored snapshot. | +| `POST /api/v1/projects/{p}/validation` | Validate a submitted JSON/source candidate, without changing head/history. | +| `POST /api/v1/projects/{p}/transactions` | Ordered typed batch; optional `dry_run: true`; all-or-nothing final validation. | +| `GET /api/v1/projects/{p}/revisions?after={r}&limit=100` | Ascending immutable revision summaries, provenance and command summaries; usable as a polling change feed. | +| `GET /api/v1/projects/{p}/revisions/{r}` | Snapshot envelope, provenance and change summary at one revision. Available for tombstoned projects to authorized clients. | +| `POST /api/v1/projects/{p}/transition-plans` | Run a core-defined layer transition in preview mode against an exact `If-Match` revision; return stored plan, generated aliases/IDs, diagnostics, effects, and expiry. Does not change model revision. | +| `POST /api/v1/projects/{p}/transition-plans/{plan}/commit` | Commit the stored typed plan only against its original base revision; revalidate and create one revision. | +| `GET /api/v1/projects/{p}/artifacts` | Paged model artifact records, filter by kind and optional revision. | +| `GET /api/v1/projects/{p}/trace-links` | Paged trace records, filter by artifact/object/predicate/revision. | +| `GET /api/v1/requests/{key}` | Authenticated receipt lookup for the same principal: resulting project/revision/status. `404` means no committed receipt is currently known, not proof that an accepted request cannot still complete. | + +Use `application/json` for model JSON and `text/plain; charset=utf-8` for Arcadia source. Both import and replacement routes reject unsupported media with `415`; HTTP does not guess formats from filenames. Candidate validation accepts `{"format":"arcadia","source":"..."}` or `{"format":"json","model":...}`. It returns `200 {valid, issues}` for a well-formed validation request, including an invalid candidate; a rejected committing request returns `422`. Source diagnostics include one-based line/column using the core's documented Unicode-scalar convention. + +Model JSON/source exports carry the V2 model, including whatever artifact/trace records the V2 normative schema defines. Service revision history, auth configuration, retry receipts, and pending plans belong to service backup, not model interchange. Until V2 parser/printer coverage exists, do not claim source/JSON parity for added metadata. Replacing from an older schema requires an explicit supported migration; it must not silently discard V2 data. + +### Create and query examples + +```http +POST /api/v1/projects HTTP/1.1 +Authorization: Bearer +Idempotency-Key: d06fbc2d-f953-4a59-a981-c6a9fc37cc8e +Content-Type: application/json + +{"name":"Harbor assistance"} +``` + +```http +HTTP/1.1 201 Created +Location: /api/v1/projects/6f7162cc-094c-4f30-9b70-3f6f355f0c7f +ETag: "p-6f7162cc-094c-4f30-9b70-3f6f355f0c7f-r1" +Content-Type: application/json + +{ + "resource_id":"6f7162cc-094c-4f30-9b70-3f6f355f0c7f", + "model_id":"debe7a6a-5a87-4d77-ad9d-39b7b5c4c608", + "name":"Harbor assistance", + "revision":"1" +} +``` + +```json +{ + "revision":"3", + "roots":["1b37b9d7-9a95-4a51-8707-010878a82419"], + "direction":"both", + "relation_kinds":["Allocation","Interaction"], + "max_depth":2, + "max_objects":200 +} +``` + +The query response is `{"revision":"3","elements":[...],"relations":[...],"truncated":false}`. Root IDs must exist. Traverse with a visited set; preserve distinct parallel relation IDs and process step occurrences. Deterministic UUID ordering, depth at most 4, at most 100 roots, and total returned objects at most 1,000. If the bound is reached, return `truncated: true` explicitly; never imply a complete graph. All returned relation endpoints must be included; count closure endpoints against the same bound. + +List filters are conjunctive. Exact alias matching follows the V2 core's alias rules; `q` is a documented Unicode-lowercase substring match on display name, with no stemming or regex. Model cursors encode resolved revision, normalized filters, and last UUID; subsequent pages read that immutable revision even if head changes. Reject mismatched cursor/filter combinations. Project catalog pagination uses a retained catalog sequence embedded in its cursor, updated atomically with creates/renames/tombstones, so concurrent catalog changes cannot silently skip records. + +### Transaction contract and example + +Use a closed tagged command DTO (`op`), versioned with the API. Its initial command families are create/edit/delete/reidentify element or relation, edit project, set/reorder process steps, replace model, and the V2-defined artifact/trace commands. Explicit UUIDs and persistent aliases make multi-object AI output deterministic. Creation fails on an existing identity; editing fails on a missing identity; reference maintenance follows the shared core. `replace_model` cannot be mixed with other model commands in the initial API, but may carry service provenance. + +```http +POST /api/v1/projects/6f7162cc-094c-4f30-9b70-3f6f355f0c7f/transactions HTTP/1.1 +Authorization: Bearer +If-Match: "p-6f7162cc-094c-4f30-9b70-3f6f355f0c7f-r1" +Idempotency-Key: 8ac64e31-2655-4120-a3b4-9cb9297d3838 +Content-Type: application/json + +{ + "message":"Describe assessment work", + "origin":{"tool":"engineering-assistant","run_id":"run-0042"}, + "commands":[ + { + "op":"create_element", + "element":{ + "id":"1b37b9d7-9a95-4a51-8707-010878a82419", + "alias":"assess", + "layer":"oa", + "kind":"Activity", + "name":"Assess assistance", + "description":"Determine the assistance needed.", + "position":{"x":360,"y":70}, + "steps":[] + } + } + ] +} +``` + +The alias/layer fields illustrate the proposed V2 record; final field spellings must come from its normative schema. A successful response is `200` with the new ETag and `{"revision":"2","changed":true,"created_ids":["1b37b9d7-9a95-4a51-8707-010878a82419"],"issues":[...]}`. An unallocated activity is an engineering warning, not a failed transaction. Return affected/deleted IDs and identity remappings where relevant. + +Apply the batch to one candidate in order, then run structural validation once. Intermediate states may be incomplete; the final candidate must be valid. A failed command or final validator leaves the current snapshot, history, aliases and traces unchanged. `dry_run` executes the same reducer and validation, returning effects and the base revision; it neither reserves IDs nor authorizes a later write. Commit always checks head again. Shared desktop `Session` should call this reducer and keep one undo entry for a batch, rather than the server constructing a chain of independent sessions. + +Transition preview stores `plan_id`, resource, base revision, normalized input, generated IDs/aliases, the exact typed commands, a plan hash, creation principal, and a short expiry (24 hours). Only the creating principal may retrieve/commit it in the first release. Return the core's explicit source/target layer transformation and conflicts; do not let the HTTP layer invent architecture elements. Commit requires the plan's original base ETag and applies the stored commands, not a regenerated plan. A concurrent edit is `412`; an expired plan is `410`; the client must preview again. Revalidate constraints at commit. An exact committed retry returns its receipt even after preview expiry. Remove expired uncommitted previews during bounded maintenance. + +## Artifact, AI and automation traceability + +Keep artifacts and trace links in `ProjectV2` and its common typed command set. The service must not maintain a parallel hidden engineering model. A useful minimum artifact record has UUID, persistent alias if the V2 model requires one, title, kind, media type, immutable content digest, optional external URI, and provenance (`tool`, version, run ID). The authenticated principal and server timestamp are trusted audit fields; client-supplied generator strings are attributed claims. + +A trace identifies an artifact, a typed predicate such as `generated_from`, `documents`, or `verifies`, and a model anchor `{service_id, resource_id, revision, object_id}`. Permit project-level anchors explicitly. The core defines which predicates are valid; arbitrary extension dictionaries must not replace typed semantics. V2 trace representation must distinguish **live references**, which follow core identity/deletion rules, from **historical anchors**, which resolve an immutable snapshot and must not be rewritten when head changes. + +For a report generated from revision `"3"`, an illustrative trace payload is: + +```json +{ + "op":"create_trace_link", + "trace":{ + "id":"e092799d-9534-46a8-89be-f38a17fd4ce7", + "artifact_id":"bd733c59-81ae-4859-a983-147114c032cb", + "predicate":"generated_from", + "anchor":{ + "service_id":"2767a28b-854f-499a-9a46-7f276f763ad1", + "resource_id":"6f7162cc-094c-4f30-9b70-3f6f355f0c7f", + "revision":"3", + "object_id":"1b37b9d7-9a95-4a51-8707-010878a82419" + } + } +} +``` + +The artifact and its traces can be created in the same batch. A documented `revision: "result"` input may anchor a newly created object to the resulting commit; resolve it to the exact numeric revision before persistence. New local trace commands must resolve every anchor against its nominated snapshot (or the resulting candidate). Keep initial locally created anchors within one hosted project; cross-project linking needs its own transaction/deletion policy later. Current-model navigation may report an object as changed/missing/reidentified while retaining the original historical evidence. Never retarget old evidence silently. + +Import is a distinct case: an exported model may retain historical anchors to its original service/resource, while model interchange does not include that service's revision history. Preserve these anchors exactly as external evidence; report `unresolved_external_anchor` as a warning when their source is unavailable, and never label them locally verified or automatically retarget them to the imported copy. This distinction must exist in the common V2 schema/validator. Lossless model interchange preserves the reference; full evidence-history recovery requires the service backup. A replacement containing a newly forged local anchor to a nonexistent revision/object is still an error. Test import/re-export of traced models explicitly. + +The minimum service stores metadata and a digest, not arbitrary binary uploads or generation jobs. Accept documented `https`/`urn` artifact URI forms as data and never fetch them automatically. A stored digest is a client-supplied claim unless a trusted producer/upload path verifies content; expose this distinction. Do not execute scripts, dereference local filesystem paths, or dispatch tools from model content. An external automation/AI client queries a pinned revision, generates its output, validates a typed batch, commits with the original precondition, and records provenance. REST plus OpenAPI is sufficient initially; an MCP adapter can later be a thin external client of the same API. A polling client follows revision summaries and deduplicates by resource/revision; no webhook delivery guarantee is claimed. + +## Storage, concurrency and failure behavior + +Use one server-owned directory on a local disk, such as `/var/lib/arcadia` or `%LOCALAPPDATA%/ArcadiaStudio/server`; do not default to the OneDrive workspace. Keep `models.sqlite3` and SQLite's journal companions together. WAL is a single-host storage mode and does not support network filesystems. [SQLite WAL](https://www.sqlite.org/wal.html). + +Use `rusqlite` with bundled SQLite for reproducible packaging and its backup feature. One dedicated repository thread owns the connection and services a bounded queue. Tokio handles networking; synchronous parsing, validation, serialization and SQLite work do not block its reactor. This deliberately starts with serialized repository operations for modest engineering models, allowing later profiling before adding a reader pool. Tokio documents separating blocking work, and warns that already-started `spawn_blocking` work cannot be aborted; a dedicated worker makes the commit lifetime explicit. [Rusqlite](https://docs.rs/rusqlite/latest/rusqlite/), [Tokio blocking tasks](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html). + +Minimal tables are store metadata (including `service_id`), `projects(resource_id, head_revision, tombstoned, created_at)`, immutable `revisions(resource_id, revision, snapshot_json, provenance_json, created_at)`, `request_receipts(principal, key, request_hash, response, expires_at)`, `transition_plans(...)`, and catalog history/sequence. `snapshot_json` is the complete V2 model including artifacts/traces; it is authoritative. Do not create separate mutable graph tables in the first implementation. Enforce primary/foreign keys and revision uniqueness; use parameterized SQL, explicit migrations, and fail startup on an unsupported newer database. + +Configure and verify `journal_mode=WAL`, `synchronous=FULL`, `foreign_keys=ON`, and a finite busy timeout. SQLite documents FULL-mode WAL commit syncing; durability still depends on a correctly functioning local filesystem/device. Never change to NORMAL/OFF without changing the durability claim. [SQLite synchronous settings](https://www.sqlite.org/pragma.html#pragma_synchronous). + +One commit executes the following sequence on the repository worker: + +1. Begin a write transaction; look up the authenticated idempotency receipt. +2. Load current head and check exact `If-Match`; reject tombstones except the documented history path. +3. Build one candidate with the shared reducer; validate core invariants, V2 aliases/traces, referenced historical anchors and configured quotas. +4. On a change, insert immutable revision, update head and any catalog entry. Insert the success receipt in the same transaction, also for no-ops. Mark a transition plan consumed where applicable. +5. Commit. Only then acknowledge success. If commit outcome is uncertain, close/reopen the connection and inspect the receipt before admitting further dependent work; never publish an uncommitted in-memory head. + +Failures before commit roll back. A dropped HTTP connection or response deadline does not revoke an already-started transaction. If a response times out after admission, return `504 outcome_unknown` when possible, with the request key, and tell the client to retry the **same** request/key or inspect its receipt. A `404` receipt while work is in flight is not proof of rollback. Queued operations may be discarded before execution if their caller has gone away; started operations must reach a known commit/rollback boundary. Tests must prove these distinctions. + +Retain revisions by default. Tombstones retain evidence and receipts. Enforce a storage quota and report a clear capacity error instead of pruning history that trace links depend on. A later retention feature needs explicit anchor-aware policy. Maintain/checkpoint WAL on the worker. Back up with SQLite's backup API or stop the service cleanly before copying its store; copying a live main database file alone is not a backup procedure. Restore into an empty service directory, run integrity checks, and validate stored models before declaring ready. [SQLite backup API](https://www.sqlite.org/backup.html). + +## Deployable defaults and bounded work + +Ship these initial defaults as documented configuration, then measure before increasing them: + +| Boundary | Initial default | +| --- | --- | +| Bind | `127.0.0.1:8787`; explicit setting required for another interface. | +| Authentication | Bearer token required, including loopback; token file required at startup. No built-in password/account flow. | +| Principals | Small configured set of token identities with read-only or read/write role; all hosted projects share this service authorization boundary initially. | +| Body bytes | 1 MiB ordinary commands; 8 MiB source/model import/replacement/validation; reject compressed request bodies initially. | +| Work admission | 8 active model requests including buffered bodies; repository queue 16; reject overload promptly with `503` and `Retry-After`. Health routes bypass the model-work queue. | +| Deadlines | 10 seconds to receive body; 30 seconds HTTP response budget; no false promise that an admitted commit was cancelled. Reverse proxy adds header/idle/connection limits. | +| Candidate size | 8 MiB serialized model; 5,000 graph elements + relations total; 50,000 process-step occurrences; 10,000 artifact/trace records; all checked before expensive semantic validation where possible. | +| Strings and operations | Names/aliases at most 1 KiB, descriptions 64 KiB, URI 4 KiB; at most 200 typed commands per transaction. V2 semantic limits may be stricter. | +| Lists / traversal | Default page 100, max 1,000; graph depth 4, roots 100, returned objects 1,000. | +| Store | Configurable 10 GiB managed-data quota initially; refuse commits exceeding quota while retaining read/export access. Account for new revision/receipt bytes and reserve journal/maintenance headroom. | + +The existing validator repeatedly scans relations, including allocation checks, so its cost is not uniformly linear. Input count limits must run before it. Measure worst cases at the chosen ceilings and add adjacency/count indexes in the shared validator if needed; do not claim a hard CPU deadline based only on a Tokio timeout. + +Apply a global raw body limiter and route-specific extractor limits; Axum's default limit is local to participating extractors and can be bypassed by custom body consumption. Map all rejection paths, including middleware and unknown routes/methods, to the documented API error response. [Axum body limits](https://docs.rs/axum/latest/axum/extract/struct.DefaultBodyLimit.html). + +For remote deployment, use a TLS reverse proxy and keep the server port private. A non-loopback bind should require an explicit acknowledgement of the proxy/private-network configuration and a valid token configuration; this is a startup configuration rule, not a runtime user approval step. Tokens must be randomly generated high-entropy secrets, scoped to named principals, held in files with restrictive OS permissions, and checked using a constant-time secret comparison; document rotation through configuration reload/restart. Disable CORS by default. If a browser client is later added, configure exact origins and allowed headers rather than wildcard credentials. The native Iced client does not require CORS. Redact authorization headers and model/source bodies from logs; log request ID, principal ID, route template, revision, status and duration. Never put tokens in URLs or returned OpenAPI examples. + +## Clear errors + +Use `application/problem+json` with `type`, `title`, `status`, `detail`, `instance`, and extensions `code`, `request_id`, optional `current_revision`, and `issues`. Each issue can carry `code`, `severity`, `object_id`, `command_index`, JSON field path, or source line/column. Do not classify errors by matching English `ModelError` strings. [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html). + +```json +{ + "type":"urn:arcadia:problem:revision-conflict", + "title":"Project changed", + "status":412, + "detail":"The submitted revision is no longer current. Reload and review the changes before retrying.", + "instance":"/api/v1/requests/8ac64e31-2655-4120-a3b4-9cb9297d3838", + "code":"revision_conflict", + "current_revision":"3", + "request_id":"8ac64e31-2655-4120-a3b4-9cb9297d3838" +} +``` + +Status mapping: `400` malformed envelope/query/UUID; `401` missing/invalid token (with `WWW-Authenticate`); `403` read-only principal attempting a write; `404` missing resource; `409` duplicate identity or conflicting retry key; `410` expired plan/deleted current project; `412` stale revision; `413` body too large; `415` unsupported media; `422` syntactically decoded but invalid model/command; `428` missing precondition; `503` overload or unavailable storage; `507` configured storage capacity reached; `504` response deadline with potentially unknown admitted-write outcome; `500` unexpected internal failure. A graph root missing from a requested existing snapshot is `422` with its ID. Invalid source syntax in a committing source request is `422` with source diagnostics. Return safe details to clients; filesystem paths and database internals belong only in restricted logs. + +## Desktop integration + +The simplest first integration is an explicit **Connect to server / Open hosted project** mode using an asynchronous Rust HTTP client inside Iced tasks. Preserve local-file mode and its existing `Session`; both use the same core records/commands. A hosted tab tracks service URL, resource ID, base revision, acknowledged model, and local drafts. Apply/Save sends one conditional typed transaction or complete parsed replacement. The server is authoritative for a hosted project; the desktop must not open/edit the SQLite database directly. + +Keep local optimistic preview if useful, but do not mark it saved until the server acknowledges the revision. On `412`, retain the draft and show the current server snapshot plus the user's proposed edit; require an explicit revised intent rather than automatically resending a complete model over newer work. Poll project metadata with `If-None-Match` while idle or refresh on focus; no WebSocket is needed. Drag updates should coalesce into one commit on release. Local undo can formulate an inverse/replacement against the last acknowledged revision; it cannot rewind other clients' history. A restore is a new revision, not decrementing the service counter. + +Do not require a background server for existing local-file users. A managed loopback sidecar can follow later, but it adds binary lifecycle, token-file/port discovery, restart and upgrade responsibilities. Initially let developers/operators start the packaged server explicitly and let the desktop connect. This provides remote service use and a testable integration boundary with less process management. + +## Run, package and verification plan + +The official docs inspected currently report Axum 0.8.9, Tokio 1.53.1 and rusqlite 0.40.2. These are research observations, not pins already tested in this workspace. Select compatible dependency versions in Cargo.lock, enable only needed features, and verify the workspace's declared Rust 1.90 floor or explicitly raise it; current local records show builds with 1.98.1. Axum supports the required routing, state, extractor and Tower middleware model. [Axum API](https://docs.rs/axum/latest/axum/), [Tokio API](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html), [rusqlite API](https://docs.rs/rusqlite/latest/rusqlite/). + +Proposed commands, valid only after implementing the crate/configuration: + +```powershell +$env:CARGO_TARGET_DIR = 'D:/CodexBuilds/Capella2-01a06e2e' +cargo build --release --locked -p oa-server +& 'D:/CodexBuilds/Capella2-01a06e2e/release/arcadia-server.exe' --bind 127.0.0.1:8787 --data-dir 'D:/ArcadiaData' --tokens-file 'D:/ArcadiaConfig/tokens.toml' +``` + +```sh +cargo build --release --locked -p oa-server +./target/release/arcadia-server --bind 127.0.0.1:8787 \ + --data-dir /var/lib/arcadia --tokens-file /etc/arcadia/tokens.toml +``` + +Package a headless Windows executable and Linux binary/container separately from the GUI. Server compilation must not pull Iced/WGPU/native GUI development packages. Run Linux under a dedicated service identity with a writable data directory and a read-only token/config file; mount persistent local storage for a container and publish only through its TLS proxy. Include configuration examples, OpenAPI, migration/backup/restore instructions, licenses and checksums. No credentials should ship in a release. Use existing build storage on D: rather than consuming the low free space on C:. + +On Ctrl+C/SIGTERM, mark unready, stop admission, stop accepting connections, and drain admitted repository work before closing SQLite. Axum provides a graceful-shutdown hook, and Tokio describes signaling and waiting for task completion. Set a documented supervisor grace period (for example 45 seconds), record a timeout, and rely on tested database recovery if a supervisor must terminate a stuck process; never report that unacknowledged writes necessarily failed. [Axum graceful shutdown](https://docs.rs/axum/latest/axum/serve/struct.Serve.html), [Tokio graceful shutdown](https://tokio.rs/tokio/topics/shutdown). + +Release gates: + +1. Core parity: graphical/API/source/JSON routes produce equal V2 models, preserve aliases/identities/step occurrences/trace fields, and reject the same structural errors. Batch rollback, deletion, reidentification and transition plans use the common reducer. +2. HTTP contract: router tests cover auth, strict DTO rejection, every status above, media types, exact ETags, limits, cursor snapshot consistency, query truncation/endpoint closure and OpenAPI examples. Axum's official testing example uses in-process service requests; add a small real-socket suite for transport behavior. [Axum testing example](https://github.com/tokio-rs/axum/blob/main/examples/testing/src/main.rs). +3. Persistence: restart after creates/edits/artifacts/tombstones; two simultaneous writes with one ETag yield exactly one new revision and one `412`; retries before/after restart produce one commit. Inject failure around revision/head/receipt/commit and prove no split state. Test abrupt process termination, recovery, disk-full/permission errors and backup/restore. Do not infer power-loss guarantees from a normal restart test. +4. Transitions/traces: a preview is side-effect free for the model; stale/expired plans cannot commit; committed retries replay; result anchors resolve; old evidence survives deletion/identity changes; incomplete or nonexistent anchors fail atomically. +5. Capacity/failure: body limits without Content-Length, slow bodies, maximum-size adverse graphs, full work queue, request cancellation after admission, graceful shutdown under writes, and storage quota. Record measured memory and latency at the chosen defaults before promising supported scale. +6. Deployment and desktop: build/test headless on Windows and Ubuntu; connect Iced, edit, detect an external API edit without losing a draft, export/reimport, and restart the service with data intact. Run fmt, strict Clippy, core/server tests and the existing desktop regressions. Test Rust 1.90 explicitly if it remains the minimum. + +This design is intentionally a single-host deployable service for modest models. Multi-tenant per-project permissions, automatic merging, active-active servers, binary object storage, webhooks, Capella interchange and a built-in AI execution runtime need separate concrete requirements and are not implied by the REST API. diff --git a/docs/research-v2-ui.md b/docs/research-v2-ui.md new file mode 100644 index 00000000..74a65fdc --- /dev/null +++ b/docs/research-v2-ui.md @@ -0,0 +1,13 @@ +# Original Capella runtime inspection — V2 + +2026-09-05, Windows, installed Capella 7.0.1. The lead engineer used native Computer Use to reopen the repaired application, import its bundled In-Flight Entertainment System example through the UI into the isolated `D:/CodexBuilds/Capella2-v2-01a06e2e/capella-workspace`, and inspect the following. The installed original sample was not edited. These are observations of Capella, not evidence of Studio V2 behavior. + +- At 1009×763 the Activity Explorer provides explicit OA/SA phase tabs and engineering tasks. The diagram browser and long task labels are cramped. Maximizing to 2560×1392 exposes the full task flow and diagram list. +- System Analysis tasks include transition from operational activities; actors/missions/capabilities; functional refinement/exchanges; allocation to System/Actors; interfaces/scenarios. This is useful contextual guidance, though the many separate tool panes consume substantial canvas space. +- Opened `[SAB] High Level System Overview`: blue System/Actor containers, green functions inside, external Passenger/Aircraft/Cabin Crew/Ground Operator. Nested work makes allocation readable. Dense orthogonal edges, truncated labels, small ports and a long palette add clutter; focused views and progressive disclosure should improve this. +- Selected `Manage Video and Audio Diffusion`. Properties expose condition, mode/state availability and realized operational activities. The semantic browser shows computed allocating actor/component, incoming/outgoing exchanges, decomposition, parent and related diagrams. This independently confirms that displayed nesting and functional parent differ. +- Expanded the OA model tree: activities, capabilities, interfaces, data, roles and operational entities. Selected `Broadcast Movies`; semantic browser showed Aircraft allocation, operational process, several realizing System Functions, outgoing interactions and Cruising/Takeoff state availability. +- Opened the context menu and Transitions submenu. An OA activity offers Functional Transition, Activity to System Capability, and Activity to System Mission. Started Functional Transition: a real comparison dialog shows Synthesis, Candidate model, Resulting model, Details, Apply/OK/Cancel and 23 differences under root System Function. Cancelled without applying. Studio should show meaningful choices directly and explain dependency scope before commit. +- Navigated directly from the semantic browser to `[OAB] All Operational Activities and Entities`. Yellow activities are allocated inside participant containers; Pilot and Cabin Crew are nested in Crew. A blue highlighted operational process traverses three activities while ordinary exchanges remain thin. This is the desired separation of containment, allocation and selected behavior emphasis. + +V2 adaptations: visible OA/SA switch and task context; focused hierarchy plus inspector; independent semantic parent and performer; nested containers with restrained semantic colors; selective edge emphasis; direct two-way counterpart and source navigation; explicit create/link/compare actions; transition preview with readable field changes and atomic undo. No copied assets or claims of native Capella interchange. diff --git a/docs/scope.md b/docs/scope.md new file mode 100644 index 00000000..b8ea1681 --- /dev/null +++ b/docs/scope.md @@ -0,0 +1,57 @@ +# V2 scope and tradeoffs + +V2 implements a general systems-engineering workbench spanning Operational Analysis and System Analysis. The normative [language and editing contract](language.rst) defines the supported Studio profile. It does not claim full Arcadia, Eclipse Capella or SysML conformance. [Acceptance](acceptance.md), [validation](validation.md) and [current state](../harness/STATE.md) record what has actually been exercised; implementation and verification are distinct. + +## Supported modeling profile + +| Area | Implemented semantics and authoring | +|---|---| +| Operational Analysis | Entities, actors, roles, activities, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement | +| System Analysis | One System of Interest, external Actors, functions, missions, capabilities and functional chains; functional decomposition independent of the system boundary; allocation, functional/boundary exchanges and mission-capability links | +| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, ports, interface contents, provided/required contracts and exchange allocations | +| Behavior | Stable ordered exchange occurrences, repeated semantic exchanges, scenario lifelines and exchange/action/state occurrences, explicit ALT/PAR/LOOP/OPTIONAL fragments and branches, pre/postconditions | +| States and conditions | Participant-owned state machines, regions, state/mode/initial/final nodes, trigger/guard/effect transitions, descriptive and linked entry/do/exit work, availability and constraints | +| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references | +| Views and editing | Multiple independent diagrams, decomposition/allocation nesting, collapse, dragging, pan/zoom/Fit/Arrange, SVG export, model explorer, typed property controls, exact strings, UUID/alias editing, history and source navigation | +| Text and storage | Canonical `arcadia 2` language, persistent aliases/UUIDs, typed constructors, highlighting/live lint, recoverable invalid drafts, text/JSON equality, deterministic V1 migration and atomic saves | +| Automation | Rust REST service for project/source/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage | + +Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically. + +## Explicit semantic choices + +- OA captures operational needs; SA defines the selected system contribution. SA contains no internal logical/physical components, and Studio does not decompose SA Actors. The System may be absent while authoring, with a completeness warning. +- Work decomposition and performer allocation are independent. Only leaves have direct allocations. A parent drawn within one performer can be a computed leaf summary, not an additional allocation. Role sharing does not create several direct performers for one function. +- Nesting expresses semantic containment or the selected allocation projection. Placement movement does not change ownership or allocation. Hide and delete have different effects. +- Counterpart creation copies mapped typed content and required references; existing-target updates compare only names/descriptions against a reviewed baseline. They do not synchronize arbitrary structure, reverse-engineer operational needs or automatically overwrite the other perspective. Scope follows ownership/reference dependencies, not every incident relationship. +- Processes/chains use ordered exchange occurrences. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile. +- OA boundary exchanges are a simplified operational-communication representation. Capability grouping is containment, not include/extend/generalization. Data/item references remain within their layer and can have explicit transitioned counterparts. +- Stable aliases are retained; comments and formatting normalize on Apply. Artifact references retain author-provided URI/revision/fragment values without fetching or executing resources. + +## Capella-inspired improvement targets + +These are concrete design responses, not claims that every Capella user experiences the same problems or that this prototype fixes every downside. + +| Concern | Studio response | Remaining tradeoff | +|---|---|---| +| Dense workbench and difficult onboarding | Task workspaces, OA/SA context, focused properties, guided example and explicit relation/transition builders | Broader usability studies, assistive technology and localization remain | +| Noisy architecture pictures | Nested ownership/allocation views, independent placements and collapse | Manual edge routing and very large diagram optimization remain | +| Hard-to-review model files | Readable typed source, stable aliases/UUIDs, canonical ordering and one specification | Comment preservation, semantic merges and an LSP are absent | +| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation | +| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter | +| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export | + +## Remaining boundaries + +- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export. +- Advanced capability include/extend/generalization and complete Capella communication/scenario variants beyond the specified Studio profile. +- Autosave/crash recovery, recent-project management, semantic diff/merge, comment-preserving source edits, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock. +- Direct desktop connection to hosted models, collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits. +- Editable edge routing, production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers. +- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case. + +Windows and Linux builds are supported. Documented Docker/systemd deployment recipes are available for the service, but a recipe is not evidence of a running remote deployment. Platform, package and GUI verification must be reported from the acceptance record rather than inferred from source or CI configuration. + +## Method evidence + +The [semantics research](research-v2-semantics.md), [architecture research](research-v2-architecture.md) and [original Capella UI observations](research-v2-ui.md) distinguish method guidance, inspected Capella behavior and Studio design decisions. Their primary-source links are informative; `language.rst` is the concrete contract for this implementation. No Capella source code, icons or branding assets were copied into Studio. diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 00000000..ae941938 --- /dev/null +++ b/docs/server.md @@ -0,0 +1,109 @@ +# Arcadia Studio server + +`arcadia-server` is a headless Rust REST service for the same schema-2 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each current project in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed. + +The complete HTTP contract is [api.rst](api.rst); machine-readable schemas are in [openapi.json](openapi.json), also served at `/api/v1/openapi.json`. The original [service research](research-v2-service.md) discusses broader alternatives; retained history, SQLite, idempotency receipts and stored transition plans from that research are **not** the implemented storage contract. + +## Build and run + +Build only the server to avoid GUI/renderer dependencies: + +```powershell +cargo build --release --locked -p oa-server +.\target\release\arcadia-server.exe +``` + +```sh +cargo build --release --locked -p oa-server +./target/release/arcadia-server +``` + +The default bind is `127.0.0.1:8787`. Default data storage is `%LOCALAPPDATA%/ArcadiaStudio/server` on Windows and `$XDG_DATA_HOME/arcadia-studio/server` or `~/.local/share/arcadia-studio/server` on Linux. Specify `--data-dir` for deployment, and use a local disk with adequate free space. The working OneDrive directory is not the default database location. `--help` and `--version` do not open the store. + +Supported options are `--bind IP:PORT`, `--data-dir PATH`, and `--token-file PATH`. `ARCADIA_SERVER_TOKEN` is an alternative to the token file; when both exist, the file wins. Tokens must have at least 32 printable ASCII characters without whitespace. Trailing CR/LF in a token file is removed. Tokens are never printed. Generate a random secret and restrict its file permissions to the service account. + +Loopback permits unauthenticated native clients unless a token is configured; unauthenticated requests must use a localhost or loopback-IP Host header. Binding any non-loopback IP requires a token. A configured token protects every model route; only health and OpenAPI are public. Requests carrying a browser `Origin` are rejected because the initial service has no browser/CORS integration. Artifact URIs are stored as model data and are never fetched or executed. + +## First API call + +With the loopback server running: + +```sh +curl --fail-with-body http://127.0.0.1:8787/health +curl --fail-with-body -i -X POST http://127.0.0.1:8787/api/v1/projects \ + -H 'Content-Type: application/json' -d '{"name":"Harbor assistance"}' +``` + +The create response returns a service `resource_id`, revision and `ETag`. Fetch `/api/v1/projects/{resource_id}` to obtain the full `{resource_id, revision, model}` envelope. Use the exact ETag as `If-Match` on updates. With authentication enabled, add `Authorization: Bearer `; avoid putting the token in URLs, shell history or shared example files. + +The service resource ID is distinct from the editable model ID. Model IDs, aliases, diagrams, artifact URIs/revisions and trace records round-trip through source/JSON. Imported copies receive new resource IDs. An artifact's `revision` is an opaque external-artifact revision string; it is not a retained server model revision. + +## Storage and concurrency + +The data directory contains `.arcadia.lock` and one `.json` file per hosted project. Each envelope has exactly `resource_id`, positive `revision` and `model`. The process keeps an exclusive OS file lock on the directory lock file for the entire store lifetime. A second process using the same directory fails at startup. Do not directly edit, synchronize or replace files while the service is running. + +Startup validates every committed envelope, its filename/UUID, revision, size and complete model. Any corrupt or unsupported envelope stops startup; the service does not silently skip bad data or invent an empty replacement. Sibling `.arcadia-write-*` files from an interrupted pre-commit write are not committed models and are ignored on startup. + +Parsing, validation and blocking disk operations run on blocking workers. A shared store mutex captures snapshots and serializes final revision checks and writes. Parsing, semantic validation and query processing use the captured snapshot outside that lock; an expensive candidate does not block the catalog or another model's preparation. Commit rechecks the exact ETag, so a candidate prepared against a superseded snapshot returns `412 revision_conflict`. A successful changed update increments revision once; an unchanged model returns `changed: false` and retains its revision. Each write serializes the whole envelope to a sibling temporary file, syncs it, and atomically replaces the destination. Unix also syncs the directory. The in-memory snapshot changes only after the replacement succeeds. A directory-sync error after replacement is reported as an uncertain write outcome while the in-memory head still matches the installed file. + +Atomic replacement protects against partially written envelopes. It does not promise survival of every device/filesystem power-loss failure, protect against out-of-band edits, or coordinate OneDrive/network filesystems. The storage directory must be local to this one process. DELETE permanently removes the envelope; there is no recycle bin, tombstone or server undo history. + +The service retains **only the current revision**. Pagination can supply the returned `revision`; if head changes between pages, it returns `409 snapshot_changed` so the caller can restart. Project-catalog pagination is live UUID-order pagination; concurrent creates/deletes may change later pages. There is no request receipt/idempotency ledger. Never automatically retry a model-creating request after an uncertain connection failure. Reload the project after an uncertain conditional write; the original ETag cannot overwrite an accepted newer revision. + +Back up by stopping the server cleanly and copying committed JSON envelopes to a separate location. Restore into an empty directory, start the service, and check readiness and representative exports. This preserves current models and revisions, not earlier revisions. Do not treat a live directory copy as a consistent multi-project backup. Keep backups outside the managed data directory. + +## Limits and errors + +- Raw request bodies: 8 MiB, including requests without `Content-Length`. Encoded/compressed request bodies are rejected. The same limit applies to serialized model JSON; the storage envelope permits small framing overhead. +- At most eight admitted model requests and eight active/waiting blocking workers. Overload returns `503` with `Retry-After: 1`. A 30-second request deadline bounds body receipt and response waiting. A started blocking write can finish after that deadline; `504 outcome_unknown` means reload before retrying. +- At most 5,000 elements plus relations, 20,000 total persistent model records, and 50,000 ordered step references per model. Graph requests allow 1–100 roots, depth 0–4, and at most 1,000 returned objects. List pages contain 1–1,000 records, default 100. +- Source declarations and decoded JSON records are counted before structural validation. V1 import includes the occurrences and placements migration will generate in its early budget check. These limits apply to import, source/model replacement, candidate validation and full-model transaction commands. Over-budget candidates cannot monopolize the catalog while being rejected. +- Transactions accept at most 200 commands, including nested batch wrappers. All commands apply to a candidate, then shared validation accepts or rejects the batch atomically. Dry run returns the candidate and diagnostics without writing. +- The fixed store cap is 128 hosted models and 256 MiB of serialized envelopes. New/imported/replaced models that exceed it return `507 store_capacity`. Startup enforces the same limits. Delete/export unneeded projects before adding more data; there are no hidden eviction or pruning rules. The server stores all current models in memory; memory includes Rust object overhead and in-flight candidates in addition to serialized bytes. + +Errors use `application/problem+json` with stable `code`, HTTP `status`, human `detail`, and contextual `issues` when available. Responses carry `X-Request-ID`; configured bearer credentials and source/model bodies are not logged. The core currently supplies human command errors; the API maps them to `422 command_rejected` without parsing English messages into invented machine codes. Source errors preserve line/column diagnostics. + +## Linux deployment + +`deployment/Dockerfile` builds a headless image from the repository root: + +```sh +docker build -f deployment/Dockerfile -t arcadia-server:local . +docker run --rm --name arcadia-server \ + -p 127.0.0.1:8787:8787 \ + -v arcadia-data:/data \ + --mount type=bind,src=/absolute/path/arcadia-token,dst=/run/secrets/arcadia-token,readonly \ + arcadia-server:local +``` + +The token file must be readable by UID 10001 inside the container. The default container binds `0.0.0.0` internally and therefore requires the token. Publishing only to host loopback keeps it behind a host TLS reverse proxy. Keep the volume on local storage; do not run multiple replicas against the same volume. + +For a native Linux installation, create an `arcadia` service account, install the release binary at `/usr/local/bin/arcadia-server`, put the restricted token file at `/etc/arcadia/token`, and install [arcadia-server.service](../deployment/arcadia-server.service). The unit's `StateDirectory` creates `/var/lib/arcadia` for that account. Inspect the unit and paths before enabling it with systemd. It binds loopback; expose it remotely through a TLS reverse proxy with request-header, connection and idle-time limits. No TLS endpoint or certificate provisioning is built into the binary. + +Ctrl+C/SIGTERM stops admission and drains HTTP requests. Already-started blocking tasks run to their completion boundary. The systemd example permits 45 seconds before a supervisor kill; an interrupted process recovers by loading the last installed envelopes. `/health/ready` means the initial store validation succeeded and the process can respond; it is not an ongoing disk-free-space or integrity probe. + +## Desktop and automation integration + +A desktop or automation client can query current JSON/source, edit with shared typed commands, validate/dry-run, and commit using the original ETag. For hosted editing, keep the user's draft until the service acknowledges the new revision. On `412`, preserve the draft and reload server state for review; a blind replacement retry would discard another client's work. + +External tools can use the artifact-link and trace query routes, and create/edit those records in the same atomic transaction as model changes. URI and external revision fields preserve the author's values; the service supplies no fetching, credentials, artifact-content verification or AI execution runtime. OpenAPI is the integration contract. An optional future adapter can call this API without introducing a second mutation path. + +`POST /projects/{id}/transitions/preview` produces a shared core transition plan and `base_revision` without storing a plan or changing the model. Send that response to `/transitions/apply` with the original ETag. Apply checks the server revision and the core's model-change fingerprint, preserves planned IDs, validates the candidate, and commits once. Plans with unresolved conflicts cannot apply. A submitted plan is a client-owned command proposal, not a signed server authorization; a client able to apply a plan already has the same write permission as the transaction endpoint. + +## Verification + +```sh +cargo fmt -p oa-server -- --check +cargo clippy -p oa-server --all-targets --locked -- -D warnings +cargo test -p oa-server --locked +``` + +The HTTP integration suite starts real loopback listeners over isolated temporary stores. It covers competing conditional writes, stale and nonexistent deletion, restart persistence, source/JSON equality, dry-run and no-op behavior, invalid candidates, graph bounds, authentication, body limits, startup corruption/locking, capacity, transition revision checks, artifact persistence and a forced replacement failure. It also checks prompt rejection of over-budget source/native/legacy imports and replacements, catalog responsiveness during candidate preparation, and a stale prepared candidate losing to a newer HTTP edit. A deterministic store test checks the final revision comparison with two fully prepared candidates. Passing those tests does not by itself prove power-loss behavior, external reverse-proxy configuration, container execution or remote deployment. Platform/release results must be recorded only after running their checks. + +Verified on 2026-09-05: + +- Windows R1/R2/R4 source/API integration: all 16 current HTTP/configuration tests plus the deterministic commit test pass; strict server all-targets Clippy passes. New cases cover mission maps, branching/reused chains, typed call value edits, original V2 compatibility, source/JSON equality and persistence after restart. See `harness/release-language-integration.md`; native UI and remaining release semantics are separate gates. +- Windows R2 release checkpoint: all 14 server integration/configuration tests plus one deterministic commit test pass; formatting and strict all-targets Clippy pass. A separate optimized executable probe rejects the previously stalling 12,000-activity request in 0.050 seconds; concurrent JSON import and accepted-source preparation leave catalog reads responsive. See `harness/release-text-service-audit.md` for exact measurements and scope. +- Ubuntu 24.04 under WSL, prior V2 baseline: all 12 then-existing tests passed using native Linux processes and temporary filesystem stores; strict all-targets Clippy and the optimized build passed with the locked, vendored dependency set. A separate release-executable smoke run verified bearer authentication, non-loopback token enforcement, exclusive directory locking, conditional create/update/delete, update and deletion persistence across restarts, served OpenAPI, and clean SIGTERM shutdown. The new first-release changes still require native Linux re-verification. +- `systemd-analyze verify` passes after copying the unit to a native temporary directory with mode 0644 and substituting the built executable's path for its documented installation path. The service account, token installation and system-wide service activation were not performed. +- A Docker daemon was unavailable, so the Docker image was not built or run. No remote service or TLS proxy was deployed. The declared Rust 1.90 minimum and Docker builder were not independently compiled in this verification; the available stable toolchains built the locked dependencies successfully. diff --git a/docs/v2-interface.md b/docs/v2-interface.md new file mode 100644 index 00000000..c4d3dce1 --- /dev/null +++ b/docs/v2-interface.md @@ -0,0 +1,70 @@ +# V2 integration interface — frozen core skeleton + +`crates/oa-core/src/model.rs` now contains the exact public schema-2 declarations. This file records integration contracts; Rust declarations are authoritative. All new model fields are required in schema-2 JSON; schema-1 migration constructs them explicitly. + +- `Project {schema_version,id,alias,name,description,elements,relations,diagrams,artifact_links,traces}`. `Project::empty()` gives empty maps and no invented SA system. `SCHEMA_VERSION = 2`. +- One global UUID and alias namespace includes project, elements, relations, diagrams, diagram nodes, artifact links and traces. Helpers: `valid_alias`, `reserved_alias`, `Project::identities`, `contains`, `fresh_alias(name,fallback,id)`. +- `Element {id,alias,name,description,parent:Option,body:ElementBody}`. `ElementBody::Oa(OaBody) | Sa(SaBody)`. Kind/layer are derived through `.kind()` / `.layer()`; no `.kind` or `.position` fields remain. +- `OaBody`: Entity, Actor, Role, Activity, Capability, Process{steps}, Shared(SharedBody). `SaBody`: System, Actor, Function, Capability, Mission, FunctionalChain{steps}, Shared(SharedBody). +- `SharedBody`: ExchangeItem, DataType, DataField, Interface, Port, Scenario, Lifeline, Occurrence, StateMachine, Region, State (kind distinguishes State/Mode/Initial/Final), Constraint. Payloads and enum spelling are in model.rs. Common `.shared()`/`.shared_mut()` and `.steps()`/`.steps_mut()` accessors support property forms. +- `steps` contain occurrence ELEMENT IDs, never relation IDs. An occurrence is parented by its process/chain/scenario/branch. Its Exchange payload references a relation. Lifelines are elements parented by a scenario. StateMachine -> Region -> State/Mode/Initial/Final uses parent ownership, with optional nested regions under state/mode. +- `Relation {id,alias,name,description,body:RelationBody}` exposes `.kind()`, `.source()`, `.target()`. Body exposes `.endpoints()`, `.set_endpoints(a,b)`, `.items()`. `RelationBody::new(kind,source,target)` creates empty/default payload fields. +- V1 Contribution maps to `CapabilityInvolvement{source,target}`; V1 Realization to `ChainInvolvement{source,target}`. Allocation/Interaction retain spelling; Interaction includes an `items: Vec` field. +- `Diagram {id,alias,name,description,layer,projection,nodes}`; `ProjectionKind`: Overview, Decomposition, Allocation, Traceability. Traceability accepts both layers; other views are layer-filtered. `NodePlacement {id,alias,element,position,size,collapsed}` has no description or parent. Absolute coordinates, positive finite size. At most one placement per element per diagram. +- `ArtifactLink {id,alias,name,description,owner,relation,uri,revision:Option,fragment:Option}`; owner is any existing model object. Preserve URI/revision verbatim; never fetch during validation. +- `Trace {id,alias,name,description,oa,sa,kind,baseline:Option}`. Canonical OA and SA endpoints can reference elements or relations. Baseline contains `oa` and `sa` `TracedProperties{name,description}` only, no live UUID references. + +## Reference-valued fields + +- Element.parent; process/chain/scenario.steps; occurrence Branch.occurrences; Fragment.branches. +- DataField.type_ref only when `TypeRef::Named(Id)`. +- Interface.items; Port.interface; Scenario.context; Lifeline.represented. +- Occurrence Exchange.exchange/sender/receiver; Action.action/lifeline; State.state/lifeline. +- Constraint.constrained. +- All RelationBody.source/target; exchange variants.items. +- NodePlacement.element; ArtifactLink.owner; Trace.oa/sa. +- Map keys, all record `id` fields and diagram-node IDs are identity fields, not ordinary references. + +`Option` uses None for incomplete drafts; warnings explain missing references. Existing-but-wrong-kind or dangling Some values are structural errors. Ordered vectors preserve order and repeated use through distinct occurrence IDs. No dual ownership fields exist. + +## Command/session API + +Commands derive Clone/Serialize/Deserialize. Public `execute_batch(&Project, &[Command]) -> Result` clones, applies all operations, validates once, and returns the accepted candidate without mutating input. + +Core mutation commands: Replace(Project), CreateElement(Element), EditElement(Id,Element), CreateRelation(Relation), EditRelation(Id,Relation), CreateDiagram(Diagram), EditDiagram(Id,Diagram), CreateArtifact(ArtifactLink), EditArtifact(Id,ArtifactLink), CreateTrace(Trace), EditTrace(Id,Trace), CreateNode{diagram,node}, EditNode{diagram,original,node}, RelocateNode{node,diagram}, EditProject{id,alias,name,description}, Reparent{element,parent}, Reidentify(Id,Id), Rename(Id,String), Describe(Id,String), SetAlias(Id,String), Delete(Id), Batch(Vec). + +`RelocateNode { node: Id, diagram: Id }` moves the existing placement between diagrams without deleting it or pruning references. Identity, alias, geometry, collapse state and attached artifacts survive. The transaction rejects duplicate element placement or incompatible target layer. Relocation into the same diagram is a no-op. `Reparent` to the same parent is also a no-op, preserving ordered occurrence/branch membership and history. + +Convenience commands: Add(Kind), AddElement{kind,layer,name,parent}, Connect(RelationKind,String,Id,Id), AddStep(sequence,exchange), RemoveStep(sequence,index), MoveStep(sequence,from,to), SetSteps(sequence,Vec), MoveNode{diagram,node,position,with_descendants}, Layout(diagram). + +`Session::try_new(Project)->Result`, `Session::new(Project)->Session` (validated convenience, panics on invalid input), `project()->&Project`, `revision()->u64`, `execute(Command)->Result,ModelError>`, `execute_many(&[Command])->Result<(),ModelError>`, undo/redo/dirty/mark_saved/can_undo/can_redo. Snapshot history remains bounded. Every changed accepted mutation/undo/redo increments revision; no-op or failed command does not. + +Transition types/functions are documented below. No request receipts, transport versions, or server infrastructure belong in core. + +P1 extension to frozen relation interface: FunctionalExchange now has `source_port: Option, target_port: Option` in addition to source/target FUNCTION IDs and items. RelationBody adds `ExchangeAllocation{source,target}` (boundary-exchange relation → same-layer interaction/functional-exchange relation), `InterfaceProvision{source,target}` and `InterfaceRequirement{source,target}` (participant/port → Interface), and `StateAction{source,target,phase:StateActionKind}` (State/Mode → Activity/Function; Entry/Do/Exit). These fields participate in reference maintenance. `.permits(Kind,Kind)` is a convenience only; ExchangeAllocation requires relation-ID-aware validation/candidate queries. + +## Transition API (implemented) + +`transitions::plan_transition(&Project, &TransitionRequest) -> Result`; `apply_transition(&Project, &TransitionPlan) -> Result`; `trace_drift(&Project,&Trace)->Drift`; `project_fingerprint(&Project)->String`. + +`transitions::counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec` is the shared choice policy for UI and other clients. It returns supported explicit element counterpart kinds in stable `Kind::ALL` order using the same compatibility predicate as transition validation; an empty list means no automatic element mapping. + +`TransitionRequest {direction:OaToSa|SaToOa, sources:Vec, scope:SelectedOnly|Required|Subtree, mappings:Vec, fields:Vec, resolutions:Vec, destination_diagram:Option}`. `TransitionRequest::new(direction,sources)` defaults to selected-only and name/description fields. `TransitionMapping {source,target:Option,target_kind:Option}` selects reuse or explicit creation kind. An explicit mapping with target=None means create a new counterpart even if another trace exists. Without mapping, one existing trace is reused; multiple traces require selection. + +`FieldResolution {source,field:Name|Description,decision:TakeSource|KeepTarget|Value(String)}`. `TransitionPlan {base_fingerprint,request,changes,conflicts,warnings,commands}`. Changes carry source/target IDs, Create/Update/Reuse and before/after field changes. Conflicts have optional source/target/field and message. Conflicts prevent apply. The deterministic fingerprint guards stale plans, is not a security hash, and does not introduce persistent project revision fields. Planned IDs and aliases remain unchanged during apply. Server owns request idempotency/revision storage. Apply is one candidate transaction; Session adoption can use Command::Replace(candidate) for one Undo. + +OA Capability defaults to SA SystemCapability; explicit OA Capability to SA Mission is supported with `TraceKind::Manual`, and SA Mission defaults to OA Capability. Repeated previews preserve reviewed divergent name/description values when the source has not changed. Source-only changes propose an update; different edits on both sides require review. An explicit decision records both accepted values. Target-only edits do not advance the skipped field's baseline, preserving reverse propagation. + +## Projection API (implemented) + +`projection::project_view(&Project,diagram_id)->ProjectedView {nodes,edges}`. `ProjectedNode {id,element,position,size,visual_parent:Option,depth,collapsed}`; id/visual_parent are placement IDs. `ProjectedEdge {id,source,target,trace}` uses placement endpoint IDs. `descendant_nodes(&Project,diagram,node)->Vec` includes collapsed descendant placements, so moving a container also keeps their future expanded positions consistent. + + +## Final integration notes + +- `validation::can_parent(project,kind,layer,parent)`, `can_connect(project,kind,source,target)`, `effective_performer(project,work)`, and `allocation_summary(project,work)` are public. The latter two derive information and never materialize allocations. Shared roles are valid; ambiguity and role/direct-allocation mismatch are warnings. One direct performer per leaf is structural. +- Owned state machines require participant owners; an unowned state machine is valid incomplete work with a warning, admitted by `can_parent`. Functional port ownership/direction, scenario lifeline contexts, state/action ownership, interface item contracts and boundary/work exchange allocations share core validation. +- Projection expands container bounds around descendants using 24-unit side/bottom padding and 44-unit header space. Persisted positions/sizes remain editable requested anchors/minima. Layout recursively places descendants and resizes their containers. Allocation projection suppresses the redundant allocation edge. +- `mark_saved(snapshot: Project)` keeps the explicit snapshot signature. Core accepted project state is private. +- Existing counterpart updates are intentionally scoped to reviewed name/description fields. New selected objects/dependencies copy mapped typed bodies. Existing structural bodies are not automatically overwritten; they remain editable using ordinary commands/source. Trace drift warnings use the same name/description baselines. Many-to-many trace records are supported, while ambiguous counterpart or conflicting merge choices require explicit transition selections. +- `Project::demo()` now supplies 39 semantic elements, three focused diagrams (OA allocation, SA allocation, SA decomposition), ten reviewed traces, and two external code/CAD artifact links. It covers information/material/energy items, typed fields, interfaces/ports, repeated scenario messages, a process/chain, normal/degraded modes, guarded transitions, availability, a typed state entry action, and a constraint. Its validator output is empty. diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 00000000..e84a0a69 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,49 @@ +# V2 validation record — 2026-09-05 + +Arcadia Studio 0.2.0 was built and exercised on Windows and native Ubuntu 24.04.2/WSLg, using Rust 1.98.1 and Iced 0.14. Historical V1 reports are not counted as V2 evidence. Remote CI and the declared Rust 1.90 minimum were not executed. + +## Automated evidence + +| Area | Observed result | +|---|---| +| Windows final source | All 93 tests pass: 56 core, 25 desktop and 12 actual HTTP/storage tests. Workspace formatting, strict all-target Clippy and optimized desktop/server build pass. | +| Native Linux | Full 91-test baseline and strict workspace Clippy passed, then both subsequent desktop-only fixes passed the latest 25 desktop tests and strict desktop all-target Clippy. Together these native runs cover the current 93 tests; a final full-workspace 93-test invocation was not performed. Formatting and final optimized desktop/server build pass. | +| Core invariants | Typed whole-model equality, deterministic V1 migration, exact strings/coordinates, strict input, atomic failure/undo, all-record identity/deletion, nested projection, roles/allocations, scenarios/states, reviewed transition baselines, Mission mapping and placement relocation. | +| Desktop behavior | Lossless graphical drafts, typed reference choices, constructor preservation, shared perspective and placement edits, modal guards, invalid save/source recovery, counterpart focus/decision context and history. Behavior/Data authoring preserves architecture while updating ordered semantic occurrences. | +| Service | Real HTTP ETags/concurrent writes, restart, invalid candidates, dry runs, bounded queries, body/auth/Host limits, exclusive store lock/capacity and transition/artifact/source workflows on Windows and Ubuntu. | +| Contracts and examples | Both reST contracts render to HTML5 with Docutils report/halt level 2 and no warnings. V2 text/JSON examples compare exactly with zero diagnostics: 39 elements, 23 relations, 3 diagrams, 10 traces and 2 artifacts. V1 examples are explicitly named migration fixtures. | + +A stale legacy-fixture include path initially failed a final test invocation. Both references were corrected and the complete suite rerun successfully. No compiler warning or failed check is being treated as a passing result. + +## Actual native interface evidence + +The lead's [native acceptance record](../harness/verification-v2-native.md) and fresh [independent Windows review](../harness/review-v2-final-ui.md) document actual Computer Use, not screenshots inferred from source. + +- Lead Windows: original repaired Capella OA/SA/transition inspection; Studio source colors/live located lint/rejected Apply/Revert; process, repeated scenario and state navigation; new/empty model; OA→SA creation and explicit reverse creation/undo; modal background guards. +- Lead Windows: opened actual REST-edited source, observed changed project/function/CAD fields, edited CAD revision and saved. External file modification then caused a visible save refusal with external bytes preserved. +- Fresh Windows reviewer: graphical fragment→branch→exchange authoring with a quoted guard and filtered references; saved model; minimum 1100×700, default 1440×900 and 2560-wide layouts; state-route selection/details and 150% nested typography on rebuilt executables. +- Lead Linux: actual file dialog/save/reopen, shared Energy perspective edit, explicit manual trace, diagram layer changes, placement identity/drag/relocation and artifact ownership. Continuous merged-text input, Keep target, target-change invalidation, reviewed commit and reverse preview were operated after fixes. +- Final packaged Windows: independently created a scenario Action with function/lifeline references, saved/reopened it, and verified the architecture retained its three original function placements. +- Final packaged Linux: lead launched the exact refreshed executable, reopened that Windows-authored file, and observed its 40 elements/23 relations/zero checks, preserved nested SA architecture and rendered saved Action. Closed normally with exit 0 and no stderr. + +Native Linux ran with Mesa software Vulkan: + +~~~sh +WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json ./arcadia-studio +~~~ + +## Independent findings and disposition + +Requirements, correctness, native UI/runtime and architecture reviews were completed by agents independent of the relevant implementation. Material findings were reproduced, fixed and reverified. These included transition baseline stability and target-specific decisions, external save protection, sequence ordering and Fragment defaults, Mission mapping, graphical field parity, modal ownership, reference filtering, placement focus, behavior selection, merged-input focus and nested typography. Final focused reviews reported no new material finding. + +The original [UI review](../harness/review-v2-ui-runtime.md), [correctness review](../harness/review-v2-correctness.md), [requirements/architecture review](../harness/review-v2-requirements-architecture.md) and [final regression review](../harness/review-v2-final-regressions.md) retain their intermediate observations and later dispositions. Pending items in those historical sections are closed by the later final/native records. + +## Service and release evidence + +The [service verification record](server.md#verification) covers optimized Windows/Linux processes, health/create/update/stale-delete/auth/store-lock/restart/SIGTERM checks and native systemd unit validation. The separate [API transfer review](../harness/review-v2-api-transfer.md) records the Windows source→REST→native desktop transfer. Docker execution, remote deployment and systemd installation were not performed. No test service or Studio acceptance window is intentionally left running. + +[Linux verification](../harness/verification-v2-linux.md) records native commands and executable hashes. [Package verification](../harness/verification-v2-packages.md) records archive inventories and executable equality. Release bundles contain both desktop/server binaries, examples, rendered/source contracts, documentation, deployment recipes and acceptance records. + +## Limits + +Graphics adapter coverage beyond the tested Windows and Ubuntu/WSLg environment, accessibility certification and production-scale performance are untested. LA/PA, execution/simulation, full Capella/SysML conformance, native Capella interchange, autosave and direct desktop-hosted editing remain outside the [specified prototype](scope.md). diff --git a/examples/coastal-rescue.arcadia b/examples/coastal-rescue.arcadia new file mode 100644 index 00000000..13395396 --- /dev/null +++ b/examples/coastal-rescue.arcadia @@ -0,0 +1,900 @@ +arcadia 2; +project mission "Coastal rescue — operational need to system behavior" id "eceea6cd-108b-45a9-9505-8b7fa462af96" { + description "A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts."; + + sa_lifeline vessel_service_lifeline "Vessel service lifeline" id "03b71dc5-435f-4c7f-923a-cb6e672212ba" { + description ""; + parent @repeated_distress_reports; + represented @vessel_communication_service; + } + + sa_initial start_reporting "Start reporting" id "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978" { + description ""; + do_activity ""; + entry ""; + exit ""; + kind Initial; + parent @reporting_quality_region; + } + + sa_state_machine reporting_operation_modes "Reporting operation modes" id "1a1b215a-c102-4948-9c88-dabfadc8e08e" { + description ""; + parent @rescue_coordination_system; + } + + sa_exchange_item backup_electrical_energy "Backup electrical energy" id "1b81d963-06e7-42d3-af31-bf5236829477" { + description ""; + kind Energy; + parent none; + } + + sa_lifeline coordination_system_lifeline "Coordination system lifeline" id "1f7da722-c91e-4d0e-a344-c1a2511dd96b" { + description ""; + parent @repeated_distress_reports; + represented @rescue_coordination_system; + } + + sa_mission support_timely_rescue_decisions "Support timely rescue decisions" id "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746" { + description ""; + parent none; + } + + sa_occurrence updated_distress_report "Updated distress report" id "28b8e56d-7ddf-4950-8c30-7385c044cc72" { + body { + Exchange { + exchange @validated_distress_report; + receiver @coordination_system_lifeline; + sender @vessel_service_lifeline; + }; + }; + description ""; + parent @repeated_distress_reports; + postcondition "The incident position is refreshed without creating a second incident."; + precondition "The vessel has more recent position information."; + } + + oa_activity assess_incident "Assess incident" id "3388cc0d-0abc-4479-a948-6740266ec236" { + description ""; + parent @respond_to_distress; + } + + sa_constraint response_time_budget "Response time budget" id "3584d050-0b4e-4620-bbea-8c3c87d2a19f" { + constrained [@classify_incident_and_alert_operator, @validated_distress_report]; + description ""; + expression "incident_alert_latency <= 120 s"; + parent none; + } + + sa_system rescue_coordination_system "Rescue coordination system" id "47e28c63-803b-4ea6-af92-e778f62ecc3e" { + description ""; + parent none; + } + + sa_functional_chain digital_distress_handling "Digital distress handling" id "49743116-7777-4df8-9601-74fd4cba22c0" { + description ""; + parent none; + steps [@report_handled]; + } + + oa_process distress_to_response "Distress to response" id "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5" { + description ""; + parent none; + steps [@distress_received]; + } + + oa_occurrence distress_received "Distress received" id "5e0c0d0e-5eb5-467c-bc35-dfe29d534703" { + body { + Exchange { + exchange @distress_signal; + receiver none; + sender none; + }; + }; + description ""; + parent @distress_to_response; + postcondition ""; + precondition ""; + } + + sa_exchange_item digital_distress_report "Digital distress report" id "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" { + description ""; + kind Information; + parent none; + } + + oa_capability protect_life_at_sea "Protect life at sea" id "6ca5efd6-2e92-4508-a02d-4013793743cb" { + description ""; + parent none; + } + + sa_port report_function_output "Report function output" id "6ecf32ed-4009-48ba-b374-3bf156f6c355" { + description ""; + direction Out; + interface @distress_reporting_interface; + parent @transmit_distress_report; + } + + oa_entity rescue_coordination "Rescue coordination" id "721b68ab-5136-408e-890d-925215d5661e" { + description ""; + parent none; + } + + sa_capability coordinate_rescue_response "Coordinate rescue response" id "7781bd9f-e788-4049-9af4-9a3eab40d377" { + description ""; + parent none; + } + + oa_actor vessel_in_distress "Vessel in distress" id "7e8ea298-1c68-4c9e-994d-6008d47f62ce" { + description ""; + parent none; + } + + oa_exchange_item distress_report "Distress report" id "835c2e84-f4e9-4592-b5df-d205803d97b7" { + description ""; + kind Information; + parent none; + } + + sa_port system_reporting_input "System reporting input" id "8477d19d-4a62-43bb-a7e3-845b9b3d1002" { + description ""; + direction In; + interface @distress_reporting_interface; + parent @rescue_coordination_system; + } + + sa_occurrence initial_distress_report "Initial distress report" id "84a0db92-ac74-493a-af29-74ec7715d7fa" { + body { + Exchange { + exchange @validated_distress_report; + receiver @coordination_system_lifeline; + sender @vessel_service_lifeline; + }; + }; + description ""; + parent @repeated_distress_reports; + postcondition ""; + precondition ""; + } + + sa_port vessel_reporting_output "Vessel reporting output" id "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932" { + description ""; + direction Out; + interface @distress_reporting_interface; + parent @vessel_communication_service; + } + + sa_function transmit_distress_report "Transmit distress report" id "b0a649d6-0558-48f5-8437-161be8329159" { + description ""; + parent @manage_distress_response; + } + + sa_function classify_incident_and_alert_operator "Classify incident and alert operator" id "b1cc95c7-1d2f-42d0-9d93-587954bd563b" { + description ""; + parent @manage_distress_response; + } + + sa_occurrence report_handled "Report handled" id "bcdb57f1-5fa8-46ce-9498-b467cea5d98f" { + body { + Exchange { + exchange @validated_distress_report; + receiver none; + sender none; + }; + }; + description ""; + parent @digital_distress_handling; + postcondition ""; + precondition ""; + } + + sa_function manage_distress_response "Manage distress response" id "c64f93a7-bf47-42b5-b432-7589b9144f2c" { + description ""; + parent none; + } + + sa_actor vessel_communication_service "Vessel communication service" id "cfb45cf5-bebe-4632-9bde-0ee023c42583" { + description ""; + parent none; + } + + sa_mode normal_reporting "Normal reporting" id "d00b5d17-8660-4955-a70d-988a6e6b6c4e" { + description ""; + do_activity ""; + entry ""; + exit ""; + kind Mode; + parent @reporting_quality_region; + } + + sa_data_field reported_latitude "Reported latitude" id "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36" { + description ""; + maximum 1; + minimum 1; + parent @digital_distress_report; + type_ref Real; + unit "degrees north"; + } + + oa_activity report_distress "Report distress" id "d50e92c4-840e-4822-bcf7-033feb2eb879" { + description ""; + parent @respond_to_distress; + } + + sa_mode degraded_reporting "Degraded reporting" id "d7fad7e9-d799-4fa6-b49f-1304d3285b5e" { + description ""; + do_activity ""; + entry ""; + exit ""; + kind Mode; + parent @reporting_quality_region; + } + + sa_exchange_item rescue_supply_package "Rescue supply package" id "eb8cf40e-8194-403f-9896-f968790a9504" { + description ""; + kind Material; + parent none; + } + + sa_region reporting_quality_region "Reporting quality region" id "edc1ad70-8fc9-42a1-a668-05c4738bb225" { + description ""; + parent @reporting_operation_modes; + } + + sa_interface distress_reporting_interface "Distress reporting interface" id "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" { + description ""; + items [@digital_distress_report]; + parent none; + } + + oa_role incident_coordinator "Incident coordinator" id "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b" { + description ""; + parent none; + } + + sa_scenario repeated_distress_reports "Repeated distress reports" id "f7514a9c-933c-4133-8b77-37c0dccd935f" { + context @coordinate_rescue_response; + description ""; + parent none; + postcondition "The operator has a classified incident with the latest vessel report."; + precondition "A vessel has detected an emergency and the reporting service is available."; + steps [@initial_distress_report, @updated_distress_report]; + } + + oa_activity respond_to_distress "Respond to distress" id "fa23a2a8-6f13-49e3-be47-f757f26d5c9f" { + description ""; + parent none; + } + + sa_port assessment_function_input "Assessment function input" id "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f" { + description ""; + direction In; + interface @distress_reporting_interface; + parent @classify_incident_and_alert_operator; + } + + state_transition signal_restored "Signal restored" id "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018" { + description ""; + effect "Clear the degraded-reporting alert."; + guard "position_is_current"; + source @degraded_reporting; + target @normal_reporting; + trigger "validated_report_received"; + } + + state_action review_on_degraded_entry "Review on degraded entry" id "044cdc5f-a34a-4e51-9774-c1774a33dc91" { + description ""; + phase Entry; + source @degraded_reporting; + target @classify_incident_and_alert_operator; + } + + allocation assesses_incident "Assesses incident" id "17fef4ab-acc7-4f21-b70d-5d64f6ad1261" { + description ""; + source @rescue_coordination; + target @assess_incident; + } + + availability assessment_in_degraded_mode "Assessment in degraded mode" id "2c092728-eafb-47e3-bcc7-8b795e36a25e" { + description ""; + source @classify_incident_and_alert_operator; + target @degraded_reporting; + } + + state_transition signal_degraded "Signal degraded" id "331c78a2-b6b3-452f-a238-84a6a3df3547" { + description ""; + effect "Flag the incident and request an operator review."; + guard "seconds_since_last_report > 30"; + source @normal_reporting; + target @degraded_reporting; + trigger "report_timeout"; + } + + capability_involvement system_capability_support "System capability support" id "39ff3786-d94c-40ad-90fa-c009ab6f53f9" { + description ""; + source @classify_incident_and_alert_operator; + target @coordinate_rescue_response; + } + + chain_involvement operational_realization "Operational realization" id "41d934ba-76f7-4425-b433-2d4d293ab0df" { + description ""; + source @distress_to_response; + target @protect_life_at_sea; + } + + role_realization fulfils_coordinator_role "Fulfils coordinator role" id "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef" { + description ""; + source @rescue_coordination; + target @incident_coordinator; + } + + state_transition initialize_reporting "Initialize reporting" id "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b" { + description ""; + effect ""; + guard ""; + source @start_reporting; + target @normal_reporting; + trigger ""; + } + + capability_involvement operational_contribution "Operational contribution" id "64ab242b-6043-4452-9b28-7205fec309df" { + description ""; + source @assess_incident; + target @protect_life_at_sea; + } + + interaction distress_signal "Distress signal" id "689a8e09-b7ca-4d97-b551-a21e3b438bfe" { + description ""; + items [@distress_report]; + source @report_distress; + target @assess_incident; + } + + allocation vessel_transmission "Vessel transmission" id "6a82e148-d38e-4103-8481-046b4c474952" { + description ""; + source @vessel_communication_service; + target @transmit_distress_report; + } + + interface_requirement reporting_service_required "Reporting service required" id "7c32cbe5-0821-4793-a37f-9f017fc8a3f4" { + description ""; + source @rescue_coordination_system; + target @distress_reporting_interface; + } + + chain_involvement functional_chain_support "Functional chain support" id "92468388-63c2-4f2c-a1fd-2c3f27b76398" { + description ""; + source @digital_distress_handling; + target @coordinate_rescue_response; + } + + allocation system_assessment "System assessment" id "93e3a60d-33c0-4110-83d9-63f2902d5eeb" { + description ""; + source @rescue_coordination_system; + target @classify_incident_and_alert_operator; + } + + role_assignment coordinates_assessment "Coordinates assessment" id "a06f90b9-361b-4da2-86a9-0447df519e0c" { + description ""; + source @incident_coordinator; + target @assess_incident; + } + + functional_exchange validated_distress_report "Validated distress report" id "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" { + description ""; + items [@digital_distress_report]; + source @transmit_distress_report; + source_port @report_function_output; + target @classify_incident_and_alert_operator; + target_port @assessment_function_input; + } + + availability assessment_in_normal_mode "Assessment in normal mode" id "bce2587e-f47c-4d85-ac6d-a098170bbaa5" { + description ""; + source @classify_incident_and_alert_operator; + target @normal_reporting; + } + + exchange_allocation report_carried_by_connection "Report carried by connection" id "d6a2c8d2-2a4f-4d90-a490-c2c661db4423" { + description ""; + source @distress_reporting_connection; + target @validated_distress_report; + } + + mission_capability mission_capability "Mission capability" id "e88a9747-0d1c-4813-8273-e6710f622e9b" { + description ""; + source @support_timely_rescue_decisions; + target @coordinate_rescue_response; + } + + boundary_exchange distress_reporting_connection "Distress reporting connection" id "eb7ae249-165c-4717-bebc-da476eebb05e" { + description ""; + items [@digital_distress_report]; + source @vessel_reporting_output; + target @system_reporting_input; + } + + interface_provision reporting_service_provided "Reporting service provided" id "ec3f9e3f-5134-49af-9969-29f818dfcb46" { + description ""; + source @vessel_communication_service; + target @distress_reporting_interface; + } + + allocation reports_distress "Reports distress" id "f844c555-b407-402c-81c9-4928245249ca" { + description ""; + source @vessel_in_distress; + target @report_distress; + } + + diagram sa_system_boundary_and_functions "SA system boundary and functions" id "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a" { + description "A focused projection of the shared engineering model."; + layer Sa; + projection Allocation; + } + + node view_vessel_communication_service id "00edf6bc-b857-439a-8747-f1f66c1675e2" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @vessel_communication_service; + position { + x 60.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_transmit_distress_report id "035dab75-ef9e-4ca3-a107-87262e3c680b" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @transmit_distress_report; + position { + x 84.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_support_timely_rescue_decisions id "16b5055a-c98b-4276-b564-85b7fe1f020f" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @support_timely_rescue_decisions; + position { + x 392.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_digital_distress_handling id "4aa2fb09-b367-402c-a438-d4f918f04e08" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @digital_distress_handling; + position { + x 676.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_rescue_coordination_system id "5a6d6a1c-9915-4e33-8299-b512c50b18f5" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @rescue_coordination_system; + position { + x 960.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_manage_distress_response id "621e0e5e-d1be-4693-b95a-7c48d36e07b4" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @manage_distress_response; + position { + x 60.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_coordinate_rescue_response id "9f41eaff-3456-494b-b136-9f6f01afd90f" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @coordinate_rescue_response; + position { + x 344.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_classify_incident_and_alert_operator id "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68" { + collapsed false; + diagram @sa_system_boundary_and_functions; + element @classify_incident_and_alert_operator; + position { + x 984.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + diagram sa_functional_decomposition "SA functional decomposition" id "a67786d6-5b08-4b06-a0c8-584a04f179ef" { + description "A focused projection of the shared engineering model."; + layer Sa; + projection Decomposition; + } + + node view_classify_incident_and_alert_operator_5ae489f2 id "5ae489f2-4be1-4842-b464-37900983e333" { + collapsed false; + diagram @sa_functional_decomposition; + element @classify_incident_and_alert_operator; + position { + x 84.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_manage_distress_response_80ab61e0 id "80ab61e0-d1f2-4008-9858-0759bdaa02e3" { + collapsed false; + diagram @sa_functional_decomposition; + element @manage_distress_response; + position { + x 60.0; + y 60.0; + }; + size { + height 276.0; + width 272.0; + }; + } + + node view_transmit_distress_report_e02431fd id "e02431fd-c27e-4b48-9c94-37b8e3d55375" { + collapsed false; + diagram @sa_functional_decomposition; + element @transmit_distress_report; + position { + x 84.0; + y 218.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + diagram oa_participants_and_activities "OA participants and activities" id "d7f59b88-4a9b-4906-b859-1c6d044bbb52" { + description "A focused projection of the shared engineering model."; + layer Oa; + projection Allocation; + } + + node view_rescue_coordination id "2f5fed31-e2dc-419d-9ebc-a5c9e9682277" { + collapsed false; + diagram @oa_participants_and_activities; + element @rescue_coordination; + position { + x 60.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_protect_life_at_sea id "91fe2d63-0083-4df2-9c29-563766e1b706" { + collapsed false; + diagram @oa_participants_and_activities; + element @protect_life_at_sea; + position { + x 392.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_distress_to_response id "a045fd40-84ea-48f3-a6d4-95641492d960" { + collapsed false; + diagram @oa_participants_and_activities; + element @distress_to_response; + position { + x 676.0; + y 60.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_report_distress id "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe" { + collapsed false; + diagram @oa_participants_and_activities; + element @report_distress; + position { + x 984.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_assess_incident id "acaa5a3d-51c4-45da-b5aa-4825bccd747c" { + collapsed false; + diagram @oa_participants_and_activities; + element @assess_incident; + position { + x 84.0; + y 108.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_vessel_in_distress id "af3a61b3-db02-400f-bd18-b4422e2db7a7" { + collapsed false; + diagram @oa_participants_and_activities; + element @vessel_in_distress; + position { + x 960.0; + y 60.0; + }; + size { + height 166.0; + width 272.0; + }; + } + + node view_respond_to_distress id "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649" { + collapsed false; + diagram @oa_participants_and_activities; + element @respond_to_distress; + position { + x 60.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + node view_incident_coordinator id "fcbda72a-5478-4d77-8330-16f89cdefc37" { + collapsed false; + diagram @oa_participants_and_activities; + element @incident_coordinator; + position { + x 344.0; + y 286.0; + }; + size { + height 94.0; + width 224.0; + }; + } + + artifact console_enclosure_cad "Console enclosure CAD" id "091b5a40-1631-49ef-8b53-1471c016a13f" { + description "Illustrative external engineering reference; opening a model does not fetch it."; + fragment none; + owner @rescue_coordination_system; + relation References; + revision "B.3"; + uri "cad://rescue-console/enclosure.step"; + } + + artifact response_implementation "Response implementation" id "0b09f95e-0545-44c3-b687-84489ce1422b" { + description "Illustrative external engineering reference; opening a model does not fetch it."; + fragment none; + owner @classify_incident_and_alert_operator; + relation Implements; + revision "design-v2"; + uri "git+https://example.org/rescue/control?path=src/response.rs"; + } + + trace trace_respond_to_distress "Respond to distress ↔ Manage distress response" id "1d0a908f-9e22-42b7-9aac-26bbe3f5df78" { + baseline { + oa { + description ""; + name "Respond to distress"; + }; + sa { + description ""; + name "Manage distress response"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Refinement; + oa @respond_to_distress; + sa @manage_distress_response; + } + + trace trace_rescue_coordination "Rescue coordination ↔ Rescue coordination system" id "49f2b55a-6dcc-44ba-a68f-a305942db7c9" { + baseline { + oa { + description ""; + name "Rescue coordination"; + }; + sa { + description ""; + name "Rescue coordination system"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Participant; + oa @rescue_coordination; + sa @rescue_coordination_system; + } + + trace trace_distress_to_response "Distress to response ↔ Digital distress handling" id "4ac98979-6c84-4a66-b043-373b70bb5203" { + baseline { + oa { + description ""; + name "Distress to response"; + }; + sa { + description ""; + name "Digital distress handling"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Sequence; + oa @distress_to_response; + sa @digital_distress_handling; + } + + trace trace_protect_life_at_sea "Protect life at sea ↔ Coordinate rescue response" id "4b6acf23-dab3-462d-b45f-066aea916932" { + baseline { + oa { + description ""; + name "Protect life at sea"; + }; + sa { + description ""; + name "Coordinate rescue response"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Capability; + oa @protect_life_at_sea; + sa @coordinate_rescue_response; + } + + trace trace_distress_signal "Distress signal ↔ Validated distress report" id "587cca51-69e8-47e7-9674-32ff49926a22" { + baseline { + oa { + description ""; + name "Distress signal"; + }; + sa { + description ""; + name "Validated distress report"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Exchange; + oa @distress_signal; + sa @validated_distress_report; + } + + trace trace_distress_report "Distress report ↔ Digital distress report" id "7790684b-220f-443d-8754-52c29414367f" { + baseline { + oa { + description ""; + name "Distress report"; + }; + sa { + description ""; + name "Digital distress report"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Item; + oa @distress_report; + sa @digital_distress_report; + } + + trace trace_vessel_in_distress "Vessel in distress ↔ Vessel communication service" id "9a8101e4-ebce-4836-a0e3-63440c2e1e87" { + baseline { + oa { + description ""; + name "Vessel in distress"; + }; + sa { + description ""; + name "Vessel communication service"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Participant; + oa @vessel_in_distress; + sa @vessel_communication_service; + } + + trace trace_assess_incident "Assess incident ↔ Classify incident and alert operator" id "b8fee65b-21da-4a27-9b42-575d417bd942" { + baseline { + oa { + description ""; + name "Assess incident"; + }; + sa { + description ""; + name "Classify incident and alert operator"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Refinement; + oa @assess_incident; + sa @classify_incident_and_alert_operator; + } + + trace trace_distress_received "Distress received ↔ Report handled" id "be4365d6-2a43-433c-935f-c039b266242f" { + baseline { + oa { + description ""; + name "Distress received"; + }; + sa { + description ""; + name "Report handled"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Scenario; + oa @distress_received; + sa @report_handled; + } + + trace trace_report_distress "Report distress ↔ Transmit distress report" id "d3f224a5-0b2d-4eba-93c9-232b9e84e742" { + baseline { + oa { + description ""; + name "Report distress"; + }; + sa { + description ""; + name "Transmit distress report"; + }; + }; + description "Reviewed OA/SA correspondence in the coastal rescue example."; + kind Refinement; + oa @report_distress; + sa @transmit_distress_report; + } + +} diff --git a/examples/coastal-rescue.json b/examples/coastal-rescue.json new file mode 100644 index 00000000..b98ce427 --- /dev/null +++ b/examples/coastal-rescue.json @@ -0,0 +1,1414 @@ +{ + "schema_version": 2, + "id": "eceea6cd-108b-45a9-9505-8b7fa462af96", + "alias": "mission", + "name": "Coastal rescue — operational need to system behavior", + "description": "A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts.", + "elements": { + "03b71dc5-435f-4c7f-923a-cb6e672212ba": { + "id": "03b71dc5-435f-4c7f-923a-cb6e672212ba", + "alias": "vessel_service_lifeline", + "name": "Vessel service lifeline", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Lifeline": { + "represented": "cfb45cf5-bebe-4632-9bde-0ee023c42583" + } + } + } + } + }, + "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978": { + "id": "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978", + "alias": "start_reporting", + "name": "Start reporting", + "description": "", + "parent": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "body": { + "Sa": { + "Shared": { + "State": { + "kind": "Initial", + "entry": "", + "exit": "", + "do_activity": "" + } + } + } + } + }, + "1a1b215a-c102-4948-9c88-dabfadc8e08e": { + "id": "1a1b215a-c102-4948-9c88-dabfadc8e08e", + "alias": "reporting_operation_modes", + "name": "Reporting operation modes", + "description": "", + "parent": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "body": { + "Sa": { + "Shared": "StateMachine" + } + } + }, + "1b81d963-06e7-42d3-af31-bf5236829477": { + "id": "1b81d963-06e7-42d3-af31-bf5236829477", + "alias": "backup_electrical_energy", + "name": "Backup electrical energy", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "ExchangeItem": { + "kind": "Energy" + } + } + } + } + }, + "1f7da722-c91e-4d0e-a344-c1a2511dd96b": { + "id": "1f7da722-c91e-4d0e-a344-c1a2511dd96b", + "alias": "coordination_system_lifeline", + "name": "Coordination system lifeline", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Lifeline": { + "represented": "47e28c63-803b-4ea6-af92-e778f62ecc3e" + } + } + } + } + }, + "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746": { + "id": "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746", + "alias": "support_timely_rescue_decisions", + "name": "Support timely rescue decisions", + "description": "", + "parent": null, + "body": { + "Sa": "Mission" + } + }, + "28b8e56d-7ddf-4950-8c30-7385c044cc72": { + "id": "28b8e56d-7ddf-4950-8c30-7385c044cc72", + "alias": "updated_distress_report", + "name": "Updated distress report", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "sender": "03b71dc5-435f-4c7f-923a-cb6e672212ba", + "receiver": "1f7da722-c91e-4d0e-a344-c1a2511dd96b" + } + }, + "precondition": "The vessel has more recent position information.", + "postcondition": "The incident position is refreshed without creating a second incident." + } + } + } + } + }, + "3388cc0d-0abc-4479-a948-6740266ec236": { + "id": "3388cc0d-0abc-4479-a948-6740266ec236", + "alias": "assess_incident", + "name": "Assess incident", + "description": "", + "parent": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "body": { + "Oa": "Activity" + } + }, + "3584d050-0b4e-4620-bbea-8c3c87d2a19f": { + "id": "3584d050-0b4e-4620-bbea-8c3c87d2a19f", + "alias": "response_time_budget", + "name": "Response time budget", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "Constraint": { + "expression": "incident_alert_latency <= 120 s", + "constrained": [ + "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" + ] + } + } + } + } + }, + "47e28c63-803b-4ea6-af92-e778f62ecc3e": { + "id": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "alias": "rescue_coordination_system", + "name": "Rescue coordination system", + "description": "", + "parent": null, + "body": { + "Sa": "System" + } + }, + "49743116-7777-4df8-9601-74fd4cba22c0": { + "id": "49743116-7777-4df8-9601-74fd4cba22c0", + "alias": "digital_distress_handling", + "name": "Digital distress handling", + "description": "", + "parent": null, + "body": { + "Sa": { + "FunctionalChain": { + "steps": [ + "bcdb57f1-5fa8-46ce-9498-b467cea5d98f" + ] + } + } + } + }, + "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5": { + "id": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "alias": "distress_to_response", + "name": "Distress to response", + "description": "", + "parent": null, + "body": { + "Oa": { + "Process": { + "steps": [ + "5e0c0d0e-5eb5-467c-bc35-dfe29d534703" + ] + } + } + } + }, + "5e0c0d0e-5eb5-467c-bc35-dfe29d534703": { + "id": "5e0c0d0e-5eb5-467c-bc35-dfe29d534703", + "alias": "distress_received", + "name": "Distress received", + "description": "", + "parent": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "body": { + "Oa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "689a8e09-b7ca-4d97-b551-a21e3b438bfe", + "sender": null, + "receiver": null + } + }, + "precondition": "", + "postcondition": "" + } + } + } + } + }, + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f": { + "id": "5e16affb-7fa4-4a3d-8bfe-6340586ab83f", + "alias": "digital_distress_report", + "name": "Digital distress report", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "ExchangeItem": { + "kind": "Information" + } + } + } + } + }, + "6ca5efd6-2e92-4508-a02d-4013793743cb": { + "id": "6ca5efd6-2e92-4508-a02d-4013793743cb", + "alias": "protect_life_at_sea", + "name": "Protect life at sea", + "description": "", + "parent": null, + "body": { + "Oa": "Capability" + } + }, + "6ecf32ed-4009-48ba-b374-3bf156f6c355": { + "id": "6ecf32ed-4009-48ba-b374-3bf156f6c355", + "alias": "report_function_output", + "name": "Report function output", + "description": "", + "parent": "b0a649d6-0558-48f5-8437-161be8329159", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "Out", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + }, + "721b68ab-5136-408e-890d-925215d5661e": { + "id": "721b68ab-5136-408e-890d-925215d5661e", + "alias": "rescue_coordination", + "name": "Rescue coordination", + "description": "", + "parent": null, + "body": { + "Oa": "Entity" + } + }, + "7781bd9f-e788-4049-9af4-9a3eab40d377": { + "id": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "alias": "coordinate_rescue_response", + "name": "Coordinate rescue response", + "description": "", + "parent": null, + "body": { + "Sa": "Capability" + } + }, + "7e8ea298-1c68-4c9e-994d-6008d47f62ce": { + "id": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "alias": "vessel_in_distress", + "name": "Vessel in distress", + "description": "", + "parent": null, + "body": { + "Oa": "Actor" + } + }, + "835c2e84-f4e9-4592-b5df-d205803d97b7": { + "id": "835c2e84-f4e9-4592-b5df-d205803d97b7", + "alias": "distress_report", + "name": "Distress report", + "description": "", + "parent": null, + "body": { + "Oa": { + "Shared": { + "ExchangeItem": { + "kind": "Information" + } + } + } + } + }, + "8477d19d-4a62-43bb-a7e3-845b9b3d1002": { + "id": "8477d19d-4a62-43bb-a7e3-845b9b3d1002", + "alias": "system_reporting_input", + "name": "System reporting input", + "description": "", + "parent": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "In", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + }, + "84a0db92-ac74-493a-af29-74ec7715d7fa": { + "id": "84a0db92-ac74-493a-af29-74ec7715d7fa", + "alias": "initial_distress_report", + "name": "Initial distress report", + "description": "", + "parent": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "body": { + "Sa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "sender": "03b71dc5-435f-4c7f-923a-cb6e672212ba", + "receiver": "1f7da722-c91e-4d0e-a344-c1a2511dd96b" + } + }, + "precondition": "", + "postcondition": "" + } + } + } + } + }, + "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932": { + "id": "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932", + "alias": "vessel_reporting_output", + "name": "Vessel reporting output", + "description": "", + "parent": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "Out", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + }, + "b0a649d6-0558-48f5-8437-161be8329159": { + "id": "b0a649d6-0558-48f5-8437-161be8329159", + "alias": "transmit_distress_report", + "name": "Transmit distress report", + "description": "", + "parent": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "body": { + "Sa": "Function" + } + }, + "b1cc95c7-1d2f-42d0-9d93-587954bd563b": { + "id": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "alias": "classify_incident_and_alert_operator", + "name": "Classify incident and alert operator", + "description": "", + "parent": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "body": { + "Sa": "Function" + } + }, + "bcdb57f1-5fa8-46ce-9498-b467cea5d98f": { + "id": "bcdb57f1-5fa8-46ce-9498-b467cea5d98f", + "alias": "report_handled", + "name": "Report handled", + "description": "", + "parent": "49743116-7777-4df8-9601-74fd4cba22c0", + "body": { + "Sa": { + "Shared": { + "Occurrence": { + "body": { + "Exchange": { + "exchange": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "sender": null, + "receiver": null + } + }, + "precondition": "", + "postcondition": "" + } + } + } + } + }, + "c64f93a7-bf47-42b5-b432-7589b9144f2c": { + "id": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "alias": "manage_distress_response", + "name": "Manage distress response", + "description": "", + "parent": null, + "body": { + "Sa": "Function" + } + }, + "cfb45cf5-bebe-4632-9bde-0ee023c42583": { + "id": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "alias": "vessel_communication_service", + "name": "Vessel communication service", + "description": "", + "parent": null, + "body": { + "Sa": "Actor" + } + }, + "d00b5d17-8660-4955-a70d-988a6e6b6c4e": { + "id": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "alias": "normal_reporting", + "name": "Normal reporting", + "description": "", + "parent": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "body": { + "Sa": { + "Shared": { + "State": { + "kind": "Mode", + "entry": "", + "exit": "", + "do_activity": "" + } + } + } + } + }, + "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36": { + "id": "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36", + "alias": "reported_latitude", + "name": "Reported latitude", + "description": "", + "parent": "5e16affb-7fa4-4a3d-8bfe-6340586ab83f", + "body": { + "Sa": { + "Shared": { + "DataField": { + "type_ref": "Real", + "minimum": 1, + "maximum": 1, + "unit": "degrees north" + } + } + } + } + }, + "d50e92c4-840e-4822-bcf7-033feb2eb879": { + "id": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "alias": "report_distress", + "name": "Report distress", + "description": "", + "parent": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "body": { + "Oa": "Activity" + } + }, + "d7fad7e9-d799-4fa6-b49f-1304d3285b5e": { + "id": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "alias": "degraded_reporting", + "name": "Degraded reporting", + "description": "", + "parent": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "body": { + "Sa": { + "Shared": { + "State": { + "kind": "Mode", + "entry": "", + "exit": "", + "do_activity": "" + } + } + } + } + }, + "eb8cf40e-8194-403f-9896-f968790a9504": { + "id": "eb8cf40e-8194-403f-9896-f968790a9504", + "alias": "rescue_supply_package", + "name": "Rescue supply package", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "ExchangeItem": { + "kind": "Material" + } + } + } + } + }, + "edc1ad70-8fc9-42a1-a668-05c4738bb225": { + "id": "edc1ad70-8fc9-42a1-a668-05c4738bb225", + "alias": "reporting_quality_region", + "name": "Reporting quality region", + "description": "", + "parent": "1a1b215a-c102-4948-9c88-dabfadc8e08e", + "body": { + "Sa": { + "Shared": "Region" + } + } + }, + "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4": { + "id": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4", + "alias": "distress_reporting_interface", + "name": "Distress reporting interface", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "Interface": { + "items": [ + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" + ] + } + } + } + } + }, + "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b": { + "id": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b", + "alias": "incident_coordinator", + "name": "Incident coordinator", + "description": "", + "parent": null, + "body": { + "Oa": "Role" + } + }, + "f7514a9c-933c-4133-8b77-37c0dccd935f": { + "id": "f7514a9c-933c-4133-8b77-37c0dccd935f", + "alias": "repeated_distress_reports", + "name": "Repeated distress reports", + "description": "", + "parent": null, + "body": { + "Sa": { + "Shared": { + "Scenario": { + "context": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "precondition": "A vessel has detected an emergency and the reporting service is available.", + "postcondition": "The operator has a classified incident with the latest vessel report.", + "steps": [ + "84a0db92-ac74-493a-af29-74ec7715d7fa", + "28b8e56d-7ddf-4950-8c30-7385c044cc72" + ] + } + } + } + } + }, + "fa23a2a8-6f13-49e3-be47-f757f26d5c9f": { + "id": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "alias": "respond_to_distress", + "name": "Respond to distress", + "description": "", + "parent": null, + "body": { + "Oa": "Activity" + } + }, + "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f": { + "id": "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f", + "alias": "assessment_function_input", + "name": "Assessment function input", + "description": "", + "parent": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "body": { + "Sa": { + "Shared": { + "Port": { + "direction": "In", + "interface": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + } + } + } + }, + "relations": { + "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018": { + "id": "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018", + "alias": "signal_restored", + "name": "Signal restored", + "description": "", + "body": { + "StateTransition": { + "source": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "target": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "trigger": "validated_report_received", + "guard": "position_is_current", + "effect": "Clear the degraded-reporting alert." + } + } + }, + "044cdc5f-a34a-4e51-9774-c1774a33dc91": { + "id": "044cdc5f-a34a-4e51-9774-c1774a33dc91", + "alias": "review_on_degraded_entry", + "name": "Review on degraded entry", + "description": "", + "body": { + "StateAction": { + "source": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "target": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "phase": "Entry" + } + } + }, + "17fef4ab-acc7-4f21-b70d-5d64f6ad1261": { + "id": "17fef4ab-acc7-4f21-b70d-5d64f6ad1261", + "alias": "assesses_incident", + "name": "Assesses incident", + "description": "", + "body": { + "Allocation": { + "source": "721b68ab-5136-408e-890d-925215d5661e", + "target": "3388cc0d-0abc-4479-a948-6740266ec236" + } + } + }, + "2c092728-eafb-47e3-bcc7-8b795e36a25e": { + "id": "2c092728-eafb-47e3-bcc7-8b795e36a25e", + "alias": "assessment_in_degraded_mode", + "name": "Assessment in degraded mode", + "description": "", + "body": { + "Availability": { + "source": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "target": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e" + } + } + }, + "331c78a2-b6b3-452f-a238-84a6a3df3547": { + "id": "331c78a2-b6b3-452f-a238-84a6a3df3547", + "alias": "signal_degraded", + "name": "Signal degraded", + "description": "", + "body": { + "StateTransition": { + "source": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "target": "d7fad7e9-d799-4fa6-b49f-1304d3285b5e", + "trigger": "report_timeout", + "guard": "seconds_since_last_report > 30", + "effect": "Flag the incident and request an operator review." + } + } + }, + "39ff3786-d94c-40ad-90fa-c009ab6f53f9": { + "id": "39ff3786-d94c-40ad-90fa-c009ab6f53f9", + "alias": "system_capability_support", + "name": "System capability support", + "description": "", + "body": { + "CapabilityInvolvement": { + "source": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "target": "7781bd9f-e788-4049-9af4-9a3eab40d377" + } + } + }, + "41d934ba-76f7-4425-b433-2d4d293ab0df": { + "id": "41d934ba-76f7-4425-b433-2d4d293ab0df", + "alias": "operational_realization", + "name": "Operational realization", + "description": "", + "body": { + "ChainInvolvement": { + "source": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "target": "6ca5efd6-2e92-4508-a02d-4013793743cb" + } + } + }, + "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef": { + "id": "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef", + "alias": "fulfils_coordinator_role", + "name": "Fulfils coordinator role", + "description": "", + "body": { + "RoleRealization": { + "source": "721b68ab-5136-408e-890d-925215d5661e", + "target": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b" + } + } + }, + "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b": { + "id": "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b", + "alias": "initialize_reporting", + "name": "Initialize reporting", + "description": "", + "body": { + "StateTransition": { + "source": "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978", + "target": "d00b5d17-8660-4955-a70d-988a6e6b6c4e", + "trigger": "", + "guard": "", + "effect": "" + } + } + }, + "64ab242b-6043-4452-9b28-7205fec309df": { + "id": "64ab242b-6043-4452-9b28-7205fec309df", + "alias": "operational_contribution", + "name": "Operational contribution", + "description": "", + "body": { + "CapabilityInvolvement": { + "source": "3388cc0d-0abc-4479-a948-6740266ec236", + "target": "6ca5efd6-2e92-4508-a02d-4013793743cb" + } + } + }, + "689a8e09-b7ca-4d97-b551-a21e3b438bfe": { + "id": "689a8e09-b7ca-4d97-b551-a21e3b438bfe", + "alias": "distress_signal", + "name": "Distress signal", + "description": "", + "body": { + "Interaction": { + "source": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "target": "3388cc0d-0abc-4479-a948-6740266ec236", + "items": [ + "835c2e84-f4e9-4592-b5df-d205803d97b7" + ] + } + } + }, + "6a82e148-d38e-4103-8481-046b4c474952": { + "id": "6a82e148-d38e-4103-8481-046b4c474952", + "alias": "vessel_transmission", + "name": "Vessel transmission", + "description": "", + "body": { + "Allocation": { + "source": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "target": "b0a649d6-0558-48f5-8437-161be8329159" + } + } + }, + "7c32cbe5-0821-4793-a37f-9f017fc8a3f4": { + "id": "7c32cbe5-0821-4793-a37f-9f017fc8a3f4", + "alias": "reporting_service_required", + "name": "Reporting service required", + "description": "", + "body": { + "InterfaceRequirement": { + "source": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "target": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + }, + "92468388-63c2-4f2c-a1fd-2c3f27b76398": { + "id": "92468388-63c2-4f2c-a1fd-2c3f27b76398", + "alias": "functional_chain_support", + "name": "Functional chain support", + "description": "", + "body": { + "ChainInvolvement": { + "source": "49743116-7777-4df8-9601-74fd4cba22c0", + "target": "7781bd9f-e788-4049-9af4-9a3eab40d377" + } + } + }, + "93e3a60d-33c0-4110-83d9-63f2902d5eeb": { + "id": "93e3a60d-33c0-4110-83d9-63f2902d5eeb", + "alias": "system_assessment", + "name": "System assessment", + "description": "", + "body": { + "Allocation": { + "source": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "target": "b1cc95c7-1d2f-42d0-9d93-587954bd563b" + } + } + }, + "a06f90b9-361b-4da2-86a9-0447df519e0c": { + "id": "a06f90b9-361b-4da2-86a9-0447df519e0c", + "alias": "coordinates_assessment", + "name": "Coordinates assessment", + "description": "", + "body": { + "RoleAssignment": { + "source": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b", + "target": "3388cc0d-0abc-4479-a948-6740266ec236" + } + } + }, + "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d": { + "id": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "alias": "validated_distress_report", + "name": "Validated distress report", + "description": "", + "body": { + "FunctionalExchange": { + "source": "b0a649d6-0558-48f5-8437-161be8329159", + "target": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "source_port": "6ecf32ed-4009-48ba-b374-3bf156f6c355", + "target_port": "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f", + "items": [ + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" + ] + } + } + }, + "bce2587e-f47c-4d85-ac6d-a098170bbaa5": { + "id": "bce2587e-f47c-4d85-ac6d-a098170bbaa5", + "alias": "assessment_in_normal_mode", + "name": "Assessment in normal mode", + "description": "", + "body": { + "Availability": { + "source": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "target": "d00b5d17-8660-4955-a70d-988a6e6b6c4e" + } + } + }, + "d6a2c8d2-2a4f-4d90-a490-c2c661db4423": { + "id": "d6a2c8d2-2a4f-4d90-a490-c2c661db4423", + "alias": "report_carried_by_connection", + "name": "Report carried by connection", + "description": "", + "body": { + "ExchangeAllocation": { + "source": "eb7ae249-165c-4717-bebc-da476eebb05e", + "target": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" + } + } + }, + "e88a9747-0d1c-4813-8273-e6710f622e9b": { + "id": "e88a9747-0d1c-4813-8273-e6710f622e9b", + "alias": "mission_capability", + "name": "Mission capability", + "description": "", + "body": { + "MissionCapability": { + "source": "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746", + "target": "7781bd9f-e788-4049-9af4-9a3eab40d377" + } + } + }, + "eb7ae249-165c-4717-bebc-da476eebb05e": { + "id": "eb7ae249-165c-4717-bebc-da476eebb05e", + "alias": "distress_reporting_connection", + "name": "Distress reporting connection", + "description": "", + "body": { + "BoundaryExchange": { + "source": "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932", + "target": "8477d19d-4a62-43bb-a7e3-845b9b3d1002", + "items": [ + "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" + ] + } + } + }, + "ec3f9e3f-5134-49af-9969-29f818dfcb46": { + "id": "ec3f9e3f-5134-49af-9969-29f818dfcb46", + "alias": "reporting_service_provided", + "name": "Reporting service provided", + "description": "", + "body": { + "InterfaceProvision": { + "source": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "target": "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" + } + } + }, + "f844c555-b407-402c-81c9-4928245249ca": { + "id": "f844c555-b407-402c-81c9-4928245249ca", + "alias": "reports_distress", + "name": "Reports distress", + "description": "", + "body": { + "Allocation": { + "source": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "target": "d50e92c4-840e-4822-bcf7-033feb2eb879" + } + } + } + }, + "diagrams": { + "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a": { + "id": "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a", + "alias": "sa_system_boundary_and_functions", + "name": "SA system boundary and functions", + "description": "A focused projection of the shared engineering model.", + "layer": "Sa", + "projection": "Allocation", + "nodes": { + "00edf6bc-b857-439a-8747-f1f66c1675e2": { + "id": "00edf6bc-b857-439a-8747-f1f66c1675e2", + "alias": "view_vessel_communication_service", + "element": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "position": { + "x": 60.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "035dab75-ef9e-4ca3-a107-87262e3c680b": { + "id": "035dab75-ef9e-4ca3-a107-87262e3c680b", + "alias": "view_transmit_distress_report", + "element": "b0a649d6-0558-48f5-8437-161be8329159", + "position": { + "x": 84.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "16b5055a-c98b-4276-b564-85b7fe1f020f": { + "id": "16b5055a-c98b-4276-b564-85b7fe1f020f", + "alias": "view_support_timely_rescue_decisions", + "element": "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746", + "position": { + "x": 392.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "4aa2fb09-b367-402c-a438-d4f918f04e08": { + "id": "4aa2fb09-b367-402c-a438-d4f918f04e08", + "alias": "view_digital_distress_handling", + "element": "49743116-7777-4df8-9601-74fd4cba22c0", + "position": { + "x": 676.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "5a6d6a1c-9915-4e33-8299-b512c50b18f5": { + "id": "5a6d6a1c-9915-4e33-8299-b512c50b18f5", + "alias": "view_rescue_coordination_system", + "element": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "position": { + "x": 960.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "621e0e5e-d1be-4693-b95a-7c48d36e07b4": { + "id": "621e0e5e-d1be-4693-b95a-7c48d36e07b4", + "alias": "view_manage_distress_response", + "element": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "position": { + "x": 60.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "9f41eaff-3456-494b-b136-9f6f01afd90f": { + "id": "9f41eaff-3456-494b-b136-9f6f01afd90f", + "alias": "view_coordinate_rescue_response", + "element": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "position": { + "x": 344.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68": { + "id": "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68", + "alias": "view_classify_incident_and_alert_operator", + "element": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "position": { + "x": 984.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + } + } + }, + "a67786d6-5b08-4b06-a0c8-584a04f179ef": { + "id": "a67786d6-5b08-4b06-a0c8-584a04f179ef", + "alias": "sa_functional_decomposition", + "name": "SA functional decomposition", + "description": "A focused projection of the shared engineering model.", + "layer": "Sa", + "projection": "Decomposition", + "nodes": { + "5ae489f2-4be1-4842-b464-37900983e333": { + "id": "5ae489f2-4be1-4842-b464-37900983e333", + "alias": "view_classify_incident_and_alert_operator_5ae489f2", + "element": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "position": { + "x": 84.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "80ab61e0-d1f2-4008-9858-0759bdaa02e3": { + "id": "80ab61e0-d1f2-4008-9858-0759bdaa02e3", + "alias": "view_manage_distress_response_80ab61e0", + "element": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "position": { + "x": 60.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 276.0 + }, + "collapsed": false + }, + "e02431fd-c27e-4b48-9c94-37b8e3d55375": { + "id": "e02431fd-c27e-4b48-9c94-37b8e3d55375", + "alias": "view_transmit_distress_report_e02431fd", + "element": "b0a649d6-0558-48f5-8437-161be8329159", + "position": { + "x": 84.0, + "y": 218.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + } + } + }, + "d7f59b88-4a9b-4906-b859-1c6d044bbb52": { + "id": "d7f59b88-4a9b-4906-b859-1c6d044bbb52", + "alias": "oa_participants_and_activities", + "name": "OA participants and activities", + "description": "A focused projection of the shared engineering model.", + "layer": "Oa", + "projection": "Allocation", + "nodes": { + "2f5fed31-e2dc-419d-9ebc-a5c9e9682277": { + "id": "2f5fed31-e2dc-419d-9ebc-a5c9e9682277", + "alias": "view_rescue_coordination", + "element": "721b68ab-5136-408e-890d-925215d5661e", + "position": { + "x": 60.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "91fe2d63-0083-4df2-9c29-563766e1b706": { + "id": "91fe2d63-0083-4df2-9c29-563766e1b706", + "alias": "view_protect_life_at_sea", + "element": "6ca5efd6-2e92-4508-a02d-4013793743cb", + "position": { + "x": 392.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "a045fd40-84ea-48f3-a6d4-95641492d960": { + "id": "a045fd40-84ea-48f3-a6d4-95641492d960", + "alias": "view_distress_to_response", + "element": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "position": { + "x": 676.0, + "y": 60.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe": { + "id": "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe", + "alias": "view_report_distress", + "element": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "position": { + "x": 984.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "acaa5a3d-51c4-45da-b5aa-4825bccd747c": { + "id": "acaa5a3d-51c4-45da-b5aa-4825bccd747c", + "alias": "view_assess_incident", + "element": "3388cc0d-0abc-4479-a948-6740266ec236", + "position": { + "x": 84.0, + "y": 108.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "af3a61b3-db02-400f-bd18-b4422e2db7a7": { + "id": "af3a61b3-db02-400f-bd18-b4422e2db7a7", + "alias": "view_vessel_in_distress", + "element": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "position": { + "x": 960.0, + "y": 60.0 + }, + "size": { + "width": 272.0, + "height": 166.0 + }, + "collapsed": false + }, + "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649": { + "id": "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649", + "alias": "view_respond_to_distress", + "element": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "position": { + "x": 60.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + }, + "fcbda72a-5478-4d77-8330-16f89cdefc37": { + "id": "fcbda72a-5478-4d77-8330-16f89cdefc37", + "alias": "view_incident_coordinator", + "element": "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b", + "position": { + "x": 344.0, + "y": 286.0 + }, + "size": { + "width": 224.0, + "height": 94.0 + }, + "collapsed": false + } + } + } + }, + "artifact_links": { + "091b5a40-1631-49ef-8b53-1471c016a13f": { + "id": "091b5a40-1631-49ef-8b53-1471c016a13f", + "alias": "console_enclosure_cad", + "name": "Console enclosure CAD", + "description": "Illustrative external engineering reference; opening a model does not fetch it.", + "owner": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "relation": "References", + "uri": "cad://rescue-console/enclosure.step", + "revision": "B.3", + "fragment": null + }, + "0b09f95e-0545-44c3-b687-84489ce1422b": { + "id": "0b09f95e-0545-44c3-b687-84489ce1422b", + "alias": "response_implementation", + "name": "Response implementation", + "description": "Illustrative external engineering reference; opening a model does not fetch it.", + "owner": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "relation": "Implements", + "uri": "git+https://example.org/rescue/control?path=src/response.rs", + "revision": "design-v2", + "fragment": null + } + }, + "traces": { + "1d0a908f-9e22-42b7-9aac-26bbe3f5df78": { + "id": "1d0a908f-9e22-42b7-9aac-26bbe3f5df78", + "alias": "trace_respond_to_distress", + "name": "Respond to distress ↔ Manage distress response", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "fa23a2a8-6f13-49e3-be47-f757f26d5c9f", + "sa": "c64f93a7-bf47-42b5-b432-7589b9144f2c", + "kind": "Refinement", + "baseline": { + "oa": { + "name": "Respond to distress", + "description": "" + }, + "sa": { + "name": "Manage distress response", + "description": "" + } + } + }, + "49f2b55a-6dcc-44ba-a68f-a305942db7c9": { + "id": "49f2b55a-6dcc-44ba-a68f-a305942db7c9", + "alias": "trace_rescue_coordination", + "name": "Rescue coordination ↔ Rescue coordination system", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "721b68ab-5136-408e-890d-925215d5661e", + "sa": "47e28c63-803b-4ea6-af92-e778f62ecc3e", + "kind": "Participant", + "baseline": { + "oa": { + "name": "Rescue coordination", + "description": "" + }, + "sa": { + "name": "Rescue coordination system", + "description": "" + } + } + }, + "4ac98979-6c84-4a66-b043-373b70bb5203": { + "id": "4ac98979-6c84-4a66-b043-373b70bb5203", + "alias": "trace_distress_to_response", + "name": "Distress to response ↔ Digital distress handling", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5", + "sa": "49743116-7777-4df8-9601-74fd4cba22c0", + "kind": "Sequence", + "baseline": { + "oa": { + "name": "Distress to response", + "description": "" + }, + "sa": { + "name": "Digital distress handling", + "description": "" + } + } + }, + "4b6acf23-dab3-462d-b45f-066aea916932": { + "id": "4b6acf23-dab3-462d-b45f-066aea916932", + "alias": "trace_protect_life_at_sea", + "name": "Protect life at sea ↔ Coordinate rescue response", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "6ca5efd6-2e92-4508-a02d-4013793743cb", + "sa": "7781bd9f-e788-4049-9af4-9a3eab40d377", + "kind": "Capability", + "baseline": { + "oa": { + "name": "Protect life at sea", + "description": "" + }, + "sa": { + "name": "Coordinate rescue response", + "description": "" + } + } + }, + "587cca51-69e8-47e7-9674-32ff49926a22": { + "id": "587cca51-69e8-47e7-9674-32ff49926a22", + "alias": "trace_distress_signal", + "name": "Distress signal ↔ Validated distress report", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "689a8e09-b7ca-4d97-b551-a21e3b438bfe", + "sa": "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d", + "kind": "Exchange", + "baseline": { + "oa": { + "name": "Distress signal", + "description": "" + }, + "sa": { + "name": "Validated distress report", + "description": "" + } + } + }, + "7790684b-220f-443d-8754-52c29414367f": { + "id": "7790684b-220f-443d-8754-52c29414367f", + "alias": "trace_distress_report", + "name": "Distress report ↔ Digital distress report", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "835c2e84-f4e9-4592-b5df-d205803d97b7", + "sa": "5e16affb-7fa4-4a3d-8bfe-6340586ab83f", + "kind": "Item", + "baseline": { + "oa": { + "name": "Distress report", + "description": "" + }, + "sa": { + "name": "Digital distress report", + "description": "" + } + } + }, + "9a8101e4-ebce-4836-a0e3-63440c2e1e87": { + "id": "9a8101e4-ebce-4836-a0e3-63440c2e1e87", + "alias": "trace_vessel_in_distress", + "name": "Vessel in distress ↔ Vessel communication service", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "7e8ea298-1c68-4c9e-994d-6008d47f62ce", + "sa": "cfb45cf5-bebe-4632-9bde-0ee023c42583", + "kind": "Participant", + "baseline": { + "oa": { + "name": "Vessel in distress", + "description": "" + }, + "sa": { + "name": "Vessel communication service", + "description": "" + } + } + }, + "b8fee65b-21da-4a27-9b42-575d417bd942": { + "id": "b8fee65b-21da-4a27-9b42-575d417bd942", + "alias": "trace_assess_incident", + "name": "Assess incident ↔ Classify incident and alert operator", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "3388cc0d-0abc-4479-a948-6740266ec236", + "sa": "b1cc95c7-1d2f-42d0-9d93-587954bd563b", + "kind": "Refinement", + "baseline": { + "oa": { + "name": "Assess incident", + "description": "" + }, + "sa": { + "name": "Classify incident and alert operator", + "description": "" + } + } + }, + "be4365d6-2a43-433c-935f-c039b266242f": { + "id": "be4365d6-2a43-433c-935f-c039b266242f", + "alias": "trace_distress_received", + "name": "Distress received ↔ Report handled", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "5e0c0d0e-5eb5-467c-bc35-dfe29d534703", + "sa": "bcdb57f1-5fa8-46ce-9498-b467cea5d98f", + "kind": "Scenario", + "baseline": { + "oa": { + "name": "Distress received", + "description": "" + }, + "sa": { + "name": "Report handled", + "description": "" + } + } + }, + "d3f224a5-0b2d-4eba-93c9-232b9e84e742": { + "id": "d3f224a5-0b2d-4eba-93c9-232b9e84e742", + "alias": "trace_report_distress", + "name": "Report distress ↔ Transmit distress report", + "description": "Reviewed OA/SA correspondence in the coastal rescue example.", + "oa": "d50e92c4-840e-4822-bcf7-033feb2eb879", + "sa": "b0a649d6-0558-48f5-8437-161be8329159", + "kind": "Refinement", + "baseline": { + "oa": { + "name": "Report distress", + "description": "" + }, + "sa": { + "name": "Transmit distress report", + "description": "" + } + } + } + } +} diff --git a/examples/legacy-v1-coastal-rescue.arcadia b/examples/legacy-v1-coastal-rescue.arcadia new file mode 100644 index 00000000..5e1e202f --- /dev/null +++ b/examples/legacy-v1-coastal-rescue.arcadia @@ -0,0 +1,58 @@ +arcadia 1; +project "Coastal rescue operation" id "fefd610d-12ef-4d8f-bd89-3c575f77e8eb" { + activity coordinate_response "Coordinate response" id "00edd523-1260-4453-98d6-584209d3d38d" { + description "An operational activity performed to achieve the mission, independent of a technical solution."; + position (940, 250); + } + + entity rescue_coordination "Rescue coordination" id "1e73f3e2-82bc-411a-a7f3-6ff2def80935" { + description "An operational organization participating in the rescue mission."; + position (70, 70); + } + + activity recover_survivors "Recover survivors" id "51eed5ae-b270-4b28-8518-bf36a9dde7cf" { + description "An operational activity performed to achieve the mission, independent of a technical solution."; + position (650, 430); + } + + activity assess_incident "Assess incident" id "65f13ca4-df37-4f79-a22c-1813a3a28f9a" { + description "An operational activity performed to achieve the mission, independent of a technical solution."; + position (650, 70); + } + + activity report_distress "Report distress" id "70e02dd2-0ea6-4336-b48e-10170b0851d2" { + description "An operational activity performed to achieve the mission, independent of a technical solution."; + position (360, 250); + } + + capability protect_life_at_sea "Protect life at sea" id "7b244951-9609-40bc-9361-e0a910df281c" { + description "The ability to locate and recover people in distress at sea."; + position (1230, 70); + } + + actor vessel_in_distress "Vessel in distress" id "896f80fd-c13c-4ccf-931f-211c1f1403b4" { + description "An external participant needing assistance and providing location information."; + position (70, 250); + } + + entity rescue_crew "Rescue crew" id "a06da4e2-f356-49dc-a052-17c70a1d3acc" { + description "An operational organization participating in the rescue mission."; + position (70, 430); + } + + process distress_to_recovery "Distress to recovery" id "eefa3adc-4c00-4d1a-97a4-799649c812c7" { + description "A representative operational path from the initial signal to survivor recovery."; + position (1230, 430); + steps [distress_signal, incident_assessment, rescue_assignment]; + } + + interaction distress_signal "Distress signal" id "2d67d22e-bad5-4ddb-ae33-51281152a7d2" from report_distress to assess_incident; + allocation performs "Performs" id "32e24ded-b437-44fc-a1c5-fc9dae7c4f7e" from rescue_coordination to assess_incident; + interaction rescue_assignment "Rescue assignment" id "5881c83e-2b62-4279-b400-2c5a6c4385cd" from coordinate_response to recover_survivors; + contribution contributes_to "Contributes to" id "87080a23-6325-4cae-b431-9bc4894cd08f" from recover_survivors to protect_life_at_sea; + allocation performs_2 "Performs" id "a039b631-4e71-4cd7-b747-0cbf5c810e28" from vessel_in_distress to report_distress; + realization realizes "Realizes" id "a860d234-163c-40e0-9396-de071e9d35dc" from distress_to_recovery to protect_life_at_sea; + allocation performs_3 "Performs" id "add44d09-346d-4545-a235-0ac33d68f523" from rescue_crew to recover_survivors; + interaction incident_assessment "Incident assessment" id "bcff7899-95a1-4656-930a-fae0f4520cbb" from assess_incident to coordinate_response; + allocation performs_4 "Performs" id "f821866f-016a-474a-90dc-38daa48cb1c0" from rescue_coordination to coordinate_response; +} diff --git a/examples/legacy-v1-coastal-rescue.oa.json b/examples/legacy-v1-coastal-rescue.oa.json new file mode 100644 index 00000000..e0a659c8 --- /dev/null +++ b/examples/legacy-v1-coastal-rescue.oa.json @@ -0,0 +1,175 @@ +{ + "schema_version": 1, + "id": "fefd610d-12ef-4d8f-bd89-3c575f77e8eb", + "name": "Coastal rescue operation", + "elements": { + "00edd523-1260-4453-98d6-584209d3d38d": { + "id": "00edd523-1260-4453-98d6-584209d3d38d", + "kind": "Activity", + "name": "Coordinate response", + "description": "An operational activity performed to achieve the mission, independent of a technical solution.", + "position": { + "x": 940.0, + "y": 250.0 + }, + "steps": [] + }, + "1e73f3e2-82bc-411a-a7f3-6ff2def80935": { + "id": "1e73f3e2-82bc-411a-a7f3-6ff2def80935", + "kind": "Entity", + "name": "Rescue coordination", + "description": "An operational organization participating in the rescue mission.", + "position": { + "x": 70.0, + "y": 70.0 + }, + "steps": [] + }, + "51eed5ae-b270-4b28-8518-bf36a9dde7cf": { + "id": "51eed5ae-b270-4b28-8518-bf36a9dde7cf", + "kind": "Activity", + "name": "Recover survivors", + "description": "An operational activity performed to achieve the mission, independent of a technical solution.", + "position": { + "x": 650.0, + "y": 430.0 + }, + "steps": [] + }, + "65f13ca4-df37-4f79-a22c-1813a3a28f9a": { + "id": "65f13ca4-df37-4f79-a22c-1813a3a28f9a", + "kind": "Activity", + "name": "Assess incident", + "description": "An operational activity performed to achieve the mission, independent of a technical solution.", + "position": { + "x": 650.0, + "y": 70.0 + }, + "steps": [] + }, + "70e02dd2-0ea6-4336-b48e-10170b0851d2": { + "id": "70e02dd2-0ea6-4336-b48e-10170b0851d2", + "kind": "Activity", + "name": "Report distress", + "description": "An operational activity performed to achieve the mission, independent of a technical solution.", + "position": { + "x": 360.0, + "y": 250.0 + }, + "steps": [] + }, + "7b244951-9609-40bc-9361-e0a910df281c": { + "id": "7b244951-9609-40bc-9361-e0a910df281c", + "kind": "Capability", + "name": "Protect life at sea", + "description": "The ability to locate and recover people in distress at sea.", + "position": { + "x": 1230.0, + "y": 70.0 + }, + "steps": [] + }, + "896f80fd-c13c-4ccf-931f-211c1f1403b4": { + "id": "896f80fd-c13c-4ccf-931f-211c1f1403b4", + "kind": "Actor", + "name": "Vessel in distress", + "description": "An external participant needing assistance and providing location information.", + "position": { + "x": 70.0, + "y": 250.0 + }, + "steps": [] + }, + "a06da4e2-f356-49dc-a052-17c70a1d3acc": { + "id": "a06da4e2-f356-49dc-a052-17c70a1d3acc", + "kind": "Entity", + "name": "Rescue crew", + "description": "An operational organization participating in the rescue mission.", + "position": { + "x": 70.0, + "y": 430.0 + }, + "steps": [] + }, + "eefa3adc-4c00-4d1a-97a4-799649c812c7": { + "id": "eefa3adc-4c00-4d1a-97a4-799649c812c7", + "kind": "Process", + "name": "Distress to recovery", + "description": "A representative operational path from the initial signal to survivor recovery.", + "position": { + "x": 1230.0, + "y": 430.0 + }, + "steps": [ + "2d67d22e-bad5-4ddb-ae33-51281152a7d2", + "bcff7899-95a1-4656-930a-fae0f4520cbb", + "5881c83e-2b62-4279-b400-2c5a6c4385cd" + ] + } + }, + "relations": { + "2d67d22e-bad5-4ddb-ae33-51281152a7d2": { + "id": "2d67d22e-bad5-4ddb-ae33-51281152a7d2", + "kind": "Interaction", + "name": "Distress signal", + "source": "70e02dd2-0ea6-4336-b48e-10170b0851d2", + "target": "65f13ca4-df37-4f79-a22c-1813a3a28f9a" + }, + "32e24ded-b437-44fc-a1c5-fc9dae7c4f7e": { + "id": "32e24ded-b437-44fc-a1c5-fc9dae7c4f7e", + "kind": "Allocation", + "name": "Performs", + "source": "1e73f3e2-82bc-411a-a7f3-6ff2def80935", + "target": "65f13ca4-df37-4f79-a22c-1813a3a28f9a" + }, + "5881c83e-2b62-4279-b400-2c5a6c4385cd": { + "id": "5881c83e-2b62-4279-b400-2c5a6c4385cd", + "kind": "Interaction", + "name": "Rescue assignment", + "source": "00edd523-1260-4453-98d6-584209d3d38d", + "target": "51eed5ae-b270-4b28-8518-bf36a9dde7cf" + }, + "87080a23-6325-4cae-b431-9bc4894cd08f": { + "id": "87080a23-6325-4cae-b431-9bc4894cd08f", + "kind": "Contribution", + "name": "Contributes to", + "source": "51eed5ae-b270-4b28-8518-bf36a9dde7cf", + "target": "7b244951-9609-40bc-9361-e0a910df281c" + }, + "a039b631-4e71-4cd7-b747-0cbf5c810e28": { + "id": "a039b631-4e71-4cd7-b747-0cbf5c810e28", + "kind": "Allocation", + "name": "Performs", + "source": "896f80fd-c13c-4ccf-931f-211c1f1403b4", + "target": "70e02dd2-0ea6-4336-b48e-10170b0851d2" + }, + "a860d234-163c-40e0-9396-de071e9d35dc": { + "id": "a860d234-163c-40e0-9396-de071e9d35dc", + "kind": "Realization", + "name": "Realizes", + "source": "eefa3adc-4c00-4d1a-97a4-799649c812c7", + "target": "7b244951-9609-40bc-9361-e0a910df281c" + }, + "add44d09-346d-4545-a235-0ac33d68f523": { + "id": "add44d09-346d-4545-a235-0ac33d68f523", + "kind": "Allocation", + "name": "Performs", + "source": "a06da4e2-f356-49dc-a052-17c70a1d3acc", + "target": "51eed5ae-b270-4b28-8518-bf36a9dde7cf" + }, + "bcff7899-95a1-4656-930a-fae0f4520cbb": { + "id": "bcff7899-95a1-4656-930a-fae0f4520cbb", + "kind": "Interaction", + "name": "Incident assessment", + "source": "65f13ca4-df37-4f79-a22c-1813a3a28f9a", + "target": "00edd523-1260-4453-98d6-584209d3d38d" + }, + "f821866f-016a-474a-90dc-38daa48cb1c0": { + "id": "f821866f-016a-474a-90dc-38daa48cb1c0", + "kind": "Allocation", + "name": "Performs", + "source": "1e73f3e2-82bc-411a-a7f3-6ff2def80935", + "target": "00edd523-1260-4453-98d6-584209d3d38d" + } + } +} diff --git a/harness/DECISIONS.md b/harness/DECISIONS.md new file mode 100644 index 00000000..53f67dc1 --- /dev/null +++ b/harness/DECISIONS.md @@ -0,0 +1,27 @@ +# Architectural and product decisions + +- **One typed model, three Rust crates.** `oa-core` owns semantics, validation, transactions, history, references, language and projection. `oa-desktop` is an Iced editor; `oa-server` is an Axum transport/store. A second GUI/text model, event framework or runtime plugin ABI would introduce unnecessary synchronization and extension complexity. +- **Bounded OA/SA profile.** Typed element bodies determine layer/kind; separate records describe relationships, diagrams, realization traces and artifacts. SA has one System and external Actors; LA/PA deployment and executable simulation are outside V2. The normative reST contract and scope document state exact semantics instead of implying full Capella/SysML conformance. +- **Nesting derives from meaning.** Semantic containment and leaf allocation feed independent diagram projections. A placement drag changes geometry, never ownership. Shared projection drives canvas, Fit and SVG. Scenario/state graphics project the same behavior records and select them directly; numbered routes lead to full transition details. +- **Creation respects its workspace.** Architecture creation also places the new element in the current view. Behavior and Data creation preserve existing architecture diagrams; an explicit placement command remains available. The shared creation path enforces this once, while core commands maintain occurrence ownership and sequence order. +- **Equivalent editing, explicit drafts.** Every persistent field is available in typed property controls and canonical source. Source Apply validates and commits one transaction. Invalid source/property drafts remain editable; separate text/model history avoids ambiguous Undo. The temporary JSON form adapter is closed over typed records, not a second semantic schema. +- **Stable identity and readable changes.** Globally unique UUIDs and persisted aliases serve distinct purposes. Flat canonical declarations and @alias references reduce Git churn. Exact string controls preserve escaped LF/CR/tab values. Comments/formatting normalize on Apply; a comment-preserving parser and LSP were deferred. V1 migration preserves old IDs and deterministically assigns new view/occurrence UUIDv5 identities. +- **Identity updates are ordinary atomic edits.** Reference maintenance happens within the validated candidate. Selection and pending canvas gestures follow resulting IDs; editing an already-selected card preserves its draft until release. Placement relocation preserves UUID, geometry and artifact ownership, then focuses its committed diagram and perspective. +- **Transitions are reviewed engineering decisions.** Canonical OA-to-SA traces support creation/linking in either direction. Preview offers explicit dependencies, System/Actor boundary choices and counterparts. Existing-target reconciliation compares names/descriptions using reviewed baselines, with Keep/Take/merged values. It is not structural synchronization or a claim of lossless phase reversal. Scope follows ownership/reference dependencies, not every incident edge. +- **Local and service persistence have distinct concurrency boundaries.** Desktop saves compare exact last-read bytes immediately before atomic replacement to detect ordinary external editor/Git changes. Conflicts preserve drafts/files for Save as/reopen; this is not filesystem compare-and-swap. The service persists complete current-model envelopes before publishing revisions, with exclusive directory ownership and ETag preconditions. SQLite, distributed replicas, retained history and retry receipts were unnecessary for the bounded prototype. +- **Artifacts remain opaque engineering references.** URI/revision/fragment metadata can target any model record. Desktop and API retain it without fetching or executing content. Desktop/hosted transfer uses explicit source import/export; a direct hosted editor requires a separate concrete workflow. +- **Native graphics and deployment.** Retain Iced WGPU with Windows, X11 and Wayland support. The tested WSLg environment requires Mesa software Vulkan. Separate headless server binaries avoid GUI dependencies. Docker/systemd recipes are documented; actual remote installation is not implied by local verification. +- **Evidence governs completion.** Original Capella was operated in an isolated example and its useful nesting/navigation/transition patterns informed the UI. Independent reviews, real native workflows and automated invariants complement each other. Platform and feature boundaries remain explicit in validation/scope documents. +# First-release decisions (supersede narrower V2 exclusions below) + +- OA/SA semantic scope follows `release-semantics-audit.md` R1–R6 and actual Capella workflow evidence. Later Arcadia layers remain future work; missing OA/SA reuse, topology, state/data contracts and semantic transition review do not become release limitations. +- Preserve one typed Rust core and atomic validated model commands. Mission/capability links and process topology are independent of containment and diagram placement. Work/chain reuse uses stable reference occurrences rather than copied semantic objects; sequence guards belong to contextual links rather than global exchanges. +- Desktop lint uses one background parser plus the latest queued source version. Accepted model checks are cached separately from draft diagnostics. Stale results cannot enter a different document or overwrite newer edits. The REST service prepares bounded candidates outside its global store lock and rechecks ETags during commit. +- Relationship creation follows the selected engineering concept: directional names, compatible endpoint choices, optional generated relationship names and conditional extension text in the initial dialog. Existing model transactions remain the sole commit path. +- Help is an offline Rust topic registry with search and guarded workspace links. Task navigation is separated from the independently scrolling model explorer so search and perspective controls remain available at minimum window size. Fit uses the actual measured canvas, with a pre-layout fallback, rather than assuming a fixed amount of window chrome. +- Source/schema3 and explicit V2 migration are planned for the expanded release. Current schema2 is an integration checkpoint, not a new compatibility claim. Existing V2 packages remain preserved until final release acceptance. + +- R5 state/event contract adopts region-owned entry/exit symbols attached to their containing boundary, independent per-region history, strict transient pseudo routing without event triggers, and preservation of the last removed typed trigger as descriptive text. This supports explicit engineering semantics without an execution engine; exact invariants and migration policy are in release-state-contract.md. + +- Git is now explicitly authorized by the user. Establish a source baseline on codex/release1-engineering, then use focused commits at verified milestones. Use Git worktrees for independent writers when their scope can be partitioned; root integrates and owns native UI. Keep target/dist, recovery/store files and credentials outside version control. No remote publication is implied. +- User's hierarchy/port correction supersedes generic graph defaults. Architecture/dataflow/capability content differs by engineering purpose, while containment/allocation and port anchors remain derived from semantic records. The exact correction and remaining port metamodel work are in release-diagram-port-correction.md. diff --git a/harness/PLAN.md b/harness/PLAN.md new file mode 100644 index 00000000..6311977c --- /dev/null +++ b/harness/PLAN.md @@ -0,0 +1,44 @@ +# First release execution plan + +## Outcome and baseline + +Deliver the first release of Arcadia Studio for system engineers: complete Operational Analysis and System Analysis/System Architecture workflows, equivalent graphical and textual models, clear artifact traceability and a deployable/programmatically accessible Rust service. Logical/Physical Architecture and later Arcadia layers are future releases, as the new objective explicitly states. + +The authoritative objective is the attachment ending 81e84f53-2b51-4151-9e34-59414bda8b2d/goal-objective.md. The accepted 0.2.0 prototype is the baseline, not proof of this release's completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and its existing binary archives remain in dist until replacement passes acceptance. + +## Existing architecture and retained decisions + +Retain three Rust crates and one typed Project/validated atomic Command boundary. oa-core owns semantics, references, history, transitions, source and persistence; oa-desktop owns Iced interaction/drafts/projections; oa-server owns REST/ETag/current-model storage. Keep identity, semantic containment, allocation and per-diagram placement separate. No runtime plugin framework or later-layer skeleton. + +The current forms are a closed typed-record adapter, not a second metamodel. Expand their real creation/reference/variant controls with every semantic addition. Core choices must drive graphical choices. New persistent fields participate in text round trips, identity/removal visitors, transitions, API schemas and graphical authoring tests. + +## Milestones and measurable acceptance + +Current priority within milestone2: address the user's diagram/port correction using `release-diagram-port-correction.md`. Separate engineering views, actual owner-bound port geometry and full functional/component-port semantics precede further breadth. Existing R5 state authoring is preserved; it still needs native verification. + +1. **Release coverage audit and interfaces.** Compare actual core/UI/API with official Capella OA/SA semantics and the new objective. Freeze a coverage table and concrete variant/reference/transition contracts before editing shared interfaces. Distinguish unsupported later layers from missing OA/SA workflows; V2 exclusions are reconsidered. Native audit exercises onboarding/help/authoring at supported sizes. Independent audits have separate file ownership. +2. **Complete engineering semantics.** Fill verified gaps in capability relationships and mission involvement; operational process/functional-chain function/control topology; scenario/state/data constructs needed by OA/SA engineers. Add typed semantic-content drift and reviewed existing-counterpart reconciliation, including reference mapping and explicit dependency/ownership decisions. Every addition has a usable graphical authoring workflow, equivalent source/API representation, meaningful reject/undo/delete/reidentify/transition tests and a worked example. Exact additions and invariants are recorded in the coverage/interface artifact from milestone1, avoiding speculative LA/PA concepts. +3. **Approachable authoring and help.** Replace static help with searchable topic navigation, concise concepts, numbered OA→SA tutorials, related topics and direct workspace actions. Add a focused starting workspace, recent models and practical creation guidance. Improve source authoring with asynchronous versioned lint, semantic warnings, diagnostic navigation and find/go-to tools. Make selection/navigation and hidden/placed model distinctions clear. Add recoverable local checkpoints for unsaved accepted models and drafts; preserve explicit file-save/conflict behavior. +4. **Connected automation workflow.** Keep headless deployment and shared core REST contract. Add direct optional desktop open/publish/save of hosted models using explicit connection details and conditional ETags, preserving drafts on failure/conflict and keeping credentials in memory. Verify actual desktop→API→desktop edits and artifact integration locally. Update OpenAPI, deployment and source contracts for the expanded release semantics. +5. **Release integration and polish.** Guided examples cover OA goals/roles/process alternatives, system missions/capabilities/boundary contracts, chains/scenarios/states and evidence. Review default/minimum/wide layouts, practical keyboard paths, empty/loading/error/recovery states and performance on representative models. Build/run the actual native app after each meaningful UI milestone, fix from evidence, then reverify. +6. **Independent final acceptance and packaging.** Fresh requirements, correctness, native UX and simplicity reviews in parallel; fix all material findings and rerun affected checks. Full Windows/Linux locked tests, formatting, strict Clippy and optimized builds. Operate primary GUI workflows and actual service transport. Verify current docs/reST rendering, examples/migration, reproducible source workflow, package binary equality/archive contents and checksums. Only then replace 0.2.0 bundles and mark this goal complete. + +Dependencies: milestone1 freezes shared interfaces for2; guide/lifecycle work in3 can proceed independently once UI ownership is partitioned. Structural counterpart work depends on2. Hosted client4 depends on final core/API types but transport/lifecycle can be developed independently. Milestones5–6 integrate all preceding work. + +## UI and state flow + +Keep task navigation, engineering perspective and context-sensitive properties. A Home/start workspace offers projects, recovery and tutorials without obscuring active modeling. Help uses local Rust-owned content and search/category navigation; workspace links pass explicit events and respect draft/modal guards. + +Text edits update a snapshot/version; parsing/semantic lint runs on a blocking worker and only the latest result is adopted. Syntax errors and semantic warnings have distinct severity/location and navigate to source. Applying text remains one atomic model transaction. Invalid text and property input are recoverable drafts. + +Recovery writes separate per-session checkpoints in user-local application data, never silently overwrites a model file, and offers explicit restore. Recent paths are convenience metadata. Hosted models use a separate connection/resource/revision/ETag handle; ordinary core editing and Undo stay local until explicit conditional Save. Network/file failures retain the same draft. Tokens are not persisted or included in diagnostics. + +## Failure and verification strategy + +Cover wrong-layer/reference/kind input, ownership/reference cycles, duplicate or ambiguous counterpart matches, stale plans/ETags, typed semantic drift, sequence control topology and repeated occurrences, invalid source/property drafts, external file edits, failed storage/network operations, oversized models and unavailable graphics. Keep incomplete engineering work editable through warnings while rejecting structural corruption. + +Tests must cover user-visible meaning and cross-representation invariants, not just serialize the same value twice. Use actual HTTP and native CUA for end-to-end behaviors. Independent review targets current artifacts and the original release objective, not only implementation summaries. Record unsupported/unavailable environments without turning missing required functionality into a limitation. + +## Definition of done + +Every concrete requirement in the new objective and release coverage table has authoritative current evidence. Primary OA/SA engineering workflows work graphically, textually and through the API; help is navigable and useful; native Windows/Linux UI is intentionally designed and exercised; no blocking/high-impact defect remains; strict checks pass; independent findings are closed; durable docs accurately reflect the release; another engineer can build/run/test and use the supplied packages. V2 completion alone does not satisfy this goal. diff --git a/harness/STATE.md b/harness/STATE.md new file mode 100644 index 00000000..34e656e1 --- /dev/null +++ b/harness/STATE.md @@ -0,0 +1,50 @@ +# First release state — R4 authoring / native iteration / R5 integration + +## Objective and baseline +Active goal: feature-complete OA + SA/Architecture first release, beyond V2; objective attachment81e84f53-2b51-4151-9e34-59414bda8b2d. User demands closer original Capella study and practical usability. Later layers remain future work. V2 source ZIP and dist packages preserved; do not mark release complete. PLAN.md is current milestone/acceptance source. + +## Verified implementation +- R2 missions/capabilities, R1 process topology and R4 declared scenarios/message semantics implemented in shared core; exact handoffs release-capability-core.md, release-topology-core.md, release-scenarios-core.md. +- Fresh independent release-semantic-review.md findings CLOSED: branch-local duplicate replies/use-after-delete, Role compatibility, loop repetition diagnostics, reverse Reply picker and state-owner picker. Independent27 R1/R2/R4 tests + compiled external repro passed. Core worker71 focused regressions + strict Clippy passed before R5. Loop warnings distinguish uncertainty from execution claims. +- R1 contextual tools now work from empty and legacy Process/Chain; preserve legacy linear interpretation and perform explained atomic path conversion. Root added measured Fit/zoom and scaled hit testing after native excessive-scroll finding; empty process now has guidance. Latest source not native reverified yet. +- R4 dedicated scenario.rs authoring supports typed lifelines, all message styles, paired Call+Reply, explicit bound ScenarioUse, fragments/operand destinations/actions/state observations, carried values. Core helpers drive endpoint/viewpoint compatibility. Worker six scenario tests; root56 full desktop tests passed session50129 before source-find addition. +- Root R4 property forms include typed message/payload/reuse/binding fields and new constructors. Shared core reply/role helpers; distinct state-owner/effective-performer rule restored after review and tested. Modeling actions picklist near top of capability/mission properties; Connections inspector traverses real core references and diagram placements. +- Source lint runs bounded max1worker/latestversion, caches accepted model checks, rejects stale diagnostics across reset. Source navigation uses layout sensor; root further added focus, declaration context and literal Find/Go to line (CtrlF/CtrlG/F3/ShiftF3), UTF-8 byte cursor offsets, invalid-draft preservation. Latest59 desktop test run session22577 currently building after tests; inspect result before claiming complete. + +## Current native verification +Root exclusively owns Computer Use. Sky persistent bindings sky/studioWindow/studioState/studioDialog/dialogState. Refresh inventory before next action. CUA through node_repl @oai/sky skill26.901.41600, not disabledcua_repl. Native file dialogs sometimes return tiny16px screenshot first; use screenshot width>100 for coordinates. One observed action then fresh capture; typing after focus observation. + +Verified checkpoint executable: D:/CodexBuilds/Capella2-release1-01a06e2e/ui-r1-r4-checkpoint/arcadia-studio.exe SHA256B24611C9AA7BF686BDA3FDD0B25BB0CE71715D5FED471287ECBAE2B0BF385F98; window395472. This predates scenario authoring, topology zoom and Find. AltF4 requested after SaveAs, fresh inventory needed to confirm closure. stderr empty and process responsive during run. +- Native1440x900 and1100x700: guide/search, persistent task/explorer navigation; selected capability correctly identifies placement; Modeling actions menu appears near top. SourceForSelection now reveals correct declaration, but it lands near viewport bottom without focus; root further focus/context patch pending native verification. +- Help→resize→Open saved capability model now automatically fits47% with all nodes visible: original cache/layout Fit bug fixed and native reverified. +- Connections outgoing extension→target capability→incoming capability/chain relationships works. Right panel retains connection tab during creation, hiding name fields; root now switches new element to Properties and added EditProperties(id) message for explicit property actions. Pending native check. +- Created empty OA Process Choose rescue response, involved Assess incident through +Work, used Branch/parallel to create Report distress / Respond to distress alternatives after it, condition Incident needs more information + else. Observed5newSequenceLinks,4branchnodes, rendered guard/alternative routes. At minimum width excessively horizontal graph; root now adds Fit/zoom (not yet native checked). Branch form scroll works and commit is atomic via same Command path. +- Saved full result through native SaveAs to ui-r1-r4-checkpoint/process-branch-ui.arcadia. On-disk source verified process name, exact guard and5SequenceLinks. No original fixture overwritten. +- Earlier R2 native extension fixture ui-r2-checkpoint/capability-extension-ui.arcadia preserved: Rescue in heavy weather extends Protect life at sea, condition Sea state is 6 or higher; exact source verified. Prior app closed. +- Original Capella six native benchmark workflows recorded in release-capella-workflow-benchmark.md. Original window524804 disappeared from fresh inventory this turn; do not assume open. Disposable workspace/sample under D:/CodexBuilds/Capella2-release1-01a06e2e; preserve it. Reopen if more comparisons needed. + +## Active ownership +- Root: desktop app/forms/views/workflow/navigation/guide/source_navigation, topology.rs (released by worker after wrapper edits), state docs, exclusive CUA. Next: finish current59test/build result, copy new checkpoint, native Fit/zoom/sourceFind/scenarioauthoring/save-reopen; strict desktop Clippy. Root basicR5forms enums/refs/Event constructors already added; ergonomic state workflow still needed. +- release_capella_benchmark: ONLY behavior.rs + behavior/scenario.rs and benchmark docs. R4 authoring code complete, final focused checks/report then release. Root owns topology now. Shared behaviorEvent::is_submit guards both editors; root integrated. Event routing explicitly tags scenario commit so viewport events cannot redirect it. +- release_capability_core: R5 core model/validation/private state_validation.rs/references/session/transitions/projection/demo/coretests(exceptlanguage). Exact adopted contract release-state-contract.md: Event Signal/Time/Change, After/At, External/Local/Internal, typed trigger/effect refs,9newpseudostates, is_stable_state. Events/refs and state module compile; hierarchy/cardinality/focused tests underway. Source factory defaults preserved; Project::relate/Command::Connect use Local from EntryPoint. No desktop ownership. +- release_text_service_fix: language/persistence/tests/server/API/OpenAPI/language.rst. R5 source/API landed; previously16language+17HTTP/config+1unit passed, reST warning-free. Await core finalfreeze for final focused/strict checks; may read-only draft R3contract while waiting. R6 read-only report done: release-data-contract.md; not implemented. +- release_semantics_review: idle, focused review closed. Reuse after R5 / current graphical changes for fresh review. + +## Required remaining work +R5 ergonomic state/event authoring/rendering plus finalcore/source verification; R6 data/contracts per design (lead still must freeze decisions); R3 fullsemantic drift and mapped existing-counterpart review; categories/persistentfilters/coverage; home/recent/recovery; optional hosteddesktop ETagopen/publish/save; explicit schema3 losslessV2migration; workedexamples and currentdocs; independent finalrequirements/correctness/UI/simplicity reviews; Windows/Linux fullchecks/nativeflows/packages. No V2 exclusion substitutes for release acceptance. + +## Environment +Windows Rust1.98.1, target D:/CodexBuilds/Capella2-01a06e2e. Build cargo build --locked -p oa-desktop; tests cargo test --locked -p oa-desktop. Copy checkpoint exe before native launch so further builds can link. WSLUbuntu24.04 config per verification-v2-linux.md. Git unborn/untracked preserve. No disk blocker. Prior target/debug deletion rejected: do not retry/bypass. FinalMSRV/platform/clean-workflow checks remain. + +## Usage-limit interruption recovery +All three workers stopped with explicit account usage-limit errors; no worker is still active. Root owns recovery/integration of their current files. R5 core+server full locked tests passed in root session11632 (exit0). Worker left behavior.rs declaring states module without writing states.rs; root is completing that module before the next build. Native currentcheckpoint disappeared from process/window inventory without stderr output; root relaunched preserved ui-scenario-checkpoint executable, new returned window1050736. No unsaved native work was lost (last work was only reopening saved fixture). Root just verified Find/Enter/F3/CtrlG+line1 with actual selection and minimum1100x700layout; Fit/scenarioauthoring remain next native checks. + +## User correction: diagram hierarchy and ports — current priority +User reports architecture diagrams have become generic graphs and port semantics/rendering differ dubiously from Capella. Lead confirmed projection includes almost every relation between placed objects, Overview flattens every parent, and renderer has no boundary-port anchors. Reopening original Capella to inspect SAB/SDFB/OAB ownership and port workflows before freezing corrective contract. Preserve completed state editor work; state native verification is pending, not release complete. Current desktop:59 prior full regressions +4 new state authoring tests pass, cargo check passes after recovery; strict Clippy/current full63 tests not yet run. New state_scene.rs gives actual nested geometry, internal transition rows and vector pseudostate symbols. No fresh agents available: three existing agents still errored on account usage limits; root continues. + +## Git adoption and hierarchy checkpoint +User explicitly authorized Git and optional worktrees. Existing .git was unborn; created codex/release1-engineering, added repository ignore/line-ending rules, and preparing the first source checkpoint. Future isolated implementation can use worktrees after this commit; read-only reviews do not need them. Nothing pushed or published. + +Hierarchy correction implementation: dedicated FunctionalDataflow/Capabilities projections, architecture filters participants/work + exchanges, six focused demo views with architecture preferred on open/layer change, calculated boundary ports referenced by FE/BoundaryExchange, shared movement/hit/SVG anchors. Layout measures port room and 64px headers, orders root containers by actual exchange flow. Source/OpenAPI enum support updated. Functional/component port metamodel correction remains required; do not mistake improved rendering for semantic completion. + +Verification: full locked core+desktop+server tests exit0 root session12111; desktop64 tests, new3 core projection regressions and1 desktop port hit/preview/SVG test. Earlier new group-movement test incorrectly used projected origin as stored MoveNode target; corrected to command's absolute persisted-coordinate contract, and shared layout/header measurement now avoids the native offset mismatch. Strict workspace Clippy passed before projection changes; rerun needed on this checkpoint. Native checkpoint ui-hierarchy-checkpoint SHA256D6055F659754E9159232483EAF9FA595C1D7F53683F8FB618D855F04B9C931A5 built/copied; native launch/verification next. Capella graphical window395654 remains a splash, console launcher14556 unresponsive; no new native port study claimed. diff --git a/harness/release-capability-core.md b/harness/release-capability-core.md new file mode 100644 index 00000000..8544999d --- /dev/null +++ b/harness/release-capability-core.md @@ -0,0 +1,36 @@ +# R2 capability and mission core milestone — 2026-09-05 + +Status: the bounded R2 core contract is integrated and tested. Graphical authoring, source/API parity and native workflow verification are separate integration gates; this report does not claim the release or the larger semantic audit is complete. + +## Frozen interface + +- `Kind::OperationalMission`, `OaBody::Mission`: OA mission; default counterpart is `Kind::Mission` / `SaBody::Mission`. Both are root semantic records. Scenarios may use either mission as their context. +- `CapabilityInclude { source, target }`: including capability → included capability. +- `CapabilityExtend { source, target, condition: String }`: extending capability → extended capability. Blank/whitespace conditions produce warning `missing_extension_condition`; conditions also use the ordinary text-validity checks. Draft extensions remain editable. +- `CapabilityGeneralization { source, target }`: specific capability → general capability. +- `MissionInvolvement { source, target }`: OA Entity/Actor → OperationalMission; SA System/SystemActor → Mission. +- Existing `MissionCapability { source, target }`: OperationalMission → OA Capability, or SA Mission → SystemCapability. +- `TraceKind::Mission`: canonical OA OperationalMission → SA Mission. The transition planner creates this typed trace in either direction. Existing explicit Capability ↔ SA Mission mappings remain available and use Manual traces; Mission→OA now defaults to OperationalMission while reusing a uniquely existing explicit Capability trace. + +All new relations reject cross-layer, wrong-family and self endpoints. Inclusion and generalization each reject directed cycles in their own graph, including transitive cycles. They remain independent of containment and of one another. Extension is conditional augmentation; no separate extension-cycle restriction was invented beyond the audited no-self rule. + +The ordinary relation endpoint visitor performs transition mapping, reidentification, deletion and reference checks for all four new variants. New mission bodies have no hidden references. No new registry, transaction type or projection path was needed. Semantic relationships remain directed diagram edges; they do not create visual/semantic parenthood. The schema stays version 2 temporarily during interface integration; the release migration/version change belongs to the language integration worker. + +## Verification actually run + +With `CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e`: + +``` +cargo test -p oa-core --locked --test workflows --test review_regressions --test process_diagnostics --test release_capabilities +cargo clippy -p oa-core --all-targets --locked -- -D warnings +``` + +Observed: **51 tests passed** (28 workflows, 15 prior regressions, 1 process diagnostic, 7 new capability/mission tests); strict core Clippy passed. Modified test files were formatted with Rustfmt edition 2024. + +The new `release_capabilities.rs` exercises both perspectives and directions, exact endpoint preservation and extension condition propagation, atomic cycle rejection with redo/history retained, invalid condition rejection and undo, typed Mission trace defaults and legacy mapping reuse, complete mission-map dependency transitions, scenario/artifact/placement/trace references across UUID changes and deletions, counterpart survival after source deletion, and projection direction independently of nesting. + +The first broader run exposed the old regression expectation that reverse SA Mission creation defaults to OA Capability. That test now explicitly preserves Capability as an alternative in both directions and verifies the new OperationalMission default. An initial full-core run also found the complete source fixture missing new relation kinds; the independently owned language worker is updating that fixture. This report intentionally does not count an unobserved final full-core/source run as passing. + +## Remaining release obligations + +Integrate the usable mission/capability graphical workflow, readable direction/condition labels and source/API constructors/specification. Then verify the actual native mission map workflow and conduct independent review. R1 contextual process/chain topology, R3 semantic drift/reconciliation, R4 reusable scenarios, R5 state/event semantics and R6 data contracts remain required by the release audit. diff --git a/harness/release-capella-workflow-benchmark.md b/harness/release-capella-workflow-benchmark.md new file mode 100644 index 00000000..beb1505c --- /dev/null +++ b/harness/release-capella-workflow-benchmark.md @@ -0,0 +1,99 @@ +# Original Capella workflow benchmark — resumed, 2026-09-05 + +Status: **incomplete**. This record contains only actual observed UI behavior. The six requested capability, chain, scenario, state, data/interface and everyday-workflow comparisons have not been completed. The source/schema investigation remains separately recorded in `release-semantics-audit.md`; it is not a substitute for this runtime benchmark. + +## Observed native UI + +Original Capella 7.0.1 was restored from its minimized window. The open IFE sample displayed `[OAB] All Operational Activities and Entities` at 2560×1392. The screenshot showed Passenger, Aircraft, Crew, Personal Device, Ground Operator and Airline Company containers. Pilot and Cabin Crew nested inside Crew; their operational activities nested inside their participant boxes. Activities remained connected by named interactions crossing container boundaries. Selecting a path highlighted its activity boxes and connecting arrows in blue without discarding surrounding context. + +The right palette grouped tools by Entities, Roles, Activities, Accelerators and Common. Visible tools included Operational Entity/Actor, Communication Mean, Role, Manage Role Allocation, Operational Activity, Interaction, Manage Activity Allocation, Activities from Mode/State, Elements from Scenario, Constraint and applied property-value groups. This is concrete evidence for contextual grouped authoring, explicit allocation operations and semantic insertion accelerators. It does not prove that any creation tool was exercised. + +The bottom Semantic Browser followed the selected `Broadcast Movies` operational activity. Its three columns exposed referencing elements, the current element and referenced elements. Actual entries included Allocating Entity; Incoming Interactions; Operational Processes; four Realizing System Functions; Scenarios; Parent; All Related Diagrams; All Related Tables; Active in States (Cruising and Takeoff); and Outgoing Interactions. The browser toolbar offered Back/Forward, diagram visibility, limited expansion, sorting, follow-selection and requirements options. Several related diagrams and the System Functions–Operational Activities table were discoverable from this one selection. + +Release acceptance tasks justified by these observations: + +- Select a nested leaf and immediately see its performer, incoming/outgoing exchanges, process/scenario uses, active states, counterparts and related diagrams with navigation in both directions. +- Create or insert appropriate objects using task-grouped controls close to the current view; keep containment, allocation and diagram insertion distinct. +- Navigate from a selected activity through a realization and back, preserving view context; provide visible relationship names rather than unexplained endpoint-ID lists. +- Highlight a selected engineering path while retaining readable surrounding architecture. + +These are observed-control comparisons, not a claim that the current Studio passes them. + +## Interruption and recovery evidence + +Selecting the project and issuing Explorer Ctrl+C/Ctrl+V to make a disposable copy left the original main window marked disabled. Repeated window inventories exposed no copy modal. Escape and one explicit activation/re-observation did not reveal a recoverable dialog. No original model edit or save was performed. The original `.metadata/.log` had no new entry from this attempt; its last entries were older transition class-loading messages, so they do not explain this behavior. + +A separate Capella process was launched using a separate workspace. A filesystem copy of the sample was prepared without changing the original. The new process displayed Welcome and opened the Import wizard with General, Capella Example, Git, Install, Tasks and Team categories. The sample has **not** been imported into the new workspace yet. + +Some state captures for the Capella target displayed pixels from a foreground application instead. Those mismatched captures were not used for input. Explicit activation and re-observation restored visible Capella. The next attempted import-category click returned: **Computer Use was stopped by the user with the physical Escape key.** All CUA stopped immediately. Further native input requires fresh user steering. + +## Historical continuation handles (superseded by resumed checkpoint below) + +- Original executable: `C:/Users/sorok/Downloads/capella-7.0.1.202503211540-win32-win32-x86_64/capella/capellac.exe`. +- Original window: `3673208`; original workspace: `D:/CodexBuilds/Capella2-v2-01a06e2e/capella-workspace`. +- Separate benchmark window: `2035890`, title `capella-workspace - Capella`; launch exec session: `75759`. +- Separate workspace: `D:/CodexBuilds/Capella2-release1-01a06e2e/capella-workspace`. +- Disposable sample copy: `D:/CodexBuilds/Capella2-release1-01a06e2e/capella-sample-source/In-Flight Entertainment System`. +- Last new-process UI: Import wizard, not yet completed. + +Screenshots were captured and visually inspected through native Computer Use outputs in this task. No screenshot files were saved and no completed six-workflow benchmark is claimed. No Studio source changes or builds were part of this interrupted UI investigation. + +## Resumed native benchmark — 2026-09-05, ongoing + +Computer Use works after the desktop update. Fresh inventory had no Capella. Launched the existing executable with the isolated release workspace (exec session 55359, window 524804), imported the already prepared disposable IFE source using File > Import > General > Existing Projects into Workspace, and loaded its AIRD. The source is the disposable copy, not the original workspace. + +### Mission/capability authoring (performed) + +From the System Analysis workflow, expanded **Define Actors, Missions and Capabilities**. It offers automatic OA-capability transition, contextual System Actor creation from OA participants, contextual Capability or Mission creation from OA capability, contextual actor diagram, mission/capability map and participant traceability matrix. Created `[MCB] Studio benchmark` through the mission/capability map command. The diagram palette separately exposes Actor, Mission, Capability; Capability Exploitation, Involved Actor, Extends, Includes, Generalization; and existing-element insertion tools Capabilities, Missions, Relationships, Actors. + +Actually created a Mission and Capability with palette then canvas position. The property dialog exposed capability pre/postconditions, inheritance, involved functions/chains, available states/modes, realized capabilities and involved components. Renamed the new capability `Deliver safe entertainment`. Existing-capability insertion opened a searchable available/selected tree chooser; double-clicking `Integrate Aircraft Constraints` transferred it to selected, and OK inserted a placement of that existing object. Created Includes by palette > source capability > target capability, then Capability Exploitation by palette > mission > capability. Both links appeared and selection identified `Abstract Capability Include` and `Capability Exploitation` respectively in Properties and semantic path. + +Friction: plain `Mission 6`/`Capability 14` defaults require a follow-up rename. The dark theme renders nearly black external names and edges on dark gray, making engineering content much less readable than the palette. Large representation/property chrome consumes substantial room. Useful behavior to preserve: view-specific creation, explicit insertion versus semantic creation, endpoint tools with compatible-target feedback, and capability-level semantic involvement editing. + +### Functional-chain involvement and control (performed) + +Opened `[SFCD] Resume VOD Movie` using the SA diagram viewer's Functional Chain Description category. The view contains function involvements, including actor functions in blue and system functions in green, connected by functional-exchange involvements. Palette **Involvements** includes Function, Exchange, Exchange and Function, Functional Chain and Connect Functions; **Sequencing** includes AND/OR/IT controls, corresponding construct accelerators, Sequence Link, Function on Sequence Link, Exchange with Sequence Link and Exchange from Sequence Link. + +Actually inserted an existing `Broadcast Audio Announcement` chain through Functional Chain > canvas > filtered same-layer chain chooser > OK. Capella created a **Functional Chain Reference**, rendered as a titled nested container with the reused chain's inner functions. Resizing that container revealed more nested content rather than copying the source functions. Added an OR control and connected an existing `Watch Video on Private Screen` involvement to it with a **Sequence Link**. The selected link has a distinct Condition field and Links reference field. The control has explicit OR/AND/ITERATE kinds. Thus functional exchange semantics and contextual precedence are separately authored, and chain reuse is a reference with nested visualization. + +Release acceptance derived from this direct operation: insert existing work/exchange/chain from a contextual picker; create a branch control and guarded precedence without inventing a global functional exchange; visibly distinguish actual referenced work from its involvement; open/expand a reusable chain by identity; permit diagram geometry changes independently of semantic reuse. These observations do not establish that Studio currently passes the workflows. + +### Scenario messages and reuse (performed) + +Opened `[FS] Perform Audio Announcement` from the SA workflow's Function Scenario category. Four lifelines represent named actor/system functions, with exchange messages, execution bars and explanatory notes. The palette offers System Function, existing Functions, Functional Exchange with return branch, Functional Exchange, Reference, LOOP variants, Operand, Involved State/Mode, Duration and Exchange Context. + +Actually used Reference by dragging across the four lifelines. The chooser offered two other Function Scenarios, filtered to the appropriate scenario type. Selected `Determine Passenger Service Availability`. The result is a `ref` region with its target name; Properties identifies a Use/Interaction Use with Covered Instance Roles and Referenced Scenario. No explicit role-correspondence dialog appeared in this operation. Right-clicking that reference > Open > its named scenario opened the referenced scenario in another tab. This is actual reference navigation, not only a visible menu item. + +In the target scenario, chose **Functional Exchange with return branch** and clicked `Select Passenger Service` then `Handle Service Activation Requests`. The Create a Message dialog offered either a new named exchange or the compatible existing `PassengerVODServiceSelection` exchange with those endpoints. Chose the existing exchange. Capella created an additional call occurrence, dashed return branch and receiver execution bar, while preserving the original occurrence higher in the scenario. The new call's Properties shows the invoked exchange and explicit SYNCHRONOUS_CALL kind, with alternatives ASYNCHRONOUS_CALL, REPLY, CREATE, DELETE and TIMER. The observed workflow reuses the model exchange while creating scenario-specific occurrences. + +Concrete Studio acceptance: create a message by choosing visible lifeline endpoints and an endpoint-compatible exchange; offer a call-and-reply action with paired occurrences; allow the same exchange more than once; render readable message names and execution spans; create a named scenario-reference region and open its target without losing the calling view. References and message occurrences need explicit semantic identity. ALT/LOOP operand creation, durations, exchange contexts and transition correspondences were not exercised in this pass. + +### States, regions and event triggers (performed subset) + +Opened `[MSM] IFE Operating Modes`. The sample includes Initial, Choice, Final, ordinary modes and a composite `Maintenance` mode containing `Region 1` and nested modes. The palette exposes Mode, State, Region, Initial, Join, Choice, Fork, Terminate, Final, Deep History, Shallow History and Transition. Created a **Deep History** H* node by clicking inside Maintenance's region; its semantic path confirms that region owns it. A subsequent attempt to connect it to Sustaining did not produce a visible edge; the cause was not diagnosed, and no successful history fallback is claimed. + +Opened the existing Choice-to-Content Management transition's property dialog. It exposes Guard, Effects, realizations and a Triggers list containing `Load Digital Media` with a function icon. Added a Time Event using the trigger clock button, selected AFTER, opened its Opaque Expression editor, and entered `30 s` in the LinkedText body. After accepting the expression, event and transition dialogs, the trigger list displayed both `Load Digital Media` and `[AFTER]30 s`. The selected Time Event properties also showed the expression and AFTER kind. Thus this observed trigger set can refer to an involved function as well as an explicit timed event. + +Useful behavior: container-based region ownership; recognizable pseudostate glyphs; transition-local guard/effect/trigger authoring; typed relative and absolute time-event choices. Friction: adding a short relative delay required three nested dialogs, and the updated edge label clipped the combined triggers. Studio acceptance is a concise transition editor supporting semantic trigger references, typed timed events and readable labels, with a clear region destination when adding state nodes. Change-event creation, full transition validity, orthogonal regions and history execution remain untested here. + +### Data, interfaces and allocation (performed subset) + +Opened `[CDB] In-Flight Entertainment Dictionary`. Existing classes render properties inside their class boxes, associations carry role names and multiplicities, and an enumeration shows its BUSINESS/PREMIUM/ECONOMY literals. The sample's own note says this particular dictionary is conceptual and is not connected to its functional analysis; that note is a sample limitation, not a Capella capability limitation. + +Created `Interface 1` with the Interface palette tool. Interface Properties exposes visibility, a Super reference and allocated Exchange Items. Expanded the exchange-item creation variants: Event, Operation, Flow, Data and Undefined Exchange Item. Chose **Operation** and clicked the canvas, creating `ExchangeItem 1`; Properties confirms Exchange Mechanism OPERATION, alternatives EVENT/FLOW/SHARED_DATA, abstract/final flags, Super and Exchange Item Elements. + +Used **Manage Exchange Item Allocations**, clicked the interface, and selected the existing ExchangeItem 1 in the familiar available/selected tree picker. After OK, the interface displays ExchangeItem 1 as an internal row while the separately placed exchange-item object remains visible. Selecting the internal row identifies a distinct **Exchange Item Allocation** pointing to the item. Its Properties include Transmission Protocol (unset, synchronous, asynchronous, unicast, multicast, broadcast) and Acquisition Protocol (unset, read, accept). This is direct evidence for representing an allocation by nesting rather than a noisy edge, without confusing it with semantic containment of the allocated item. + +Studio acceptance: create an exchange item with an explicit mechanism, allocate it to an interface from a compatible-object picker, show the allocation as a nested named row, preserve the item identity across placements, and edit allocation-specific protocols separately from the item definition. Property typing/ranges/defaults/multiplicity edits, interface exchange realization, and exchange-category operations remain untested in this native pass. + +### Everyday navigation and representation operations (performed subset) + +The task-oriented workflow page and type-grouped diagram viewer were used repeatedly to locate and open MCB, SFCD, FS, MSM and CDB representations. Existing-object insertion consistently uses a searchable available/selected picker with tree/list display and semantic paths. The scenario-reference Open command was used successfully. A reused-chain container was resized to reveal nested content without changing the referenced model object. + +Pressed **F9** on the newly created exchange-item allocation. Semantic Browser opened three panes: Referencing Elements; Current Element (including Parent = Interface 1 and All Related Diagrams = the CDB); and Referenced Elements (Exchange Item = ExchangeItem 1). This gives a navigable explanation of an allocation's owner, target and representations instead of only an opaque edge. The previously inspected scenario-reference context menu additionally exposes Show in Project Explorer (F8), Show in Semantic Browser (F9), Show Related Elements, Impact Analysis, Show/Hide, Layout, Reset Origin, Validate, Allocation Management and mass-editing/visualization actions. Except for F9 and Open, these menu actions were observed rather than executed in this resumed pass. + +### Checkpoint and remaining comparison work + +This pass materially advanced all five engineering feature comparisons with actual creation/editing, and exercised a subset of navigation; it is still **not** a completed six-area benchmark. Remaining direct comparisons include participant/include/extend variants; complete AND/OR/iterate branch topology; OA process equivalents; scenario operands/context/correspondences; state transition edge cases; data properties and categories; show/hide/selection productivity; and a current transition-preview round trip. Controls merely seen in a palette are distinguished from successfully operated controls above. No conclusions about Studio passing these acceptance workflows are implied. + +CUA handed back to the lead for Studio verification. Original Capella remains open, window **524804**, launch exec session **55359**, maximized at 2560x1392. The active tab is the CDB with the allocation selected and Semantic Browser visible; other edited benchmark diagrams remain open. Changes are **unsaved** in the disposable sample. Re-inventory windows before future input; these are continuation hints, not durable identities. All screenshots were captured and inspected through the native Computer Use tool; no screenshot files were saved. No Studio code or build was changed by this benchmark agent. diff --git a/harness/release-data-contract.md b/harness/release-data-contract.md new file mode 100644 index 00000000..b7d03c2f --- /dev/null +++ b/harness/release-data-contract.md @@ -0,0 +1,161 @@ +# R6 data and interface contract proposal + +Status: read-only design, 2026-09-05. No implementation, test pass, or native Studio acceptance is claimed here. This is the recommended integration contract for the lead to freeze before the R6 worker changes exhaustive enums. It extends the current typed model and transaction path; it does not introduce an instance database, expression evaluator, units engine, or plugin metamodel. + +## Evidence and scope + +Current `model.rs` has Information/Material/Energy/Event in one item-kind enum, primitive/enum/Record type definitions, fields with type/multiplicity/unit, and `Interface.items: Vec`. `references.rs` centrally maintains these links. The desktop uses the generic typed forms, including an item picker; its kind selector still mixes Event with physical nature. The R6 gap and release proof are recorded in `release-semantics-audit.md`. + +The native benchmark in `release-capella-workflow-benchmark.md` actually created an Operation exchange item, an interface, and an allocation of that existing item. The allocation is independently selectable, has its own transmission/acquisition protocols, and appears as a nested row while the original item retains its identity. The dictionary displayed nested class properties, enum literals, association role names and multiplicities. Numeric/default edits and category operations were not exercised; do not report them as native observations. + +Primary-source support: Capella Information defines independent exchange mechanisms, operation-parameter directions, typed numeric/string bounds/defaults, and structured types/associations. This supports the feature families, not every simplification chosen below. [Information.ecore](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/Information.ecore). Capella's interface allocation is a relationship with an allocated item and sender/receiver protocol properties; its container is the allocating interface. [CompositeStructure.ecore](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/CompositeStructure.ecore). Functional input/output ports are distinguished in the Arcadia reference, as already cited in the audit. [Arcadia metamodel](https://mbse-capella.org/resources/arcadia-reference/Arcadia%20Language%20-%20MetaModel.pdf). + +The detailed rules below are explicit Studio design decisions. They are not claims of complete Capella/UML compatibility. + +## Proposed persisted shapes + +Retain existing headers, layer ownership, field IDs, relation IDs and `TypeRef`. All additions belong to the final schema/source version 3. Reference-bearing properties participate in the existing reference visitor, delete impact, transition closure and semantic review snapshot. + +```rust +enum ExchangeItemKind { Information, Material, Energy } // remove legacy Event via migration +enum ExchangeMechanism { Unspecified, Flow, Event, Operation, SharedData } +enum ParameterDirection { Unspecified, In, Out, InOut, Return } +enum FieldStorage { Value, Reference } +enum DataValue { Boolean(bool), Integer(i64), Real(f64), String(String), Enumeration(String) } + +enum DataTypeDefinition { + Boolean, + Integer { minimum: Option, maximum: Option }, + Real { minimum: Option, maximum: Option }, + String { minimum_length: u32, maximum_length: Option }, + Enumeration { literals: Vec }, + Record { supertype: Option }, +} + +SharedBody::ExchangeItem { + kind: ExchangeItemKind, + mechanism: ExchangeMechanism, + supertype: Option, +} +SharedBody::DataType { + definition: DataTypeDefinition, + default: Option, + unit: String, +} +SharedBody::DataField { + type_ref: TypeRef, + minimum: u32, + maximum: Option, + unit: String, + direction: ParameterDirection, + storage: FieldStorage, + default: Option>, +} +SharedBody::Interface { supertype: Option } + +enum TransmissionProtocol { Unspecified, Synchronous, Asynchronous, Unicast, Multicast, Broadcast } +enum AcquisitionProtocol { Unspecified, Read, Accept } +RelationBody::InterfaceItemAllocation { + source: Id, // Interface + target: Id, // ExchangeItem + order: u32, // presentation order within the source interface + transmission: TransmissionProtocol, + acquisition: AcquisitionProtocol, +} +RelationBody::DataAssociation { + source: Id, // Record DataType + target: Id, // Record DataType + source_role: String, + target_role: String, + source_minimum: u32, + source_maximum: Option, + target_minimum: u32, + target_maximum: Option, + source_navigable: bool, + target_navigable: bool, +} +``` + +`InterfaceItemAllocation` is deliberately distinct from the existing `ExchangeAllocation` (boundary exchange realizes a work exchange). The former is a normal relation record, so it already supports artifact traceability, selection, query, identity changes and undo. `Interface.items` must not remain a second writable list of the same fact. Use a derived `interface_items()`/`effective_interface_allocations()` helper wherever existing code needs the items. + +New GUI defaults: Information + Unspecified mechanism; bounds absent; String length 0..*; type default absent; field 1..1, Unspecified direction, Value storage, no field default; no supertype; protocols Unspecified. Append an allocation after existing order values; ties are valid and sort by alias/UUID, so ordinary relation creation with order 0 remains deterministic. The contextual “Operation parameter” action sets In and the “Physical flow” action sets Material + Flow. Defaulting generic creation to an asserted mechanism would invent an engineering decision. + +## Item mechanism, parameter and message semantics + +- Nature describes what crosses the boundary; mechanism describes how its contract is used. Material/Energy + Flow is directly useful. Do not derive nature from fields or rename an Event item to Material. Material/Energy with Event/Operation/SharedData warrants an explanatory warning (often the author intended information *about* the physical exchange), not an unsupported categorical rejection. +- Unspecified mechanism is a valid draft with a located completeness warning. Flow is an ongoing value/physical transfer contract; Event is a notification; Operation is an invocation with input/output parameters; SharedData describes access to shared information. No mechanism schedules or executes behavior. +- Parameter direction is interpreted from the operation provider/callee's perspective: In travels on invocation, Out and Return on response, InOut participates in both. Only fields directly owned by an Operation item may set a direction other than Unspecified. Record fields describe structure and have Unspecified direction; they inherit their containing parameter's direction when viewed in a message. +- An Operation may have no parameters or no response parameters (a void operation). Allow multiple Out fields and at most one Return field across the effective inherited parameter set. An Unspecified operation parameter is a warning; it must not silently become In. In/Out/InOut/Return on non-operation content is a structural error; changing mechanism therefore requires resolving incompatible directions in the same transaction. +- Keep R4 call/reply pairing and endpoint/path checks. Add phase-aware value annotation checking only where the message actually carries an Operation item: Call values can address In/InOut; Reply values Out/InOut/Return. Unspecified parameters warn and remain addressable during authoring. Event/Flow exchange annotations do not gain operation directions. A Call without an Operation item is a warning, preserving existing generic call modeling; it is not reinterpreted or broken on migration. + +### Necessary R4 reference seam + +`MessageValue { field, expression }` currently identifies a field definition. When `origin` and `destination` both use the same `Coordinate` record, both have the same nested `latitude` field UUID. One UUID cannot disambiguate two argument values or enforce their possibly different directions. + +Recommend adding `MessageValue.path: Vec` (default `[]`) with the existing `field` as terminal ID. `path` contains the enclosing DataField IDs from the direct item parameter down to, but excluding, the terminal field. Direct parameters have an empty path. Each hop must follow Value storage through a named Record's effective fields. Uniqueness is `(path, field)` rather than `field`. Aliases render each hop in text, and the picker displays `origin.latitude` / `destination.latitude` with both UUIDs maintained. + +For old empty-path nested annotations, resolve exactly one valid path when possible during version-2 migration. If several paths exist, retain the old field-definition annotation and issue `message_value_ambiguous_path`; do not guess an argument or discard the expression. The editor offers explicit path selection. This unresolved legacy form has no asserted parameter direction, so only warn until resolved. New graphical annotation creation always writes a concrete path. This is a small R4 shape amendment to freeze with R6, not a second scenario engine. + +## Types, values, multiplicity and units + +Numeric ranges are inclusive; an absent bound means unbounded within the represented Rust numeric domain. Integer literals are signed 64-bit; Real literals are finite IEEE-754 binary64. Reject NaN/infinities, integer overflow, reversed ranges and defaults outside range. Do not compare integer ranges by converting to f64. The spec must explicitly avoid claiming arbitrary-precision decimal arithmetic. Canonical serialization retains a valid JSON/source numeric spelling with round-trip value equality. + +String lengths count Unicode scalar values (`chars().count()`), not UTF-8 bytes or grapheme clusters. Enforce nonnegative ordered bounds and default lengths. Enum literals remain case-sensitive unique nonempty strings; a default must match a literal. Rename/remove of a literal is a transaction affecting defaults or is rejected if it would leave invalid values. No heuristic matching by display name. + +`DataType.default` is a scalar value for Boolean/Integer/Real/String/Enumeration definitions. `DataField.default = None` means no explicit field default, and `Some([])` is an explicit empty collection allowed only at minimum 0. A nonempty field default is a sequence of typed scalar values with length in the field's multiplicity. A scalar field therefore uses a one-value vector. Type defaults supply the value of an individual element; they do not instantiate a required number of values or make a collection present. Field defaults override type defaults; a derived/read-only inspector row can show the effective element default. No numeric/string coercion; `Real(1.0)` is distinct from `Integer(1)`. + +Record/object-reference literals and executable default expressions are outside this concrete scalar-default feature. Reject them explicitly instead of accepting opaque JSON or validating only part of a literal. A record may still be described completely by its typed fields and their scalar defaults. R4 `MessageValue.expression` remains a descriptive expression annotation, not a `DataValue` claimed to have been evaluated. + +Unit text is an engineering annotation, not a dimension or conversion registry. Numeric named types can define a reusable unit. An empty field unit inherits the type unit; an explicit field unit equal to that unit is redundant but valid. A different nonempty unit on a named type with a nonempty unit is a warning and must be shown clearly, because the tool cannot prove conversion compatibility. Units on nonnumeric fields are preserved with a warning (legacy models permit them). Never convert bounds/defaults based on unit strings. Required physical acceptance can use `kg/s`, `V`, `A` and `degC` without introducing a units framework. + +## Records, specialization, associations and ownership + +Record specializations have at most one same-layer Record supertype. Effective fields are inherited fields followed by locally owned fields. Preserve inherited field UUIDs; show their origin/read-only ownership in nested rows. Reject cycles and local fields with a name already present in the inherited set; this release has no implicit shadowing/redefinition. Supertype removal can remove inherited fields from the effective payload and must trigger the existing annotation-pruning/impact path. Inheritance is not model containment: deleting a derived type never deletes its base or base fields. + +ExchangeItem and Interface each get the same single-supertype pattern, constrained to their own family and layer, acyclic. A specialized item inherits fields and must preserve the base nature and any specified base mechanism; it may refine an Unspecified base mechanism. Incompatible changes are rejected as an explicit contract conflict. An interface inherits allocations; a local allocation of an already inherited item is rejected rather than creating ambiguous protocol overrides. Editing the original allocation changes all inheritors, with reverse usage navigation. Empty records/items/interfaces remain valid authoring drafts, with warnings where an allocated/carrying contract lacks definition. + +Value storage means the field's values form part of the serialized/transported record value; Reference storage means a link to a separately identified record object. Reference storage requires `TypeRef::Named` pointing to a Record, permits no scalar default, and does not imply ownership/deletion of the target type. Model containment is always the DataField's actual `parent`; changing storage never reparents the referenced type. + +Detect cycles in the graph of mandatory Value record fields (edges with minimum > 0), including effective inherited fields, and warn `record_no_finite_value`: such a type cannot have a finite fully populated value. This is a completeness warning rather than rejection because existing V2 record references lacked storage semantics and the model describes types rather than asserting an instantiated value. Optional (minimum 0) or Reference cycles are permitted without that warning. This distinguishes a linked-list/tree definition from an impossible required self-value while keeping saved models readable. Traversal must use a visited/path guard and bounded work; do not reject every recursive type just to simplify recursion. + +`DataAssociation` is a binary reference relationship between Record types. It does not add contained fields, carry a payload transitively, or imply deletion of instance objects. Self-associations are valid. The source-end multiplicity says how many source instances may be related to one target instance, and vice versa; a multiplicity is not a count of model elements. Role names belong to the named ends. Navigation toward an end is permitted when that end's navigable flag is true. Empty roles are a completeness warning, invalid ranges are errors, and same-type self-associations require distinct nonempty role names when both ends are navigable. Associations remain distinct identified relations even when endpoint types match; duplicate role contracts warrant a warning rather than global source/target deduplication. + +Do not add association composition/shared aggregation in this tranche without their ownership semantics: Value fields already supply nested value composition, and binary association supplies cross references. Exchange categories and persistent diagram filters remain a separate named release requirement; they must not be encoded as fake DataAssociations or fake exchanges. + +## Interface allocation and port rules + +The allocation source is an Interface and target a same-layer ExchangeItem; at most one direct allocation per source/target pair. Sender/receiver protocols belong to this relationship, not the global item. A nested interface row selects the allocation UUID, opens allocation protocols, and offers “Open item” / “Find usages”. Removing the row removes allocation only. The item and other interfaces' allocations remain. Protocol enums represent the choices actually benchmarked; no constraint is invented between protocol and item mechanism without evidence. An Unspecified protocol is valid and need not generate noise. + +Provided/required interface relations retain their current meaning. Boundary/work exchange compatibility must use the effective inherited interface-item set, with the same-item or documented item-specialization acceptance rule. Recommendation: an exchange may carry a specialized item when the interface allocates its base (subtype satisfies base contract); the reverse does not satisfy a specialized contract. Compare UUID plus an explicit ancestry walk, never names. Reuse this rule in exchange allocation and port checks. + +Functional ports on Activities/Functions are directed In or Out. Participant ports retain In/Out/InOut. Preserve legacy function InOut as an actionable warning so saved projects remain readable. New UI creation and Change direction controls expose only In/Out for work owners. For consistent REST/source draft behavior, core validation continues to report a warning for an explicit legacy/author-entered InOut; do not create an unpersisted origin flag or make input channel determine validity. Publish this draft allowance honestly in the spec. + +Provide a transaction-level Split bidirectional work port action. Keep the original port ID as the selected primary direction, create a sibling opposite port, preserve owner/interface, remap source_port usages to Out and target_port usages to In, and preview ambiguous non-exchange references/artifact attachments rather than duplicating their meaning silently. The action must be undoable; input/output placements are ordinary port placements. An alternative strict ban on all new API InOut would require a persisted explicit legacy distinction or automatic load-time split, neither recommended for this minimal contract. + +## Source, API, migration and lifecycle + +1. Freeze final `schema_version = 3` and `arcadia 3` after R5/R6 shapes are agreed. Retain explicit readers for original schema/source 2 and V1. The two golden V2 fixtures must stay byte-for-byte unchanged. Treat existing staged version-2 R1/R2/R4 additions as a documented compatibility input, not as evidence that original V2 carried their semantics. +2. V2 Information/Material/Energy becomes the same nature with Unspecified mechanism, never guessed Flow. V2 Event becomes Information + Event. Old Integer/Real/String/Record constructors receive empty bounds/default/supertype; old fields retain UUID, parent, type, multiplicity and unit, plus Unspecified direction / Value storage / no default. Old interfaces gain no supertype. +3. Convert each old `Interface.items` entry into one InterfaceItemAllocation, with deterministic UUID v5 using the project UUID namespace and stable name `v2-interface-item::`. Generate a deterministic collision-free alias from the endpoint aliases/IDs, protocols Unspecified, and `order` equal to its original zero-based list index. Validate collision against *all* existing object IDs and aliases; fail a pathological collision with a located migration diagnostic rather than overwrite anything. This preserves the existing list's visible ordering without retaining two writable representations of membership. +4. Keep all old element/relation/diagram/node/trace/artifact identities. Migrating a file does not change stored revision until explicit save/import commit. Canonical output is version 3, so opening old source may show an explicit upgrade diff. The migration report lists semantic defaults and generated allocation IDs. Update V1 migration through the same V2-to-V3 semantic normalization rather than two divergent new-data migrations. +5. Text uses the existing readable constructor grammar, typed numbers/enum constructors, and unquoted aliases for references. Add `supertype` and MessageValue `path` to reference-aware fields; `direction`, `storage`, `mechanism`, protocols and typed DataValue constructors to the catalog. OpenAPI describes each exact closed object/enum, finite ranges, reference semantics and migration defaults. No JSON blob escape hatch. Keep the current REST query/transaction/candidate/ETag endpoints; new relation kinds are normal queryable records, not new services. +6. Shared helpers should compute effective fields, item ancestry, effective interface allocations and carried *field paths* once per validation snapshot. The public `carried_fields()` helper can derive the terminal-ID set for existing callers. Direct fields of either storage kind are carried, including inherited fields; recursive descent only follows Value record fields and never association/Reference targets. Make validation, picker compatibility, API diagnostics and transitions call these helpers to avoid divergent definitions. +7. Reidentify/remap visits every supertype, association/allocation endpoint and every MessageValue path entry. Required transition closure copies/remaps required types, supertypes, fields and interface allocation records; associations between included types are preserved, but do not cause a whole dictionary to be copied merely because a type has a reference association. Exact dependency policy is visible in the existing preview. R3 baseline/diff must include bounds, default values, units, direction, storage, protocols, specialization and end-role contracts. +8. Delete impact never removes a base type merely because its user is deleted. Deleting a base with surviving derived contracts or a named type with dependent fields must show those consequences and use the existing explicit cascading transaction policy; do not silently substitute String. Removing an interface allocation does not delete its item. Removing carried-field connectivity prunes or explicitly reports affected R4 annotations according to the current deletion policy; no dangling field paths survive a successful transaction. + +## Observable release proof and focused regression plan + +- Build OA Material fuel Flow and Energy electrical Flow with field quantities using reusable bounded types (`MassFlow`, `Voltage`), and an information Operation with In/InOut/Out/Return parameters using an enum and a nested record. Create these through contextual GUI actions, edit a field row, and read the resulting contracts without raw constructors. +- Allocate the same operation to two interfaces; show nested allocation rows and separate global item. Change one allocation's protocol and verify the other remains unchanged. Select a row, follow item/owner/reverse usages, attach a development artifact to the allocation, delete one allocation, undo, and reload. +- Define a base record plus specialized record with an inherited enum field, a distinct local bounded value, and an association to another record. Verify nested inherited rows, role labels/multiplicities, navigation and no noisy allocation arrows. Use a reused Coordinate record twice in operation parameters and verify scenario value pickers show both concrete paths. +- Exercise a Call with input values and paired Reply with output/return values. Rename/reidentify the record field and operation; annotations remain attached. Wrong-direction values, invalid field paths, wrong enum defaults, integer overflow, non-finite reals, reversed limits, invalid multiplicity and inheritance cycles produce located diagnostics and preserve the last accepted model. +- Verify empty types/interfaces, Unspecified mechanisms/directions, recursive optional/reference data and legacy InOut produce understandable drafts/warnings, without crashes or recursive traversal stalls. Run the work-port split and verify both directions, all exchange references, undo and saved source. +- Round-trip the same model through GUI save/load, canonical source parse/format, JSON, REST candidate/transaction/export/restart, and OA-to-SA/back transition preview. Query allocation/association records, reidentify their endpoints, and verify artifact links plus R3 semantic drift. Confirm invalid REST changes remain atomic with unchanged ETag/disk state. +- Keep original V2 golden migrations and add a small fixture containing legacy Event, InOut functional port, Interface.items, nested records, defaults absent and reused nested message field ambiguity. Verify deterministic new IDs, no lost old IDs/content, precise migration reports and canonical second-save stability. +- Native inspect at normal and compact widths: nested rows, long qualified field names, inherited origin, bounds/unit labels, protocol editor, source lint navigation and diagram association ends. Numeric entry must accept temporary editing text without committing invalid data or moving keyboard focus unexpectedly. + +## Decisions the lead should explicitly accept or alter before coding + +Recommended: separate nature/mechanism with honest Unspecified defaults; scalar/list-of-scalar typed defaults with finite numeric domains; one same-family supertype; reference associations and Value/Reference field storage; allocation as an identified relation; MessageValue path qualification; legacy InOut as a visible draft warning with an explicit split action. Each choice has a specified migration above. + +The main bounded limitations are arbitrary-precision decimals, record/object-instance default literals, expression evaluation, dimensional conversion, association aggregation/redefinition and multiple inheritance. These must be documented as unsupported, not silently represented as unchecked text or described as completed Capella semantics. Their absence does not prevent the listed physical, quantity, operation, record, interface and traceability workflows. Exchange categories/filter work remains required elsewhere in the release plan. diff --git a/harness/release-diagram-port-correction.md b/harness/release-diagram-port-correction.md new file mode 100644 index 00000000..33001e7b --- /dev/null +++ b/harness/release-diagram-port-correction.md @@ -0,0 +1,23 @@ +# Diagram and port correction — adopted priority, 2026-09-05 + +User identifies flat graph diagrams and divergent port concepts as material defects. This is a release requirement, not optional visual polish. The prior data contract's generic InOut-port warning is insufficient. + +## Evidence + +Core `projection.rs` emits every relationship between placements; Overview removes all containment. Demo architecture mixes participants/work with capabilities, processes, chains and missions. `diagram.rs` paints every object, including a Port, as a large card; functional edges ignore their source_port/target_port geometry. Boundary exchanges disappear unless ports themselves are placed. This explains the reported divergence despite existing allocation-parent logic. + +Official [diagram descriptions](https://mbse-capella.org/features.html) distinguish OAB/SAB allocation, functional dataflow, capability and state views. [Allocation documentation](https://raw.githubusercontent.com/eclipse-capella/capella/master/doc/plugins/org.polarsys.capella.ui.doc/html/05.%20Diagram%20Management/Allocating%20Functions%20on%20Components.mediawiki) explains nested allocated leaves and distinguishable inferred parent summaries. + +Installed 7.0.1 Help, `4.05. Diagram Editor`, says function ports appear automatically with their displayed functions in synchronized diagrams. `7.1. Modeling accelerators` describes component exchange generation producing component ports and port allocations, and separate exchange-item propagation/synchronization to function ports. + +The [FunctionalAnalysis metamodel](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/FunctionalAnalysis.ecore) distinguishes FunctionInputPort/FunctionOutputPort with incoming/outgoing item contracts; FE creation automatically creates those endpoints. ComponentPort has distinct Standard/Flow kind and orientation applicable to Flow, plus ComponentPortAllocation to allocated ports. These semantics must not be reduced to one bidirectional interface-bearing field. + +Native Capella reopen attempted using the existing disposable workspace. Console launcher pid14556 has not exposed a targetable modeling window; graphical launcher also reported no targetable window. No new native port workflow has been observed yet. Existing native benchmark establishes nested OA activities and class/interface allocation rows but does not prove ports. + +## Ordered implementation + +1. Correct shared projection + SVG + canvas: architecture shows participant containment and allocated work with meaningful exchange edges; dataflow shows work hierarchy; capabilities have a dedicated view. Keep an explicitly named all-relationships view for exploration. Do not change semantic objects just to remove clutter. Ports derive as small, selectable boundary anchors of visible owners, and exchanges terminate on the actual port. Computed links after collapse must not invent port identities. Group movement, Fit, SVG and hit testing use this same geometry. +2. Correct authoring/model contracts: distinguish functional In/Out contracts from component Standard/Flow ports; function-port exchange items; explicit component→function port allocation and direction/owner checks. Creating a functional exchange creates or reuses compatible ports atomically. OA interactions remain activity-to-activity without fake functional ports. Preserve legacy IDs/prose/interface references in the explicit schema3 migration, with reviewed resolution for incompatible old InOut work ports. Implement source/form/API/reference/transition parity with this change, not a canvas-only workaround. +3. Contextual insertion/allocation and grouped edge filters, orthogonal routes and labels. Default demo/worked examples must communicate these modeling distinctions immediately. Show allocation/containment explanations and derived-summary styling. Verify nested movement/collapse/reveal, creation, port selection/connection, exchange allocation, undo, save/reopen and SVG on actual native UI at default/minimum size. + +Keep R5 state editor's current code/tests; its native acceptance remains pending. R6 data contract and release audit must be amended around step2 before implementation. All prior first-release requirements remain active. diff --git a/harness/release-language-integration.md b/harness/release-language-integration.md new file mode 100644 index 00000000..bdcb3ff2 --- /dev/null +++ b/harness/release-language-integration.md @@ -0,0 +1,121 @@ +# R1 / R2 / R4 language and API integration — 2026-09-05 + +Status: these bounded source/API milestones are implemented and verified. This +is not first-release completion; native authoring and remaining R3/R5/R6, +lifecycle, migration and final independent reviews remain required. + +## Current contract + +- R2: `oa_mission`, Mission traces, include/extend/generalization and mission + involvement have canonical source and OpenAPI counterparts. Conditional + extension preserves its exact condition. The broad fixture covers all 28 + element kinds and all 20 current relation kinds (including R1 SequenceLink). +- R1: Control Choice/Merge/Fork/Join/Loop and SequenceReference use typed source; + `sequence @alias` is a real reference field. The OA and SA round-trip fixture + contains work contributions, parallel/alternative paths, an explicit loop, + a reused sequence and guarded contextual links. Recursive reuse and invalid + reference spelling reject atomically. +- R4: scenario viewpoints, every message kind including Timer, call correlation, + carried-field expressions and ScenarioUse bindings have canonical source and + closed OpenAPI payloads. Reference fields are `scenario`, `reply_to`, `field`, + `referenced` and `local`; viewpoint/message-kind enum fields remain readable. + Native parser fixtures cover both strict work viewpoints, call/reply reversal, + create/delete order, timers, values and a reused scenario. No expressions are + evaluated or silently converted to literals. +- `docs/language.rst` now specifies the current R1/R2/R4 constructors, ownership, + topology/message/reuse constraints, traces, compatibility and located lint. + `docs/api.rst` and `docs/openapi.json` expose the matching service contract. + The rendered language/API HTML is regenerated from those reST sources. + +## Compatibility is deliberate and testable + +Schema 2 remains a temporary integration header. Omitted old Scenario viewpoint +hydrates as MixedLegacy in **both source and JSON**, rather than applying a +new GUI factory's stricter interpretation. Missing Exchange message fields +hydrate as Asynchronous, no reply correlation and no value annotations. The +V1 migration constructor supplies the same explicit defaults. + +The original V2 coastal rescue source and JSON are preserved under +`crates/oa-core/tests/fixtures/v2-coastal-rescue.*`. Do not regenerate them from +the new demo. The golden regression compares the exact original JSON against +the upgraded model after removing only the newly added defaults: every other +persistent field remains equal. Original source and JSON produce the same +model; canonical source records explicit compatibility choices and round-trips. + +The final distinct release schema and explicit V2 migration are still required +after R5/R6 interfaces freeze. Current serde defaults are staged compatibility, +not evidence of that future adapter. New schema-3 contracts must not silently +inherit these missing-field defaults without an explicit decision. + +## Verification actually observed + +Target: `D:/CodexBuilds/Capella2-01a06e2e`, Windows Rust 1.98.1. + +``` +cargo test -p oa-core --locked +cargo test -p oa-server --locked +cargo clippy -p oa-server --all-targets --locked -- -D warnings +``` + +Current combined core: **83 passed** (15 language, 4 admission/lint, 7 +capability, 6 topology, 7 scenario, 15 prior regressions, 28 workflows, +1 process diagnostic). Server: **16 HTTP/configuration tests + 1 deterministic +prepared-commit test passed**. Strict server all-target Clippy passes; the core +dependency is checked as part of that command, while the core owner separately +ran strict all-target core Clippy. Owned Rust files pass Rustfmt. Language/API +reST builds halt on warnings and both completed without warnings. + +The actual HTTP suite imports the new mission/branching chain model, edits a +SequenceLink guard, exports equivalent readable source, filters by the new +relation kind and restarts from disk. A separate actual HTTP case imports the +original V2 JSON, rejects a malformed richer source without advancing its +revision, edits a typed call value, retains reply references, exports equivalent +source and reopens the accepted envelope after restart. + +TS1 parser/API responsiveness evidence remains in +`harness/release-text-service-audit.md`: exact formerly stalling 638,689-byte +source rejected in 0.050 s; concurrent native/legacy JSON catalog reads and a +valid-candidate/rename race verified on the optimized native server. Those exact +wall times were measured at the R2 checkpoint. The same rejection/concurrency +transport regressions pass after R4; no new throughput guarantee is inferred. + +No Computer Use operations were performed by this worker. No probe server or +test process is intentionally left running. Current first-release changes have +not yet had final Linux, container, release-toolchain or independent review +verification; prior V2 platform evidence must remain labeled as prior evidence. + +## R5 state/event source and service integration checkpoint + +R5 shapes are coordinated with the core worker: nine additional StateKind/Kind +vertices, Event Signal/Time After/At/Change, and StateTransition +`transition_kind`, `triggers`, `effects`. Language uses `oa_`/`sa_` snake-case +declarations, alias references in the new lists and a typed transition-kind +word. The existing generic constructors handle the rest; no separate parser, +event registry or service route was introduced. Core serde/factory defaults +provide staged External/[]/[] compatibility, and the original V2 golden test +now excludes only those added defaults when proving every original field +remains identical. Trigger/guard/effect strings are explicitly compared. + +The all-kind parity fixture includes all 38 kinds. A separate OA/SA source test +exercises every new vertex, all Event bodies, every TransitionKind, Unicode +and quoted prose, invalid typed enums/references, and old-field defaults. +OpenAPI now names StateKind, EventBody, TimeEventKind and TransitionKind; +language section 4.4 records the approved state semantics and API documents the +ordinary batch/reference lifecycle. Header remains **temporary schema 2**; +the explicit final schema-3 migration is still required after R6. + +Observed at this checkpoint: 16/16 language tests; 17/17 HTTP/configuration +tests plus the deterministic Store unit; strict all-target server Clippy. +The actual HTTP event test imports source, creates an Event and changes its +transition in one batch, reidentifies event/payload UUIDs, retains exact prose, +checks stale ETag 412 and wrong-layer rollback 422, queries Event records, +exports equal source, removes trigger/effect/payload references and verifies +the last removed trigger leaves descriptive intent, then restarts from disk. + +A follow-up added a located warning assertion for empty Time expression. +Its rerun intersected the core worker's deliberate state-topology integration: +`lib.rs` declared `state_validation` before the new file was saved, producing +E0583. No source/service workaround was added. Await final core topology freeze, +then rerun language+admission tests, server regression and strict Clippy. +Both reST outputs already render with halt-on-warning enabled; an initial short +section underline warning was fixed and successfully re-rendered. diff --git a/harness/release-native-ux-audit.md b/harness/release-native-ux-audit.md new file mode 100644 index 00000000..ddc79835 --- /dev/null +++ b/harness/release-native-ux-audit.md @@ -0,0 +1,49 @@ +# First-release native UX audit — 2026-09-05 + +Independent, bounded review of the existing V2 executable against the new first-release objective. No application source or existing model was modified by this review. Computer Use used the supported `@oai/sky` native API; screenshots were captured and visually inspected after each meaningful action. + +## Environment and scope + +- Windows packaged executable: `dist/ArcadiaStudio-Windows/arcadia-studio.exe`, SHA-256 `e2aec2ac935e270c6185729cbb76f8323b381654c04dbde7aae90c4628a98a20`. +- Actual application window 2560154. Startup client 1440×900; compact client 1100×700, changed through the native system-menu Size operation. +- Exercised startup demo, guide, New, first architecture view, entity creation/properties, allocated activity creation, minimum layout/Fit, model search, invalid-property rejection/Revert, native save/open and normal close. +- Disposable UI-authored fixture: `D:/CodexBuilds/Capella2-v2-01a06e2e/final-ui-review/release-native-audit-new-project.arcadia`. +- Original Capella window 3673208 was left untouched. The review app was closed using Alt+F4; a subsequent process query found no `arcadia-studio` process. Native session ownership is released. +- This pass did not launch the server or re-exercise all OA/SA semantics. It is additional first-release UX evidence, not a replacement for the V2 verification reports. + +## Findings requiring first-release action + +### R1 — Built-in help does not yet satisfy the new navigable tutorial requirement + +**Priority: P1 requirement gap.** Clicked Engineering guide and read both its initial viewport and remaining content. It contains seven static cards plus Open coastal rescue example. The concepts are accurate and concise, but there is no table of contents, topic navigation, help search, practical sequence of controls for common modeling tasks, contextual topic access or shortcut/reference section. The sidebar Search is model-element search and does not search help. `views.rs::guide` confirms the seven-card implementation. + +A first-time engineer is told to allocate activities, connect exchanges and review transitions without being shown the actual sequence of controls or the distinction between semantic ownership and a diagram placement. The new objective explicitly requests easy navigation/exploration and clear information on common modeling scenarios and application use. + +**Feasible fix:** a small built-in article/topic browser with search and related topics, a first-project walkthrough, OA/SA boundary and allocation explanations, transition review tutorial, scenarios/states/data/interfaces, traceability, text/lint/recovery, files/shortcuts and API integration. Keep it native and available offline; reuse concise content instead of adding a general documentation framework. Add contextual links where the user already encounters these operations. + +### R2 — Open/New/Demo forget the actual window size; Fit becomes wrong + +**Priority: P2 reproducible functional defect.** At the minimum size, an authored entity with one allocated activity fits correctly at 95%. Saved the model through the native Save As dialog, then Ctrl+O reopened that exact file. The app immediately displayed 150%, with the nested activity and parent clipped under the right inspector and below the canvas. Clicked Fit and took an additional fresh screenshot: it remained 150% and clipped. Thus Fit itself no longer repairs the view until another physical resize event updates app state. + +The fixture has two elements, one Allocation and two diagram nodes. File bytes confirm those records; no malformed geometry is involved. `app.rs::reset` replaces the complete application with `Self::with_project(project)`, whose `window_size` is the launch default 1440×900. `fit()` uses that field. The native window remains 1100×700 and does not need to emit another resize when a file opens. + +**Fix:** preserve the current native `window_size` across project replacement, then fit. Apply this to every reset caller (New/Open/Demo). Add a regression that sends WindowResized, resets/loads a model, then checks Fit uses that preserved viewport. Reverify saved fixture opening at minimum and default sizes. + +### R3 — At minimum size, search results are initially entirely below the visible sidebar + +**Priority: P2 usability.** At 1100×700, the eight navigation rows and creation controls place Search at the very bottom. Typing `rescue` produces no visible result. Scrolling the entire sidebar then reveals Rescue coordinator, while the perspective switch and upper navigation disappear. This is functioning search, but a frequent navigation action gives no visible feedback until the user discovers the outer scroll. `views.rs` puts navigation, creation, search and all results into one scrollable column. + +**Feasible fix:** reserve useful visible height for search results and provide a compact navigation treatment at short window heights, or separate the explorer from navigation. Also give an explicit no-results state. Preserve usable hit targets and verify both supported sizes. This matters more as the new release grows its modeling and help features. + +## Observed strengths and recovery behavior + +1. Startup is reliable and visually intentional: the rich demo opens with 39 objects, 23 links and zero checks; nesting is understandable. +2. New gives a useful empty-state instruction and Create architecture view button. Creating the view immediately produces an editable OA view. +3. Entity → At root creates and selects a visible node. Renaming it Rescue coordinator and Apply changes updates the diagram. Choosing Activity while that participant is selected changes the second create action to Allocated; clicking it creates an Allocation and visibly nests the activity. This is a clear, efficient interaction worth preserving. +4. At the minimum size before reopening, Fit renders the authored nested pair fully. The toolbar and inspector remain reachable with appropriate panel scrolling. There was no general overlap of controls. +5. Entered `bad alias` in Stable source alias. Apply rejected it without changing the accepted node (`invalid_alias: Invalid or reserved alias 'bad alias'.`). Revert restored `new_activity`. This recovery works. The feedback is currently only a small footer line; field-local guidance and valid alias examples would help but are lower priority than R1–R3. +6. Native Save As accepted the disposable absolute path, reported All changes saved/Project saved and produced canonical text containing the expected two elements, allocation and placements. Ctrl+O reopened the exact file with two objects/one link/zero checks; semantics were preserved despite R2's viewport regression. + +## Review disposition + +First-release UX completion is not yet proven. R1 is an explicit new requirement gap; R2 is a verified existing defect; R3 is a practical compact-layout navigation problem. No crash, accepted-model corruption or destructive recovery was observed in this bounded pass. Root was notified of R1 and R2 immediately; this report completes the independent audit and provides concrete next verification steps. diff --git a/harness/release-scenarios-core.md b/harness/release-scenarios-core.md new file mode 100644 index 00000000..2ecec120 --- /dev/null +++ b/harness/release-scenarios-core.md @@ -0,0 +1,63 @@ +# R4 reusable scenario and message core milestone — 2026-09-05 + +Status: bounded core implementation and focused verification complete. Source/API migration, graphical authoring/rendering, native workflows and independent review remain integration gates. This provides explicit modeling semantics, not message execution or simulation. + +## Frozen interface + +- `ScenarioViewpoint`: OperationalActivity, ParticipantInteraction, Functional, BoundaryInterface, MixedLegacy. `SharedBody::Scenario` adds `viewpoint`. New OA scenarios default to OperationalActivity; new SA scenarios default to Functional. The demo explicitly uses BoundaryInterface because its lifelines represent participants. +- Work viewpoints use Activity/Function lifelines and the corresponding Interaction/FunctionalExchange. Participant viewpoints use OA Entity/Actor/Role or SA System/SystemActor lifelines; their exchanges can use participant ports or allocated work, with the existing endpoint/effective-performer agreement rules. Wrong declared viewpoint/layer or represented family rejects. +- `OccurrenceBody::Exchange` adds `message_kind: MessageKind`, `reply_to: Option` and `values: Vec`. +- `MessageKind`: Asynchronous, Call, Reply, Create, Delete, Timer `{ expression: String }`. A timer is a local lifeline event: sender required as an editable reference; receiver absent or equal to sender; no global exchange or carried values. Missing duration/deadline expression warns. +- `MessageValue { field: Id, expression: String }`: contextual expression annotation bound to an actual carried DataField identity, including nested named-record fields. It is not a claim that expressions are evaluated or literal values type-checked. Duplicate/unrelated fields reject; empty expressions warn. +- `LifelineBinding { referenced: Id, local: Id }` and `OccurrenceBody::ScenarioUse { scenario: Option, bindings: Vec }`: same-layer, same-viewpoint reuse with explicit one-to-one lifeline correspondence. Bound lifelines represent the same semantic object, or an explicitly realized operational role. This is reuse of existing semantics, not an implicit generic-template substitution. Missing target/bindings warn; wrong context/identity, duplicates and recursive reuse reject. MixedLegacy viewpoints remain reviewable without inventing a precise historical viewpoint. + +## Behavioral rules + +A Reply references a Call in the same scenario, reverses its sender/receiver, and follows it on a guaranteed containing branch path. A call inside one alternative or optional loop is not treated as an unconditional call after the fragment. A reply can reuse the call's exchange with reversed direction, or use a separate reverse-direction exchange. Sequential duplicate replies reject; mutually exclusive branches are not silently linearized. Only Reply has `reply_to`. + +Create/Delete check structurally possible co-execution and order: a created receiver cannot already be used earlier on a possible path, and a deleted receiver cannot be used later. A conditional branch and a following sibling can co-execute, as can Parallel operands. Distinct Alternative operands are mutually exclusive. These checks do not solve arbitrary guards or execute traces. Call/reply/lifecycle/timer kinds belong to scenarios; ordinary process/chain exchange contributions remain Asynchronous. + +Reuse and call/value references participate in the common visitor, reference closure, transition mapping and deletion. Scenario viewpoint maps OperationalActivity↔Functional and ParticipantInteraction↔BoundaryInterface. Reusing one scenario twice preserves one referenced scenario identity. Explicit loop fragments model repetition; recursion in scenario references is rejected. + +Deleting a referenced scenario keeps its caller use as an incomplete draft and removes invalid lifeline bindings. Deleting a call clears reply correlation; deleting a bound lifeline prunes its binding. Deleting a carried payload/item also removes annotations for fields that cease to be carried, even if those fields survive in a shared record type. `validation::carried_fields(p, relation)` is the shared query for these direct/nested fields and graphical picker filtering. + +## Compatibility during integration + +Schema version stays 2 temporarily. Missing serialized Scenario viewpoint defaults to MixedLegacy; missing message kind/reply/value fields default to Asynchronous/None/empty. These defaults preserve old model information while clearly flagging unreviewed viewpoint semantics. The language/persistence owner is responsible for explicit lossless V2 migration and the eventual release schema/version change. The default factory and old-input compatibility behavior are intentionally different. + +## Verification actually run + +Using `CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e`: + +``` +cargo check -p oa-core --all-targets --locked +cargo test -p oa-core --locked --test release_scenarios +cargo test -p oa-core --locked --test workflows --test review_regressions --test process_diagnostics --test release_capabilities --test release_topology +cargo clippy -p oa-core --all-targets --locked -- -D warnings +``` + +Observed **7 scenario tests plus 57 prior focused core tests passed**, strict core Clippy passed, edited Rust sources/tests formatted. Evidence includes both transition directions; shared reuse from two callers; wrong/recursive bindings; call/reply direction, branch order and correlation; lifecycle/timer contracts; carried-field and nested-record values; every conversation element/relation UUID reidentification/deletion with exact undo; reused-scenario/lifeline deletion retaining callers; and loops distinct from recursive reuse. + +The annotation cleanup test initially failed when deleting a payload disconnected a surviving nested field. The core deletion path was corrected and the full scenario suite rerun successfully. No remaining failing focused test is waived. R3 full semantic-drift reconciliation, R5 states/events and R6 data contracts remain required; native authoring and rendering of these R4 constructs is not established by core tests. + +## Independent review corrections — 2026-09-05 + +Reproduced all three findings in `release-semantic-review.md` by running its separately compiled reproduction package. The original implementation wrongly reused call-dominance ordering for conflict detection. It now keeps `guaranteed_before` for Call→Reply correlation and uses separate common-ancestor co-execution/order for conflicting replies and lifecycle operations. Optional/Alternative-branch reply followed by a second reply and conditional deletion followed by use reject. Two Parallel replies or parallel delete/use reject. Legal mutually exclusive Alternative replies and delete/use operands remain accepted. + +Role endpoint compatibility now recognizes an explicitly assigned operational role. A distinct unassigned role realized by the same participant is incompatible; an assigned abstract role remains valid when concrete realizations are ambiguous. Three public helpers share these semantics between core validation, scenario-use binding checks and graphical pickers: + +``` +validation::message_endpoints(&Project, &OccurrenceBody) -> Option<(Id, Id)> +validation::lifeline_matches_endpoint(&Project, line: Id, endpoint: Id) -> Option +validation::semantic_representation_compatible(&Project, representation: Id, semantic: Id) -> Option +``` + +An unresolved reply correlation has no inferred endpoints. `None` compatibility means a missing reference or unresolved concrete performer, never an arbitrarily chosen realization. + +Observed after fixes: **68 focused core tests passed** (11 scenario plus 57 prior), strict core all-target Clippy passed. The independent reproduction now reports `duplicate_reply_after_optional: 1 error (duplicate_reply)`, `use_after_optional_delete: 1 error (lifeline_lifetime)`, and `valid_role_lifeline: 0 errors, no diagnostics`. Four new regression tests cover conditional/parallel/exclusive paths, role assignment versus realization/ambiguity, and effective forward/reverse reply endpoints. Graphical picker changes and independent re-review belong to the lead's integration gate. + +### Repetition follow-up + +Fresh review exposed two remaining silent repeated-execution risks. Added `reply_repetition` warnings when a Reply has an enclosing Loop that does not also enclose its correlated Call, checking every nested scope. A Call and Reply inside the same repeating scope remain valid. Added `lifeline_repetition` warnings for repeated Create/Delete without an ordered complementary lifecycle message in the same operand. This preserves coherent create→use→delete per iteration while diagnosing `[use, delete]` on the next iteration, creation without deletion, and conditional cleanup that may be skipped. Free-text guards provide no proven iteration bound, so these are conservative warnings rather than simulated execution results or a blanket prohibition of loops. + +Observed after this follow-up: **71 focused core tests passed** (14 scenario plus 57 prior), strict core all-target Clippy passed. The independently compiled reproduction now emits `reply_repetition` for `one_call_with_repeated_reply` and `lifeline_repetition` for `use_after_delete_next_loop_iteration`; both models remain editable with explicit warnings. The original conditional-path errors and valid Role scenario retain their corrected results. Three regression groups cover outside/same/nested call scopes, both missing lifecycle complements, balanced per-iteration lifetime and optional cleanup. No serialized shape changed. diff --git a/harness/release-semantic-review.md b/harness/release-semantic-review.md new file mode 100644 index 00000000..206b5a14 --- /dev/null +++ b/harness/release-semantic-review.md @@ -0,0 +1,145 @@ +# Independent R1/R2/R4 semantic review + +**Final focused status: all material findings from this narrow review are closed.** Historical findings and evidence below are retained chronologically. The final verification section distinguishes the accepted semantic/form checkpoint from subsequent scenario-authoring integration. + +Reviewed 2026-09-05 against the current goal, PLAN, actual core and desktop source. This is a narrow milestone review, not final release acceptance. R3 semantic reconciliation, R5 state/event expansion and R6 data contracts were explicitly outside this review. No application files were edited; no Computer Use was performed because the lead owns native interaction. + +## Material findings + +### P1 — Branch-local replies and lifeline destruction silently permit contradictory execution paths + +`crates/oa-core/src/validation.rs:1098` defines `guaranteed_before` by walking only the **after** occurrence's ancestors. The duplicate-reply check at approximately1277 and lifetime check at1315 reuse it as though it also determines whether two events can occur in order on any one branch path. These are different questions. + +Two actual reproductions were built and run: + +1. Scenario steps: Call A→B; Optional fragment whose one branch contains Reply B→A to that call; then another Reply B→A to the same call. The optional path executes two replies to one invocation. `validation::validate` reports **zero errors and no scenario warnings**. +2. Scenario steps: normal conversation; Optional fragment whose one branch deletes B; then a message to B. Taking the optional branch uses a destroyed lifeline. Validation again reports **zero errors and no scenario warnings**. + +The existing straight-line checks work and the current reST describes only guaranteed-path checks. This is therefore also a contract gap: the narrow rule currently leaves these concrete invalid paths entirely undiagnosed. It does not require simulation or expression solving to detect them. + +Smallest fix: retain the strict dominance/common-path predicate for proving that a correlated Call precedes Reply. Add a separate structural relation for whether two occurrences can execute on the same branch path and in what order. Distinct branches of one Alternative are mutually exclusive; a branch and an occurrence after its enclosing fragment can coexist; Parallel branches can coexist. Use this relation for conflicting replies and lifetime checks, at least warning when a violation is possible and rejecting structurally certain contradictions. Do not flatten alternatives into an unconditional execution order. Cover Optional/Alternative versus following siblings, two Parallel replies, and legal mutually exclusive Alternative replies in regression tests. + +### P2 — Role-based operational scenario lifelines are rejected despite valid role assignment and realization + +`validation.rs:2483–2500` compares represented lifeline identity only against exchange work identity or its concrete `effective_performer`. `ScenarioViewpoint::ParticipantInteraction` explicitly allows Role lifelines, and scenario-use bindings separately support realized operational roles. The endpoint check cannot express the same legitimate role correspondence. + +Actual reproduction: + +- OA activities Request service and Provide service, connected by Interaction. +- Entity Controller allocates Request service. +- Controller realizes Role Coordinator; Coordinator is assigned Request service. +- Other entity allocates Provide service. +- A ParticipantInteraction scenario represents Coordinator on its first lifeline and Other on its second, with a correctly directed call and correlated reverse reply. + +The base graph is valid; validation rejects the scenario with **two `scenario_endpoint` errors**. Removing the concrete allocation and relying on RoleAssignment/RoleRealization inference has the same underlying mismatch: effective performer remains Controller rather than the represented role. + +Smallest fix: define one core endpoint/lifeline compatibility helper that recognizes an explicitly assigned Role for the work, and participant correspondence through a valid RoleRealization where appropriate. Preserve wrong-role rejection. Reuse it for scenario-use identity compatibility and desktop choices rather than adding another slightly different predicate. Test the valid role case, a distinct unassigned role realized by the same entity, ambiguous realizations, and the existing direct-work/participant/port cases. + +### P1 integration seam — Graphical reply lifeline choices use forward exchange direction + +Current `crates/oa-desktop/src/forms.rs:600–661`, `lifeline_matches`, always chooses global exchange source for sender and target for receiver. Core's `validate_occurrence_context` correctly reverses these endpoints when Reply reuses its correlated Call exchange. Consequently a valid reverse reply's sender and receiver are excluded from their corresponding choice lists, while invalid forward-direction choices are offered. + +Evidence is direct source inspection: `reference_ids` calls `lifeline_matches` for sender/receiver; the helper does not inspect `message_kind` or `reply_to`. Core at approximately2522 does. This was reported to the lead while R4 form wiring was still underway, so it is an integration requirement to verify after those concurrent edits, not a claim about a finished release UI. + +Smallest fix: use the same core helper above for derived message endpoint direction and semantic lifeline compatibility. Add a form-choice regression asserting correct reversed choices for same-exchange replies and ordinary choices for an explicit reverse exchange; exercise both graphically after integration. + +## Verification and reproduction artifact + +Executed successfully: + +```powershell +$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-01a06e2e' +cargo test --locked -p oa-core --test release_topology --test release_capabilities --test release_scenarios +``` + +All **20 existing tests pass**: six topology, seven mission/capability, seven scenario. Their fixtures cover ordinary branch topology/reuse, closure mapping, straight-line calls/replies/lifetime, field references, deletion/reidentify/undo. They do not cover the above branch interactions or role lifelines. + +A separate read-only-to-application reproduction package is retained at: + +`D:/CodexBuilds/Capella2-release1-01a06e2e/semantic-review-repro` + +Run: + +```powershell +$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-01a06e2e' +cargo run --offline --manifest-path D:/CodexBuilds/Capella2-release1-01a06e2e/semantic-review-repro/Cargo.toml +``` + +Observed output: + +```text +duplicate_reply_after_optional: 0 errors, codes ["missing_system", "unallocated_leaf", "unallocated_leaf"] +use_after_optional_delete: 0 errors, codes ["missing_system", "unallocated_leaf", "unallocated_leaf"] +valid_role_lifeline: 2 errors, codes ["scenario_endpoint", "scenario_endpoint"] +``` + +The external package includes the existing release_scenarios fixture and adds only reproduction construction; it has its own Cargo.toml/lockfile and changes no repository tests. + +## Correctness and simplicity assessment + +No additional concrete high-impact R1 or R2 defect was found in this pass. Typed contextual SequenceLink relations, explicit reference visitors, validated atomic commands and ordinary core enums fit the existing architecture without a new framework. Required transition closure includes contextual sequence links and reusable target dependencies; tests cover remapping and both directions. Capability inclusion/specialization remain separate graphs and preserve containment independently, which is clear and appropriate. + +The principal avoidable duplication is endpoint agreement between core validation, scenario-use bindings and desktop filtering. Consolidating that precise predicate is a small correctness fix, not grounds for a wider abstraction or refactor. All findings require independent verification after the fixes; this review does not mark R4 or the release complete. + +## Focused independent re-review after core fixes + +Original branch and Role findings are **closed in core** by independently rerunning the unchanged original constructions. Current output reports `duplicate_reply` and `lifeline_lifetime` errors for the two invalid Optional paths, and no errors for the valid Role scenario. `semantic_representation_compatible`, `lifeline_matches_endpoint`, and `message_endpoints` now provide shared core semantics. The corrected `possibly_before` distinguishes structural co-execution from call dominance, retaining valid mutually exclusive Alternative replies. + +Reran all focused suites: **24 tests pass** (six topology, seven capability, eleven scenario). The four added regression groups cover the earlier findings and shared message direction helper. Desktop picker closure remains pending lead integration and an explicit notification to review those changes. + +### Remaining P1 — Loop repetition is still analyzed as a single execution + +Current `validation.rs:1211–1261` finds order at the first diverging structural ancestor but never considers returning to an earlier step during a subsequent Loop iteration. The message/lifetime scans also exclude the occurrence itself. Two additional independently compiled constructions produce **zero errors and no scenario warnings**: + +- One Call occurs before a Loop. The Loop body contains a Reply correlated to that outside Call. Repeating the body replies to the same single call multiple times. +- A Loop body uses lifeline B, then deletes B. A subsequent iteration uses the already destroyed lifeline before executing another deletion. + +These require no interpretation of free-text conditions to diagnose as possible violations. There is no loop bound in the model proving at most one execution. Both constructions are appended as `loop_review()` to the external reproduction program. Observed output: + +```text +one_call_with_repeated_reply: 0 errors, codes ["missing_system", "unallocated_leaf", "unallocated_leaf"] +use_after_delete_next_loop_iteration: 0 errors, codes ["missing_system", "unallocated_leaf", "unallocated_leaf"] +``` + +Smallest safe fix: preserve single-iteration dominance for ordinary Call→Reply validation, but identify enclosing Loop ancestors. A Reply repeated by a loop that does not similarly repeat its correlated Call needs a diagnostic. Lifecycle messages inside a potentially repeating loop need a clear diagnostic when repetition can create an already existing or use/delete an already destroyed lifeline. Conservative warnings are appropriate if finite bounds/guards cannot be proven; silence currently presents these paths as coherent. Avoid indiscriminately prohibiting loops: a Call and matching Reply repeated together within the same operand remain valid. Cover nested loops, Call outside versus inside the repeating scope, and the `[use, delete]` back-edge case. + +### Desktop integration follow-up + +After lead notification, inspected the current typed scenario-use/binding/call selectors. The original reverse Reply selector finding is **closed by source inspection**: forms now uses `message_endpoints` and `lifeline_matches_endpoint`. Scenario bindings call the shared semantic helper in the same local→referenced direction as core, retain one-to-one constraints and scope candidates to the relevant scenarios. No native verification was performed by this reviewer. + +One new P2 regression was reproduced in the state-observation branch of the updated form helper (`forms.rs:667` at review time). The exchange endpoint helper is not equivalent to state-owner compatibility: + +- SA Functional scenario lifeline represents Function F. +- F is allocated to System S. +- State Q belongs to S's StateMachine/Region. +- A State observation references Q on F's lifeline. + +Core accepts the model, because `validate_occurrence_context` permits `effective_performer(F) == S`. The new form selector evaluates `lifeline_matches_endpoint(p, F_lifeline, S) != Some(false)`, which returns **false**, hiding the valid lifeline. The prior form code included this work-performer allowance. The external reproduction now contains `state_picker_review()` and prints: + +```text +function_lifeline_state_observation: 0 errors, codes ["unallocated_leaf", "missing_initial"] +form_state_selector_accepts_function: false +``` + +Smallest fix: preserve the state observation's dedicated compatibility rule (represented owner, or work effectively performed by that owner), ideally shared by state validation and its picker. Do not broaden general exchange endpoint identity to compensate for the reversed state/performer relation. Add a form-choice regression with a Function/Activity lifeline, plus a wrong-owner negative case. + + +## Final focused closure + +Independently reran the external compiled reproductions against the frozen core and the **27 current focused tests**: seven capability, six topology and fourteen scenario. All tests pass. The new scenario tests cover nested loop reply scope, balanced repeated lifetimes, and optional cleanup that cannot guarantee balance. + +Current observed behavior: + +- Optional reply plus subsequent duplicate: rejected with `duplicate_reply`. +- Optional destruction plus subsequent use: rejected with `lifeline_lifetime`. +- Valid assigned Role lifeline: accepted without errors. +- Reply repeated without its Call: accepted as an editable model with `reply_repetition` warning. +- `[use, delete]` repeated lifetime: accepted as an editable model with `lifeline_repetition` warning. +- Balanced Call/Reply and create/use/delete loops: covered by passing current regressions. +- Valid state observation on allocated Function lifeline: accepted by core; the restored form predicate independently evaluates true. Source inspection confirms the form uses the represented-owner/effective-performer rule, preserving distinct exchange compatibility semantics. + +The original reverse Reply direction and binding selector integration remain coherent by inspected source. There is **no remaining material semantic finding from this focused review**. Conservative loop warnings deliberately leave conditions and at-most-once constraints to the engineer; they no longer silently present questionable repetition as coherent. + +An attempted fresh `cargo test --locked -p oa-desktop forms::tests` encountered a **subsequent concurrent integration state**: `behavior.rs:18` declared `mod scenario` before `behavior/scenario.rs` was written, causing E0583. The lead confirmed that all **50 desktop tests**, including the State picker regression, had passed at the preceding verified checkpoint and that the isolated scenario authoring worker had then started its next task. That 50-test result is lead-reported, not independently observed by this reviewer. Per lead instruction, no rebuild was attempted while the new module was under construction. This temporary build state is not closure evidence for the ongoing authoring milestone; its integration must be verified separately. + +Only this report and the external reproduction package were changed. No native/UI verification was performed in this review. This closes the narrow R1/R2/R4 semantic findings, not final release acceptance. diff --git a/harness/release-semantics-audit.md b/harness/release-semantics-audit.md new file mode 100644 index 00000000..7cb14682 --- /dev/null +++ b/harness/release-semantics-audit.md @@ -0,0 +1,135 @@ +# First release OA/SA semantic audit — 2026-09-05 + +Read-only review of the new first-release objective in `81e84f53-2b51-4151-9e34-59414bda8b2d/goal-objective.md`, the current core, and official Arcadia/Capella references. This is a release planning audit, not a test pass or runtime assessment. No implementation files, builds, or native UI were touched. The old V2 scope exclusions are historical decisions, not authorization to omit OA/SA release functionality. + +## Conclusion + +The current implementation has a useful coherent foundation, but its 27 element kinds and 15 relation kinds are not yet feature-complete OA/SA. The largest holes are contextual process/chain topology, capability/mission relationships, meaningful iterative semantic updates, reusable/typed scenario behavior, and fuller state/data contracts. Keeping those absent while changing the version number would preserve the prototype boundary rather than fulfill the new objective. + +“System Architecture” is interpreted as the System Analysis perspective containing the System of Interest, external actors, functions, exchanges and interface contracts. Internal logical components, physical implementation and deployment belong to the explicitly deferred later perspectives. The new objective does not require copying every Capella implementation class or every Eclipse extension. + +## Already present, and worth retaining + +- `model.rs` separates semantic elements, typed relationships, diagram placements, canonical cross-layer traces and artifact references. `session.rs` and `references.rs` centralize validation, identity remapping, deletion cleanup and undo. These are suitable release boundaries. +- OA entities/actors/roles, activity decomposition and allocation; SA System/actors/functions, missions/capabilities; typed items, records, ports/interfaces; basic nested scenarios and state machines are real model data, not presentation-only constructs. +- OA communication allocation already exists: `validation.rs:969–986` accepts BoundaryExchange → Interaction as well as the SA FunctionalExchange case; `1357–1403` checks allocated endpoint performers and carried items. Earlier SA-only wording in `RelationKind::hint` and `docs/language.rst:230` understates the implementation. This is a contract/discoverability correction, not grounds to add a duplicate OA relation. +- Work decomposition and performer allocation remain independent. Keep containment/allocation projections, stable placement identity and separate hide/delete operations. Do not introduce SA internal components just to make nesting more elaborate. + +## Release-critical additions + +### R1. Processes and chains must represent contextual paths, branching and reusable contributions + +**Evidence.** Official Capella FunctionalAnalysis defines function involvements, chain references, control nodes and sequence links in OA and SA. Arcadia distinguishes the global dataflow from contextual paths. [FunctionalAnalysis schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/FunctionalAnalysis.ecore), [Arcadia Q&A](https://mbse-capella.org/arcadia-qna.html). + +**Current limitation.** `OaBody::Process` and `SaBody::FunctionalChain` store only `steps: Vec` (`model.rs:187–205`). `validation.rs:944–952` expressly rejects every direct sequence occurrence except Exchange. `validate_sequence` at 769 only walks a linear list, checking consecutive exchange endpoints. There is no explicit function involvement to hold a context-specific contribution, no guarded split/join, no chain reference, and no sequence topology independent of global exchanges. A rescue path with parallel position assessment and asset preparation cannot be modeled as one process/chain without inventing a false linear order or moving the engineering information into a scenario. + +**Minimal coherent design.** Preserve durable involvement IDs. Add work involvement, control node and reusable sequence-reference variants; add explicit contextual links with guard text and optional referenced semantic exchange. Links belong to one process/chain and connect its involvement records, never raw functions. Keep a simple linear authoring action that generates this same topology. Validate scope, endpoint correspondence, dangling refs and recursive sequence references; diagnose unreachable or ambiguous starts/ends while allowing incomplete editing. A scenario remains a distinct time-oriented representation. A flat `steps` list can remain a presentation order, but must not masquerade as branch topology. + +**Release proof.** OA parallel/alternative process with a reused subprocess; transition to SA chain; edit branch guard and per-function contribution; graph/text/save/reopen/delete/undo preserve all involvement identities. No implicit changes to global dataflow. + +### R2. Capability relationships and mission participation need first-class semantics + +**Evidence.** Capella's Interaction schema has capability include, extend and generalization; ContextArchitecture has MissionInvolvement. Arcadia also names operational missions, although Capella's OA package does not expose a matching Mission class. [Interaction schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/Interaction.ecore), [ContextArchitecture schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/ContextArchitecture.ecore), [Arcadia metamodel, missions/capabilities section](https://mbse-capella.org/resources/arcadia-reference/Arcadia%20Language%20-%20MetaModel.pdf). + +**Current limitation.** The complete `RelationKind` catalog (`model.rs:531–647`) has none of the three capability relationships or participant→mission involvement. `parent_permits` allows capability containment, which is not a replacement for inclusion, conditional extension or specialization. OA lacks a mission kind entirely. Engineers cannot express reusable common capabilities, conditional degraded behavior at capability level, or who participates in a mission independently of a capability. + +**Minimal coherent design.** Add typed CapabilityInclude, CapabilityExtend (condition), CapabilityGeneralization and MissionInvolvement. Define direction precisely in UI and reST; same-layer/same-capability-family endpoints, no self-reference, inheritance/include cycle checks, and explicit extension-condition diagnostics. Add OperationalMission with mission→capability and participant→mission links, clearly documented as Arcadia method support rather than byte-for-byte Capella OA conformance. Keep visual group containment distinct. Transition these relationships using mapped endpoints; do not turn include into parenthood. + +**Release proof.** Build a mission map with actor participation, common included capability and conditional extension in both layers; demonstrate cycle rejection and readable diagram/source relationship navigation. + +### R3. Transition review must detect and handle semantic drift beyond names + +**Evidence.** Capella documents conservative iterative transformations over ownership and related elements, with a proposed-result comparison against the target. The user additionally requires intuitive transitions in both directions. [Iterative transition concepts](https://raw.githubusercontent.com/eclipse-capella/capella/master/doc/plugins/org.polarsys.capella.transitions.doc/html/11.%20Iterative%20Transitions/11.1.%20Concepts.mediawiki). + +**Current limitation.** `TransitionField` is only Name/Description (`transitions.rs:31–35`). `trace_drift` (`127–140`) reads `Project::properties`, which returns those same two strings. Existing typed bodies, referenced items, parentage, exchange endpoints, guards and sequence contents are not reviewed. Changing a traced data-field multiplicity or scenario body can leave the trace Unchanged. This is a material mismatch with an engineer's expectation of a reviewed model relationship, even though V2 documented the restriction. + +**Minimal coherent design.** Extend baseline data to a normalized semantic snapshot, excluding diagram geometry and generated target IDs. Store mapped identity-aware references. Expose typed content, containment and relationship proposals separately, with Keep target/Take mapped source and explicit endpoint choices. Preserve the existing conflict gate, target-context resolution reset, stale-plan check, atomic undo and no cross-layer deletion cascade. Many-to-many merges and splits require engineer choices; never invent an automatic inverse. At minimum every supported semantic edit must mark drift, and every automatically mappable field must have a reviewed propagation path. Arbitrary structural replacement under an opaque “body” checkbox is insufficient if it can silently destroy independent target work. + +**Release proof.** Change datatype, multiplicity, exchange items, parent, guard and process topology after initial transition. Show accurate drift and field/structure proposals both ways; keep one target divergence; repeat idempotently; undo; verify stale previews cannot apply. + +### R4. Reusable scenarios and explicit message semantics + +**Evidence.** Capella's Interaction schema distinguishes scenario/message kinds and InteractionUse references. [Interaction schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/Interaction.ecore). + +**Current limitation.** `SharedBody::Scenario` has context, pre/postconditions and steps; Exchange occurrence has one relation and two optional lifelines (`model.rs:261–323`). Existing generic lifelines do support functions or participants, and ALT/PAR/LOOP/OPTIONAL branches are already implemented. Missing are scenario reuse, message call/reply/asynchronous distinctions and a declared viewpoint contract. A reusable start-up sequence must be copied, losing its single identity; a request/reply looks like two indistinguishable arrows. + +**Minimal coherent design.** Add scenario-use occurrences with explicit referenced scenario and lifeline mapping, with same-layer and recursive-use checks. Add message kind and correlation to a call when applicable; define safe defaults for migrated generic exchanges. Add clear scenario viewpoint selection (operational activity, participant interaction, functional, boundary/interface), or an equally precise inferred viewpoint with visible diagnostics. Define source/target representation compatibility once in core. Add contextual exchanged-value annotations bound to stable data-field IDs rather than relying exclusively on prose. Do not claim message execution or simulation. + +**Release proof.** Reuse one scenario from two capabilities; change it once and navigate both uses. Model a request, reply and unsolicited event, including a guarded branch; reject a wrongly mapped lifeline. Show equivalent rendered/source semantics and transition the whole reference closure. + +### R5. State machines need more than four vertex kinds and string-only triggers + +**Evidence.** CapellaCommon includes choice, junction, fork/join, shallow/deep history and entry/exit/terminate pseudostates, transition kinds and time/change events. [CapellaCommon schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/CapellaCommon.ecore). + +**Current limitation.** StateKind supports State/Mode/Initial/Final only; StateTransition stores trigger/guard/effect strings (`model.rs:240–246`, `704–711`). Nested regions and linked state actions already exist and should be retained. Engineers cannot explicitly model conditional dispatch, parallel-state synchronization or resume-after-interruption history; a trigger naming an event is not a maintained semantic reference. + +**Minimal coherent design.** Extend a single typed state-vertex enum with the standard pseudostates rather than separate unrelated frameworks. Add transition kind (external/local/internal), typed event references alongside optional descriptive expressions, and time/change event bodies. Validate each pseudostate's cardinality, region context, history fallback and initial/final direction rules. Rendering needs distinct readable symbols and nested-region visibility; a new enum shown as an ordinary state box does not meet the workflow. + +**Release proof.** Degraded operation machine with choice, parallel regions/fork/join, recovery history and a timeout. Rename/reidentify the event without breaking triggers; test inappropriate endpoints; round-trip/transition/undo the nested machine. + +### R6. General-system data/interface contracts need explicit reusable semantics + +**Evidence.** Capella Information provides exchange mechanisms, structured typed content, data inheritance/associations and value constraints/defaults. [Information schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/Information.ecore). + +**Current limitation.** Current records/enum/primitive data and field multiplicity/unit are useful. But `ExchangeItemKind` mixes physical nature with Event and has no independent interaction mechanism; DataTypeDefinition lacks constrained numeric/string types, defaults, inheritance or association. A quantity range or input/output operation parameter can only be prose. Functional and participant ports share unrestricted InOut (`model.rs:234–239`); `validation.rs:1082–1087` admits InOut on functional endpoints although Arcadia functional ports are exclusively input or output. [Arcadia metamodel, function-port section](https://mbse-capella.org/resources/arcadia-reference/Arcadia%20Language%20-%20MetaModel.pdf). + +**Minimal coherent design.** Keep nature (information/material/energy) separate from mechanism (flow/event/operation/shared data); preserve legacy Event by explicit migration. Add typed parameter direction for operation items, type-level bounds/default/unit, record specialization and association with multiplicity. Define own-versus-reference semantics and recursive type rules. Warn on existing bidirectional function ports, provide a split migration/editor action, and prevent new ambiguous function-port contracts. Keep participant bidirectionality. A typed property/value facility for units and measurable non-functional constraints is appropriate; a general plugin metamodel is not needed. + +**Release proof.** Physical flow, energy transfer and request/reply interface with reusable bounded quantity/enum/record types. Invalid defaults/ranges and direction mismatches are diagnosed; semantic types survive API, source, GUI and cross-layer review. + +## Release usability and contract work tied to semantics + +1. **Exchange categories and selective diagrams.** The official functional schema supports exchange categories; Arcadia recommends synthesis to reduce clutter. Current `Diagram` exposes four projections and nodes but no category/filter definitions. Add named exchange sets and persistent visible-layer/category/relation filters with equivalent text fields; optionally show aggregate edges with navigable members. Do not replace actual edges with fake semantic exchanges. This is strongly recommended for the user's explicit uncluttered-system-engineering requirement. [FunctionalAnalysis schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/FunctionalAnalysis.ecore). +2. **Completeness diagnostics.** Add actionable coverage for non-leaf work carrying exchanges, leaf work absent from capabilities/processes/scenarios, mission without capabilities and interface responsibilities. These are warnings during authoring, not a ban on incomplete projects. Current validation has allocation-leaf checks and sequence gaps, but no exchange-on-parent or path coverage diagnostic. Arcadia's leaf guidance is explicit. [Arcadia Q&A](https://mbse-capella.org/arcadia-qna.html). +3. **Normative parity cannot be inherited.** Correct the OA ExchangeAllocation table/hint now. For every added constructor/field, update language keywords, parsing, canonical writing, reference enumeration/remap/removal, schema migration, inspector creation/editing, diagrams, REST queries and OpenAPI examples together. `schema_version` and source version are currently 2; a deliberately versioned migration is safer than silently changing closed enum/record contracts under “arcadia 2”. Preserve all V2 UUIDs/aliases and explain generated involvement IDs. +4. **Built-in guide is explicit release scope.** The root/UI workstream must add navigable concepts, common workflows and application commands, including the distinction between dataflow, process/chain, scenario, containment, allocation and trace. A link to repository Markdown alone does not satisfy the requested in-app guide. + +## Reasonably future, without redefining OA/SA completion + +- LA/PA/EPBS components, deployment and physical paths: explicitly later layers, not a missing SA internal hierarchy. +- Executable simulation, scheduling or code generation: modeling guards and time constraints need unambiguous descriptive semantics, not an execution engine. +- Byte-compatible native Capella import/export and every Eclipse metamodel implementation detail: valuable interoperability work, but the user asks a modern general tool, not an exact Capella file adapter. +- SysML v2 conformance, full expression-language theorem proving, complete Capella viewpoint/plugin ABI, replicated collaborative storage: neither necessary to encode the OA/SA semantics above nor explicitly required for first release. +- Advanced reusable model libraries and semantic Git merge can follow after stable identities, source parity and the direct editing workflows are complete. They must not be used as substitutes for basic scenario/process reuse in R1/R4. + +## Implementation order and acceptance boundary + +1. Freeze additions in the normative reST and core interfaces. Keep the existing one-model/one-transaction design. Agree on the state/event, occurrence/topology and value representations before parallel desktop/API implementation. +2. Implement R2 as a small first observable milestone, alongside the existing OA allocation documentation correction and built-in help work. +3. Implement R1/R4 together because stable involvements, contextual references and reusable behavior share reference-maintenance requirements. Implement R5 and R6 as separately owned core modules/commits where practical; do not let several workers edit the same exhaustive match catalogs simultaneously. +4. Extend semantic transition snapshots/mapped proposals (R3) against the frozen typed bodies; prove drift coverage for all persistent semantic fields with a meaningful table-driven fixture, not only a rename test. +5. Integrate projection/filter/diagnostic polish, then exercise a complete general-system OA→SA workflow including degraded behavior, item/interface contracts, artifact links, source editing and REST transactions. All new persistent records must be graphically editable, safely deletable/reidentifiable, undoable, canonical and round-trip stable. + +These findings identify necessary release work from inspected current source and primary semantics. They do not prove a specific implementation is correct and do not constitute permission to call the release complete before native runtime and independent verification. + +## Proposed concrete core interface for integration discussion + +This is a proposal, not an agreed API or implemented contract. Prefer extending existing typed records over adding independent object registries. Freeze the exact names with the language/desktop owners before coding. + +| Existing location | Proposed addition/change | Key invariant | +|---|---|---| +| `Kind`, `OaBody` | `OperationalMission`; `OaBody::Mission` | OA-only; counterpart default is SA Mission, preserving an explicit Manual trace until a dedicated Mission trace is introduced. | +| `RelationKind`/`RelationBody` | `CapabilityInclude { source, target }`; `CapabilityExtend { source, target, condition: String }`; `CapabilityGeneralization { source, target }` | Include points including→included, Extend extending→extended, Generalization specific→general; same family/layer. | +| Mission links | `MissionInvolvement { source, target }`; widen existing `MissionCapability` to the corresponding OA/SA family | Involvement participant→mission; MissionCapability mission→capability. | +| `OccurrenceBody::Action` | Reuse its `action` reference as a work involvement in a process/chain; require `lifeline == None` outside scenarios | Avoid a redundant Work enum. The occurrence's existing description is its context-specific contribution. | +| `OccurrenceBody` | `Control { kind: ControlKind }` with Choice, Merge, Fork, Join, Loop; `SequenceReference { sequence: Option }` | Process/chain only; reuse target in same layer and sequence family; no recursive references. | +| `RelationBody` | `SequenceLink { source, target, guard: String }` | Both endpoints are occurrence records directly owned by the same process/chain. Contextual precedence never invents a global functional exchange. Exchange occurrences remain identifiable nodes between work/control nodes. | +| Existing `steps` vectors | Retain as stable presentation order/membership for all owned occurrences; explicit SequenceLinks encode topology | Migrating a linear V2 process preserves Exchange occurrence UUIDs, generates work occurrences and links deterministically, and does not renumber old references. | +| `SharedBody::Scenario` | `viewpoint: ScenarioViewpoint` (OperationalActivity, ParticipantInteraction, Functional, BoundaryInterface) | V2 generic scenarios migrate by inspected lifeline/exchange types; ambiguous legacy content has an explicit MixedLegacy value and diagnostic until resolved. | +| `OccurrenceBody::Exchange` | `message_kind: MessageKind` (Asynchronous, Call, Reply); `reply_to: Option` | Reply references an earlier compatible call in the same scenario; legacy asynchronous is a documented default. | +| `OccurrenceBody` | `ScenarioUse { scenario: Option, bindings: Vec }`; `LifelineBinding { referenced: Id, local: Id }` | Unique referenced/local role mappings, compatible represented objects, same layer, acyclic scenario use. | +| `StateKind` | Choice, Junction, Fork, Join, ShallowHistory, DeepHistory, EntryPoint, ExitPoint, Terminate | Reuse the State typed body with an expanded vertex kind; entry/do/exit actions apply only to actual State/Mode vertices. | +| State transition body | `kind: StateTransitionKind` (External, Local, Internal); `events: Vec` alongside current descriptive trigger/guard/effect | Event references are maintained by ordinary delete/reidentify operations; internal transition has defined self/target policy. | +| Shared event kind/body | `Event { kind: EventKind }`; EventKind Signal { item: Option }, Time { expression: String, relative: bool }, Change { expression: String } | Same-layer typed references, not interpretation of arbitrary trigger strings. | +| Exchange item body | Preserve `kind`; add independent `mechanism: ExchangeMechanism` (Flow, Event, Operation, SharedData) | Physical nature and interaction mechanism remain independent; preserve all legacy category values when migrating. | +| Data field body | `direction: ParameterDirection` (Unspecified, In, Out, InOut, Return); `default: Option` | Operation parameter directions valid only in the correct item context; defaults match referenced type and range. | +| Data types | Add constrained numeric/string constructors plus record supertype, or introduce equivalent typed restriction fields | Existing unit/multiplicity fields retain meaning; ranges constrain values rather than collection cardinality. Reject cyclic specialization. | +| Relations | `DataAssociation { source, target, source_minimum, source_maximum, target_minimum, target_maximum }` | Record/type endpoints in one layer; multiplicity bounds valid. Reuse typed relation infrastructure. | +| Shared category body | `ExchangeCategory { exchanges: Vec }` | Same-layer exchange references; categories may overlap and do not change exchange endpoints. | +| Diagram | Typed persistent relation/category visibility configuration | Hiding a category changes presentation only, and source parity includes every persisted filter. | + +For transition review, the smallest robust extension is a typed `SemanticSnapshot` containing the element parent/body or relation body in addition to name/description, stored independently for OA and SA. `TransitionField` can gain Parent and Content initially, provided Content previews expand all changed fields/references and require an explicit review; it must not be an opaque replacement button. Better per-property decisions can use a closed set of field paths derived from each typed body, validated against its schema rather than allowing arbitrary JSON writes. The mapped candidate resolves every source reference to an explicitly selected/unique traced target before comparison. Parent, content and incident-relationship proposals remain separate; a source's unselected unrelated relationships must not be silently copied. + +Use `SemanticSnapshot` equality for complete drift coverage even where automatic mapping is not offered. Preserve V2 baselines as name/description-only historical reviews with an explicit semantic-unreviewed state, rather than pretending they certify content they never recorded. Preserve many-to-many traces; incompatible merging remains an explicit unresolved proposal. + +Cross-cutting integration surface: `ElementBody::{new,kind,shared,steps}`, `RelationBody::{kind,endpoints,items}`, `Kind::{ALL,supports,label}`, `RelationKind::{ALL,permits,hint}`, `references::{element_references,relation_references,remap,cleanup}`, validation/parent/connect queries, Session transaction admission, counterpart mappings, language constructor catalogs, property-form variant selectors, behavior projection and migration fixtures. Do not consider enum additions done until these surfaces are wired and exercised. diff --git a/harness/release-state-contract.md b/harness/release-state-contract.md new file mode 100644 index 00000000..0733cb05 --- /dev/null +++ b/harness/release-state-contract.md @@ -0,0 +1,230 @@ +# R5 state/event implementation contract proposal — 2026-09-05 + +Status: adopted by the lead after the focused R1/R2/R4 correctness review closed. +R5 core, source/API and graphical authoring implementation is in progress; +runtime acceptance is not yet claimed. The lead adopted the four Studio choices +at the end of this contract; current evidence and ownership are in STATE.md. + +## Evidence and intended outcome + +The native benchmark actually created an H* Deep History node inside +Maintenance's region. It also added `[AFTER]30 s` beside an existing function +trigger (`Load Digital Media`) in a transition inspector. It did not establish +history fallback connectivity or validate the resulting transition. Preserve +that distinction when writing acceptance evidence. See +`harness/release-capella-workflow-benchmark.md:71`. + +The implementation currently has State/Mode/Initial/Final, nested regions, +StateAction links, availability, and trigger/guard/effect strings. Rendering +shows Initial/Final glyphs and ordinary boxes; creation only offers State and +Initial. Keep its one typed state payload and existing command/reference +boundary. R5 should let an engineer build degraded operation, parallel work, +recovery history and a timeout without creating unrelated custom objects. + +Capella exposes typed time/change events, transition kinds and additional state +vertices. Its relative/absolute time choices distinguish AFTER from AT, and its +transition definition retains descriptive triggering text separately from +semantic trigger references. Those are the implementation features to retain. +[CapellaCommon schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/CapellaCommon.ecore). + +## Exact model changes + +1. Extend **both** `StateKind` and corresponding `Kind` entries with `Choice`, + `Junction`, `Fork`, `Join`, `ShallowHistory`, `DeepHistory`, `EntryPoint`, + `ExitPoint`, `Terminate`. Continue using + `SharedBody::State { kind, entry, exit, do_activity }`. No separate graph or + state-machine framework. Keep `is_state` as the vertex-family query, but add + `is_stable_state` (State/Mode only) and use it for behaviors, availability, + observation and composite ownership so expanding the family cannot grant + pseudostates inappropriate features. This is nine additional vertices. +2. Add one `Kind::Event`, shared in OA/SA: + + ```rust + SharedBody::Event { body: EventBody } + enum EventBody { + Signal { items: Vec }, + Time { kind: TimeEventKind, expression: String }, + Change { expression: String }, + } + enum TimeEventKind { After, At } + enum TransitionKind { External, Local, Internal } + ``` + + An Event is an ordinary root semantic record with durable name/alias/UUID. + Signal identifies a named occurrence with optional same-layer ExchangeItem + payloads. Time and Change are expression-based definitions. AFTER is relative + to activation of the transition's source state; AT names an absolute instant. + Change means the named Boolean condition becomes true. No expression evaluator, + timer runtime, event queue or signal transport is introduced. Empty time/change + expressions warn; invalid XML text, duplicates or invalid item references reject. +3. Extend existing `RelationBody::StateTransition` with + `transition_kind: TransitionKind`, `triggers: Vec`, `effects: Vec`. + Retain `source`, `target`, `trigger`, `guard`, `effect` exactly. `triggers` + accepts same-layer Event or Activity/Function IDs; the referenced kind resolves + their meaning without another wrapper object. `effects` accepts same-layer + work IDs. Duplicate references reject. The text fields remain authored prose, + not inferred UUIDs or executable code. Factory defaults are External and empty + lists. Entry/do/exit typed work stays in existing StateAction relations. + +Multiple referenced triggers are alternatives (OR), not an implicit conjunction. +Work triggers identify that work's occurrence/completion as an engineering +trigger; no call to that work is executed. Do not require a trigger function to +be allocated to the machine's owner: an external participant's work can cause +the observed transition. Existing owner-relative StateAction checks remain. + +## Activation, hierarchy and transition kinds + +The following is the proposed Studio contract, not an execution engine: + +- Nonempty `triggers`: semantic triggering references; `trigger` is additional + descriptive text. Empty references plus nonblank `trigger`: legacy/prose-only + triggering, with `unresolved_trigger` warning. Do **not** call this completion + or infer an Event by matching its name. Empty references and blank trigger: + completion transition from a stable state, or transient routing from a pseudo. +- Completion of a simple state means its entry/do behavior is complete; for a + composite, all its active regions must complete. Final completes its region; + it does not terminate every region or delete the modeled participant. + Terminate ends the whole containing state-machine activation. These meanings + are documented and rendered; the application never asserts they ran. +- External may leave/re-enter the source configuration. A stable self-transition + is permitted and includes exit and entry behavior. Internal requires equal + State/Mode endpoints; it preserves the active configuration, including nested + regions, and does not imply exit/entry actions. +- Local requires a composite State/Mode source and a proper descendant target; + the source remains active while the affected nested configuration changes. + Local from an EntryPoint is also permitted when it targets a vertex within + that point's boundary composite. Local to self or outside that composite rejects. + Pseudostates otherwise use External; outgoing EntryPoint routing uses Local. + +The internal/self and local/composite distinction follows the primary UML2 +transition model. Its entry-point and fork/join segment restrictions also prevent +the common mistake of treating every arrow as an interchangeable external +transition. [Transition constraints](https://download.eclipse.org/modeling/mdt/uml2/javadoc/5.5.0/org/eclipse/uml2/uml/Transition.html), +[transition kinds](https://download.eclipse.org/modeling/mdt/uml2/javadoc/5.5.0/org/eclipse/uml2/uml/TransitionKind.html). + +Endpoints must be existing vertices in the same layer and StateMachine. A normal +transition may cross nested regions of that machine; do not impose a blanket +same-immediate-region rule. Stable self-edges remain possible; pseudo self-edges +reject. Regions remain directly owned by a StateMachine or State/Mode. Two or +more sibling regions represent orthogonal/concurrent regions, without adding a +parallel boolean that can disagree with the hierarchy. + +## Pseudostate ownership and validation policy + +All new vertices remain region-owned. For EntryPoint/ExitPoint, the boundary is +the containing Region's owner (State/Mode or StateMachine); rendering attaches +their symbols to that owner's border while retaining the explicit region +destination. This is a deliberate Studio encoding that reuses current ownership, +not a claim of byte-identical UML/Capella containment. + +Reject invalid references, wrong owners, upper-cardinality contradictions and +forbidden directions/transition kinds. Warn about absent required connections, +unfinished guards/defaults and unreachable vertices, so insertion can be followed +by wiring in separate undoable edits. No pseudostate may own a nested region or +typed StateAction. Retained legacy entry/exit/do prose on Initial/Final warns and +is preserved, not silently discarded or advertised as executable behavior. + +| Vertex | Proposed exact invariant | Incomplete-model warning | +| --- | --- | --- | +| Initial | At most one per Region; no incoming; at most one outgoing; outgoing has no triggers, trigger prose or guard, but may have effect | Missing initial/default-entry path or missing outgoing | +| Final | No outgoing; no nested region or behaviors | No incoming; unreachable | +| Terminate | No outgoing; no nested region or behaviors | No incoming | +| Choice | At most one incoming; outgoing edges are transient guarded alternatives | Missing incoming, fewer than two outgoing, empty branch guard, absent else | +| Junction | One or more incoming/outgoing transient segments; no implicit concurrency | Missing side; ambiguous unguarded alternatives | +| Fork | At most one incoming; outgoing targets are stable states in distinct orthogonal regions of one composite/machine | Missing input, fewer than two outputs | +| Join | At most one outgoing; incoming sources are stable states in distinct orthogonal regions of one composite/machine | Fewer than two inputs, missing output | +| ShallowHistory | At most one per Region; at most one outgoing fallback to a direct stable child in that Region; no fallback trigger/guard | Missing fallback/default-entry path | +| DeepHistory | At most one per Region; at most one outgoing fallback to a stable vertex inside that Region's subtree; no fallback trigger/guard | Missing fallback/default-entry path | +| EntryPoint | Incoming from outside its boundary, outgoing into it; at most one outgoing per destination region; outgoing Local | No outgoing or incomplete affected-region entry | +| ExitPoint | Incoming from inside its boundary; at most one outgoing to outside it; outgoing External | Missing input/output; top-machine exit without a caller is an exit endpoint, not automatic termination | + +Fork outputs and Join inputs must not have guards or triggers: conditional +dispatch belongs at Choice/Junction, not in a partial synchronization. With more +than one arm, using the same immediate orthogonal region twice rejects. Compute +the first region below the nearest common composite, not simply each state's +immediate region, so deeper nesting does not create false concurrency. + +Other pseudo outgoing edges have no new event trigger. Initial restrictions +retain the current strict rule. For the newly introduced pseudos, forbidden +triggers should be structural errors when committing a complete edge; the native +benchmark's accepted Choice trigger was **not validated** and does not prove a +different semantic rule. The UI should move the timed/function trigger onto the +incoming stable-state transition, retaining Choice guards on the alternatives. +This is an explicit resolution of that observed ambiguity, not hidden loss. + +Choice has at most one literal `else` outgoing guard; duplicate else rejects. +Do not parse arbitrary guards to claim exhaustiveness/disjointness. Junction +chooses a compound path from guard conditions before traversing it; Choice is a +dynamic decision after incoming effects. Keep their symbols and labels distinct. +Shallow history remembers only the immediate state; deeper regions use default +entry. Deep history remembers the nested configuration. Fallback applies where +no resumable history exists; do not synthesize history IDs or running state. +The same-region history policy is a Studio choice supporting independent +orthogonal regions; top-level history is allowed, with no submachine-execution +promise. Primary sources have an acknowledged disagreement about top-level +history placement. [Pseudostate constraints](https://download.eclipse.org/modeling/mdt/uml2/javadoc/5.5.0/org/eclipse/uml2/uml/Pseudostate.html), +[OMG history issue](https://issues.omg.org/issues/UMLR-816). + +## Reference lifecycle, source and REST + +Extend the existing visitors for Event Signal items, transition triggers/effects. +Reidentification remaps every reference. Deleting a trigger/effect record removes +its reference but retains the transition and prose; return an actionable cleanup +notice. If removing the last typed trigger would make a transition appear to be +completion, retain a descriptive placeholder in `trigger` naming the removed +trigger (atomic command) so the edit does not silently change activation meaning. +Undo restores the exact original record and references. No transition deletion +cascades to shared Events or counterpart-layer state machines. + +Use `oa_event` / `sa_event`, existing `oa_` / `sa_` declarations, +typed Event body constructors, `transition_kind External`, `triggers [@event, +@work]`, and `effects [@work]`. Reuse the existing typed parser/formatter/form +adapter; update reference/enum field catalogs and every closed selector/schema. +State traces allow matching new vertex kinds and Event definitions in opposite +layers. Required transition closure includes referenced Events, item payloads, +work and effects. R3 reviewed drift must include all new fields and mapped refs. + +No REST route is added: current model, element, relation, transaction, validation +and transition routes expose these records. OpenAPI must enumerate every vertex, +Event body and transition field. A POST Event + EditRelation batch is atomic and +conditional; editing the Event name or UUID must not break trigger references. + +Old V2 migration preserves all IDs and exact trigger/guard/effect strings. Missing +transition kind becomes External; missing lists become empty. Do not parse +`after 30s` prose into a Time Event, or invent function references by display-name +match. Old prose-only transitions warn until explicitly linked. No old pseudo +vertex is rewritten. Preserve golden V2 source/JSON, add these defaults to its +comparison allowlist only after schema migration is explicit and tested. + +## UI and acceptance sequence + +Behavior adds a region-context vertex palette with distinct Initial ●, Final ◉, +Choice diamond, Junction dot, Fork/Join bars, H/H* circles, entry/exit border +symbols and Terminate cross. Every symbol gets a readable text name and usable +hit target; nested regions must stay visible and selectable. Add State/Mode +internal-transition rows inside the state card rather than a misleading self-loop. + +One transition editor shows kind, typed trigger chips, trigger notes, guard, +typed effects and effect notes. `Add timed event` opens one inline form with +After/At and expression, creates the Event and attaches it in one undo step. +Existing function/event selectors are searchable and filtered by layer. A full +inspector/list displays all trigger names; the edge shows a concise summary and +never clips the only accessible copy. `Completion` is visibly distinguished +from unresolved descriptive triggering. No nested modal stack is needed. + +Implement/verify in observable milestones: (1) events + references + concise +editor and original `[AFTER]30 s`/function example; (2) nested vertex palette, +choice/history + completion; (3) parallel Fork/Join and transition-kind rendering; +(4) source/API/migration/transition parity and independent review. Meaningful +tests must cover every vertex insertion incomplete state, invalid endpoint and +upper-cardinality case; shallow/deep scope; nested orthogonal arms; internal vs +external self edges; local source retention; trigger/effect UUID edits/deletion +undo; old prose preservation; source/JSON/API equality. Personally exercise the +degraded machine, event creation/rename, H* fallback, branch guard, resize and +save/reopen in the native application. No execution/simulation claim is allowed. + +Before implementation, lead should adopt or amend the explicit Studio choices: +region-owned entry/exit symbols, history per Region, pseudo-trigger strictness +despite the unvalidated benchmark edge, and preserving removed-trigger intent +as descriptive text. These are concrete design decisions, not additional user +permission questions. diff --git a/harness/release-states-core.md b/harness/release-states-core.md new file mode 100644 index 00000000..488fb5fe --- /dev/null +++ b/harness/release-states-core.md @@ -0,0 +1,82 @@ +# R5 core state/event milestone — 2026-09-05 + +Status: core implementation frozen for independent review and desktop integration. +This is the bounded R5 milestone, not a declaration of release completion. R3, +R6, native interaction review and final migration remain separately owned work. + +## Concrete model and editing behavior + +- `Kind::ALL` has 38 kinds. `StateKind` has 13 vertices: State, Mode, + Initial, Final, Choice, Junction, Fork, Join, ShallowHistory, DeepHistory, + EntryPoint, ExitPoint and Terminate. `is_state()` means vertex family; + `is_stable_state()` restricts behaviors, availability and composite ownership. +- Shared `EventBody` is Signal `{ items }`, Time `{ kind: After|At, expression }`, + or Change `{ expression }`. Time/change expressions are engineering annotations; + they are checked for valid text and missing content, never evaluated. +- StateTransition retains exact trigger/guard/effect prose and adds + `transition_kind: External|Local|Internal`, `triggers: Vec` and + `effects: Vec`. Triggers are same-layer Event/work alternatives; effects + are same-layer work. External participants' work may trigger a machine. +- Existing constructor/serde defaults remain External and empty typed lists. + `Project::relate` (therefore `Command::Connect`) and `can_connect` infer Local + for EntryPoint routing. Explicitly authored relation constructors remain exact. +- Empty trigger references with nonblank trigger prose warn `unresolved_trigger`; + they are not reinterpreted as completion. Removing the last typed trigger keeps + a descriptive replacement prompt naming the removed trigger, unless authored + trigger prose already exists. All reference cleanup is part of the undoable edit. + +## Validation and ownership + +`state_validation.rs` is one private structural-rule module called by the existing +validator; it introduces no new graph, framework or execution state. Existing +reference visitors handle Event payloads and transition trigger/effect lists. + +The adopted contract in `release-state-contract.md` is implemented: same-machine +hierarchy, stable Internal/External self transitions, proper-descendant Local +routing, region-owned boundary points, strict pseudo triggers/directions, +upper cardinalities, literal Choice else uniqueness, per-region shallow/deep +history, and Fork/Join orthogonal arms measured at their nearest common composite. +Incomplete connections, guards, default entry and structural reachability warn. +Reachability includes nested default entry and conservatively ignores guard +evaluation and stored history. It does not claim machine execution. + +State-machine Subtree transition closure now includes its state transitions and +typed StateActions; Required edge closure includes referenced Events, items and +work. Matching new vertices and Events get typed State traces in both directions. +Unrelated Availability relations are not pulled into a machine transition. + +Demo still has no validation diagnostics. It now has 41 elements: the two added +Events resolve the existing timeout/report transition prose without rewriting it. + +## Verification actually performed + +Target directory: `D:/CodexBuilds/Capella2-01a06e2e`. + +- `cargo check -p oa-core --all-targets --locked` passed. +- `cargo test -p oa-core --locked` passed **102 integration tests**: + language 16, language limits 4, process diagnostics 1, capabilities 7, + topology 6, scenarios 14, states 11, review regressions 15, workflows 28. +- After strengthening all-vertex OA↔SA coverage and atomic rejection assertions, + `cargo test -p oa-core --test release_states --locked` passed all 11 again. +- `cargo clippy -p oa-core --all-targets --locked -- -D warnings` passed after + the final focused test edits. + +The 11 new tests cover every vertex as an editable draft, stable-only behaviors, +typed Event/transition refs and invalid refs, last-trigger cleanup/reidentify/ +delete/undo, local/internal hierarchy, terminal/initial rules, Choice/Junction, +history, deep orthogonal Fork/Join, boundary routing and candidate filtering, +nested reachability, and complete typed transition closure in both directions. +R4 scenario regression tests remain passing. No native UI/Computer Use was +performed by this core worker; the lead owns native verification. + +Initial broad regression found only the old demo element-count expectation (39); +it was corrected to 41 after adding the two typed Events. The no-diagnostics +assertion still passes. No failed check is being left unexplained. + +## Handoff + +Core signatures are frozen. Source/API integration owns `language.rs`, persistence, +OpenAPI, normative reST and schema migration. Desktop owns the state editor, +Event controls, distinct symbols and runtime interactions. Independent review +should check structural boundary/orthogonal edge cases against the adopted +contract, not infer simulation support from enum names. diff --git a/harness/release-text-service-audit.md b/harness/release-text-service-audit.md new file mode 100644 index 00000000..db98dbc2 --- /dev/null +++ b/harness/release-text-service-audit.md @@ -0,0 +1,133 @@ +# First-release text and service audit + +Date: 2026-09-05. Scope: the new first-release objective in the goal attachment `81e84f53-2b51-4151-9e34-59414bda8b2d/goal-objective.md`, current text editor/language, graphical field adapter, REST/storage and deployment. Read-only source audit; no native UI operation. No source files changed. This report assesses release suitability and does not inherit the V2 prototype exclusions as release acceptance decisions. + +## Material findings + +### TS1 — High: rejected oversized source monopolizes every hosted model + +**Observed on the actual Windows release server:** a 638,689-byte source request containing 12,000 nested OA activities returned `422 model_limit` after **26.163 seconds**. A concurrent `GET /api/v1/projects`, issued 0.5 seconds later, took **25.655 seconds** to return 200. The documented 5,000-object cap is applied only after this expensive work. The request is far below the 8 MiB body cap. + +Evidence: `crates/oa-server/src/routes.rs:271` imports source inside `blocking`; `routes.rs:188` calls the unrestricted `language::parse`; `crates/oa-core/src/language.rs:1026` performs full structural validation; only afterward does `StoreInner::create` call `validate_model` / `check_model_size` (`store.rs:166`, `store.rs:280`). `crates/oa-server/src/lib.rs:203` holds the one store mutex throughout parsing and validation. Source replacement/candidate validation have the same ordering. JSON import calls `persistence::decode`, which also validates before the server size cap. Parent walks and several validation scans are quadratic. A longer request can exceed the 30-second response deadline while retaining its blocking worker and mutex; this latter consequence is source-derived, not separately timed. + +Reproduction: launch the release binary with an isolated data directory and `--bind 127.0.0.1:18787`; POST `text/plain` to `/api/v1/projects/import` using: + +```python +n = 12000 +source = 'arcadia 2; project oversize "Oversize" { oa_activity a0 "Root" {}\n' +source += ''.join(f'oa_activity a{i} "Activity {i}" {{ parent @a{i-1}; }}\n' for i in range(1, n)) +source += '}' +``` + +Concurrently query the catalog. The isolated audit process (PID 27728) was stopped after its requests completed. Its logs and probe are under `D:/CodexBuilds/Capella2-r1-text-service-audit`; stderr contains only the expected startup line. + +**Fix before release:** enforce declaration/record/reference budgets before expensive full validation, including source and legacy import; move request parsing outside the store lock, keeping snapshot/revision capture and final conditional commit atomic. Validate size at the shared parsing boundary or introduce an explicit bounded parse option rather than duplicating lexical heuristics. Add a real HTTP regression proving a rejected large candidate does not stall an unrelated read. A mutex refactor alone does not bound CPU cost; an early cap alone still leaves accepted large model validation holding the catalog lock. + +### TS2 — Medium: source lint executes expensive work on the GUI thread + +`crates/oa-desktop/src/app.rs:936` schedules a 350 ms sleep, but the actual `language::lint` runs synchronously in `Message::Lint` at line 945. Source undo/redo also lints synchronously. The version check prevents stale results, but does not make analysis asynchronous. Every source edit additionally clones full before/after buffers and retains up to 100 full-document versions (`app.rs:923`). Highlighting rebuilds tokens for the entire source on update (`highlight.rs:19`, `highlight.rs:61`). + +A release-linked core probe measured: + +| Model | Canonical/source bytes | Full parse | Canonical lint | +| --- | ---: | ---: | ---: | +| 201 graph objects, flat allocation | 27,990 | 2 ms | 2 ms | +| 2,001 graph objects, flat allocation | 281,790 | 60 ms | 68 ms | +| 4,999 graph objects, flat allocation | 710,504 | 358 ms | 303 ms | +| 1,000 nested activities | 49,687 | 88 ms | not timed separately | +| 5,000 nested activities | 261,686 | 2,605 ms | not timed separately | + +These are exact core timings, not claims that a GUI freeze was personally observed. The synchronous update call means equivalent analysis blocks native event handling. For a release aimed at working systems engineers, even an otherwise valid server-sized model introduces hundreds of milliseconds per pause in typing. + +**Fix:** execute snapshot/versioned lint on a blocking worker, discard obsolete results, and keep source edit response independent from analysis completion. Bound/coalesce pending analysis; do not spawn an unbounded number of obsolete full parses. Cache model validation by committed revision where reused. Add a realistic model-size interaction check; do not silently reduce supported semantics to obtain a passing performance test. + +### TS3 — Medium: textual lint omits engineering warnings and offers no diagnostic navigation + +`crates/oa-core/src/language.rs:139` is `parse(source).err().unwrap_or_default()`. A valid draft `arcadia 2; project demo "Needs" { sa_function assess "Assess" {} }` produces **zero lint diagnostics**, while `validation::validate` returns both `missing_system` and `unallocated_leaf`. The parser intentionally returns only the first structural error; warnings are discarded. As a result, the source workspace does not provide the same immediate engineering feedback as Model checks, and warnings only become visible after applying the draft and changing pages. + +`crates/oa-desktop/src/views.rs:853` renders the first eight diagnostics as plain text. There are no diagnostic click actions, source search, go-to-line input or line-number gutter in the text workspace. `app.rs:977` implements graphical selection → source declaration, but no general error navigation. An error at line 4,000 is located numerically without a practical way to reach it, undermining the explicit human/AI-editable modeling goal. + +**Fix:** expose located syntax/semantic diagnostics, including warnings with severity and codes, and navigate/select the relevant source range on click. Add Ctrl+F / next/previous match or go-to-line; display cursor line/column. A line gutter is useful if it stays aligned during wrapping/scrolling; it is not a substitute for direct diagnostic navigation. Test draft warnings without applying, non-ASCII columns, stale analysis results and navigation after edits. + +## Product decisions requiring explicit release disposition + +- **Source comments are erased on Apply and any save after open.** Reproduced `language::format(language::parse(source))` removing an explanatory comment. This is honest in `views.rs:901` and README and does not currently violate the normative V2 contract. For first-release Git/AI use, preserving comment trivia or providing a deliberate canonicalize command would avoid surprising loss of engineering notes. At minimum, the guide should teach durable model `description` fields and make the actual loss behavior clear before a first destructive normalization. Do not describe whitespace/comment preservation as implemented. +- **No crash recovery/autosave exists.** This is a declared V2 boundary; it is now worth a release decision for long engineering sessions. Ordinary save uses atomic replacement and external-byte conflict detection, but it does not recover unsaved source/property/model drafts after process failure. This audit did not simulate a crash. +- **Hosted models are accessed by explicit import/export, not a desktop connection.** The objective explicitly requires a programmatic backend, so absence of direct connection is not automatically a violation. If the release promises editing shared stored models from the desktop, a connection/revision/conflict workflow must be implemented and verified; current shell transfer alone is insufficient for that stronger promise. +- **Deployment evidence is incomplete for the provided Docker builder.** Dockerfile pins Rust 1.90; current verification records Rust 1.98.1 and no Docker execution. Native standalone Rust deployment is real; remote deployment is not required merely because a deployable service was requested. Before calling the provided container workflow verified, build it or independently compile the exact declared toolchain/dependencies and clearly retain the unrun container limitation. + +## Strong existing evidence and boundaries + +- Language tests exercise strict fields/enums/references/duplicates, all relation kinds, stable aliases/identities, ordered repeated occurrences, explicit V1 migration, UTF-8 diagnostics, exact permitted strings/float values, and full canonical round trips. `complete_model` is broad but `every_demo_record_has_a_lossless_graphical_draft` verifies only records in the demo; it is not an exhaustive proof that every closed enum/optional combination is easily authorable. +- Graphical forms maintain temporary typed values, reject malformed edits atomically, expose optional/reference/ordered values and preserve current references even if temporarily invalid. Tests cover escaped strings, reference choices, identity remapping, draft preservation and modal command gates. This source audit found no new concrete field-loss counterexample. +- `crates/oa-server/tests/http.rs` uses actual loopback listeners and filesystem stores, covering ETags, competing writes, stale deletes, no-ops, dry runs, exact source/JSON round-trip, revision-qualified pagination, bounded graph closure, authentication/browser/Host/media/body limits, restart/corrupt store/lock checks, forced pre-install storage failure, transitions/artifacts and store capacity. The audit inspected coverage and ran the additional release HTTP responsiveness probe; it did not rerun the entire suite. +- Storage holds an OS directory lock, persists before successful return, keeps in-memory state consistent after post-install directory sync failures, and rejects corrupt startup envelopes. Timeout/503 uncertain-write behavior and lack of idempotency/history are documented accurately. No new data-corruption or authentication bypass was established in this bounded audit. +- Request body caps and command limits exist, but do not currently solve TS1. Tests named capacity/bounds mostly check final rejection/status, not prompt rejection or concurrent service responsiveness. + +## Suggested release acceptance additions + +1. Under-limit and over-limit source/JSON candidates are bounded before expensive validation; an unrelated model/catalog read remains responsive during rejected import. +2. A 5,000-object representative model remains editable while analysis runs; obsolete diagnostics cannot overwrite newer draft state. +3. A text-only author sees engineering warnings and can jump directly to the affected declaration; search/navigation works on Unicode source and large documents. +4. The release decision on comments, recovery, hosted editing and tested deployment platforms is reflected consistently in the guide, normative contract, README and acceptance record. + +## TS1 / TS3 implementation and re-verification — 2026-09-05 + +The previously observed TS1 catalog stall is fixed at both boundaries. Shared +`ParseLimits` checks source declarations and decoded JSON record counts before +structural validation; V1 checks include generated placements and process +occurrences before migration. Source/model replacement and candidate checks use +the same budgets. Store access now captures an owned snapshot under the mutex, +prepares/validates outside it, then rechecks the exact ETag while atomically +installing a privately constructed `PreparedModel`. Catalog reads copy only +summaries. Capacity accounting, directory ownership and install-failure behavior +remain under the commit lock. + +TS3 core diagnostics now carry severity, stable engineering code, object/field +identity and exact source location. Valid source drafts report engineering +warnings without becoming unparseable; syntax and structural errors remain +blocking. Unicode scalar columns are preserved. Semantic diagnostics index +source line starts once, avoiding a complete prefix scan per warning. Native +background analysis and diagnostic navigation are separately owned desktop +verification gates, not claimed by this worker. + +Observed current R2 verification: all **67 core tests**, **14 HTTP tests** and +**1 deterministic prepared-candidate commit test** pass on Windows. Strict +core/server all-target Clippy and the optimized server build pass. The full +language fixture covers every element/relation kind, new OA mission and SA +mission links, conditional extension with quoted guards, and typed mission +trace. OpenAPI exposes the same new vocabulary. These counts describe the R2 +checkpoint; subsequent R1 topology integration has its own verification gate. + +New actual HTTP cases exercise over-budget source, native JSON and legacy JSON +imports, source/model replacement and non-mutating candidate checks. Every +rejected candidate preserves the existing revision/model. A within-budget +2,000-deep candidate is prepared concurrently with catalog reads and a winning +rename; the stale candidate returns 412 and cannot overwrite either memory or +the installed envelope. A deterministic store unit test separately ensures two +fully prepared candidates from one original head cannot both commit. + +Optimized executable probe against an isolated Windows store, using the exact +original source shape: + +| Candidate | Bytes | Candidate response | Concurrent catalog | +| --- | ---: | ---: | ---: | +| 12,000 nested activities, source | 638,689 | 422 `model_limit`, 0.050 s | 0.016 s | +| 12,000 nested activities, native JSON | 2,737,965 | 422 `model_limit`, 0.142 s | 0.0023 s | +| 12,000 activities, legacy JSON | 2,341,015 | 422 `model_limit`, 0.127 s | 0.0013 s | +| 2,000 nested activities, accepted-size source racing a rename | 102,698 | 412 `revision_conflict`, 0.883 s | 0.0014 s | + +The source rejection completed before the catalog read began 50 ms later; +both JSON requests were confirmed still in flight during their catalog read. +The accepted-size source was confirmed in flight at 100 ms; the competing +rename committed in 0.024 s and remained revision 2 on disk and in memory. +Measurements are one native run, not throughput guarantees. Evidence JSON and +stderr are in `D:/CodexBuilds/Capella2-r1-text-service-audit/fixed-20260905-095047`. +The probe process was terminated after all requests completed; no server is +left running. Stderr contains only the expected startup line. Graceful restart +and draining behavior remain covered by the transport suite and prior native +platform verification, not this timing probe. + +Remaining first-release work is not waived: comment/recovery/hosted editing +decisions, exact deployment toolchain verification, native large-draft +interaction, the R1–R6 engineering semantics and final independent review. diff --git a/harness/release-topology-core.md b/harness/release-topology-core.md new file mode 100644 index 00000000..84338861 --- /dev/null +++ b/harness/release-topology-core.md @@ -0,0 +1,36 @@ +# R1 contextual process/chain core milestone — 2026-09-05 + +Status: bounded core implementation and focused automated verification complete. Native authoring/rendering, source/API integration and release migration remain separate gates. The original Capella benchmark is still incomplete; its actually observed path highlighting and semantic navigation inform the required UI work, not a claim of feature equivalence. + +## Frozen contract + +- Existing `OccurrenceBody::Action { action, lifeline }` is a contextual work involvement when directly owned by an OA Process or SA FunctionalChain. Its `action` points to same-layer Activity/Function; `lifeline` must be absent. Its name/description/precondition/postcondition retain contextual meaning independently of the referenced work. +- `ControlKind` is Choice, Merge, Fork, Join or Loop. `OccurrenceBody::Control { kind }` belongs directly to a process/chain. +- `OccurrenceBody::SequenceReference { sequence: Option }` reuses a same-layer process/chain of the same family. Direct/transitive recursive reuse is rejected. An absent target is an editable incomplete reference. Deleting the reused sequence clears this reference and preserves the occurrence; it does not delete callers or an unrelated reusable sequence. +- Existing process/chain Exchange occurrences continue to reference Interaction in OA or FunctionalExchange in SA; they do not use scenario lifelines. Boundary exchanges remain available to scenario occurrences. +- `RelationKind::SequenceLink` / `RelationBody::SequenceLink { source, target, guard: String }` connects distinct contribution occurrences directly owned by the same process/chain. That common owner determines contextual link ownership. Source is preceding; target is following. `can_connect` applies the same context and endpoint rules as committed transactions. +- Action→Exchange, Exchange→Action and Exchange→Exchange adjacency must match the referenced global exchange's source/target work. Other links express contextual precedence without manufacturing a global work exchange. +- `steps` remains stable membership/presentation order. Explicit links express topology; reordering steps does not alter links. A legacy exchange-only sequence without explicit links keeps the existing linear interpretation and sequence-gap diagnostics. No old IDs, schema version or stored sequence is silently rewritten. + +## Diagnostics and transition behavior + +Once explicit links or work/control/reuse contributions exist, validation checks graph starts/ends, isolated/unreachable contributions and ability to reach an exit. Ambiguous splits/merges on non-control contributions warn. Choice/Fork expect one incoming and at least two outgoing links; Merge/Join expect at least two incoming and one outgoing; Loop expects at least one incoming and two outgoing (repeat/exit). Missing Choice/Loop outgoing guards warn and can use explicit `else`. Incomplete topology remains editable. Guard strings use the ordinary text-validity validator. Loops are permitted; there is no blanket topology-cycle prohibition. + +Required/Subtree transition closure of a process/chain includes contextual links as well as owned steps and referenced dependencies. The shared visitor maps reused sequence references and link endpoints; guards and contextual properties survive. New mapped process/chain occurrences use `TraceKind::Sequence`, admitted for paired direct Process/FunctionalChain owners. Legacy `TraceKind::Scenario` occurrence traces remain accepted. Existing name/description-only drift remains a required R3 extension, not a claim that edited topology is already fully reconciled on existing counterparts. + +The enum additions are 2 occurrence variants and 1 relation variant; `Kind::ALL` stays 28 and `RelationKind::ALL` is now 20. Generic session creation/ownership, projection endpoints, identity remapping and deletion infrastructure were reused. No additional registry, command type or UI-only semantic store was added. + +## Verification actually run + +Target directory: `D:/CodexBuilds/Capella2-01a06e2e`. + +``` +cargo check -p oa-core --locked +cargo test -p oa-core --locked --test release_topology +cargo test -p oa-core --locked --test workflows --test review_regressions --test process_diagnostics --test release_capabilities +cargo clippy -p oa-core --all-targets --locked -- -D warnings +``` + +Observed: **6 new topology tests plus 51 prior focused core tests passed**, strict core Clippy passed, edited sources/tests formatted with Rustfmt edition 2024. Tests exercise valid parallel and alternative paths with a reused subprocess in both transition directions; mapped reference closure and guards; explicit Sequence traces; invalid adjacency/ownership/self/recursive reuse; loop and incomplete topology diagnostics; every fixture element/relation reidentification/deletion and exact undo; list reordering independent of topology; and unchanged legacy linear behavior. Initial zero-warning assertions were corrected to account explicitly for intentionally unallocated global work and an unspecified system boundary in topology-only fixtures; no topology warning is filtered out. + +Language fixtures, source/API/schema migration and native UI are being integrated by separate owners. Do not treat this focused report as a final workspace or first-release completion result. diff --git a/harness/release-transition-contract.md b/harness/release-transition-contract.md new file mode 100644 index 00000000..cf659fb6 --- /dev/null +++ b/harness/release-transition-contract.md @@ -0,0 +1,107 @@ +# R3 semantic transition review contract proposal + +Status: read-only integration design, 2026-09-05; not implemented. The lead requested this while R5 topology was finishing. R5 final source/API verification takes precedence. R6 properties below refer to the proposed `release-data-contract.md` shapes and must follow the lead's final R6 freeze. + +## Current boundary and concrete outcome + +`transitions.rs` already supplies direction, SelectedOnly/Required/Subtree scope, explicit source→target/kind mappings, preview changes/conflicts, per-field resolutions, a snapshot fingerprint and atomic commands. `TraceBaseline` stores only OA/SA `TracedProperties { name, description }`; `TransitionField` is only Name/Description and `FieldChange` contains string before/after values. `trace_drift()` therefore cannot detect a changed body, parent, reference or ordered contribution. Creation already maps typed bodies; updating an existing counterpart does not review those bodies. Retain the current pipeline and extend its review surface rather than replace it with a general graph synchronization framework. + +An engineer must see a changed range, item list, parent, guard, occurrence, event trigger or interface contract; inspect its mapped proposal; accept selected changes or preserve intentional target differences; apply once, undo, and repeat without overwriting independent work. Neither direction is an automatic inverse. The original Capella iterative transition reference already linked in `release-semantics-audit.md` motivates conservative target comparison; the detailed decisions here are Studio's own contract. + +## Recommended exact public types + +Keep Name/Description unchanged for existing callers. Add a closed-catalog semantic field selector and typed values: + +```rust +enum TransitionField { Name, Description, Semantic(SemanticPath) } +struct SemanticPath { segments: Vec } +enum SemanticValue { + Absent, Boolean(bool), Integer(i64), Unsigned(u64), Real(f64), + Text(String), Enum(String), + Reference(HistoricalRef), + List(Vec), + Record(BTreeMap), +} +struct HistoricalRef { uuid: Id, kind: SemanticRecordKind } +enum SemanticRecordKind { Element(Kind), Relation(RelationKind), Artifact } +struct ReviewedSemanticField { path: SemanticPath, value: SemanticValue } +struct ReviewedSemanticSide { fields: Vec } +struct SemanticBaseline { oa: ReviewedSemanticSide, sa: ReviewedSemanticSide } +// TraceBaseline retains existing oa/sa TracedProperties and adds: +// semantic: Option + +enum FieldDecision { + TakeSource, KeepTarget, Value(String), // Value remains prose only + SemanticValue(SemanticValue), +} +// FieldChange.before/after become SemanticValue (Text for Name/Description). +// Add source_before/source_after and target_before so a reviewed delta is visible. +``` + +`SemanticPath` is not an arbitrary JSON Patch address. The core returns a finite catalog of valid paths for each supported record/variant and rejects unknown paths, duplicates, identity/header edits, invalid enum/value combinations and unsupported writes. The GUI displays human labels, not path strings. API/source uses the same catalog and typed value constructors. Represent missing optional values with Absent; distinguish it from an empty list or empty string. Finite numeric rules follow the actual R6 types. + +Baseline field entries are sorted lexicographically by path and unique; the vector is intentional because JSON cannot use a structured SemanticPath as an object-map key. Internal lookup may use a BTreeMap. Types containing finite f64 values derive PartialEq rather than promising Rust Eq; validation rejects non-finite values before baseline storage. + +Recommended path shape is `parent`, `body/` and nested `body//` as necessary; constructors are explicit read-only/switch proposals, never inferred from property names. Examples: `body/DataField/minimum`, `body/StateTransition/guard`, `body/StateTransition/triggers`, `body/Occurrence/Exchange/reply_to`. Use named stable identity selectors for collection-member proposals; never use a vector index as durable identity. Paths are arrays in JSON/source so no slash-escaping ambiguity exists. The exact path list is generated/tested from an exhaustive typed-model adapter after R6 freeze. + +HistoricalRef values are baseline/proposal snapshots, not additional live graph edges. They retain deleted reference identities for detecting removals. They must not be pruned by ordinary target deletion or rejected as dangling live references. Reidentification explicitly remaps these historical UUIDs when it remaps an existing identity. Source prints `uuid "..."` inside a HistoricalRef; it cannot require an `@alias` for an object that no longer exists. Human preview labels resolve current names where available and mark removed references otherwise. This separation prevents deletion cleanup from erasing the evidence needed to show drift. + +The release schema/API change to typed FieldChange is explicit; do not silently serve the new shape under documentation claiming old strings. Existing REST endpoint version can remain `/api/v1` with an explicitly updated OpenAPI/model schema contract, but version-2 source/JSON migration only concerns stored models, not undocumented old transition-plan compatibility. Old retained client plans are stale and must be regenerated. + +## Snapshot coverage and drift + +Every directly persistent semantic field of an element/relation is captured: name, description, parent, body kind/constructor and all typed body fields/references. Exclude identity, alias, layer identity, diagram placement/size/collapse and the trace baseline itself. Alias changes do not change engineering meaning. Type/layer changes are explicit counterpart mapping decisions, not an incidental editable field. + +The implementation adapter must exhaustively cover: + +- Existing data/interface/port, constraint, state behavior text, availability/allocation, exchange endpoints/ports/items and mission/capability relationships. +- R1 process/chain steps, control kinds, SequenceReference, SequenceLink guards/endpoints and contribution parentage. +- R2 mission/include/extend/generalization/involvement fields, especially Extend condition and endpoint mappings. +- R4 viewpoints, context/pre/postconditions, lifelines, message kind/timer expression/reply correlation/value annotations, scenario-use bindings, fragment/branch membership and guards. +- R5 StateKind, Event Signal items/Time kind and expression/Change expression, transition kind and both typed trigger/effect lists, with descriptive strings separate. +- R6 nature/mechanism, defaults/ranges/unit/direction/storage, supertypes, association roles/multiplicities/navigation, interface allocation protocols/order, and message value paths if accepted. + +Owned-child membership and semantic incident-relation membership are also reviewed facets. Keep relation records separately identified; a parent→child relation is not a second opaque nested copy of an entire graph. Created/selected dependency records each get their own trace baseline. The parent review rolls up drift from owned descendants' traces and flags newly untraced descendants as unreviewed. Direct drift and inherited/related impact must be visually distinguished; a referenced shared type changing should show as a linked impact, not pretend the user's own field reference UUID changed. + +For efficient storage, store each traced record's own fields once per side and direct ownership/relationship identity membership; do not embed the whole subtree into every ancestor baseline. A descendant removed from the live graph remains detectable from historical membership even when its child trace is removed. Aggregate drift walks current and recorded ownership with visited IDs and bounded work. Artifact links owned by the compared semantic record are reviewed separately (URI/revision/fragment/relation and membership); artifact content itself is not fetched or diffed. A trace's own rationale/artifacts are not recursively included in its baseline. + +For a reviewed field, compare current OA with recorded OA and current SA with recorded SA, preserving intentionally unequal reviewed values. Drift is Unchanged/OaChanged/SaChanged/BothChanged as today. Any supported field lacking a semantic baseline is Unreviewed, never falsely Unchanged. Source change affecting only an unselected field remains drift after applying a Name-only review. + +## Mapping, three-way proposals and target preservation + +1. Freeze an explicit mapping table before generating proposals. Reuse a unique compatible trace or the engineer's exact mapping. Missing or multiple eligible mappings create actionable conflicts; no display-name matching. Resolve all references through the same table used by current creation. Mapping and source-selection changes invalidate all affected decisions, even if a string value happens to match. +2. Compare mapped source-before/source-now with target-before/target-now. A scalar source change can apply when the target still matches its recorded value. If both sides changed the same scalar incompatibly, or a baseline is absent, require TakeSource/KeepTarget/typed merged value. If target-now already equals mapped source-now, it is a no-op that may be acknowledged. Preserve independent fields automatically. +3. TakeSource for reference collections means apply the displayed source *delta*, not replace the entire target collection. Target-only members survive. Additions map by UUID and are shown individually. Removing a mapped source member proposes unlinking it; deleting the target semantic object is a separate explicit action with ordinary impact preview. Never cascade deletion across layers. +4. Ordered membership is a distinct proposal from presence. When unchanged target ordering maps directly to the source baseline, source reorder can apply. If target-only contributions or independent reorder make insertion anchors ambiguous, require an explicit final target ordering; do not guess placements or discard extra steps. New mapped members may insert after a uniquely mapped unchanged predecessor, with source order preserved. A proposed order must include every surviving target member exactly once. +5. A parent change is an explicit reparent proposal, with mapped destination and the actual affected target subtree visible. Changing a relation endpoint is an explicit endpoint proposal, validated against mapped kinds and allocation/scenario context. An unsupported layer-specific source relation has no destructive fallback. Keep target and explain the missing mapping. +6. Constructor changes (e.g. Asynchronous→Timer, Time→Change, Record→primitive) are grouped typed proposals with all added/removed fields and affected owned records visible. The whole group must be compatible with retained target-only work. If it would invalidate independent children, references or scenario observations, block until explicit resolutions exist; do not hide it behind one generic Body checkbox. +7. Many source records mapped to one target are allowed only when their resolved deltas agree for every touched facet. Incompatible merges/splits require explicit individual mappings/resolutions; no automatic reverse merge. Reuse remains idempotent and aliases/target IDs remain stable. + +All retained independent work is still validated in the final candidate. Keeping an incompatible target field can make a chosen source proposal impossible; report that exact interaction rather than force either side to win silently. Creation of required dependency records remains supported, but the preview must name every creation and make existing mapped records' updates opt-in. + +## Baseline acknowledgement and command safety + +KeepTarget acknowledges the current source and the intentionally retained target only for the selected field/facet. It does not copy the source or refresh unrelated baseline fields. TakeSource acknowledges the source and successfully resulting target. A manually merged value records that pair. No-op acknowledgement can update a baseline without emitting an EditElement. Cancellation changes neither model nor baseline. + +Applying a plan still checks the exact project fingerprint and HTTP ETag/base revision, builds one candidate and runs whole-model structural validation before one atomic history/disk commit. An invalid candidate leaves the project, baseline, revision and undo history unchanged. Baseline updates occur in that same transaction after successful resolved model changes. Keep the current documented stateless server plan contract: a client plan is a candidate, not a cryptographically trusted receipt. Do not bypass ordinary typed command validation because it came from a preview. + +Identity/reference remapping, stale-plan rejection, duplicate resolutions, wrong field types, unsupported paths and removal impacts need focused tests. Fingerprints remain change detection, not authorization or an execution capability. + +## Migration and integration sequence + +V2 baselines retain exact name/description values and gain `semantic: None`. The first semantic review is explicitly Unreviewed and requires acceptance for differing typed values; do not fabricate historical body snapshots from today's model. Name/Description-only legacy behavior remains available without pretending semantic review happened. Golden V2 UUIDs/aliases/prose/artifacts remain unchanged. Historical reference values have explicit typed source constructors and OpenAPI definitions rather than escaped JSON blobs. + +Implement after R6 shapes freeze: (1) exhaustive semantic snapshots and honest drift; (2) mapped scalar/parent/endpoint proposals with per-field decisions; (3) identified collection/ownership changes and order review; (4) native reviewer UX plus API/source/schema parity. Each milestone must have observable review behavior. These are one existing transition subsystem, not separate synchronization services. + +GUI: show source and target labels, origin/current values and mapped proposal; group Properties, Ownership, Relationships, Ordered behavior and Artifacts. Include deliberate Keep target differences and unreviewed semantics. Selecting a reference opens its semantic record or mapping picker. Checkboxes control individual coherent proposals. A pending conflict identifies the field and affected target object. Do not show opaque UUID-only bodies, mark unchanged rows as changed, or advertise two-way automatic sync. + +## Required proof before accepting R3 + +- Initial OA→SA transition of a complete process/state/scenario/data slice, then change datatype range/unit/default, field multiplicity, exchange items/ports, parent, guard, topology order, scenario binding and event triggers. Each produces exact drift/proposals; do the same in reverse. +- Intentionally retain a target guard and add a target-only branch, field and interface allocation. Apply unrelated source changes and verify these survive byte-for-byte. Remove source contributions and verify target records are retained until explicitly deleted. +- Exercise ambiguous endpoint mapping, constructor switch with dependent target children, simultaneous reorder, differing many-to-one proposals and absent V2 semantic baselines. No partial application or invented inverse. +- Apply the same accepted review twice (second has no further semantic changes), undo/redo exactly, edit after preview then reject the stale plan, and fail one resolved field validation without advancing any baseline. +- Prove exhaustive coverage by walking each actual supported typed payload and mutating one meaningful field/reference at a time against a reviewed baseline, including every R1/R4/R5/R6 property. The expectation is observed drift plus one justified proposal or explicit unsupported conflict, not just serialization equality. +- Verify JSON/source stored-baseline roundtrip with deleted historical references, reidentification, real HTTP preview/apply with ETag races, disk restart, and native inspect at compact and normal widths. + +Lead choices to freeze: typed SemanticValue/closed path catalog; field-specific historical snapshots rather than whole-subtree copies; explicit collection deltas preserving target-only members; old semantic baselines remain Unreviewed; no source deletion automatically deletes a target record. R6 names must be reconciled with its final accepted contract before generating the exhaustive catalog. diff --git a/harness/review-architecture.md b/harness/review-architecture.md new file mode 100644 index 00000000..a995b033 --- /dev/null +++ b/harness/review-architecture.md @@ -0,0 +1,24 @@ +# Independent architecture review — 2026-09-05 + +Scope: current integrated Rust core, language, persistence, desktop message/draft state, views, diagram, tests, architecture and execution documents. Read-only implementation review; no Computer Use or build/test execution by this reviewer. + +## Material finding + +**High impact: help navigation allowed two competing editable drafts. Resolved in source; runtime verification remains with the lead.** + +`app.rs` deliberately lets `Page::Guide` open without applying source so that syntax help is available for invalid drafts. Previously `views.rs` also displayed the graphical inspector on Guide. A user could edit valid source, open help, edit an inspector property, then Apply or Save. `apply()` calls `apply_source()` first; successful source replacement calls `sync_selection()`, overwriting that inspector draft before `draft_dirty()` examines it. This was a reachable silent-loss path, not merely hypothetical state complexity. + +The lead changed `views.rs:170` to omit the inspector on both Text and Guide. I reread the updated condition and confirmed the competing editor is no longer rendered on the exception route. Keep Guide read-only. Runtime regression: select an element, edit source without applying, open help, verify no inspector and unchanged draft, return to Text and apply; repeat with invalid source to verify help remains reachable and the draft survives. If a future guide adds editable controls, route them through the same apply boundary before exposing a second draft. + +## Architecture assessment + +- The two-crate boundary is appropriate: no Iced dependency in `oa-core`; parsing, shared structural validation, transactional history and persistence operate on the same model. Desktop mutation paths inspected use `Session::execute`, and loaded candidates pass persistence validation before session adoption. +- Whole-project text replacement is one transaction. This avoids an unnecessary second model store and synchronization framework. Human-readable aliases are presentation; persisted UUIDs retain identity. Neither a dynamic plugin ABI nor an incremental parser framework is warranted for the current prototype. +- Separate source-draft history and committed-model history have different jobs and are presented separately. Bounded snapshots are a defensible modest-model tradeoff. Large-model profiling and delta history remain future work, not prerequisites for the MVP. +- `App` is large, but its current event loop and a few explicit apply/sync helpers are understandable. A broad state-machine rewrite would add risk during integration. The discovered loss path needed a local ownership fix, now made. Future additions should group draft ownership and file-operation state only when a concrete new transition requires it. +- `Session::project` remains publicly mutable and `Session::new` assumes a valid candidate. Thus the mutation boundary is enforced by call-site convention rather than Rust privacy. Current desktop call sites inspected honor it; this is a nonblocking library-hardening opportunity, not evidence of an existing application defect. +- No blocking unnecessary abstraction or speculative infrastructure found. Native-platform file selection and renderer limitations are explicit implementation/platform tradeoffs and require the lead's runtime evidence; this review does not certify that evidence. + +Result: no unresolved high-impact architecture finding in the inspected source after the Guide fix. Runtime re-verification of that fix and final build/test evidence remain separate completion gates. + +Lead integration closure: the read-only Guide and invalid-draft recovery were subsequently exercised in the actual Windows application by the lead/UI reviewer. Final Windows/Linux checks and native runtime evidence are recorded in `docs/validation.md`; no architecture finding remains open. diff --git a/harness/review-correctness.md b/harness/review-correctness.md new file mode 100644 index 00000000..714f0186 --- /dev/null +++ b/harness/review-correctness.md @@ -0,0 +1,94 @@ +# Independent correctness review — 2026-09-05 + +Scope: current core model, language parser/printer, JSON/text persistence, +transaction history, and desktop source/property/save/navigation state. Read the +attached goal and normative language specification. No implementation edits or +Computer Use were performed by this reviewer. + +## Material findings + +1. **P2: invalid property draft could trap the user in Getting started.** + Previously `Message::Page(Guide)` skipped `apply()` while the guide hid the + inspector. Select an element, enter `invalid` as X, enter Getting started, + then attempt Architecture or another selection: validation rejected every + return action and the correction/revert controls were inaccessible. Root + changed `app.rs` to apply property drafts before opening help when there is + no source draft. A temporary compiled update harness subsequently observed + that the invalid-X attempt stays on Diagram, preserving the draft and its + accessible controls. **Resolved in the inspected code; retain a regression + test and exercise the UI.** Invalid source still needs access to help. + +2. **P2: accepted name strings could not all be authored graphically.** + `validation::valid_text` permits LF, CR and tab, and language tests explicitly + use these in names. Project, element and relationship names use Iced + `text_input` in `views.rs`; the installed Iced 0.14.2 implementation filters + control characters on paste (text_input.rs:970–975) and typing + (1022–1023). Thus a valid text-authored name was outside the graphical + editor's authoring capability. Root accepted the finding and is adding + an explicit exact-string inspector mode. **Resolved by the focused follow-up + review:** compiled state tests authored CR/LF/tab/quotes/backslashes/Unicode + in project, element and relationship names and element descriptions; + toggled both presentations; checked canonical source; executed the actual + Save tasks to `.arcadia` and `.json`; and loaded both files with complete + model equality. Invalid JSON literals and XML-forbidden decoded characters + in all four property cases preserved model, draft and mode, blocked Save, + and remained recoverable through Revert. Real-app visual verification + remains the lead's check. + +3. **P2: noncanonical exact literals remain falsely dirty after Save/navigation.** + In exact-string mode, type `"\u0041"` as the project name and Save. The file + correctly contains the model name `A`, and the saved model baseline is + current; however, `project_name` retains its noncanonical spelling while + `draft_dirty()` compares it with `display_string(model.name)`, which yields + `"A"`. Save does not call `sync_selection()` after successful `apply()`, so + the UI continues reporting unsaved work and may show an unnecessary discard + guard. The same path affects element/relation names and descriptions. + Root now calls `sync_selection()` after a successful property command, + while leaving failed drafts intact. **Resolved and reverified:** the + compiled harness saved noncanonical literals in project, element and + relation names and descriptions, observed clean saved state in each case, + then edited another noncanonical literal and navigated to Text with clean + property state and matching canonical model. Before the fix this same + real-Save-task harness failed its clean-state assertion. + +## Additional verification + +- Compiled a focused parser harness against the current built core. Mutated + the valid document at every Unicode character boundary with punctuation, + quotes, escapes, comments, line breaks, control characters and extreme + exponents; also supplied 3,000 deterministic arbitrary Unicode strings. + All returned diagnostics or structurally valid models without panics, and + every accepted model round-tripped through the canonical printer. +- Compiled a focused Iced content harness: LF, CR, CRLF, trailing line endings, + U+2028 and U+0085 descriptions round-tripped through `text_editor::Content` + exactly. This potential description-normalization defect was not reproduced. +- Inspected duplicate-identity handling, forward reference resolution, strict + unknown-field rejection, UUID collisions, kind/endpoint edits, process step + references, failed replacement history, atomic persistence, and save guards. + No additional material correctness defect was established in these areas. +- Correction to the earlier review: treating identity entry as an explicit-only + action did not satisfy the specification's property-draft rule. The final + implementation treats IDs as ordinary property drafts: they contribute to + unsaved state, apply on Save/navigation, and commit atomically with the other + properties. The requirements reviewer identified and closed this gap. +- Temporary reproduction sources/binaries are outside the repository under + `D:/CodexBuilds/Capella2-01a06e2e/review-*`. Whole-workspace regression and + real-app verification remain the lead's integration checks; this report + does not claim those were independently rerun here. +- Follow-up complete: recompiled the current desktop modules in the isolated + harness and ran all 18 tests (15 repository desktop/diagram tests plus the + three independent exact-string tests); all passed. This covers real Save + task execution to private test files but does not replace Computer Use. +- Final identity follow-up: inspected `EditElement(old, record)`, + `EditRelation(old, record)`, `EditProject(id, name)`, the shared reference + update helper, identity-aware draft detection, and action remapping after + Apply. Recompiled the current modules and ran five focused identity tests; + all passed. Four repository tests cover failure preservation, references, + navigation, source parity, process actions, Delete and pending-ID movement. + An independent test executes real Save tasks for project, element and + relationship ID-plus-name drafts, reloads equal models, observes clean saved + state, then verifies one Undo restores both properties together. No material + issue was established in this bounded change; native drag verification is + assigned to the runtime reviewer. + +No unresolved material correctness finding remains from this review. diff --git a/harness/review-requirements.md b/harness/review-requirements.md new file mode 100644 index 00000000..cecbe6d5 --- /dev/null +++ b/harness/review-requirements.md @@ -0,0 +1,45 @@ +# Independent requirements review + +Reviewed 2026-09-05 against the complete attached user objective, `docs/language.rst`, current Rust implementation/tests, README, scope, acceptance, plan and state. No implementation edits or Computer Use were performed by this reviewer. Independent focused test execution is recorded below; parent-reported runtime results are distinguished from inspected code. + +## Material finding + +**R1 — identity edits are lost outside the dedicated button (fixed; focused re-review).** + +`App::draft_dirty` in `crates/oa-desktop/src/app.rs` checks names, descriptions, kinds, endpoints and positions, but not `identity`. `Message::Identity` changes the input. From a clean project, entering a different UUID leaves `dirty()` false, so `Request(New)` resets the project without an unsaved-work guard. Selecting another object calls `apply()`, which sees no property draft, then `sync_selection()` overwrites the entered UUID. Even ordinary Apply discards the ID input without applying it. Only the separate Change ID button uses the entered value. + +This contradicts specification section 6: edited property drafts contribute to unsaved work, failed edits preserve drafts, and Save first applies valid drafts. The identity field edits an existing persistent model field, unlike preparatory controls for creating a new relationship. Integrate identity into validated property application, or preserve and guard its pending state explicitly. Verify valid/invalid identity edits, Save, selection changes, New/Cancel and reference updates; avoid partially applying companion properties before an invalid identity is rejected. + +The integrated fix adds identity to `draft_dirty`, parses it before any property command, and combines identity plus companion properties through `EditElement(old, value)`, `EditRelation(old, value)`, and `EditProject(id, name)`. Their shared reidentification helper rewrites references on a candidate before validation/adoption, making collision failures atomic and successful property/identity edits one history entry. Ordinary Apply, navigation and Save use this path; Change ID delegates to it. Inspected new desktop regressions cover all three object categories, guards, collision/invalid input, reference integrity and one-step Undo. Parent reports 49 full-workspace tests passing. This reviewer independently built the desktop tests with the D: target directory and ran the resulting test binary with `identity_ --nocapture`: all three identity/source-identity tests passed, exit 0. Valid Save captures the post-apply `session.project`, so the saved snapshot contains the new identity. R1 is closed by focused code/test review; GUI verification remains a separate gate. + +**R2 — an action can retain the old target across identity auto-apply (P2, fixed).** + +Select an object, edit its UUID, then click its Delete control. The rendered button carries `Command::Delete(old)`. `Message::Command` first applies the identity draft, then executes that old command. Delete is now a successful no-op, leaving the object present with a new identity. Process Remove/Up/Down controls likewise embed the old process ID and report a missing element after committing the new ID. `Connect` snapshots source/target choices before applying drafts and can submit an obsolete endpoint when that selected endpoint is being reidentified. Selecting the same canvas/explorer object first applies the ID then selects the obsolete ID, clearing selection. + +These are concrete reachable single-click workflows, not asynchronous hypothetical races. They do not corrupt committed model data and can be recovered by repeating the action; severity is P2. Apply must either remap action targets after a successful identity change or resolve the user's target after application. Include both command targets and relation endpoint choices; preserve the intended selection as well. + +Focused re-review: the fix remaps selected command targets across Apply, preserves same-item selection, remaps existing builder choices only for identity commands and resolves Connect choices after Apply. Independently ran `cargo test --locked -p oa-desktop identity_`: all four tests passed, including the new process reorder/remove, selection, delete and connection regression. These single-message paths are resolved. + +The first fix left a residual drag path: ButtonPressed retained an old ID in `State.drag`, while Select auto-applied the new identity before ButtonReleased submitted Move(old). The final fix preserves a draft when reselecting the current object outside Text; that selection is a no-op, so drag identity remains valid until release. Move then applies the draft and remaps its old ID in the same message. Selection changes and leaving Text still apply drafts as required. The expanded regression sends Select(current), asserts the ID draft remains pending, then sends Move(old) and checks the surviving new-ID object's position. Independently reran `cargo test --locked -p oa-desktop identity_` after this fix: all four focused tests passed, exit 0. R2 is closed by code/test review; actual drag/identity operation remains part of the root's separate native GUI retest. + +## Implementation coverage + +- The Rust/Iced two-crate application implements the specified OA MVP: five element kinds, four typed directed relations, cardinality checks, ordered/repeated process interactions, history, model checks, diagram/explorer/narrative views, and durable files. +- The graphical and textual representations share `Project`, the validator and transactional session. Every persistent field has both a source representation and a graphical route, including exact strings, coordinates, kind/endpoint changes and identity. R1 affects the draft contract, rather than the existence of an identity operation. +- The normative reST document covers abstract values, lexical syntax/EBNF, structural/warning semantics, forward resolution, canonicalization, equivalence, graphical editing, transaction/draft guards, persistence, SVG export and examples. The language is explicitly an OA prototype language, with the same domain boundary in both representations. +- Inspected tests cover complete model round-trip, canonical idempotence, aliases/Unicode/coordinates, strict malformed input, process order/repetition, shared validation, identity reference updates, atomic replacement and JSON/text equivalence. Application state tests now include the R1 transition and action-after-identity paths described in R2, including the press/release message sequence. +- `cargo metadata --offline --locked --format-version 1` found no dependency declaring a Rust minimum above the workspace's stated 1.90. Actual 1.90 build verification is not claimed. +- Scope documents candidly list unsupported Capella concepts and remaining usability/performance tradeoffs. The objective requests an MVP and asks to try to address known downsides; the implementation makes concrete improvements without claiming complete Capella or SysML conformance. I found no additional explicit required OA feature silently available only in one representation. + +## Open completion gates (expected pending verification) + +1. Complete the native GUI retest of identity/drag changes and close material findings from the other independent reviewers. R1 and R2 focused code/test verification is complete. +2. Record real-app editing of existing relation kind/endpoints, process append/reorder/remove, and IDs/reference preservation. Source-created objects plus graphical rename alone do not establish these newly exposed workflows. Root reports Windows source authoring, graph synchronization/save/error guards and 1100×700/1440×900/maximized inspection; these are useful but narrower evidence. +3. Exercise the current Linux editor/file workflow on the actual native build, including text-to-graph-to-text and save/reopen. The 44-test native pass predates the latest focused fixes; run final checks after integration. +4. Complete the clean documented build, final full-workspace checks, refreshed Windows/Linux artifacts and reproducible startup guidance. Existing historical artifacts and CI configuration do not prove the final revision. +5. Reconcile `harness/STATE.md`, `docs/validation.md` and `docs/acceptance.md` with final commands/runtime/review outcomes. The inspected validation record still describes the earlier 18-test MVP; the updated README and scope correctly defer final acceptance. + +The reported R1/R2 findings are closed by independent focused review. No unresolved blocking or high-impact requirement defect remains from this review. Overall completion remains unproven until the runtime/build/documentation gates have current evidence. No claim of actual rendered-UI quality is made by this reviewer. +## Lead integration closure — after the independent review + +All five completion gates listed above now have final evidence in `docs/validation.md` and `docs/acceptance.md`. The independent UI reviewer exercised existing relation/process operations and the final release's pending-ID drag, closing all material UI findings. The lead completed the native Linux text Apply/save/reopen and process-view workflow. Windows and Linux each passed the final 50-test suite, strict Clippy, formatting and optimized builds; both refreshed archives were checked against executable hashes. Documentation is reconciled. This note records integration evidence and does not imply this reviewer personally performed the lead's GUI checks. diff --git a/harness/review-ui.md b/harness/review-ui.md new file mode 100644 index 00000000..bbd085a5 --- /dev/null +++ b/harness/review-ui.md @@ -0,0 +1,112 @@ +# Independent UI and runtime review + +Reviewed 2026-09-05 using the actual Windows `target/debug/arcadia-studio.exe` +through the Computer Use skill and `@oai/sky`. This reviewer did not implement +the application or modify implementation files. The running executable was the +build supplied at review start; concurrent source fixes are not covered here. + +## Original findings (resolved in final release below) + +- **P2 — nearby horizontal interaction becomes invisible.** In Text, apply the + fixture below, open Architecture, and select Observe. At 60% zoom the Report + label appears inside Observe's card, while the line and arrow between Observe + and Respond disappear. `diagram.rs` draws a fixed 170-unit opaque label + background over the 76-unit gap between the cards. Text layering also makes + the label visible over the card. Selecting the relation reproduces the same + issue, as does changing it to a Contribution between equally spaced cards. + A normal close horizontal arrangement must retain a visible connection and + a label that does not impersonate card content. + + ```text + arcadia 1; + project "Review cycle" { + activity observe "Observe" { position (0, 0); } + activity respond "Respond" { position (300, 0); } + capability safe "Safe operation" { position (600, 0); } + interaction report "Report" from observe to respond; + } + ``` + +- **P2 — inspector preserves an unrelated scroll position.** At minimum size, + select a process and scroll the inspector to Share & Explore. Load the rescue + example, select its process, or create New. The new selection/project's name, + kind and description may be above the visible inspector. New from the clean + example displayed Stable ID at the top instead of project properties. Reset + or intentionally anchor the inspector when its selection/project changes. + +- **P3 — process continuity warnings omit the offending occurrences.** Move + the second rescue process step above the first. Model checks displays two + identical `Process 'Distress to recovery' has disconnected consecutive steps.` + messages. Include the adjacent step indexes/names so users can identify each + gap. Both warnings correctly disappear on Undo. + +No crash, data loss, or blocked recovery was observed in the exercised workflows. +The three findings above were reported for the initial reviewed debug build. +Their resolution was independently verified against the final release below. + +## Personally exercised behavior + +| Workflow | Observed result | +| --- | --- | +| Launch latest Windows executable | Clean native window, built-in model rendered, 9 elements / 9 relationships / 0 checks. | +| Open `D:/CodexBuilds/Capella2-01a06e2e/harbour-ui.arcadia` through native Open dialog | Harbour response loaded with 6 elements / 5 relationships / 0 checks and All changes saved. Triage incoming report, description's two lines, and exact X=350.25 visible. | +| Invalid source + help navigation | Appended `invalid` after the project's closing brace. Getting started remained accessible without losing draft. Architecture returned to Text with line 40, column 1 diagnostic and preserved 6-element model. Revert restored clean source and saved status. | +| Invalid graphical property + help recovery | Replaced activity X with `invalid`. Getting started stayed on Architecture, with numeric error in footer and inspector still visible. Revert property edits restored 350.25 and clean status. | +| Window resizing | Inspected 1440x900 content (1442x932 outer) and 1100x700 content (1102x732 outer). Header, sidebar navigation, inspector, footer, text toolbar, source editor, help link and modal actions remained reachable without overlapping controls. | +| Fit at minimum size | Six harbour cards fit inside canvas at 46%; text is small at that zoom but no cards were clipped. | +| Process reorder and checks | Moved Incident assessment above Distress signal. Process narrative immediately reflected order, checks count became 2, and Model checks showed continuity warnings. Ctrl+Z restored order and All checks passed. | +| Process remove / append | Removed Rescue assignment occurrence: narrative became two steps, all 9 relationship records remained. Picked Rescue assignment and Append step: original three-step narrative and clean model state restored. | +| Empty project states | New produced zero records; diagram was empty with creation toolbar, Process page had Add process, Model checks reported consistent references, Text showed a valid empty project. | +| Authoring in minimum-size Text view | Entered Review cycle fixture above. Apply generated stable IDs/canonical source, graph showed all three elements and one relation, three expected incompleteness warnings. | +| Edit existing relation kind and both endpoints | Selected Report via Observe's Relationships list. Changed Interaction to Contribution, chose Respond source and Safe operation target, Apply. Inspector showed Contribution and updated endpoints, graph record counts updated, checks fell from 3 to 2. Text showed `contribution report ... from respond to safe_operation;` with the existing relation ID retained. | +| Unsaved-change modal | Loading built-in example from disposable Review cycle displayed Save/Discard/Cancel modal with legible minimum-size layout. Discard replaced only disposable in-memory review model. | +| Clean exit | After restoring the rescue process to original state, Alt+F4 exited; fresh `sky.list_windows()` confirmed no Arcadia window remained. | + +No existing fixture was saved over. Review-created models were disposable +in-memory data. The original harbour fixture was opened read-only through UI. + +## Scope and remaining verification + +This pass covers Windows. Linux rendering/runtime, final packaging, full +automated checks, and edits to source after review started are outside this +review's evidence. Exact-string edge cases were not re-exercised here because +the primary independent focus was navigation, source/property failure recovery, +relation mutation, process editing, and responsive layout. Accessibility tree +for Iced exposed the native window rather than individual application controls; +app interaction therefore used inspected screenshots and keyboard input. + +Computer Use ownership released to the lead agent after confirmed clean exit. + +## Final release follow-up — 2026-09-05 + +Independently launched +`D:/CodexBuilds/Capella2-01a06e2e/release/arcadia-studio.exe` and personally +verified the following using Computer Use at the default 1440x900 content size: + +- **Pending identity + same-card drag regression passed.** Selected Report + distress (position 360,250), typed stable ID + `00000000-0000-4000-8000-000000000123` without Apply, then dragged that same + card. The card moved to 390,570 and retained both relationships. Canonical + Text displayed the new UUID and `position (390, 570);`; both the allocation + and interaction still referenced `report_distress`. Counts remained 9 + elements / 9 relationships / 0 checks. Undo first restored 360,250 while + retaining the new ID; a second Undo restored the original ID and clean state. +- **P3 contextual warnings resolved.** Moved Incident assessment above Distress + signal. The two Model checks messages now separately identified steps 1/2 + and 2/3, the interaction names, and the incompatible ending/starting activity + names. Undo restored All checks passed. +- **P2 inspector scroll issue resolved.** Scrolled the process inspector to + Share & Explore, then selected Project properties. Inspector reset to its top, + with project name and Apply properties immediately visible. +- **P2 horizontal connection issue resolved.** Re-entered the exact Review + cycle fixture from the original finding. At 60%, Observe and Respond were + visibly joined by a continuous line and directional arrow; Report appeared + above the line in the gap, outside both cards. No background mask obscured + the connection. + +All temporary changes were undone. Alt+F4 closed the clean release, and a fresh +window inventory confirmed it exited. No test fixture was saved or overwritten. +No unresolved material UI findings remain from this review. The final follow-up +does not replace the initial minimum-size coverage or the lead's Linux checks; +it specifically proves the reviewed fixes and pending-ID/drag regression in the +actual Windows release. Computer Use ownership released again after exit. diff --git a/harness/review-v2-api-transfer.md b/harness/review-v2-api-transfer.md new file mode 100644 index 00000000..022ff4db --- /dev/null +++ b/harness/review-v2-api-transfer.md @@ -0,0 +1,60 @@ +# V2 API/source/desktop transfer verification + +Date: 2026-09-05. Integration worker: `v2_requirements_arch_review`. Native Computer Use was performed by the lead; this worker owns no CUA session. Code remained unchanged. Documentation changes: the new `RelocateNode` command schema in `docs/openapi.json`, a corrected title underline in `docs/api.rst`, and V2 README/scope/architecture updates. + +## Checks completed + +- After the core worker declared the final core changes stable, `cargo test -p oa-server --locked` passed all 12 actual HTTP/configuration/storage tests on Windows. +- `cargo clippy -p oa-server --all-targets --locked -- -D warnings` passed. +- `cargo build --release --locked -p oa-server` passed; the resulting optimized executable was used for the transfer below. +- `docs/api.rst` initially failed warning-as-error rendering at its too-short title underline. The authorized documentation fix was applied; Docutils HTML5 rendering then passed with `halt_level=2` and `report_level=2`. Output: `D:/CodexBuilds/Capella2-v2-01a06e2e/api-review.html`. +- OpenAPI parses as JSON, declares exactly one `RelocateNode {node, diagram}` command variant, and the actual release server serves that updated schema. +- Rewrote README, scope and architecture for V2. The README's new source example was parsed by the actual core. A focused independent `cargo test -p oa-desktop --locked forms::` pass ran all six form tests successfully after the membership/perspective fixes. + +## Actual release transport workflow + +The desktop executable's headless `--write-example` entry point generated `api-input-demo.arcadia`. This is a generated V2 example, not a claim that this worker saved it by operating the GUI. A fresh isolated loopback server imported the source with `Content-Type: text/plain`, preserving model identity while assigning a distinct hosted resource identity. + +1. Import returned revision 1. +2. Conditional `PATCH /api/v1/projects/{id}` renamed the project and returned revision 2. +3. One conditional transaction edited the SA function name and CAD artifact URI/revision, returning revision 3. +4. Exact model comparison proved that all UUIDs, aliases, diagram placements, traces, occurrence lists and other fields remained unchanged apart from those intended values. +5. An actual HTTP dry run created a proposed diagram and applied `RelocateNode` into it. The candidate moved the intact placement; the accepted model and revision stayed unchanged. +6. `GET .../source` exported canonical source. Reimporting it into a separate hosted resource produced an exactly equal model, including all persistent identity and artifact metadata. +7. The server log contained only the expected startup line, with no runtime error. The verification process was terminated after requests completed; this is not a new graceful-shutdown claim. + +| Property | Verified value | +|---|---| +| Hosted resource | `6e6204f4-6def-43b7-a7db-1c09e93c5775` | +| Hosted revision | `3` | +| Model UUID | `e0242a95-142d-4a44-8f27-1afd019e879b` | +| Project name | `Coastal rescue - API integration review` | +| SA function alias | `classify_incident_and_alert_operator` | +| SA function UUID | `b8a17e7e-d94b-429d-9899-5c1eca899efa` | +| SA function name | `Classify incident and coordinate rescue (API review)` | +| Artifact alias | `console_enclosure_cad` | +| Artifact UUID | `7a4eb9a9-6951-4d6a-aaea-fcf760bb8241` | +| Artifact URI | `cad/console-enclosure-reviewed.step` | +| Artifact revision | `CAD-REV-B-api-review` | +| Retained model contents | 39 elements, 23 relations, 3 diagrams, 10 traces, 2 artifacts | + +GUI-ready source: `D:/CodexBuilds/Capella2-v2-01a06e2e/api-edited-demo.arcadia`. + +Reproducible transfer script and structured result: `D:/CodexBuilds/Capella2-v2-01a06e2e/api-transfer.py` and `api-transfer-result.json`. Isolated store/log/result directory: `D:/CodexBuilds/Capella2-v2-01a06e2e/api-transfer-50d2b0c1-0035-4060-8afc-b37a9d4aa1a4`. + +## Lead's native GUI verification + +The lead reported personally opening the exported `.arcadia` through the Windows file picker. The actual running application displayed the changed project title, the API-renamed SA function with its preserved alias, and the CAD URI/revision in Traceability. Counts remained 39 elements and 23 relationships, with one expected review-drift warning caused by the independently renamed traced SA function. This closes the REST-export-to-native-desktop portion of the transfer. Further native editing/save/reopen is being performed by the lead and must be recorded after its result is observed. + +## Reproducible build commands + +The ordinary repository commands require no D: cache convention: + +```sh +cargo test -p oa-server --locked +cargo clippy -p oa-server --all-targets --locked -- -D warnings +cargo build --release --locked -p oa-server +cargo run --locked -p oa-server --bin arcadia-server +``` + +This worker used the pre-existing D: build target to share available dependencies. Windows native tests/release are this pass's evidence. Previous native Linux service evidence remains in `docs/server.md`; this pass did not repeat Linux tests, Docker, a remote deployment or systemd installation. Docker was not retried because no daemon was available. Overall desktop workflow, package and multi-platform acceptance remains the lead's responsibility. diff --git a/harness/review-v2-correctness.md b/harness/review-v2-correctness.md new file mode 100644 index 00000000..7bb25db2 --- /dev/null +++ b/harness/review-v2-correctness.md @@ -0,0 +1,67 @@ +# Independent V2 correctness review + +Review date: 2026-09-05. Read-only review of current core, text/persistence and desktop state flow against PLAN and language.rst; no Computer Use (separate UI reviewer owns it). Implementation-owner conclusions were not treated as proof. Review remains open until the findings below are independently reverified. + +## Evidence actually run + +- `cargo test -p oa-core --locked --offline` on Windows, D: target: 41 tests pass (12 language, 1 process diagnostics, 28 workflows). Existing tests do not cover the counterexamples below. +- Standalone executable probe outside the repository: `D:/CodexBuilds/Capella2-v2-01a06e2e/correctness-review/src/main.rs`, with Cargo manifest in its parent. Run using `cargo run --manifest-path D:/CodexBuilds/Capella2-v2-01a06e2e/correctness-review/Cargo.toml --offline` and the same D: target. It reproduces findings C1–C4 without modifying repository source. + +## Material findings + +### C1 — High: reviewed Keep Target value is overwritten on unchanged re-preview + +Location: `crates/oa-core/src/transitions.rs`, field reconciliation around `let safe` (line 634 at review). + +Reproduction: create OA Activity named `Operational assessment` and SA Function named `System classification`. Explicitly link them with Name = KeepTarget; apply. Without changing either object, preview the same OA source again with defaults. + +Observed: no conflicts, action Update, Name before `System classification` -> `Operational assessment`. Apply overwrites the value the engineer just chose to preserve. The safe test checks only the destination against its baseline; it never compares the source against its own baseline. The existing conflict test stops immediately after KeepTarget and misses the second preview. + +Expected: unchanged reviewed divergent values remain unchanged in either direction. Only a source change should trigger normal propagation; genuine subsequent competing changes should still conflict. Root assigned a worker fix and focused regressions; independent re-probe pending. + +### C2 — High: normal Save overwrites external edits without a conflict + +Location: `crates/oa-core/src/persistence.rs` unconditional atomic replacement, and desktop save/load snapshot flow. + +Reproduction: save/open a model; change the disk model name externally to `External update to preserve`; change only the loaded in-memory description; save loaded snapshot to the original path. + +Observed before fix: save returns success; external name is reverted to `Untitled mission`. This is ordinary Git/text-editor/second-instance use of the requested text-first workflow. Atomic replacement prevents partial files, but does not prevent lost updates. The language spec originally excluded conflict resolution from the storage contract; PLAN nevertheless explicitly requires file-conflict checks. Root accepted this as an application safety requirement and is adding a checked save path. Independent verification must exercise the desktop guarded path; the explicit low-level overwrite helper may remain available. + +### C3 — Medium: same-parent Reparent changes behavior ordering + +Location: `crates/oa-core/src/session.rs`, `Command::Reparent` (line 420 at review). + +Reproduction: create a process with two distinct occurrences, then execute Reparent on its first occurrence with the exact existing parent ID. + +Observed: `[first, second]` becomes `[second, first]`; revision 2 becomes 3. Unconditional detach/attach makes an idempotent ownership update silently alter sequence semantics. Fragment branches use the same mechanism and also require coverage. + +Expected: unchanged ownership preserves order, history and revision. Root assigned worker fix; independent re-probe pending. + +### C4 — Medium: primary child-creation control cannot populate a fragment + +Location: `crates/oa-desktop/src/app.rs`, `add_element` (line 1095 at review). + +Reproduction: select an Alternative Fragment occurrence, select Occurrence kind, then use `+ In selected`. The enabled state comes from `can_parent`, which returns true. Desktop constructs `make_element` followed by `CreateElement`, yielding the default Exchange occurrence under the fragment. + +Observed via the exact core candidate path: `Err(ModelError("occurrence_owner: Nested occurrences belong to branches; branches belong to fragments."))`. The existing core AddElement command correctly creates Branch for this parent and succeeds. Thus the primary GUI workflow disagrees with its enabled state and shared command behavior; a multi-field manual workaround is not an adequate primary flow. + +Expected: create an empty Branch under Fragment, and ordinary occurrences under Branch. Root notified; runtime confirmation and focused reducer regression remain required. + +## Extension of a previously known finding + +Root's same-kind data-reset fix also applies to the constructor pickers in `forms.rs`, not only ElementKind/RelationKind. The picker determines its selected entry using `same_variant`, but emits a fresh default payload even when the selected constructor is unchanged. Re-selecting Enumeration erases literals; Named data type clears the referenced type to nil; Exchange occurrence clears exchange/sender/receiver. Root notified; treat this as part of the existing same-kind fix rather than a duplicate independent issue. + +## Review limits + +No claims about native rendering, pointer behavior, Linux execution, service HTTP routes, or finished requirements coverage are made here. These belong to the parallel reviews. No additional blocking reference-cleanup or text round-trip defect was found in the reviewed paths; this does not supersede the findings above. + +## Focused re-probe during integration + +The final standalone probe was rebuilt and run again after the workers' current changes became visible (exit 0). Observed results: + +- C1: unchanged re-preview now reports `Reuse`, empty fields, no conflicts; the SA name remains `System classification`. +- C2: `save_checked` with the exact bytes originally read returns `Err(Conflict)` after an external name edit; an assertion verifies the external name survives. The explicit low-level `save` still overwrites by design. Desktop wiring and its user-facing conflict workflow still need runtime verification. +- C3: unchanged-parent Reparent now preserves `[first, second]` and revision remains `2 -> 2`. +- C4: the exact make_element/CreateElement path now succeeds, as does core AddElement. GUI child creation still needs runtime verification. + +These independent probes confirm the originally reported core counterexamples are fixed in this snapshot. The implementation worker's broader new regressions and the desktop constructor-picker fix still require the final focused review once source stabilizes. Per root request, this review turn is ending to free an implementation slot; it does not claim final application acceptance. diff --git a/harness/review-v2-final-regressions.md b/harness/review-v2-final-regressions.md new file mode 100644 index 00000000..1b40ae67 --- /dev/null +++ b/harness/review-v2-final-regressions.md @@ -0,0 +1,24 @@ +# Independent review of final desktop regressions + +Reviewer: v2_ui_runtime_review. Scope: latest root-owned `App::apply`, `selected_behavior` StateTransition selection and the SelectNode-before-MoveNode regression. Read-only source review; no builds, tests or Computer Use were run in this pass. Forms implementation was explicitly excluded because this reviewer authored it. + +**Result: no material correctness or maintainability finding in the reviewed changes.** + +- `App::apply` adopts identity, active diagram, selection and layer only after the core transaction succeeds. Failed commands return before navigation mutation and retain the draft. For a selected placement, searching the committed diagram map finds its actual owner after relocation or UUID change, then sets `layer`, `active_diagram` and `selected_node` together. For an edited diagram, the same lookup adopts its committed layer and clears node selection. Subsequent `sync` retains that valid same-layer diagram and normalizes creation kind. This handles an OA element placed in an SA traceability diagram using the placement owner's perspective rather than the semantic element's perspective. +- The placement regression now sends the real canvas message sequence: SelectNode on press, then MoveNode on release. SelectNode's early return for the already selected placement deliberately keeps its identity draft pending. MoveNode records the original selected identity, applies once, and remaps the old node ID to the committed selection before the move. `DiagramCanvas::update` was inspected to confirm this event order. The test also checks that editing diagram identity before Arrange keeps the new active diagram. +- `selected_behavior` resolves a selected StateTransition to its source state before traversing semantic parents. A valid transition source is an existing state in its machine; core validation rejects dangling endpoints and containment cycles before models enter Session. The traversal therefore terminates at the StateMachine without falling back to an unrelated first scenario. The focused regression checks source and target belong to that returned machine. Ordinary sequence, occurrence, region and state traversal remains unchanged. +- The added placement relocation test checks actual model ownership, active diagram, selected node and layer after cross-layer traceability relocation, then checks a diagram perspective edit. These assertions cover the new navigation contract; no new abstraction or duplicate model state was introduced. + +This source review is evidence for the narrowly scoped fixes, not a substitute for root's pending final automated and native runtime verification. + +## Follow-up: merged-text focus stability + +Read-only review of root's `workflow.rs` fix after the native Linux typing failure. No build, test or CUA in this pass. + +The fix addresses the reported widget identity loss: changes and general conflicts now each occupy one unconditional outer column slot. Resolving a field may change their contents, but no longer shifts the later input's sibling index. Field cards use a sorted union of current conflicts and retained resolutions, so resolving the first field does not remove its card or reorder another focused card. Core planning retains a `TransitionChange` for reuse as well as update, so the mapping-card source set stays stable during merged-value typing. Each resolution card has a fixed child structure, and its text input reads the retained `FieldDecision::Value` for the same `(source, field)` key. No remaining focus-identity defect was found for consecutive Resolve events. Card geometry can still move when preview text wraps; continuous native typing/retyping still needs observation after rebuilding. + +One existing decision-identity hazard remains outside that focus fix: `Event::MappingTarget` and `MappingKind` change the target mapping but retain earlier field resolutions for the same source. Enter merged text for counterpart A, then choose counterpart B: the old Value decision is reused when replanning B, without requiring a new resolution. Clearing that source's resolutions when its target/kind actually changes would prevent a reviewed decision from carrying into a different target context; selecting the same mapping should preserve it. Reported to root as a concrete follow-up, not as a regression caused by the stable-column change. + +## Final disposition + +The reported decision-identity hazard is fixed: resolutions now belong to the effective target/kind. The focused regression covers retargeting, same-target preservation, creation and kind changes. The separate requirements reviewer inspected the final fix and found no new material issue (`review-v2-final-ui.md`). The lead personally typed continuously, changed counterparts, observed the old decision clear and Apply block, then explicitly reviewed and committed the new values on the rebuilt native Linux application (`verification-v2-native.md`). The pending runtime checks above are therefore closed by those separate evidence records; this reviewer does not claim to have performed them. diff --git a/harness/review-v2-final-ui.md b/harness/review-v2-final-ui.md new file mode 100644 index 00000000..14549509 --- /dev/null +++ b/harness/review-v2-final-ui.md @@ -0,0 +1,67 @@ +# Independent final native UI review + +Reviewer: requirements/architecture agent, independent of desktop implementation. +Date: 2026-09-05. Windows native Iced application operated through the Computer Use skill (`@oai/sky`). No desktop source edits. + +## Build and fixtures + +- Built current source with `cargo build --locked -p oa-desktop --bin arcadia-studio`, using `CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e`. Build passed. +- Launched distinct copy `D:/CodexBuilds/Capella2-v2-01a06e2e/final-ui-review/arcadia-studio.exe`. Closed the previous throwaway test session through its normal unsaved-changes guard. +- Rebuilt and relaunched after the state-transition selection fix, numbered transition details, and Ctrl+O/Ctrl+N wiring. Second process session: 69649, native main window 1380574. +- GUI-authored scenario fixture saved at `D:/CodexBuilds/Capella2-v2-01a06e2e/final-ui-review/branching-reviewed.arcadia`. +- Additional isolated `controls-fixture.arcadia` loaded through the native Open dialog for focused trace/placement/perspective tests. This is disposable review data. + +## Observed passes + +- OA process renders participant lifelines and a message; SA repeated-report scenario renders two participants and two distinct occurrence arrows. Clicking a message selects its occurrence, highlights it, and opens the corresponding properties. +- Through actual controls: added an occurrence, changed its kind to Fragment, created a Branch with `+ In selected`, entered guard `sea_state > 4 && operator == "Ready"`, then created an owned exchange occurrence. +- Exchange choices were filtered to compatible exchanges; sender/receiver choices were filtered to the valid lifelines. Assigned the typed exchange and both endpoints. The resulting alternative fragment, guard text, and nested message rendered; model had 42 objects, 23 links, and zero checks. +- Saved the authored branch fixture through native Save As; the unsaved marker cleared and the application reported Project saved. +- Inspected default 1440×900 client, exact minimum 1100×700 client (native Size menu), and maximized 2560-wide layout. Primary navigation and action controls remained reachable. At minimum width, horizontal scrolling inside the scenario canvas actually revealed the other lifeline. No overlapping controls observed in these workflows. +- Rebuilt state view renders three numbered routes and matching numbered detail cards with source/target, trigger, guard, and effect. Selecting route 1 (`Signal restored`) retains the state-machine view, highlights the route, and selects the correct transition properties. +- At 150% architecture zoom, the parent function and both nested children have separated title and alias lines. The enlarged parent header keeps child content clear; no title/alias overlap observed in the reviewed decomposition. +- Ctrl+O opens the guarded native file picker; the focused review fixture loads successfully. + +## Material finding and re-verification + +**F1 — fixed and personally reverified:** selecting a state-transition route previously jumped the center pane back to the first scenario while selecting the transition in the inspector. Reported to the lead; the lead changed active-behavior resolution to follow the transition source through its region to its machine. On the rebuilt executable, selecting `Signal restored` retained `Reporting operation modes`, highlighted the correct route, and showed its matching guard/effect. The numbered transition detail cards also match the rendered route numbers. + +## Remaining bounded review + +Conflict resolution values/KeepTarget/merged text; manual trace explicit target; shared data perspective editing; placement diagram/UUID editing followed by actual dragging; and saved branch fixture reopening remain to be exercised by this reviewer. Native input paused because the application was being actively manipulated between observations and Computer Use twice reported user input detected. The lead was notified; no concurrent input attempted after the pause. CUA ownership was explicitly released to the lead. The copied Windows app remains open with disposable review data; the saved branch fixture is separate. + +The lead subsequently identified a placement focus corner case by source inspection and is handling its fix and regression: moving a placement into an opposite-layer Traceability diagram must select the owning diagram's layer. This reviewer has not runtime-verified that later fix. + +This report covers personally observed Windows behavior only. It does not assert Linux runtime acceptance, complete keyboard accessibility, large-model performance, or final product completion. + +## Final bounded source follow-up + +Independently inspected the final changes in `workflow.rs`, the target/kind decision regression in `tests.rs`, and the placement artifact label in `views.rs`. No builds or native inputs were run for this follow-up; the lead owns final native verification. + +- **Merge-input focus fix:** changing preview rows and general conflicts are now contained in stable child columns. Conflict property cards include both unresolved fields and existing resolutions and sort by source identity and field, preserving their order after each replan. The input remains in the same card position while its status and preview change. No further source-level focus defect identified within this change; continuous typing still requires the lead's native confirmation. +- **Decision invalidation fix:** changing the effective counterpart clears that source's prior field decisions; choosing the same inferred or explicit counterpart preserves its draft. Moving from an existing counterpart to creation clears old decisions. Counterpart-kind changes also clear decisions while selecting the same effective kind preserves them. The inspected regression asserts retargeting restores an unresolved conflict and blocks Apply, then verifies a newly reviewed value updates only the second target. It also covers creation and kind changes. No new material issue found. +- **Placement artifact label:** artifact owners that are placements resolve through the containing diagram's node map and display `Placement of · `; other owners retain the existing semantic-object name path. This handles valid placement references without falsely labeling them as missing. No new material issue found. + +These are source-review dispositions, not claims that the later fixes were personally exercised by this reviewer. + +## Packaged Windows creation-placement acceptance + +Built the final frozen source with `cargo build --release --workspace --locked` using the D: target directory; build passed. Copied both release executables to `dist/ArcadiaStudio-Windows` and launched that packaged `arcadia-studio.exe` through its normal entry point. + +Personally exercised on the actual packaged Windows application: + +1. Started the fresh 39-object demo, selected SA Architecture, and observed `SA functional decomposition`: one parent function and two child function placements. +2. Opened Behavior & scenarios, selected `Repeated distress reports`, clicked `+ Occurrence`, changed the new occurrence to Action, and named it `Review response readiness`. +3. Assigned `Classify incident and alert operator` and its compatible `Coordination system lifeline`. Committed the properties. The action rendered on the coordination lifeline after the two existing report messages; the model showed 40 objects, 23 links, and zero checks. +4. Returned to Architecture. The same three function placements remained; no occurrence card was added. +5. Used native Save As to write `D:/CodexBuilds/Capella2-v2-01a06e2e/final-ui-review/packaged-behavior-placement-reviewed.arcadia`, reopened it through Ctrl+O and the native file picker, and rechecked both the unchanged three-card decomposition and the rendered saved action. +6. Closed the packaged app normally with Alt+F4. A fresh native window inventory confirmed that it had closed. CUA ownership was released to the lead. + +The saved source independently confirms `@new_occurrence` is an Action owned by `@repeated_distress_reports`, belongs to its ordered steps, refers to the selected function/lifeline, and has no architecture placement. + +Source review found one shared `self.page == Page::Diagram` guard in `App::add_element`, used by both `Message::Add` and `Message::AddOwned`. It confines implicit placement to Architecture while retaining semantic creation/step updates on other pages. Explicit `PlaceSelected` remains available. This is simpler than duplicating guards in both message branches. The worker's new regression was inspected, but its execution is recorded by the worker/lead rather than claimed here. No new material finding in this bounded review. + +Final packaged Windows SHA-256: + +- `arcadia-studio.exe`: `E2AEC2AC935E270C6185729CBB76F8323B381654C04DBDE7AAE90C4628A98A20` +- `arcadia-server.exe`: `D33EDAE56308035781856AE54358DEF59818CC93242380BC29C874F7DC7C53E4` diff --git a/harness/review-v2-requirements-architecture.md b/harness/review-v2-requirements-architecture.md new file mode 100644 index 00000000..8c8d1676 --- /dev/null +++ b/harness/review-v2-requirements-architecture.md @@ -0,0 +1,76 @@ +# V2 independent requirements and architecture review + +Date: 2026-09-05. Reviewer: fresh `v2_requirements_arch_review` agent. Scope: user objective, PLAN/STATE, normative `docs/language.rst`, current core/desktop/service implementation, research and deployment docs. Source was read-only; no Computer Use was performed because the lead owns the desktop. This is not a substitute for the separate runtime acceptance pass. + +## Latest disposition after integration + +R1–R7 are addressed in the current implementation/contract. Independently re-read the changed code: conflict cards now identify the object pair/field and both values, keep resolution controls after selection, accept merged text and display warning messages; the source caption explains comment loss; the role rule explicitly describes its advisory warning; and root StateMachine choices agree with incomplete-model validation. The core now exports counterpart-kind choices rather than requiring a divergent desktop policy. + +An independent executable rerun confirms the offered Capability→Mission mapping has no conflicts. `cargo test -p oa-desktop --locked forms::` passed all six focused form tests, including placement relocation plus UUID change with one-step undo, shared perspective movement preserving payload, incompatible-dependency rejection and filtered references. The README V2 source example was also parsed successfully. The Branch constructor fix remains independently verified. + +No unresolved source-level blocking requirements or architecture finding remains from this review. Native interaction/visual acceptance of the updated conflict and membership controls belongs to the lead's runtime pass; this reviewer does not infer it from passing reducer tests. The original findings below remain as the audit trail, with their initial pending language superseded by this disposition. Manual desktop/API transfer is now documented and was exercised over real HTTP; the lead confirmed the exported changes in the native GUI, recorded in `review-v2-api-transfer.md`. + +## Findings requiring disposition + +### R1 — P1: counterpart conflicts cannot be meaningfully reviewed in the dialog + +`crates/oa-desktop/src/workflow.rs`, conflict rendering around line 450, displays only the generic conflict message plus **Keep target / Take source**. It does not identify the source, target, field, or either value. Conflicted fields are omitted from `plan.changes`, so the separate change list does not supply the missing comparison. Two simultaneous Name/Description conflicts are indistinguishable. This undermines the primary reviewed bidirectional update workflow and can lead an engineer to overwrite the wrong property. Show labeled source/target values and field identity, with the existing core merged-value decision if practical. Also display actual warning messages; the present dialog shows only their count. + +Verification: independently traced `plan_transition` conflict generation and `Workflow::view`; reported promptly to lead. Pending implementation and actual GUI verification. + +### R2 — P1: advertised capability/mission transitions are rejected by core + +`workflow::counterpart_kinds` offers OA Capability -> SA Mission and SA Mission -> OA Capability. `transitions::compatible_kinds` only supports Capability <-> SystemCapability, participant choices, and inferred same-kind shared mappings. Choosing Mission therefore yields an unrecoverable automatic-mapping conflict. Implement the offered method mapping (a Manual trace can express the special correspondence), or remove unsupported menu choices and disclose scope consistently. + +Verification: external executable probe constructed an OA Capability and an explicit Mission mapping; `plan_transition` returned `Counterpart kind is not valid for this automatic transition.` Probe is at `D:/CodexBuilds/Capella2-v2-01a06e2e/requirements-review`, outside repository sources. Pending fix/reverification. + +### R3 — P2: two stored membership choices lack direct graphical editing + +- `forms::Draft::from_project` stores a Node's containing diagram in `RecordKind::Node(diagram)` out of band. `Draft::command` always edits the node within that diagram. There is no graphical destination-diagram property or move-placement operation. In source, changing the node's `diagram @alias` preserves node UUID and artifact references while moving its membership. +- Shared elements derive perspective from the Oa/Sa body wrapper. The inspector offers kinds only in the current layer and hides those wrappers. A source edit can move an isolated shared element to the opposite layer while preserving its identity, but no corresponding graphical operation is exposed. + +The normative contract explicitly promises every persistent property in both editing forms. Add explicit validated move operations, preserving references and identity. This is about stored membership parity, not automatic cross-layer realization or silent drag semantics. Pending disposition. + +### R4 — P2: source editor does not explain comment loss + +`docs/language.rst` section 7 requires the editor to explain that canonical Apply discards comments/whitespace. Neither the Text workspace caption nor the Engineering guide currently mentions this. A short source caption is sufficient; comments are especially plausible for the promised human/AI/Git authoring workflow. Pending UI copy fix. + +### R5 — P2: normative role-allocation rule disagrees with diagnostic severity + +Section 4 says a direct allocation must not contradict role-realizing performers. `validation::validate_relation` reports `role_allocation_conflict` as a Warning, allowing the candidate to commit/save. Advisory diagnosis can be a reasonable authoring decision, but specify it as such instead of an unconditional prohibition. Pending contract or behavior alignment. + +### R6 — P2: state-machine parent-choice predicate excludes a valid incomplete model + +`validation::can_parent(... StateMachine, None)` returns false, disabling **At root** and potentially hiding a valid parent choice from filtered forms. Normative section 4 and `validate` permit an unowned machine with a warning. Align the creation/reference predicate with that explicit policy. Pending fix. + +## Finding already fixed during review + +R7 — Initial Branch creation under a Fragment previously used `make_element`'s default Exchange body in the desktop, while only `Command::AddElement` converted it to Branch. The UI enabled **In selected**, then validation rejected creation before the user could change the variant. The lead/core worker moved the context-sensitive construction into `Project::make_element` during this review. The independent executable probe now confirms both the desktop-style `CreateElement(make_element(...))` and `AddElement` accept a Branch under a Fragment. Actual scenario UI authoring must still be exercised after integration. + +This fix is also a concrete architecture improvement: one context-aware constructor avoids desktop/core command divergence. + +## Coverage and scope assessment + +- Core exposes OA participants/roles/work/goals/processes and SA System/Actors/functions/missions/capabilities/chains, with typed relations, decomposition, leaf allocation, independent diagram placements and traces. Shared payloads cover data/items/interfaces/ports, rich scenario occurrences/fragments, state-machine regions/transitions/actions/availability and constraints. These are substantive OA/SA semantics, beyond renamed V1 enums. +- Text has a closed versioned language, stable aliases/UUIDs, typed constructors, reference resolution, canonical ordering, tolerant tokens/lint, strict validation and explicit deterministic V1 migration. The normative contract is detailed enough to assess supported field parity, but the inconsistencies above must be corrected before calling it satisfied. +- Desktop source exposes creation/editing for element/relation/diagram/placement/trace/artifact record families, ordered occurrence lists and typed variant controls. Source editing and graphical edits share the Session boundary. Behavior graphics are actively being replaced by the lead; old flat lists are a known pending milestone, not an independently verified completed behavior view. +- REST uses the same Project/Command/language/validator, with resource identity separate from editable model identity, ETag preconditions, atomic envelopes, bounded requests and a single-owner local store. Query, source, validation, transaction, transition and artifact routes exist. Deployment recipes are concrete; Docker and installed remote service execution remain unverified, as documented. +- Desktop hosted-model editing is absent: no API endpoint/open/save client exists. This is not automatically a demand for collaboration. The literal backend requirement can be met by the independent hosted model service, but final limitations must say that desktop and hosted models transfer by import/export. Acceptance should exercise desktop export -> REST import/edit/export -> desktop reopen so the claimed integration is demonstrated. +- Transition scope is a reference/ownership closure, not all related content. An independent probe confirmed **Whole subtree** with two selected activities does not include their connecting interaction. Allocation relationships and allocated work are likewise not implicit ownership children. Explain this in user-facing scope/help rather than promising a complete architectural copy. Existing-target updates deliberately compare names/descriptions only, not structural synchronization; the normative spec discloses this boundary. +- LA/PA components/deployment, simulation, native `.capella/.aird` interchange, complete SysML/Capella conformance, advanced capability include/extend/generalization, distributed collaboration and retained service history are legitimate declared prototype boundaries. This review does not request those systems as speculative extensions. + +## Architecture and simplicity assessment + +The three-crate boundary is appropriate: one renderer-independent typed core, an Iced interaction/draft layer, and a headless transport/persistence layer. Candidate validation before adoption is shared by all editing paths. UUID references and persisted aliases serve separate clear purposes. Diagrams do not own semantics. Projection reuse for canvas/Fit/SVG avoids duplicate layout meaning. Full snapshot history and whole-envelope persistence are reasonable for the explicit bounded prototype; no speculative plugin ABI/database/event framework is needed. + +The material maintainability risk is duplicated domain-choice policy in desktop helpers, evidenced by R2 and R7. Expose a small core query for permitted transition target kinds, analogous to `can_parent`/`can_connect`, rather than maintaining a separately diverging match. Keep generic JSON only as a closed temporary form adapter; future additions need a graphical reachability test, not just a lossless no-op serialization test. The existing `every_demo_record_has_a_lossless_graphical_draft` proves preservation but cannot prove that every control/action exists. + +No additional architecture blocker was found. Avoid broad module rearrangement solely for line-count/style preferences. Final README/architecture/scope/acceptance documents are still V1 and must be rewritten as the lead already plans; they currently contain false V2 statements about one diagram, transient aliases, process exchange IDs and absent roles/hierarchies. + +## Verification performed by this reviewer + +- Read the user objective and authoritative plan/contract, inspected core model/session/references/projection/transitions/validation plus graphical drafts/builders/views and service routes/store/deployment boundaries. +- Ran a standalone Rust executable against the current `oa-core`: reproduced R2; verified the integrated R7 constructor fix; established actual transition scope behavior for connected work endpoints. +- Did not run the native GUI, Linux application, full regression suite, service HTTP tests or packaging. Those are separate lead/runtime/QA responsibilities and remain required completion evidence. + +Initial review status was incomplete; the latest disposition above records the subsequent fixes and independent focused verification. Overall product completion still depends on the lead's final runtime/platform/package evidence. diff --git a/harness/review-v2-ui-runtime.md b/harness/review-v2-ui-runtime.md new file mode 100644 index 00000000..4f95d61b --- /dev/null +++ b/harness/review-v2-ui-runtime.md @@ -0,0 +1,54 @@ +# Independent V2 UI/runtime review + +Reviewer: v2_ui_runtime_review. Completed 2026-09-05 03:30 +03:00. This is a diagnostic review of a fixed integration snapshot, **not final V2 acceptance**. Root continued editing after the build; subsequent fixes need a fresh runtime pass. + +## Evidence and scope + +- Read current goal, PLAN, STATE and Computer Use skill/guidance/API/confirmations. No source files edited. +- Ran `cargo build -p oa-desktop --locked` with `CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e` and TEMP/TMP on D. Build passed. +- Copied binary to `D:/CodexBuilds/Capella2-v2-01a06e2e/ui-review/arcadia-studio.exe`; SHA256 `E54E2A6673E5D814F04D1B06BFCAA1C0F00C3DC7CA6F259AEB4472FB70244ED2`. +- Launched copied native Windows executable and operated it through `@oai/sky` using screenshot observations after each input. Target window was 1839418, title Coastal rescue — operational need to system behavior — Arcadia Studio. Original Capella was left untouched. +- Captured and personally inspected default 1440×900 client, minimum 1100×700 client and maximized 2560-wide layout. Screenshots are in the Computer Use transcript; no duplicate screenshot payloads were saved. Native menu Size → Right → Down → click lower-right target produced exact minimum size; maximize/restore worked. +- `ui-review/stderr.log` remained empty through shutdown. No crash was observed. All test edits were in the unsaved demo; close guard appeared and Discard closed only the review copy. CUA ownership is released to root. + +## Material findings + +### P1: modal allows background navigation + +Reproduction at 1100×700: Traceability → select the SA Rescue coordination system endpoint → Create/update counterpart → System → Operational modal. Click dimmed Architecture sidebar at approximately (70,206). Underlying page switches from Traceability to Architecture and sidebar highlight changes while transition modal remains open. Confirmed visually, not inferred from code. Pointer input must be captured by the overlay and background state-changing messages gated. Root was notified immediately and reports a source fix; this snapshot did not include that fix. + +### P1: behavior workspace omits primary behavioral relationships + +At both compact and wide sizes, SA Reporting operation modes shows three flat rows with identical circular glyphs: Degraded reporting, Initial Start reporting and Normal reporting. The overview does not depict transitions, their source/target, guards, initial destination, actions or availability. Repeated distress reports is two identical Validated distress report rows without visible lifelines, message sender/receiver or fragments. These are record browsers rather than usable scenario/state views. Root acknowledged and is implementing behavioral visuals. Those changes require separate review. + +### P2: automatic layer switching leaves incompatible create kind + +Reproduction: apply System → Operational counterpart update on Rescue coordination system. Application selects the OA entity and OA layer, but CREATE ELEMENT still displays Function from SA. Undo also retains this inconsistency. Explicitly clicking a layer tab later resets the selection. Normalize creation kind whenever navigation/transition changes the current layer. Root reports a source fix; not reverified in this snapshot. + +### P2: architecture edge/label overlap reduces readability + +Default 1440×900 → SA allocation → select nested Classify incident and alert operator. Straight edge portions traverse System/Actor header text; selected-edge captions including System capability support, chain and functional-exchange labels overlap around the central chain/actor region. The dense overlap persists at minimum size after Fit. Different relations should have distinguishable routes/label positions; parent/performer headers should remain legible. No routing fix was observed in this snapshot. + +### P2: property forms expose raw storage labels + +Selecting Backup electrical energy exposes consecutive Shared / ExchangeItem / Kind headings. Selecting the created Functional exchange exposes FunctionalExchange twice, before Items and Source. These duplicate raw type names add clutter and do not help engineers make a choice. Use user-facing semantic field groups. Selected catalog/sidebar rows use dark blue-gray backgrounds with gray secondary labels; contrast is visibly poor compared with unselected labels. + +### P3: narrow diagram selector clips title + +At 1100×700, current diagram selector becomes roughly 108px wide and wraps SA system boundary and functions across more text than its fixed height shows. Remaining toolbar controls fit. A compact ellipsis/tooltip or second toolbar row would communicate the chosen view more clearly. + +## Workflows actually observed passing + +- Rich 39-object/23-link demo launches with zero checks. OA participants show activity allocated within entity/actor; SA shows function inside system and external actor. Layer navigation and model selection work. +- Fit adapts graph to reduced center width; app navigation, property inspector, data list, source editor and counterpart/relationship modals remain reachable at 1100×700. Wide layout does not clip inspector or task navigation. +- Source action from Classify incident and alert operator opens its declaration. Source action from late-ID Backup electrical energy scrolls substantially down to the correct declaration. Syntax colors visibly distinguish keywords/references/strings. The target declaration can land at the bottom of the editor rather than top, but it is visible. +- Created SA Function at root; renamed it to `Review "quoted" function`; Apply saved the literal quote-bearing name while stable alias stayed `new_function`. +- Created Functional exchange through explicit modal, source new function, chosen compatible target Classify incident and alert operator, name Review outcome. Apply committed it, selected the relation and showed the new canvas edge; link count increased 23→24. Missing target/name correctly left Apply disabled before completion. +- Reverse counterpart preview displayed OA Rescue coordination → Rescue coordination system descriptive change. Apply committed it atomically and selected OA entity. Ctrl+Z restored the name and showed Model change undone. No counterpart duplication was observed for this update. +- Artifact action created an editable reference owned by the selected Review outcome exchange. Inspector exposed owner, relation, URI, optional revision and fragment. Arbitrary URI editing/save/reopen was not completed in this bounded pass. +- Inserted `?` at source beginning. Live lint displayed `1:1 Invalid token '?'`. Apply rejected the draft, retained visible `?`, and displayed Source contains errors. Your draft is preserved. Revert restored accepted source and removed lint. Model counts remained unchanged. +- Close with unsaved edits displayed Keep your changes? with Cancel, Discard changes and Save & continue. Discard closed the temporary review application; fresh window inventory confirmed it absent. + +## Required follow-up / not claimed + +Reverify fixed overlay capture and layer-kind normalization; inspect new state/scenario views and routing at all sizes. This pass did not complete forward transition creation, conflict choices, manual trace editing, graphical decomposition editing, node/placement UUID drag remapping, exact CR/LF/tab round trip, save/reopen, artifact URI/revision edits, or native Linux runtime. Existing reducer tests are not substitutes for those real-app workflows. Initial post-input screenshots occasionally preceded the next rendered frame; fresh observations were used before declaring behavior broken. diff --git a/harness/verification-v2-linux.md b/harness/verification-v2-linux.md new file mode 100644 index 00000000..6a490292 --- /dev/null +++ b/harness/verification-v2-linux.md @@ -0,0 +1,156 @@ +# V2 native Linux verification — 2026-09-05 + +Status: **passed**, including the focused desktop refresh verified at 04:54 +Moscow time on 2026-09-05. This report covers native Linux compilation, tests, linting, and +the headless example command. It does not claim graphical runtime acceptance; +the root agent owns that separate WSLg check. + +## Environment and reproducible commands + +- Distribution: WSL `Ubuntu-24.04`, Ubuntu 24.04.2 LTS, x86_64. +- Kernel: `6.6.87.2-microsoft-standard-WSL2`. +- Native compiler: `rustc 1.98.1 (48a229cea 2026-09-01)`. +- Native Cargo: `cargo 1.98.1 (797e8a9bc 2026-08-05)`. +- Workspace: `/mnt/c/Users/sorok/OneDrive/Documents/ChatGPT/Capella 2`. +- Native Rust binaries: `/home/sorokinoleg/.cache/arcadia/rust/bin`. +- Native target cache: `/mnt/d/CodexBuilds/Capella2-01a06e2e/linux-target`. +- Vendor directory: `/mnt/d/CodexBuilds/Capella2-01a06e2e/vendor`, selected by + the existing ignored `target/linux-cargo.toml` source-replacement config. +- `Cargo.lock` SHA-256: + `eb3b55fe34920d5d9e069e2f4c80ee130eb85ec2ca12fddd7fc880b8ccfe062d`. + +Run inside this WSL distribution: + +```sh +cd '/mnt/c/Users/sorok/OneDrive/Documents/ChatGPT/Capella 2' +export PATH=/home/sorokinoleg/.cache/arcadia/rust/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export CARGO_TARGET_DIR=/mnt/d/CodexBuilds/Capella2-01a06e2e/linux-target +cargo fmt --all -- --check +cargo test --workspace --locked --offline --config target/linux-cargo.toml +cargo clippy --workspace --all-targets --locked --offline --config target/linux-cargo.toml -- -D warnings +cargo build --release -p oa-desktop -p oa-server --locked --offline --config target/linux-cargo.toml +``` + +The Windows orchestration uses `wsl.exe -d Ubuntu-24.04 --cd +--exec /usr/bin/env PATH= CARGO_TARGET_DIR= cargo ...`. +No cross-compilation target is selected: Cargo and Rust execute natively in +Ubuntu and generate Linux executables. + +## Results + +`cargo test --workspace --locked --offline --config target/linux-cargo.toml` +passed on the full-workspace baseline: **91 tests**, zero failures (incremental +test build completed in 12.05s). This includes the root agent's 64-unit nested +header allowance, canvas text width correction, counterpart diagram focus, +StateTransition behavior context, numbered transition details, diagram/layer +focus after placement relocation, and actual SelectNode-before-drag regression. + +| Suite | Passed | +| --- | ---: | +| Core language | 12 | +| Core process diagnostics | 1 | +| Core review regressions | 15 | +| Core workflows | 28 | +| Desktop reducer/property forms | 23 | +| Server actual HTTP/storage | 12 | + +The core tests include reviewed divergent transitions in both directions, +Capability/Mission mapping, same-parent sequence/branch preservation, +placement relocation/artifact survival/invalid rollback, unowned-machine +warnings, and checked external-save conflicts. Desktop tests include shared +perspective and placement ownership forms, plus counterpart creation focus +and existing-view reuse. The server suite exercises actual +HTTP, atomic writes, restart, auth, bounded bodies, and concurrent ownership. + +Native `cargo fmt --all -- --check` passed. Strict +`cargo clippy --workspace --all-targets --locked --offline --config +target/linux-cargo.toml -- -D warnings` passed on that full-workspace snapshot +(12.44s). + +The optimized desktop/server build passed (initial build 3m53s, workspace +refresh 31.69s). `file` identifies both +outputs as native x86-64 ELF PIE executables. `ldd` resolves every linked +library; runtime-loaded graphics drivers are covered by the separate GUI check. +A preliminary test invocation caught the same renamed legacy-fixture include +path that Windows caught. The root agent corrected the two language-test paths +to `examples/legacy-v1-coastal-rescue.*`; the complete rerun above passes. +No Linux-specific source changes were needed or made by this worker. + +## Focused desktop refresh after native GUI review + +Native GUI review prompted four desktop-only corrections: stable merge-input +widget placement while preview rows change, readable artifact owner labels for +placements, and clearing prior merge decisions when the effective counterpart +or target kind changes, and automatic node placement only while authoring on +the Architecture page. Behavior and Data authoring no longer inserts nodes +into an unrelated active architecture diagram. The latest source passed: + +- `cargo test -p oa-desktop --locked --offline --config target/linux-cargo.toml`: + **25 tests**, zero failures (19.29s build). The focused regressions include + `transition_resolutions_belong_to_the_effective_target_and_kind` and + `behavior_and_data_authoring_do_not_place_nodes_in_unrelated_architecture_views`. +- `cargo clippy -p oa-desktop --all-targets --locked --offline --config + target/linux-cargo.toml -- -D warnings`: passed (10.71s). +- `cargo fmt --all -- --check`: passed. +- The established locked/offline release command for desktop and server: + passed (22.40s), recompiling only `oa-desktop`. + +Core and server source were unchanged; their earlier 56 and 12 passing native +tests remain the evidence for those crates. Together with the current desktop +suite, this covers 93 tests across these native runs; a new full-workspace +93-test invocation was not performed for the desktop-only corrections. +The refreshed desktop's `ldd` output still has no unresolved libraries. + +## Headless examples and release artifacts + +The native optimized desktop command below exited successfully and produced +a 29,222-byte canonical `arcadia 2` example without launching a window: + +```sh +/mnt/d/CodexBuilds/Capella2-01a06e2e/linux-target/release/arcadia-studio --write-example /mnt/d/CodexBuilds/Capella2-v2-01a06e2e/linux-verification/linux-demo.arcadia +``` + +Before the focused UI-only refresh, the headless example was checked by a temporary native Rust assertion +program linked to the current `oa-core`. It confirmed: + +- Packaged `examples/coastal-rescue.arcadia` and `examples/coastal-rescue.json` + deserialize to equal projects, with zero validation checks. +- Native-generated source exactly equals canonical formatting of its parsed + model, reparses to an equal project, and equals its saved/reloaded JSON. +- The native-generated example has zero validation checks: 39 elements, + 23 relations, three diagrams, ten traces, and two artifact links. + +The assertion source and executable are `verify_examples.rs` and +`verify_examples` in the evidence directory below. Their assertions compare +each example's representations; independently generated demo instances have +fresh identities and are not claimed byte-identical to one another. + +Final native binaries are in +`D:/CodexBuilds/Capella2-01a06e2e/linux-target/release/` (the same directory is +`/mnt/d/CodexBuilds/Capella2-01a06e2e/linux-target/release/` inside Ubuntu): + +| Binary | Bytes | SHA-256 | +| --- | ---: | --- | +| `arcadia-studio` | 23,344,152 | `df88c9dd2f4759c95514c32c0c50ad225642fff085b67162e6bebafafad01b04` | +| `arcadia-server` | 7,180,464 | `0df8a6eaaa9bb548669cf38d9b7e452d11171affe075652b9d561ddef2281814` | + +Raw logs are stored outside the source tree at +`D:/CodexBuilds/Capella2-v2-01a06e2e/linux-verification/`. +The full-workspace baseline logs are `release-ready-fmt.log`, +`release-ready-tests.log`, `release-ready-clippy.log`, +`release-ready-build.log`, `release-ready-examples.log`, and the two +`release-ready-ldd-*.log` files. The latest focused desktop logs are +`context-fix-desktop-tests.log`, `context-fix-desktop-clippy.log`, +`context-fix-fmt.log`, `context-fix-release-build.log`, and +`context-fix-ldd-studio.log`. Other earlier logs cover superseded snapshots. + +## Scope and refresh + +Only this report was edited by the Linux verification worker. The root agent +declared the tested source stable before the 91-test workspace pass and before +the focused 25-test desktop/Clippy/release refresh. +No GUI was launched and no server daemon was started by this worker. Later +source changes require another incremental refresh before packaging. + +The root agent received the native release-ready path immediately after the +build completed, before hashes/report work, for its separate WSLg GUI run. diff --git a/harness/verification-v2-native.md b/harness/verification-v2-native.md new file mode 100644 index 00000000..6d81f728 --- /dev/null +++ b/harness/verification-v2-native.md @@ -0,0 +1,37 @@ +# Lead native acceptance — 2026-09-05 + +The lead personally operated the Windows Iced application and three packaged native Linux releases through the Computer Use skill (`@oai/sky`). This record supplements the independent Windows reports, automated Linux report and API transfer report. It records observed behavior, including unsuccessful attempts, rather than inferring GUI success from tests. + +## Windows + +- Operated repaired Capella 7.0.1 in an isolated imported IFE example: OA/SA nesting, semantic browser/properties and Functional Transition comparison. Cancelled that preview; the original model was not transitioned. Findings informed `docs/research-v2-ui.md`. +- In Studio, inspected syntax colors, inserted invalid `?`, observed located live lint, rejected Apply with draft/model retained, then recovered with Revert. Navigated OA processes, SA chains/scenarios and state views; selected the second repeated message occurrence and confirmed its inspector/highlight. +- Opened the actual REST-exported `api-edited-demo.arcadia` through the native picker. Project/function name and alias, CAD URI and revision reflected the API edits. Edited CAD revision to `CAD-REV-C-native-review`, used Ctrl+S and verified persisted source. After an external comment append, Ctrl+S refused overwrite and retained the external bytes. +- New produced an empty model. Created a view and `Assess sea conditions` OA activity, previewed/created its SA Function, and observed target-view focus and creation-kind normalization. Preview blocked background Text navigation and Ctrl+Z. Explicit reverse creation and single-step Undo preserved the other layer. +- Found and fixed 150% nested-caption overlap and counterpart focus. The fresh reviewer personally reverified rebuilt nested typography, state-transition selection, minimum/default/wide layouts, fragment/branch creation and saved action rendering; see `review-v2-final-ui.md`. +- Launched the staged Windows release directly and inspected the default 1440×900 zero-check demo, then closed normally. The fresh reviewer subsequently rebuilt the final release, operated that exact packaged executable, created/saved/reopened an Action and confirmed Architecture retained its original three cards. Final binary hashes are in that report. + +## Native Ubuntu 24.04 / WSLg + +Both sessions used the staged Linux executable with `WGPU_BACKEND=vulkan` and `VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json`. The client rendered at 1440×900 with Mesa software Vulkan. The remote window's `[WARN:COPY MODE]` title is a compositor label; application stderr was empty and both processes exited 0 on normal close (sessions 58001 and 57111). + +The native Open path dialog loaded the isolated `controls-fixture.arcadia` used by Windows review. The lead then: + +1. Changed `Independent energy` from OA to SA through its graphical engineering-perspective control. The saved record preserved Energy kind and UUID `20000000-0000-4000-8000-000000000004`. +2. Opened Manual trace. Apply remained disabled until explicitly choosing `Other operational need`; committing created a trace with canonical OA/SA endpoints and did not create another semantic element. +3. Changed a Traceability diagram's layer SA→OA→SA and confirmed that active diagram and editing perspective followed the committed owner. +4. Changed a function placement UUID to `60000000-0000-4000-8000-000000000099` and dragged the placement. The first gesture selected the parent without movement; the subsequent actual function drag succeeded. Saved source confirmed the new UUID and position `(90,250)`, size `(224,94)`. The automated regression separately sends the real SelectNode→MoveNode sequence with the pending UUID draft. +5. Relocated OA `work_card` into the SA Traceability diagram through Placement properties. The UI selected the destination diagram and SA context; saved source preserved placement identity and its artifact owner. +6. Saved through Ctrl+S, closed, refreshed the executable and reopened the saved fixture. All edits persisted. The artifact card correctly displayed `Placement of OA "Rescue" updated · SA existing architecture`. +7. Exercised the actual counterpart conflict dialog showing source/target names and current/proposed field values. Initial continuous typing exposed lost input focus during replanning; fixed stable widget grouping/order and rebuilt before repeating. +8. In the rebuilt dialog, typed successive name fragments and punctuation without refocusing; all remained in the same input. Changed counterpart to `Other System Service`: the previous merged value cleared and Apply became disabled pending review. Returning to the original counterpart did not revive the old decision. +9. Entered `Reviewed rescue function`, chose **Keep target** for description, and applied with no unresolved decisions. Saved source confirmed that name and the retained description `System design deliberately changed.`. Traceability displayed Reviewed. Reverse preview reused the counterpart with no field proposals or conflicts, then was cancelled. +10. Reopened the graphical `branching-reviewed.arcadia` fixture. Behavior rendered two repeated messages plus an Alternative fragment, a branch with guard `sea_state > 4 && operator == "Ready"`, and its nested validated-report message. The fixture showed 42 elements, 23 relationships and zero checks. + +The last fixture also exposed unwanted Architecture placements from Behavior creation. The fix confines automatic placement to the Architecture page; Behavior/Data still create semantic records. A meaningful regression checks Fragment→Branch→Exchange ordering, Data creation and exact preservation of every diagram, plus ordinary Architecture placement. The fresh independent reviewer verified the final Windows packaged Action/create/save/reopen workflow and unchanged architecture. + +After Linux compilation/tests/release were refreshed, the lead launched that exact final staged executable (SHA-256 `df88c9dd2f4759c95514c32c0c50ad225642fff085b67162e6bebafafad01b04`). Ctrl+O opened the Windows-authored `packaged-behavior-placement-reviewed.arcadia`. SA Architecture showed the three original nested function cards at 150%, with no occurrence placement. Behavior → Repeated distress reports rendered the saved `Review response readiness` Action on the coordination lifeline after both messages. The model had 40 objects,23 links,zero checks and all changes saved. Normal Alt+F4 closed the process (session74334,exit0,empty stderr); a fresh window inventory confirmed closure. Creation itself was exercised in Windows, with this final Linux pass verifying cross-platform reopening/rendering. + +## Boundaries + +No test server or Studio acceptance window is intentionally left running. Raw fixtures/logs are outside source under `D:/CodexBuilds/Capella2-v2-01a06e2e/`. Reports retain the evidence needed to understand them without that directory. Native Linux runs cover this Ubuntu/WSLg/Mesa environment, not all graphics drivers. Remote CI, Docker execution/remote installation, actual Rust 1.90, assistive-technology certification and production-scale performance were not exercised. diff --git a/harness/verification-v2-packages.md b/harness/verification-v2-packages.md new file mode 100644 index 00000000..e05691e5 --- /dev/null +++ b/harness/verification-v2-packages.md @@ -0,0 +1,26 @@ +# V2 release package verification — 2026-09-05 + +Release: Arcadia Studio 0.2.0, native x86_64 Windows and Linux. The Linux build targets the tested Ubuntu 24.04.2 runtime. Both final desktop executables were launched from their staging bundles and operated through Computer Use; their normal closes completed without application stderr. The Linux final pass reopened the Windows-authored Action fixture. See `verification-v2-native.md` and `review-v2-final-ui.md`. + +## Outputs + +- `dist/ArcadiaStudio-Windows.zip` +- `dist/ArcadiaStudio-Linux.tar.gz` +- `dist/SHA256SUMS.txt` — SHA-256 of the final archives (kept outside them to avoid a self-referential hash). + +Each bundle contains the two platform executables, START-HERE, README, LICENSE, all current docs (including both rendered/source reST contracts and OpenAPI), examples, deployment recipes, and V2 harness state/review/verification records. It is a binary bundle; repository source is required for the documented Cargo/Docker build commands. Historical V1 release executables are absent. Explicitly named legacy examples are retained for migration. + +## Verification + +The archive builder checks every staged regular file against its repository source or final build output before assembling. It reads and SHA-256 compares every archived regular file against staging, checks duplicate names and required contents, and checks ZIP CRC integrity. Linux tar headers use executable mode 0755 for binaries/directories and 0644 for other files, with root ownership metadata. No extraction or elevated installation is required for these integrity checks. + +An initial staging check correctly rejected outdated copies of `server.md`/`validation.md` after the independent reviewer improved their quickstart/evidence attribution. Those files were recopied; assembly and full inventory verification passed. The final pass includes this package record and finalized PLAN/STATE and repeats the complete comparison. Raw structured results are at `D:/CodexBuilds/Capella2-v2-01a06e2e/release-packages.json`; archive hashes are available directly in dist. + +| Platform binary | SHA-256 | +|---|---| +| Windows desktop | `e2aec2ac935e270c6185729cbb76f8323b381654c04dbde7aae90c4628a98a20` | +| Windows server | `d33edae56308035781856ae54358def59818cc93242380bc29c874f7dc7c53e4` | +| Linux desktop | `df88c9dd2f4759c95514c32c0c50ad225642fff085b67162e6bebafafad01b04` | +| Linux server | `0df8a6eaaa9bb548669cf38d9b7e452d11171affe075652b9d561ddef2281814` | + +The binaries in both archives equal these final release outputs. Earlier service process checks still apply because both server binaries are unchanged. This verification does not claim signed installers, a fresh-machine runtime installation, broad Linux distribution/GPU compatibility, Docker execution or remote deployment. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..73cb934d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"]