diff --git a/.github/workflows/runtime-build.yml b/.github/workflows/runtime-build.yml new file mode 100644 index 000000000..0e908f9d1 --- /dev/null +++ b/.github/workflows/runtime-build.yml @@ -0,0 +1,148 @@ +name: Runtime Build + +# V1.153 P2 T2 (DF-73): native multi-platform build + packaging for the +# headless `nexus-runtime` binary (spoke-connect only, no embedded SPA). +# +# Each matrix leg builds natively on its host runner: +# windows-x64 → windows-latest / x86_64-pc-windows-msvc +# macos-arm64 → macos-latest / aarch64-apple-darwin +# linux-x64 → ubuntu-latest / x86_64-unknown-linux-gnu +# with `--no-default-features --features connect-host` (web-embed OFF → no +# `apps/web/dist` bytes; connect-host ON → the P1 N-C1 invoke surface), then +# packages `nexus-runtime--.zip` + `.sha256`, uploads them, and +# smoke-tests `--version` on the runner itself. +# +# Trigger set (locked by Clarify #3): `workflow_dispatch` + tag +# `runtime-v*` ONLY. Note: `workflow_dispatch` is only offered once this +# file is on the default branch; release-channel automation polish is E2+. + +on: + workflow_dispatch: + push: + tags: + - 'runtime-v*' + +concurrency: + group: runtime-build-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + SQLX_OFFLINE: "true" + # Cap target/ incremental growth on ephemeral runners (same ENOSPC + # mitigation rationale as ci.yml). + CARGO_INCREMENTAL: "0" + +jobs: + runtime-build: + name: ${{ matrix.slug }} + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - slug: windows-x64 + os: windows-latest + target: x86_64-pc-windows-msvc + bin: nexus-runtime.exe + - slug: macos-arm64 + os: macos-latest + target: aarch64-apple-darwin + bin: nexus-runtime + - slug: linux-x64 + os: ubuntu-latest + target: x86_64-unknown-linux-gnu + bin: nexus-runtime + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + # Heavy Rust job convention (ci.yml): reclaim ubuntu disk before the + # toolchain/cache restore. Windows/macOS runners carry enough free + # space for this single-bin release graph. + - uses: ./.github/actions/free-disk-space + if: runner.os == 'Linux' + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + # wasm32-unknown-unknown: nexus-wasm-host's build.rs compiles the + # embedded modules from source (modules/basic-combat) — it is in the + # slim runtime graph too (via nexus-orchestration / -daemon-runtime / + # -spoke-adapter). The host triple is installed by default; the + # explicit `--target` below keeps the locked triple load-bearing. + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: nexus-runtime + workspaces: . + + - name: Build nexus-runtime (release, native ${{ matrix.target }}) + # Single line on purpose: the backslash continuation is bash-only and + # breaks under the Windows runner's default pwsh shell. + run: cargo build --release --target ${{ matrix.target }} --bin nexus-runtime --no-default-features --features connect-host + + - name: Smoke test --version + if: runner.os == 'Windows' + shell: pwsh + run: | + $out = & "target/${{ matrix.target }}/release/${{ matrix.bin }}" --version + Write-Output $out + if ($out -notmatch '^nexus-runtime ') { + Write-Error "unexpected --version output: $out" + exit 1 + } + + - name: Smoke test --version + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + out="$(./target/${{ matrix.target }}/release/${{ matrix.bin }} --version)" + echo "$out" + case "$out" in + nexus-runtime\ *) ;; + *) + echo "::error::unexpected --version output: $out" + exit 1 + ;; + esac + + - name: Package zip + sha256 (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $zip_name = "nexus-runtime-${{ matrix.slug }}.zip" + # Single-file Compress-Archive stores the binary at the zip root. + Compress-Archive -Path "target/${{ matrix.target }}/release/${{ matrix.bin }}" -DestinationPath $zip_name -Force + $hash = (Get-FileHash -Algorithm SHA256 -Path $zip_name).Hash.ToLower() + # sha256sum-style line: " " + Set-Content -Path "$zip_name.sha256" -Value "$hash $zip_name" -NoNewline -Encoding ascii + Get-Content "$zip_name.sha256" + + - name: Package zip + sha256 (unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + zip_name="nexus-runtime-${{ matrix.slug }}.zip" + # -j (junk paths): store the binary at the zip root. + zip -j "$zip_name" "target/${{ matrix.target }}/release/${{ matrix.bin }}" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$zip_name" > "$zip_name.sha256" + else + shasum -a 256 "$zip_name" > "$zip_name.sha256" + fi + cat "$zip_name.sha256" + + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: nexus-runtime-${{ matrix.slug }} + path: | + nexus-runtime-${{ matrix.slug }}.zip + nexus-runtime-${{ matrix.slug }}.zip.sha256 + if-no-files-found: error diff --git a/.mstar/specs/spoke-adapter-architecture.md b/.mstar/specs/spoke-adapter-architecture.md index 5987ddcb5..15f897dde 100644 --- a/.mstar/specs/spoke-adapter-architecture.md +++ b/.mstar/specs/spoke-adapter-architecture.md @@ -1,6 +1,6 @@ # Spoke Adapter Architecture -> **Status:** Normative (v0.12 — V1.152 DF-77 §11 Narrative Knowledge Pack I/O: shipped P0+P1; P2 dogfood-confirmed — additive daemon export/import routes + all three conflict policies (skip/rename/overwrite) + CLI↔daemon shared `import_pack` module + Control Room panel; v0.11 — V1.151 DF-76 §7.4 inspector packet field surface (shipped P0+P1; P2 dogfood-confirmed against the spoke assemble-module recipe handbook); v0.10 — V1.150 DF-75 §7.4 slot + Moment Directive + generation-stage matrix shipped at P2 close; v0.9 was V1.149 lore activation §7.4 production matrix: default-on engine + Relation hop expand; v0.8 was V1.148 spoke pin 0.6.1→0.8.2 + RuleQueryPort production + orchestrate_check daemon route + Connect Host N-C0 surface; v0.7 was V1.146 spoke InternalError reject code: pin bump 0.6.0→0.6.1; v0.6 was V1.145 spoke consumer alignment: adapter rehome to spoke-adapter + dep reversal + WorldKB/timeline read via ScopeQuery + scope-pushdown contract; v0.5 was V1.144 spoke 0.5.0 upgrade + RelationPort OCC extension + orchestrate_relate cutover) +> **Status:** Normative (v0.13 — V1.153 P0 spoke lockstep pin 0.8.2→0.9.1: `spoke-operations` port traits + `orchestrate_*` sync→async (adapter adapted signature-level; durable note §7.3); v0.12 — V1.152 DF-77 §11 Narrative Knowledge Pack I/O: shipped P0+P1; P2 dogfood-confirmed — additive daemon export/import routes + all three conflict policies (skip/rename/overwrite) + CLI↔daemon shared `import_pack` module + Control Room panel; v0.11 — V1.151 DF-76 §7.4 inspector packet field surface (shipped P0+P1; P2 dogfood-confirmed against the spoke assemble-module recipe handbook); v0.10 — V1.150 DF-75 §7.4 slot + Moment Directive + generation-stage matrix shipped at P2 close; v0.9 was V1.149 lore activation §7.4 production matrix: default-on engine + Relation hop expand; v0.8 was V1.148 spoke pin 0.6.1→0.8.2 + RuleQueryPort production + orchestrate_check daemon route + Connect Host N-C0 surface; v0.7 was V1.146 spoke InternalError reject code: pin bump 0.6.0→0.6.1; v0.6 was V1.145 spoke consumer alignment: adapter rehome to spoke-adapter + dep reversal + WorldKB/timeline read via ScopeQuery + scope-pushdown contract; v0.5 was V1.144 spoke 0.5.0 upgrade + RelationPort OCC extension + orchestrate_relate cutover) > **Document class:** Master > **Scope:** The `nexus-spoke-adapter` crate boundary, `extensions.nexus` namespace contract, spoke-operations delegation rules, daemon-api envelope strategy, drift detection adaptation, the `/kb/` HTTP route stability decision, the opt-in Connect Host N-C0 surface (DF-72), and the Narrative Knowledge Pack I/O product-transport surface (DF-77). > **Related:** [entity-scope-model.md](entity-scope-model.md), [local-db-schema.md](local-db-schema.md), [schemas-directory-layout.md](schemas-directory-layout.md), spoke `CONCEPTS.md`, spoke `.mstar/specs/spoke-data-model.md`, spoke `.mstar/specs/spoke-operations.md`, spoke `.mstar/specs/spoke-connect.md`. Iteration product drafts (process): `.mstar/iterations/v1.148/specs/fl-r-connect-host-foundation.md`, `.mstar/iterations/v1.152/specs/fl-l-w7-knowledge-pack-productization.md`. @@ -16,11 +16,11 @@ These are the architecture bedrock — do not re-litigate. ### 1.1 Consume spoke packages directly nexus depends on spoke's published packages directly: -- **Rust:** `spoke-schemas` + `spoke-operations` (crates.io, lockstep **`0.8.2`** exact pin) -- **TypeScript:** `@42ch/spoke-schemas` + `@42ch/spoke-operations` (npm, lockstep **`0.8.2`** exact pin) -- **Rust (opt-in Connect Host only):** `spoke-connect` (crates.io, lockstep **`0.8.2`** exact pin) — workspace dep consumed **only** behind cargo feature `connect-host` on `apps/nexus42`. Default `nexus42` / daemon builds MUST NOT link `spoke-connect`. See §10. +- **Rust:** `spoke-schemas` + `spoke-operations` (crates.io, lockstep **`0.9.1`** exact pin) +- **TypeScript:** `@42ch/spoke-schemas` + `@42ch/spoke-operations` (npm, lockstep **`0.9.1`** exact pin) +- **Rust (opt-in Connect Host only):** `spoke-connect` (crates.io, lockstep **`0.9.1`** exact pin) — workspace dep consumed **only** behind cargo feature `connect-host` on `apps/nexus42`. Default `nexus42` / daemon builds MUST NOT link `spoke-connect`. See §10. -> **Historical:** V1.139 shipped at `0.1.1`; V1.140 bumped to `0.2.0`. V1.141 jumped to `0.4.0` (covering both the `0.3.0` capability-sliced port architecture and `0.4.0` additive `HostCapabilityManifest` + body helpers + UTF-8 peer sort). V1.144 bumped to `0.5.0` (additive `Relation.revision` + OCC-aware `RelationPort` + `RelationAlreadyExists`/`RelationNotFound` reject codes + relate-gate explicit mode). V1.145 bumped to `0.6.0` (additive `Scope.extensions` + `KnowledgeEntry.modules`). V1.146 bumped to `0.6.1` (additive `InternalError` 500-class reject code, PR #35). **V1.148 bumped to `0.8.2`** (spoke-connect surface 0.7.0–0.8.2 additive; 0.7.0 demote pack catalog from ModuleMap — pack catalog is product transport envelope, not `modules.pack` on KE/AssemblePacket; connect family schemas additive). +> **Historical:** V1.139 shipped at `0.1.1`; V1.140 bumped to `0.2.0`. V1.141 jumped to `0.4.0` (covering both the `0.3.0` capability-sliced port architecture and `0.4.0` additive `HostCapabilityManifest` + body helpers + UTF-8 peer sort). V1.144 bumped to `0.5.0` (additive `Relation.revision` + OCC-aware `RelationPort` + `RelationAlreadyExists`/`RelationNotFound` reject codes + relate-gate explicit mode). V1.145 bumped to `0.6.0` (additive `Scope.extensions` + `KnowledgeEntry.modules`). V1.146 bumped to `0.6.1` (additive `InternalError` 500-class reject code, PR #35). **V1.148 bumped to `0.8.2`** (spoke-connect surface 0.7.0–0.8.2 additive; 0.7.0 demote pack catalog from ModuleMap — pack catalog is product transport envelope, not `modules.pack` on KE/AssemblePacket; connect family schemas additive). **V1.153 bumped to `0.9.1`** (lockstep re-baseline on the connect v2 wire; 0.9.0's dial-bound hello + envelope-auth v2 are internal to `spoke-connect`; `spoke-operations` 0.9.1 additionally converted the adapter port traits + `orchestrate_*` to native async — nexus adapted signature-level, see §7.3). The bespoke `schemas/domain/key-block.schema.json` is deleted. No nexus-local copy of spoke schemas exists. The atomic KB wire type is `KnowledgeEntry` from spoke. @@ -197,8 +197,8 @@ The `schema_drift_detection.rs` `build_schema_map()` removes the `key-block.sche `check-wire-drift.sh` gains a new spoke-conformance step (P0 T4): -1. Verify `spoke-schemas` crate version matches pinned **`0.8.2`** in `Cargo.toml`. -2. Verify `@42ch/spoke-schemas` npm version matches pinned **`0.8.2`** in `package.json`. +1. Verify the three crate pins (`spoke-schemas`, `spoke-operations`, `spoke-connect`) all match the pinned **`0.9.1`** in `Cargo.toml`. +2. Verify the two npm pins (`@42ch/spoke-schemas`, `@42ch/spoke-operations`) both match the pinned **`0.9.1`** in `package.json`. 3. Construct a `KnowledgeEntry` from spoke fixture JSON, deserialize via nexus's serde path, serialize back — verify structural round-trip. This catches type-mapping regressions without requiring a local schema. ### 5.3 Daemon-api envelopes that `$ref` spoke types @@ -367,6 +367,8 @@ All delegation wrappers are thin — they enforce the boundary that operands mus As of spoke 0.3.0, `spoke-operations` ships a **capability-sliced adapter port** architecture with **injection orchestration**. The `nexus-spoke-adapter` crate re-exports the port traits and orchestration entrypoints so that consumers can participate in spoke's injection-orchestration model through the same boundary crate — without a direct `spoke-operations` dependency. +> **Async port surface (spoke-operations ≥ 0.9.1):** the adapter port traits are `#[async_trait] async fn` and the `orchestrate_*` entrypoints are native `async fn` — no sync compatibility shim exists (verified against the 0.9.1 registry source). Nexus port impls and every `orchestrate_*` call site use the async surface (`.await`); the adapter no longer captures a tokio runtime handle and can be constructed anywhere. Any future port impl or orchestrator call MUST use the async surface. + **What changes:** the adapter crate's public API gains a second surface (Surface B) alongside the existing pure-delegate helpers (Surface A). Consumers that currently call `ops::validate_promote` / `ops::apply_promote` directly (Surface A) can **optionally** adopt the port+orchestrator pattern (Surface B) by implementing spoke port traits and calling `orchestrate_*` entrypoints. Surface A is **frozen and unchanged** — no existing call site must migrate. #### Surface A — Pure delegates (unchanged) @@ -601,7 +603,7 @@ spoke `Scope` supports `entry_ids`, `entry_types`, `source_id`, `fork_id`, `time #### Read-path ScopeQuery adoption (V1.145) -**P2 — MCA WorldKB read:** MCA's `fetch_world_kb` (in `nexus-moment-context-assembly/src/moment.rs`) switches from `SqliteKbStore` to a `SpokeBackedKbStore` wrapper (`nexus-spoke-adapter/src/adapter/mca_read.rs`) that implements `KbStore` by translating `KbQuery` → spoke `Scope` (native `entry_types` from `block_type` + the nexus-specific filters under `scope.extensions["nexus"]`) → `NexusAdapter::list_knowledge_entries_scoped` (an async inherent method, NOT the sync spoke `ScopeQueryPort` trait method, so MCA does not inherit the spoke port's reject-on-overflow). The wrapper converts spoke `KnowledgeEntry` → nexus `WorldKbEntry` via the free function `spoke_to_world_kb` (V1.145 P1a conversion seam; lossless body carrier preserves summary/tags/attributes). The MCA read is wired at `apps/nexus42/src/commands/platform/context.rs::run_assemble_moment` (the single production `assemble_moment` KB-store call site). MCA's generic `K: KbStore` signature is unchanged — only the injected implementation changes. +**P2 — MCA WorldKB read:** MCA's `fetch_world_kb` (in `nexus-moment-context-assembly/src/moment.rs`) switches from `SqliteKbStore` to a `SpokeBackedKbStore` wrapper (`nexus-spoke-adapter/src/adapter/mca_read.rs`) that implements `KbStore` by translating `KbQuery` → spoke `Scope` (native `entry_types` from `block_type` + the nexus-specific filters under `scope.extensions["nexus"]`) → `NexusAdapter::list_knowledge_entries_scoped` (an async inherent method, NOT the spoke `ScopeQueryPort` trait method, so MCA does not inherit the spoke port's reject-on-overflow). The wrapper converts spoke `KnowledgeEntry` → nexus `WorldKbEntry` via the free function `spoke_to_world_kb` (V1.145 P1a conversion seam; lossless body carrier preserves summary/tags/attributes). The MCA read is wired at `apps/nexus42/src/commands/platform/context.rs::run_assemble_moment` (the single production `assemble_moment` KB-store call site). MCA's generic `K: KbStore` signature is unchanged — only the injected implementation changes. **P2 scope boundary (explicit):** MCA is the only production consumer cut over in V1.145. Daemon CRUD read paths (`get_graph`, `get_candidates`) stay on `SqliteKbStore` directly — these are UI views, not spoke integration concerns. Evaluation deferred to V1.146+. @@ -721,7 +723,7 @@ Normative architectural surface for the first FL-R Connect Host slice. Product b |------|------| | Cargo feature | `connect-host` on `apps/nexus42` (default **off**) | | CLI entrypoint | `nexus42 connect start` only (feature-gated) | -| Dependency | `spoke-connect = "=0.8.2"` workspace dep; optional on `nexus42` | +| Dependency | `spoke-connect = "=0.9.1"` workspace dep; optional on `nexus42` | | Default daemon | Feature-off build does **not** link `spoke-connect`. `nexus42 daemon start` never opens a Connect listener (even if feature-on binary is used as daemon). | | mDNS | Do not enable `spoke-connect` `mdns` feature for N-C0 | @@ -737,6 +739,7 @@ Normative architectural surface for the first FL-R Connect Host slice. Product b - **Single builder** shared by `HostManifestPort::get_host_capability_manifest` and `ConnectConfig.local_manifest`. - Wire type: spoke `HostCapabilityManifest` (`schemas/data/host-capability-manifest.schema.json`); hello embeds field-identical `connect_hello::HostCapabilityManifest`. - N-C0 field contract: `schema_version = 1`; `host_id` = `~/.nexus42/device-id` UUID; `roles = ["data-store"]`; `capabilities = ["spoke-baseline", "l2-computable", "l5-fork"]`; `namespaces = ["nexus"]`; `authority` absent; `extensions.nexus = { connect_host_slice: "n-c0", daemon_http_coexists: true }`. +- N-C1 field extension (V1.153, delivered): `extensions.nexus = { connect_host_slice: "n-c1", served_ops: ["upsert", "promote", "relate"], daemon_http_coexists: true }` — `served_ops` advertises **exactly** the write ops the Connect invoke dispatcher serves; the honesty tests machine-check both directions (advertised ⇔ served) so the manifest cannot drift from the dispatch. `roles` / `capabilities` / `namespaces` unchanged. - MUST NOT advertise `"reasoning-complete"` or any role beyond `data-store` until Connect dispatch exists for that role. - Capability strings must map to production adapter ports (machine-checked in P3 honesty test). @@ -746,6 +749,7 @@ Normative architectural surface for the first FL-R Connect Host slice. Product b - Allowlist: `~/.nexus42/connect/allowlist.json` + CLI `--allow-peer`; empty ⇒ fail-closed (spoke semantics). - Capability-token: structural gate only — production defaults `trusted_issuers` empty, `capability_token_provider = None`, `require_capability_token = false`. - **Op refusal:** `ConnectConfig.invoke_handler = None` ⇒ every inbound invoke returns `ErrorEnvelope.code = "op_unsupported"` with no side effects. No `NexusAdapter` call from Connect invoke in N-C0. +- **N-C1 refusal contract (extends):** the connect host wires `invoke_handler = Some(…)`; non-served ops (`check` / `assemble` / `project` / `compute` / unknown) still return `ErrorEnvelope.code = "op_unsupported"` with zero side effects. The `= None` rule above remains the crate default for hosts without a handler. ### 10.5 Daemon HTTP `check` (related, not Connect) @@ -756,7 +760,7 @@ Normative architectural surface for the first FL-R Connect Host slice. Product b | Slice | Content | Tracker | |-------|---------|---------| | **N-C0** (V1.148) | Opt-in host, hello + allowlist, honest manifest, all ops refused | DF-72 partial | -| **N-C1** (next) | Inbound write ops over Connect + OCC + token/world scoping | DF-72; owner architect; trigger: N-C0 dogfood green + partner demand | +| **N-C1** (V1.153, delivered) | Inbound write ops `upsert` / `promote` / `relate` over Connect + OCC (`expected_base_revision`, locked reject-code mapping) + fail-closed world scoping (allowlist `world_scope` + stored-world gate); coexistence = SQLite WAL (no `runtime_lock` on the invoke path); peer_id trust boundary = payload-carried `extensions.nexus.peer_id` (spoofable → E2 residual) | DF-72 | | **N-C2** | `check` / `assemble` over Connect; "reasoning-complete" legitimate | DF-72 later | | **N-C3** | `list_peer_host_capability_manifests` production / multi-host | DF-72 later; `R-V1142P1-002` | | **DF-73** | Headless `nexus-runtime` binary | Separate backlog; after N-C0 dogfood | diff --git a/Cargo.lock b/Cargo.lock index 43c23b8a6..6d3d50ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3816,6 +3816,7 @@ dependencies = [ name = "nexus-spoke-adapter" version = "0.1.0" dependencies = [ + "async-trait", "chrono", "dirs", "nexus-contracts", @@ -3887,6 +3888,7 @@ dependencies = [ "serial_test", "sha2", "spoke-connect", + "spoke-schemas", "sqlx", "tempfile", "thiserror 1.0.69", @@ -5721,11 +5723,13 @@ dependencies = [ [[package]] name = "spoke-connect" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d940440989fc376beb0dc925dfe7ec6710aa1de2ad2c4d41e1274c290c015af" +checksum = "1801da43521f7f71c65aa42eb1498a5ae79eff051a4b03eb76f88dda94049ec2" dependencies = [ + "async-trait", "base64", + "chrono", "ed25519-dalek", "futures", "getrandom 0.2.17", @@ -5741,10 +5745,11 @@ dependencies = [ [[package]] name = "spoke-operations" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2895c98377b3b1e9dbe99061e07698ae09def95b9a72baa12439690669477d78" +checksum = "d1c4392185cae9b359e2193af7dad63a65e83dbf34576822e17fec3e1e82f868" dependencies = [ + "async-trait", "chrono", "serde", "serde_json", @@ -5753,9 +5758,9 @@ dependencies = [ [[package]] name = "spoke-schemas" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35ae272a882ee226f3e30fb4c171d14f3740fb5750eea180582a002bdd8963b0" +checksum = "7f3c564bc28694262b5123c8fafe33dc20696384d90414368fff4ddd19a75ad9" dependencies = [ "chrono", "regress", diff --git a/Cargo.toml b/Cargo.toml index dbdae78f0..ab819ee55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,14 @@ chrono-tz = "0.10" regress = "0.10" # V1.139 T1: SPOKE foundation deps. Lockstep exact pin at 0.6.0 across both # Rust crates and both npm packages (compass Q14). The `=` prefix forces an -# exact resolution (no caret/tilde range). Published on crates.io at 0.8.2. +# exact resolution (no caret/tilde range). Published on crates.io at 0.9.1. +# V1.153 P0 (FL-R E1): 0.8.2 → 0.9.1 — lockstep re-baseline on the connect +# v2 wire. 0.9.0's connect changes (dial-bound hello, envelope-auth v2 +# signature fields) are internal to spoke-connect (pub(crate), owned by +# SpokeConnectNode); the nexus-used connect surface is unchanged. +# spoke-operations 0.9.1 additionally converted the adapter port traits + +# `orchestrate_*` from sync to native async — adapted signature-level in +# V1.153 P0 T2 (no operations-surface parity implied). # V1.148: 0.6.1 → 0.8.2 — the connect family is additive: 0.7.0 added the # connect interaction wire surface (schemas/connect/*, + libp2p spike) and # the pack-catalog demote (ModuleMap / KE.modules description-only — catalog @@ -122,14 +129,16 @@ regress = "0.10" # additive + KnowledgeEntry.modules (capability-flagged, additive). # Consumers (the upcoming spoke-adapter crate + nexus-contracts generated # types) will reference these via `spoke-schemas.workspace = true`. -spoke-schemas = "=0.8.2" -spoke-operations = "=0.8.2" -# V1.148 P3 N-C0 (DF-72): spoke-connect =0.8.2 — Connect Host facade (signed -# hello, allowlist, capability-token gate). Opt-in ONLY: libp2p 0.56 is a +spoke-schemas = "=0.9.1" +spoke-operations = "=0.9.1" +# V1.148 P3 N-C0 (DF-72): spoke-connect — Connect Host facade (signed +# hello, allowlist, capability-token gate). V1.153 P0: 0.8.2 → 0.9.1 +# (dial-bound hello + envelope-auth v2 are internal to spoke-connect; +# nexus surface unchanged). Opt-in ONLY: libp2p 0.56 is a # transitive dep, so this entry is consumed behind feature `connect-host` on # `apps/nexus42` and the DEFAULT daemon graph stays libp2p-free. The `mdns` # feature of spoke-connect must NOT be enabled (explicit peering only). -spoke-connect = "=0.8.2" +spoke-connect = "=0.9.1" # V1.148 P3 N-C0: libp2p 0.56 — identity/PeerId/Multiaddr types for the # `nexus42 connect` CLI (ed25519 keypair + protobuf key serialization only). # Pinned exactly to spoke-connect's transport so the feature-on graph shares diff --git a/apps/nexus42/Cargo.toml b/apps/nexus42/Cargo.toml index 19ac67553..01b18c372 100644 --- a/apps/nexus42/Cargo.toml +++ b/apps/nexus42/Cargo.toml @@ -18,6 +18,16 @@ path = "src/lib.rs" name = "nexus42" path = "src/main.rs" +# V1.153 P2 (DF-73): standalone headless Connect runtime — same crate's +# config/home-layout/boot code, `run_daemon` never called. Requires +# `connect-host` (the distributed artifact is built with +# `--no-default-features --features connect-host`, web-embed OFF → no +# embedded SPA bytes; see the P2 spec § Subsystem profile). +[[bin]] +name = "nexus-runtime" +path = "src/bin/nexus-runtime.rs" +required-features = ["connect-host"] + # V1.148 P4 (DF-72 N-C0): real-process dogfood dialer — a spoke-connect # reference peer that dials a running `nexus42 connect start` host from # OUTSIDE the host process. Feature-gated so the default graph stays @@ -27,6 +37,15 @@ name = "connect_dialer" path = "examples/connect_dialer.rs" required-features = ["connect-host"] +# V1.153 P2 T1 (DF-73): spawned-process smoke probe for the headless +# `nexus-runtime` bin — same reference-peer shape, machine-readable output +# for `tests/nexus_runtime_smoke.rs`. Same feature gate: the default test +# graph stays libp2p-free. +[[example]] +name = "runtime_smoke_probe" +path = "examples/runtime_smoke_probe.rs" +required-features = ["connect-host"] + [dependencies] nexus-contracts = { path = "../../crates/nexus-contracts" } nexus-orchestration = { path = "../../crates/nexus-orchestration" } @@ -37,7 +56,11 @@ nexus-cloud-sync = { path = "../../crates/nexus-cloud-sync", features = ["legacy nexus-home-layout = { path = "../../crates/nexus-home-layout" } nexus-local-db = { path = "../../crates/nexus-local-db" } nexus-acp-host = { path = "../../crates/nexus-acp-host" } -nexus-daemon-runtime = { path = "../../crates/nexus-daemon-runtime" } +# V1.153 P2 (DF-73): `default-features = false` + explicit `web-embed` +# forwarding (see [features]) — the slim `nexus-runtime` artifact +# (`--no-default-features --features connect-host`) compiles the embedded +# SPA out while the default `nexus42` build keeps it ON. +nexus-daemon-runtime = { path = "../../crates/nexus-daemon-runtime", default-features = false } nexus-narrative = { path = "../../crates/nexus-narrative" } nexus-knowledge = { path = "../../crates/nexus-knowledge" } # V1.145 P2 — MCA WorldKB read routes through the spoke-adapter boundary @@ -48,6 +71,12 @@ nexus-spoke-adapter = { path = "../../crates/nexus-spoke-adapter" } # default build (and `daemon start`) must stay libp2p-free, so this dep is # `optional` and only `connect start` links it. `mdns` is never enabled. spoke-connect = { workspace = true, optional = true } +# V1.153 P1 N-C1: the connect wire `ErrorEnvelope` +# (`spoke_schemas::connect::connect_invoke_response::ErrorEnvelope`) the +# invoke handler returns. Same 0.9.1 pin; optional + feature-gated with +# `connect-host` (spoke-schemas is a lightweight schema crate, but the +# connect path is the only consumer). +spoke-schemas = { workspace = true, optional = true } # libp2p identity/PeerId/Multiaddr types for the `connect start` CLI (key # generation + protobuf key persistence + allowlist/listen parsing). Same # 0.56 pin as spoke-connect's transport; optional + feature-gated with @@ -73,13 +102,11 @@ thiserror = { workspace = true } url = { workspace = true } sha2 = { workspace = true } hex = "0.4" -# ACP SDK consumed via nexus-acp-host (worker-only linkage — see spec §11.3) -# agent-client-protocol = "=0.10.4" — REMOVED: consumed through nexus-acp-host -# tokio-util, async-trait, async-broadcast — consumed through nexus-acp-host -# nix — also used directly by daemon.rs for process management -[target.'cfg(unix)'.dependencies] -nix = { version = "0.28", features = ["signal", "process"] } - +# V1.153 P2 T2: these six were previously declared after `nix` inside +# `[target.'cfg(unix)'.dependencies]` (missing section header), which broke +# every Windows build with E0433 "cannot find crate" in lib code using them +# on all platforms (works/cron.rs cron validation, auth/user_auth.rs base64, +# creator/memory.rs tempfile, rules_runtime.rs dialoguer). Moved here. dialoguer = "0.11" tempfile = "3" regex = "1" @@ -87,6 +114,12 @@ base64 = "0.22" # V1.50 T-A P0 T2: cron validation + IANA TZ parsing (novel-writing staggering) cron = { workspace = true } chrono-tz = { workspace = true } +# ACP SDK consumed via nexus-acp-host (worker-only linkage — see spec §11.3) +# agent-client-protocol = "=0.10.4" — REMOVED: consumed through nexus-acp-host +# tokio-util, async-trait, async-broadcast — consumed through nexus-acp-host +# nix — also used directly by daemon.rs for process management +[target.'cfg(unix)'.dependencies] +nix = { version = "0.28", features = ["signal", "process"] } [dev-dependencies] assert_cmd = "2.0" @@ -96,6 +129,12 @@ wiremock = "0.6" # Without it, the summary_config_from_env_* tests race on NEXUS_CONTEXT_MAX_FILE_SIZE # under cargo's default parallel runner and intermittently fail on CI. serial_test = "3" +# V1.153 P2 T1: spawned-binary smoke test (`tests/nexus_runtime_smoke.rs`). +# The Connect dialing lives in the `runtime_smoke_probe` EXAMPLE (regular +# connect-host deps — the default test graph stays libp2p-free); the test +# itself only spawns processes and asserts output. +tokio = { workspace = true } +tempfile = "3" # V1.148 P3 N-C0 (DF-72): opt-in Connect Host surface. Feature OFF by default # — the default daemon graph stays libp2p-free (`cargo tree -p nexus42 -i @@ -103,4 +142,11 @@ serial_test = "3" # but only `nexus42 connect start` opens a Connect listener; `daemon start` # never does. `spoke-connect/mdns` is deliberately NOT enabled. [features] -connect-host = ["dep:spoke-connect", "dep:libp2p"] +connect-host = ["dep:spoke-connect", "dep:spoke-schemas", "dep:libp2p"] +# V1.153 P2 (DF-73): forwarded gate for the embedded Web UI — ON by default +# (the `nexus42` bin is unchanged); OFF for the `nexus-runtime` artifact via +# `--no-default-features --features connect-host` (compile-time exclusion of +# `apps/web/dist` bytes + the SPA fallback route, per the P2 spec +# § Subsystem profile). +web-embed = ["nexus-daemon-runtime/web-embed"] +default = ["web-embed"] diff --git a/apps/nexus42/examples/runtime_smoke_probe.rs b/apps/nexus42/examples/runtime_smoke_probe.rs new file mode 100644 index 000000000..025261a2e --- /dev/null +++ b/apps/nexus42/examples/runtime_smoke_probe.rs @@ -0,0 +1,161 @@ +//! V1.153 P2 T1 — spawned-process smoke probe for the headless +//! `nexus-runtime` bin. +//! +//! A `spoke-connect` reference peer that dials a **running** `nexus-runtime` +//! process from outside the process (loopback, mDNS off) and prints +//! machine-readable facts the smoke test asserts on: +//! +//! ```text +//! PROBE_PEER_ID= # --print-peer-only (allowlist seed) +//! DIAL_OK session= remote= # signed-hello handshake completed +//! SERVED_OPS=upsert,promote,relate # manifest extensions.nexus.served_ops +//! SESSION_OK # session still usable after refusals +//! ``` +//! +//! The identity comes from a fixed Ed25519 seed (default 7) so the host's +//! `allowlist.json` entry is deterministic across runs. Usage: +//! +//! ```text +//! cargo build -p nexus42 --features connect-host --example runtime_smoke_probe +//! ./target/debug/examples/runtime_smoke_probe --print-peer-only +//! ./target/debug/examples/runtime_smoke_probe \ +//! --addr /ip4/127.0.0.1/tcp/ --host-peer +//! ``` + +use libp2p::identity::Keypair; +use spoke_connect::{parse_multiaddr, ConnectConfig, SpokeConnectNode}; +use spoke_schemas::connect::connect_hello::HostCapabilityManifest; +use std::collections::HashMap; + +/// Fixed Ed25519 seed for this probe's identity — the deterministic peer id +/// the smoke test allowlists before the host boots. +const DIALER_SEED: u8 = 7; + +fn fixed_keypair(seed: u8) -> Keypair { + Keypair::ed25519_from_bytes([seed; 32]).expect("fixed seed is a valid ed25519 secret") +} + +/// Minimal peer manifest (the probe is a reference peer — its own manifest +/// shape does not matter to the assertions; mirrors the upstream +/// spoke-connect test fixture). +fn probe_manifest() -> HostCapabilityManifest { + HostCapabilityManifest { + authority: None, + capabilities: vec!["spoke-baseline".into()], + extensions: HashMap::default(), + host_id: "smoke-dialer".parse().expect("host id parses"), + namespaces: Vec::new(), + roles: vec!["input-source".into()], + schema_version: std::num::NonZeroU64::new(1).expect("non-zero"), + } +} + +#[tokio::main] +async fn main() { + let mut addr: Option = None; + let mut host_peer: Option = None; + let mut print_peer_only = false; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--addr" => addr = Some(args.next().expect("--addr value")), + "--host-peer" => host_peer = Some(args.next().expect("--host-peer value")), + "--print-peer-only" => print_peer_only = true, + other => { + eprintln!("unknown arg: {other}"); + std::process::exit(2); + } + } + } + + let key = fixed_keypair(DIALER_SEED); + let my_peer = key.public().to_peer_id(); + println!("PROBE_PEER_ID={my_peer}"); + if print_peer_only { + return; + } + + let Some(addr) = addr else { + eprintln!("--addr is required"); + std::process::exit(2); + }; + let Some(host_peer) = host_peer else { + eprintln!("--host-peer is required"); + std::process::exit(2); + }; + let host_peer = match host_peer.parse() { + Ok(peer) => peer, + Err(e) => { + eprintln!("host peer id does not parse: {e}"); + std::process::exit(2); + } + }; + + let config = ConnectConfig { + identity: key, + peer_allowlist: vec![host_peer], + listen_addrs: vec![parse_multiaddr("/ip4/127.0.0.1/tcp/0").expect("loopback multiaddr")], + local_manifest: probe_manifest(), + handshake_timeout: Some(std::time::Duration::from_secs(10)), + invoke_handler: None, + op_capability_requirements: HashMap::new(), + trusted_issuers: Vec::new(), + require_capability_token: false, + capability_token_provider: None, + }; + let node = match SpokeConnectNode::start(config).await { + Ok(node) => node, + Err(e) => { + eprintln!("PROBE_NODE_START_FAILED: {e:?}"); + std::process::exit(1); + } + }; + + // Dial the running host (bounded by the handshake timeout above). + let session = match node + .connect(parse_multiaddr(&addr).expect("host addr parses")) + .await + { + Ok(session) => session, + Err(e) => { + println!("DIAL_REJECTED: {e:?}"); + node.shutdown().await.expect("shutdown"); + std::process::exit(1); + } + }; + println!( + "DIAL_OK session={} remote={}", + session.session_id(), + session.remote_peer_id() + ); + + // N-C1 surface: the manifest advertises exactly the served write ops. + let manifest_json = + serde_json::to_string(session.remote_manifest()).expect("manifest serializes"); + println!("MANIFEST_JSON={manifest_json}"); + let manifest: serde_json::Value = + serde_json::from_str(&manifest_json).expect("manifest json parses"); + let served_ops: Vec<&str> = manifest["extensions"]["nexus"]["served_ops"] + .as_array() + .map(|ops| ops.iter().filter_map(|op| op.as_str()).collect()) + .unwrap_or_default(); + println!("SERVED_OPS={}", served_ops.join(",")); + + // Session usable after the reads: a non-served op is refused with the + // `op_unsupported` wire envelope (N-C0 refusal contract). + match session + .invoke("check", serde_json::json!({ "extensions": {} })) + .await + { + Err(spoke_connect::InvokeError::Wire(envelope)) if envelope.code == "op_unsupported" => { + println!("SESSION_OK"); + } + other => { + println!("SESSION_CHECK_FAILED: {other:?}"); + node.shutdown().await.expect("shutdown"); + std::process::exit(1); + } + } + + node.shutdown().await.expect("shutdown"); +} diff --git a/apps/nexus42/src/bin/nexus-runtime.rs b/apps/nexus42/src/bin/nexus-runtime.rs new file mode 100644 index 000000000..1b251de4b --- /dev/null +++ b/apps/nexus42/src/bin/nexus-runtime.rs @@ -0,0 +1,173 @@ +//! `nexus-runtime` — headless Connect runtime (V1.153 P2, DF-73). +//! +//! Standalone binary serving ONLY the spoke-connect surface (the N-C1 +//! write-op invoke surface, P1) for the partner/integrator channel. Boots: +//! PATH enrichment, the shared `~/.nexus42` home layout, config load, +//! active-workspace `SQLite` open (`DbPool`, WAL), ONE per-process +//! `NexusAdapter`, and the Connect host with the N-C1 invoke handler — then +//! blocks on SIGINT. Liveness = **stdout readiness only** (no HTTP health +//! endpoint). +//! +//! `nexus_daemon_runtime::boot::run_daemon` is **never called**: the daemon +//! HTTP data router, embedded `apps/web` SPA, Setup/Canvas/Control Room +//! routes, ACP/agent-host subsystem, and schedule/worker supervision never +//! boot in this process (P2 spec § Subsystem profile). The embedded SPA +//! bytes are additionally excluded at COMPILE time: the distributed +//! artifact is built with +//! `cargo build --release --bin nexus-runtime --no-default-features +//! --features connect-host` (the `web-embed` feature is OFF). +//! +//! Home resolution: `--home` > `$NEXUS42_HOME` > the user home. The home +//! value is the HOME DIR itself — the PARENT of the `.nexus42` layout dir +//! (e.g. `/home/me` → `/home/me/.nexus42`), not the layout dir. The home is +//! shared with the creator-facing `nexus42` app; shared-DB write access is +//! governed by `SQLite` `WAL` (not the per-Work `runtime_lock`), per the P2 +//! spec § Coexistence. + +use clap::Parser; +use nexus42::commands::connect; +use nexus42::errors::Result; +use std::path::{Path, PathBuf}; + +/// Default listen multiaddr when `--listen` is not given — loopback only, +/// identical to `nexus42 connect start`. +const DEFAULT_LISTEN: &str = "/ip4/127.0.0.1/tcp/0"; + +/// Headless Connect runtime CLI (mirrors the `nexus42 connect start` +/// surface; spec-locked in the P2 spec § CLI surface). +#[derive(Debug, Parser)] +#[command( + name = "nexus-runtime", + version, + about = "Nexus headless Connect runtime (N-C1 invoke surface)", + long_about = "Headless Connect runtime: serves the N-C1 write-op invoke \ + surface (upsert/promote/relate, world-scoped) over \ + spoke-connect against the shared ~/.nexus42 home. No \ + daemon HTTP router, no embedded Web UI, no Setup/Canvas/\ + Control Room." +)] +struct RuntimeCli { + /// Listen multiaddr (repeatable; default `/ip4/127.0.0.1/tcp/0`). + #[arg(long, value_name = "MULTIADDR", default_value = DEFAULT_LISTEN)] + listen: Vec, + + /// Peer IDs to allowlist for this run (repeatable; unioned with + /// `~/.nexus42/connect/allowlist.json`). + #[arg(long = "allow-peer", value_name = "PEER_ID")] + allow_peer: Vec, + + /// Nexus home override (default: `$NEXUS42_HOME`, else the user home). + /// + /// The home is the parent of the `.nexus42` layout dir: `/foo` → + /// `/foo/.nexus42`. + #[arg(long, value_name = "PATH")] + home: Option, +} + +fn main() { + let cli = RuntimeCli::parse(); + + // PATH enrichment before Tokio starts (GUI-launched sidecars inherit a + // minimal PATH; same Class-B rule as the nexus42 CLI main). + nexus_daemon_runtime::path_enrichment::apply_process_path_enrichment(); + + let home = resolve_home(cli.home.as_deref()); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build Tokio runtime"); + if let Err(e) = runtime.block_on(boot(&home, &cli.allow_peer, &cli.listen)) { + eprintln!("nexus-runtime: {e}"); + std::process::exit(1); + } +} + +/// Resolve the nexus home: `--home` > `$NEXUS42_HOME` > the user home. +#[must_use] +fn resolve_home(cli_home: Option<&Path>) -> PathBuf { + let overridden = cli_home + .map(Path::to_path_buf) + .or_else(|| std::env::var_os("NEXUS42_HOME").map(PathBuf::from)); + overridden.map_or_else( + || match nexus42::config::user_home_dir() { + Ok(home) => home, + Err(e) => { + eprintln!("nexus-runtime: cannot resolve user home: {e}"); + std::process::exit(1); + } + }, + |home| { + // The shared config/workspace resolution in this crate reads + // `dirs::home_dir()` directly (there is no central home + // indirection), so an override re-points the platform home env + // BEFORE any downstream read — the same mechanism + // `testutil::isolated_home` uses in-process. Safe here because + // `main` runs single-threaded before the Tokio runtime starts + // (same pre-runtime env discipline as + // `apply_process_path_enrichment`). + let home = if home.is_absolute() { + home + } else { + std::env::current_dir() + .map(|cwd| cwd.join(&home)) + .unwrap_or(home) + }; + #[cfg(unix)] + std::env::set_var("HOME", &home); + #[cfg(windows)] + std::env::set_var("USERPROFILE", &home); + home + }, + ) +} + +/// The headless boot: shared home layout + the exact `connect start` N-C1 +/// assembly ([`connect::build_host_config`]) + node start + stdout +/// readiness. `run_daemon` is never called. +/// +/// # Errors +/// [`CliError`] on layout/identity/allowlist/workspace-DB/node failures. +async fn boot(home: &Path, allow_peer: &[String], listen: &[String]) -> Result<()> { + // Shared `~/.nexus42` skeleton (idempotent; no-op on an existing home). + let nexus_home = nexus_home_layout::nexus_root_from_home(home); + nexus_home_layout::ensure_system_layout(&nexus_home).map_err(nexus42::errors::CliError::Io)?; + + // The full N-C1 host boot shared with `nexus42 connect start`: + // persisted identity + device-id host_id + allowlist (fail-closed) + + // honest manifest + active-workspace WAL pool + per-process + // NexusAdapter + the N-C1 invoke dispatch handler (P1). + let (config, host_id, allowlist_len) = + connect::build_host_config(home, allow_peer, listen, None).await?; + + let node = spoke_connect::SpokeConnectNode::start(config) + .await + .map_err(|e| { + nexus42::errors::CliError::Config(format!("connect node start failed: {e}")) + })?; + + // Liveness = stdout readiness (the ONLY liveness surface — no HTTP + // health endpoint; the daemon router never boots in this process). + println!("nexus-runtime: Connect Host (N-C1) ready"); + println!(" peer_id: {}", node.local_peer_id()); + println!(" host_id: {host_id}"); + for addr in node.listen_addrs() { + println!(" listen: {addr}"); + } + println!( + " allowlisted peers: {allowlist_len} (fail-closed; add via allowlist.json or --allow-peer)" + ); + println!( + " invokes: upsert/promote/relate served (world-scoped); all other ops refused (op_unsupported)" + ); + println!(" press Ctrl-C to stop"); + + tokio::signal::ctrl_c().await.map_err(|e| { + nexus42::errors::CliError::Other(format!("failed to listen for SIGINT: {e}")) + })?; + println!("nexus-runtime: shutting down"); + node.shutdown().await.map_err(|e| { + nexus42::errors::CliError::Other(format!("connect node shutdown failed: {e}")) + })?; + Ok(()) +} diff --git a/apps/nexus42/src/commands/connect/allowlist.rs b/apps/nexus42/src/commands/connect/allowlist.rs index 906e010fe..3acbc2eb6 100644 --- a/apps/nexus42/src/commands/connect/allowlist.rs +++ b/apps/nexus42/src/commands/connect/allowlist.rs @@ -2,25 +2,213 @@ //! repeatable `--allow-peer` overlay). //! //! N-C0 product contract (draft §2.3): the allowlist is the trust root. -//! File shape: `{ "peer_ids": ["12D3…", …] }`. A missing file ⇒ empty list -//! ⇒ **fail-closed** (spoke-connect rejects every remote peer). The operator -//! edits the allowlist out-of-band; there is no online enroll endpoint in -//! N-C0. +//! N-C1 world scoping (P1 spec § World scoping — schema locked): each +//! `peer_ids` entry is either a bare `"12D3…"` peer id (N-C0 shape — no +//! write access) or an object `{ "peer_id": "12D3…", "world_scope": +//! ["", …], "op_scope": ["upsert","promote","relate"] }`. +//! Both scopes are optional and **fail-closed**: an absent/empty scope +//! denies world writes — a bare entry (or a `--allow-peer` overlay) is +//! handshake-allowlisted but can never write. World ids are world UUID +//! strings, never filesystem paths. A missing file ⇒ empty list ⇒ +//! **fail-closed** (spoke-connect rejects every remote peer). The operator +//! edits the allowlist out-of-band; there is no online enroll endpoint. use crate::errors::{CliError, Result}; use libp2p::PeerId; use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; /// On-disk allowlist shape (`allowlist.json`). /// /// `deny_unknown_fields` turns typos like `peerIds` / `peer-id` into a hard /// config error instead of silently producing an empty (fail-closed) -/// allowlist that confuses the operator. +/// allowlist that confuses the operator. The same guard applies inside each +/// scoped entry ([`PeerEntryScoped`]). #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct AllowlistFile { - peer_ids: Vec, + peer_ids: Vec, +} + +/// One `peer_ids` entry: a bare peer id (N-C0 shape — no write access) or a +/// scoped object (N-C1). +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PeerEntry { + Bare(String), + Scoped(PeerEntryScoped), +} + +/// Scoped entry form — locked schema (P1 spec § World scoping). +/// +/// `world_scope` / `op_scope` are optional; absent fields deserialize to +/// empty lists and the gate then denies every world/op (fail-closed). +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PeerEntryScoped { + peer_id: String, + #[serde(default)] + world_scope: Vec, + #[serde(default)] + op_scope: Vec, +} + +/// Resolved allowlist scoping: peer → allowed world ids / allowed ops. +/// +/// Consumed by (a) the N-C1 dispatch gate via [`PeerScope::allows_world`] / +/// [`PeerScope::allows_op`] and (b) `ConnectConfig.peer_allowlist` via +/// [`PeerScope::peer_ids`] (handshake allow set). +#[derive(Debug, Clone, Default)] +pub struct PeerScope { + entries: BTreeMap, +} + +/// Per-peer scoping. Empty sets ⇒ fail-closed: the peer is handshake- +/// allowlisted but has no world-write access. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PeerAccess { + /// World ids (world UUID strings, not paths) this peer may write to. + pub world_scope: BTreeSet, + /// Write ops this peer may invoke (N-C1 served ops: `upsert` / `promote` + /// / `relate`). + pub op_scope: BTreeSet, +} + +/// The served write ops (N-C1) — the `op_scope` members that make a peer +/// write-capable for the multi-write-peer boot warning. Kept in lockstep +/// with `invoke::SERVED_OPS` (same wire strings); the interop honesty +/// machine-check pins the manifest side to that const, and this const only +/// feeds a conservative boot warning, so drift here cannot open a gate. +const WRITE_OPS: [&str; 3] = ["upsert", "promote", "relate"]; + +impl PeerScope { + /// All allowlisted peer ids (sorted) — the `ConnectConfig.peer_allowlist` + /// handshake set. + #[must_use] + pub fn peer_ids(&self) -> Vec { + self.entries.keys().copied().collect() + } + + /// Per-peer access; `None` when the peer is not allowlisted. + #[must_use] + pub fn access_for(&self, peer: &PeerId) -> Option<&PeerAccess> { + self.entries.get(peer) + } + + /// Fail-closed world gate: true only when the peer is allowlisted AND its + /// `world_scope` contains `world_id`. + #[must_use] + pub fn allows_world(&self, peer: &PeerId, world_id: &str) -> bool { + self.access_for(peer) + .is_some_and(|access| access.world_scope.contains(world_id)) + } + + /// Fail-closed op gate: true only when the peer is allowlisted AND its + /// `op_scope` contains `op`. + #[must_use] + pub fn allows_op(&self, peer: &PeerId, op: &str) -> bool { + self.access_for(peer) + .is_some_and(|access| access.op_scope.contains(op)) + } + + /// True when no peer is allowlisted at all (missing/empty file). + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Number of allowlisted peers holding any write scope: a peer counts + /// when its `op_scope` contains any served write op ([`WRITE_OPS`]), or + /// when it carries a non-empty `world_scope` (world scoping expresses + /// write intent for those worlds). Over-approximates by design — this + /// feeds a boot warning (see [`warn_multi_write_peer`]), and warning + /// about a peer that turns out write-less is harmless, while NOT + /// warning about a write-capable peer is the failure mode the warning + /// exists for. + #[must_use] + pub fn write_capable_peer_count(&self) -> usize { + self.entries + .values() + .filter(|access| { + access + .op_scope + .iter() + .any(|op| WRITE_OPS.contains(&op.as_str())) + || !access.world_scope.is_empty() + }) + .count() + } + + /// File entries: last occurrence wins (a later hand-authored duplicate + /// overrides an earlier one, matching JSON duplicate-key conventions). + fn insert_entry(&mut self, entry: PeerEntry) -> Result<()> { + match entry { + PeerEntry::Bare(id) => { + let peer = parse_peer_id(&id)?; + self.entries.insert(peer, PeerAccess::default()); + } + PeerEntry::Scoped(scoped) => { + let peer = parse_peer_id(&scoped.peer_id)?; + self.entries.insert( + peer, + PeerAccess { + world_scope: scoped.world_scope.into_iter().collect(), + op_scope: scoped.op_scope.into_iter().collect(), + }, + ); + } + } + Ok(()) + } + + /// CLI overlay: fills gaps only — a `--allow-peer` entry (which carries + /// no scope) must never strip a hand-authored file scope for the same + /// peer. CLI peers have no write access by design (fail-closed). + fn insert_cli_peer(&mut self, id: &str) -> Result<()> { + let peer = parse_peer_id(id)?; + self.entries.entry(peer).or_default(); + Ok(()) + } +} + +fn parse_peer_id(raw: &str) -> Result { + raw.parse::() + .map_err(|e| CliError::Config(format!("invalid peer id {raw:?} in allowlist: {e}"))) +} + +/// Boot-time warning (plan QC, QC2 W-1 cheap hardening): the allowlist +/// holds more than one write-scoped peer. +/// +/// The per-invoke caller peer id is payload-carried +/// (`extensions.nexus.peer_id`) and spoofable — the locked spoke-connect +/// 0.9.1 handler signature (`dyn Fn(&str, Value)`) carries no authenticated +/// session peer — so with more than one write-scoped allowlisted peer, +/// per-peer world/op scoping silently degrades to the union of all scopes +/// (any allowlisted peer can put another's id in the envelope and inherit +/// its full write scope). +/// +/// N-C1 is accepted ONLY while the allowlist holds at most one write- +/// capable peer (spec §10.6). This is a **warning, not a refusal**: the +/// operator may have a legitimate reason (e.g. a CLI-overlay peer for +/// manual writes). The E2 fix is session-bound identity (upstream handler +/// signature change) or capability-token auth. +/// +/// Writes to `sink` (boot callers pass `std::io::stderr()`; tests pass a +/// buffer). +pub fn warn_multi_write_peer(scope: &PeerScope, sink: &mut dyn std::io::Write) { + let write_capable = scope.write_capable_peer_count(); + if write_capable > 1 { + let _ = writeln!( + sink, + "nexus42 connect start: WARNING: {write_capable} allowlisted peers hold write \ + scope. The per-invoke caller peer_id is payload-carried (spoofable): any \ + allowlisted peer can impersonate another and inherit its full world/op scope, \ + so per-peer scoping degrades to the union of all scopes. N-C1 assumes at most \ + one write-capable peer — with more, split write peers into separate processes \ + or accept the risk deliberately." + ); + } } /// Load the effective allowlist: file entries ∪ `--allow-peer` CLI entries. @@ -34,36 +222,38 @@ struct AllowlistFile { /// internally via `connect_allowlist_path`, so callers MUST NOT pre-join /// `~/.nexus42`. /// +/// # Returns +/// A [`PeerScope`]: `peer_ids()` feeds `ConnectConfig.peer_allowlist` +/// (handshake), `allows_world` / `allows_op` feed the N-C1 dispatch gate. +/// CLI overlay entries carry no scope — write access requires a +/// hand-authored `allowlist.json` scope (fail-closed). +/// /// # Errors /// Returns [`CliError::Io`] when the file exists but cannot be read, or /// [`CliError::Config`] when the file is malformed or an entry is not a /// valid libp2p `PeerId`. -pub fn load(home: &Path, cli_peers: &[String]) -> Result> { +pub fn load(home: &Path, cli_peers: &[String]) -> Result { let path = nexus_home_layout::connect_allowlist_path(home); - let mut entries = Vec::new(); + let mut scope = PeerScope::default(); match std::fs::read_to_string(&path) { Ok(content) => { let parsed: AllowlistFile = serde_json::from_str(&content).map_err(|e| { CliError::Config(format!("invalid allowlist at {}: {e}", path.display())) })?; - entries.extend(parsed.peer_ids); + for entry in parsed.peer_ids { + scope.insert_entry(entry)?; + } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Missing file ⇒ empty allowlist ⇒ fail-closed. } Err(e) => return Err(CliError::Io(e)), } - entries.extend(cli_peers.iter().cloned()); - - entries - .iter() - .map(|peer| { - peer.parse::().map_err(|e| { - CliError::Config(format!("invalid peer id {peer:?} in allowlist: {e}")) - }) - }) - .collect() + for peer in cli_peers { + scope.insert_cli_peer(peer)?; + } + Ok(scope) } #[cfg(test)] @@ -77,12 +267,22 @@ mod tests { .to_peer_id() } + fn write_allowlist(home: &Path, peer_ids: &serde_json::Value) { + let path = nexus_home_layout::connect_allowlist_path(home); + std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + std::fs::write( + &path, + serde_json::json!({ "peer_ids": peer_ids }).to_string(), + ) + .expect("write allowlist"); + } + #[test] fn missing_file_is_empty_and_fail_closed() { let temp = tempfile::tempdir().expect("tempdir"); - let allowlist = load(temp.path(), &[]).expect("missing file loads as empty"); + let scope = load(temp.path(), &[]).expect("missing file loads as empty"); assert!( - allowlist.is_empty(), + scope.is_empty(), "missing allowlist file must resolve to an empty (fail-closed) allowlist" ); } @@ -91,8 +291,8 @@ mod tests { fn cli_peers_overlay_on_empty_file() { let temp = tempfile::tempdir().expect("tempdir"); let peer = peer_id(1); - let allowlist = load(temp.path(), &[peer.to_string()]).expect("cli peer loads"); - assert_eq!(allowlist, vec![peer]); + let scope = load(temp.path(), &[peer.to_string()]).expect("cli peer loads"); + assert_eq!(scope.peer_ids(), vec![peer]); } #[test] @@ -100,16 +300,12 @@ mod tests { let temp = tempfile::tempdir().expect("tempdir"); let file_peer = peer_id(2); let cli_peer = peer_id(3); - let path = nexus_home_layout::connect_allowlist_path(temp.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); - std::fs::write( - &path, - serde_json::json!({ "peer_ids": [file_peer.to_string()] }).to_string(), - ) - .expect("write allowlist"); + write_allowlist(temp.path(), &serde_json::json!([file_peer.to_string()])); - let allowlist = load(temp.path(), &[cli_peer.to_string()]).expect("load"); - assert_eq!(allowlist, vec![file_peer, cli_peer]); + let scope = load(temp.path(), &[cli_peer.to_string()]).expect("load"); + let mut expected = vec![file_peer, cli_peer]; + expected.sort(); + assert_eq!(scope.peer_ids(), expected); } #[test] @@ -143,4 +339,258 @@ mod tests { load(temp.path(), &["not-a-peer-id".into()]).expect_err("invalid peer id rejected"); assert!(matches!(err, CliError::Config(_)), "got {err:?}"); } + + // ---- N-C1 world/op scoping (locked schema, fail-closed) ---- + + /// The T1 gate contract (brief Step 1): a scoped peer is allowed on a + /// listed world and denied on every other world; ops outside its + /// `op_scope` are denied too. + #[test] + fn scoped_peer_allowed_on_listed_world_denied_on_other_world() { + let temp = tempfile::tempdir().expect("tempdir"); + let peer = peer_id(4); + write_allowlist( + temp.path(), + &serde_json::json!([{ + "peer_id": peer.to_string(), + "world_scope": ["world-a"], + "op_scope": ["upsert", "promote"], + }]), + ); + + let scope = load(temp.path(), &[]).expect("scoped entry loads"); + assert_eq!( + scope.peer_ids(), + vec![peer], + "scoped peer stays allowlisted" + ); + assert!( + scope.allows_world(&peer, "world-a"), + "peer must be allowed on its listed world" + ); + assert!( + !scope.allows_world(&peer, "world-b"), + "peer must be denied on an unlisted world" + ); + assert!(scope.allows_op(&peer, "upsert"), "listed op allowed"); + assert!(scope.allows_op(&peer, "promote"), "listed op allowed"); + assert!(!scope.allows_op(&peer, "relate"), "unlisted op denied"); + } + + /// N-C0 backward compat: a bare string entry stays handshake-allowlisted + /// but carries no scope ⇒ never any world-write access (fail-closed). + #[test] + fn bare_string_entries_are_allowlisted_but_never_write() { + let temp = tempfile::tempdir().expect("tempdir"); + let peer = peer_id(5); + write_allowlist(temp.path(), &serde_json::json!([peer.to_string()])); + + let scope = load(temp.path(), &[]).expect("bare entry loads"); + assert_eq!(scope.peer_ids(), vec![peer], "bare entry stays allowlisted"); + assert!( + !scope.allows_world(&peer, "world-a"), + "bare entry has no world scope — fail-closed" + ); + assert!( + !scope.allows_op(&peer, "upsert"), + "bare entry has no op scope — fail-closed" + ); + } + + /// Absent `world_scope` ⇒ no world writes, even with an op scope present. + #[test] + fn object_entry_without_world_scope_is_fail_closed() { + let temp = tempfile::tempdir().expect("tempdir"); + let peer = peer_id(6); + write_allowlist( + temp.path(), + &serde_json::json!([{ "peer_id": peer.to_string(), "op_scope": ["upsert"] }]), + ); + + let scope = load(temp.path(), &[]).expect("entry without world_scope loads"); + assert!( + !scope.allows_world(&peer, "world-a"), + "absent world_scope must deny every world" + ); + } + + /// Absent `op_scope` ⇒ no ops, even with a world scope present. + #[test] + fn object_entry_without_op_scope_is_fail_closed() { + let temp = tempfile::tempdir().expect("tempdir"); + let peer = peer_id(7); + write_allowlist( + temp.path(), + &serde_json::json!([{ "peer_id": peer.to_string(), "world_scope": ["world-a"] }]), + ); + + let scope = load(temp.path(), &[]).expect("entry without op_scope loads"); + assert!( + !scope.allows_op(&peer, "upsert"), + "absent op_scope must deny every op" + ); + } + + /// `deny_unknown_fields` is retained on the scoped object form: a typo + /// like `op_scop` stays a hard config error. + #[test] + fn unknown_fields_inside_scoped_entry_are_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + write_allowlist( + temp.path(), + &serde_json::json!([{ + "peer_id": peer_id(8).to_string(), + "world_scope": ["world-a"], + "op_scop": ["upsert"], + }]), + ); + + let err = load(temp.path(), &[]).expect_err("unknown scoped field rejected"); + assert!(matches!(err, CliError::Config(_)), "got {err:?}"); + } + + #[test] + fn invalid_peer_id_inside_scoped_entry_is_a_config_error() { + let temp = tempfile::tempdir().expect("tempdir"); + write_allowlist( + temp.path(), + &serde_json::json!([{ "peer_id": "not-a-peer-id", "world_scope": ["world-a"] }]), + ); + + let err = load(temp.path(), &[]).expect_err("invalid scoped peer id rejected"); + assert!(matches!(err, CliError::Config(_)), "got {err:?}"); + } + + /// The `--allow-peer` overlay carries no scope by design; it must fill + /// gaps only and never strip a hand-authored file scope. + #[test] + fn cli_overlay_never_strips_file_scope() { + let temp = tempfile::tempdir().expect("tempdir"); + let peer = peer_id(9); + write_allowlist( + temp.path(), + &serde_json::json!([{ + "peer_id": peer.to_string(), + "world_scope": ["world-a"], + "op_scope": ["upsert"], + }]), + ); + + let scope = load(temp.path(), &[peer.to_string()]).expect("load with overlay"); + assert_eq!(scope.peer_ids(), vec![peer]); + assert!( + scope.allows_world(&peer, "world-a"), + "CLI overlay must not strip a hand-authored file scope" + ); + } + + /// A peer that is not allowlisted at all has no access (gate is + /// fail-closed on unknown peers too). + #[test] + fn non_allowlisted_peer_has_no_access() { + let temp = tempfile::tempdir().expect("tempdir"); + let listed = peer_id(10); + write_allowlist( + temp.path(), + &serde_json::json!([{ + "peer_id": listed.to_string(), + "world_scope": ["world-a"], + "op_scope": ["upsert"], + }]), + ); + + let scope = load(temp.path(), &[]).expect("load"); + let outsider = peer_id(11); + assert_eq!(scope.access_for(&outsider), None); + assert!(!scope.allows_world(&outsider, "world-a")); + assert!(!scope.allows_op(&outsider, "upsert")); + } + + // ---- Plan QC fix wave (QC2 W-1): multi-write-peer boot warning ---- + + /// The N-C1 trust precondition (spec §10.6): the per-invoke caller + /// `peer_id` is payload-carried (`extensions.nexus.peer_id`) and + /// spoofable — the locked spoke-connect 0.9.1 handler signature carries + /// no session peer — so per-peer world/op scoping is a real boundary + /// ONLY while the allowlist holds at most one write-capable peer. More + /// than one ⇒ any allowlisted peer can impersonate another and the + /// scoping silently degrades to the union of all scopes. The boot + /// warning must fire for a multi-write-peer allowlist and name the + /// spoofing risk + the single-write-peer precondition. + #[test] + fn multi_write_peer_allowlist_emits_boot_warning() { + let temp = tempfile::tempdir().expect("tempdir"); + let peer_a = peer_id(12); + let peer_b = peer_id(13); + write_allowlist( + temp.path(), + &serde_json::json!([ + { + "peer_id": peer_a.to_string(), + "world_scope": ["world-a"], + "op_scope": ["upsert"], + }, + { + "peer_id": peer_b.to_string(), + "world_scope": ["world-a"], + "op_scope": ["relate"], + }, + ]), + ); + + let scope = load(temp.path(), &[]).expect("multi-peer allowlist loads"); + assert_eq!( + scope.write_capable_peer_count(), + 2, + "both scoped peers hold write scope" + ); + + let mut sink = Vec::new(); + warn_multi_write_peer(&scope, &mut sink); + let output = String::from_utf8(sink).expect("warning is utf8"); + assert!( + output.contains("WARNING"), + "multi-write-peer allowlist must warn at boot: {output:?}" + ); + assert!( + output.contains("spoof") && output.contains("at most one"), + "warning must name the spoofing risk and the single-write-peer \ + precondition: {output:?}" + ); + } + + /// The single-write-peer deployment (the N-C1 acceptance shape: one + /// scoped write peer + bare allowlisted peers) must NOT warn. + #[test] + fn single_write_peer_allowlist_does_not_warn() { + let temp = tempfile::tempdir().expect("tempdir"); + let peer = peer_id(14); + let bare = peer_id(15); + write_allowlist( + temp.path(), + &serde_json::json!([ + { + "peer_id": peer.to_string(), + "world_scope": ["world-a"], + "op_scope": ["upsert", "promote", "relate"], + }, + bare.to_string(), + ]), + ); + + let scope = load(temp.path(), &[]).expect("single-write-peer allowlist loads"); + assert_eq!( + scope.write_capable_peer_count(), + 1, + "bare entries carry no write scope" + ); + + let mut sink = Vec::new(); + warn_multi_write_peer(&scope, &mut sink); + assert!( + sink.is_empty(), + "single write-capable peer must not warn: {:?}", + String::from_utf8_lossy(&sink) + ); + } } diff --git a/apps/nexus42/src/commands/connect/interop.rs b/apps/nexus42/src/commands/connect/interop.rs index 20d0746b8..4efd59be5 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -43,6 +43,86 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +/// Seed a `narrative_worlds` row (plus its `creators` FK row) so the +/// workspace DB satisfies the WAL-adjacent FK constraints the production +/// adapter's `put_*` ports hit (PRAGMA foreign_keys = ON). +async fn seed_world(pool: &sqlx::SqlitePool, creator_id: &str, world_id: &str) { + sqlx::query( + "INSERT OR IGNORE INTO creators (creator_id, display_name, status, cached_at, data) \ + VALUES (?, 'test creator', 'active', 'now', '{}')", + ) + .bind(creator_id) + .execute(pool) + .await + .expect("creator seed"); + // SAFETY: test-only static INSERT with bind params against known schema. + sqlx::query( + "INSERT OR IGNORE INTO narrative_worlds \ + (world_id, workspace_id, owner_creator_id, title, slug, status, visibility, time_policy, metadata_json) \ + VALUES (?, 'wrk_test', ?, ?, ?, 'active', 'private', 'manual', '{}')", + ) + .bind(world_id) + .bind(creator_id) + .bind(world_id) + .bind(world_id) + .execute(pool) + .await + .expect("world seed"); +} + +/// Seed a `kb_key_blocks` row directly (test-only). Used by the fix-loop +/// regression tests to plant rows in a world the peer is NOT scoped to — +/// the dispatch gate must never let the peer reach rows seeded this way. +async fn seed_key_block( + pool: &sqlx::SqlitePool, + entry_id: &str, + world_id: &str, + canonical_name: &str, + status: &str, + revision: i64, +) { + // SAFETY: test-only static INSERT with bind params against known schema + // (20260731000001_pack_import_provenance.sql; `created_at` has a default). + sqlx::query( + "INSERT INTO kb_key_blocks \ + (key_block_id, world_id, block_type, canonical_name, status, revision) \ + VALUES (?, ?, 'character', ?, ?, ?)", + ) + .bind(entry_id) + .bind(world_id) + .bind(canonical_name) + .bind(status) + .bind(revision) + .execute(pool) + .await + .expect("seed key block"); +} + +/// A wire-shape `KnowledgeEntry` JSON fixture (the pack-test sample shape + +/// the `extensions.nexus.world_id` carrier the dispatch gate reads). +/// +/// `revision: None` serializes to JSON `null` (== absent for the wire +/// `Option`), which is the legal create-path shape; the OCC test cases +/// pass `Some(...)` to drive the update-path revision checks. +fn entry_fixture( + entry_id: &str, + canonical_name: &str, + world_id: &str, + status: &str, + revision: Option, +) -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "entry_id": entry_id, + "entry_type": "character", + "canonical_name": canonical_name, + "status": status, + "revision": revision, + "body": { "summary": format!("{canonical_name} summary") }, + "extensions": { "nexus": { "world_id": world_id } }, + }) +} + /// Serializes the network scenarios (see module docs). static NETWORK_TEST_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -137,12 +217,14 @@ fn issuer_peer_id(issuer_secret: &[u8; 32]) -> String { /// A challenge-response token provider for `subject`: mints a token from /// `issuer_secret` with the challenger as the audience (per the upstream -/// spoke-connect test pattern). +/// spoke-connect test pattern). Issued at `now` (Unix seconds, the 0.9.1 +/// issuance-time parameter) with a one-hour lifetime (`exp = now + 3600`), +/// which passes the 0.9.1 fail-fast issuance guards. fn token_provider( issuer_secret: [u8; 32], subject: PeerId, capabilities: Vec, - exp: u64, + now: u64, ) -> Arc { Arc::new(move |audience: &str| { let proof = issue_capability_token( @@ -152,23 +234,26 @@ fn token_provider( sub: subject.to_string(), aud: audience.to_string(), capabilities: capabilities.clone(), - exp, + exp: now + 3600, iat: None, jti: None, }, + now, ) .map_err(|e| e.to_string())?; serde_json::to_value(&proof).map_err(|e| e.to_string()) }) } -/// A valid per-invoke `auth` proof (wire `proof` object). +/// A valid per-invoke `auth` proof (wire `proof` object), issued at `now` +/// (Unix seconds) with a one-hour lifetime (`exp = now + 3600`) — the +/// 0.9.1 `issue_capability_token` signature and issuance-guard shape. fn token_proof( issuer_secret: &[u8; 32], subject: &str, audience: &str, capabilities: &[&str], - exp: u64, + now: u64, ) -> serde_json::Value { let proof = issue_capability_token( issuer_secret, @@ -177,12 +262,13 @@ fn token_proof( sub: subject.to_string(), aud: audience.to_string(), capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(), - exp, + exp: now + 3600, iat: None, jti: None, }, + now, ) - .expect("issuer key derives iss"); + .expect("issuer key derives iss and claims pass the 0.9.1 issuance guards"); serde_json::to_value(&proof).expect("proof serializes") } @@ -226,7 +312,9 @@ async fn allowlisted_peer_handshakes_and_reads_nexus_manifest() { assert!(!session.session_id().is_empty()); // The manifest is delivered inside the signed hello (§2.5 — no separate - // get-manifest op). Assert the full N-C0 field contract. + // get-manifest op). Assert the full N-C0 baseline + N-C1 extension of + // the field contract (the single shared builder now advertises the + // delivered N-C1 slice + served write ops). let wire = serde_json::to_value(session.remote_manifest()).expect("manifest serializes"); assert_eq!(wire["host_id"], serde_json::json!(TEST_HOST_ID)); assert_eq!(wire["schema_version"], serde_json::json!(1)); @@ -238,7 +326,11 @@ async fn allowlisted_peer_handshakes_and_reads_nexus_manifest() { assert_eq!(wire["namespaces"], serde_json::json!(["nexus"])); assert_eq!( wire["extensions"]["nexus"]["connect_host_slice"], - serde_json::json!("n-c0") + serde_json::json!("n-c1") + ); + assert_eq!( + wire["extensions"]["nexus"]["served_ops"], + serde_json::json!(["upsert", "promote", "relate"]) ); assert_eq!( wire["extensions"]["nexus"]["daemon_http_coexists"], @@ -258,6 +350,60 @@ async fn allowlisted_peer_handshakes_and_reads_nexus_manifest() { peer_node.shutdown().await.expect("peer shuts down"); } +/// N-C1 honesty machine-check (P1 spec § Manifest honesty, both directions): +/// the manifest a peer reads off the signed hello must advertise exactly the +/// write ops the invoke dispatcher serves. (a) Every op the manifest +/// advertises (`extensions.nexus.served_ops`) is actually served by the +/// dispatch; (b) every op the dispatch serves (`super::invoke::SERVED_OPS`) +/// is advertised by the manifest. The manifest comes from the single shared +/// builder (`build_connect_hello_manifest` — the same bytes +/// `connect start` puts in `ConnectConfig.local_manifest`), so this is the +/// wire-truth cross-crate check; the crate-level honesty test +/// (`n_c1_manifest_is_honest` in `nexus-spoke-adapter`) covers the +/// manifest-side contract (exact op list + production-orchestrator backing). +/// +/// No network needed — the builder is host_id-injectable and hermetic. +#[test] +fn n_c1_manifest_served_ops_match_dispatch_both_directions() { + let manifest = nexus_manifest(TEST_HOST_ID); + let wire = serde_json::to_value(&manifest).expect("manifest serializes"); + let advertised = wire["extensions"]["nexus"]["served_ops"] + .as_array() + .expect("extensions.nexus.served_ops array present") + .iter() + .map(|op| op.as_str().expect("served op is a string").to_string()) + .collect::>(); + + // (a) Every capability advertised in the manifest is actually served by + // the dispatch. + for op in &advertised { + assert!( + super::invoke::SERVED_OPS.contains(&op.as_str()), + "manifest advertises op {op:?} but the dispatch does not serve it" + ); + } + + // (b) Every op served by the dispatch is advertised in the manifest. + for op in super::invoke::SERVED_OPS { + assert!( + advertised + .iter() + .any(|advertised| advertised.as_str() == op), + "dispatch serves op {op:?} but the manifest does not advertise it" + ); + } + + // The two sets are identical — no extras on either side. + assert_eq!( + advertised, + super::invoke::SERVED_OPS + .iter() + .map(ToString::to_string) + .collect::>(), + "advertised served_ops must equal the dispatch served-op table exactly" + ); +} + /// (b) A non-allowlisted peer is rejected at the handshake — no session, no /// manifest — while the allowlisted pair keeps working afterwards. #[tokio::test] @@ -379,7 +525,7 @@ async fn capability_token_gate_authorizes_and_ops_stay_refused() { issuer_secret, host_peer, vec!["spoke-baseline".into()], - now_plus(3600), + now_plus(0), )); let mut peer_cfg = peer_config(peer_key, vec![host_peer]); peer_cfg.trusted_issuers = vec![issuer_peer]; @@ -388,7 +534,7 @@ async fn capability_token_gate_authorizes_and_ops_stay_refused() { issuer_secret, peer_peer, vec!["spoke-baseline".into()], - now_plus(3600), + now_plus(0), )); let host = start(host_cfg).await; @@ -460,7 +606,7 @@ async fn token_required_host_rejects_tokenless_peer_then_accepts_valid_auth() { &peer_peer.to_string(), &host_peer.to_string(), &["spoke-baseline"], - now_plus(3600), + now_plus(0), ); match session .invoke_with_auth("check", serde_json::json!({ "extensions": {} }), Some(auth)) @@ -471,13 +617,16 @@ async fn token_required_host_rejects_tokenless_peer_then_accepts_valid_auth() { } // An expired proof is rejected auth_failed (the gate validates on every - // invoke). + // invoke). 0.9.1 `issue_capability_token` fail-fast refuses `exp` within + // the clock-skew window of its `now` argument, so the fixture backdates + // the issuance time two hours: the mint-time guards pass, but the token's + // `exp = now + 3600` lands an hour in the past of the verifier's clock. let expired = token_proof( &issuer_secret, &peer_peer.to_string(), &host_peer.to_string(), &["spoke-baseline"], - now_plus(0).saturating_sub(60), + now_plus(0).saturating_sub(7200), ); match session .invoke_with_auth( @@ -496,8 +645,9 @@ async fn token_required_host_rejects_tokenless_peer_then_accepts_valid_auth() { } /// The CLI wiring end-to-end: persisted identity + allowlist file/overlay + -/// device-id host_id + shared manifest builder assemble the architect-locked -/// `ConnectConfig` (exactly `connect start`'s code path) and the node starts. +/// device-id host_id + shared manifest builder + the N-C1 workspace-DB open + +/// per-process adapter + invoke handler assemble the full `connect start` +/// boot path (`build_host_config`) and the node starts. #[tokio::test] async fn cli_wiring_starts_a_node_with_persisted_identity_and_allowlist() { let _guard = network_test_guard().await; @@ -515,11 +665,16 @@ async fn cli_wiring_starts_a_node_with_persisted_identity_and_allowlist() { ) .expect("write allowlist"); - let (config, host_id, allowlist_len) = super::build_config( + // Hermetic workspace DB (the `build_host_config` override seam keeps the + // boot out of the real `~/.nexus42` config). + let db_path = temp.path().join("workspace").join("state.db"); + let (config, host_id, allowlist_len) = super::build_host_config( home, &[cli_peer.to_string()], &["/ip4/127.0.0.1/tcp/0".to_string()], + Some(&db_path), ) + .await .expect("config builds through the CLI path"); // The CLI path resolved a real device id + the file ∪ CLI allowlist. @@ -530,10 +685,13 @@ async fn cli_wiring_starts_a_node_with_persisted_identity_and_allowlist() { "host_id must use the shared host_manifest_port resolution" ); assert_eq!(allowlist_len, 2); - assert_eq!(config.peer_allowlist, vec![file_peer, cli_peer]); + // `PeerScope.peer_ids()` is a sorted set — compare order-independently. + let mut expected_allowlist = vec![file_peer, cli_peer]; + expected_allowlist.sort(); + assert_eq!(config.peer_allowlist, expected_allowlist); assert!( - config.invoke_handler.is_none(), - "N-C0 must never install an invoke handler" + config.invoke_handler.is_some(), + "N-C1 host boot must install the invoke dispatch handler" ); // Manifest fields come from the shared builder (single SSOT). let manifest_json = serde_json::to_value(&config.local_manifest).expect("serialize"); @@ -554,3 +712,921 @@ async fn cli_wiring_starts_a_node_with_persisted_identity_and_allowlist() { node.shutdown().await.expect("node shuts down"); } + +/// N-C1 (V1.153 P1): the Connect invoke dispatch layer end-to-end over the +/// wire. An allowlisted, world-scoped peer runs the three write ops against +/// a hermetic workspace DB inside the host process (per-process adapter); +/// the OCC reject mapping is exercised with a stale-revision second upsert; +/// wrong-world, absent-scope, and non-served ops are denied with +/// `op_unsupported` and zero side effects; the session stays usable. +/// +/// `flavor = "multi_thread"` mirrors the CLI runtime (`connect start` runs +/// on a multi-thread tokio runtime — the dispatch bridge is +/// `block_in_place` + `Handle::block_on`, which requires it). +#[tokio::test(flavor = "multi_thread")] +async fn n_c1_peer_upserts_promotes_relates_with_world_scoping() { + let _guard = network_test_guard().await; + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path(); + let peer_key = fixed_keypair(62); + let outsider_key = fixed_keypair(63); + let peer_peer = peer_key.public().to_peer_id(); + let outsider_peer = outsider_key.public().to_peer_id(); + + const WORLD_A: &str = "wld_test_a"; + const WORLD_B: &str = "wld_test_b"; + + // Hermetic workspace DB (FK rows for both worlds so the production + // adapter's put paths can persist). + let db_path = temp.path().join("workspace").join("state.db"); + let pool = crate::db::Schema::init(&db_path) + .await + .expect("workspace DB initializes"); + seed_world(&pool, "ctr_test", WORLD_A).await; + seed_world(&pool, "ctr_test", WORLD_B).await; + + // Allowlist file: `peer` is world-scoped to WORLD_A with all three write + // ops; `outsider` is allowlisted via the CLI overlay only (no scope ⇒ + // handshake-ok but every write denied — fail-closed). + let allow_path = nexus_home_layout::connect_allowlist_path(home); + std::fs::create_dir_all(allow_path.parent().expect("parent dir")).expect("mkdir"); + std::fs::write( + &allow_path, + serde_json::json!({ "peer_ids": [{ + "peer_id": peer_peer.to_string(), + "world_scope": [WORLD_A], + "op_scope": ["upsert", "promote", "relate"], + }] }) + .to_string(), + ) + .expect("write allowlist"); + + // Boot the host through the full N-C1 CLI path (hermetic DB override). + // The host identity is the persisted `identity.key` from the temp home — + // its real peer id is what the dialing peers must allowlist. + let (config, _, _) = super::build_host_config( + home, + &[outsider_peer.to_string()], + &["/ip4/127.0.0.1/tcp/0".to_string()], + Some(&db_path), + ) + .await + .expect("N-C1 host config builds"); + let host_peer = config.identity.public().to_peer_id(); + let host = start(config).await; + let peer_node = start(peer_config(peer_key, vec![host_peer])).await; + let outsider_node = start(peer_config(outsider_key, vec![host_peer])).await; + let session = peer_node + .connect(host.listen_addrs()[0].clone()) + .await + .expect("scoped peer handshake"); + let outsider_session = outsider_node + .connect(host.listen_addrs()[0].clone()) + .await + .expect("outsider handshake"); + + let peer_claim = serde_json::json!(peer_peer.to_string()); + let outsider_claim = serde_json::json!(outsider_peer.to_string()); + + // 1. upsert round-trip: the scoped peer persists a confirmed entry in + // WORLD_A; the response carries the post-create revision (Some(1)). + let upsert = session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture("kb_a1", "Mira", WORLD_A, "confirmed", None)], + }), + ) + .await + .expect("scoped upsert is served"); + assert_eq!(upsert.payload["knowledge_entries"][0]["entry_id"], "kb_a1"); + assert_eq!( + upsert.payload["knowledge_entries"][0]["revision"], 1, + "fresh create must persist with revision 1" + ); + let stored_rev: Option = sqlx::query_scalar( + "SELECT revision FROM kb_key_blocks WHERE world_id = ? AND key_block_id = ?", + ) + .bind(WORLD_A) + .bind("kb_a1") + .fetch_optional(&pool) + .await + .expect("read persisted row"); + assert_eq!(stored_rev, Some(1), "entry persisted in the workspace DB"); + + // 2. OCC CAS-accept: an update carrying the current revision (1) passes + // the revision match and bumps the persisted revision to 2. + let updated = session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture("kb_a1", "Mira", WORLD_A, "confirmed", Some(1))], + }), + ) + .await + .expect("CAS-accept upsert is served"); + assert_eq!(updated.payload["knowledge_entries"][0]["revision"], 2); + let stored_rev: Option = sqlx::query_scalar( + "SELECT revision FROM kb_key_blocks WHERE world_id = ? AND key_block_id = ?", + ) + .bind(WORLD_A) + .bind("kb_a1") + .fetch_optional(&pool) + .await + .expect("read persisted row"); + assert_eq!(stored_rev, Some(2), "CAS update persisted revision 2"); + + // 3. OCC stale: a candidate revision behind the stored revision maps to + // the locked `stored_revision_stale` envelope code (retry-safe) and the + // stored row is untouched. + match session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture("kb_a1", "Mira", WORLD_A, "confirmed", Some(0))], + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!(envelope.code, "stored_revision_stale"); + } + other => panic!("stale upsert must reject with stored_revision_stale, got {other:?}"), + } + + // 4. OCC conflict: a candidate revision ahead of the stored revision + // maps to the locked `revision_conflict` envelope code. + match session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture("kb_a1", "Mira", WORLD_A, "confirmed", Some(3))], + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!(envelope.code, "revision_conflict"); + } + other => panic!("conflicting upsert must reject with revision_conflict, got {other:?}"), + } + let stored_rev: Option = sqlx::query_scalar( + "SELECT revision FROM kb_key_blocks WHERE world_id = ? AND key_block_id = ?", + ) + .bind(WORLD_A) + .bind("kb_a1") + .fetch_optional(&pool) + .await + .expect("read persisted row"); + assert_eq!( + stored_rev, + Some(2), + "OCC rejects must not mutate the stored row" + ); + + // 3. promote round-trip: a fresh provisional candidate is confirmed. + let promoted = session + .invoke( + "promote", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "candidate": entry_fixture("kb_a2", "Ashford", WORLD_A, "provisional", None), + }), + ) + .await + .expect("scoped promote is served"); + assert_eq!(promoted.payload["knowledge_entry"]["entry_id"], "kb_a2"); + assert_eq!(promoted.payload["knowledge_entry"]["status"], "confirmed"); + + // 4. relate round-trip: a fresh relation between the two entries. + let related = session + .invoke( + "relate", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "relation": { + "schema_version": 1, + "relation_id": "rel_a1", + "relation_type": "related_to", + "from_id": "kb_a1", + "to_id": "kb_a2", + "extensions": { "nexus": { "world_id": WORLD_A } }, + }, + }), + ) + .await + .expect("scoped relate is served"); + assert_eq!(related.payload["relation"]["relation_id"], "rel_a1"); + let rel_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM kb_relationships WHERE world_id = ? AND relationship_id = ?", + ) + .bind(WORLD_A) + .bind("rel_a1") + .fetch_one(&pool) + .await + .expect("count relation rows"); + assert_eq!(rel_count, 1, "relation persisted in the workspace DB"); + + // 5. Wrong-world denied: the same peer targeting WORLD_B gets + // `op_unsupported` and the entry is NOT persisted (zero side effects). + match session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture("kb_b1", "Banished", WORLD_B, "confirmed", None)], + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!(envelope.code, "op_unsupported", "wrong-world upsert denied"); + } + other => panic!("wrong-world upsert must be denied, got {other:?}"), + } + let leaked: Option = + sqlx::query_scalar("SELECT 1 FROM kb_key_blocks WHERE world_id = ? AND key_block_id = ?") + .bind(WORLD_B) + .bind("kb_b1") + .fetch_optional(&pool) + .await + .expect("check for leaked row"); + assert!(leaked.is_none(), "wrong-world entry must not be persisted"); + + // 6. Absent-scope peer denied: the CLI-overlay outsider (no world/op + // scope in the file) is handshake-allowlisted but every write is + // refused — fail-closed. + match outsider_session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": outsider_claim } }, + "knowledge_entries": [entry_fixture("kb_x1", "Nobody", WORLD_A, "confirmed", None)], + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "absent-scope peer write denied" + ); + } + other => panic!("absent-scope upsert must be denied, got {other:?}"), + } + + // 7. Non-served op (N-C0 refusal contract extends into the handler). + assert_op_unsupported(&session, "check").await; + + // 8. Denials consume sequences but leave the session open: a served op + // still round-trips afterwards. + let after = session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture("kb_a3", "Rook", WORLD_A, "confirmed", None)], + }), + ) + .await + .expect("session stays usable after denials"); + assert_eq!(after.payload["knowledge_entries"][0]["entry_id"], "kb_a3"); + + host.shutdown().await.expect("host shuts down"); + peer_node.shutdown().await.expect("peer shuts down"); + outsider_node.shutdown().await.expect("outsider shuts down"); +} + +/// N-C1 fix loop (L2 Critical regression): the orchestrators' stored +/// lookups and CAS updates match on id + revision only (world-agnostic — +/// `WHERE key_block_id = ?` / `AND COALESCE(revision,0) = ?`), so a payload +/// claiming WORLD_A can rewrite a row stored in WORLD_B by replaying the +/// revision the OCC rejects disclose. The dispatch layer must verify the +/// stored row's world against the payload-claimed world BEFORE the +/// orchestrator CAS runs and deny with zero side effects. Covers the +/// update, promote, and relate paths (relate shares the same +/// world-agnostic lookup/CAS shape). +#[tokio::test(flavor = "multi_thread")] +async fn n_c1_cross_world_update_promote_and_relate_are_denied_with_zero_mutation() { + let _guard = network_test_guard().await; + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path(); + let peer_key = fixed_keypair(64); + let peer_peer = peer_key.public().to_peer_id(); + + const WORLD_A: &str = "wld_test_a"; + const WORLD_B: &str = "wld_test_b"; + + // Hermetic workspace DB with both worlds seeded. + let db_path = temp.path().join("workspace").join("state.db"); + let pool = crate::db::Schema::init(&db_path) + .await + .expect("workspace DB initializes"); + seed_world(&pool, "ctr_test", WORLD_A).await; + seed_world(&pool, "ctr_test", WORLD_B).await; + + // Plant world-B rows the world-A peer must NOT be able to touch: a + // confirmed entry (update target), a provisional entry (promote target), + // and a relation between them. Revisions are 1 — the value a hostile + // peer learns from OCC reject details (`actualRevision`/`storeRevision`). + seed_key_block(&pool, "kb_b_update", WORLD_B, "Banished", "confirmed", 1).await; + seed_key_block(&pool, "kb_b_promote", WORLD_B, "Seer", "provisional", 1).await; + // SAFETY: test-only static INSERT with bind params against the known + // 202606290001_kb_relationships.sql schema. + sqlx::query( + "INSERT INTO kb_relationships \ + (relationship_id, world_id, source_entity_id, target_entity_id, relation_type, created_at, updated_at, revision) \ + VALUES (?, ?, ?, ?, 'related_to', 'now', 'now', 1)", + ) + .bind("rel_b1") + .bind(WORLD_B) + .bind("kb_b_update") + .bind("kb_b_promote") + .execute(&pool) + .await + .expect("seed world-B relation"); + + // Allowlist file: the peer is world-scoped to WORLD_A only. + let allow_path = nexus_home_layout::connect_allowlist_path(home); + std::fs::create_dir_all(allow_path.parent().expect("parent dir")).expect("mkdir"); + std::fs::write( + &allow_path, + serde_json::json!({ "peer_ids": [{ + "peer_id": peer_peer.to_string(), + "world_scope": [WORLD_A], + "op_scope": ["upsert", "promote", "relate"], + }] }) + .to_string(), + ) + .expect("write allowlist"); + + let (config, _, _) = super::build_host_config( + home, + &[], + &["/ip4/127.0.0.1/tcp/0".to_string()], + Some(&db_path), + ) + .await + .expect("N-C1 host config builds"); + let host_peer = config.identity.public().to_peer_id(); + let host = start(config).await; + let peer_node = start(peer_config(peer_key, vec![host_peer])).await; + let session = peer_node + .connect(host.listen_addrs()[0].clone()) + .await + .expect("scoped peer handshake"); + let peer_claim = serde_json::json!(peer_peer.to_string()); + + // 1. Cross-world UPDATE denied: the correct stored revision (1) is + // replayed while the payload claims WORLD_A. + match session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture( + "kb_b_update", + "Banished", + WORLD_A, + "confirmed", + Some(1), + )], + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "cross-world update must be denied" + ); + } + other => panic!("cross-world update must be denied, got {other:?}"), + } + + // 2. Cross-world PROMOTE denied (same replay, candidate claims WORLD_A). + match session + .invoke( + "promote", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "candidate": entry_fixture( + "kb_b_promote", + "Seer", + WORLD_A, + "provisional", + Some(1), + ), + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "cross-world promote must be denied" + ); + } + other => panic!("cross-world promote must be denied, got {other:?}"), + } + + // 3. Cross-world RELATE denied (relation row stored in WORLD_B, payload + // claims WORLD_A with the stored revision replayed). + match session + .invoke( + "relate", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "relation": { + "schema_version": 1, + "relation_id": "rel_b1", + "relation_type": "related_to", + "from_id": "kb_b_update", + "to_id": "kb_b_promote", + "revision": 1, + "extensions": { "nexus": { "world_id": WORLD_A } }, + }, + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "cross-world relate must be denied" + ); + } + other => panic!("cross-world relate must be denied, got {other:?}"), + } + + // Zero side effects: every world-B row is untouched (same world, same + // revision, same status). + let row: (String, i64, String) = sqlx::query_as( + "SELECT world_id, revision, status FROM kb_key_blocks WHERE key_block_id = ?", + ) + .bind("kb_b_update") + .fetch_one(&pool) + .await + .expect("read update row"); + assert_eq!( + row, + (WORLD_B.to_string(), 1, "confirmed".to_string()), + "world-B update target must be untouched" + ); + let row: (String, i64, String) = sqlx::query_as( + "SELECT world_id, revision, status FROM kb_key_blocks WHERE key_block_id = ?", + ) + .bind("kb_b_promote") + .fetch_one(&pool) + .await + .expect("read promote row"); + assert_eq!( + row, + (WORLD_B.to_string(), 1, "provisional".to_string()), + "world-B promote target must be untouched" + ); + let row: (String, i64) = + sqlx::query_as("SELECT world_id, revision FROM kb_relationships WHERE relationship_id = ?") + .bind("rel_b1") + .fetch_one(&pool) + .await + .expect("read relation row"); + assert_eq!( + row, + (WORLD_B.to_string(), 1), + "world-B relation must be untouched" + ); + + // 4. Denials consume sequences but leave the session open: a legitimate + // world-A write still round-trips afterwards. + let after = session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [entry_fixture( + "kb_a_ok", + "Mira", + WORLD_A, + "confirmed", + None, + )], + }), + ) + .await + .expect("session stays usable after cross-world denials"); + assert_eq!(after.payload["knowledge_entries"][0]["entry_id"], "kb_a_ok"); + + host.shutdown().await.expect("host shuts down"); + peer_node.shutdown().await.expect("peer shuts down"); +} + +/// N-C1 fix loop (L2 Important regression): a multi-entry upsert mixing one +/// scoped entry with one world-less entry used to pass the world gate (the +/// world-less entry was filter-mapped OUT of the gate's world set), persist +/// the scoped entry, then fail the world-less entry in the adapter — a +/// partial-batch write surfacing as `internal_error` for a client input +/// error. Every entry must carry `extensions.nexus.world_id`; one missing ⇒ +/// the WHOLE payload is denied and zero entries persist. +#[tokio::test(flavor = "multi_thread")] +async fn n_c1_mixed_payload_missing_world_id_denies_whole_payload() { + let _guard = network_test_guard().await; + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path(); + let peer_key = fixed_keypair(65); + let peer_peer = peer_key.public().to_peer_id(); + + const WORLD_A: &str = "wld_test_a"; + + let db_path = temp.path().join("workspace").join("state.db"); + let pool = crate::db::Schema::init(&db_path) + .await + .expect("workspace DB initializes"); + seed_world(&pool, "ctr_test", WORLD_A).await; + + let allow_path = nexus_home_layout::connect_allowlist_path(home); + std::fs::create_dir_all(allow_path.parent().expect("parent dir")).expect("mkdir"); + std::fs::write( + &allow_path, + serde_json::json!({ "peer_ids": [{ + "peer_id": peer_peer.to_string(), + "world_scope": [WORLD_A], + "op_scope": ["upsert", "promote", "relate"], + }] }) + .to_string(), + ) + .expect("write allowlist"); + + let (config, _, _) = super::build_host_config( + home, + &[], + &["/ip4/127.0.0.1/tcp/0".to_string()], + Some(&db_path), + ) + .await + .expect("N-C1 host config builds"); + let host_peer = config.identity.public().to_peer_id(); + let host = start(config).await; + let peer_node = start(peer_config(peer_key, vec![host_peer])).await; + let session = peer_node + .connect(host.listen_addrs()[0].clone()) + .await + .expect("scoped peer handshake"); + let peer_claim = serde_json::json!(peer_peer.to_string()); + + // The world-less fixture: identical to the canonical entry fixture + // except `extensions.nexus.world_id` is absent. + let worldless = serde_json::json!({ + "schema_version": 1, + "entry_id": "kb_m2", + "entry_type": "character", + "canonical_name": "Drifter", + "status": "confirmed", + "revision": null, + "body": { "summary": "Drifter summary" }, + "extensions": { "nexus": {} }, + }); + + match session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [ + entry_fixture("kb_m1", "Mira", WORLD_A, "confirmed", None), + worldless, + ], + }), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "mixed payload must be denied as a whole" + ); + } + other => panic!("mixed payload must be denied, got {other:?}"), + } + + // Zero entries persisted — the scoped entry must NOT have been written + // before the denial (whole-payload gate, no partial write). + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM kb_key_blocks WHERE key_block_id IN ('kb_m1', 'kb_m2')", + ) + .fetch_one(&pool) + .await + .expect("count persisted rows"); + assert_eq!(count, 0, "no partial write: zero entries persisted"); + + host.shutdown().await.expect("host shuts down"); + peer_node.shutdown().await.expect("peer shuts down"); +} + +/// N-C1 fix loop (L2 Important regression): `SERVED_OPS` is the load-bearing +/// serving gate — `dispatch()` consults it before routing, so the honesty +/// machine check (`n_c1_manifest_served_ops_match_dispatch_both_directions`) +/// transitively enforces manifest ⇔ actual dispatch routing. This test +/// pins the other half of that lockstep: EVERY op the const advertises must +/// actually round-trip through a dispatch match arm. Removing an arm (or +/// growing `SERVED_OPS` without a matching arm) makes the op fall through +/// to the `op_unsupported` refusal and this loop fails — drift the honesty +/// check alone cannot see, because it only compares manifest ⇔ const. +#[tokio::test(flavor = "multi_thread")] +async fn n_c1_every_served_op_advertised_by_the_const_actually_routes() { + let _guard = network_test_guard().await; + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path(); + let peer_key = fixed_keypair(66); + let peer_peer = peer_key.public().to_peer_id(); + + const WORLD_A: &str = "wld_test_a"; + + // Hermetic workspace DB with the world seeded. + let db_path = temp.path().join("workspace").join("state.db"); + let pool = crate::db::Schema::init(&db_path) + .await + .expect("workspace DB initializes"); + seed_world(&pool, "ctr_test", WORLD_A).await; + + // The peer is scoped to exactly the op set the const advertises (the + // allowlist file is built from the const itself), so the loop below can + // only fail if a served op does not route. + let allow_path = nexus_home_layout::connect_allowlist_path(home); + std::fs::create_dir_all(allow_path.parent().expect("parent dir")).expect("mkdir"); + std::fs::write( + &allow_path, + serde_json::json!({ "peer_ids": [{ + "peer_id": peer_peer.to_string(), + "world_scope": [WORLD_A], + "op_scope": super::invoke::SERVED_OPS, + }] }) + .to_string(), + ) + .expect("write allowlist"); + + let (config, _, _) = super::build_host_config( + home, + &[], + &["/ip4/127.0.0.1/tcp/0".to_string()], + Some(&db_path), + ) + .await + .expect("N-C1 host config builds"); + let host_peer = config.identity.public().to_peer_id(); + let host = start(config).await; + let peer_node = start(peer_config(peer_key, vec![host_peer])).await; + let session = peer_node + .connect(host.listen_addrs()[0].clone()) + .await + .expect("scoped peer handshake"); + let peer_claim = serde_json::json!(peer_peer.to_string()); + + // Relate's `kb_relationships` FKs require both endpoints to exist, so + // pre-create the pair it references before the loop; the loop itself + // then only uses fresh ids (order-independent of the const's iteration). + let pair = session + .invoke( + "upsert", + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [ + entry_fixture("kb_loop_pair_1", "PairOne", WORLD_A, "confirmed", None), + entry_fixture("kb_loop_pair_2", "PairTwo", WORLD_A, "confirmed", None), + ], + }), + ) + .await + .expect("pre-create the relate pair"); + assert_eq!( + pair.payload["knowledge_entries"][0]["entry_id"], + "kb_loop_pair_1" + ); + + // The regression loop: every op the const advertises must route through + // a dispatch match arm. A removed arm surfaces here as the `op_unsupported` + // refusal (the gate passes the op, the match falls through) and fails + // the invoke — the exact drift the const-binding prevents. + for op in super::invoke::SERVED_OPS { + let payload = match op { + "upsert" => serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "knowledge_entries": [ + entry_fixture("kb_loop_upsert", "LoopUpsert", WORLD_A, "confirmed", None), + ], + }), + "promote" => serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "candidate": entry_fixture( + "kb_loop_promote", + "LoopPromote", + WORLD_A, + "provisional", + None, + ), + }), + "relate" => serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "relation": { + "schema_version": 1, + "relation_id": "rel_loop_1", + "relation_type": "related_to", + "from_id": "kb_loop_pair_1", + "to_id": "kb_loop_pair_2", + "extensions": { "nexus": { "world_id": WORLD_A } }, + }, + }), + other => panic!( + "SERVED_OPS advertises op {other:?} but the routing-loop test has no \ + payload fixture for it — add one so the new op is proven to route" + ), + }; + session.invoke(op, payload).await.unwrap_or_else(|error| { + panic!( + "SERVED_OPS advertises op {op:?} but dispatch does not route it \ + (dispatch-arm drift?): {error:?}" + ) + }); + } + + host.shutdown().await.expect("host shuts down"); + peer_node.shutdown().await.expect("peer shuts down"); +} + +/// N-C1 fix loop (plan QC, QC1 F-001 + QC2 W-2 regression): the relate +/// CREATE path used to skip the stored-world gate entirely — the relation +/// row does not exist yet, so the relation-row world check is a no-op, and +/// `kb_relationships` FKs are single-column on `key_block_id` (world- +/// agnostic). A peer scoped ONLY to WORLD_A could therefore mint a world-A +/// relation whose endpoints are world-B entry ids (cross-world edge + an +/// id-existence oracle via insert success vs FK/`internal_error` +/// differential). The gate must resolve `from_id` / `to_id` on the create +/// path and require their stored worlds to equal the claimed relation +/// world; mismatch or missing endpoint denies the whole payload with zero +/// insert. Endpoints are immutable on the update path (the update port +/// carries no endpoint fields), so the create-path check closes the gap. +#[tokio::test(flavor = "multi_thread")] +async fn n_c1_relate_create_rejects_foreign_world_endpoints() { + let _guard = network_test_guard().await; + let temp = tempfile::tempdir().expect("tempdir"); + let home = temp.path(); + let peer_key = fixed_keypair(67); + let peer_peer = peer_key.public().to_peer_id(); + + const WORLD_A: &str = "wld_test_a"; + const WORLD_B: &str = "wld_test_b"; + + // Hermetic workspace DB with both worlds seeded. + let db_path = temp.path().join("workspace").join("state.db"); + let pool = crate::db::Schema::init(&db_path) + .await + .expect("workspace DB initializes"); + seed_world(&pool, "ctr_test", WORLD_A).await; + seed_world(&pool, "ctr_test", WORLD_B).await; + + // Endpoint rows: a same-world pair in WORLD_A and a foreign pair in + // WORLD_B (the peer is scoped to WORLD_A only — the world-B entries + // must be unreachable as relation endpoints). + seed_key_block(&pool, "kb_a_e1", WORLD_A, "Mira", "confirmed", 1).await; + seed_key_block(&pool, "kb_a_e2", WORLD_A, "Ashford", "confirmed", 1).await; + seed_key_block(&pool, "kb_b_e1", WORLD_B, "Banished", "confirmed", 1).await; + seed_key_block(&pool, "kb_b_e2", WORLD_B, "Seer", "confirmed", 1).await; + + // Allowlist file: the peer is world-scoped to WORLD_A only. + let allow_path = nexus_home_layout::connect_allowlist_path(home); + std::fs::create_dir_all(allow_path.parent().expect("parent dir")).expect("mkdir"); + std::fs::write( + &allow_path, + serde_json::json!({ "peer_ids": [{ + "peer_id": peer_peer.to_string(), + "world_scope": [WORLD_A], + "op_scope": ["upsert", "promote", "relate"], + }] }) + .to_string(), + ) + .expect("write allowlist"); + + let (config, _, _) = super::build_host_config( + home, + &[], + &["/ip4/127.0.0.1/tcp/0".to_string()], + Some(&db_path), + ) + .await + .expect("N-C1 host config builds"); + let host_peer = config.identity.public().to_peer_id(); + let host = start(config).await; + let peer_node = start(peer_config(peer_key, vec![host_peer])).await; + let session = peer_node + .connect(host.listen_addrs()[0].clone()) + .await + .expect("scoped peer handshake"); + let peer_claim = serde_json::json!(peer_peer.to_string()); + + let relate_payload = |relation_id: &str, from_id: &str, to_id: &str| { + serde_json::json!({ + "extensions": { "nexus": { "peer_id": peer_claim } }, + "relation": { + "schema_version": 1, + "relation_id": relation_id, + "relation_type": "related_to", + "from_id": from_id, + "to_id": to_id, + "extensions": { "nexus": { "world_id": WORLD_A } }, + }, + }) + }; + + // 1. Both endpoints in WORLD_B: denied (`op_unsupported` family). + match session + .invoke("relate", relate_payload("rel_new_b", "kb_b_e1", "kb_b_e2")) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "cross-world relate create must be denied" + ); + } + other => panic!("cross-world relate create must be denied, got {other:?}"), + } + + // 2. Mixed endpoints (from WORLD_A, to WORLD_B): denied — BOTH + // endpoints must be verified, not just the first. + match session + .invoke( + "relate", + relate_payload("rel_new_mix", "kb_a_e1", "kb_b_e1"), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "mixed-world relate create must be denied" + ); + } + other => panic!("mixed-world relate create must be denied, got {other:?}"), + } + + // 3. Missing endpoint (no stored row at all): denied — never surfaced + // as an FK/`internal_error` id-existence oracle. + match session + .invoke( + "relate", + relate_payload("rel_new_ghost", "kb_ghost", "kb_a_e1"), + ) + .await + { + Err(InvokeError::Wire(envelope)) => { + assert_eq!( + envelope.code, "op_unsupported", + "relate to a missing endpoint must be denied" + ); + } + other => panic!("relate to a missing endpoint must be denied, got {other:?}"), + } + + // Zero inserts: none of the denied relations persisted. + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM kb_relationships \ + WHERE relationship_id IN ('rel_new_b', 'rel_new_mix', 'rel_new_ghost')", + ) + .fetch_one(&pool) + .await + .expect("count relation rows"); + assert_eq!(count, 0, "denied relates must insert zero rows"); + + // 4. Same-world relate (both endpoints in WORLD_A) still succeeds — + // denials consumed sequences but the session stays usable and the + // legitimate create path is not over-blocked. + let ok = session + .invoke("relate", relate_payload("rel_new_a", "kb_a_e1", "kb_a_e2")) + .await + .expect("same-world relate create is served"); + assert_eq!(ok.payload["relation"]["relation_id"], "rel_new_a"); + let stored: (String, String, String) = sqlx::query_as( + "SELECT world_id, source_entity_id, target_entity_id FROM kb_relationships \ + WHERE relationship_id = ?", + ) + .bind("rel_new_a") + .fetch_one(&pool) + .await + .expect("read persisted relation"); + assert_eq!( + stored, + ( + WORLD_A.to_string(), + "kb_a_e1".to_string(), + "kb_a_e2".to_string() + ), + "same-world relation persisted in the claimed world" + ); + + host.shutdown().await.expect("host shuts down"); + peer_node.shutdown().await.expect("peer shuts down"); +} diff --git a/apps/nexus42/src/commands/connect/invoke.rs b/apps/nexus42/src/commands/connect/invoke.rs new file mode 100644 index 000000000..ee9a80fc8 --- /dev/null +++ b/apps/nexus42/src/commands/connect/invoke.rs @@ -0,0 +1,585 @@ +//! N-C1 Connect invoke dispatch (DF-72, V1.153 P1) — the architect-locked +//! home of the `InvokeHandler` closure. +//! +//! The handler is the product-owned write spine of the Connect Host: it +//! parses the inbound invoke op/payload, resolves the calling peer, gates it +//! through the fail-closed `PeerScope` allowlist (T1), and routes exactly +//! `upsert` / `promote` / `relate` through the production `NexusAdapter` +//! orchestrators (re-exported via `nexus_spoke_adapter`). Contract sources: +//! `.mstar/specs/spoke-adapter-architecture.md` §10.6 and the P1 spec +//! § OCC + error mapping / § World scoping. +//! +//! Every other op — `check` / `assemble` / `project` / `compute` / unknown — +//! is refused with `ErrorEnvelope.code = "op_unsupported"` and zero side +//! effects (the N-C0 refusal contract extends). +//! +//! ## Caller identity (wire reality, documented) +//! +//! The locked spoke-connect 0.9.1 handler signature +//! (`dyn Fn(&str, serde_json::Value) -> Result`) does +//! NOT carry the authenticated session peer — the node calls the closure +//! with `(op, payload)` only. The allowlist handshake is the trust root +//! (only allowlisted peers ever reach the handler); the per-invoke **caller +//! peer id rides in the ops request envelope** under +//! `extensions.nexus.peer_id`, which the dispatch gate resolves and then +//! checks against the peer's `world_scope` / `op_scope`. Resolution is +//! fail-closed: a missing/unparseable `peer_id` denies the op. The target +//! world id(s) are read from the payload entries/relation +//! (`extensions.nexus.world_id` — the canonical carrier the conversion seam +//! uses); a payload without a world id is denied (cannot verify scope). +//! +//! ## World-scope gates (V1.153 P1 fix loop) +//! +//! Two fail-closed gates sit in front of the orchestrators, both with zero +//! side effects: +//! +//! 1. **Whole-payload world-id requirement (Important):** EVERY +//! entry/relation must carry a parseable `extensions.nexus.world_id`. +//! If any entry lacks one the WHOLE payload is denied — the old +//! filter-and-continue shape let a mixed payload pass the gate and fail +//! later in the adapter as a partial-batch write surfaced as +//! `internal_error`. +//! 2. **Stored-world check (Critical):** the orchestrators' stored lookups +//! and CAS updates are world-agnostic (id + revision only), so a payload +//! claiming world A could otherwise rewrite a row stored in world B by +//! replaying the revision the OCC rejects disclose. Before the +//! orchestrator CAS runs, every targeted existing row's stored `world_id` +//! is verified against the payload-claimed `world_id`; a mismatch denies +//! the op. The relate **create** path additionally verifies its +//! endpoints (plan QC, QC1 F-001 / QC2 W-2): `from_id` / `to_id` must +//! exist and their stored worlds must equal the claimed world — +//! `kb_relationships` FKs are single-column on `key_block_id`, so a +//! world-A relation row could otherwise legally reference world-B +//! entries (cross-world edge + id-existence oracle). +//! +//! ## Async bridge +//! +//! The orchestrators are native `async fn` (V1.153 P0 T2) but the handler +//! runs synchronously on the spoke-connect network event loop, so dispatch +//! bridges with `tokio::task::block_in_place` + +//! `Handle::current().block_on` (multi-thread runtime only — both the CLI +//! `main` and `#[tokio::test]` default are multi-thread). The adapter is a +//! **per-process singleton** constructed once at host boot and held for the +//! process lifetime (P1 spec § Process model); per-invoke construction is +//! deliberately avoided. + +use super::allowlist::PeerScope; +use libp2p::PeerId; +use nexus_spoke_adapter::extensions::get_world_id; +use nexus_spoke_adapter::{ + orchestrate_promote, orchestrate_relate, orchestrate_upsert, KnowledgeEntryPort, NexusAdapter, + PromoteRequest, PromoteResponse, RelateRequest, RelateResponse, Relation, + RelationExtensionsKey, RelationPort, SpokeReject, SpokeRejectCode, SpokeResult, UpsertRequest, + UpsertResponse, +}; +use serde_json::{Map, Value}; +use spoke_connect::InvokeHandler; +use spoke_schemas::connect::connect_invoke_response::ErrorEnvelope; +use std::collections::HashMap; +use std::sync::Arc; + +/// The write ops this host serves (N-C1). +/// +/// This const is load-bearing, not declaration-only: [`dispatch`] gates on +/// it before routing, so the host can never serve an op it does not list. +/// The manifest-honesty test +/// (`n_c1_manifest_served_ops_match_dispatch_both_directions` in +/// `commands::connect::interop`) machine-checks this set ⇔ the manifest's +/// advertised `extensions.nexus.served_ops` (`nexus_spoke_adapter`'s +/// `LOCAL_SERVED_OPS`) in both directions — so the manifest ⇔ actual +/// dispatch routing lockstep holds by construction. +pub const SERVED_OPS: [&str; 3] = ["upsert", "promote", "relate"]; + +/// Build the N-C1 `InvokeHandler`: a fail-closed op gate + allowlist +/// world/op scope gate in front of the three `NexusAdapter` orchestrators. +/// +/// The returned closure is `Send + Sync` (the node holds it in an +/// `Arc`); `scope` and `adapter` are captured for the +/// process lifetime — one adapter per Connect process (P1 spec § Process +/// model). +#[must_use] +pub fn build_handler(scope: PeerScope, adapter: Arc>) -> Arc { + Arc::new(move |op: &str, payload: Value| dispatch(&scope, &adapter, op, payload)) +} + +/// One served write op. +#[derive(Debug, Clone, Copy)] +enum Route { + Upsert, + Promote, + Relate, +} + +/// The full dispatch pipeline. Every gate is fail-closed and runs before +/// any orchestrator call, so denials have zero side effects. +fn dispatch( + scope: &PeerScope, + adapter: &NexusAdapter<'static>, + op: &str, + payload: Value, +) -> Result { + // 1. Served-op gate (N-C1): `SERVED_OPS` is the load-bearing serving + // gate — the manifest-honesty machine check + // (`n_c1_manifest_served_ops_match_dispatch_both_directions`) + // verifies the manifest against this const, so dispatch MUST read it + // too, or the check would bind to a declaration-only table. Anything + // outside the const is refused unconditionally, regardless of payload + // shape (N-C0 refusal contract extends); a match arm for an op that + // is not in `SERVED_OPS` is unreachable by construction. + if !SERVED_OPS.contains(&op) { + return Err(unsupported( + op, + "this host serves only upsert / promote / relate", + )); + } + + // 2. Map the gate-passed op to its route. The arms cover exactly the + // `SERVED_OPS` set; the `_` tail below is unreachable for served ops + // (the gate refused everything else) and stays as a defensive + // fallthrough. + let route = match op { + "upsert" => Route::Upsert, + "promote" => Route::Promote, + "relate" => Route::Relate, + _ => { + return Err(unsupported( + op, + "this host serves only upsert / promote / relate", + )); + } + }; + + // 3. Calling peer from the ops envelope (fail-closed — see module docs + // for why the peer id must ride the payload). + let Some(peer) = payload + .pointer("/extensions/nexus/peer_id") + .and_then(Value::as_str) + .and_then(|raw| raw.parse::().ok()) + else { + return Err(denied( + "invoke payload must declare the calling peer id in extensions.nexus.peer_id", + )); + }; + + // 4. Op-scope gate (T1 PeerScope, fail-closed). + if !scope.allows_op(&peer, op) { + return Err(denied(&format!("op {op} is not in this peer's op_scope"))); + } + + // 5. World-scope gate (T1 PeerScope, fail-closed): every target world in + // the payload must be in the peer's `world_scope`. Strictness (fix + // loop, Important): EVERY entry/relation must carry a parseable + // `extensions.nexus.world_id` — a payload where any entry lacks one + // denies the WHOLE payload (no filter-and-continue; that shape let a + // mixed payload pass the gate and fail later as a partial write). + let Some(worlds) = payload_world_ids(route, &payload) else { + return Err(denied( + "invoke payload requires extensions.nexus.world_id on every entry/relation; \ + cannot verify world scope", + )); + }; + if worlds.is_empty() { + return Err(denied( + "invoke payload carries no world id; cannot verify world scope", + )); + } + if let Some(world) = worlds + .iter() + .find(|world| !scope.allows_world(&peer, world)) + { + return Err(denied(&format!( + "world {world} is not in this peer's world_scope" + ))); + } + + // 6. Stored-world gate (fix loop, Critical): the orchestrators' stored + // lookups and CAS updates are world-agnostic (they match on id + + // revision only), so a payload claiming world A could rewrite a row + // stored in world B by replaying the revision the OCC rejects + // disclose. Before the orchestrator CAS runs, verify every targeted + // existing row's stored world_id equals the payload-claimed world_id; + // a mismatch denies with zero side effects. + // + // 7. Route through the orchestrator. The orchestrators are native async + // fn (V1.153 P0 T2) but this closure is sync on the node's event + // loop: bridge with block_in_place + Handle::block_on (multi-thread + // runtime; the CLI main and tokio::test default are multi-thread). + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + verify_stored_worlds(adapter, route, &payload).await?; + route_orchestrator(route, adapter, payload).await + }) + }) +} + +/// Run one served op through its orchestrator and map the outcome to the +/// handler's return type. Rejects map through the locked +/// `SpokeRejectCode → ErrorEnvelope` table (P1 spec § OCC + error mapping). +async fn route_orchestrator( + route: Route, + adapter: &NexusAdapter<'static>, + payload: Value, +) -> Result { + match route { + Route::Upsert => { + let request: UpsertRequest = match serde_json::from_value(payload) { + Ok(request) => request, + Err(error) => return Err(map_reject(&invalid_payload("upsert", &error))), + }; + match orchestrate_upsert(adapter, request).await { + SpokeResult::Ok(response) => serialize_response::(&response), + SpokeResult::Reject(reject) => Err(map_reject(&reject)), + } + } + Route::Promote => { + let request: PromoteRequest = match serde_json::from_value(payload) { + Ok(request) => request, + Err(error) => return Err(map_reject(&invalid_payload("promote", &error))), + }; + match orchestrate_promote(adapter, request).await { + SpokeResult::Ok(response) => serialize_response::(&response), + SpokeResult::Reject(reject) => Err(map_reject(&reject)), + } + } + Route::Relate => { + let request: RelateRequest = match serde_json::from_value(payload) { + Ok(request) => request, + Err(error) => return Err(map_reject(&invalid_payload("relate", &error))), + }; + match orchestrate_relate(adapter, request).await { + SpokeResult::Ok(response) => serialize_response::(&response), + SpokeResult::Reject(reject) => Err(map_reject(&reject)), + } + } + } +} + +/// Read the canonical world-id carrier from a wire object +/// (`extensions.nexus.world_id` — the field the conversion seam writes). +fn world_id_of(value: &Value) -> Option<&str> { + value + .pointer("/extensions/nexus/world_id") + .and_then(Value::as_str) +} + +/// Target world ids inside an ops payload, in wire order — raw JSON reads so +/// the scope gate runs before any typed parse (fail-closed, zero side +/// effects). `extensions.nexus.world_id` is the canonical carrier the +/// conversion seam writes on entries/relations. +/// +/// Strictness (fix loop, Important): EVERY entry/relation must carry a +/// parseable world id. `None` means the payload cannot be scoped as a whole +/// — an entry/relation lacks the carrier (or the container is absent) — and +/// the whole payload is denied. Entries are never filtered out of the set. +fn payload_world_ids(route: Route, payload: &Value) -> Option> { + match route { + Route::Upsert => { + let entries = payload.get("knowledge_entries")?.as_array()?; + let mut worlds = Vec::with_capacity(entries.len()); + for entry in entries { + worlds.push(world_id_of(entry)?.to_string()); + } + Some(worlds) + } + Route::Promote => { + let candidate = payload.get("candidate")?; + Some(vec![world_id_of(candidate)?.to_string()]) + } + Route::Relate => { + let relation = payload.get("relation")?; + Some(vec![world_id_of(relation)?.to_string()]) + } + } +} + +/// Verify the stored-world invariant before any orchestrator CAS (fix loop, +/// Critical): for every payload entry/relation that already exists in +/// storage, the stored row's `world_id` must equal the payload-claimed +/// `world_id`. The orchestrators' lookups and CAS updates match on id + +/// revision only (world-agnostic), so without this gate a peer scoped to +/// world A could rewrite a row stored in world B by replaying the revision +/// disclosed by OCC rejects. Denials happen before any write — zero side +/// effects. +/// +/// Create paths carry no stored *target row*, but the relate create path +/// still has stored **endpoints**: `kb_relationships` FKs are single-column +/// on `key_block_id` (world-agnostic), so a new relation row claimed in +/// world A must not reference entries stored in world B (plan QC, QC1 +/// F-001 / QC2 W-2) — `from_id` / `to_id` are resolved and their stored +/// worlds must equal the claimed world; mismatch or missing endpoint denies +/// the whole payload with zero insert. +async fn verify_stored_worlds( + adapter: &NexusAdapter<'static>, + route: Route, + payload: &Value, +) -> Result<(), ErrorEnvelope> { + match route { + Route::Upsert => { + if let Some(entries) = payload.get("knowledge_entries").and_then(Value::as_array) { + for entry in entries { + let Some(entry_id) = entry.get("entry_id").and_then(Value::as_str) else { + // No id ⇒ no stored row can be targeted; the typed + // parse rejects the payload later. + continue; + }; + let Some(claimed) = world_id_of(entry) else { + // Unreachable: the world gate (step 4) already + // denied any payload with an entry lacking the id. + // Fail closed rather than skip the check. + return Err(denied( + "entry missing extensions.nexus.world_id; cannot verify stored world", + )); + }; + assert_stored_entry_world_matches(adapter, entry_id, claimed).await?; + } + } + } + Route::Promote => { + if let Some(candidate) = payload.get("candidate") { + if let Some(entry_id) = candidate.get("entry_id").and_then(Value::as_str) { + let Some(claimed) = world_id_of(candidate) else { + return Err(denied( + "candidate missing extensions.nexus.world_id; cannot verify stored world", + )); + }; + assert_stored_entry_world_matches(adapter, entry_id, claimed).await?; + } + } + } + Route::Relate => { + if let Some(relation) = payload.get("relation") { + if let Some(relation_id) = relation.get("relation_id").and_then(Value::as_str) { + let Some(claimed) = world_id_of(relation) else { + return Err(denied( + "relation missing extensions.nexus.world_id; cannot verify stored world", + )); + }; + match adapter.get_relation(relation_id).await { + SpokeResult::Ok(stored) => { + if relation_world_id_of(&stored) != Some(claimed) { + return Err(cross_world_denied( + "relation", + relation_id, + relation_world_id_of(&stored), + claimed, + )); + } + } + SpokeResult::Reject(reject) + if reject.code == SpokeRejectCode::RelationNotFound => + { + // Create path (plan QC, QC1 F-001 / QC2 W-2): + // no relation row exists yet, so the stored-row + // check above is a no-op — but the INSERT's FKs + // are single-column on `key_block_id` (world- + // agnostic), so a new world-A relation could + // legally reference world-B entries. Endpoints + // are immutable on the update path (the update + // port carries no endpoint fields), so this + // create-path check is what closes the + // cross-world-edge gap: require each endpoint's + // stored world to equal the claimed world; + // mismatch or missing endpoint denies the whole + // payload with zero insert. + for endpoint in ["from_id", "to_id"] { + let Some(endpoint_id) = + relation.get(endpoint).and_then(Value::as_str) + else { + return Err(denied(&format!( + "relation missing {endpoint}; cannot verify endpoint world" + ))); + }; + assert_relate_endpoint_world_matches(adapter, endpoint_id, claimed) + .await?; + } + } + SpokeResult::Reject(reject) => return Err(map_reject(&reject)), + } + } + } + } + } + Ok(()) +} + +/// Assert an existing entry's stored `world_id` equals the payload-claimed +/// `world_id` (fix loop, Critical). A missing stored entry (create path) needs +/// no check; any other read reject fails closed through the locked reject +/// mapping (a storage fault must not read as a scope denial). +async fn assert_stored_entry_world_matches( + adapter: &NexusAdapter<'static>, + entry_id: &str, + claimed: &str, +) -> Result<(), ErrorEnvelope> { + match adapter.get_knowledge_entry(entry_id).await { + SpokeResult::Ok(stored) => { + let stored_world = get_world_id(&stored); + if stored_world != Some(claimed) { + return Err(cross_world_denied("entry", entry_id, stored_world, claimed)); + } + } + SpokeResult::Reject(reject) if reject.code == SpokeRejectCode::KnowledgeEntryNotFound => {} + SpokeResult::Reject(reject) => return Err(map_reject(&reject)), + } + Ok(()) +} + +/// Assert a relate create-path endpoint exists in storage AND its stored +/// `world_id` equals the relation's claimed world (plan QC, QC1 F-001 / +/// QC2 W-2). Unlike [`assert_stored_entry_world_matches`] (whose create +/// paths legitimately reference not-yet-stored rows), a relate endpoint +/// MUST exist: `kb_relationships` FKs are single-column on `key_block_id`, +/// so a missing endpoint would otherwise persist a dangling edge or +/// surface as an FK `internal_error` — an id-existence oracle via insert +/// success vs FK-failure differential. Denied like the stored-world gate: +/// `op_unsupported` family, zero insert. +async fn assert_relate_endpoint_world_matches( + adapter: &NexusAdapter<'static>, + endpoint_id: &str, + claimed: &str, +) -> Result<(), ErrorEnvelope> { + match adapter.get_knowledge_entry(endpoint_id).await { + SpokeResult::Ok(stored) => { + let stored_world = get_world_id(&stored); + if stored_world != Some(claimed) { + return Err(cross_world_denied( + "entry", + endpoint_id, + stored_world, + claimed, + )); + } + } + SpokeResult::Reject(reject) if reject.code == SpokeRejectCode::KnowledgeEntryNotFound => { + return Err(denied(&format!( + "relation endpoint {endpoint_id} does not exist; cannot verify its world" + ))); + } + SpokeResult::Reject(reject) => return Err(map_reject(&reject)), + } + Ok(()) +} + +/// Read `extensions.nexus.world_id` from a stored spoke `Relation` (the +/// typed map lookup — `RelationExtensionsKey` does not implement +/// `Borrow`, mirroring the adapter's own extension-key pattern). +fn relation_world_id_of(relation: &Relation) -> Option<&str> { + let key = RelationExtensionsKey::try_from("nexus") + .expect("\"nexus\" matches the extensions-key regex"); + relation + .extensions + .get(&key) + .and_then(|namespace| namespace.get("world_id")) + .and_then(Value::as_str) +} + +/// Cross-world stored-mismatch denial: same `op_unsupported` refusal family +/// as the world-scope gate (the plan's locked code for wrong-world), with a +/// human-readable reason naming the row and both worlds. No information +/// about OTHER peers' scopes leaks; the stored world of a row the caller +/// already targets by id is disclosed (consistent with the OCC rejects, +/// which already disclose stored revisions). +fn cross_world_denied( + kind: &str, + id: &str, + stored_world: Option<&str>, + claimed: &str, +) -> ErrorEnvelope { + denied(&format!( + "stored {kind} {id} belongs to world {}; refusing cross-world write (claimed world {claimed})", + stored_world.unwrap_or(""), + )) +} + +/// Locked `SpokeRejectCode → ErrorEnvelope` mapping (P1 spec § OCC + error +/// mapping — verbatim): +/// +/// | `SpokeRejectCode` | `ErrorEnvelope.code` | Retry-safe? | +/// |-------------------|----------------------|-------------| +/// | `KnowledgeEntryAlreadyExists` | `knowledge_entry_already_exists` | yes | +/// | `StoredRevisionStale` | `stored_revision_stale` | yes | +/// | `RevisionConflict` | `revision_conflict` | yes | +/// | `InternalError` | `internal_error` | no | +/// | any other reject | `internal_error` (carries `reject.code`/`message` in `details`) | no | +/// +/// `reject.message` flows into `ErrorEnvelope.message`; `reject.details` +/// (when present) flows into `ErrorEnvelope.details`. +fn map_reject(reject: &SpokeReject) -> ErrorEnvelope { + let code = match reject.code { + SpokeRejectCode::KnowledgeEntryAlreadyExists => "knowledge_entry_already_exists", + SpokeRejectCode::StoredRevisionStale => "stored_revision_stale", + SpokeRejectCode::RevisionConflict => "revision_conflict", + // Locked safe default for every other reject (incl. InternalError). + _ => "internal_error", + }; + let mut details = Map::new(); + if let Some(extra) = &reject.details { + details.extend(extra.clone()); + } + // The safe-default branch preserves the original reject identity so a + // client can distinguish a validation reject from a server fault. + if code == "internal_error" && reject.code != SpokeRejectCode::InternalError { + details.insert( + "reject_code".to_string(), + Value::String(reject.code.as_str().to_string()), + ); + details.insert( + "reject_message".to_string(), + Value::String(reject.message.clone()), + ); + } + ErrorEnvelope { + code: code.to_string(), + message: reject.message.clone(), + details, + extensions: HashMap::default(), + } +} + +/// A synthetic `InvalidInput` reject for a payload the handler cannot +/// deserialize into the typed ops envelope. Mapped through the same locked +/// table as orchestrator rejects (a malformed envelope would have produced +/// the identical `InvalidInput` from the orchestrator's own `wire_convert`). +fn invalid_payload(op: &str, error: &serde_json::Error) -> SpokeReject { + SpokeReject { + code: SpokeRejectCode::InvalidInput, + message: format!("invalid {op} payload: {error}"), + details: None, + } +} + +/// Serialize an orchestrator success response into the opaque ops response +/// success body the wire carries. Serialization cannot fail for a typed +/// spoke response; a failure would be a wire-shape drift, mapped to +/// `internal_error` (server fault). +fn serialize_response(response: &T) -> Result { + serde_json::to_value(response).map_err(|error| ErrorEnvelope { + code: "internal_error".to_string(), + message: format!("orchestrator response serialization failed: {error}"), + details: Map::new(), + extensions: HashMap::default(), + }) +} + +/// Non-served op refusal (N-C0 contract): `op_unsupported`, zero side +/// effects. +fn unsupported(op: &str, reason: &str) -> ErrorEnvelope { + ErrorEnvelope { + code: "op_unsupported".to_string(), + message: format!("op {op} is not supported: {reason}"), + details: Map::new(), + extensions: HashMap::default(), + } +} + +/// Scope-gate denial: same `op_unsupported` refusal family as N-C0 (P1 spec +/// § World scoping — "the spoke `op_unsupported` family"), with a +/// human-readable reason. No information about other peers' scopes leaks. +fn denied(reason: &str) -> ErrorEnvelope { + ErrorEnvelope { + code: "op_unsupported".to_string(), + message: format!("op denied: {reason}"), + details: Map::new(), + extensions: HashMap::default(), + } +} diff --git a/apps/nexus42/src/commands/connect/mod.rs b/apps/nexus42/src/commands/connect/mod.rs index 4fe0f77ee..74105a2c7 100644 --- a/apps/nexus42/src/commands/connect/mod.rs +++ b/apps/nexus42/src/commands/connect/mod.rs @@ -1,10 +1,13 @@ -//! Connect Host commands (DF-72 N-C0) — opt-in feature `connect-host`. +//! Connect Host commands (DF-72 N-C0 → N-C1) — opt-in feature `connect-host`. //! //! `nexus42 connect start` runs a `spoke-connect` node in a **separate OS //! process** (architect lock Q7): signed-hello handshake, allowlist, -//! honest `HostCapabilityManifest`, and **every inbound op refused** via -//! `invoke_handler = None` (architect lock — no `NexusAdapter` on the -//! connect invoke path; N-C1 is a Non-Goal). +//! honest `HostCapabilityManifest`, and — since V1.153 P1 (N-C1) — an +//! inbound **write-op invoke dispatcher** ([`invoke`]) backed by a +//! per-process `NexusAdapter` over the active workspace DB. Every op the +//! host does not serve (`check` / `assemble` / `project` / `compute` / +//! unknown) is refused with `op_unsupported` (the N-C0 refusal contract +//! extends); non-allowlisted peers never reach the handler (handshake). //! //! Topology rules (product draft `fl-r-connect-host-foundation.md` §2.1/§2.6): //! - mDNS is **never** enabled (`spoke-connect/mdns` not in the feature set). @@ -12,19 +15,27 @@ //! `connect start` does (feature-on binary still keeps the daemon unchanged). //! - Identity + allowlist persist under `~/.nexus42/connect/` (home-layout //! path helpers); missing allowlist ⇒ fail-closed (rejects all peers). +//! - N-C1 coexistence with a co-running daemon/CLI is governed by the +//! `SQLite` WAL mode (1 writer + N readers, `DbPool` busy timeout) — the +//! per-Work `nexus-local-db` `runtime_lock` is daemon-internal and the +//! Connect invoke path never acquires it (P1 spec § Process model, +//! corrected). Same-entry write correctness is the orchestrators' OCC CAS. pub mod allowlist; pub mod identity; +// V1.153 P1 N-C1: the `InvokeHandler` closure (architect-locked home). +pub mod invoke; use crate::errors::{CliError, Result}; use clap::Subcommand; use libp2p::Multiaddr; use nexus_home_layout::device_id::get_or_create_device_id; use nexus_spoke_adapter::manifest::build_connect_hello_manifest; -use nexus_spoke_adapter::SpokeResult; +use nexus_spoke_adapter::{NexusAdapter, SpokeResult}; use spoke_connect::{parse_multiaddr, ConnectConfig, SpokeConnectNode}; use std::collections::HashMap; use std::path::Path; +use std::sync::Arc; /// Default listen multiaddr when `--listen` is not given (loopback only — /// binding a routable interface is an explicit operator choice, N-C0 §5.3). @@ -33,7 +44,8 @@ const DEFAULT_LISTEN: &str = "/ip4/127.0.0.1/tcp/0"; /// Connect Host subcommands. #[derive(Debug, Subcommand)] pub enum ConnectCommand { - /// Start the Connect Host node (N-C0: handshake + manifest; all ops refused) + /// Start the Connect Host node (N-C1: handshake + manifest + world-scoped + /// upsert/promote/relate invoke dispatch; all other ops refused) Start { /// Peer IDs to allowlist for this run (repeatable; unioned with /// `~/.nexus42/connect/allowlist.json`). @@ -56,27 +68,27 @@ pub async fn run(command: ConnectCommand) -> Result<()> { } } -/// Wire the N-C0 `ConnectConfig` exactly per the architect lock: -/// `invoke_handler = None` (every inbound invoke answered `op_unsupported`), -/// empty `op_capability_requirements` / `trusted_issuers`, -/// `require_capability_token = false`, provider `None`, handshake timeout -/// default, mDNS not compiled. The manifest comes from the **single builder -/// SSOT** shared with `HostManifestPort` (JSON round-trip to the -/// `connect_hello` wire type). +/// Run the full N-C1 `connect start` boot: the N-C0 assembly +/// ([`build_config`]) + the active-workspace DB open + the per-process +/// `NexusAdapter` + the [`invoke::build_handler`] wiring — exactly the +/// shared boot shape the P1 spec § Process model locks (shared with +/// `nexus-runtime` in P2). Every inbound op that is not served is answered +/// `op_unsupported` by the handler (N-C0 refusal contract extends). async fn start(allow_peer: Vec, listen: Vec) -> Result<()> { // Raw home: the home-layout identity/allowlist helpers join `.nexus42` // themselves. The device-id resolution mirrors `host_manifest_port` // exactly so the Connect host_id is the SAME value HostManifestPort // advertises (single builder SSOT). let home = crate::config::user_home_dir()?; - let (config, host_id, allowlist_len) = build_config(&home, &allow_peer, &listen)?; + let (config, host_id, allowlist_len) = + build_host_config(&home, &allow_peer, &listen, None).await?; // 7. Start the node; block on the tokio runtime until SIGINT. let node = SpokeConnectNode::start(config) .await .map_err(|e| CliError::Config(format!("connect node start failed: {e}")))?; - eprintln!("nexus42 connect start: Connect Host (N-C0) listening"); + eprintln!("nexus42 connect start: Connect Host (N-C1) listening"); eprintln!(" peer_id: {}", node.local_peer_id()); eprintln!(" host_id: {host_id}"); for addr in node.listen_addrs() { @@ -85,7 +97,9 @@ async fn start(allow_peer: Vec, listen: Vec) -> Result<()> { eprintln!( " allowlisted peers: {allowlist_len} (fail-closed; add via allowlist.json or --allow-peer)" ); - eprintln!(" invokes: all refused (op_unsupported; invoke_handler = None)"); + eprintln!( + " invokes: upsert/promote/relate served (world-scoped); all other ops refused (op_unsupported)" + ); eprintln!(" press Ctrl-C to stop"); tokio::signal::ctrl_c() @@ -99,7 +113,9 @@ async fn start(allow_peer: Vec, listen: Vec) -> Result<()> { } /// Assemble the architect-locked N-C0 `ConnectConfig` from the on-disk -/// state + CLI inputs (steps 1–6 of `start`). +/// state + CLI inputs (steps 1–6 of `start`). The N-C1 pieces (workspace DB +/// open + adapter + invoke handler) are added by [`build_host_config`], so +/// this function stays pure (no I/O beyond the identity/allowlist reads). /// /// `home` is the **raw** user home (`$HOME`): identity/allowlist helpers /// join `.nexus42` themselves. The device-id is resolved via @@ -107,8 +123,9 @@ async fn start(allow_peer: Vec, listen: Vec) -> Result<()> { /// `host_manifest_port::resolve_device_id_from_standard_home` uses, so the /// Connect `host_id` always equals the manifest's `host_id`. /// -/// Returns the config, the resolved `host_id` (for start-up logging), and -/// the effective allowlist length. +/// Returns the config, the resolved `host_id` (for start-up logging), the +/// effective allowlist length, and the resolved `PeerScope` (consumed by the +/// N-C1 dispatch gate in [`invoke`]). /// /// # Errors /// [`CliError`] on identity/allowlist/manifest/listen failures — see the @@ -117,7 +134,7 @@ fn build_config( home: &Path, allow_peer: &[String], listen: &[String], -) -> Result<(ConnectConfig, String, usize)> { +) -> Result<(ConnectConfig, String, usize, allowlist::PeerScope)> { // 1. Identity: `~/.nexus42/connect/identity.key` (Ed25519, create-once 0600). let identity = identity::load_or_create_identity(home)?; @@ -129,7 +146,10 @@ fn build_config( .map_err(|e| CliError::Config(format!("device id unavailable: {e}")))?; // 3. Allowlist: file ∪ `--allow-peer*`; missing file ⇒ empty ⇒ fail-closed. - let peer_allowlist = allowlist::load(home, allow_peer)?; + // `load` resolves the N-C1 `PeerScope` (per-peer world/op scope for + // the dispatch gate); the flat id set feeds the handshake allowlist. + let peer_scope = allowlist::load(home, allow_peer)?; + let peer_allowlist = peer_scope.peer_ids(); let allowlist_len = peer_allowlist.len(); // 4. Listen multiaddrs from `--listen` (default loopback ephemeral port). @@ -152,7 +172,8 @@ fn build_config( } }; - // 6. Architect-locked ConnectConfig (N-C0: no inbound op dispatch). + // 6. Architect-locked ConnectConfig (the invoke handler is installed by + // build_host_config once the workspace adapter exists). let config = ConnectConfig { identity, peer_allowlist, @@ -165,8 +186,75 @@ fn build_config( require_capability_token: false, capability_token_provider: None, }; + Ok((config, host_id, allowlist_len, peer_scope)) +} + +/// Full N-C1 host boot: [`build_config`] + workspace DB open + per-process +/// `NexusAdapter` + [`invoke::build_handler`] — the `connect start` shape. +/// +/// DF-73: the `nexus-runtime` bin shares this boot; `run_daemon` is never +/// called. +/// +/// `workspace_db` overrides the resolved active-workspace DB path (hermetic +/// tests); `None` resolves it by the daemon rules: active workspace from the +/// `~/.nexus42` `CliConfig` (`active_creator_id` + +/// `active_workspace_slug_by_creator` → `resolve_state_db_path`). +/// +/// # Errors +/// [`CliError`] on N-C0 assembly, workspace resolution, or DB open failures. +pub async fn build_host_config( + home: &Path, + allow_peer: &[String], + listen: &[String], + workspace_db: Option<&Path>, +) -> Result<(ConnectConfig, String, usize)> { + let (mut config, host_id, allowlist_len, peer_scope) = build_config(home, allow_peer, listen)?; + + // Plan QC (QC2 W-1) cheap hardening: warn at boot when more than one + // allowlisted peer holds write scope — the per-invoke caller peer_id is + // payload-carried (spoofable), so per-peer scoping silently degrades to + // the union of all scopes. Warning only (not a refusal): the operator + // may have a legitimate reason; the E2 fix is session-bound identity. + allowlist::warn_multi_write_peer(&peer_scope, &mut std::io::stderr()); + + // N-C1: workspace DB open (WAL pool via the shared Schema initializer — + // coexistence with a co-running daemon is WAL-governed, not + // runtime_lock-governed; P1 spec § Process model) + the per-process + // adapter singleton + the invoke dispatch handler. + let pool = open_workspace_pool(workspace_db).await?; + let adapter = Arc::new(NexusAdapter::new(pool)); + config.invoke_handler = Some(invoke::build_handler(peer_scope, adapter)); + Ok((config, host_id, allowlist_len)) } +/// Open the active-workspace `SQLite` pool (WAL mode, migrations applied) — +/// the `DbPool` the per-process `NexusAdapter` runs against. +/// +/// `workspace_db` is a test/embedding seam; `None` resolves the path by the +/// daemon rules (active workspace from `~/.nexus42` config). +/// +/// # Errors +/// [`CliError::Config`] when the active workspace cannot be resolved +/// (fail-closed: the host refuses to boot without a workspace), or +/// [`CliError`] from the DB open/migration path. +async fn open_workspace_pool(workspace_db: Option<&Path>) -> Result { + let db_path = if let Some(path) = workspace_db { + path.to_path_buf() + } else { + let config = crate::config::CliConfig::load() + .map_err(|e| CliError::Config(format!("active workspace resolution failed: {e}")))?; + crate::config::resolve_state_db_path(&config) + .map_err(|e| CliError::Config(format!("active workspace resolution failed: {e}")))? + }; + let pool = crate::db::Schema::init(&db_path).await.map_err(|e| { + CliError::Other(format!( + "workspace DB open failed at {}: {e}", + db_path.display() + )) + })?; + Ok(pool) +} + #[cfg(all(test, feature = "connect-host"))] mod interop; diff --git a/apps/nexus42/src/commands/creator/kb/rescan.rs b/apps/nexus42/src/commands/creator/kb/rescan.rs index 310d881b6..7f8db0a02 100644 --- a/apps/nexus42/src/commands/creator/kb/rescan.rs +++ b/apps/nexus42/src/commands/creator/kb/rescan.rs @@ -495,6 +495,8 @@ async fn extract_per_chapter( /// Acquire the T-B P0 advisory file lock for a work-scoped rescan, mapping /// `FileLockError` → the dual exit-code contract (Locked → 75, Io → 78). +/// Unix-only — the `file_lock` module is `#[cfg(unix)]` (V1.153 P2 T2). +#[cfg(unix)] fn acquire_work_lock( work_dir: &std::path::Path, ) -> Result { @@ -509,6 +511,14 @@ fn acquire_work_lock( } } +/// V1.153 P2 T2: no advisory flock on Windows (the `file_lock` module is +/// unix-only); shared-DB write access is WAL-governed (P2 spec § Coexistence) +/// — no-op so the CLI proceeds unlocked. +#[cfg(not(unix))] +fn acquire_work_lock(_work_dir: &std::path::Path) -> Result<()> { + Ok(()) +} + /// Upsert one row per cross-chapter aggregate + remove stale pending /// candidates whose name vanished from ALL chapters. // CLI helper — single-threaded tokio; Send not required. Argument count diff --git a/apps/nexus42/src/commands/creator/run.rs b/apps/nexus42/src/commands/creator/run.rs index 18554c34e..0e66b65ee 100644 --- a/apps/nexus42/src/commands/creator/run.rs +++ b/apps/nexus42/src/commands/creator/run.rs @@ -103,6 +103,7 @@ pub async fn handle_run(cmd: RunCommand, config: &CliConfig) -> Result<()> { // resolve a work_ref (no workspace, no DB, work hasn't been bootstrapped), // skip the lock and let the mutation proceed (no-lock degradation is // acceptable when there is no workspace to anchor the lock to). + #[cfg(unix)] let _file_lock = { let maybe_lock = if let Ok(db_path) = crate::config::resolve_state_db_path(config) { if let Ok(pool) = crate::db::Schema::init(&db_path).await { @@ -149,6 +150,12 @@ pub async fn handle_run(cmd: RunCommand, config: &CliConfig) -> Result<()> { maybe_lock }; + #[cfg(not(unix))] + // V1.153 P2 T2: the file_lock module is unix-only (flock); on Windows the + // shared-DB write access is WAL-governed (P2 spec § Coexistence) — no + // advisory lock, proceed unlocked. + let _file_lock = (); + // FL-E stage-advance presets: dispatch to stage_advance. if let Some(target_stage) = stage_for_preset(&preset_id) { tracing::info!( diff --git a/apps/nexus42/src/commands/creator/works/cron.rs b/apps/nexus42/src/commands/creator/works/cron.rs index adab710da..106f7c146 100644 --- a/apps/nexus42/src/commands/creator/works/cron.rs +++ b/apps/nexus42/src/commands/creator/works/cron.rs @@ -659,6 +659,7 @@ async fn handle_set( // V1.51 T-B P0: acquire advisory file lock before mutating works.schedule_json. // The lock is released on drop when this function returns. + #[cfg(unix)] let _file_lock = { let work_dir = workspace_dir.join("Works").join(work_ref); // Best-effort: only acquire if the work directory exists (production path). @@ -681,6 +682,12 @@ async fn handle_set( } }; + #[cfg(not(unix))] + // V1.153 P2 T2: the file_lock module is unix-only (flock); on Windows the + // shared-DB write access is WAL-governed (P2 spec § Coexistence) — no + // advisory lock, proceed unlocked. + let _file_lock = (); + // R-V150P0-W5 (resolved in T-A P1): transactional compare-and-swap write. // The previous path (`get → apply → set`) was an unconditional // read-modify-write with a TOCTOU window. The daemon-side cron evaluator diff --git a/apps/nexus42/src/commands/creator/world/kb/mod.rs b/apps/nexus42/src/commands/creator/world/kb/mod.rs index f40a0440f..2f5c7e424 100644 --- a/apps/nexus42/src/commands/creator/world/kb/mod.rs +++ b/apps/nexus42/src/commands/creator/world/kb/mod.rs @@ -721,6 +721,7 @@ pub async fn kb_adopt( // V1.51 T-B P0: acquire advisory file lock before the DB transaction (W-001). // Resolve work_ref from the candidate's work_id, then acquire the lock. + #[cfg(unix)] let _file_lock = if let (Some(ws_dir), Some(ref wid)) = (workspace_dir, candidate.work_id.as_deref()) { let work_ref: Option = @@ -756,6 +757,12 @@ pub async fn kb_adopt( None }; + #[cfg(not(unix))] + // V1.153 P2 T2: the file_lock module is unix-only (flock); on Windows the + // shared-DB write access is WAL-governed (P2 spec § Coexistence) — no + // advisory lock, proceed unlocked. + let _file_lock = (); + // Parse proposed body. let body: WorldKbBody = serde_json::from_str(candidate.proposed_payload.as_deref().unwrap_or("{}")) diff --git a/apps/nexus42/src/commands/platform/context.rs b/apps/nexus42/src/commands/platform/context.rs index 8c73cc7ea..6d7194598 100644 --- a/apps/nexus42/src/commands/platform/context.rs +++ b/apps/nexus42/src/commands/platform/context.rs @@ -675,6 +675,7 @@ pub async fn run_assemble_moment( } else { nexus_spoke_adapter::adapter::NexusAdapter::new(pool.clone()) .list_hop_edges_for_world(wid) + .await .ok() .filter(|edges| !edges.is_empty()) }; diff --git a/apps/nexus42/tests/kb_rescan.rs b/apps/nexus42/tests/kb_rescan.rs index 106d393bf..2e72bb688 100644 --- a/apps/nexus42/tests/kb_rescan.rs +++ b/apps/nexus42/tests/kb_rescan.rs @@ -348,6 +348,7 @@ async fn cross_chapter_idempotent_rerun_produces_empty_candidate_diff() { // ── AC5: advisory lock contention → CliError::Locked (exit 75) ───────────── #[tokio::test] +#[cfg(unix)] // advisory file lock is unix-only (V1.153 P2 T2) async fn cross_chapter_lock_contention_returns_e_lock() { let (pool, dir) = fresh_pool().await; seed_chapter(&pool, dir.path(), 1, "Aelin walked into the tavern.").await; @@ -376,6 +377,7 @@ async fn cross_chapter_lock_contention_returns_e_lock() { // ── AC5: dry-run never acquires the lock (read-only) ─────────────────────── #[tokio::test] +#[cfg(unix)] // advisory file lock is unix-only (V1.153 P2 T2) async fn cross_chapter_dry_run_succeeds_under_lock_contention() { let (pool, dir) = fresh_pool().await; seed_chapter(&pool, dir.path(), 1, "Aelin walked into the tavern.").await; @@ -394,6 +396,7 @@ async fn cross_chapter_dry_run_succeeds_under_lock_contention() { // ── AC5: I/O failure → CliError::LockIo (exit 78) ────────────────────────── #[tokio::test] +#[cfg(unix)] // advisory file lock is unix-only (V1.153 P2 T2) async fn cross_chapter_lock_io_failure_returns_e_lock_io() { let (pool, dir) = fresh_pool().await; seed_chapter(&pool, dir.path(), 1, "Aelin walked into the tavern.").await; diff --git a/apps/nexus42/tests/nexus_runtime_smoke.rs b/apps/nexus42/tests/nexus_runtime_smoke.rs new file mode 100644 index 000000000..42dd08dcc --- /dev/null +++ b/apps/nexus42/tests/nexus_runtime_smoke.rs @@ -0,0 +1,372 @@ +//! P2 T1 — spawned-process smoke test for the headless `nexus-runtime` bin. +//! +//! Builds the REAL binary (`env!("CARGO_BIN_EXE_nexus-runtime")`) and runs +//! it against a hermetic temp `NEXUS42_HOME`, proving the headless boot +//! contract (P2 spec § Subsystem profile / AC-2): +//! +//! 1. **stdout readiness** — the process prints the readiness block +//! (`peer_id` / `host_id` / `listen` / served invokes) on stdout; +//! 2. **serves Connect** — the `runtime_smoke_probe` example (a reference +//! spoke-connect peer, built with the same `connect-host` feature) +//! completes the signed-hello handshake against the spawned process and +//! reads the N-C1 manifest (`extensions.nexus.served_ops` = +//! upsert/promote/relate — the P1 invoke surface, honest by the P1 +//! machine-check); +//! 3. **no HTTP/SPA listener** — the daemon HTTP port refuses connections +//! (the daemon router never boots; in release the SPA fallback is +//! additionally compiled out by `web-embed` OFF). +//! +//! Compiled only with `--features connect-host` (same gate as the bin); +//! the test itself only spawns processes, so the default test graph stays +//! libp2p-free — the dialing lives in the probe example. + +#![cfg(feature = "connect-host")] + +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +/// Peer id of the probe's fixed Ed25519 seed (7) — deterministic, derived +/// once via `runtime_smoke_probe --print-peer-only` (libp2p peer-id +/// derivation is stable). Allowlisted in the seeded `allowlist.json` +/// BEFORE the host boots (fail-closed allowlist: the handshake only +/// succeeds for listed peers). +const DIALER_PEER_ID: &str = "12D3KooWRawPbxPtP1eZaJpumGnyWX2DcUyd3RQnydr3eAto4Az7"; + +/// Canonical nexus home dir name (the layout join used by every home +/// helper; kept literal so the test does not need the layout crate). +const NEXUS_DIR: &str = ".nexus42"; + +/// How long the spawned runtime may take to print readiness. +const READY_TIMEOUT: Duration = Duration::from_secs(30); + +/// Seed a hermetic `~/.nexus42` home for the spawned runtime: the CLI +/// config keys the boot resolves the active workspace from (same keys the +/// creator app writes), plus the Connect allowlist holding the fixed-seed +/// probe peer. +fn seed_home(tmp: &Path) { + let nexus = tmp.join(NEXUS_DIR); + std::fs::create_dir_all(&nexus).expect("create nexus home"); + + std::fs::write( + nexus.join("config.toml"), + "active_creator_id = \"ctr_smoke\"\n\ + [active_workspace_slug_by_creator]\n\ + ctr_smoke = \"default\"\n", + ) + .expect("write config.toml"); + + let connect_dir = nexus.join("connect"); + std::fs::create_dir_all(&connect_dir).expect("create connect dir"); + std::fs::write( + connect_dir.join("allowlist.json"), + format!("{{\"peer_ids\": [\"{DIALER_PEER_ID}\"]}}"), + ) + .expect("write allowlist.json"); +} + +/// The `runtime_smoke_probe` example binary — compiled by cargo (with +/// `connect-host` on) when the test target builds; resolved through the +/// standard workspace target layout. +fn probe_binary() -> PathBuf { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + let target = std::env::var_os("CARGO_TARGET_DIR") + .map_or_else(|| manifest_dir.join("../../target"), PathBuf::from); + let exe = if cfg!(windows) { + "runtime_smoke_probe.exe" + } else { + "runtime_smoke_probe" + }; + target.join(profile).join("examples").join(exe) +} + +/// Spawn the real `nexus-runtime` binary against `home` (via the +/// `NEXUS42_HOME` override) and wait for the stdout readiness block. +/// Returns the child (still running), the readiness lines, and the printed +/// `listen:` multiaddrs. +fn spawn_runtime(home: &Path) -> (Child, Vec, Vec) { + let mut child = Command::new(env!("CARGO_BIN_EXE_nexus-runtime")) + .env("NEXUS42_HOME", home) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn nexus-runtime"); + + // Drain both pipes through channels so the child can never block on a + // full pipe; stderr lines are kept for failure diagnostics. + let stdout = child.stdout.take().expect("runtime stdout"); + let stderr = child.stderr.take().expect("runtime stderr"); + let (line_tx, line_rx) = mpsc::channel::(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if line_tx.send(line).is_err() { + break; + } + } + }); + let (stderr_tx, stderr_rx) = mpsc::channel::(); + thread::spawn(move || { + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if stderr_tx.send(line).is_err() { + break; + } + } + }); + + let deadline = Instant::now() + READY_TIMEOUT; + let mut ready_lines = Vec::new(); + let mut listen_addrs = Vec::new(); + while Instant::now() < deadline { + match line_rx.recv_timeout(Duration::from_millis(250)) { + Ok(line) => { + ready_lines.push(line.clone()); + if let Some(rest) = line.trim().strip_prefix("listen:") { + listen_addrs.push(rest.trim().to_string()); + } + // The readiness block ends with the Ctrl-C hint line. + if !listen_addrs.is_empty() && line.contains("press Ctrl-C to stop") { + break; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + if listen_addrs.is_empty() { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "nexus-runtime did not print a `listen:` readiness line.\n\ + stdout so far:\n{}\nstderr:\n{}", + ready_lines.join("\n"), + stderr_rx.try_iter().collect::>().join("\n") + ); + } + + // Keep the stderr drain alive for the caller's lifetime (detached). + std::mem::forget(stderr_rx); + (child, ready_lines, listen_addrs) +} + +/// RAII guard that kills and reaps the spawned runtime child on drop — +/// including panic unwind — so a failed assertion never orphans the +/// process (the pre-fix test only killed on the success path). +struct RuntimeGuard { + child: Option, +} + +impl RuntimeGuard { + const fn new(child: Child) -> Self { + Self { child: Some(child) } + } +} + +impl Drop for RuntimeGuard { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// Assert the spawned runtime has NO HTTP listener: every TCP listener of +/// the runtime process must be one of the Connect listen multiaddrs it +/// printed. (A well-known-port probe would false-fail under the spec's +/// coexistence model — a creator-facing `nexus42` daemon may legitimately +/// occupy the daemon port while the runtime runs; the guarantee is about +/// THIS process, which never boots the daemon router.) +/// +/// Implemented with `lsof` on unix (present on macOS + Linux CI runners); +/// on Windows the check is a no-op — the property is structural (the +/// headless boot binds only `SpokeConnectNode`; there is no axum bind in +/// the path), and the T2 Windows CI leg smoke-tests `--version`. +fn assert_no_http_listener(child_pid: u32, listen_addrs: &[String]) { + #[cfg(unix)] + { + let ports: Vec = listen_addrs + .iter() + .filter_map(|addr| addr.rsplit('/').next()) + .filter_map(|port| port.parse().ok()) + .collect(); + let out = Command::new("lsof") + .args([ + "-nP", + "-iTCP", + "-sTCP:LISTEN", + "-a", + "-p", + &child_pid.to_string(), + ]) + .output() + .expect("run lsof"); + assert!( + out.status.success(), + "lsof failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines().skip(1) { + let Some(port) = lsof_line_port(line) else { + continue; + }; + assert!( + ports.contains(&port), + "runtime process holds an unexpected TCP listener on port {port} \ + (daemon HTTP / SPA listener?):\n{text}" + ); + } + } + #[cfg(not(unix))] + { + // Structural no-op — see the doc comment. + let _ = (child_pid, listen_addrs); + } +} + +/// Extract the listening port from a `lsof -sTCP:LISTEN` NAME column line. +/// +/// Real lines end with the state suffix — `TCP 127.0.0.1:62488 (LISTEN)` — +/// so the port token is the SECOND-TO-LAST whitespace token +/// (`split_whitespace().rev().nth(1)` — `str` has no `rsplit_whitespace`), +/// NOT `.last()` (which is always `(LISTEN)` and never parses as a port — +/// the pre-fix parser skipped every line, making the no-HTTP assertion +/// vacuous). +fn lsof_line_port(line: &str) -> Option { + line.split_whitespace() + .rev() + .nth(1) + .and_then(|addr| addr.rsplit(':').next()) + .and_then(|port| port.parse::().ok()) +} + +#[test] +fn lsof_line_port_parses_real_listener_lines() { + // Real shapes from `lsof -nP -iTCP -sTCP:LISTEN -a -p `: IPv4 + // loopback (observed: `TCP 127.0.0.1:62488 (LISTEN)`), wildcard, and + // bracketed IPv6. + assert_eq!( + lsof_line_port( + "nexus-runtime 5469 user 14u IPv4 0x8f7d2f5b5e0b4c8f 0t0 TCP 127.0.0.1:62488 (LISTEN)" + ), + Some(62488) + ); + assert_eq!(lsof_line_port("TCP *:62086 (LISTEN)"), Some(62086)); + assert_eq!(lsof_line_port("TCP [::1]:62086 (LISTEN)"), Some(62086)); + + // Regression guard: the vacuous pre-fix parser (`.last()` token, i.e. + // always `(LISTEN)`) extracts no port from ANY real listener line, so + // it could never fail the no-HTTP assertion. If this guard trips, the + // assertion became vacuous again. + for line in [ + "TCP 127.0.0.1:62488 (LISTEN)", + "TCP *:62086 (LISTEN)", + "TCP [::1]:62086 (LISTEN)", + ] { + assert_eq!( + line.split_whitespace() + .last() + .and_then(|tok| tok.rsplit(':').next()) + .and_then(|p| p.parse::().ok()), + None, + "pre-fix parser must not extract a port from {line:?}" + ); + } + + // Malformed / non-listen lines are skipped, never mis-parsed. + assert_eq!(lsof_line_port("TCP 127.0.0.1:62488"), None); + assert_eq!(lsof_line_port(""), None); +} + +#[test] +fn version_flag_prints_crate_version() { + let out = Command::new(env!("CARGO_BIN_EXE_nexus-runtime")) + .arg("--version") + .output() + .expect("run nexus-runtime --version"); + assert!(out.status.success(), "status: {:?}", out.status); + let text = String::from_utf8_lossy(&out.stdout); + assert!( + text.starts_with("nexus-runtime "), + "unexpected --version output: {text:?}" + ); + assert!( + text.contains(env!("CARGO_PKG_VERSION")), + "--version must print the crate version: {text:?}" + ); +} + +#[test] +fn headless_runtime_prints_readiness_serves_connect_and_has_no_http_listener() { + let tmp = tempfile::tempdir().expect("temp dir"); + seed_home(tmp.path()); + + // Boot the real binary against the temp home. The guard kills the + // child on EVERY exit path — success or panic — never orphaning it. + let (child, ready_lines, listen_addrs) = spawn_runtime(tmp.path()); + let runtime_pid = child.id(); + let _guard = RuntimeGuard::new(child); + + // 1. Readiness block: the required lines are present on stdout. + let ready = ready_lines.join("\n"); + for expected in [ + "Connect Host (N-C1) ready", + "peer_id:", + "host_id:", + "allowlisted peers: 1", + "upsert/promote/relate served", + ] { + assert!( + ready.contains(expected), + "readiness block missing {expected:?}:\n{ready}" + ); + } + + // 2. No HTTP/SPA listener: every TCP listener of the runtime process + // is one of its printed Connect listen addrs. + assert_no_http_listener(runtime_pid, &listen_addrs); + + // 3. The reference probe peer dials the host and completes the + // signed-hello handshake; the N-C1 manifest advertises exactly the + // served write ops and the session stays usable. + let host_peer = ready_lines + .iter() + .find_map(|line| line.trim().strip_prefix("peer_id:")) + .expect("peer_id line") + .trim(); + + let probe = probe_binary(); + assert!( + probe.exists(), + "probe example not built — expected at {}", + probe.display() + ); + let out = Command::new(&probe) + .args(["--addr", &listen_addrs[0], "--host-peer", host_peer]) + .output() + .expect("run runtime_smoke_probe"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let status = out.status; + assert!( + status.success(), + "probe failed (status {status})\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + for expected in ["DIAL_OK", "SERVED_OPS=upsert,promote,relate", "SESSION_OK"] { + assert!( + stdout.contains(expected), + "probe output missing {expected:?}:\n{stdout}\nstderr:\n{stderr}" + ); + } + // `_guard` drops here (and on any panic unwind above): the child is + // killed and reaped on every path. +} diff --git a/apps/nexus42/tests/works_status_lock_holder.rs b/apps/nexus42/tests/works_status_lock_holder.rs index 2ee9528f7..e53391dfa 100644 --- a/apps/nexus42/tests/works_status_lock_holder.rs +++ b/apps/nexus42/tests/works_status_lock_holder.rs @@ -3,6 +3,9 @@ //! Tests that `lock_holder` info is correctly read from filesystem. //! Full status JSON integration deferred to QA (requires daemon). +// The advisory lock + its holder file are unix-only (flock; V1.153 P2 T2). +#![cfg(unix)] + use std::path::PathBuf; fn test_work_dir() -> (tempfile::TempDir, PathBuf) { diff --git a/crates/nexus-acp-host/src/transport.rs b/crates/nexus-acp-host/src/transport.rs index cb565bda5..f0a794289 100644 --- a/crates/nexus-acp-host/src/transport.rs +++ b/crates/nexus-acp-host/src/transport.rs @@ -471,17 +471,22 @@ impl AcpSession { #[cfg(windows)] { - // On Windows, directly kill the process + // On Windows, directly kill the process. + // V1.153 P2 T2: `agent_path` is non-Copy and both error closures + // need it (the unix branch avoids this because its first use is + // on a `return` path); clone once per closure — error path only. + let agent_path = self.agent_path.clone(); self.child .kill() .await - .map_err(|e| AcpError::agent_crashed(None, self.agent_path, Some(e.to_string())))?; - - let status = - self.child.wait().await.map_err(|e| { - AcpError::agent_crashed(None, self.agent_path, Some(e.to_string())) + .map_err(|e| { + AcpError::agent_crashed(None, agent_path.clone(), Some(e.to_string())) })?; + let status = self.child.wait().await.map_err(|e| { + AcpError::agent_crashed(None, agent_path.clone(), Some(e.to_string())) + })?; + tracing::info!( agent_id = %self.agent_id, exit_code = ?status.code(), diff --git a/crates/nexus-daemon-runtime/Cargo.toml b/crates/nexus-daemon-runtime/Cargo.toml index 509fa0ce2..88c408329 100644 --- a/crates/nexus-daemon-runtime/Cargo.toml +++ b/crates/nexus-daemon-runtime/Cargo.toml @@ -62,7 +62,11 @@ regex = { workspace = true } serde_yaml = { workspace = true } sha2 = { workspace = true } nix = { version = "0.28", features = ["fs"] } -rust-embed = { version = "8.11.0", default-features = false, features = ["mime-guess"] } +# V1.153 P2 (DF-73): the embedded `apps/web` SPA is compile-time-gated behind +# the `web-embed` feature (default ON; OFF for the slim `nexus-runtime` +# artifact — see the P2 spec § Subsystem profile). `rust-embed` + the +# `static_assets` mod + the api SPA fallback are all gated on it. +rust-embed = { version = "8.11.0", default-features = false, features = ["mime-guess"], optional = true } mime_guess = "2.0.5" # V1.92 P-1 T1 — TLS transport security (self-signed Ed25519 cert via rcgen, # axum-server bind_rustls drop-in for non-loopback listeners). @@ -92,3 +96,8 @@ harness = false [features] test-support = [] +# V1.153 P2 (DF-73): embedded Web UI (rust-embed SPA bytes) — default ON so +# the creator-facing daemon is unchanged; OFF (`--no-default-features`) +# compiles the SPA serving stack out of the `nexus-runtime` artifact. +web-embed = ["dep:rust-embed"] +default = ["web-embed"] diff --git a/crates/nexus-daemon-runtime/src/api/handlers/check.rs b/crates/nexus-daemon-runtime/src/api/handlers/check.rs index 86193af3e..659655c2b 100644 --- a/crates/nexus-daemon-runtime/src/api/handlers/check.rs +++ b/crates/nexus-daemon-runtime/src/api/handlers/check.rs @@ -68,8 +68,8 @@ //! //! # Panics //! -//! `NexusAdapter::new` panics if no tokio multi-threaded runtime is running -//! on the current thread (the daemon uses one — see adapter module docs). +//! None: `NexusAdapter` port methods are natively `async fn` (spoke-operations +//! 0.9.1 surface, V1.153 P0 T2) and run on the handler's runtime. use crate::api::errors::NexusApiError; use crate::api::handlers::works::read_active_creator_id; @@ -157,7 +157,7 @@ pub async fn run_check( // via RuleQueryPort inside orchestrate_check; a real quality-loop // evaluator is a tracked residual. let adapter = NexusAdapter::new(pool.clone()); - let result = orchestrate_check(&adapter, check_req, |_input| SpokeResult::Ok(vec![])); + let result = orchestrate_check(&adapter, check_req, |_input| SpokeResult::Ok(vec![])).await; match result { // Success branch: findings (possibly empty). Round-trip through JSON diff --git a/crates/nexus-daemon-runtime/src/api/handlers/inspector.rs b/crates/nexus-daemon-runtime/src/api/handlers/inspector.rs index e9cc9ffe1..dd575a1e1 100644 --- a/crates/nexus-daemon-runtime/src/api/handlers/inspector.rs +++ b/crates/nexus-daemon-runtime/src/api/handlers/inspector.rs @@ -181,6 +181,7 @@ pub async fn inspect_moment( // empty confirmed graph yields `None` (P0 activation-only behavior). let hop_edges = nexus_spoke_adapter::adapter::NexusAdapter::new(pool.clone()) .list_hop_edges_for_world(req.world_id.as_str()) + .await .ok() .filter(|edges| !edges.is_empty()); // Parity note: the CLI only caps the hop budget when the caller passes diff --git a/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs b/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs index b904c4eb2..7ded0a882 100644 --- a/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs +++ b/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs @@ -19,6 +19,7 @@ use nexus_contracts::{ }; use nexus_home_layout::{user_preset_base_dir, user_preset_bundle_dir}; use serde_json::Value; +#[cfg(unix)] use std::os::fd::AsRawFd; use tracing::info; @@ -39,11 +40,17 @@ const PRESET_MAX_YAML_DEPTH: usize = 10; // ─── Cross-process advisory lock ─────────────────────────────────────────── /// RAII guard holding an exclusive `flock` on the strategy bundle lock file. +/// +/// Unix-only (`flock`); on other platforms a no-op unit guard (V1.153 P2 T2 — +/// the shared home is WAL-governed, and this lock serializes preset-bundle +/// writers which the headless runtime does not run). #[derive(Debug)] +#[cfg(unix)] struct StrategyLockGuard { fd: std::fs::File, } +#[cfg(unix)] impl Drop for StrategyLockGuard { fn drop(&mut self) { let raw_fd = self.fd.as_raw_fd(); @@ -56,10 +63,17 @@ impl Drop for StrategyLockGuard { } } +/// No-op guard on non-unix (V1.153 P2 T2). +#[derive(Debug)] +#[cfg(not(unix))] +struct StrategyLockGuard; + /// Acquire an exclusive advisory lock for a strategy bundle. /// /// Blocks until the lock is available. All mutating strategy operations must /// hold this lock for the entire load → CAS → validate → write sequence. +/// Unix-only (`flock`); on other platforms a no-op (V1.153 P2 T2). +#[cfg(unix)] fn acquire_strategy_lock(bundle_dir: &std::path::Path) -> Result { let lock_path = bundle_dir.join(STRATEGY_LOCK_FILE); if let Some(parent) = lock_path.parent() { @@ -92,6 +106,14 @@ fn acquire_strategy_lock(bundle_dir: &std::path::Path) -> Result Result { + Ok(StrategyLockGuard) +} + // ─── Request helpers ─────────────────────────────────────────────────────── /// Parsed `set` payload for `patch_state`. diff --git a/crates/nexus-daemon-runtime/src/api/handlers/world_kb.rs b/crates/nexus-daemon-runtime/src/api/handlers/world_kb.rs index 8b26d013a..877a3312c 100644 --- a/crates/nexus-daemon-runtime/src/api/handlers/world_kb.rs +++ b/crates/nexus-daemon-runtime/src/api/handlers/world_kb.rs @@ -428,7 +428,9 @@ pub async fn patch_entity( // begin/commit → tx dropped → rolled back, surfaced as Reject. The // handler never holds the tx here, unlike `promote_adopt`'s bound // `tx.commit()`/`rollback()`. - let result = adapter.with_bound_tx(|| orchestrate_upsert(&adapter, spoke_req)); + let result = adapter + .with_bound_tx(|| orchestrate_upsert(&adapter, spoke_req)) + .await; // Confirm success + extract the bumped revision from the persisted entry. let persisted = map_upsert_response(result, pool, &req.entity_id).await?; let new_version = persisted.revision.unwrap_or(0); @@ -661,8 +663,8 @@ fn merge_aliases_into_body(body: &mut WorldKbBody, aliases: &[String]) { /// /// V1.142 P3: single `COMMIT` covers both the orchestrator's KB write and /// `mark_confirmed_in_tx_with_cas`. The handler owns `BEGIN`/`COMMIT`; the -/// adapter joins the open transaction via a shared cell for the synchronous -/// `orchestrate_promote` bridge. +/// adapter joins the open transaction via a shared cell while the native-async +/// `orchestrate_promote` is awaited (`with_bound_tx(...).await`). async fn promote_adopt( state: &WorkspaceState, world_id: &str, @@ -702,7 +704,9 @@ async fn promote_adopt( let adapter = NexusAdapter::new(pool.clone()).with_tx_cell(Arc::clone(&tx_cell)); let spoke_req = build_spoke_promote_request(&kb); - let result = adapter.with_bound_tx(|| orchestrate_promote(&adapter, spoke_req)); + let result = adapter + .with_bound_tx(|| orchestrate_promote(&adapter, spoke_req)) + .await; let knowledge_entry = match map_promote_response(result, pool, &req.job_id, &kb).await { Ok(PromoteAdoptOrchestrateOutcome::RecoveredConfirmed(knowledge_entry)) => { if let Some(tx) = tx_cell.lock().ok().and_then(|mut guard| guard.take()) { @@ -1159,11 +1163,10 @@ fn extract_store_revision(reject: &SpokeReject) -> Option { } /// Re-read the current `kb_key_blocks.revision` for `entity_id` (fallback when -/// a CAS reject's details omit the store revision). `map_upsert_reject` is -/// reached only after the synchronous orchestrator call returns (the adapter's -/// port methods bridge to async `SQLite` via `block_in_place` internally), so -/// by the time we get here the outer handler is back on its normal async path — -/// awaiting the store read is safe and avoids a nested `block_in_place`. +/// a CAS reject's details omit the store revision). Reached after the +/// (now-awaited) orchestrator call returns; the store read is a plain async +/// read on the handler's runtime (adapter ports are natively `async fn` since +/// spoke-operations 0.9.1 — V1.153 P0 T2). async fn reread_entity_revision_sync(pool: &sqlx::SqlitePool, entity_id: &str) -> u64 { let store = kb_store::SqliteKbStore::new(pool.clone()); store @@ -2263,7 +2266,9 @@ async fn patch_relationship_add( // (`put_relation_create` opens + commits its own transaction) — add has no // sibling write, so the unbound path is behavior-equivalent to the previous // `pool.begin()` → INSERT → `commit()` block (mirrors `patch_entity`). - let result = adapter.with_bound_tx(|| orchestrate_relate(&adapter, spoke_req)); + let result = adapter + .with_bound_tx(|| orchestrate_relate(&adapter, spoke_req)) + .await; let row = map_relate_response(result, pool, &relationship_id).await?; Ok(Json(WorldKbPatchRelationshipResponse { @@ -2400,7 +2405,9 @@ async fn patch_relationship_update( let spoke_req = build_spoke_relate_request(&relation); let adapter = NexusAdapter::new(pool.clone()); - let result = adapter.with_bound_tx(|| orchestrate_relate(&adapter, spoke_req)); + let result = adapter + .with_bound_tx(|| orchestrate_relate(&adapter, spoke_req)) + .await; let row = map_relate_response(result, pool, relationship_id).await?; Ok(Json(WorldKbPatchRelationshipResponse { diff --git a/crates/nexus-daemon-runtime/src/api/mod.rs b/crates/nexus-daemon-runtime/src/api/mod.rs index 916282025..7017889d1 100644 --- a/crates/nexus-daemon-runtime/src/api/mod.rs +++ b/crates/nexus-daemon-runtime/src/api/mod.rs @@ -21,7 +21,7 @@ pub mod runtime_lock; pub mod sort; use crate::api::auth_middleware::DaemonApiConfig; -#[cfg(not(debug_assertions))] +#[cfg(all(not(debug_assertions), feature = "web-embed"))] use crate::static_assets; use crate::workspace::WorkspaceState; use axum::{ @@ -698,7 +698,9 @@ pub fn create_router(state: WorkspaceState, auth_config: DaemonApiConfig) -> Rou // unmatched non-API paths. Excluded in debug/test builds so unmatched // paths return the framework 404 (avoids masking API routing bugs in // tests; dev uses the Vite dev-server proxy, never the daemon's `/`). - #[cfg(not(debug_assertions))] + // V1.153 P2 (DF-73): additionally gated on `web-embed` (default ON) so + // the slim `nexus-runtime` artifact has no SPA fallback by construction. + #[cfg(all(not(debug_assertions), feature = "web-embed"))] let router = router.fallback(static_assets::serve_embedded_app); let allowed_origins: Vec = auth_config diff --git a/crates/nexus-daemon-runtime/src/boot.rs b/crates/nexus-daemon-runtime/src/boot.rs index 965f4b19c..e8393e8ee 100644 --- a/crates/nexus-daemon-runtime/src/boot.rs +++ b/crates/nexus-daemon-runtime/src/boot.rs @@ -803,6 +803,7 @@ pub async fn run_daemon(config: DaemonConfig) -> anyhow::Result<()> { // --- Section 7: Signal handlers and panic hook --- let lifecycle_for_signals = Arc::clone(&lifecycle); let state_for_signals = state.clone(); + #[cfg(unix)] tokio::spawn(async move { let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("Failed to register SIGTERM handler"); @@ -827,6 +828,19 @@ pub async fn run_daemon(config: DaemonConfig) -> anyhow::Result<()> { } }); + #[cfg(not(unix))] + // V1.153 P2 T2: tokio::signal::unix is unix-only; Windows gets the + // cross-platform Ctrl+C path (graceful shutdown, same dispatch). + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + tracing::info!("SIGINT received (Ctrl+C)"); + lifecycle_for_signals.dispatch(Event::ShutdownRequested { + source: "signal".into(), + }); + state_for_signals.request_shutdown(); + } + }); + // Set up panic hook let lifecycle_for_panic = Arc::clone(&lifecycle); let previous_hook = std::panic::take_hook(); diff --git a/crates/nexus-daemon-runtime/src/lib.rs b/crates/nexus-daemon-runtime/src/lib.rs index d884422d5..83f2e346d 100644 --- a/crates/nexus-daemon-runtime/src/lib.rs +++ b/crates/nexus-daemon-runtime/src/lib.rs @@ -19,7 +19,11 @@ pub mod pack_import; pub mod path_enrichment; pub mod refresh_scheduler; pub mod stale_findings_watcher; -#[cfg(not(debug_assertions))] +// V1.153 P2 (DF-73): embedded `apps/web` SPA — compiled only when the +// `web-embed` feature is ON (default). The slim `nexus-runtime` artifact +// builds with `--no-default-features --features connect-host`, so the SPA +// bytes never enter that binary (P2 spec § Subsystem profile). +#[cfg(all(not(debug_assertions), feature = "web-embed"))] pub mod static_assets; pub mod tls; pub mod worker_provider; diff --git a/crates/nexus-daemon-runtime/src/pack_import.rs b/crates/nexus-daemon-runtime/src/pack_import.rs index e2644dc18..d061f4f3c 100644 --- a/crates/nexus-daemon-runtime/src/pack_import.rs +++ b/crates/nexus-daemon-runtime/src/pack_import.rs @@ -402,7 +402,7 @@ pub async fn import_pack( } prepare_create_entry(&mut entry, world_id); - match persist_entry_upsert(pool, &entry) { + match persist_entry_upsert(pool, &entry).await { PersistOutcome { outcome: ImportOutcome::Created, .. @@ -497,7 +497,7 @@ pub async fn import_pack( continue; } update_relation_world_id(&mut relation, world_id); - match persist_relation_relate(pool, &relation) { + match persist_relation_relate(pool, &relation).await { PersistOutcome { outcome: ImportOutcome::Overwritten, .. @@ -556,7 +556,7 @@ pub async fn import_pack( relation.revision = None; let renamed = relation.relation_id != pack_relation_id; - match persist_relation_relate(pool, &relation) { + match persist_relation_relate(pool, &relation).await { PersistOutcome { outcome: ImportOutcome::Created, .. @@ -664,7 +664,7 @@ async fn import_renamed_on_entry_id_collision( prepare_create_entry(entry, world_id); remap.insert(pack_entry_id.to_string(), fresh_id.clone()); - match persist_entry_upsert(pool, entry) { + match persist_entry_upsert(pool, entry).await { PersistOutcome { outcome: ImportOutcome::Created, .. @@ -778,7 +778,7 @@ async fn import_renamed_entry( prepare_create_entry(entry, world_id); remap.insert(pack_entry_id.to_string(), fresh_id.clone()); - match persist_entry_upsert(pool, entry) { + match persist_entry_upsert(pool, entry).await { PersistOutcome { outcome: ImportOutcome::Created, .. @@ -842,7 +842,7 @@ async fn import_overwritten_entry( extensions::set_world_id(entry, world_id.to_string()); extensions::set_provenance(entry, None, None, Some(IMPORT_PROVENANCE.to_string())); - match persist_entry_upsert(pool, entry) { + match persist_entry_upsert(pool, entry).await { PersistOutcome { outcome: ImportOutcome::Created, .. @@ -987,10 +987,10 @@ fn prepare_create_entry(entry: &mut KnowledgeEntry, world_id: &str) { extensions::set_provenance(entry, None, None, Some(IMPORT_PROVENANCE.to_string())); } -fn persist_entry_upsert(pool: &SqlitePool, entry: &KnowledgeEntry) -> PersistOutcome { +async fn persist_entry_upsert(pool: &SqlitePool, entry: &KnowledgeEntry) -> PersistOutcome { let upsert_req = build_import_upsert_request(entry); let adapter = NexusAdapter::new(pool.clone()); - match orchestrate_upsert(&adapter, upsert_req) { + match orchestrate_upsert(&adapter, upsert_req).await { nexus_spoke_adapter::SpokeResult::Ok(_) => PersistOutcome { outcome: ImportOutcome::Created, reject_reason: None, @@ -1010,10 +1010,10 @@ fn persist_entry_upsert(pool: &SqlitePool, entry: &KnowledgeEntry) -> PersistOut } } -fn persist_relation_relate(pool: &SqlitePool, relation: &Relation) -> PersistOutcome { +async fn persist_relation_relate(pool: &SqlitePool, relation: &Relation) -> PersistOutcome { let relate_req = build_import_relate_request(relation); let adapter = NexusAdapter::new(pool.clone()); - match orchestrate_relate(&adapter, relate_req) { + match orchestrate_relate(&adapter, relate_req).await { nexus_spoke_adapter::SpokeResult::Ok(_) => PersistOutcome { outcome: if relation.revision.is_some() { ImportOutcome::Overwritten diff --git a/crates/nexus-daemon-runtime/tests/check_api.rs b/crates/nexus-daemon-runtime/tests/check_api.rs index d9edbd907..4cc6006a8 100644 --- a/crates/nexus-daemon-runtime/tests/check_api.rs +++ b/crates/nexus-daemon-runtime/tests/check_api.rs @@ -16,9 +16,9 @@ //! `require_active_creator` gate reads the active creator from the seeded //! `config.toml` (`test_creator`). //! -//! All tests run on a multi-threaded tokio runtime: `NexusAdapter::new` -//! panics under a `current_thread` runtime (`block_in_place` requirement — -//! same rationale as the `world_kb_patch` tests). +//! Tests run on a multi-threaded tokio runtime (retained from the +//! pre-0.9.1 `block_in_place` bridge era; the adapter port methods are now +//! natively `async fn` — V1.153 P0 T2). #![allow(clippy::unwrap_used, clippy::expect_used)] diff --git a/crates/nexus-daemon-runtime/tests/cron_lock_integration.rs b/crates/nexus-daemon-runtime/tests/cron_lock_integration.rs index b16b498d7..558a6f45d 100644 --- a/crates/nexus-daemon-runtime/tests/cron_lock_integration.rs +++ b/crates/nexus-daemon-runtime/tests/cron_lock_integration.rs @@ -4,6 +4,9 @@ //! Works directory) gracefully skips the file lock and does not regress //! existing cron supervisor behavior. +// flock is unix-only; the file_lock module is `#[cfg(unix)]` (V1.153 P2 T2). +#![cfg(unix)] + use nexus_local_db::works::{self, WorkRecord}; use sqlx::SqlitePool; diff --git a/crates/nexus-daemon-runtime/tests/world_kb_patch.rs b/crates/nexus-daemon-runtime/tests/world_kb_patch.rs index d94272b68..abfb677a7 100644 --- a/crates/nexus-daemon-runtime/tests/world_kb_patch.rs +++ b/crates/nexus-daemon-runtime/tests/world_kb_patch.rs @@ -142,11 +142,12 @@ async fn fresh_state() -> ( // ─── patch-entity ─────────────────────────────────────────────────────────── // V1.143 P1: patch_entity now routes the canonical edit through -// `orchestrate_upsert` via `NexusAdapter`, which bridges sync spoke -// ports to async SQLite via `tokio::task::block_in_place`. That requires a -// multi-threaded runtime (the production daemon uses one; tests must opt in -// via `flavor = "multi_thread"` — same rationale as the promote_adopt tests -// below). The fast-fail patch_entity tests (stale version / deleted / +// `orchestrate_upsert` via `NexusAdapter`. The adapter port methods are +// natively `async fn` (spoke-operations 0.9.1 surface, V1.153 P0 T2) and run +// on the test runtime; the multi-threaded flavor is retained from the +// pre-0.9.1 `block_in_place` bridge era (harmless either way — same +// rationale as the promote_adopt tests below). The fast-fail patch_entity +// tests (stale version / deleted / // cross-author) short-circuit on pre-orchestrator guards and stay on the // default current-thread runtime. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -583,11 +584,10 @@ const NOVEL_CHARACTER_BODY: &str = #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn promote_adopt_confirms_candidate() { - // V1.142 P2: promote_adopt now routes through `orchestrate_promote` via - // `NexusAdapter`, which bridges sync spoke ports to async SQLite - // via `tokio::task::block_in_place`. That requires a multi-threaded - // runtime (the production daemon uses one; tests must opt in via - // `flavor = "multi_thread"`). + // V1.142 P2: promote_adopt routes through `orchestrate_promote` via + // `NexusAdapter`. The adapter port methods are natively `async fn` + // (spoke-operations 0.9.1 surface, V1.153 P0 T2); the multi-threaded + // flavor is retained from the pre-0.9.1 `block_in_place` bridge era. let (_tmp, state) = fresh_state().await; let candidate = insert_pending( state.pool().unwrap(), diff --git a/crates/nexus-daemon-runtime/tests/world_kb_relationships.rs b/crates/nexus-daemon-runtime/tests/world_kb_relationships.rs index 088c74bcf..c4119832d 100644 --- a/crates/nexus-daemon-runtime/tests/world_kb_relationships.rs +++ b/crates/nexus-daemon-runtime/tests/world_kb_relationships.rs @@ -120,11 +120,11 @@ fn add_request( } } -// V1.144 P2: add/update now route through `orchestrate_relate` via -// `NexusAdapter`, which bridges sync spoke ports to async SQLite via -// `tokio::task::block_in_place`. That requires a multi-threaded runtime (the -// production daemon uses one; tests opt in via `flavor = "multi_thread"` — -// same rationale as the V1.143 patch_entity tests). Pre-orchestrator +// V1.144 P2: add/update route through `orchestrate_relate` via +// `NexusAdapter`. The adapter port methods are natively `async fn` +// (spoke-operations 0.9.1 surface, V1.153 P0 T2); the multi-threaded flavor +// is retained from the pre-0.9.1 `block_in_place` bridge era (same +// rationale as the V1.143 patch_entity tests). Pre-orchestrator // fast-fail tests (self-loop / bad-label / confidence / cross-world / bad // anchor) short-circuit on handler guards and stay on the default runtime. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/nexus-local-db/src/lib.rs b/crates/nexus-local-db/src/lib.rs index 2b9203f2d..5c444c847 100644 --- a/crates/nexus-local-db/src/lib.rs +++ b/crates/nexus-local-db/src/lib.rs @@ -10,10 +10,13 @@ //! //! See `.mstar/archived/knowledge/local-db-refactor-legacy.md` for design baseline. -#[cfg(unix)] +// V1.153 P2 T2: cas is pure SQL (OCC helpers — no unix APIs); the former +// `#[cfg(unix)]` gate was wrong and broke `kb_relationships` (which imports +// `crate::cas`) on the Windows x64 build. pub mod cas; pub mod compute_runs; pub mod compute_session; +#[cfg(unix)] pub mod file_lock; pub mod findings; pub mod force_gates_audit; diff --git a/crates/nexus-local-db/tests/file_lock.rs b/crates/nexus-local-db/tests/file_lock.rs index 6c5497e34..11ff4a623 100644 --- a/crates/nexus-local-db/tests/file_lock.rs +++ b/crates/nexus-local-db/tests/file_lock.rs @@ -4,6 +4,9 @@ //! the same process, exercising the `flock` semantics through separate //! `FileLockGuard` scopes. +// flock is unix-only; the module itself is `#[cfg(unix)]` (V1.153 P2 T2). +#![cfg(unix)] + use std::path::PathBuf; use nexus_local_db::file_lock; diff --git a/crates/nexus-orchestration/src/schedule/cron_supervisor.rs b/crates/nexus-orchestration/src/schedule/cron_supervisor.rs index e861acbce..a3cb168fb 100644 --- a/crates/nexus-orchestration/src/schedule/cron_supervisor.rs +++ b/crates/nexus-orchestration/src/schedule/cron_supervisor.rs @@ -246,6 +246,8 @@ async fn evaluate_work( /// Returns `Ok(Some(guard))` on successful acquisition, `Ok(None)` when the /// workspace directory doesn't exist (test environments — skip file lock), /// and `Err(())` when the lock is held by another process or an I/O error occurs. +/// Unix-only — the `file_lock` module is `#[cfg(unix)]` (V1.153 P2 T2). +#[cfg(unix)] fn maybe_acquire_cron_file_lock( workspace_dir: Option<&Path>, work_ref: Option<&String>, @@ -262,6 +264,18 @@ fn maybe_acquire_cron_file_lock( .map_or_else(|_| Err(()), |guard| Ok(Some(guard))) } +/// V1.153 P2 T2: no advisory flock on Windows (the `file_lock` module is +/// unix-only); shared-DB write access is WAL-governed (P2 spec § Coexistence) +/// — always proceed unlocked. +#[cfg(not(unix))] +fn maybe_acquire_cron_file_lock( + _workspace_dir: Option<&Path>, + _work_ref: Option<&String>, + _role_name: &str, +) -> Result, ()> { + Ok(None) +} + /// Evaluate one role for one Work: skip (no-match / disabled / gated / /// idempotent) or enqueue. Mutates `summary` to record the outcome. // diff --git a/crates/nexus-spoke-adapter/AGENTS.md b/crates/nexus-spoke-adapter/AGENTS.md index 11a537f1c..0991ae5ea 100644 --- a/crates/nexus-spoke-adapter/AGENTS.md +++ b/crates/nexus-spoke-adapter/AGENTS.md @@ -9,7 +9,7 @@ Since V1.145 P1b this crate is the **capability aggregation** layer (spec §7.4 1. **Typed accessors** over the `extensions.nexus` namespace on a spoke `KnowledgeEntry` (5 fields: `world_id`, `created_from_command_id`, `source_work_id`, `source_chapter`, `source_provenance_kind`). See `src/extensions.rs`. 2. **Lifecycle delegation (Surface A)** — delegates standard lifecycle invariants to `spoke-operations` (validate/apply promote, status transitions, assemble packet, extension merge, revision assert). Where `spoke-operations` exports a function, this crate re-exports or wraps it; it never reimplements a lifecycle invariant. See `src/ops.rs`. 3. **Conversion seam** — the sole `WorldKbEntry` ↔ spoke `KnowledgeEntry` conversion (`world_kb_to_spoke` / `spoke_to_world_kb` + `WorldKbEntrySpokeExt`), owned here since V1.145 P1a (moved out of `nexus-knowledge` per the orphan rule). See `src/conversion/`. -4. **Production adapter home** — `NexusAdapter` (V1.146 rename) + 6 spoke port impls in `src/adapter/` (V1.145 P1b rehome from `nexus-local-db`). Consumes `nexus-local-db` storage primitives and bridges spoke's sync port traits to async `SQLite` I/O. +4. **Production adapter home** — `NexusAdapter` (V1.146 rename) + 6 spoke port impls in `src/adapter/` (V1.145 P1b rehome from `nexus-local-db`). Consumes `nexus-local-db` storage primitives and implements spoke's port traits (natively `async fn` since spoke-operations 0.9.1) over async `SQLite` I/O. Since V1.141 the crate also flat-re-exports spoke 0.4.0's adapter **port traits + `orchestrate_*` entrypoints + operand wire types** (Surface B, spec §7.3) so consumers implement spoke's ports and call spoke's orchestrators through this single import boundary — pure pass-through, no nexus logic. @@ -23,7 +23,7 @@ Since V1.141 the crate also flat-re-exports spoke 0.4.0's adapter **port traits ## Authority - Normative spec: [`specs/spoke-adapter-architecture.md`](../../.mstar/specs/spoke-adapter-architecture.md) (tracked). §7.2 is the authoritative public API surface; §7.3 is the Surface B (ports + orchestrators) surface; §2 is the `extensions.nexus` contract. -- Upstream types: `spoke-schemas` + `spoke-operations` (crates.io, lockstep exact pin `=0.8.2`). +- Upstream types: `spoke-schemas` + `spoke-operations` (crates.io, lockstep exact pin `=0.9.1`). ## Key rules @@ -34,11 +34,11 @@ Since V1.141 the crate also flat-re-exports spoke 0.4.0's adapter **port traits ## Dependencies -- `spoke-schemas`, `spoke-operations` (workspace, `=0.8.2`) +- `spoke-schemas`, `spoke-operations` (workspace, `=0.9.1`) - `serde`, `serde_json` - `nexus-knowledge` (domain types + conversion seam, V1.145 P1a) - `nexus-local-db` (storage primitives — `SqliteKbStore`, `open_pool`, `run_migrations`, CAS helpers; V1.145 P1b adapter rehome) -- `sqlx`, `tokio` (adapter async↔sync bridge + SQLite I/O) +- `sqlx`, `tokio` (SQLite I/O; adapter port impls are natively `async fn` — no sync bridge) Dev-deps mirror the runtime deps so tests can compare wrapper output against the underlying spoke function directly; `tempfile` for the adapter's `#[cfg(test)]` SQLite fixtures. diff --git a/crates/nexus-spoke-adapter/Cargo.toml b/crates/nexus-spoke-adapter/Cargo.toml index e5f2f79bd..473314ff3 100644 --- a/crates/nexus-spoke-adapter/Cargo.toml +++ b/crates/nexus-spoke-adapter/Cargo.toml @@ -16,6 +16,10 @@ path = "src/lib.rs" [dependencies] spoke-schemas.workspace = true spoke-operations.workspace = true +# V1.153 P0 T2 — spoke-operations 0.9.1 converted the adapter port traits to +# `#[async_trait] async fn`; the production impls (and the mock impls in +# tests/examples) carry the matching attribute. +async-trait.workspace = true serde.workspace = true serde_json.workspace = true # V1.145 P1a — the WorldKbEntry↔KnowledgeEntry conversion seam + the diff --git a/crates/nexus-spoke-adapter/examples/baseline_adapter.rs b/crates/nexus-spoke-adapter/examples/baseline_adapter.rs index 0192e9a13..95e2cf4d4 100644 --- a/crates/nexus-spoke-adapter/examples/baseline_adapter.rs +++ b/crates/nexus-spoke-adapter/examples/baseline_adapter.rs @@ -25,9 +25,10 @@ #![allow(clippy::missing_panics_doc)] #![allow(clippy::must_use_candidate)] -use std::cell::RefCell; use std::collections::HashMap; +use std::sync::Mutex; +use async_trait::async_trait; use nexus_spoke_adapter::{ orchestrate_assemble, orchestrate_promote, orchestrate_relate, AssembleRequest, AssembleResponse, Finding, FindingPort, HostCapabilityManifest, HostManifestPort, @@ -51,8 +52,9 @@ use spoke_operations::{spoke_ok, spoke_reject}; // adapter-owned per spec §7 ("adapters own transport, persistence, // transactions"; the library stays I/O-free). // -// simplify: `RefCell` for interior mutability — this example is single- -// threaded. A production adapter backs these ports with real storage. +// simplify: `Mutex` for interior mutability — the 0.9.1 port traits are +// `#[async_trait] async fn` (Send futures), so the mock must be `Sync`. +// A production adapter backs these ports with real storage. /// In-memory store implementing spoke's six baseline port families. /// @@ -61,11 +63,11 @@ use spoke_operations::{spoke_ok, spoke_reject}; /// hidden module and reuse the mock without duplication. In the example /// binary itself `pub` is a no-op (the binary crate root sees everything). pub struct NexusBaselineMock { - entries: RefCell>, - relations: RefCell>, - events: RefCell>, - rules: RefCell>, - findings: RefCell>, + entries: Mutex>, + relations: Mutex>, + events: Mutex>, + rules: Mutex>, + findings: Mutex>, self_manifest: HostCapabilityManifest, peer_manifests: Vec, /// One-shot `get_knowledge_entry` overrides used by the test twin to @@ -76,7 +78,7 @@ pub struct NexusBaselineMock { /// the given `entry_id`: /// - `Some(rev)` → return the stored entry with `revision` overwritten by `rev` /// - `None` → return `KnowledgeEntryNotFound` even if the entry exists - next_get_override: RefCell>>, + next_get_override: Mutex>>, } impl NexusBaselineMock { @@ -91,14 +93,14 @@ impl NexusBaselineMock { let mut entries = HashMap::new(); entries.insert(entry.entry_id.clone(), entry); Self { - entries: RefCell::new(entries), - relations: RefCell::new(HashMap::new()), - events: RefCell::new(Vec::new()), - rules: RefCell::new(HashMap::new()), - findings: RefCell::new(Vec::new()), + entries: Mutex::new(entries), + relations: Mutex::new(HashMap::new()), + events: Mutex::new(Vec::new()), + rules: Mutex::new(HashMap::new()), + findings: Mutex::new(Vec::new()), self_manifest: host_manifest("nexus-baseline-mock", &["nexus"], &["data-store"]), peer_manifests: Vec::new(), - next_get_override: RefCell::new(HashMap::new()), + next_get_override: Mutex::new(HashMap::new()), } } @@ -107,7 +109,8 @@ impl NexusBaselineMock { /// stored the entry between the caller's read and write window. pub fn mask_next_get(&self, entry_id: &str) { self.next_get_override - .borrow_mut() + .lock() + .expect("mock override mutex") .insert(entry_id.into(), None); } @@ -119,15 +122,22 @@ impl NexusBaselineMock { /// `stored < expected` → CONFLICT) through `orchestrate_upsert`. pub fn override_next_get_revision(&self, entry_id: &str, revision: u64) { self.next_get_override - .borrow_mut() + .lock() + .expect("mock override mutex") .insert(entry_id.into(), Some(revision)); } } +#[async_trait] impl KnowledgeEntryPort for NexusBaselineMock { - fn get_knowledge_entry(&self, entry_id: &str) -> SpokeResult { + async fn get_knowledge_entry(&self, entry_id: &str) -> SpokeResult { // Consume a one-shot override first (test-fixture race simulation). - if let Some(override_rev) = self.next_get_override.borrow_mut().remove(entry_id) { + if let Some(override_rev) = self + .next_get_override + .lock() + .expect("mock override mutex") + .remove(entry_id) + { match override_rev { None => { let mut details = Map::new(); @@ -139,7 +149,13 @@ impl KnowledgeEntryPort for NexusBaselineMock { ); } Some(rev) => { - if let Some(entry) = self.entries.borrow().get(entry_id).cloned() { + if let Some(entry) = self + .entries + .lock() + .expect("mock entries mutex") + .get(entry_id) + .cloned() + { let mut snapshot = entry; snapshot.revision = Some(rev); return spoke_ok(snapshot); @@ -148,7 +164,7 @@ impl KnowledgeEntryPort for NexusBaselineMock { } } } - let entries = self.entries.borrow(); + let entries = self.entries.lock().expect("mock entries mutex"); if let Some(entry) = entries.get(entry_id) { return spoke_ok(entry.clone()); } @@ -161,12 +177,12 @@ impl KnowledgeEntryPort for NexusBaselineMock { ) } - fn put_knowledge_entry( + async fn put_knowledge_entry( &self, entry: KnowledgeEntry, expected_base_revision: Option, ) -> SpokeResult { - let mut entries = self.entries.borrow_mut(); + let mut entries = self.entries.lock().expect("mock entries mutex"); let existing = entries.get(&entry.entry_id); match expected_base_revision { None => { @@ -230,12 +246,13 @@ impl KnowledgeEntryPort for NexusBaselineMock { } } +#[async_trait] impl RelationPort for NexusBaselineMock { /// In-memory OCC reference — mirrors the production /// `nexus_spoke_adapter::NexusAdapter::get_relation`: read the stored /// `Relation` by id; absent → `RelationNotFound`. - fn get_relation(&self, relation_id: &str) -> SpokeResult { - let relations = self.relations.borrow(); + async fn get_relation(&self, relation_id: &str) -> SpokeResult { + let relations = self.relations.lock().expect("mock relations mutex"); if let Some(relation) = relations.get(relation_id) { return spoke_ok(relation.clone()); } @@ -261,12 +278,12 @@ impl RelationPort for NexusBaselineMock { /// candidate's revision before calling the port), the relate orchestrator /// passes the candidate through untouched — so the port owns the revision /// seed/bump, exactly as the production SQLite-backed port does. - fn put_relation( + async fn put_relation( &self, relation: Relation, expected_base_revision: Option, ) -> SpokeResult { - let mut relations = self.relations.borrow_mut(); + let mut relations = self.relations.lock().expect("mock relations mutex"); let relation_id = relation.relation_id.clone(); match expected_base_revision { None => { @@ -336,30 +353,43 @@ impl RelationPort for NexusBaselineMock { } } +#[async_trait] impl ScopeQueryPort for NexusBaselineMock { - fn list_knowledge_entries(&self, _scope: &Scope) -> SpokeResult> { + async fn list_knowledge_entries(&self, _scope: &Scope) -> SpokeResult> { // The mock returns all stored entries; spoke's orchestrator applies // scope filtering via `filter_knowledge_entries_by_scope`. Re- // implementing that helper here would violate the call-boundary // invariant (spec §7). - spoke_ok(self.entries.borrow().values().cloned().collect()) + spoke_ok( + self.entries + .lock() + .expect("mock entries mutex") + .values() + .cloned() + .collect(), + ) } - fn list_timeline_events(&self, _scope: &Scope) -> SpokeResult> { - spoke_ok(self.events.borrow().clone()) + async fn list_timeline_events(&self, _scope: &Scope) -> SpokeResult> { + spoke_ok(self.events.lock().expect("mock events mutex").clone()) } } +#[async_trait] impl FindingPort for NexusBaselineMock { - fn put_findings(&self, findings: Vec) -> SpokeResult> { - self.findings.borrow_mut().extend(findings.iter().cloned()); + async fn put_findings(&self, findings: Vec) -> SpokeResult> { + self.findings + .lock() + .expect("mock findings mutex") + .extend(findings.iter().cloned()); spoke_ok(findings) } } +#[async_trait] impl RuleQueryPort for NexusBaselineMock { - fn list_rules(&self, rule_refs: &[String]) -> SpokeResult> { - let rules = self.rules.borrow(); + async fn list_rules(&self, rule_refs: &[String]) -> SpokeResult> { + let rules = self.rules.lock().expect("mock rules mutex"); let mut resolved = Vec::new(); for rule_ref in rule_refs { if let Some(rule) = rules.get(rule_ref) { @@ -378,12 +408,15 @@ impl RuleQueryPort for NexusBaselineMock { } } +#[async_trait] impl HostManifestPort for NexusBaselineMock { - fn get_host_capability_manifest(&self) -> SpokeResult { + async fn get_host_capability_manifest(&self) -> SpokeResult { spoke_ok(self.self_manifest.clone()) } - fn list_peer_host_capability_manifests(&self) -> SpokeResult> { + async fn list_peer_host_capability_manifests( + &self, + ) -> SpokeResult> { spoke_ok(self.peer_manifests.clone()) } } @@ -471,7 +504,8 @@ fn print_reject(label: &str, reject: &SpokeReject) { ); } -fn main() { +#[tokio::main] +async fn main() { let entry_id = "kb_mira"; let mock = NexusBaselineMock::with_seeded_entry(knowledge_entry(entry_id, 2)); @@ -487,7 +521,7 @@ fn main() { // sees stored == expected (2 == 2) and accepts the write; the persisted // entry becomes status=confirmed, revision=3. println!("--- 1. orchestrate_promote (happy path) ---"); - match orchestrate_promote(&mock, promote_request(entry_id, 2)) { + match orchestrate_promote(&mock, promote_request(entry_id, 2)).await { SpokeResult::Ok(PromoteResponse::Variant0 { knowledge_entry, .. }) => { @@ -508,7 +542,7 @@ fn main() { // the caller's expectation, so it rejects with `STORED_REVISION_STALE`. // This is the CAS gate surfacing through the orchestrator. println!("--- 2. orchestrate_promote (stale revision -> CAS reject) ---"); - match orchestrate_promote(&mock, promote_request(entry_id, 2)) { + match orchestrate_promote(&mock, promote_request(entry_id, 2)).await { SpokeResult::Ok(_) => println!(" UNEXPECTED OK — expected CAS reject"), SpokeResult::Reject(reject) => print_reject("promote-cas", &reject), } @@ -519,7 +553,7 @@ fn main() { // (returns all stored entries); spoke's scope helpers filter to the // requested entry_ids and the packet builder assembles the result. println!("--- 3. orchestrate_assemble (happy path) ---"); - match orchestrate_assemble(&mock, assemble_request("world_1", entry_id)) { + match orchestrate_assemble(&mock, assemble_request("world_1", entry_id)).await { SpokeResult::Ok(AssembleResponse::Variant0 { packet, .. }) => { println!( " OK: packet_id={} entries={}", @@ -540,7 +574,7 @@ fn main() { // relation. This is the 3rd shipped Surface B cutover (promote + upsert // + relate) exercised through the mock. println!("--- 4. orchestrate_relate (create happy path) ---"); - match orchestrate_relate(&mock, relate_request("rel_demo", None)) { + match orchestrate_relate(&mock, relate_request("rel_demo", None)).await { SpokeResult::Ok(RelateResponse::Variant0 { relation, .. }) => { println!( " OK: relation_id={} type={} revision={:?}", @@ -558,7 +592,7 @@ fn main() { // `assert_revision_match(1, 1)` passes, then our mock's CAS sees // stored(1) == expected(1) → accepts and bumps to revision 2. println!("--- 5. orchestrate_relate (update happy path) ---"); - match orchestrate_relate(&mock, relate_request("rel_demo", Some(1))) { + match orchestrate_relate(&mock, relate_request("rel_demo", Some(1))).await { SpokeResult::Ok(RelateResponse::Variant0 { relation, .. }) => { println!( " OK: relation_id={} revision={:?} (bumped 1 -> 2)", @@ -578,7 +612,7 @@ fn main() { // as the promote CAS reject (block 2) — the CAS gate surfaces through // the orchestrator's validation before the mock's put fires. println!("--- 6. orchestrate_relate (stale revision -> CAS reject) ---"); - match orchestrate_relate(&mock, relate_request("rel_demo", Some(1))) { + match orchestrate_relate(&mock, relate_request("rel_demo", Some(1))).await { SpokeResult::Ok(_) => println!(" UNEXPECTED OK — expected CAS reject"), SpokeResult::Reject(reject) => print_reject("relate-cas", &reject), } diff --git a/crates/nexus-spoke-adapter/src/adapter/computable_port.rs b/crates/nexus-spoke-adapter/src/adapter/computable_port.rs index 65dd5e67a..fbd0d208a 100644 --- a/crates/nexus-spoke-adapter/src/adapter/computable_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/computable_port.rs @@ -24,14 +24,13 @@ //! explicitly declare their module choice via `project()` state or entry //! `body.computable`. //! -//! # Async ↔ sync bridge +//! # Async surface (V1.153 P0 T2) //! -//! The port traits are sync; the adapter bridges async `SQLite` + WASM host -//! construction via the established `block_on` pattern (see `mod.rs`). The -//! `WasmEngine::new()` call is one-time (cold; subsequent calls reuse the +//! The port methods are natively `async fn` (spoke-operations 0.9.1 surface) +//! and await `SQLite` I/O directly; the former `block_on` bridge is gone. +//! The `WasmEngine::new()` call is one-time (cold; subsequent calls reuse the //! engine via a `OnceCell`), and the WASM compute call is synchronous -//! (the wasmtime host does not require an async runtime), so the sync -//! constraint is compatible with the port signature. +//! (the wasmtime host does not require an async runtime). use super::NexusAdapter; use crate::{ @@ -39,6 +38,7 @@ use crate::{ SpokeRejectCode, SpokeResult, }; use crate::{ComputeRequest, ComputeResponse}; +use async_trait::async_trait; use nexus_local_db::compute_session::{get_compute_session, insert_compute_session}; use nexus_wasm_host::{ embedded_module_bytes, embedded_module_ids, embedded_module_manifest, ComputeInput, @@ -156,15 +156,16 @@ fn load_module(module_id: &str) -> SpokeResult<(WasmModule, ModuleManifest)> { impl NexusAdapter<'_> {} +#[async_trait] impl ComputablePort for NexusAdapter<'_> { - fn project(&self, request: ProjectRequest) -> SpokeResult { + async fn project(&self, request: ProjectRequest) -> SpokeResult { let pool = self.pool.clone(); let session_id = request.session_id.clone(); let entry_id = request.entry_id.clone(); let state = request.state; // Validate entry exists (rejects with InvalidInput if entry is missing). - let _entry = match self.get_knowledge_entry(&entry_id) { + let _entry = match self.get_knowledge_entry(&entry_id).await { SpokeResult::Ok(e) => e, SpokeResult::Reject(r) => { if r.code == SpokeRejectCode::KnowledgeEntryNotFound { @@ -180,38 +181,36 @@ impl ComputablePort for NexusAdapter<'_> { let state_json = serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string()); - self.block_on(async move { - match insert_compute_session(&pool, &session_id, &entry_id, &state_json).await { - Ok(_session) => SpokeResult::Ok(ProjectResponse::Variant0 { - computable: state, - entry_id, - session_id, - extensions: HashMap::default(), - }), - Err(e) => { - // sqlx::Error::Database with UNIQUE constraint → session - // already exists. Map to a friendly InvalidInput. - let msg = e.to_string(); - if msg.contains("UNIQUE") { - reject( - SpokeRejectCode::InvalidInput, - format!("session already exists: {session_id}"), - json!({ "session_id": session_id }), - ) - } else { - reject( - SpokeRejectCode::InternalError, - format!("storage error on compute session insert: {e}"), - json!({ "session_id": session_id }), - ) - } + match insert_compute_session(&pool, &session_id, &entry_id, &state_json).await { + Ok(_session) => SpokeResult::Ok(ProjectResponse::Variant0 { + computable: state, + entry_id, + session_id, + extensions: HashMap::default(), + }), + Err(e) => { + // sqlx::Error::Database with UNIQUE constraint → session + // already exists. Map to a friendly InvalidInput. + let msg = e.to_string(); + if msg.contains("UNIQUE") { + reject( + SpokeRejectCode::InvalidInput, + format!("session already exists: {session_id}"), + json!({ "session_id": session_id }), + ) + } else { + reject( + SpokeRejectCode::InternalError, + format!("storage error on compute session insert: {e}"), + json!({ "session_id": session_id }), + ) } } - }) + } } #[allow(clippy::too_many_lines)] - fn compute(&self, request: ComputeRequest) -> SpokeResult { + async fn compute(&self, request: ComputeRequest) -> SpokeResult { let pool = self.pool.clone(); let session_id = request.session_id.clone(); let entry_id = request.entry_id.clone(); @@ -219,7 +218,7 @@ impl ComputablePort for NexusAdapter<'_> { let settle = request.settle.unwrap_or(false); // ── 1. Load session row ────────────────────────────────────────── - let session = self.block_on(async { get_compute_session(&pool, &session_id).await }); + let session = get_compute_session(&pool, &session_id).await; let session = match session { Ok(Some(s)) => s, Ok(None) => { @@ -255,7 +254,7 @@ impl ComputablePort for NexusAdapter<'_> { } // ── 2. Load the entry for compute envelope ──────────────────────── - let entry = match self.get_knowledge_entry(&entry_id) { + let entry = match self.get_knowledge_entry(&entry_id).await { SpokeResult::Ok(e) => e, SpokeResult::Reject(r) => return SpokeResult::Reject(r), }; @@ -305,7 +304,7 @@ impl ComputablePort for NexusAdapter<'_> { .iter() .any(|kb| kb.get("entry_id").and_then(Value::as_str) == Some(ref_id)) { - match self.get_knowledge_entry(ref_id) { + match self.get_knowledge_entry(ref_id).await { SpokeResult::Ok(ref_entry) => { let ref_world_id = ref_entry .extensions @@ -449,7 +448,7 @@ impl ComputablePort for NexusAdapter<'_> { for (target_id, deltas) in &deltas_by_target { // Load the target entry. - let target_entry = match self.get_knowledge_entry(target_id) { + let target_entry = match self.get_knowledge_entry(target_id).await { SpokeResult::Ok(e) => e, SpokeResult::Reject(r) => return SpokeResult::Reject(r), }; @@ -566,7 +565,9 @@ impl ComputablePort for NexusAdapter<'_> { self, pending_entry_updates, Some((session_id.clone(), updated_state_json)), - ) { + ) + .await + { SpokeResult::Ok(()) => {} SpokeResult::Reject(r) => return SpokeResult::Reject(r), } @@ -827,7 +828,10 @@ mod tests { // Create a character entry first. let entry = spoke_character_entry("kb_hero", "Hero", 100, 20, 10, 100); - let _created = unwrap_ok(adapter.put_knowledge_entry(entry, None), "create character"); + let _created = unwrap_ok( + adapter.put_knowledge_entry(entry, None).await, + "create character", + ); // Stage a project session. let mut state = Map::new(); @@ -848,7 +852,7 @@ mod tests { extensions: Default::default(), }; - match adapter.project(project_req) { + match adapter.project(project_req).await { SpokeResult::Ok(ProjectResponse::Variant0 { computable, entry_id, @@ -887,7 +891,7 @@ mod tests { extensions: Default::default(), }; - match adapter.project(project_req) { + match adapter.project(project_req).await { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); assert!(r.message.contains("not found")); @@ -903,7 +907,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_character_entry("kb_dup_ses", "DupSes", 100, 20, 10, 100); - unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); let project_req = ProjectRequest { session_id: "ses_dup".to_string(), @@ -911,9 +915,9 @@ mod tests { state: Map::new(), extensions: Default::default(), }; - unwrap_ok(adapter.project(project_req.clone()), "first project"); + unwrap_ok(adapter.project(project_req.clone()).await, "first project"); - match adapter.project(project_req) { + match adapter.project(project_req).await { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); assert!(r.message.contains("already exists")); @@ -938,7 +942,7 @@ mod tests { extensions: Default::default(), }; - match adapter.compute(compute_req) { + match adapter.compute(compute_req).await { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); assert!(r.message.contains("not found")); @@ -954,7 +958,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_character_entry("kb_mismatch", "Mismatch", 100, 20, 10, 100); - unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); // Stage a session against kb_mismatch. let project_req = ProjectRequest { @@ -963,7 +967,7 @@ mod tests { state: Map::new(), extensions: Default::default(), }; - unwrap_ok(adapter.project(project_req), "project"); + unwrap_ok(adapter.project(project_req).await, "project"); // Compute against a different entry_id. let compute_req = ComputeRequest { @@ -974,7 +978,7 @@ mod tests { extensions: Default::default(), }; - match adapter.compute(compute_req) { + match adapter.compute(compute_req).await { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); assert!(r.message.contains("mismatch")); @@ -1003,8 +1007,11 @@ mod tests { // Create two character entries in spoke format. let hero = spoke_character_entry("kb_hero_c", "HeroC", 100, 25, 15, 100); let monster = spoke_character_entry("kb_monster_c", "MonsterC", 60, 15, 8, 60); - unwrap_ok(adapter.put_knowledge_entry(hero, None), "create hero"); - unwrap_ok(adapter.put_knowledge_entry(monster, None), "create monster"); + unwrap_ok(adapter.put_knowledge_entry(hero, None).await, "create hero"); + unwrap_ok( + adapter.put_knowledge_entry(monster, None).await, + "create monster", + ); // ── project: stage the combat session ── let mut state = Map::new(); @@ -1022,12 +1029,14 @@ mod tests { ); let project_resp = unwrap_ok( - adapter.project(ProjectRequest { - session_id: "ses_combat_001".to_string(), - entry_id: "kb_hero_c".to_string(), - state, - extensions: Default::default(), - }), + adapter + .project(ProjectRequest { + session_id: "ses_combat_001".to_string(), + entry_id: "kb_hero_c".to_string(), + state, + extensions: Default::default(), + }) + .await, "project combat", ); @@ -1047,13 +1056,15 @@ mod tests { ); let compute_resp = unwrap_ok( - adapter.compute(ComputeRequest { - session_id: "ses_combat_001".to_string(), - entry_id: "kb_hero_c".to_string(), - computable, - settle: Some(false), - extensions: Default::default(), - }), + adapter + .compute(ComputeRequest { + session_id: "ses_combat_001".to_string(), + entry_id: "kb_hero_c".to_string(), + computable, + settle: Some(false), + extensions: Default::default(), + }) + .await, "compute combat", ); @@ -1091,8 +1102,11 @@ mod tests { let hero = spoke_character_entry("kb_settle_h", "SettleHero", 100, 25, 15, 100); let monster = spoke_character_entry("kb_settle_m", "SettleMonster", 60, 15, 8, 60); - unwrap_ok(adapter.put_knowledge_entry(hero, None), "create hero"); - unwrap_ok(adapter.put_knowledge_entry(monster, None), "create monster"); + unwrap_ok(adapter.put_knowledge_entry(hero, None).await, "create hero"); + unwrap_ok( + adapter.put_knowledge_entry(monster, None).await, + "create monster", + ); // project let mut state = Map::new(); @@ -1109,12 +1123,14 @@ mod tests { Value::String("kb_settle_m".to_string()), ); unwrap_ok( - adapter.project(ProjectRequest { - session_id: "ses_settle".to_string(), - entry_id: "kb_settle_h".to_string(), - state, - extensions: Default::default(), - }), + adapter + .project(ProjectRequest { + session_id: "ses_settle".to_string(), + entry_id: "kb_settle_h".to_string(), + state, + extensions: Default::default(), + }) + .await, "project", ); @@ -1130,13 +1146,15 @@ mod tests { ); let compute_resp = unwrap_ok( - adapter.compute(ComputeRequest { - session_id: "ses_settle".to_string(), - entry_id: "kb_settle_h".to_string(), - computable, - settle: Some(true), - extensions: Default::default(), - }), + adapter + .compute(ComputeRequest { + session_id: "ses_settle".to_string(), + entry_id: "kb_settle_h".to_string(), + computable, + settle: Some(true), + extensions: Default::default(), + }) + .await, "compute settle", ); @@ -1150,9 +1168,12 @@ mod tests { // target it. // Re-read both entries — the defender (target) must have updated state. - let entry_h = unwrap_ok(adapter.get_knowledge_entry("kb_settle_h"), "re-read hero"); + let entry_h = unwrap_ok( + adapter.get_knowledge_entry("kb_settle_h").await, + "re-read hero", + ); let entry_m = unwrap_ok( - adapter.get_knowledge_entry("kb_settle_m"), + adapter.get_knowledge_entry("kb_settle_m").await, "re-read monster", ); let hero_state = &entry_h.body.state; @@ -1186,27 +1207,32 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_character_entry("kb_no_mod", "NoModule", 100, 20, 10, 100); - unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); // Project WITHOUT module_id in state and WITHOUT body.computable on // the entry — both resolution tiers are empty. unwrap_ok( - adapter.project(ProjectRequest { - session_id: "ses_no_mod".to_string(), - entry_id: "kb_no_mod".to_string(), - state: Map::new(), - extensions: Default::default(), - }), + adapter + .project(ProjectRequest { + session_id: "ses_no_mod".to_string(), + entry_id: "kb_no_mod".to_string(), + state: Map::new(), + extensions: Default::default(), + }) + .await, "project no-module", ); - match adapter.compute(ComputeRequest { - session_id: "ses_no_mod".to_string(), - entry_id: "kb_no_mod".to_string(), - computable: Map::new(), - settle: None, - extensions: Default::default(), - }) { + match adapter + .compute(ComputeRequest { + session_id: "ses_no_mod".to_string(), + entry_id: "kb_no_mod".to_string(), + computable: Map::new(), + settle: None, + extensions: Default::default(), + }) + .await + { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); assert!(r.message.contains("module identity required")); @@ -1224,7 +1250,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_character_entry("kb_unknown_mod", "UnknownMod", 100, 20, 10, 100); - unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); // Stage with a non-existent module_id. let mut state = Map::new(); @@ -1233,22 +1259,27 @@ mod tests { Value::String("nonexistent-module".to_string()), ); unwrap_ok( - adapter.project(ProjectRequest { - session_id: "ses_bad_mod".to_string(), - entry_id: "kb_unknown_mod".to_string(), - state, - extensions: Default::default(), - }), + adapter + .project(ProjectRequest { + session_id: "ses_bad_mod".to_string(), + entry_id: "kb_unknown_mod".to_string(), + state, + extensions: Default::default(), + }) + .await, "project", ); - match adapter.compute(ComputeRequest { - session_id: "ses_bad_mod".to_string(), - entry_id: "kb_unknown_mod".to_string(), - computable: Map::new(), - settle: None, - extensions: Default::default(), - }) { + match adapter + .compute(ComputeRequest { + session_id: "ses_bad_mod".to_string(), + entry_id: "kb_unknown_mod".to_string(), + computable: Map::new(), + settle: None, + extensions: Default::default(), + }) + .await + { SpokeResult::Reject(r) => { // F-002: unknown module_id is a client-input error → InvalidInput. assert_eq!(r.code, SpokeRejectCode::InvalidInput); @@ -1272,12 +1303,21 @@ mod tests { // Create attacker (hero) and defender (monster) entries. let hero = spoke_character_entry("kb_atk_r", "AttackerR", 100, 25, 15, 100); let monster = spoke_character_entry("kb_def_r", "DefenderR", 60, 15, 8, 60); - unwrap_ok(adapter.put_knowledge_entry(hero, None), "create hero"); - unwrap_ok(adapter.put_knowledge_entry(monster, None), "create monster"); + unwrap_ok(adapter.put_knowledge_entry(hero, None).await, "create hero"); + unwrap_ok( + adapter.put_knowledge_entry(monster, None).await, + "create monster", + ); // Record pre-settle HP values. - let pre_hero = unwrap_ok(adapter.get_knowledge_entry("kb_atk_r"), "read hero pre"); - let pre_monster = unwrap_ok(adapter.get_knowledge_entry("kb_def_r"), "read monster pre"); + let pre_hero = unwrap_ok( + adapter.get_knowledge_entry("kb_atk_r").await, + "read hero pre", + ); + let pre_monster = unwrap_ok( + adapter.get_knowledge_entry("kb_def_r").await, + "read monster pre", + ); let pre_hero_hp = pre_hero .body .state @@ -1310,12 +1350,14 @@ mod tests { Value::String("kb_def_r".to_string()), ); unwrap_ok( - adapter.project(ProjectRequest { - session_id: "ses_f001".to_string(), - entry_id: "kb_atk_r".to_string(), - state, - extensions: Default::default(), - }), + adapter + .project(ProjectRequest { + session_id: "ses_f001".to_string(), + entry_id: "kb_atk_r".to_string(), + state, + extensions: Default::default(), + }) + .await, "project", ); @@ -1330,19 +1372,27 @@ mod tests { Value::String("kb_def_r".to_string()), ); unwrap_ok( - adapter.compute(ComputeRequest { - session_id: "ses_f001".to_string(), - entry_id: "kb_atk_r".to_string(), - computable, - settle: Some(true), - extensions: Default::default(), - }), + adapter + .compute(ComputeRequest { + session_id: "ses_f001".to_string(), + entry_id: "kb_atk_r".to_string(), + computable, + settle: Some(true), + extensions: Default::default(), + }) + .await, "compute settle", ); // Re-read both entries. - let post_hero = unwrap_ok(adapter.get_knowledge_entry("kb_atk_r"), "read hero post"); - let post_monster = unwrap_ok(adapter.get_knowledge_entry("kb_def_r"), "read monster post"); + let post_hero = unwrap_ok( + adapter.get_knowledge_entry("kb_atk_r").await, + "read hero post", + ); + let post_monster = unwrap_ok( + adapter.get_knowledge_entry("kb_def_r").await, + "read monster post", + ); let post_hero_hp = post_hero .body .state @@ -1393,7 +1443,7 @@ mod tests { // Create a character in world A (wld_cmp, seeded by seed_world). let hero = spoke_character_entry("kb_hero_f003", "HeroF003", 100, 20, 10, 100); unwrap_ok( - adapter.put_knowledge_entry(hero, None), + adapter.put_knowledge_entry(hero, None).await, "create hero in wld_cmp", ); @@ -1427,7 +1477,7 @@ mod tests { }); let other_spoke = world_kb_to_spoke(&world_b); unwrap_ok( - adapter.put_knowledge_entry(other_spoke, None), + adapter.put_knowledge_entry(other_spoke, None).await, "create other_world char", ); @@ -1447,12 +1497,14 @@ mod tests { Value::String("kb_other_f003".to_string()), ); unwrap_ok( - adapter.project(ProjectRequest { - session_id: "ses_f003".to_string(), - entry_id: "kb_hero_f003".to_string(), - state, - extensions: Default::default(), - }), + adapter + .project(ProjectRequest { + session_id: "ses_f003".to_string(), + entry_id: "kb_hero_f003".to_string(), + state, + extensions: Default::default(), + }) + .await, "project", ); @@ -1466,13 +1518,16 @@ mod tests { "defender_id".to_string(), Value::String("kb_other_f003".to_string()), ); - match adapter.compute(ComputeRequest { - session_id: "ses_f003".to_string(), - entry_id: "kb_hero_f003".to_string(), - computable, - settle: None, - extensions: Default::default(), - }) { + match adapter + .compute(ComputeRequest { + session_id: "ses_f003".to_string(), + entry_id: "kb_hero_f003".to_string(), + computable, + settle: None, + extensions: Default::default(), + }) + .await + { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); assert!(r.message.contains("cross-world")); @@ -1490,7 +1545,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_character_entry("kb_pathmod", "PathMod", 100, 20, 10, 100); - unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); // Stage with a module_id that looks like path traversal. let mut state = Map::new(); @@ -1499,22 +1554,27 @@ mod tests { Value::String("../etc/passwd".to_string()), ); unwrap_ok( - adapter.project(ProjectRequest { - session_id: "ses_pathmod".to_string(), - entry_id: "kb_pathmod".to_string(), - state, - extensions: Default::default(), - }), + adapter + .project(ProjectRequest { + session_id: "ses_pathmod".to_string(), + entry_id: "kb_pathmod".to_string(), + state, + extensions: Default::default(), + }) + .await, "project", ); - match adapter.compute(ComputeRequest { - session_id: "ses_pathmod".to_string(), - entry_id: "kb_pathmod".to_string(), - computable: Map::new(), - settle: None, - extensions: Default::default(), - }) { + match adapter + .compute(ComputeRequest { + session_id: "ses_pathmod".to_string(), + entry_id: "kb_pathmod".to_string(), + computable: Map::new(), + settle: None, + extensions: Default::default(), + }) + .await + { SpokeResult::Reject(r) => { assert_eq!( r.code, diff --git a/crates/nexus-spoke-adapter/src/adapter/finding_port.rs b/crates/nexus-spoke-adapter/src/adapter/finding_port.rs index 7be6663a8..0a203d20c 100644 --- a/crates/nexus-spoke-adapter/src/adapter/finding_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/finding_port.rs @@ -54,61 +54,61 @@ use crate::{ Finding as SpokeFinding, FindingExtensionsKey, FindingPort, SpokeReject, SpokeRejectCode, SpokeResult, }; +use async_trait::async_trait; use nexus_local_db::findings::{validate_finding_enums, Finding as NexusFinding}; use nexus_local_db::LocalDbError; use serde_json::{json, Map, Value}; +#[async_trait] impl FindingPort for NexusAdapter<'_> { - fn put_findings(&self, findings: Vec) -> SpokeResult> { + async fn put_findings(&self, findings: Vec) -> SpokeResult> { let pool = self.pool.clone(); - self.block_on(async move { - let mut persisted: Vec = Vec::with_capacity(findings.len()); - - // W-1 (qc3): wrap the entire batch in one SQLite transaction so a - // mid-batch failure rolls back every earlier insert. Without this, - // each insert auto-committed independently and the caller would - // receive a Reject while the preceding findings stayed persisted - // (a retry would then hit UNIQUE errors on those rows). `tx` drops - // uncommitted on any early `return` below → automatic rollback. - let mut tx = match pool.begin().await { - Ok(tx) => tx, - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!("failed to begin findings batch transaction: {e}"), - json!({}), - ); - } - }; - - for finding in findings { - let nexus_finding = match map_spoke_to_nexus(&finding) { - Ok(n) => n, - Err(rejection) => return SpokeResult::Reject(rejection), - }; - let finding_id = nexus_finding.finding_id.clone(); - if let Err(e) = insert_finding_tx(&mut tx, &nexus_finding).await { - return reject( - SpokeRejectCode::InternalError, - format!("storage error on finding {finding_id} insert: {e}"), - json!({ "finding_id": finding_id }), - ); - } - persisted.push(finding); + let mut persisted: Vec = Vec::with_capacity(findings.len()); + + // W-1 (qc3): wrap the entire batch in one SQLite transaction so a + // mid-batch failure rolls back every earlier insert. Without this, + // each insert auto-committed independently and the caller would + // receive a Reject while the preceding findings stayed persisted + // (a retry would then hit UNIQUE errors on those rows). `tx` drops + // uncommitted on any early `return` below → automatic rollback. + let mut tx = match pool.begin().await { + Ok(tx) => tx, + Err(e) => { + return reject( + SpokeRejectCode::InternalError, + format!("failed to begin findings batch transaction: {e}"), + json!({}), + ); } + }; - // All inserts succeeded — commit the batch atomically. Until this - // point nothing is durably persisted; a failure here rolls back. - if let Err(e) = tx.commit().await { + for finding in findings { + let nexus_finding = match map_spoke_to_nexus(&finding) { + Ok(n) => n, + Err(rejection) => return SpokeResult::Reject(rejection), + }; + let finding_id = nexus_finding.finding_id.clone(); + if let Err(e) = insert_finding_tx(&mut tx, &nexus_finding).await { return reject( SpokeRejectCode::InternalError, - format!("failed to commit findings batch transaction: {e}"), - json!({}), + format!("storage error on finding {finding_id} insert: {e}"), + json!({ "finding_id": finding_id }), ); } + persisted.push(finding); + } + + // All inserts succeeded — commit the batch atomically. Until this + // point nothing is durably persisted; a failure here rolls back. + if let Err(e) = tx.commit().await { + return reject( + SpokeRejectCode::InternalError, + format!("failed to commit findings batch transaction: {e}"), + json!({}), + ); + } - SpokeResult::Ok(persisted) - }) + SpokeResult::Ok(persisted) } } @@ -495,7 +495,7 @@ mod tests { Some("fix me"), ); - let result = adapter.put_findings(vec![spoke]); + let result = adapter.put_findings(vec![spoke]).await; let returned = match result { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), @@ -525,7 +525,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let spoke = spoke_finding("fnd_voc", "error", "dismissed", None, None); - match adapter.put_findings(vec![spoke]) { + match adapter.put_findings(vec![spoke]).await { SpokeResult::Ok(v) => assert_eq!(v.len(), 1), SpokeResult::Reject(r) => panic!("ok on valid vocabulary: {r:?}"), } @@ -560,7 +560,7 @@ mod tests { })) .expect("valid Finding"); - match adapter.put_findings(vec![spoke]) { + match adapter.put_findings(vec![spoke]).await { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); assert_eq!( @@ -592,7 +592,7 @@ mod tests { })) .expect("valid Finding shape (vocabulary not yet validated by spoke)"); - match adapter.put_findings(vec![spoke]) { + match adapter.put_findings(vec![spoke]).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -610,7 +610,7 @@ mod tests { seed_work(&pool).await; let adapter = NexusAdapter::new(pool); - let result = adapter.put_findings(Vec::new()); + let result = adapter.put_findings(Vec::new()).await; match result { SpokeResult::Ok(v) => assert!(v.is_empty()), SpokeResult::Reject(r) => panic!("empty input must be Ok: {r:?}"), @@ -652,7 +652,7 @@ mod tests { // clearly separate objects. let second = spoke_finding("fnd_rb_first", "warning", "open", None, None); - match adapter.put_findings(vec![first, second]) { + match adapter.put_findings(vec![first, second]).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -684,7 +684,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let finding = spoke_finding("fnd_fail", "info", "open", None, None); - match adapter.put_findings(vec![finding]) { + match adapter.put_findings(vec![finding]).await { SpokeResult::Reject(r) => { assert_eq!( r.code, diff --git a/crates/nexus-spoke-adapter/src/adapter/fork_port.rs b/crates/nexus-spoke-adapter/src/adapter/fork_port.rs index c6f916af6..2671072e8 100644 --- a/crates/nexus-spoke-adapter/src/adapter/fork_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/fork_port.rs @@ -26,11 +26,13 @@ use super::NexusAdapter; use crate::{ ForkTimelineQueryPort, Scope, SpokeReject, SpokeRejectCode, SpokeResult, TimelineEvent, }; +use async_trait::async_trait; use nexus_local_db::narrative_gateway::list_timeline_events_scoped; use serde_json::{json, Map, Value}; +#[async_trait] impl ForkTimelineQueryPort for NexusAdapter<'_> { - fn list_fork_timeline_events(&self, scope: &Scope) -> SpokeResult> { + async fn list_fork_timeline_events(&self, scope: &Scope) -> SpokeResult> { // ── fork_id is required for fork-scoped queries ───────────────── let fork_id: String = match &scope.fork_id { Some(fid) => fid.to_string(), @@ -47,69 +49,65 @@ impl ForkTimelineQueryPort for NexusAdapter<'_> { let world_id = scope.scope_id.clone(); let timeline_event_ids = scope.timeline_event_ids.clone(); - self.block_on(async move { - // ── Validate world existence (unknown fork → reject, not empty) ── - // SAFETY: runtime query for existence check — SELECT 1 pattern, - // single bind parameter. Do NOT use unwrap_or(false) — a storage - // failure (dropped table, SQLite file error) must surface as - // InternalError, not silently become "unknown world" InvalidInput. - let world_exists: bool = match sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM narrative_worlds WHERE world_id = ?)", - ) - .bind(&world_id) - .fetch_one(&pool) - .await - { - Ok(exists) => exists, - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!( - "storage error checking world existence for {world_id}: {e}" - ), - json!({ "scope_id": world_id, "fork_id": fork_id }), - ); - } - }; - - if !world_exists { + // ── Validate world existence (unknown fork → reject, not empty) ── + // SAFETY: runtime query for existence check — SELECT 1 pattern, + // single bind parameter. Do NOT use unwrap_or(false) — a storage + // failure (dropped table, SQLite file error) must surface as + // InternalError, not silently become "unknown world" InvalidInput. + let world_exists: bool = match sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM narrative_worlds WHERE world_id = ?)", + ) + .bind(&world_id) + .fetch_one(&pool) + .await + { + Ok(exists) => exists, + Err(e) => { return reject( - SpokeRejectCode::InvalidInput, - format!( - "fork scope references unknown world {world_id}; fork_id {fork_id} cannot be resolved" - ), + SpokeRejectCode::InternalError, + format!("storage error checking world existence for {world_id}: {e}"), json!({ "scope_id": world_id, "fork_id": fork_id }), ); } + }; - // ── Query timeline events with fork_id as branch filter ───── - // Decision 3 mapping: scope.fork_id → branch_id in - // narrative_timeline_events. Pass fork_id as branch_id to the - // same production read primitive used by ScopeQueryPort. - let rows = match list_timeline_events_scoped( - &pool, - &world_id, - Some(&fork_id), - &timeline_event_ids, - ) - .await - { - Ok(rows) => rows, - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!("storage error on list_fork_timeline_events: {e}"), - json!({ "scope_id": world_id, "fork_id": fork_id }), - ); - } - }; - - // ── Convert via V1.143 seam (same as ScopeQueryPort) ──────── - // The `From` impl packs the 7 - // typed nexus fields into extensions.nexus (spec §7.1). - let wire: Vec = rows.into_iter().map(Into::into).collect(); - SpokeResult::Ok(wire) - }) + if !world_exists { + return reject( + SpokeRejectCode::InvalidInput, + format!( + "fork scope references unknown world {world_id}; fork_id {fork_id} cannot be resolved" + ), + json!({ "scope_id": world_id, "fork_id": fork_id }), + ); + } + + // ── Query timeline events with fork_id as branch filter ───── + // Decision 3 mapping: scope.fork_id → branch_id in + // narrative_timeline_events. Pass fork_id as branch_id to the + // same production read primitive used by ScopeQueryPort. + let rows = match list_timeline_events_scoped( + &pool, + &world_id, + Some(&fork_id), + &timeline_event_ids, + ) + .await + { + Ok(rows) => rows, + Err(e) => { + return reject( + SpokeRejectCode::InternalError, + format!("storage error on list_fork_timeline_events: {e}"), + json!({ "scope_id": world_id, "fork_id": fork_id }), + ); + } + }; + + // ── Convert via V1.143 seam (same as ScopeQueryPort) ──────── + // The `From` impl packs the 7 + // typed nexus fields into extensions.nexus (spec §7.1). + let wire: Vec = rows.into_iter().map(Into::into).collect(); + SpokeResult::Ok(wire) } } @@ -238,7 +236,7 @@ mod tests { // Query fbk_main branch — should return 2 events (evt_fk_0, evt_fk_1) let scope = fork_scope(&world_id, "fbk_main", &[]); - let events = match adapter.list_fork_timeline_events(&scope) { + let events = match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -250,7 +248,7 @@ mod tests { // Query fbk_alt branch — should return 1 event (evt_fk_2) let scope_alt = fork_scope(&world_id, "fbk_alt", &[]); - let events_alt = match adapter.list_fork_timeline_events(&scope_alt) { + let events_alt = match adapter.list_fork_timeline_events(&scope_alt).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -274,7 +272,7 @@ mod tests { // but the world does exist. Per Decision 3: an empty event list // for a real world IS Ok(vec![]). let scope = fork_scope(&world_id, "fbk_empty", &[]); - let events = match adapter.list_fork_timeline_events(&scope) { + let events = match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -289,7 +287,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let scope = fork_scope("wld_nonexistent", "fbk_any", &[]); - match adapter.list_fork_timeline_events(&scope) { + match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Ok(_) => panic!("unknown world must reject, not return Ok"), SpokeResult::Reject(r) => { assert_eq!( @@ -321,7 +319,7 @@ mod tests { })) .expect("minimal scope is schema-valid"); - match adapter.list_fork_timeline_events(&scope) { + match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Ok(_) => panic!("missing fork_id must reject"), SpokeResult::Reject(r) => { assert_eq!( @@ -347,7 +345,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let scope = fork_scope(&world_id, "fbk_main", &[&event_ids[0]]); - let events = match adapter.list_fork_timeline_events(&scope) { + let events = match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -368,7 +366,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let scope = fork_scope(&world_id, "fbk_main", &[]); - let events = match adapter.list_fork_timeline_events(&scope) { + let events = match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -421,7 +419,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let scope = fork_scope(&world_id, "fbk_main", &[]); - match adapter.list_fork_timeline_events(&scope) { + match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -446,7 +444,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let scope = fork_scope(&world_id, "fbk_main", &[]); - match adapter.list_fork_timeline_events(&scope) { + match adapter.list_fork_timeline_events(&scope).await { SpokeResult::Reject(r) => { assert_eq!( r.code, diff --git a/crates/nexus-spoke-adapter/src/adapter/host_manifest_port.rs b/crates/nexus-spoke-adapter/src/adapter/host_manifest_port.rs index a092b7637..0e4305b82 100644 --- a/crates/nexus-spoke-adapter/src/adapter/host_manifest_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/host_manifest_port.rs @@ -40,10 +40,12 @@ use super::NexusAdapter; use crate::{HostCapabilityManifest, HostManifestPort, SpokeRejectCode, SpokeResult}; +use async_trait::async_trait; use nexus_home_layout::device_id::get_or_create_device_id; +#[async_trait] impl HostManifestPort for NexusAdapter<'_> { - fn get_host_capability_manifest(&self) -> SpokeResult { + async fn get_host_capability_manifest(&self) -> SpokeResult { // Single builder SSOT (product draft §4.1): the Connect Host's // `local_manifest` (P3 T3) calls the same function with the same // device-id host_id — one capability list, one extensions.nexus block. @@ -64,7 +66,9 @@ impl HostManifestPort for NexusAdapter<'_> { /// Local-first nexus has no peers; peer discovery is a roadmap item /// triggered when nexus supports multi-host collaboration. See the /// module-level docs. - fn list_peer_host_capability_manifests(&self) -> SpokeResult> { + async fn list_peer_host_capability_manifests( + &self, + ) -> SpokeResult> { SpokeResult::Ok(Vec::new()) } } @@ -102,7 +106,7 @@ mod tests { use crate::HostManifestPort; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn self_manifest_reports_injected_host_id_and_n_c0_contract() { + async fn self_manifest_reports_injected_host_id_and_n_c1_contract() { // HostManifestPort is storage-free; the pool is only needed to // satisfy the adapter struct shape. Use an in-memory pool with // migrations so the adapter construction path mirrors the other @@ -114,7 +118,7 @@ mod tests { nexus_local_db::run_migrations(&pool).await.unwrap(); let adapter = NexusAdapter::new(pool).with_host_id("test-host-uuid-0000"); - let manifest = match adapter.get_host_capability_manifest() { + let manifest = match adapter.get_host_capability_manifest().await { SpokeResult::Ok(m) => m, SpokeResult::Reject(r) => panic!("self manifest is Ok: {r:?}"), }; @@ -137,7 +141,7 @@ mod tests { .collect::>(), vec!["nexus".to_string()] ); - // Same builder as the Connect Host → the extensions.nexus N-C0 block + // Same builder as the Connect Host → the extensions.nexus N-C1 block // is present on the port surface too (product draft §4.3 item 9). let nexus_ext = manifest .extensions @@ -147,7 +151,17 @@ mod tests { nexus_ext .get("connect_host_slice") .and_then(serde_json::Value::as_str), - Some("n-c0") + Some("n-c1") + ); + let expected_ops_value = serde_json::json!(crate::manifest::LOCAL_SERVED_OPS); + let expected_ops = expected_ops_value + .as_array() + .expect("locked op list serializes as an array"); + assert_eq!( + nexus_ext + .get("served_ops") + .and_then(serde_json::Value::as_array), + Some(expected_ops) ); assert_eq!( nexus_ext @@ -168,7 +182,7 @@ mod tests { nexus_local_db::run_migrations(&pool).await.unwrap(); let adapter = NexusAdapter::new(pool); - let peers = match adapter.list_peer_host_capability_manifests() { + let peers = match adapter.list_peer_host_capability_manifests().await { SpokeResult::Ok(p) => p, SpokeResult::Reject(r) => panic!("peer list is Ok: {r:?}"), }; diff --git a/crates/nexus-spoke-adapter/src/adapter/knowledge_entry_port.rs b/crates/nexus-spoke-adapter/src/adapter/knowledge_entry_port.rs index 3ecf79611..eec1fa7aa 100644 --- a/crates/nexus-spoke-adapter/src/adapter/knowledge_entry_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/knowledge_entry_port.rs @@ -27,6 +27,7 @@ use super::NexusAdapter; use crate::conversion::{spoke_to_world_kb, world_kb_to_spoke}; use crate::extensions::build_extensions_nexus; use crate::{KnowledgeEntry, KnowledgeEntryPort, SpokeReject, SpokeRejectCode, SpokeResult}; +use async_trait::async_trait; use nexus_knowledge::world_kb::store::{KbStore, KbStoreError}; use nexus_knowledge::world_kb::WorldKbEntry; use nexus_local_db::kb_store::{ @@ -116,34 +117,31 @@ impl NexusAdapter<'_> { } } +#[async_trait] impl KnowledgeEntryPort for NexusAdapter<'_> { - fn get_knowledge_entry(&self, entry_id: &str) -> SpokeResult { + async fn get_knowledge_entry(&self, entry_id: &str) -> SpokeResult { let pool = self.pool.clone(); let entry_id = entry_id.to_string(); - self.block_on(async move { - let store = SqliteKbStore::new(pool); - let world_entry: WorldKbEntry = match store.get_knowledge_entry(&entry_id).await { - Ok(row) => row, - Err(e) => return Self::map_get_err(e, &entry_id), - }; - // Reuse the sole conversion seam (spec §7.1) — now free functions - // in nexus-spoke-adapter (V1.145 P1a dep-graph reversal). - SpokeResult::Ok(world_kb_to_spoke(&world_entry)) - }) + let store = SqliteKbStore::new(pool); + let world_entry: WorldKbEntry = match store.get_knowledge_entry(&entry_id).await { + Ok(row) => row, + Err(e) => return Self::map_get_err(e, &entry_id), + }; + // Reuse the sole conversion seam (spec §7.1) — now free functions + // in nexus-spoke-adapter (V1.145 P1a dep-graph reversal). + SpokeResult::Ok(world_kb_to_spoke(&world_entry)) } - fn put_knowledge_entry( + async fn put_knowledge_entry( &self, entry: KnowledgeEntry, expected_base_revision: Option, ) -> SpokeResult { let pool = self.pool.clone(); - self.block_on(async { - match expected_base_revision { - None => put_create(self, &pool, entry).await, - Some(expected) => put_update(self, &pool, entry, expected).await, - } - }) + match expected_base_revision { + None => put_create(self, &pool, entry).await, + Some(expected) => put_update(self, &pool, entry, expected).await, + } } } @@ -348,13 +346,13 @@ async fn put_update_unbound( /// `entry_updates`: `(candidate entry, expected_base_revision)` pairs. /// `session_update`: optional `(session_id, state_json)` to persist after /// all entry CAS succeeds, still inside the same transaction. -pub(crate) fn commit_compute_settlement( +pub(crate) async fn commit_compute_settlement( adapter: &NexusAdapter<'_>, entry_updates: Vec<(KnowledgeEntry, u64)>, session_update: Option<(String, String)>, ) -> SpokeResult<()> { let pool = adapter.pool.clone(); - adapter.block_on(async move { + { let mut tx = match pool.begin().await { Ok(tx) => tx, Err(e) => { @@ -405,7 +403,7 @@ pub(crate) fn commit_compute_settlement( } SpokeResult::Ok(()) - }) + } } async fn run_cas_update_in_tx( @@ -582,7 +580,7 @@ mod tests { seed_world(&pool).await; let adapter = NexusAdapter::new(pool); - let result = adapter.get_knowledge_entry("kb_missing"); + let result = adapter.get_knowledge_entry("kb_missing").await; match result { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::KnowledgeEntryNotFound); @@ -602,13 +600,13 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_entry("kb_alpha", "Alpha", None); - let put_result = adapter.put_knowledge_entry(entry, None); + let put_result = adapter.put_knowledge_entry(entry, None).await; assert!( matches!(put_result, SpokeResult::Ok(_)), "create should succeed" ); - let got = adapter.get_knowledge_entry("kb_alpha"); + let got = adapter.get_knowledge_entry("kb_alpha").await; match got { SpokeResult::Ok(e) => { assert_eq!(e.entry_id, "kb_alpha"); @@ -629,7 +627,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let entry = spoke_entry("kb_create_happy", "CreateHappy", None); - match adapter.put_knowledge_entry(entry, None) { + match adapter.put_knowledge_entry(entry, None).await { SpokeResult::Ok(e) => { assert_eq!(e.entry_id, "kb_create_happy"); assert_eq!(e.revision, Some(1)); @@ -646,10 +644,10 @@ mod tests { let adapter = NexusAdapter::new(pool); let entry = spoke_entry("kb_dup", "Dup", None); - let first = adapter.put_knowledge_entry(entry.clone(), None); + let first = adapter.put_knowledge_entry(entry.clone(), None).await; assert!(matches!(first, SpokeResult::Ok(_))); - match adapter.put_knowledge_entry(entry, None) { + match adapter.put_knowledge_entry(entry, None).await { SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::KnowledgeEntryAlreadyExists); } @@ -668,7 +666,7 @@ mod tests { let entry = spoke_entry("kb_upd_happy", "UpdHappy", None); // Create first (revision becomes 1). - let created = match adapter.put_knowledge_entry(entry, None) { + let created = match adapter.put_knowledge_entry(entry, None).await { SpokeResult::Ok(e) => e, SpokeResult::Reject(r) => panic!("create failed: {r:?}"), }; @@ -680,7 +678,7 @@ mod tests { updated.body.summary = Some("Updated summary".to_string()); updated.status = "confirmed".to_string(); - match adapter.put_knowledge_entry(updated, Some(1)) { + match adapter.put_knowledge_entry(updated, Some(1)).await { SpokeResult::Ok(e) => { assert_eq!(e.revision, Some(2), "CAS update must bump revision"); assert_eq!(e.body.summary.as_deref(), Some("Updated summary")); @@ -691,7 +689,7 @@ mod tests { // Verify the row persisted the post-CAS field update (status + body // must both be reflected on re-read through the conversion seam). - match adapter.get_knowledge_entry("kb_upd_happy") { + match adapter.get_knowledge_entry("kb_upd_happy").await { SpokeResult::Ok(e) => { assert_eq!(e.revision, Some(2)); assert_eq!(e.status, "confirmed"); @@ -712,13 +710,13 @@ mod tests { // Create → revision 1. Bump to 2. Then attempt another update with // expected = 1 (caller read a stale base before the second writer // bumped). Store (2) > expected (1) → STORED_REVISION_STALE. - let created = unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + let created = unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); let _ = unwrap_ok( - adapter.put_knowledge_entry(created.clone(), Some(1)), + adapter.put_knowledge_entry(created.clone(), Some(1)).await, "first update", ); - match adapter.put_knowledge_entry(created, Some(1)) { + match adapter.put_knowledge_entry(created, Some(1)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -744,9 +742,9 @@ mod tests { // Create → revision 1. Then attempt update with expected = 5 (caller // expects a revision the store has never reached). Store (1) < // expected (5) → REVISION_CONFLICT. - let created = unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + let created = unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); - match adapter.put_knowledge_entry(created, Some(5)) { + match adapter.put_knowledge_entry(created, Some(5)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -772,7 +770,7 @@ mod tests { // No prior create — entry is absent. Caller passes expected = Some(3), // expecting a base the store has never reached. Per spec §7.4 row 3, // absent + Some(_) → REVISION_CONFLICT. - match adapter.put_knowledge_entry(entry, Some(3)) { + match adapter.put_knowledge_entry(entry, Some(3)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -799,7 +797,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_entry("kb_unbound_create", "UnboundCreate", None); - match adapter.put_knowledge_entry(entry, None) { + match adapter.put_knowledge_entry(entry, None).await { SpokeResult::Ok(e) => assert_eq!(e.revision, Some(1)), SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), } @@ -825,7 +823,7 @@ mod tests { .unwrap(); let adapter = NexusAdapter::new(pool); - match adapter.get_knowledge_entry("kb_alpha") { + match adapter.get_knowledge_entry("kb_alpha").await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -849,7 +847,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let entry = spoke_entry("kb_fail", "FailCreate", None); - match adapter.put_knowledge_entry(entry, None) { + match adapter.put_knowledge_entry(entry, None).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -870,7 +868,7 @@ mod tests { // Create a real entry first so the update path is exercised. let adapter = NexusAdapter::new(pool.clone()); let entry = spoke_entry("kb_upd_fail", "UpdFail", None); - let created = unwrap_ok(adapter.put_knowledge_entry(entry, None), "create"); + let created = unwrap_ok(adapter.put_knowledge_entry(entry, None).await, "create"); assert_eq!(created.revision, Some(1)); // Drop the table to simulate a DB-level failure on update. @@ -879,7 +877,7 @@ mod tests { .await .unwrap(); - match adapter.put_knowledge_entry(created, Some(1)) { + match adapter.put_knowledge_entry(created, Some(1)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -904,9 +902,12 @@ mod tests { // put_create on already-existing entry — this is an OCC/domain signal, // NOT a storage failure; the DAO's pre-check returns `KnowledgeEntryAlreadyExists`. let entry = spoke_entry("kb_val_ae", "ValAE", None); - let _ = unwrap_ok(adapter.put_knowledge_entry(entry.clone(), None), "create"); + let _ = unwrap_ok( + adapter.put_knowledge_entry(entry.clone(), None).await, + "create", + ); - match adapter.put_knowledge_entry(entry, None) { + match adapter.put_knowledge_entry(entry, None).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -918,7 +919,7 @@ mod tests { } // get on non-existent entry still surfaces KnowledgeEntryNotFound - match adapter.get_knowledge_entry("kb_never_created") { + match adapter.get_knowledge_entry("kb_never_created").await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -950,7 +951,12 @@ mod tests { let entry = spoke_entry("kb_bound_create", "BoundCreate", None); let entry_id = entry.entry_id.clone(); - let put_result = adapter.with_bound_tx(|| adapter.put_knowledge_entry(entry, None)); + // The port method is now async: the closure returns the future and + // the handler awaits it outside (with_bound_tx stays a sync + // passthrough). UFCS keeps the future un-awaited inside the closure. + let put_result = adapter + .with_bound_tx(|| KnowledgeEntryPort::put_knowledge_entry(&adapter, entry, None)) + .await; assert!( matches!(put_result, SpokeResult::Ok(_)), "bound put should succeed in-tx" diff --git a/crates/nexus-spoke-adapter/src/adapter/mca_read.rs b/crates/nexus-spoke-adapter/src/adapter/mca_read.rs index 19a279f5f..640ab53e7 100644 --- a/crates/nexus-spoke-adapter/src/adapter/mca_read.rs +++ b/crates/nexus-spoke-adapter/src/adapter/mca_read.rs @@ -158,14 +158,11 @@ pub struct SpokeBackedKbStore { } impl SpokeBackedKbStore { - /// Construct from a [`SqlitePool`], built inside a tokio multi-threaded - /// runtime (the adapter captures the runtime [`Handle`] — see + /// Construct from a [`SqlitePool`]. The adapter's port methods are + /// natively `async fn` (spoke-operations 0.9.1 surface) and await + /// `SQLite` I/O on the caller's runtime — no runtime handle is captured + /// and no tokio runtime is required at construction (see /// [`NexusAdapter::new`]). - /// - /// # Panics - /// - /// Panics if no tokio runtime is running on the current thread (same - /// precondition as [`NexusAdapter::new`]). #[must_use] pub fn new(pool: SqlitePool) -> Self { let adapter = NexusAdapter::new(pool.clone()); diff --git a/crates/nexus-spoke-adapter/src/adapter/mod.rs b/crates/nexus-spoke-adapter/src/adapter/mod.rs index 74c2a82f9..8fa236bca 100644 --- a/crates/nexus-spoke-adapter/src/adapter/mod.rs +++ b/crates/nexus-spoke-adapter/src/adapter/mod.rs @@ -10,17 +10,14 @@ //! storage (no spoke-adapter dep) and `nexus-spoke-adapter` is the capability //! aggregation layer (spec §8 dep-graph reversal). //! -//! # Async ↔ sync bridge +//! # Async surface (V1.153 P0 T2) //! -//! Spoke's port traits are **synchronous** (`fn ... -> SpokeResult`) while -//! `SQLite` I/O is async. The adapter captures the current tokio runtime -//! [`Handle`] at construction and bridges each sync port method to async I/O -//! via `tokio::task::block_in_place` + `Handle::block_on`. This requires the -//! calling thread to be inside a tokio **multi-threaded** runtime — which the -//! production daemon uses (`tokio::runtime::Builder::new_multi_thread` in -//! `apps/nexus42/src/main.rs`). Construct the adapter from inside an async -//! context (e.g. an HTTP handler or a `#[tokio::test(flavor = "multi_thread")]` -//! test) so a runtime handle is available. +//! spoke-operations 0.9.1 converted the adapter port traits to +//! `#[async_trait] async fn` (and `orchestrate_*` to native `async fn`), so +//! the port impls are now natively async: each method awaits `SQLite` I/O +//! directly on the caller's runtime. The former sync bridge +//! (`Handle::block_on` + `tokio::task::block_in_place`) is gone; the adapter +//! no longer captures a runtime handle and can be constructed anywhere. pub mod activation; pub mod computable_port; @@ -36,30 +33,20 @@ pub mod scope_query_port; use sqlx::SqlitePool; use std::sync::{Arc, Mutex}; -use tokio::runtime::Handle; /// Production `BaselinePorts` impl backing spoke orchestrators against nexus /// `SQLite` storage. /// /// See `.mstar/specs/spoke-adapter-architecture.md` §7.4 for the family /// matrix (which families are production vs stub). Construct per-request from -/// a [`SqlitePool`] (cheap handle clone) **while inside a tokio multi-threaded -/// runtime**: the adapter captures the current runtime [`Handle`] and bridges -/// the sync spoke port trait to async `SQLite` I/O via -/// `tokio::task::block_in_place`. +/// a [`SqlitePool`] (cheap handle clone); the port methods are natively +/// `async fn` (spoke-operations 0.9.1 surface) and await `SQLite` I/O on the +/// caller's runtime — no runtime handle is captured. /// /// When `with_tx_cell` is used, the lifetime parameter ties the adapter to the /// handler-owned `sqlx::Transaction` for the duration of one orchestrate call. -/// -/// # Panics -/// -/// [`Self::new`] panics if no tokio runtime is running on the current thread. -/// In debug builds it additionally panics if that runtime is **not** -/// multi-threaded — the `block_in_place` bridge used by every sync port -/// method requires a multi-threaded runtime (see [`Self::block_on`]). pub struct NexusAdapter<'a> { pool: SqlitePool, - handle: Handle, /// Injected installation identity (`~/.nexus42/device-id` UUID) for the /// `HostCapabilityManifest`. `None` → `HostManifestPort` resolves the /// device id from the standard nexus home on demand. V1.148 P3 N-C0: @@ -74,34 +61,16 @@ pub struct NexusAdapter<'a> { } impl NexusAdapter<'static> { - /// Construct from the current tokio runtime. + /// Construct from a [`SqlitePool`] (cheap handle clone). /// - /// # Panics - /// - /// Panics if no tokio runtime is running on the current thread - /// ([`Handle::current`]). In debug builds, additionally panics if the - /// current runtime is **not** multi-threaded: `block_in_place` (used by - /// [`Self::block_on`]) panics under a `current_thread` runtime, so this - /// early check surfaces the misuse at construction rather than at the - /// first port method call. Construct this from inside a multi-threaded - /// tokio context (e.g. an async daemon handler or a - /// `#[tokio::test(flavor = "multi_thread")]` test). + /// V1.153 P0 T2: no tokio runtime is captured anymore — the port methods + /// are natively `async fn` (spoke-operations 0.9.1 surface) and await + /// `SQLite` I/O on the caller's runtime, so the former multi-threaded + /// runtime requirement (`block_in_place` bridge) is gone. #[must_use] - pub fn new(pool: SqlitePool) -> Self { - let handle = Handle::current(); - // W-2 (qc3): `Handle::current()` succeeds even for a `current_thread` - // runtime, so the real guard is the flavor check. `block_in_place` - // panics under a current-thread runtime; fail fast at construction in - // debug builds so the panic points here, not at the first port call. - // No-op in release builds. - debug_assert!( - handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread, - "NexusAdapter requires a multi-threaded tokio runtime \ - (block_in_place panics under a current_thread runtime)" - ); + pub const fn new(pool: SqlitePool) -> Self { NexusAdapter { pool, - handle, host_id: None, bound_tx_cell: None, } @@ -141,10 +110,10 @@ impl<'a> NexusAdapter<'a> { /// Run `f` while the adapter's bound transaction cell (if any) is active. /// - /// `orchestrate_promote` and other sync spoke orchestrators call this from - /// async handlers via a short synchronous bridge (`block_in_place` inside - /// `put_knowledge_entry`). The handler must keep the [`Arc`] alive and must - /// not commit/rollback until after `f` returns. + /// `orchestrate_promote` and the other `orchestrate_*` entrypoints (now + /// native `async fn`) are awaited from async handlers while the bound + /// transaction cell is active. The handler must keep the [`Arc`] alive and + /// must not commit/rollback until after the awaited orchestrator returns. pub fn with_bound_tx(&self, f: F) -> R where F: FnOnce() -> R, @@ -170,20 +139,6 @@ impl<'a> NexusAdapter<'a> { .as_ref() .is_some_and(|cell| cell.lock().ok().is_some_and(|guard| guard.is_some())) } - - /// Bridge a sync trait method → async `SQLite` I/O. - /// - /// Requires the calling thread to be inside a tokio multi-threaded runtime - /// (the production daemon uses `tokio::runtime::Builder::new_multi_thread`; - /// see `apps/nexus42/src/main.rs`). `block_in_place` moves the current - /// worker out of the scheduler while the `SQLite` future resolves elsewhere - /// on the runtime. - fn block_on(&self, future: F) -> R - where - F: std::future::Future, - { - tokio::task::block_in_place(|| self.handle.block_on(future)) - } } #[cfg(test)] diff --git a/crates/nexus-spoke-adapter/src/adapter/narrative_read.rs b/crates/nexus-spoke-adapter/src/adapter/narrative_read.rs index c9146733b..07d9e5da8 100644 --- a/crates/nexus-spoke-adapter/src/adapter/narrative_read.rs +++ b/crates/nexus-spoke-adapter/src/adapter/narrative_read.rs @@ -75,11 +75,7 @@ impl NexusAdapter<'_> { /// deps) were removed in Task 3 of this plan — this facet is the /// canonical ordered read for the workspace. /// - /// # Panics - /// - /// Panics only if constructed outside a tokio multi-threaded runtime - /// (inherited from [`NexusAdapter::new`] via [`Self::block_on`]). - pub fn list_timeline_events_ordered( + pub async fn list_timeline_events_ordered( &self, scope: &Scope, ordered_ids: &[String], @@ -95,54 +91,50 @@ impl NexusAdapter<'_> { .and_then(|ns| ns.get("branch_id")) .and_then(Value::as_str) .map(str::to_owned); - // Clone the ordered_ids so the async block is 'static (the sync method - // takes a slice; the async bridge owns its own copy). let ordered_ids = ordered_ids.to_vec(); - self.block_on(async move { - let rows = match list_timeline_events_scoped( - &pool, - &world_id, - branch_id.as_deref(), - // No event_ids filter: the ordered view needs the full matching - // set so the spoke helper can build a correct stable tail. - &[], - ) - .await - { - Ok(rows) => rows, - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!("storage error on list_timeline_events_scoped: {e}"), - json!({ "scope_id": world_id }), - ); - } - }; - - // Sort purely by sequence_no so the spoke helper's "stable tail" - // (un-listed events) is appended in deterministic sequence order — - // matching the V1.143 T2/T3 stable-tail semantics the gateways - // established (`list_timeline_events_scoped` with a branch already - // returns sequence_no order; the re-sort keeps parity for the - // no-branch / cross-branch case). - let mut sorted = rows; - sorted.sort_by_key(|e| e.sequence_no); - - // Convert nexus TimelineEvent → spoke TimelineEvent via the V1.143 - // conversion seam (the `From` impl - // in nexus-narrative). Call-boundary §7 preserved: the spoke helper - // receives only spoke wire types. The `Vec` - // annotation pins the `Into` target to the spoke type. - let spoke_events: Vec = sorted.into_iter().map(Into::into).collect(); - - // Delegate ordering to the spoke beat-assist helper (pure, - // synchronous — no DB I/O inside). The helper returns the reordered - // spoke events directly; since our return type is already spoke, - // we surface its `SpokeResult` verbatim (Ok or Reject) with no - // reverse conversion — the read-only ordering cannot mutate fields. - order_timeline_events_by_ids(&spoke_events, &ordered_ids) - }) + let rows = match list_timeline_events_scoped( + &pool, + &world_id, + branch_id.as_deref(), + // No event_ids filter: the ordered view needs the full matching + // set so the spoke helper can build a correct stable tail. + &[], + ) + .await + { + Ok(rows) => rows, + Err(e) => { + return reject( + SpokeRejectCode::InternalError, + format!("storage error on list_timeline_events_scoped: {e}"), + json!({ "scope_id": world_id }), + ); + } + }; + + // Sort purely by sequence_no so the spoke helper's "stable tail" + // (un-listed events) is appended in deterministic sequence order — + // matching the V1.143 T2/T3 stable-tail semantics the gateways + // established (`list_timeline_events_scoped` with a branch already + // returns sequence_no order; the re-sort keeps parity for the + // no-branch / cross-branch case). + let mut sorted = rows; + sorted.sort_by_key(|e| e.sequence_no); + + // Convert nexus TimelineEvent → spoke TimelineEvent via the V1.143 + // conversion seam (the `From` impl + // in nexus-narrative). Call-boundary §7 preserved: the spoke helper + // receives only spoke wire types. The `Vec` + // annotation pins the `Into` target to the spoke type. + let spoke_events: Vec = sorted.into_iter().map(Into::into).collect(); + + // Delegate ordering to the spoke beat-assist helper (pure, + // synchronous — no DB I/O inside). The helper returns the reordered + // spoke events directly; since our return type is already spoke, + // we surface its `SpokeResult` verbatim (Ok or Reject) with no + // reverse conversion — the read-only ordering cannot mutate fields. + order_timeline_events_by_ids(&spoke_events, &ordered_ids) } } @@ -276,14 +268,17 @@ mod tests { let adapter = NexusAdapter::new(pool); // Request [evt_3, evt_1, evt_5] explicitly; remaining (evt_2 seq1, // evt_4 seq2) form the stable tail in sequence_no order. - let ordered = match adapter.list_timeline_events_ordered( - &ordered_scope("wld_ord", Some("fbk_root")), - &[ - "evt_3".to_string(), - "evt_1".to_string(), - "evt_5".to_string(), - ], - ) { + let ordered = match adapter + .list_timeline_events_ordered( + &ordered_scope("wld_ord", Some("fbk_root")), + &[ + "evt_3".to_string(), + "evt_1".to_string(), + "evt_5".to_string(), + ], + ) + .await + { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -342,7 +337,7 @@ mod tests { let scope = ordered_scope("wld_nomut", Some("fbk_root")); // Baseline: the same rows via the un-ordered spoke read. - let baseline = match adapter.list_timeline_events(&scope) { + let baseline = match adapter.list_timeline_events(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("baseline read rejected: {r:?}"), }; @@ -353,6 +348,7 @@ mod tests { let ordered = match adapter .list_timeline_events_ordered(&scope, &["evt_1".to_string(), "evt_2".to_string()]) + .await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), @@ -384,10 +380,12 @@ mod tests { seed_world_shuffled(&pool).await; let adapter = NexusAdapter::new(pool); - let result = adapter.list_timeline_events_ordered( - &ordered_scope("wld_ord", Some("fbk_root")), - &["evt_1".to_string(), "evt_missing".to_string()], - ); + let result = adapter + .list_timeline_events_ordered( + &ordered_scope("wld_ord", Some("fbk_root")), + &["evt_1".to_string(), "evt_missing".to_string()], + ) + .await; let SpokeResult::Reject(reject) = result else { panic!("expected reject for unknown ordered id, got: {result:?}"); }; @@ -413,10 +411,12 @@ mod tests { seed::event(&pool, "evt_1", "wld_dup", "fbk_root", "story_advance", 1).await; let adapter = NexusAdapter::new(pool); - let result = adapter.list_timeline_events_ordered( - &ordered_scope("wld_dup", Some("fbk_root")), - &["evt_1".to_string(), "evt_1".to_string()], - ); + let result = adapter + .list_timeline_events_ordered( + &ordered_scope("wld_dup", Some("fbk_root")), + &["evt_1".to_string(), "evt_1".to_string()], + ) + .await; let SpokeResult::Reject(reject) = result else { panic!("expected reject for duplicate ordered id, got: {result:?}"); }; @@ -443,6 +443,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let ordered = match adapter .list_timeline_events_ordered(&ordered_scope("wld_ord", Some("fbk_root")), &[]) + .await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => { @@ -500,6 +501,7 @@ mod tests { // Scope carries world_id only — no `extensions.nexus.branch_id`. let ordered = match adapter .list_timeline_events_ordered(&ordered_scope("wld_nb", None), &[]) + .await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok for no-branch scope, got reject: {r:?}"), @@ -565,10 +567,13 @@ mod tests { .unwrap(); let adapter = NexusAdapter::new(pool); - match adapter.list_timeline_events_ordered( - &ordered_scope("wld_ord", Some("fbk_root")), - &["evt_1".to_string()], - ) { + match adapter + .list_timeline_events_ordered( + &ordered_scope("wld_ord", Some("fbk_root")), + &["evt_1".to_string()], + ) + .await + { SpokeResult::Reject(r) => { assert_eq!( r.code, diff --git a/crates/nexus-spoke-adapter/src/adapter/relation_port.rs b/crates/nexus-spoke-adapter/src/adapter/relation_port.rs index 7aa495c6a..89848c72b 100644 --- a/crates/nexus-spoke-adapter/src/adapter/relation_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/relation_port.rs @@ -76,6 +76,7 @@ use super::NexusAdapter; use crate::{ Relation, RelationExtensionsKey, RelationPort, SpokeReject, SpokeRejectCode, SpokeResult, }; +use async_trait::async_trait; use nexus_local_db::kb_relationships::{ get_relationship, list_relationships_for_world, update_relationship_in_tx, KbRelationshipRow, UpdateRelationshipParams, SOURCE_MANUAL, @@ -83,44 +84,41 @@ use nexus_local_db::kb_relationships::{ use nexus_local_db::LocalDbError; use serde_json::{json, Map, Value}; +#[async_trait] impl RelationPort for NexusAdapter<'_> { - fn get_relation(&self, relation_id: &str) -> SpokeResult { + async fn get_relation(&self, relation_id: &str) -> SpokeResult { let pool = self.pool.clone(); let relation_id = relation_id.to_string(); - self.block_on(async move { - let row = match get_relationship(&pool, &relation_id).await { - Ok(row) => row, - Err(LocalDbError::Sqlx(sqlx::Error::RowNotFound)) => { - return reject( - SpokeRejectCode::RelationNotFound, - format!("Relation not found: {relation_id}"), - json!({ "relation_id": relation_id }), - ); - } - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!("storage error on relation read: {e}"), - json!({ "relation_id": relation_id }), - ); - } - }; - SpokeResult::Ok(crate::conversion::kb_relationship_row_to_spoke(&row)) - }) + let row = match get_relationship(&pool, &relation_id).await { + Ok(row) => row, + Err(LocalDbError::Sqlx(sqlx::Error::RowNotFound)) => { + return reject( + SpokeRejectCode::RelationNotFound, + format!("Relation not found: {relation_id}"), + json!({ "relation_id": relation_id }), + ); + } + Err(e) => { + return reject( + SpokeRejectCode::InternalError, + format!("storage error on relation read: {e}"), + json!({ "relation_id": relation_id }), + ); + } + }; + SpokeResult::Ok(crate::conversion::kb_relationship_row_to_spoke(&row)) } - fn put_relation( + async fn put_relation( &self, relation: Relation, expected_base_revision: Option, ) -> SpokeResult { let pool = self.pool.clone(); - self.block_on(async move { - match expected_base_revision { - None => put_relation_create(&pool, relation).await, - Some(expected) => put_relation_update(&pool, relation, expected).await, - } - }) + match expected_base_revision { + None => put_relation_create(&pool, relation).await, + Some(expected) => put_relation_update(&pool, relation, expected).await, + } } } @@ -160,30 +158,30 @@ impl NexusAdapter<'_> { /// # Errors /// /// Returns [`LocalDbError`] on database failure. - pub fn list_hop_edges_for_world(&self, world_id: &str) -> Result, LocalDbError> { + pub async fn list_hop_edges_for_world( + &self, + world_id: &str, + ) -> Result, LocalDbError> { let pool = self.pool.clone(); let world_id = world_id.to_string(); - self.block_on(async move { - // `cap + 1` probe per the `list_relationships_for_world` caller - // convention: a result of `cap + 1` rows signals overflow; the - // probe row is dropped below (truncate, no panic). The const - // (10_000) always fits `usize`; the fallback is defensive only. - let limit = usize::try_from(HOP_EDGE_LIST_LIMIT).unwrap_or(usize::MAX); - let rows = - list_relationships_for_world(&pool, &world_id, false, HOP_EDGE_LIST_LIMIT + 1) - .await?; - let edges = rows - .into_iter() - .take(limit) - .map(|row| HopEdge { - relation_id: row.relationship_id, - from_id: row.source_entity_id, - to_id: row.target_entity_id, - relation_type: row.relation_type, - }) - .collect(); - Ok(edges) - }) + // `cap + 1` probe per the `list_relationships_for_world` caller + // convention: a result of `cap + 1` rows signals overflow; the + // probe row is dropped below (truncate, no panic). The const + // (10_000) always fits `usize`; the fallback is defensive only. + let limit = usize::try_from(HOP_EDGE_LIST_LIMIT).unwrap_or(usize::MAX); + let rows = + list_relationships_for_world(&pool, &world_id, false, HOP_EDGE_LIST_LIMIT + 1).await?; + let edges = rows + .into_iter() + .take(limit) + .map(|row| HopEdge { + relation_id: row.relationship_id, + from_id: row.source_entity_id, + to_id: row.target_entity_id, + relation_type: row.relation_type, + }) + .collect(); + Ok(edges) } } @@ -702,7 +700,7 @@ mod tests { seed_world_and_endpoints(&pool).await; let adapter = NexusAdapter::new(pool); - match adapter.get_relation("rel_missing") { + match adapter.get_relation("rel_missing").await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -725,12 +723,14 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let created = unwrap_ok( - adapter.put_relation(spoke_relation("rel_rt", "kb_src", "kb_dst"), None), + adapter + .put_relation(spoke_relation("rel_rt", "kb_src", "kb_dst"), None) + .await, "create", ); assert_eq!(created.revision, Some(1)); - match adapter.get_relation("rel_rt") { + match adapter.get_relation("rel_rt").await { SpokeResult::Ok(r) => { assert_eq!(r.relation_id, "rel_rt"); assert_eq!(r.from_id, "kb_src"); @@ -785,7 +785,7 @@ mod tests { })) .expect("valid spoke Relation fixture with full nexus-locals"); - let created = unwrap_ok(adapter.put_relation(relation, None), "create"); + let created = unwrap_ok(adapter.put_relation(relation, None).await, "create"); assert_eq!(created.revision, Some(1)); // Re-read through the port and confirm every nexus-local survived. @@ -794,7 +794,7 @@ mod tests { // unknown `extensions.nexus` keys across the SQLite round-trip. // Known keys are verified below; unknown-key round-trip is covered // by the dedicated `put_relation_round_trips_unknown_nexus_key` test. - match adapter.get_relation("rel_locals") { + match adapter.get_relation("rel_locals").await { SpokeResult::Ok(r) => { assert_eq!(r.relation_id, "rel_locals"); assert_eq!(r.relation_type, "rivals_with"); @@ -851,7 +851,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let relation = spoke_relation("rel_happy", "kb_src", "kb_dst"); - let returned = unwrap_ok(adapter.put_relation(relation, None), "create"); + let returned = unwrap_ok(adapter.put_relation(relation, None).await, "create"); assert_eq!(returned.relation_id, "rel_happy"); assert_eq!(returned.from_id, "kb_src"); assert_eq!(returned.to_id, "kb_dst"); @@ -899,10 +899,10 @@ mod tests { let adapter = NexusAdapter::new(pool); let relation = spoke_relation("rel_dup", "kb_src", "kb_dst"); - let first = adapter.put_relation(relation.clone(), None); + let first = adapter.put_relation(relation.clone(), None).await; assert!(matches!(first, SpokeResult::Ok(_)), "first create succeeds"); - match adapter.put_relation(relation, None) { + match adapter.put_relation(relation, None).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -931,7 +931,7 @@ mod tests { })) .expect("valid minimal Relation"); - match adapter.put_relation(relation, None) { + match adapter.put_relation(relation, None).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -955,7 +955,7 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let relation = spoke_relation("rel_bad_endpoint", "kb_src", "kb_nonexistent"); - match adapter.put_relation(relation, None) { + match adapter.put_relation(relation, None).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -984,7 +984,9 @@ mod tests { // Create → revision 1. let created = unwrap_ok( - adapter.put_relation(spoke_relation("rel_upd", "kb_src", "kb_dst"), None), + adapter + .put_relation(spoke_relation("rel_upd", "kb_src", "kb_dst"), None) + .await, "create", ); assert_eq!(created.revision, Some(1)); @@ -995,7 +997,7 @@ mod tests { updated.label = Some("Alice ↔ Bob (revised)".to_string()); updated.relation_type = "opposes".to_string(); - let rev2 = unwrap_ok(adapter.put_relation(updated, Some(1)), "first update"); + let rev2 = unwrap_ok(adapter.put_relation(updated, Some(1)).await, "first update"); assert_eq!(rev2.relation_id, "rel_upd"); assert_eq!(rev2.revision, Some(2), "CAS update must bump revision"); assert_eq!(rev2.relation_type, "opposes"); @@ -1013,7 +1015,10 @@ mod tests { // not a one-shot. Mutate the label again to distinguish the writes. let mut rev2_mut = rev2; rev2_mut.label = Some("Alice ↔ Bob (v3)".to_string()); - let rev3 = unwrap_ok(adapter.put_relation(rev2_mut, Some(2)), "second update"); + let rev3 = unwrap_ok( + adapter.put_relation(rev2_mut, Some(2)).await, + "second update", + ); assert_eq!( rev3.revision, Some(3), @@ -1022,7 +1027,7 @@ mod tests { assert_eq!(rev3.label.as_deref(), Some("Alice ↔ Bob (v3)")); // Re-read: persisted row has revision 3 + the latest label/type. - match adapter.get_relation("rel_upd") { + match adapter.get_relation("rel_upd").await { SpokeResult::Ok(r) => { assert_eq!(r.revision, Some(3)); assert_eq!(r.relation_type, "opposes"); @@ -1043,15 +1048,17 @@ mod tests { // expected = 1 (caller read a stale base before the second writer // bumped). Store (2) > expected (1) → STORED_REVISION_STALE. let created = unwrap_ok( - adapter.put_relation(spoke_relation("rel_stale", "kb_src", "kb_dst"), None), + adapter + .put_relation(spoke_relation("rel_stale", "kb_src", "kb_dst"), None) + .await, "create", ); let _ = unwrap_ok( - adapter.put_relation(created.clone(), Some(1)), + adapter.put_relation(created.clone(), Some(1)).await, "first update", ); - match adapter.put_relation(created, Some(1)) { + match adapter.put_relation(created, Some(1)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -1075,7 +1082,10 @@ mod tests { // No prior create — relation is absent. Caller passes expected = Some(3); // the relation-port CAS mapping collapses this to STORED_REVISION_STALE // with storeRevision = null (V1.144 brief). - match adapter.put_relation(spoke_relation("rel_absent", "kb_src", "kb_dst"), Some(3)) { + match adapter + .put_relation(spoke_relation("rel_absent", "kb_src", "kb_dst"), Some(3)) + .await + { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -1123,7 +1133,7 @@ mod tests { } })) .expect("valid seed Relation"); - let created = unwrap_ok(adapter.put_relation(seed, None), "create"); + let created = unwrap_ok(adapter.put_relation(seed, None).await, "create"); assert_eq!(created.revision, Some(1)); // Update with a Relation that carries ONLY the required world_id and @@ -1143,11 +1153,11 @@ mod tests { } })) .expect("valid update Relation omitting optional locals"); - let updated = unwrap_ok(adapter.put_relation(update, Some(1)), "update"); + let updated = unwrap_ok(adapter.put_relation(update, Some(1)).await, "update"); assert_eq!(updated.revision, Some(2)); // Re-read through the port and confirm every omitted local is cleared. - let r = unwrap_ok(adapter.get_relation("rel_clr"), "re-read"); + let r = unwrap_ok(adapter.get_relation("rel_clr").await, "re-read"); let key = RelationExtensionsKey::try_from("nexus").unwrap(); let ns = r.extensions.get(&key).expect("nexus namespace present"); assert_eq!( @@ -1186,7 +1196,7 @@ mod tests { .unwrap(); let adapter = NexusAdapter::new(pool); - match adapter.get_relation("rel_any") { + match adapter.get_relation("rel_any").await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -1210,7 +1220,7 @@ mod tests { let adapter = NexusAdapter::new(pool); let relation = spoke_relation("rel_fail_create", "kb_src", "kb_dst"); - match adapter.put_relation(relation, None) { + match adapter.put_relation(relation, None).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -1230,7 +1240,9 @@ mod tests { let adapter = NexusAdapter::new(pool.clone()); let created = unwrap_ok( - adapter.put_relation(spoke_relation("rel_upd_fail", "kb_src", "kb_dst"), None), + adapter + .put_relation(spoke_relation("rel_upd_fail", "kb_src", "kb_dst"), None) + .await, "create", ); assert_eq!(created.revision, Some(1)); @@ -1241,7 +1253,7 @@ mod tests { .await .unwrap(); - match adapter.put_relation(created, Some(1)) { + match adapter.put_relation(created, Some(1)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -1265,9 +1277,12 @@ mod tests { let adapter = NexusAdapter::new(pool); // Create-success-then-recreate → RelationAlreadyExists (domain signal, not storage) let first = spoke_relation("rel_val_ae", "kb_src", "kb_dst"); - let _ = unwrap_ok(adapter.put_relation(first.clone(), None), "first create"); + let _ = unwrap_ok( + adapter.put_relation(first.clone(), None).await, + "first create", + ); - match adapter.put_relation(first, None) { + match adapter.put_relation(first, None).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -1279,7 +1294,7 @@ mod tests { } // get on non-existent → RelationNotFound - match adapter.get_relation("rel_never_created") { + match adapter.get_relation("rel_never_created").await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -1329,22 +1344,25 @@ mod tests { } })) .expect("valid seed Relation"); - let created = unwrap_ok(adapter.put_relation(seed, None), "create"); + let created = unwrap_ok(adapter.put_relation(seed, None).await, "create"); assert_eq!(created.revision, Some(1)); // Read-modify-write: get the fully-populated Relation, mutate a // non-local field (label), write it back with the get-result's // revision as the CAS base. - let read = unwrap_ok(adapter.get_relation("rel_rt2"), "get"); + let read = unwrap_ok(adapter.get_relation("rel_rt2").await, "get"); assert_eq!(read.revision, Some(1)); let mut to_write = read; to_write.label = Some("round-tripped".to_string()); - let written = unwrap_ok(adapter.put_relation(to_write, Some(1)), "round-trip update"); + let written = unwrap_ok( + adapter.put_relation(to_write, Some(1)).await, + "round-trip update", + ); assert_eq!(written.revision, Some(2)); // Re-read: every local survived the get→put round-trip (clear-on-omit // did NOT fire because get populated them all). - let r = unwrap_ok(adapter.get_relation("rel_rt2"), "final get"); + let r = unwrap_ok(adapter.get_relation("rel_rt2").await, "final get"); assert_eq!(r.label.as_deref(), Some("round-tripped")); let key = RelationExtensionsKey::try_from("nexus").unwrap(); let ns = r.extensions.get(&key).expect("nexus namespace present"); @@ -1397,11 +1415,11 @@ mod tests { })) .expect("valid spoke Relation with unknown nexus key"); - let created = unwrap_ok(adapter.put_relation(relation, None), "create"); + let created = unwrap_ok(adapter.put_relation(relation, None).await, "create"); assert_eq!(created.revision, Some(1)); // Re-read and confirm the unknown keys survived alongside the known ones. - let r = unwrap_ok(adapter.get_relation("rel_unknown_key"), "get"); + let r = unwrap_ok(adapter.get_relation("rel_unknown_key").await, "get"); let key = RelationExtensionsKey::try_from("nexus").unwrap(); let ns = r.extensions.get(&key).expect("nexus namespace present"); @@ -1452,16 +1470,19 @@ mod tests { })) .expect("valid seed Relation"); - let created = unwrap_ok(adapter.put_relation(seed, None), "create"); + let created = unwrap_ok(adapter.put_relation(seed, None).await, "create"); assert_eq!(created.revision, Some(1)); // Update: change only the label. The unknown keys must survive. let mut update = created; update.label = Some("updated label".to_string()); - let updated = unwrap_ok(adapter.put_relation(update, Some(1)), "update"); + let updated = unwrap_ok(adapter.put_relation(update, Some(1)).await, "update"); assert_eq!(updated.revision, Some(2)); - let r = unwrap_ok(adapter.get_relation("rel_upd_unk"), "get after update"); + let r = unwrap_ok( + adapter.get_relation("rel_upd_unk").await, + "get after update", + ); let key = RelationExtensionsKey::try_from("nexus").unwrap(); let ns = r.extensions.get(&key).expect("nexus namespace present"); @@ -1494,11 +1515,15 @@ mod tests { // Two confirmed edges (different types) + one extraction suggestion // (needs_review = true — must be excluded from lore hops). unwrap_ok( - adapter.put_relation(spoke_relation("rel_hop1", "kb_src", "kb_dst"), None), + adapter + .put_relation(spoke_relation("rel_hop1", "kb_src", "kb_dst"), None) + .await, "create confirmed edge 1", ); unwrap_ok( - adapter.put_relation(spoke_relation("rel_hop2", "kb_dst", "kb_src"), None), + adapter + .put_relation(spoke_relation("rel_hop2", "kb_dst", "kb_src"), None) + .await, "create confirmed edge 2", ); let suggested: Relation = serde_json::from_value(json!({ @@ -1517,12 +1542,13 @@ mod tests { })) .expect("valid spoke Relation fixture (suggested)"); unwrap_ok( - adapter.put_relation(suggested, None), + adapter.put_relation(suggested, None).await, "create suggested relation", ); let edges = adapter .list_hop_edges_for_world("wld_rel") + .await .expect("hop-edge load succeeds"); assert_eq!(edges.len(), 2, "suggested (needs_review) edge excluded"); diff --git a/crates/nexus-spoke-adapter/src/adapter/rule_query_port.rs b/crates/nexus-spoke-adapter/src/adapter/rule_query_port.rs index 731a555cd..7fa6a2bf5 100644 --- a/crates/nexus-spoke-adapter/src/adapter/rule_query_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/rule_query_port.rs @@ -44,6 +44,7 @@ use super::NexusAdapter; use crate::{Rule, RuleQueryPort, SpokeReject, SpokeRejectCode, SpokeResult}; +use async_trait::async_trait; use chrono::{DateTime, Utc}; use nexus_local_db::spoke_rules::{get_spoke_rules_by_ids, SpokeRuleRow}; use serde_json::{json, Map, Value}; @@ -51,23 +52,22 @@ use spoke_schemas::data::rule::{RuleCanonicalName, RuleExtensionsKey}; use std::collections::HashMap; use std::num::NonZeroU64; +#[async_trait] impl RuleQueryPort for NexusAdapter<'_> { - fn list_rules(&self, rule_refs: &[String]) -> SpokeResult> { + async fn list_rules(&self, rule_refs: &[String]) -> SpokeResult> { let pool = self.pool.clone(); let rule_refs = rule_refs.to_vec(); - self.block_on(async move { - let rows = match get_spoke_rules_by_ids(&pool, &rule_refs).await { - Ok(rows) => rows, - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!("storage error on rule lookup: {e}"), - json!({}), - ); - } - }; - SpokeResult::Ok(rows.iter().filter_map(row_to_rule).collect()) - }) + let rows = match get_spoke_rules_by_ids(&pool, &rule_refs).await { + Ok(rows) => rows, + Err(e) => { + return reject( + SpokeRejectCode::InternalError, + format!("storage error on rule lookup: {e}"), + json!({}), + ); + } + }; + SpokeResult::Ok(rows.iter().filter_map(row_to_rule).collect()) } } @@ -192,11 +192,13 @@ mod tests { let adapter = NexusAdapter::new(pool); let rules = unwrap_ok( - adapter.list_rules(&[ - "rule_a".to_string(), - "rule_missing".to_string(), - "rule_b".to_string(), - ]), + adapter + .list_rules(&[ + "rule_a".to_string(), + "rule_missing".to_string(), + "rule_b".to_string(), + ]) + .await, "list_rules", ); @@ -246,7 +248,10 @@ mod tests { .await; let adapter = NexusAdapter::new(pool); - let rules = unwrap_ok(adapter.list_rules(&["rule_rich".to_string()]), "list_rules"); + let rules = unwrap_ok( + adapter.list_rules(&["rule_rich".to_string()]).await, + "list_rules", + ); assert_eq!(rules.len(), 1); let rule = &rules[0]; @@ -269,7 +274,7 @@ mod tests { seed_rule(&pool, "rule_a", "wld_1", None, "{}").await; let adapter = NexusAdapter::new(pool); - let rules = unwrap_ok(adapter.list_rules(&[]), "list_rules empty"); + let rules = unwrap_ok(adapter.list_rules(&[]).await, "list_rules empty"); assert!( rules.is_empty(), "empty refs must return Ok(vec![]) without error" @@ -283,7 +288,9 @@ mod tests { let adapter = NexusAdapter::new(pool); let rules = unwrap_ok( - adapter.list_rules(&["rule_nope".to_string(), "rule_nada".to_string()]), + adapter + .list_rules(&["rule_nope".to_string(), "rule_nada".to_string()]) + .await, "list_rules all missing", ); assert!( @@ -302,7 +309,7 @@ mod tests { .unwrap(); let adapter = NexusAdapter::new(pool); - match adapter.list_rules(&["rule_a".to_string()]) { + match adapter.list_rules(&["rule_a".to_string()]).await { SpokeResult::Reject(r) => { assert_eq!( r.code, diff --git a/crates/nexus-spoke-adapter/src/adapter/scope_query_port.rs b/crates/nexus-spoke-adapter/src/adapter/scope_query_port.rs index 8da6b2d75..c144d17ae 100644 --- a/crates/nexus-spoke-adapter/src/adapter/scope_query_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/scope_query_port.rs @@ -39,9 +39,11 @@ use crate::{ KnowledgeEntry, Scope, ScopeExtensionsKey, ScopeQueryPort, SpokeReject, SpokeRejectCode, SpokeResult, TimelineEvent, }; +use async_trait::async_trait; use nexus_local_db::kb_store::SqliteKbStore; use nexus_local_db::narrative_gateway::list_timeline_events_scoped; use serde_json::{json, Map, Value}; +#[async_trait] impl ScopeQueryPort for NexusAdapter<'_> { /// List the active knowledge entries for the scope's world. /// @@ -49,68 +51,64 @@ impl ScopeQueryPort for NexusAdapter<'_> { /// `entry_ids` / `entry_types` filters are applied in SQL. Unfiltered /// full-world listings reject when more than `LIST_BY_WORLD_LIMIT` active /// rows exist (no silent truncation). - fn list_knowledge_entries(&self, scope: &Scope) -> SpokeResult> { + async fn list_knowledge_entries(&self, scope: &Scope) -> SpokeResult> { let pool = self.pool.clone(); - // Clone the filters so the async block is 'static (the sync trait - // method takes `&Scope`; the async bridge owns its own copy). let world_id = scope.scope_id.clone(); let entry_ids: Vec = scope.entry_ids.clone(); let entry_types: Vec = scope.entry_types.clone(); - self.block_on(async move { - let store = SqliteKbStore::new(pool); - let scoped = match store - .list_by_world_scoped(&world_id, &entry_ids, &entry_types) - .await - { - Ok(rows) => rows, - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!("storage error on list_by_world_scoped: {e}"), - json!({ "scope_id": world_id }), - ); - } - }; - - if scoped.truncated { + let store = SqliteKbStore::new(pool); + let scoped = match store + .list_by_world_scoped(&world_id, &entry_ids, &entry_types) + .await + { + Ok(rows) => rows, + Err(e) => { return reject( - SpokeRejectCode::InvalidInput, - format!( - "world {world_id} has more active knowledge entries than the safety cap; \ - narrow the scope with entry_ids or entry_types" - ), - json!({ - "scope_id": world_id, - "cap": nexus_local_db::kb_store::LIST_BY_WORLD_LIMIT, - "truncated": true, - }), + SpokeRejectCode::InternalError, + format!("storage error on list_by_world_scoped: {e}"), + json!({ "scope_id": world_id }), ); } + }; - let wire: Vec = scoped - .entries - .iter() - // Reuse the V1.139 conversion seam — sole boundary between - // WorldKbEntry rows and the spoke wire type (spec §7.1); free - // function in nexus-spoke-adapter since V1.145 P1a. - .map(world_kb_to_spoke) - .collect(); - - // Carrier-boundary guard (QC2-W003): the reserved `_nexus_body` - // carrier is MCA-read-path + persist-path only. This orchestrator - // read path hands spoke entries straight back to the orchestrator, - // so a leaked carrier would persist into the `extensions` DB - // column. `world_kb_to_spoke` never sets it, but this debug-only - // assertion catches a future caller that accidentally stashes one. - debug_assert!( - !wire.iter().any(has_nexus_body), - "ScopeQueryPort::list_knowledge_entries must not carry the _nexus_body carrier \ - (MCA/persist-only); a leaked carrier would reach the extensions DB column" + if scoped.truncated { + return reject( + SpokeRejectCode::InvalidInput, + format!( + "world {world_id} has more active knowledge entries than the safety cap; \ + narrow the scope with entry_ids or entry_types" + ), + json!({ + "scope_id": world_id, + "cap": nexus_local_db::kb_store::LIST_BY_WORLD_LIMIT, + "truncated": true, + }), ); + } - SpokeResult::Ok(wire) - }) + let wire: Vec = scoped + .entries + .iter() + // Reuse the V1.139 conversion seam — sole boundary between + // WorldKbEntry rows and the spoke wire type (spec §7.1); free + // function in nexus-spoke-adapter since V1.145 P1a. + .map(world_kb_to_spoke) + .collect(); + + // Carrier-boundary guard (QC2-W003): the reserved `_nexus_body` + // carrier is MCA-read-path + persist-path only. This orchestrator + // read path hands spoke entries straight back to the orchestrator, + // so a leaked carrier would persist into the `extensions` DB + // column. `world_kb_to_spoke` never sets it, but this debug-only + // assertion catches a future caller that accidentally stashes one. + debug_assert!( + !wire.iter().any(has_nexus_body), + "ScopeQueryPort::list_knowledge_entries must not carry the _nexus_body carrier \ + (MCA/persist-only); a leaked carrier would reach the extensions DB column" + ); + + SpokeResult::Ok(wire) } /// List the timeline events matching the scope (production — V1.145 P3). @@ -121,7 +119,7 @@ impl ScopeQueryPort for NexusAdapter<'_> { /// `scope.timeline_event_ids` → event-id IN filter. Rows are converted to /// spoke [`TimelineEvent`] via the V1.143 conversion seam. See the /// module-level docs for the full filter matrix. - fn list_timeline_events(&self, scope: &Scope) -> SpokeResult> { + async fn list_timeline_events(&self, scope: &Scope) -> SpokeResult> { let pool = self.pool.clone(); let world_id = scope.scope_id.clone(); // branch_id rides scope.extensions["nexus"] (spoke-native since 0.6.0), @@ -135,37 +133,35 @@ impl ScopeQueryPort for NexusAdapter<'_> { .map(str::to_owned); let timeline_event_ids = scope.timeline_event_ids.clone(); - self.block_on(async move { - let rows = match list_timeline_events_scoped( - &pool, - &world_id, - branch_id.as_deref(), - &timeline_event_ids, - ) - .await - { - Ok(rows) => rows, - Err(e) => { - return reject( - SpokeRejectCode::InternalError, - format!("storage error on list_timeline_events_scoped: {e}"), - json!({ "scope_id": world_id }), - ); - } - }; - - // Convert nexus TimelineEvent → spoke TimelineEvent via the V1.143 - // conversion seam (the `From` impl - // defined in nexus-narrative packs the 7 typed nexus fields into - // extensions.nexus). Sole boundary between the nexus domain row and - // the spoke wire type; call-boundary invariant §7 preserved (the - // primitive's nexus type never crosses). The `Vec` - // annotation pins the `Into` target to the spoke type (nexus - // TimelineEvent also has a `From` for nexus_contracts::TimelineEvent, - // so `.into()` is otherwise ambiguous). - let wire: Vec = rows.into_iter().map(Into::into).collect(); - SpokeResult::Ok(wire) - }) + let rows = match list_timeline_events_scoped( + &pool, + &world_id, + branch_id.as_deref(), + &timeline_event_ids, + ) + .await + { + Ok(rows) => rows, + Err(e) => { + return reject( + SpokeRejectCode::InternalError, + format!("storage error on list_timeline_events_scoped: {e}"), + json!({ "scope_id": world_id }), + ); + } + }; + + // Convert nexus TimelineEvent → spoke TimelineEvent via the V1.143 + // conversion seam (the `From` impl + // defined in nexus-narrative packs the 7 typed nexus fields into + // extensions.nexus). Sole boundary between the nexus domain row and + // the spoke wire type; call-boundary invariant §7 preserved (the + // primitive's nexus type never crosses). The `Vec` + // annotation pins the `Into` target to the spoke type (nexus + // TimelineEvent also has a `From` for nexus_contracts::TimelineEvent, + // so `.into()` is otherwise ambiguous). + let wire: Vec = rows.into_iter().map(Into::into).collect(); + SpokeResult::Ok(wire) } } @@ -280,7 +276,7 @@ mod tests { let (world_id, seeded) = seed_world_with_entries(&pool).await; let adapter = NexusAdapter::new(pool); - let entries = match adapter.list_knowledge_entries(&scope_for(&world_id)) { + let entries = match adapter.list_knowledge_entries(&scope_for(&world_id)).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -303,7 +299,10 @@ mod tests { let (pool, _dir) = fresh_pool().await; let adapter = NexusAdapter::new(pool); - let entries = match adapter.list_knowledge_entries(&scope_for("wld_empty")) { + let entries = match adapter + .list_knowledge_entries(&scope_for("wld_empty")) + .await + { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -325,7 +324,7 @@ mod tests { scope.entry_ids = vec![target_id.clone()]; let adapter = NexusAdapter::new(pool); - let entries = match adapter.list_knowledge_entries(&scope) { + let entries = match adapter.list_knowledge_entries(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -342,7 +341,7 @@ mod tests { scope.entry_types = vec!["character".to_string()]; let adapter = NexusAdapter::new(pool); - let entries = match adapter.list_knowledge_entries(&scope) { + let entries = match adapter.list_knowledge_entries(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -370,7 +369,7 @@ mod tests { scope.entry_ids = vec![target_id.clone()]; let adapter = NexusAdapter::new(pool); - let entries = match adapter.list_knowledge_entries(&scope) { + let entries = match adapter.list_knowledge_entries(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -409,7 +408,7 @@ mod tests { } let adapter = NexusAdapter::new(pool); - match adapter.list_knowledge_entries(&scope_for("wld_big")) { + match adapter.list_knowledge_entries(&scope_for("wld_big")).await { SpokeResult::Ok(_) => panic!("unfiltered cap overflow must reject"), SpokeResult::Reject(r) => { assert_eq!(r.code, SpokeRejectCode::InvalidInput); @@ -428,7 +427,7 @@ mod tests { let (world_id, _) = seed_world_with_timeline_events(&pool).await; let adapter = NexusAdapter::new(pool); - let events = match adapter.list_timeline_events(&scope_for(&world_id)) { + let events = match adapter.list_timeline_events(&scope_for(&world_id)).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -476,7 +475,7 @@ mod tests { let adapter = NexusAdapter::new(pool); // branch_id rides scope.extensions["nexus"] (spoke-native ≥ 0.6.0). let scope = timeline_scope(&world_id, Some("fbk_root"), &[]); - let events = match adapter.list_timeline_events(&scope) { + let events = match adapter.list_timeline_events(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -501,7 +500,7 @@ mod tests { let adapter = NexusAdapter::new(pool); // timeline_event_ids is a native Scope field (not under extensions). let scope = timeline_scope(&world_id, None, &["evt_tl_1"]); - let events = match adapter.list_timeline_events(&scope) { + let events = match adapter.list_timeline_events(&scope).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -519,7 +518,7 @@ mod tests { let (pool, _dir) = fresh_pool().await; let adapter = NexusAdapter::new(pool); - let events = match adapter.list_timeline_events(&scope_for("wld_empty")) { + let events = match adapter.list_timeline_events(&scope_for("wld_empty")).await { SpokeResult::Ok(v) => v, SpokeResult::Reject(r) => panic!("expected ok, got reject: {r:?}"), }; @@ -639,7 +638,7 @@ mod tests { .unwrap(); let adapter = NexusAdapter::new(pool); - match adapter.list_knowledge_entries(&scope_for(&world_id)) { + match adapter.list_knowledge_entries(&scope_for(&world_id)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, @@ -662,7 +661,7 @@ mod tests { .unwrap(); let adapter = NexusAdapter::new(pool); - match adapter.list_timeline_events(&scope_for(&world_id)) { + match adapter.list_timeline_events(&scope_for(&world_id)).await { SpokeResult::Reject(r) => { assert_eq!( r.code, diff --git a/crates/nexus-spoke-adapter/src/manifest.rs b/crates/nexus-spoke-adapter/src/manifest.rs index a16684bf5..9f742319d 100644 --- a/crates/nexus-spoke-adapter/src/manifest.rs +++ b/crates/nexus-spoke-adapter/src/manifest.rs @@ -6,19 +6,25 @@ //! [`build_local_host_manifest`] — there is exactly one capability list and //! one `extensions.nexus` block in the product. The N-C0 field lock: //! -//! | Field | N-C0 value | -//! |-------|------------| -//! | `schema_version` | `1` (`NonZeroU64::MIN`) | -//! | `host_id` | caller-supplied installation device-id UUID | -//! | `roles` | `["data-store"]` | -//! | `capabilities` | `["spoke-baseline", "l2-computable", "l5-fork"]` | -//! | `namespaces` | `["nexus"]` | -//! | `authority` | `None` | -//! | `extensions.nexus` | `{ "connect_host_slice": "n-c0", "daemon_http_coexists": true }` | +//! | Field | N-C0 value | N-C1 (V1.153) | +//! |-------|------------|---------------| +//! | `schema_version` | `1` (`NonZeroU64::MIN`) | unchanged | +//! | `host_id` | caller-supplied installation device-id UUID | unchanged | +//! | `roles` | `["data-store"]` | unchanged | +//! | `capabilities` | `["spoke-baseline", "l2-computable", "l5-fork"]` | unchanged | +//! | `namespaces` | `["nexus"]` | unchanged | +//! | `authority` | `None` | unchanged | +//! | `extensions.nexus` | `{ "connect_host_slice": "n-c0", "daemon_http_coexists": true }` | `{ "connect_host_slice": "n-c1", "served_ops": ["upsert", "promote", "relate"], "daemon_http_coexists": true }` | //! //! Honesty rules: `l5-fork` is included because `ForkTimelineQueryPort` is //! production (V1.146); `"reasoning-complete"` MUST NOT appear anywhere //! (reserved for N-C2 when `check`/`assemble` run over Connect). +//! N-C1: `extensions.nexus.served_ops` advertises **exactly** the write ops +//! the Connect invoke dispatcher serves ([`LOCAL_SERVED_OPS`]) — the +//! connect-host dispatch gate owns the same set +//! (`apps/nexus42` `commands::connect::invoke::SERVED_OPS`), and the honesty +//! tests machine-check both directions (advertised ⇔ served) so the two +//! cannot drift unnoticed. use crate::{HostCapabilityManifest, SpokeReject, SpokeRejectCode, SpokeResult}; use serde_json::{json, Map, Value}; @@ -50,6 +56,15 @@ pub const LOCAL_CAPABILITIES: [&str; 3] = ["spoke-baseline", "l2-computable", "l /// Namespaces owned by the local host. pub const LOCAL_NAMESPACES: [&str; 1] = ["nexus"]; +/// Write ops the Nexus host serves over Connect (N-C1, V1.153) — the +/// manifest's advertised op set (`extensions.nexus.served_ops`). +/// +/// The connect-host dispatch gate owns the same set as its served-op table +/// (`apps/nexus42` `commands::connect::invoke::SERVED_OPS`); the honesty +/// tests machine-check both directions (advertised ⇔ served, see +/// [`build_local_host_manifest`] docs) so the two cannot drift unnoticed. +pub const LOCAL_SERVED_OPS: [&str; 3] = ["upsert", "promote", "relate"]; + /// Build the N-C0 `HostCapabilityManifest` from the given `host_id`. /// /// The `host_id` is the installation-scoped device-id UUID (`~/.nexus42/device-id` @@ -81,7 +96,8 @@ pub fn build_local_host_manifest(host_id: &str) -> SpokeResult, op: &str) { + match op { + "upsert" => { + let _ = || { + let request: crate::UpsertRequest = + serde_json::from_value(serde_json::json!({})).expect("typecheck-only"); + let _ = crate::orchestrate_upsert(adapter, request); + }; + } + "promote" => { + let _ = || { + let request: crate::PromoteRequest = + serde_json::from_value(serde_json::json!({})).expect("typecheck-only"); + let _ = crate::orchestrate_promote(adapter, request); + }; + } + "relate" => { + let _ = || { + let request: crate::RelateRequest = + serde_json::from_value(serde_json::json!({})).expect("typecheck-only"); + let _ = crate::orchestrate_relate(adapter, request); + }; + } + other => panic!("advertised op {other:?} is not backed by a production orchestrator"), + } + } + /// Compile-time production-port proof for one advertised capability. /// /// The trait upcasts only compile while the corresponding `impl @@ -187,9 +238,12 @@ mod tests { } } - /// AC-I4.3 / product draft §4.3 — the manifest honesty test (machine-checkable). + /// AC-I4.3 / product draft §4.3 + P1 spec § Manifest honesty — the + /// manifest honesty test (machine-checkable). Renamed for the delivered + /// slice: it now machine-checks the N-C1 manifest while keeping the + /// full N-C0 baseline (roles / capabilities / no `"reasoning-complete"`). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn n_c0_manifest_is_honest() { + async fn n_c1_manifest_is_honest() { // The builder is host_id-injectable so the test is hermetic (no // writes to the real `~/.nexus42`). let manifest = match build_local_host_manifest("test-device-uuid-0000") { @@ -247,7 +301,7 @@ mod tests { // 8. schema_version == 1. assert_eq!(manifest.schema_version.get(), 1); - // 9. extensions["nexus"] carries the N-C0 marker block. + // 9. extensions["nexus"] carries the N-C1 marker block. let nexus_key = "nexus".parse().expect("locked extension key parses"); let nexus_ext = manifest .extensions @@ -255,7 +309,7 @@ mod tests { .expect("extensions.nexus block present"); assert_eq!( nexus_ext.get("connect_host_slice").and_then(Value::as_str), - Some("n-c0") + Some("n-c1") ); assert_eq!( nexus_ext @@ -263,6 +317,34 @@ mod tests { .and_then(Value::as_bool), Some(true) ); + + // 10. N-C1 served-op advertisement (P1 spec § Manifest honesty, + // machine-checkable): extensions.nexus.served_ops is EXACTLY + // LOCAL_SERVED_OPS — no more, no fewer — and every advertised op + // maps to a production orchestrator (compile-time proof). + let served_ops = nexus_ext + .get("served_ops") + .and_then(Value::as_array) + .expect("extensions.nexus.served_ops array present"); + assert_eq!( + served_ops + .iter() + .map(|op| op.as_str().expect("served op is a string")) + .collect::>(), + LOCAL_SERVED_OPS, + "advertised served_ops must be exactly the N-C1 write-op set" + ); + for op in served_ops + .iter() + .map(|op| op.as_str().expect("served op is a string")) + { + assert_op_maps_to_production_orchestrator(&adapter, op); + } + // Direction (b) — every dispatch-served op is advertised — is + // machine-checked against the real dispatch table in the + // connect-host interop suite (apps/nexus42 + // `n_c1_manifest_served_ops_match_dispatch_both_directions`), where + // both the manifest builder and the dispatch gate are visible. } #[test] @@ -301,7 +383,11 @@ mod tests { assert_eq!(hello_json["schema_version"], serde_json::json!(1)); assert_eq!( hello_json["extensions"]["nexus"]["connect_host_slice"], - serde_json::json!("n-c0") + serde_json::json!("n-c1") + ); + assert_eq!( + hello_json["extensions"]["nexus"]["served_ops"], + serde_json::json!(LOCAL_SERVED_OPS) ); // The round-tripped value deserializes back into the data type diff --git a/crates/nexus-spoke-adapter/tests/orchestration_adoption.rs b/crates/nexus-spoke-adapter/tests/orchestration_adoption.rs index 573d21c3d..5c4831934 100644 --- a/crates/nexus-spoke-adapter/tests/orchestration_adoption.rs +++ b/crates/nexus-spoke-adapter/tests/orchestration_adoption.rs @@ -23,6 +23,7 @@ #[allow(dead_code, unused_imports)] mod baseline_adapter; +use async_trait::async_trait; use baseline_adapter::{ assemble_request, knowledge_entry, knowledge_entry_wire, promote_request, NexusBaselineMock, }; @@ -82,15 +83,15 @@ fn project_request(entry_id: &str) -> ProjectRequest { // ── 1. orchestrate_upsert — happy create path ────────────────────────── -#[test] -fn orchestrate_upsert_happy_create_path() { +#[tokio::test] +async fn orchestrate_upsert_happy_create_path() { // Seeded mock holds kb_mira; we upsert a *different* entry so the // create path (`expected_base_revision = None`, entry absent) fires. let mock = seeded_mock(); let new_id = "kb_aran"; let request = upsert_request(&[upsert_entry_wire(new_id, 0)]); - let result = orchestrate_upsert(&mock, request); + let result = orchestrate_upsert(&mock, request).await; match result { SpokeResult::Ok(UpsertResponse::Variant0 { knowledge_entries, .. @@ -104,6 +105,7 @@ fn orchestrate_upsert_happy_create_path() { // Entry is actually in the store. let stored = mock .get_knowledge_entry(new_id) + .await .expect_ok("stored after upsert"); assert_eq!(stored.entry_id, new_id); assert_eq!(stored.revision, Some(0)); @@ -111,8 +113,8 @@ fn orchestrate_upsert_happy_create_path() { // ── 2. orchestrate_upsert — CAS reject: create when entry already exists ── -#[test] -fn orchestrate_upsert_cas_reject_create_when_exists() { +#[tokio::test] +async fn orchestrate_upsert_cas_reject_create_when_exists() { // kb_mira is already seeded. Mask the orchestrator's get so it believes // the entry is absent → derives `expected_base_revision = None` and // treats this as a create (`validate_create_path` requires revision ≤ 0 @@ -124,14 +126,14 @@ fn orchestrate_upsert_cas_reject_create_when_exists() { let request = upsert_request(&[upsert_entry_wire(SEEDED_ENTRY_ID, 0)]); - let result = orchestrate_upsert(&mock, request); + let result = orchestrate_upsert(&mock, request).await; expect_reject_with_code(result, SpokeRejectCode::KnowledgeEntryAlreadyExists); } // ── 3. orchestrate_upsert — happy update path ────────────────────────── -#[test] -fn orchestrate_upsert_happy_update_path() { +#[tokio::test] +async fn orchestrate_upsert_happy_update_path() { // Upsert is CAS-guarded replace, not an increment: the candidate MUST // carry the same revision as stored (the library's // `validate_update_path` calls `assert_revision_match(candidate.revision, @@ -153,7 +155,7 @@ fn orchestrate_upsert_happy_update_path() { }); let request = upsert_request(&[updated_wire]); - let result = orchestrate_upsert(&mock, request); + let result = orchestrate_upsert(&mock, request).await; match result { SpokeResult::Ok(UpsertResponse::Variant0 { knowledge_entries, .. @@ -167,6 +169,7 @@ fn orchestrate_upsert_happy_update_path() { let stored = mock .get_knowledge_entry(SEEDED_ENTRY_ID) + .await .expect_ok("stored after upsert"); assert_eq!(stored.revision, Some(SEEDED_REVISION)); assert_eq!(stored.canonical_name.as_str(), "Mira Vale (revised)"); @@ -174,8 +177,8 @@ fn orchestrate_upsert_happy_update_path() { // ── 4. orchestrate_upsert — CAS reject: stored > expected (STALE) ─────── -#[test] -fn orchestrate_upsert_cas_reject_stale_revision() { +#[tokio::test] +async fn orchestrate_upsert_cas_reject_stale_revision() { // Seeded entry is at revision 3. Override the orchestrator's get so it // returns revision 1 → orchestrator derives `expected_base_revision = // Some(1)` → calls put(candidate, Some(1)). Mock CAS sees stored(3) > @@ -185,7 +188,7 @@ fn orchestrate_upsert_cas_reject_stale_revision() { let request = upsert_request(&[upsert_entry_wire(SEEDED_ENTRY_ID, 1)]); - let result = orchestrate_upsert(&mock, request); + let result = orchestrate_upsert(&mock, request).await; expect_reject_with_code(result, SpokeRejectCode::StoredRevisionStale); } @@ -197,8 +200,8 @@ fn orchestrate_upsert_cas_reject_stale_revision() { // - stored > expected → `STORED_REVISION_STALE` (test #4 above) // - stored < expected → `REVISION_CONFLICT` (this test) -#[test] -fn orchestrate_upsert_cas_reject_conflict() { +#[tokio::test] +async fn orchestrate_upsert_cas_reject_conflict() { // Seeded entry is at revision 1. Override the orchestrator's get so it // returns revision 5 → orchestrator derives `expected_base_revision = // Some(5)` → calls put(candidate, Some(5)). Mock CAS sees stored(1) < @@ -209,21 +212,22 @@ fn orchestrate_upsert_cas_reject_conflict() { let request = upsert_request(&[upsert_entry_wire(SEEDED_ENTRY_ID, 5)]); - let result = orchestrate_upsert(&mock, request); + let result = orchestrate_upsert(&mock, request).await; expect_reject_with_code(result, SpokeRejectCode::RevisionConflict); } // ── 6. orchestrate_promote — happy path ──────────────────────────────── -#[test] -fn orchestrate_promote_happy_path() { +#[tokio::test] +async fn orchestrate_promote_happy_path() { // Candidate claims revision 2, matching the stored provisional entry. // The orchestrator's `assert_revision_match(2, 2)` passes, the promote // gate validates, and the mock's CAS sees stored(2) == expected(2) → // accepts. Persisted entry is `confirmed` at revision 3. let mock = seeded_mock(); - let result = orchestrate_promote(&mock, promote_request(SEEDED_ENTRY_ID, SEEDED_REVISION)); + let result = + orchestrate_promote(&mock, promote_request(SEEDED_ENTRY_ID, SEEDED_REVISION)).await; match result { SpokeResult::Ok(PromoteResponse::Variant0 { knowledge_entry, .. @@ -238,27 +242,28 @@ fn orchestrate_promote_happy_path() { // ── 7. orchestrate_promote — CAS reject: stale candidate revision ────── -#[test] -fn orchestrate_promote_cas_reject_stale() { +#[tokio::test] +async fn orchestrate_promote_cas_reject_stale() { // Candidate claims revision 1; stored is at revision 2. The // orchestrator's `assert_revision_match(1, 2)` fires before reaching // the mock's put: actual(2) > expected(1) → `STORED_REVISION_STALE`. let mock = seeded_mock(); - let result = orchestrate_promote(&mock, promote_request(SEEDED_ENTRY_ID, SEEDED_REVISION - 1)); + let result = + orchestrate_promote(&mock, promote_request(SEEDED_ENTRY_ID, SEEDED_REVISION - 1)).await; expect_reject_with_code(result, SpokeRejectCode::StoredRevisionStale); } // ── 8. orchestrate_assemble — happy path ─────────────────────────────── -#[test] -fn orchestrate_assemble_happy_path() { +#[tokio::test] +async fn orchestrate_assemble_happy_path() { // Scope filters the mock's full entry list down to [kb_mira]; spoke's // packet builder assembles the scoped entries into an AssemblePacket // keyed by `assemble:`. let mock = seeded_mock(); - let result = orchestrate_assemble(&mock, assemble_request("world_1", SEEDED_ENTRY_ID)); + let result = orchestrate_assemble(&mock, assemble_request("world_1", SEEDED_ENTRY_ID)).await; match result { SpokeResult::Ok(AssembleResponse::Variant0 { packet, .. }) => { assert_eq!(packet.packet_id, "assemble:world_1"); @@ -271,8 +276,8 @@ fn orchestrate_assemble_happy_path() { // ── 9. orchestrate_check — happy path ────────────────────────────────── -#[test] -fn orchestrate_check_happy_path() { +#[tokio::test] +async fn orchestrate_check_happy_path() { // Loads scoped entries/events, resolves rules (none requested), invokes // the product checker callback, persists findings. A trivial callback // returning Ok(empty) exercises the full pipeline without product @@ -286,7 +291,8 @@ fn orchestrate_check_happy_path() { assert!(input.events.is_empty()); assert!(input.rules.is_empty()); spoke_ok(Vec::new()) - }); + }) + .await; match result { SpokeResult::Ok(CheckResponse::Variant0 { findings, .. }) => { @@ -309,73 +315,84 @@ fn orchestrate_check_happy_path() { struct BaselineOnlyPorts(NexusBaselineMock); +#[async_trait] impl KnowledgeEntryPort for BaselineOnlyPorts { - fn get_knowledge_entry(&self, entry_id: &str) -> SpokeResult { - self.0.get_knowledge_entry(entry_id) + async fn get_knowledge_entry(&self, entry_id: &str) -> SpokeResult { + self.0.get_knowledge_entry(entry_id).await } - fn put_knowledge_entry( + async fn put_knowledge_entry( &self, entry: KnowledgeEntry, expected_base_revision: Option, ) -> SpokeResult { - self.0.put_knowledge_entry(entry, expected_base_revision) + self.0 + .put_knowledge_entry(entry, expected_base_revision) + .await } } +#[async_trait] impl nexus_spoke_adapter::RelationPort for BaselineOnlyPorts { - fn get_relation(&self, relation_id: &str) -> SpokeResult { - self.0.get_relation(relation_id) + async fn get_relation(&self, relation_id: &str) -> SpokeResult { + self.0.get_relation(relation_id).await } - fn put_relation( + async fn put_relation( &self, relation: nexus_spoke_adapter::Relation, expected_base_revision: Option, ) -> SpokeResult { - self.0.put_relation(relation, expected_base_revision) + self.0.put_relation(relation, expected_base_revision).await } } +#[async_trait] impl ScopeQueryPort for BaselineOnlyPorts { - fn list_knowledge_entries( + async fn list_knowledge_entries( &self, scope: &nexus_spoke_adapter::Scope, ) -> SpokeResult> { - self.0.list_knowledge_entries(scope) + self.0.list_knowledge_entries(scope).await } - fn list_timeline_events( + async fn list_timeline_events( &self, scope: &nexus_spoke_adapter::Scope, ) -> SpokeResult> { - self.0.list_timeline_events(scope) + self.0.list_timeline_events(scope).await } } +#[async_trait] impl FindingPort for BaselineOnlyPorts { - fn put_findings( + async fn put_findings( &self, findings: Vec, ) -> SpokeResult> { - self.0.put_findings(findings) + self.0.put_findings(findings).await } } +#[async_trait] impl RuleQueryPort for BaselineOnlyPorts { - fn list_rules(&self, rule_refs: &[String]) -> SpokeResult> { - self.0.list_rules(rule_refs) + async fn list_rules( + &self, + rule_refs: &[String], + ) -> SpokeResult> { + self.0.list_rules(rule_refs).await } } +#[async_trait] impl HostManifestPort for BaselineOnlyPorts { - fn get_host_capability_manifest( + async fn get_host_capability_manifest( &self, ) -> SpokeResult { - self.0.get_host_capability_manifest() + self.0.get_host_capability_manifest().await } - fn list_peer_host_capability_manifests( + async fn list_peer_host_capability_manifests( &self, ) -> SpokeResult> { - self.0.list_peer_host_capability_manifests() + self.0.list_peer_host_capability_manifests().await } } @@ -389,13 +406,13 @@ impl ComputablePorts for BaselineOnlyPorts { } } -#[test] -fn capability_missing_reject() { +#[tokio::test] +async fn capability_missing_reject() { let ports = BaselineOnlyPorts(seeded_mock()); // `require_port_method(as_computable().is_some(), "project")` fires // before `validate_project_request`, so the request body is irrelevant. - let result = orchestrate_project(&ports, project_request(SEEDED_ENTRY_ID)); + let result = orchestrate_project(&ports, project_request(SEEDED_ENTRY_ID)).await; expect_reject_with_code(result, SpokeRejectCode::CapabilityPortMissing); } diff --git a/crates/nexus-spoke-adapter/tests/rule_query_port_production.rs b/crates/nexus-spoke-adapter/tests/rule_query_port_production.rs index 9834c6383..52ab3345f 100644 --- a/crates/nexus-spoke-adapter/tests/rule_query_port_production.rs +++ b/crates/nexus-spoke-adapter/tests/rule_query_port_production.rs @@ -135,11 +135,13 @@ async fn list_rules_returns_exactly_requested_rules_across_worlds() { let adapter = NexusAdapter::new(pool); let rules = unwrap_ok( - adapter.list_rules(&[ - "rule_a1".to_string(), - "rule_missing".to_string(), - "rule_a2".to_string(), - ]), + adapter + .list_rules(&[ + "rule_a1".to_string(), + "rule_missing".to_string(), + "rule_a2".to_string(), + ]) + .await, "list_rules", ); @@ -170,7 +172,7 @@ async fn list_rules_empty_refs_returns_empty_vec() { seed_rule(&pool, "rule_a1", "wld_a").await; let adapter = NexusAdapter::new(pool); - let rules = unwrap_ok(adapter.list_rules(&[]), "list_rules empty"); + let rules = unwrap_ok(adapter.list_rules(&[]).await, "list_rules empty"); assert!( rules.is_empty(), "empty refs must return Ok(vec![]) without error" @@ -217,7 +219,9 @@ async fn list_rules_does_not_fabricate_from_work_side_rule_sources() { // Phantom id requested alongside a real id: only the `spoke_rules` row // comes back. let rules = unwrap_ok( - adapter.list_rules(&["rule_a1".to_string(), "rule_phantom".to_string()]), + adapter + .list_rules(&["rule_a1".to_string(), "rule_phantom".to_string()]) + .await, "list_rules phantom + real", ); assert_eq!( @@ -228,7 +232,7 @@ async fn list_rules_does_not_fabricate_from_work_side_rule_sources() { // Phantom id requested alone: resolves to the empty subset. let rules = unwrap_ok( - adapter.list_rules(&["rule_phantom".to_string()]), + adapter.list_rules(&["rule_phantom".to_string()]).await, "list_rules phantom only", ); assert!( @@ -251,11 +255,13 @@ async fn list_rules_duplicate_refs_are_deduplicated() { // deduplicated; one row per distinct `rule_id`") holds end-to-end through // the production port. let rules = unwrap_ok( - adapter.list_rules(&[ - "rule_a1".to_string(), - "rule_a1".to_string(), - "rule_a2".to_string(), - ]), + adapter + .list_rules(&[ + "rule_a1".to_string(), + "rule_a1".to_string(), + "rule_a2".to_string(), + ]) + .await, "list_rules duplicates", ); assert_eq!( diff --git a/crates/nexus-spoke-adapter/tests/spoke_orchestrator_integration.rs b/crates/nexus-spoke-adapter/tests/spoke_orchestrator_integration.rs index 7969f6a7b..5352ad271 100644 --- a/crates/nexus-spoke-adapter/tests/spoke_orchestrator_integration.rs +++ b/crates/nexus-spoke-adapter/tests/spoke_orchestrator_integration.rs @@ -192,7 +192,7 @@ async fn orchestrate_upsert_happy_create() { let candidate = spoke_entry(entry_id, "CreateHappy", None, "provisional"); let request = upsert_request(&candidate); - let result = orchestrate_upsert(&adapter, request); + let result = orchestrate_upsert(&adapter, request).await; match result { SpokeResult::Ok(UpsertResponse::Variant0 { knowledge_entries, .. @@ -236,7 +236,7 @@ async fn orchestrate_upsert_happy_update() { // Create → stored at revision 1. let entry_id = "kb_update_happy"; let created = spoke_entry(entry_id, "UpdateHappy", None, "provisional"); - let create_result = orchestrate_upsert(&adapter, upsert_request(&created)); + let create_result = orchestrate_upsert(&adapter, upsert_request(&created)).await; assert!( matches!(create_result, SpokeResult::Ok(_)), "create must succeed first" @@ -247,7 +247,7 @@ async fn orchestrate_upsert_happy_update() { // table) and tweak canonical_name to prove row mutation beyond just the // revision bump. let updated = spoke_entry(entry_id, "UpdateHappy Revised", Some(1), "confirmed"); - let result = orchestrate_upsert(&adapter, upsert_request(&updated)); + let result = orchestrate_upsert(&adapter, upsert_request(&updated)).await; match result { SpokeResult::Ok(UpsertResponse::Variant0 { knowledge_entries, .. @@ -289,10 +289,10 @@ async fn orchestrate_upsert_stale_reject() { // Create → revision 1. let entry_id = "kb_stale_reject"; let created = spoke_entry(entry_id, "StaleReject", None, "provisional"); - let _ = orchestrate_upsert(&adapter, upsert_request(&created)); + let _ = orchestrate_upsert(&adapter, upsert_request(&created)).await; // Bump → revision 2 (happy update with matching revision). let bumped = spoke_entry(entry_id, "StaleReject", Some(1), "provisional"); - let bump_result = orchestrate_upsert(&adapter, upsert_request(&bumped)); + let bump_result = orchestrate_upsert(&adapter, upsert_request(&bumped)).await; assert!( matches!(bump_result, SpokeResult::Ok(_)), "first update must succeed to advance the stored revision" @@ -300,7 +300,7 @@ async fn orchestrate_upsert_stale_reject() { // Stale candidate: revision 1 < stored 2. let stale_candidate = spoke_entry(entry_id, "StaleReject", Some(1), "provisional"); - let result = orchestrate_upsert(&adapter, upsert_request(&stale_candidate)); + let result = orchestrate_upsert(&adapter, upsert_request(&stale_candidate)).await; expect_reject_with_code(result, SpokeRejectCode::StoredRevisionStale); // INDEPENDENT verification: row is unchanged at revision 2 (the stale @@ -332,7 +332,7 @@ async fn orchestrate_promote_happy() { // Create a provisional entry → stored at revision 1. let entry_id = "kb_promote_happy"; let created = spoke_entry(entry_id, "PromoteHappy", None, "provisional"); - let create_result = orchestrate_upsert(&adapter, upsert_request(&created)); + let create_result = orchestrate_upsert(&adapter, upsert_request(&created)).await; assert!( matches!(create_result, SpokeResult::Ok(_)), "create must succeed before promote" @@ -341,7 +341,7 @@ async fn orchestrate_promote_happy() { // Build promote candidate carrying revision = Some(1), status = provisional // (validate_promote_request enforces provisional). let candidate = spoke_entry(entry_id, "PromoteHappy", Some(1), "provisional"); - let result = orchestrate_promote(&adapter, promote_request(&candidate)); + let result = orchestrate_promote(&adapter, promote_request(&candidate)).await; match result { SpokeResult::Ok(PromoteResponse::Variant0 { knowledge_entry, .. @@ -384,8 +384,8 @@ async fn orchestrate_assemble_scope_filtered() { // Create two entries in wld_1. let a = spoke_entry("kb_assemble_a", "AssembleA", None, "provisional"); let b = spoke_entry("kb_assemble_b", "AssembleB", None, "provisional"); - let _ = orchestrate_upsert(&adapter, upsert_request(&a)); - let _ = orchestrate_upsert(&adapter, upsert_request(&b)); + let _ = orchestrate_upsert(&adapter, upsert_request(&a)).await; + let _ = orchestrate_upsert(&adapter, upsert_request(&b)).await; // Scope filters to entry A only. let request = serde_json::from_value(json!({ @@ -394,7 +394,7 @@ async fn orchestrate_assemble_scope_filtered() { })) .expect("valid AssembleRequest fixture"); - let result = orchestrate_assemble(&adapter, request); + let result = orchestrate_assemble(&adapter, request).await; match result { SpokeResult::Ok(AssembleResponse::Variant0 { packet, .. }) => { assert_eq!( diff --git a/package.json b/package.json index 4a3c31761..949ce7401 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "typescript": "7.0.2" }, "dependencies": { - "@42ch/spoke-operations": "0.8.2", - "@42ch/spoke-schemas": "0.8.2" + "@42ch/spoke-operations": "0.9.1", + "@42ch/spoke-schemas": "0.9.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39021570c..c7aa6af94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,11 +15,11 @@ importers: .: dependencies: '@42ch/spoke-operations': - specifier: 0.8.2 - version: 0.8.2 + specifier: 0.9.1 + version: 0.9.1 '@42ch/spoke-schemas': - specifier: 0.8.2 - version: 0.8.2 + specifier: 0.9.1 + version: 0.9.1 devDependencies: oxlint: specifier: ^1.75.0 @@ -356,11 +356,11 @@ importers: packages: - '@42ch/spoke-operations@0.8.2': - resolution: {integrity: sha512-V5YCatALAK3rsaufaMysqI8u5PBCNrJxhRolRmUrab7owMPXd5tp7Km8kW/HabWYVOruVPZ1BvGOykphHrLMVw==} + '@42ch/spoke-operations@0.9.1': + resolution: {integrity: sha512-FUpGNf7kdYm4hlT7BJpyQ7Ien6wtAvBEPv/s/Zuf8tImaR1dB5kx3uYjDFEqh+9zu5RG2IUPEj941mgcH/sikw==} - '@42ch/spoke-schemas@0.8.2': - resolution: {integrity: sha512-CTPqSDfMX2zLBry8nD7wjpQ9lMKDEt5xtNHY8HQF/USnnTwsZArDbBGkGep/Qrc5sexNkxaLjcbYWpwMMy/FoQ==} + '@42ch/spoke-schemas@0.9.1': + resolution: {integrity: sha512-Zr6xa4pjC+0X3tOlFWzUf54AQ2BB3H7Ip5pn7ZhBx9ZZjlt/qKt0IiXYw6eNEAxQ0lrhKSdtt32nWty9+4lb3g==} '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} @@ -3859,11 +3859,11 @@ packages: snapshots: - '@42ch/spoke-operations@0.8.2': + '@42ch/spoke-operations@0.9.1': dependencies: - '@42ch/spoke-schemas': 0.8.2 + '@42ch/spoke-schemas': 0.9.1 - '@42ch/spoke-schemas@0.8.2': {} + '@42ch/spoke-schemas@0.9.1': {} '@adobe/css-tools@4.5.0': {} diff --git a/tooling/check-wire-drift.sh b/tooling/check-wire-drift.sh index 90fb14e9c..20b0318ed 100755 --- a/tooling/check-wire-drift.sh +++ b/tooling/check-wire-drift.sh @@ -5,8 +5,10 @@ # Two gates: # 1. Spoke version conformance — the lockstep spoke pin (spoke-adapter- # architecture spec §1.1/§5.2) is honored in both the Rust workspace -# Cargo.toml and the root npm package.json, for BOTH spoke packages -# (spoke-schemas + spoke-operations). All four pins must match. +# Cargo.toml and the root npm package.json, for ALL spoke packages +# (spoke-schemas + spoke-operations + spoke-connect crates; +# @42ch/spoke-schemas + @42ch/spoke-operations npm). All five pins must +# match. # 2. Schema drift detection — the integration test that validates JSON Schema # wire contracts match their corresponding Rust struct definitions. # @@ -21,18 +23,20 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Lockstep spoke pin (spoke-adapter-architecture spec §1.1). Bump this in # lockstep across Cargo.toml + package.json when adopting a new spoke release. -SPOKE_PIN="0.8.2" +SPOKE_PIN="0.9.1" echo "=== Spoke Version Conformance ===" echo "Expected lockstep pin: ${SPOKE_PIN}" echo "" # ── Gate 1a: Rust crate pins (workspace Cargo.toml) ───────────────────────── -# The workspace [workspace.dependencies] declares exact pins for both crates: -# spoke-schemas = "=0.8.2" -# spoke-operations = "=0.8.2" +# The workspace [workspace.dependencies] declares exact pins for all three +# crates: +# spoke-schemas = "=0.9.1" +# spoke-operations = "=0.9.1" +# spoke-connect = "=0.9.1" (opt-in behind feature `connect-host`) CARGO_TOML="${PROJECT_ROOT}/Cargo.toml" -for crate in spoke-schemas spoke-operations; do +for crate in spoke-schemas spoke-operations spoke-connect; do cargo_spoke_raw=$(grep -E "^[[:space:]]*${crate}[[:space:]]*=" "$CARGO_TOML" | head -1) # Strip to the version token inside the quotes, dropping the leading `=` (exact pin). cargo_spoke=$(printf '%s' "$cargo_spoke_raw" | sed -E 's/.*"=?([^"]*)".*/\1/')