From f4ce80bc083826cc1c5aaf9f0cbc0643b961b595 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 00:49:44 +0800 Subject: [PATCH 01/25] =?UTF-8?q?chore(spoke):=20lockstep=20pin=20bump=200?= =?UTF-8?q?.8.2=20=E2=86=92=200.9.1=20(schemas/operations/connect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1.153 P0 wave 0: workspace spoke deps move 0.8.2 → 0.9.1 lockstep across Rust (spoke-schemas/spoke-operations/spoke-connect =0.9.1) and npm (@42ch/spoke-schemas/@42ch/spoke-operations 0.9.1). libp2p =0.56.0 pin unchanged (spoke-connect 0.9.1 still requires =0.56.0; feature-on graph shares ONE libp2p build, default build stays libp2p-free, mdns off). check-wire-drift.sh SPOKE_PIN → 0.9.1; pin-citing comments updated. codegen + validate-schemas + wire-drift + schema-drift gates green; generated output unchanged. --- Cargo.lock | 15 +++++++++------ Cargo.toml | 18 ++++++++++++------ package.json | 4 ++-- pnpm-lock.yaml | 22 +++++++++++----------- tooling/check-wire-drift.sh | 6 +++--- 5 files changed, 37 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43c23b8a6..186acba2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5721,11 +5721,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 +5743,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 +5756,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..0853dd9cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,7 +107,11 @@ 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 public surface is unchanged. # 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 +126,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/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..99515ba69 100755 --- a/tooling/check-wire-drift.sh +++ b/tooling/check-wire-drift.sh @@ -21,7 +21,7 @@ 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}" @@ -29,8 +29,8 @@ 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" +# spoke-schemas = "=0.9.1" +# spoke-operations = "=0.9.1" CARGO_TOML="${PROJECT_ROOT}/Cargo.toml" for crate in spoke-schemas spoke-operations; do cargo_spoke_raw=$(grep -E "^[[:space:]]*${crate}[[:space:]]*=" "$CARGO_TOML" | head -1) From 342558fe7f2b0df9e518c28a679b35da75df3f78 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 01:48:28 +0800 Subject: [PATCH 02/25] refactor(spoke-adapter): adapt port impls + callers to spoke-operations 0.9.1 async surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spoke-operations 0.9.1 converted the 8 adapter port traits to #[async_trait] async fn and orchestrate_* to native async fn (T1 finding; pure sync->async, no compat shim). Signature-level adaptation only — no behavior/logic changes: - 8 production port impls (knowledge_entry/relation/scope_query/ finding/rule_query/host_manifest/computable/fork) become #[async_trait] async fn awaiting SQLite I/O directly; the Handle::block_on + block_in_place sync bridge (mod.rs block_on helper, handle field, runtime-flavor debug_assert) is removed as dead machinery. - Inherent helpers commit_compute_settlement, list_hop_edges_for_world, list_timeline_events_ordered become async fn. - Mock port impls (example baseline_adapter, orchestration_adoption BaselineOnlyPorts) adopt #[async_trait] async fn; mock storage RefCell -> Mutex so the Send futures type-check. - ~90 call sites await: daemon-runtime pack_import (persist fns made async), check/world_kb/inspector handlers (with_bound_tx sites await outside the sync passthrough), CLI context.rs hop loader, and adapter tests/examples ([test] -> [tokio::test] where needed). - async-trait added as workspace dep of nexus-spoke-adapter. - Stale block_in_place bridge prose in docs/comments updated. Gates: cargo check --workspace --all-features PASS; cargo test -p nexus-spoke-adapter -p nexus-daemon-runtime --all-features PASS (1228 tests, 0 failures); baseline_adapter example runs with identical outcomes; libp2p graph unchanged (1x v0.56.0 under connect-host, libp2p-free default, mdns off). --- Cargo.lock | 1 + apps/nexus42/src/commands/platform/context.rs | 1 + .../src/api/handlers/check.rs | 6 +- .../src/api/handlers/inspector.rs | 1 + .../src/api/handlers/world_kb.rs | 25 +- .../nexus-daemon-runtime/src/pack_import.rs | 20 +- .../nexus-daemon-runtime/tests/check_api.rs | 6 +- .../tests/world_kb_patch.rs | 20 +- .../tests/world_kb_relationships.rs | 10 +- crates/nexus-spoke-adapter/Cargo.toml | 4 + .../examples/baseline_adapter.rs | 122 ++++-- .../src/adapter/computable_port.rs | 376 ++++++++++-------- .../src/adapter/finding_port.rs | 98 ++--- .../src/adapter/fork_port.rs | 134 +++---- .../src/adapter/host_manifest_port.rs | 12 +- .../src/adapter/knowledge_entry_port.rs | 96 ++--- crates/nexus-spoke-adapter/src/adapter/mod.rs | 83 +--- .../src/adapter/narrative_read.rs | 149 +++---- .../src/adapter/relation_port.rs | 206 +++++----- .../src/adapter/rule_query_port.rs | 53 +-- .../src/adapter/scope_query_port.rs | 189 +++++---- .../tests/orchestration_adoption.rs | 117 +++--- .../tests/rule_query_port_production.rs | 32 +- .../tests/spoke_orchestrator_integration.rs | 22 +- 24 files changed, 957 insertions(+), 826 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 186acba2f..6460c6ea4 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", 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/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/world_kb.rs b/crates/nexus-daemon-runtime/src/api/handlers/world_kb.rs index 8b26d013a..d95385871 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); @@ -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/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/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-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..cf2bb5911 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()) } } @@ -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:?}"), }; @@ -168,7 +172,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/mod.rs b/crates/nexus-spoke-adapter/src/adapter/mod.rs index 74c2a82f9..03a638008 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)" - ); 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/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!( From 6b1955188e75358e057fac2d7be7f9e25313d127 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 02:07:32 +0800 Subject: [PATCH 03/25] test(connect): re-baseline interop fixtures on spoke-connect 0.9.1 token issuance 0.9.1 issue_capability_token gained a mandatory now (Unix seconds) parameter and fail-fast issuance guards (exp must clear the clock-skew window). Re-baseline the N-C0 interop suite fixtures on the new signature, mirroring the upstream 0.9.1 reference test shape: token provider/proof helpers issue at now with a one-hour lifetime; the expired-token fixture backdates the issuance time so the mint-time guards pass while the verifier's clock rejects the proof. Handshake, op_unsupported refusal, and manifest honesty assertions are unchanged. --- apps/nexus42/src/commands/connect/interop.rs | 33 +++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/nexus42/src/commands/connect/interop.rs b/apps/nexus42/src/commands/connect/interop.rs index 20d0746b8..651814b8f 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -137,12 +137,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 +154,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 +182,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") } @@ -379,7 +385,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 +394,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 +466,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 +477,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( From e1b2388cff6483377de63ecdffbe17bc414c5bc5 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 02:55:30 +0800 Subject: [PATCH 04/25] chore(spoke): sync spec/gate/docs to 0.9.1 async surface (T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec §1.1/§5.2/§10.1: lockstep pins 0.8.2 → 0.9.1 (all 5 spoke pins); add durable async port-surface note (§7.3) + V1.153 historical entry; bump spec revision to v0.13 - check-wire-drift.sh: Gate 1 now enforces all 5 in-repo spoke pins (spoke-connect Cargo pin was previously unenforced) - Cargo.toml: V1.153 comment no longer implies operations-surface parity — records the sync→async conversion (T2) - mca_read.rs: drop stale runtime-Handle/panic prose from SpokeBackedKbStore::new (constructor works anywhere post-T2) - world_kb.rs: promote_adopt doc sync-bridge → native-async wording - AGENTS.md: native-async adapter description + stale =0.8.2 pins - adapter/mod.rs: NexusAdapter::new → const fn (clippy -D warnings missing_const_for_fn, surfaced by the T4 full-gate sweep) --- .mstar/specs/spoke-adapter-architecture.md | 20 ++++++++++--------- Cargo.toml | 5 ++++- .../src/api/handlers/world_kb.rs | 4 ++-- crates/nexus-spoke-adapter/AGENTS.md | 8 ++++---- .../src/adapter/mca_read.rs | 11 ++++------ crates/nexus-spoke-adapter/src/adapter/mod.rs | 2 +- tooling/check-wire-drift.sh | 12 +++++++---- 7 files changed, 34 insertions(+), 28 deletions(-) diff --git a/.mstar/specs/spoke-adapter-architecture.md b/.mstar/specs/spoke-adapter-architecture.md index 5987ddcb5..5efeca956 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 | diff --git a/Cargo.toml b/Cargo.toml index 0853dd9cf..ab819ee55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,7 +111,10 @@ regress = "0.10" # 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 public surface is unchanged. +# 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 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 d95385871..877a3312c 100644 --- a/crates/nexus-daemon-runtime/src/api/handlers/world_kb.rs +++ b/crates/nexus-daemon-runtime/src/api/handlers/world_kb.rs @@ -663,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, 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/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 03a638008..8fa236bca 100644 --- a/crates/nexus-spoke-adapter/src/adapter/mod.rs +++ b/crates/nexus-spoke-adapter/src/adapter/mod.rs @@ -68,7 +68,7 @@ impl NexusAdapter<'static> { /// `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 { + pub const fn new(pool: SqlitePool) -> Self { NexusAdapter { pool, host_id: None, diff --git a/tooling/check-wire-drift.sh b/tooling/check-wire-drift.sh index 99515ba69..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. # @@ -28,11 +30,13 @@ 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: +# 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/') From b3844efcc960c4279daf76e3c90d7ad7a1290607 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 03:29:38 +0800 Subject: [PATCH 05/25] feat(connect): N-C1 allowlist world/op scoping with fail-closed PeerScope gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1 of the P1 inbound-write-ops plan. allowlist.json peer_ids entries are now PeerEntry = bare peer id (N-C0) | { peer_id, world_scope?: [world-uuid], op_scope?: [ops] } with deny_unknown_fields retained on both file and scoped-object forms (P1 spec § World scoping — schema locked). load() now returns PeerScope (peer -> allowed world ids + allowed ops): peer_ids() feeds ConnectConfig.peer_allowlist (handshake), and allows_world/allows_op feed the T2 dispatch gate. Fail-closed semantics: absent/empty scope denies world writes; --allow-peer overlay carries no scope and never strips a hand-authored file scope; missing file stays an empty allowlist. TDD: 8 new scoping tests (listed-world allow, cross-world deny, absent world/op scope deny, unknown-field and invalid-peer-id config errors, CLI overlay gap-fill, unknown-peer deny) + 6 existing tests adapted to the PeerScope return type. --- .../nexus42/src/commands/connect/allowlist.rs | 363 ++++++++++++++++-- apps/nexus42/src/commands/connect/interop.rs | 5 +- apps/nexus42/src/commands/connect/mod.rs | 5 +- 3 files changed, 339 insertions(+), 34 deletions(-) diff --git a/apps/nexus42/src/commands/connect/allowlist.rs b/apps/nexus42/src/commands/connect/allowlist.rs index 906e010fe..4a07bbfc2 100644 --- a/apps/nexus42/src/commands/connect/allowlist.rs +++ b/apps/nexus42/src/commands/connect/allowlist.rs @@ -2,25 +2,150 @@ //! 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, +} + +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() + } + + /// 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}"))) } /// Load the effective allowlist: file entries ∪ `--allow-peer` CLI entries. @@ -34,36 +159,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 +204,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 +228,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 +237,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 +276,170 @@ 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")); + } } diff --git a/apps/nexus42/src/commands/connect/interop.rs b/apps/nexus42/src/commands/connect/interop.rs index 651814b8f..1de02918b 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -539,7 +539,10 @@ 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" diff --git a/apps/nexus42/src/commands/connect/mod.rs b/apps/nexus42/src/commands/connect/mod.rs index 4fe0f77ee..01a4d8d5a 100644 --- a/apps/nexus42/src/commands/connect/mod.rs +++ b/apps/nexus42/src/commands/connect/mod.rs @@ -129,7 +129,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 T2 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). From 486159bdd358b32075aa23e10e4416d662ba5bd8 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 04:01:52 +0800 Subject: [PATCH 06/25] =?UTF-8?q?feat(connect):=20N-C1=20invoke=20dispatch?= =?UTF-8?q?=20=E2=80=94=20world-scoped=20upsert/promote/relate=20via=20wor?= =?UTF-8?q?kspace=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1.153 P1 Task 2 (N-C1 core write path): architect-locked invoke.rs owns the InvokeHandler closure; the connect start boot now opens the active-workspace SQLite pool (WAL; daemon resolution rules), constructs one per-process NexusAdapter, and wires upsert/promote/relate through the spoke orchestrators with the locked SpokeRejectCode->ErrorEnvelope mapping. Fail-closed gates: served-op set, payload-resolved calling peer (extensions.nexus.peer_id), PeerScope op_scope + world_scope before any dispatch. All other ops stay op_unsupported with zero side effects. Integration test: write round-trips (upsert CAS accept + stored_revision_stale + revision_conflict, promote, relate), wrong-world/absent-scope/unknown-op denial with no DB mutation. --- Cargo.lock | 1 + apps/nexus42/Cargo.toml | 8 +- apps/nexus42/src/commands/connect/interop.rs | 356 ++++++++++++++++++- apps/nexus42/src/commands/connect/invoke.rs | 317 +++++++++++++++++ apps/nexus42/src/commands/connect/mod.rs | 120 +++++-- 5 files changed, 774 insertions(+), 28 deletions(-) create mode 100644 apps/nexus42/src/commands/connect/invoke.rs diff --git a/Cargo.lock b/Cargo.lock index 6460c6ea4..6d3d50ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3888,6 +3888,7 @@ dependencies = [ "serial_test", "sha2", "spoke-connect", + "spoke-schemas", "sqlx", "tempfile", "thiserror 1.0.69", diff --git a/apps/nexus42/Cargo.toml b/apps/nexus42/Cargo.toml index 19ac67553..2fb1d6e96 100644 --- a/apps/nexus42/Cargo.toml +++ b/apps/nexus42/Cargo.toml @@ -48,6 +48,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 @@ -103,4 +109,4 @@ 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"] diff --git a/apps/nexus42/src/commands/connect/interop.rs b/apps/nexus42/src/commands/connect/interop.rs index 1de02918b..e26a15d91 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -43,6 +43,58 @@ 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"); +} + +/// 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(); @@ -505,8 +557,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; @@ -524,11 +577,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. @@ -544,8 +602,8 @@ async fn cli_wiring_starts_a_node_with_persisted_identity_and_allowlist() { 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"); @@ -566,3 +624,291 @@ 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"); +} diff --git a/apps/nexus42/src/commands/connect/invoke.rs b/apps/nexus42/src/commands/connect/invoke.rs new file mode 100644 index 000000000..91c9302f8 --- /dev/null +++ b/apps/nexus42/src/commands/connect/invoke.rs @@ -0,0 +1,317 @@ +//! 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). +//! +//! ## 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::{ + orchestrate_promote, orchestrate_relate, orchestrate_upsert, NexusAdapter, PromoteRequest, + PromoteResponse, RelateRequest, RelateResponse, 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). T3's manifest-honesty test +/// machine-checks this set ⇔ the advertised capabilities. +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 set (N-C1): anything else is refused unconditionally, + // regardless of payload shape (N-C0 refusal contract extends). + let route = match op { + "upsert" => Route::Upsert, + "promote" => Route::Promote, + "relate" => Route::Relate, + _ => { + return Err(unsupported( + op, + "this host serves only upsert / promote / relate", + )); + } + }; + + // 2. 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", + )); + }; + + // 3. 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"))); + } + + // 4. World-scope gate (T1 PeerScope, fail-closed): every target world in + // the payload must be in the peer's `world_scope`; a payload without + // a world id cannot be scoped and is denied. + let worlds = payload_world_ids(route, &payload); + 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" + ))); + } + + // 5. 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(route_orchestrator(route, adapter, payload)) + }) +} + +/// 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. +fn payload_world_ids(route: Route, payload: &Value) -> Vec { + match route { + Route::Upsert => payload + .get("knowledge_entries") + .and_then(Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(|entry| world_id_of(entry).map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + Route::Promote => payload + .get("candidate") + .and_then(world_id_of) + .map(str::to_string) + .into_iter() + .collect(), + Route::Relate => payload + .get("relation") + .and_then(world_id_of) + .map(str::to_string) + .into_iter() + .collect(), + } +} + +/// 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 01a4d8d5a..fac1986eb 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)?; @@ -130,7 +147,7 @@ fn build_config( // 3. Allowlist: file ∪ `--allow-peer*`; missing file ⇒ empty ⇒ fail-closed. // `load` resolves the N-C1 `PeerScope` (per-peer world/op scope for - // the T2 dispatch gate); the flat id set feeds the handshake allowlist. + // 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(); @@ -155,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, @@ -168,8 +186,66 @@ 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`] + active-workspace DB open + +/// per-process `NexusAdapter` + [`invoke::build_handler`] wiring — the exact +/// shape `connect start` runs (and `nexus-runtime` shares in P2). +/// +/// `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. +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)?; + + // 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; From b15e1e88daafa00019f534da65e9be82cf23abeb Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 04:32:30 +0800 Subject: [PATCH 07/25] fix(connect): close cross-world write + mixed-payload partial-write in N-C1 dispatch L2 review findings (task-2 fix loop): - Critical: orchestrators' stored lookups and CAS updates are world-agnostic (id + revision only), so a payload claiming world A could rewrite a row stored in world B by replaying the revision disclosed by OCC rejects. New stored-world gate (before the orchestrator CAS) verifies every targeted existing row's stored world_id against the payload-claimed world_id for update/promote/relate; mismatch denies with zero side effects. - Important: payload_world_ids filter-mapped world-less entries out of the gate's world set, letting a mixed multi-entry upsert pass and fail later as a partial write (internal_error). The gate now requires EVERY entry/relation to carry extensions.nexus.world_id; one missing denies the whole payload. Regression tests (TDD red->green): cross-world update/promote/relate denied with zero mutation; mixed payload denied with zero entries persisted. --- apps/nexus42/src/commands/connect/interop.rs | 354 +++++++++++++++++++ apps/nexus42/src/commands/connect/invoke.rs | 231 ++++++++++-- 2 files changed, 554 insertions(+), 31 deletions(-) diff --git a/apps/nexus42/src/commands/connect/interop.rs b/apps/nexus42/src/commands/connect/interop.rs index e26a15d91..7431fa190 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -70,6 +70,34 @@ async fn seed_world(pool: &sqlx::SqlitePool, creator_id: &str, world_id: &str) { .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). /// @@ -912,3 +940,329 @@ async fn n_c1_peer_upserts_promotes_relates_with_world_scoping() { 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"); +} diff --git a/apps/nexus42/src/commands/connect/invoke.rs b/apps/nexus42/src/commands/connect/invoke.rs index 91c9302f8..65ceefa1d 100644 --- a/apps/nexus42/src/commands/connect/invoke.rs +++ b/apps/nexus42/src/commands/connect/invoke.rs @@ -28,6 +28,25 @@ //! (`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. +//! //! ## Async bridge //! //! The orchestrators are native `async fn` (V1.153 P0 T2) but the handler @@ -41,10 +60,12 @@ 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, NexusAdapter, PromoteRequest, - PromoteResponse, RelateRequest, RelateResponse, SpokeReject, SpokeRejectCode, SpokeResult, - UpsertRequest, UpsertResponse, + 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; @@ -116,9 +137,17 @@ fn dispatch( } // 4. World-scope gate (T1 PeerScope, fail-closed): every target world in - // the payload must be in the peer's `world_scope`; a payload without - // a world id cannot be scoped and is denied. - let worlds = payload_world_ids(route, &payload); + // 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", @@ -133,12 +162,23 @@ fn dispatch( ))); } - // 5. Route through the orchestrator. The orchestrators are native async + // 5. 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. + // + // 6. 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(route_orchestrator(route, adapter, payload)) + tokio::runtime::Handle::current().block_on(async { + verify_stored_worlds(adapter, route, &payload).await?; + route_orchestrator(route, adapter, payload).await + }) }) } @@ -196,31 +236,160 @@ fn world_id_of(value: &Value) -> Option<&str> { /// 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. -fn payload_world_ids(route: Route, payload: &Value) -> Vec { +/// +/// 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. Rows that do not exist yet (create paths) carry no stored world +/// and need no check. +async fn verify_stored_worlds( + adapter: &NexusAdapter<'static>, + route: Route, + payload: &Value, +) -> Result<(), ErrorEnvelope> { match route { - Route::Upsert => payload - .get("knowledge_entries") - .and_then(Value::as_array) - .map(|entries| { - entries - .iter() - .filter_map(|entry| world_id_of(entry).map(str::to_string)) - .collect() - }) - .unwrap_or_default(), - Route::Promote => payload - .get("candidate") - .and_then(world_id_of) - .map(str::to_string) - .into_iter() - .collect(), - Route::Relate => payload - .get("relation") - .and_then(world_id_of) - .map(str::to_string) - .into_iter() - .collect(), + 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 => {} + 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(()) +} + +/// 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 From 21c1026b365120b37f7b5f19e329070536c2cdff Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 04:55:44 +0800 Subject: [PATCH 08/25] =?UTF-8?q?feat(connect):=20N-C1=20manifest=20honest?= =?UTF-8?q?y=20machine-check=20+=20spec=20=C2=A710.6=20delivered-shape=20l?= =?UTF-8?q?ock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest now advertises the delivered N-C1 slice: extensions.nexus = { connect_host_slice: "n-c1", served_ops: ["upsert", "promote", "relate"], daemon_http_coexists: true }. Honesty is machine-checked both directions: the crate-level n_c1_manifest_is_honest test (renamed from n_c0_manifest_is_honest, N-C0 baseline kept) asserts the advertised set is exactly LOCAL_SERVED_OPS and that every advertised op maps to a production orchestrator (typecheck-only closure proof); the connect-host interop test n_c1_manifest_served_ops_match_dispatch_both_directions compares the wire manifest a peer reads off the signed hello against the dispatch's SERVED_OPS table (a: advertised ⊆ served, b: served ⊆ advertised). Interop evidence (Step 2): n_c1_peer_upserts_promotes_relates_with_world_scoping already runs the full upsert → promote → relate round-trip against a real SpokeConnectNode host process (full N-C1 CLI boot path, real TCP loopback, signed-hello handshake, invoke wire, real SQLite workspace) — verified, not duplicated. Spec .mstar/specs/spoke-adapter-architecture.md: §10.3 N-C1 field extension, §10.4 N-C1 refusal-contract extension, §10.6 N-C1 row updated to the delivered shape (ops served, OCC, fail-closed world scoping + stored-world gate, WAL coexistence, payload-carried peer_id as E2 residual). --- .mstar/specs/spoke-adapter-architecture.md | 4 +- apps/nexus42/src/commands/connect/interop.rs | 64 +++++++++- apps/nexus42/src/commands/connect/invoke.rs | 9 +- .../src/adapter/host_manifest_port.rs | 16 ++- crates/nexus-spoke-adapter/src/manifest.rs | 116 +++++++++++++++--- 5 files changed, 186 insertions(+), 23 deletions(-) diff --git a/.mstar/specs/spoke-adapter-architecture.md b/.mstar/specs/spoke-adapter-architecture.md index 5efeca956..15f897dde 100644 --- a/.mstar/specs/spoke-adapter-architecture.md +++ b/.mstar/specs/spoke-adapter-architecture.md @@ -739,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). @@ -748,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) @@ -758,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/apps/nexus42/src/commands/connect/interop.rs b/apps/nexus42/src/commands/connect/interop.rs index 7431fa190..902f1df64 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -312,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)); @@ -324,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"], @@ -344,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] diff --git a/apps/nexus42/src/commands/connect/invoke.rs b/apps/nexus42/src/commands/connect/invoke.rs index 65ceefa1d..f9b2feef8 100644 --- a/apps/nexus42/src/commands/connect/invoke.rs +++ b/apps/nexus42/src/commands/connect/invoke.rs @@ -73,8 +73,13 @@ use spoke_schemas::connect::connect_invoke_response::ErrorEnvelope; use std::collections::HashMap; use std::sync::Arc; -/// The write ops this host serves (N-C1). T3's manifest-honesty test -/// machine-checks this set ⇔ the advertised capabilities. +/// The write ops this host serves (N-C1). +/// +/// 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. pub const SERVED_OPS: [&str; 3] = ["upsert", "promote", "relate"]; /// Build the N-C1 `InvokeHandler`: a fail-closed op gate + allowlist 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 cf2bb5911..0e4305b82 100644 --- a/crates/nexus-spoke-adapter/src/adapter/host_manifest_port.rs +++ b/crates/nexus-spoke-adapter/src/adapter/host_manifest_port.rs @@ -106,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 @@ -141,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 @@ -151,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 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 From a1a792fb087565fae6a836a992173364114d600a Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 05:12:11 +0800 Subject: [PATCH 09/25] fix(connect): bind dispatch to SERVED_OPS so honesty check is load-bearing --- apps/nexus42/src/commands/connect/interop.rs | 130 +++++++++++++++++++ apps/nexus42/src/commands/connect/invoke.rs | 36 +++-- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/apps/nexus42/src/commands/connect/interop.rs b/apps/nexus42/src/commands/connect/interop.rs index 902f1df64..ac235b8e6 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -1326,3 +1326,133 @@ async fn n_c1_mixed_payload_missing_world_id_denies_whole_payload() { 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"); +} diff --git a/apps/nexus42/src/commands/connect/invoke.rs b/apps/nexus42/src/commands/connect/invoke.rs index f9b2feef8..1916edd1e 100644 --- a/apps/nexus42/src/commands/connect/invoke.rs +++ b/apps/nexus42/src/commands/connect/invoke.rs @@ -75,11 +75,14 @@ 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. +/// `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 @@ -110,8 +113,25 @@ fn dispatch( op: &str, payload: Value, ) -> Result { - // 1. Served op set (N-C1): anything else is refused unconditionally, - // regardless of payload shape (N-C0 refusal contract extends). + // 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, @@ -124,7 +144,7 @@ fn dispatch( } }; - // 2. Calling peer from the ops envelope (fail-closed — see module docs + // 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") @@ -136,12 +156,12 @@ fn dispatch( )); }; - // 3. Op-scope gate (T1 PeerScope, fail-closed). + // 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"))); } - // 4. World-scope gate (T1 PeerScope, fail-closed): every target world in + // 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 @@ -167,7 +187,7 @@ fn dispatch( ))); } - // 5. Stored-world gate (fix loop, Critical): the orchestrators' stored + // 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 @@ -175,7 +195,7 @@ fn dispatch( // existing row's stored world_id equals the payload-claimed world_id; // a mismatch denies with zero side effects. // - // 6. Route through the orchestrator. The orchestrators are native async + // 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). From 81a3de895876cc9fdfcb3b1beb18cc98e3aea4b5 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 05:40:14 +0800 Subject: [PATCH 10/25] fix(connect): verify relate endpoint worlds + warn on multi-write-peer allowlist (plan QC) --- .../nexus42/src/commands/connect/allowlist.rs | 151 +++++++++++++++ apps/nexus42/src/commands/connect/interop.rs | 174 ++++++++++++++++++ apps/nexus42/src/commands/connect/invoke.rs | 82 ++++++++- apps/nexus42/src/commands/connect/mod.rs | 7 + 4 files changed, 410 insertions(+), 4 deletions(-) diff --git a/apps/nexus42/src/commands/connect/allowlist.rs b/apps/nexus42/src/commands/connect/allowlist.rs index 4a07bbfc2..3acbc2eb6 100644 --- a/apps/nexus42/src/commands/connect/allowlist.rs +++ b/apps/nexus42/src/commands/connect/allowlist.rs @@ -75,6 +75,13 @@ pub struct PeerAccess { 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. @@ -111,6 +118,28 @@ impl PeerScope { 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<()> { @@ -148,6 +177,40 @@ fn parse_peer_id(raw: &str) -> Result { .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. /// /// A missing file is not an error — it contributes nothing (fail-closed). @@ -442,4 +505,92 @@ mod tests { 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 ac235b8e6..4efd59be5 100644 --- a/apps/nexus42/src/commands/connect/interop.rs +++ b/apps/nexus42/src/commands/connect/interop.rs @@ -1456,3 +1456,177 @@ async fn n_c1_every_served_op_advertised_by_the_const_actually_routes() { 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 index 1916edd1e..ee9a80fc8 100644 --- a/apps/nexus42/src/commands/connect/invoke.rs +++ b/apps/nexus42/src/commands/connect/invoke.rs @@ -45,7 +45,12 @@ //! 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 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 //! @@ -294,8 +299,15 @@ fn payload_world_ids(route: Route, payload: &Value) -> Option> { /// 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. Rows that do not exist yet (create paths) carry no stored world -/// and need no check. +/// 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, @@ -354,7 +366,33 @@ async fn verify_stored_worlds( } } SpokeResult::Reject(reject) - if reject.code == SpokeRejectCode::RelationNotFound => {} + 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)), } } @@ -386,6 +424,42 @@ async fn assert_stored_entry_world_matches( 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). diff --git a/apps/nexus42/src/commands/connect/mod.rs b/apps/nexus42/src/commands/connect/mod.rs index fac1986eb..038f501b9 100644 --- a/apps/nexus42/src/commands/connect/mod.rs +++ b/apps/nexus42/src/commands/connect/mod.rs @@ -208,6 +208,13 @@ async fn build_host_config( ) -> 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 From a7aa25df6f0359b8b66522e676a93a83c41437cf Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 06:37:49 +0800 Subject: [PATCH 11/25] feat(runtime): headless nexus-runtime bin with web-embed SPA gate + boot smoke (V1.153 P2 T1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [[bin]] nexus-runtime (required-features connect-host) booting the shared connect start N-C1 assembly (build_host_config) — run_daemon never called; stdout-only readiness, no HTTP health endpoint, --home/NEXUS42_HOME override - web-embed feature on nexus-daemon-runtime (default ON; gated: rust-embed dep, static_assets mod, api SPA fallback import+route); nexus42 forwards it (default ON) and declares the dep with default-features=false, so the slim artifact (--no-default-features --features connect-host) excludes the SPA while the default nexus42 bin is unchanged - tests/nexus_runtime_smoke.rs spawns the real binary against a temp NEXUS42_HOME: stdout readiness, Connect handshake via the runtime_smoke_probe example (N-C1 manifest served_ops), no HTTP/SPA listener (per-PID lsof), --version; probe example is connect-host-gated so the default graph stays libp2p-free --- apps/nexus42/Cargo.toml | 38 ++- apps/nexus42/examples/runtime_smoke_probe.rs | 161 ++++++++++ apps/nexus42/src/bin/nexus-runtime.rs | 168 +++++++++++ apps/nexus42/src/commands/connect/mod.rs | 10 +- apps/nexus42/tests/nexus_runtime_smoke.rs | 297 +++++++++++++++++++ crates/nexus-daemon-runtime/Cargo.toml | 11 +- crates/nexus-daemon-runtime/src/api/mod.rs | 6 +- crates/nexus-daemon-runtime/src/lib.rs | 6 +- 8 files changed, 688 insertions(+), 9 deletions(-) create mode 100644 apps/nexus42/examples/runtime_smoke_probe.rs create mode 100644 apps/nexus42/src/bin/nexus-runtime.rs create mode 100644 apps/nexus42/tests/nexus_runtime_smoke.rs diff --git a/apps/nexus42/Cargo.toml b/apps/nexus42/Cargo.toml index 2fb1d6e96..f21d889ec 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 @@ -102,6 +125,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 @@ -110,3 +139,10 @@ serial_test = "3" # never does. `spoke-connect/mdns` is deliberately NOT enabled. [features] 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..8e91c7986 --- /dev/null +++ b/apps/nexus42/src/bin/nexus-runtime.rs @@ -0,0 +1,168 @@ +//! `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 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). + #[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/mod.rs b/apps/nexus42/src/commands/connect/mod.rs index 038f501b9..74105a2c7 100644 --- a/apps/nexus42/src/commands/connect/mod.rs +++ b/apps/nexus42/src/commands/connect/mod.rs @@ -189,9 +189,11 @@ fn build_config( Ok((config, host_id, allowlist_len, peer_scope)) } -/// Full N-C1 host boot: [`build_config`] + active-workspace DB open + -/// per-process `NexusAdapter` + [`invoke::build_handler`] wiring — the exact -/// shape `connect start` runs (and `nexus-runtime` shares in P2). +/// 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 @@ -200,7 +202,7 @@ fn build_config( /// /// # Errors /// [`CliError`] on N-C0 assembly, workspace resolution, or DB open failures. -async fn build_host_config( +pub async fn build_host_config( home: &Path, allow_peer: &[String], listen: &[String], diff --git a/apps/nexus42/tests/nexus_runtime_smoke.rs b/apps/nexus42/tests/nexus_runtime_smoke.rs new file mode 100644 index 000000000..85f2dccf2 --- /dev/null +++ b/apps/nexus42/tests/nexus_runtime_smoke.rs @@ -0,0 +1,297 @@ +//! 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) +} + +/// 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 port = line + .split_whitespace() + .last() + .and_then(|addr| addr.rsplit(':').next()) + .and_then(|port| port.parse::().ok()); + let Some(port) = port 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); + } +} + +#[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. + let (mut child, ready_lines, listen_addrs) = spawn_runtime(tmp.path()); + + // 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(child.id(), &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}" + ); + } + + let _ = child.kill(); + let _ = child.wait(); +} 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/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/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; From e8d24bcc7ac8b305e99bc4ea93e71edc80874790 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 06:52:35 +0800 Subject: [PATCH 12/25] fix(runtime): make no-HTTP smoke assertion load-bearing + orphan guard + home doc (P2 T1 fix) --- apps/nexus42/src/bin/nexus-runtime.rs | 7 +- apps/nexus42/tests/nexus_runtime_smoke.rs | 99 ++++++++++++++++++++--- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/apps/nexus42/src/bin/nexus-runtime.rs b/apps/nexus42/src/bin/nexus-runtime.rs index 8e91c7986..1b251de4b 100644 --- a/apps/nexus42/src/bin/nexus-runtime.rs +++ b/apps/nexus42/src/bin/nexus-runtime.rs @@ -17,7 +17,9 @@ //! `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 is +//! 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. @@ -55,6 +57,9 @@ struct RuntimeCli { 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, } diff --git a/apps/nexus42/tests/nexus_runtime_smoke.rs b/apps/nexus42/tests/nexus_runtime_smoke.rs index 85f2dccf2..42dd08dcc 100644 --- a/apps/nexus42/tests/nexus_runtime_smoke.rs +++ b/apps/nexus42/tests/nexus_runtime_smoke.rs @@ -157,6 +157,28 @@ fn spawn_runtime(home: &Path) -> (Child, Vec, Vec) { (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 @@ -194,12 +216,9 @@ fn assert_no_http_listener(child_pid: u32, listen_addrs: &[String]) { ); let text = String::from_utf8_lossy(&out.stdout); for line in text.lines().skip(1) { - let port = line - .split_whitespace() - .last() - .and_then(|addr| addr.rsplit(':').next()) - .and_then(|port| port.parse::().ok()); - let Some(port) = port else { continue }; + let Some(port) = lsof_line_port(line) else { + continue; + }; assert!( ports.contains(&port), "runtime process holds an unexpected TCP listener on port {port} \ @@ -214,6 +233,60 @@ fn assert_no_http_listener(child_pid: u32, listen_addrs: &[String]) { } } +/// 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")) @@ -237,8 +310,11 @@ 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. - let (mut child, ready_lines, listen_addrs) = spawn_runtime(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"); @@ -257,7 +333,7 @@ fn headless_runtime_prints_readiness_serves_connect_and_has_no_http_listener() { // 2. No HTTP/SPA listener: every TCP listener of the runtime process // is one of its printed Connect listen addrs. - assert_no_http_listener(child.id(), &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 @@ -291,7 +367,6 @@ fn headless_runtime_prints_readiness_serves_connect_and_has_no_http_listener() { "probe output missing {expected:?}:\n{stdout}\nstderr:\n{stderr}" ); } - - let _ = child.kill(); - let _ = child.wait(); + // `_guard` drops here (and on any panic unwind above): the child is + // killed and reaped on every path. } From b6d151f1def83d05dfe30182e7d30a463e4f8f0b Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 07:01:36 +0800 Subject: [PATCH 13/25] ci(runtime): native 3-platform build + zip/sha256 packaging workflow (V1.153 P2 T2) Builds the headless nexus-runtime bin with --no-default-features --features connect-host (web-embed OFF) natively on windows-latest (x86_64-pc-windows-msvc), macos-latest (aarch64-apple-darwin), and ubuntu-latest (x86_64-unknown-linux-gnu); packages nexus-runtime--.zip + .sha256, uploads them, and smoke-tests --version on each runner. Temporary push trigger scoped to the plan branch for iteration (workflow_dispatch requires the workflow on the default branch); removed in the final T2 commit. Delivered trigger set stays workflow_dispatch + tag runtime-v*. --- .github/workflows/runtime-build.yml | 157 ++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .github/workflows/runtime-build.yml diff --git a/.github/workflows/runtime-build.yml b/.github/workflows/runtime-build.yml new file mode 100644 index 000000000..f9582d409 --- /dev/null +++ b/.github/workflows/runtime-build.yml @@ -0,0 +1,157 @@ +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 + PM drift writeback 2026-08-07): +# `workflow_dispatch` + tag `runtime-v*` ONLY. GitHub only offers +# `workflow_dispatch` for workflows present on the DEFAULT branch, so during +# plan-branch iteration the `push` trigger below is TEMPORARY scaffolding +# scoped to this branch; it is removed in the final T2 commit. + +on: + workflow_dispatch: + push: + # TEMPORARY (T2 iteration only): workflow_dispatch requires the workflow + # on the default branch, so the plan branch iterates via a push trigger. + # REMOVE before merge — the delivered trigger set is workflow_dispatch + + # tag runtime-v* only. + branches: + - plan/v1.153-p2-nexus-runtime-headless-bin-windows-x64 + 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 }}) + 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 From 007e2b2ca8943d27eb5778216996b0d773d0fb91 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 07:02:40 +0800 Subject: [PATCH 14/25] ci(runtime): trigger first evaluation of runtime-build.yml A workflow file added by a push does not run for the creating push; this empty commit makes GitHub evaluate the temp-push-triggered workflow (T2 iteration scaffolding; removed in the final T2 commit). From 7a7ede2f4643a121eae1fcb9d69de406f719e7c7 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 07:07:10 +0800 Subject: [PATCH 15/25] ci(runtime): iterate via temporary pull_request trigger instead of push A workflow file that exists only on a non-default branch is not evaluated for push events (GitHub's workflow registry is built from the default branch; verified: no check suite was created for the plan-branch pushes). The documented iteration vehicle for an unregistered workflow is a pull_request trigger + PR against the spec integration branch. Removed in the final T2 commit; delivered trigger set unchanged (workflow_dispatch + tag runtime-v*). --- .github/workflows/runtime-build.yml | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/workflows/runtime-build.yml b/.github/workflows/runtime-build.yml index f9582d409..4fb6b04ce 100644 --- a/.github/workflows/runtime-build.yml +++ b/.github/workflows/runtime-build.yml @@ -14,21 +14,25 @@ name: Runtime Build # # Trigger set (locked by Clarify #3 + PM drift writeback 2026-08-07): # `workflow_dispatch` + tag `runtime-v*` ONLY. GitHub only offers -# `workflow_dispatch` for workflows present on the DEFAULT branch, so during -# plan-branch iteration the `push` trigger below is TEMPORARY scaffolding -# scoped to this branch; it is removed in the final T2 commit. +# `workflow_dispatch` for workflows present on the DEFAULT branch, and a +# workflow file that exists only on a non-default branch is not evaluated +# for `push` events at all (verified empirically during T2: the workflow +# never registered and no check suite was created). So plan-branch iteration +# runs through the TEMPORARY `pull_request` trigger below (PR against the +# spec integration branch); it is removed in the final T2 commit. on: workflow_dispatch: push: - # TEMPORARY (T2 iteration only): workflow_dispatch requires the workflow - # on the default branch, so the plan branch iterates via a push trigger. - # REMOVE before merge — the delivered trigger set is workflow_dispatch + - # tag runtime-v* only. - branches: - - plan/v1.153-p2-nexus-runtime-headless-bin-windows-x64 tags: - 'runtime-v*' + pull_request: + # TEMPORARY (T2 iteration only): iteration vehicle for a workflow that + # does not exist on the default branch yet (see header note). REMOVE + # before merge — the delivered trigger set is workflow_dispatch + tag + # runtime-v* only. + branches: + - iteration/v1.153 concurrency: group: runtime-build-${{ github.ref }} From 36c5fca112c108d31c3c98b762f660072cf624d1 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 07:10:43 +0800 Subject: [PATCH 16/25] =?UTF-8?q?chore(ci):=20probe=20workflow=20=E2=80=94?= =?UTF-8?q?=20does=20GitHub=20evaluate=20unregistered=20workflow=20files?= =?UTF-8?q?=20on=20this=20repo=3F=20(T2=20diagnostic,=20removed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/probe.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/workflows/probe.yml diff --git a/.github/workflows/probe.yml b/.github/workflows/probe.yml new file mode 100644 index 000000000..f071b5286 --- /dev/null +++ b/.github/workflows/probe.yml @@ -0,0 +1,13 @@ +name: Probe +on: + push: + branches: + - plan/v1.153-p2-nexus-runtime-headless-bin-windows-x64 + pull_request: + branches: + - iteration/v1.153 +jobs: + probe: + runs-on: ubuntu-latest + steps: + - run: echo probe-ok From d2af6e34ee77863f2da464e00c0f2c63cb86602b Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 07:14:22 +0800 Subject: [PATCH 17/25] chore(ci): remove probe workflow (T2 diagnostic complete) --- .github/workflows/probe.yml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .github/workflows/probe.yml diff --git a/.github/workflows/probe.yml b/.github/workflows/probe.yml deleted file mode 100644 index f071b5286..000000000 --- a/.github/workflows/probe.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Probe -on: - push: - branches: - - plan/v1.153-p2-nexus-runtime-headless-bin-windows-x64 - pull_request: - branches: - - iteration/v1.153 -jobs: - probe: - runs-on: ubuntu-latest - steps: - - run: echo probe-ok From f3e38ac5265b950711679a950c85e72591790948 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 07:30:46 +0800 Subject: [PATCH 18/25] fix(ci): single-line cargo build step (pwsh breaks on bash backslash continuation) + restore temp push trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows runners default to pwsh, which rejects the multi-line backslash continuation — 'Missing expression after unary operator' parse error. One line works on all three shells. Both temp triggers (push on plan branch + pull_request on iteration/v1.153) restored for iteration; removed in the final T2 commit. --- .github/workflows/runtime-build.yml | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/runtime-build.yml b/.github/workflows/runtime-build.yml index 4fb6b04ce..52532e227 100644 --- a/.github/workflows/runtime-build.yml +++ b/.github/workflows/runtime-build.yml @@ -16,21 +16,27 @@ name: Runtime Build # `workflow_dispatch` + tag `runtime-v*` ONLY. GitHub only offers # `workflow_dispatch` for workflows present on the DEFAULT branch, and a # workflow file that exists only on a non-default branch is not evaluated -# for `push` events at all (verified empirically during T2: the workflow -# never registered and no check suite was created). So plan-branch iteration -# runs through the TEMPORARY `pull_request` trigger below (PR against the -# spec integration branch); it is removed in the final T2 commit. +# for ANY event (verified empirically during T2: the workflow never +# registered, no check suite was created for push/tag/pull_request). So +# plan-branch iteration uses the TEMPORARY `push` (plan branch) + +# `pull_request` (spec integration branch) triggers below, with the +# workflow mirrored to the default branch by the PM; both are removed in +# the final T2 commit. on: workflow_dispatch: push: + # TEMPORARY (T2 iteration only): iteration vehicle while the workflow is + # not on the default branch. REMOVE before merge — the delivered trigger + # set is workflow_dispatch + tag runtime-v* only. + branches: + - plan/v1.153-p2-nexus-runtime-headless-bin-windows-x64 tags: - 'runtime-v*' pull_request: - # TEMPORARY (T2 iteration only): iteration vehicle for a workflow that - # does not exist on the default branch yet (see header note). REMOVE - # before merge — the delivered trigger set is workflow_dispatch + tag - # runtime-v* only. + # TEMPORARY (T2 iteration only): PR-based iteration vehicle (see header + # note). REMOVE before merge — the delivered trigger set is + # workflow_dispatch + tag runtime-v* only. branches: - iteration/v1.153 @@ -92,10 +98,9 @@ jobs: workspaces: . - name: Build nexus-runtime (release, native ${{ matrix.target }}) - run: | - cargo build --release --target ${{ matrix.target }} \ - --bin nexus-runtime \ - --no-default-features --features connect-host + # 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' From 0e47ff2ccaa1d453e6bcc2703fc8178cd005ce25 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 08:00:18 +0800 Subject: [PATCH 19/25] fix(local-db): gate file_lock module and consumers on unix so nexus-runtime builds on windows-msvc (V1.153 P2 T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (surfaced by the new Windows CI leg): nexus-local-db's file_lock module is flock-based and documented 'Unix-only (the entire module is #[cfg(unix)])', but no cfg gate existed — pub mod file_lock; compiled unconditionally, breaking every Windows build of the runtime graph (nix::fcntl::flock + AsRawFd on windows-msvc: E0432/E0433/E0599). Fix follows the repo's existing precedent (works/mod.rs status JSON and script_section_status.rs already gate file_lock usage): - crates/nexus-local-db/src/lib.rs: #[cfg(unix)] pub mod file_lock; - nexus-local-db + nexus-daemon-runtime + nexus42 lock integration tests: #![cfg(unix)] (or #[cfg(unix)] per-test) - Consumers proceed unlocked on non-unix (WAL-governed shared-DB writes per P2 spec § Coexistence; the headless runtime never takes the per-Work advisory lock): - creator/run.rs, creator/works/cron.rs, creator/world/kb/mod.rs: cfg-split the RAII guard binding - creator/kb/rescan.rs acquire_work_lock + orchestration cron_supervisor maybe_acquire_cron_file_lock: cfg(unix) impl + cfg(not(unix)) no-op stub with the same call shape Unix behavior unchanged: file_lock integration tests 3/3, cron lock integration 3/3, kb_rescan 11/11 incl. the 3 AC5 lock tests, works_status_lock_holder 2/2; canonical clippy -D warnings and nightly fmt --check pass. --- apps/nexus42/src/commands/creator/kb/rescan.rs | 10 ++++++++++ apps/nexus42/src/commands/creator/run.rs | 7 +++++++ apps/nexus42/src/commands/creator/works/cron.rs | 7 +++++++ apps/nexus42/src/commands/creator/world/kb/mod.rs | 7 +++++++ apps/nexus42/tests/kb_rescan.rs | 3 +++ apps/nexus42/tests/works_status_lock_holder.rs | 3 +++ .../tests/cron_lock_integration.rs | 3 +++ crates/nexus-local-db/src/lib.rs | 1 + crates/nexus-local-db/tests/file_lock.rs | 3 +++ .../src/schedule/cron_supervisor.rs | 14 ++++++++++++++ 10 files changed, 58 insertions(+) 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/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/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-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-local-db/src/lib.rs b/crates/nexus-local-db/src/lib.rs index 2b9203f2d..2fefc9269 100644 --- a/crates/nexus-local-db/src/lib.rs +++ b/crates/nexus-local-db/src/lib.rs @@ -14,6 +14,7 @@ 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. // From 266a719079e7e9a80feacf1d7c4c2ff34d08424a Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 08:18:39 +0800 Subject: [PATCH 20/25] fix(local-db,daemon-runtime): un-gate portable cas + gate flock/signal code for windows-msvc (V1.153 P2 T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of Windows compile blockers surfaced by the CI leg (nexus-local-db built; these were next in the graph): - nexus-local-db: 'pub mod cas;' was wrongly #[cfg(unix)]-gated although cas.rs is pure SQL (OCC helpers) — kb_relationships imports it unconditionally (E0432 on Windows). Removed the gate. - nexus-daemon-runtime api/handlers/strategy.rs: StrategyLockGuard flock was un-gated (std::os::fd::AsRawFd + nix::fcntl::flock). Gated the guard + acquire fn on unix; not(unix) no-op unit guard, same call shape (callers unchanged: let _guard = acquire_strategy_lock(...)?). - nexus-daemon-runtime boot.rs run_daemon: tokio::signal::unix SIGTERM/ SIGINT spawn was un-gated. Unix keeps both; not(unix) uses the cross-platform tokio::signal::ctrl_c() with the same graceful ShutdownRequested dispatch. Canonical clippy -D warnings + nightly fmt --check pass on unix; unix behavior unchanged (gates are additive). --- .../src/api/handlers/strategy.rs | 21 +++++++++++++++++++ crates/nexus-daemon-runtime/src/boot.rs | 14 +++++++++++++ crates/nexus-local-db/src/lib.rs | 4 +++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs b/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs index b904c4eb2..6575863fc 100644 --- a/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs +++ b/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs @@ -39,11 +39,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 +62,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 +105,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/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-local-db/src/lib.rs b/crates/nexus-local-db/src/lib.rs index 2fefc9269..5c444c847 100644 --- a/crates/nexus-local-db/src/lib.rs +++ b/crates/nexus-local-db/src/lib.rs @@ -10,7 +10,9 @@ //! //! 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; From b073d63f2b816166b4b8eaeebca9d2b678a01ade Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 08:35:21 +0800 Subject: [PATCH 21/25] fix(acp-host): clone agent_path in the windows kill() error closures (V1.153 P2 T2) Windows compile blocker surfaced by the CI leg: the #[cfg(windows)] branch of AgentProcess::shutdown moved self.agent_path (PathBuf, non-Copy) into two map_err closures -> E0382 use of moved value. Latent since the branch was never compiled on macOS/Linux CI. Unix branch is unaffected (its first agent_path use sits on a return path, so the borrow checker allows the later closure capture); the windows branch clones per closure on the error path only. --- crates/nexus-acp-host/src/transport.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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(), From 21286a2eb2bae3134c287e04148db0ed481aa360 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 08:52:48 +0800 Subject: [PATCH 22/25] fix(daemon-runtime): gate the AsRawFd import in strategy.rs on unix (V1.153 P2 T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strategy.rs flock gate added in the previous commit covered the guard + acquire fn but left the top-level 'use std::os::fd::AsRawFd;' ungated — std::os::fd does not exist on windows-msvc (E0432). --- crates/nexus-daemon-runtime/src/api/handlers/strategy.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs b/crates/nexus-daemon-runtime/src/api/handlers/strategy.rs index 6575863fc..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; From 2cc1e795168db9c8a15d9b440b08d7ec9ed77d46 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 09:13:02 +0800 Subject: [PATCH 23/25] =?UTF-8?q?fix(nexus42):=20restore=20[dependencies]?= =?UTF-8?q?=20section=20header=20=E2=80=94=20six=20deps=20were=20unix-gate?= =?UTF-8?q?d=20by=20accident=20(V1.153=20P2=20T2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dialoguer/tempfile/regex/base64/cron/chrono-tz were declared after nix under [target.'cfg(unix)'.dependencies] with no [dependencies] header, so the Windows build of the nexus42 lib failed with 15 'cannot find crate' errors (E0433) in lib code that uses them on all platforms (works/cron.rs cron::Schedule validation, auth/user_auth.rs base64, creator/memory.rs tempfile, rules_runtime.rs dialoguer). Never caught before: no Windows CI existed; on unix the target-gated deps resolve identically. Unix dep graph and Cargo.lock unchanged. --- apps/nexus42/Cargo.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/nexus42/Cargo.toml b/apps/nexus42/Cargo.toml index f21d889ec..9a31d7d6a 100644 --- a/apps/nexus42/Cargo.toml +++ b/apps/nexus42/Cargo.toml @@ -109,6 +109,12 @@ hex = "0.4" [target.'cfg(unix)'.dependencies] nix = { version = "0.28", features = ["signal", "process"] } +# V1.153 P2 T2: these were accidentally declared under +# `[target.'cfg(unix)'.dependencies]` (missing `[dependencies]` header after +# the nix entry), so every Windows build failed with "cannot find crate +# dialoguer/tempfile/regex/base64/cron/chrono-tz" (E0433) in lib code that +# uses them on ALL platforms (e.g. works/cron.rs cron validation). +[dependencies] dialoguer = "0.11" tempfile = "3" regex = "1" From 6639649a51a4563f6a7efb1f07286049489f30e8 Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 09:14:26 +0800 Subject: [PATCH 24/25] fix(nexus42): move dialoguer/tempfile/regex/base64/cron/chrono-tz out of the unix target section (V1.153 P2 T2) They were declared after nix under [target.'cfg(unix)'.dependencies] without a [dependencies] header (TOML tables extend until the next header), making every Windows build of the nexus42 lib fail with E0433 'cannot find crate' in lib code used on all platforms. Moved into the existing [dependencies] section; unix dep graph unchanged. --- apps/nexus42/Cargo.toml | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/nexus42/Cargo.toml b/apps/nexus42/Cargo.toml index 9a31d7d6a..01b18c372 100644 --- a/apps/nexus42/Cargo.toml +++ b/apps/nexus42/Cargo.toml @@ -102,19 +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 were accidentally declared under -# `[target.'cfg(unix)'.dependencies]` (missing `[dependencies]` header after -# the nix entry), so every Windows build failed with "cannot find crate -# dialoguer/tempfile/regex/base64/cron/chrono-tz" (E0433) in lib code that -# uses them on ALL platforms (e.g. works/cron.rs cron validation). -[dependencies] +# 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" @@ -122,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" From a415ef272bdf150a494481c9a27130851ffabefb Mon Sep 17 00:00:00 2001 From: "Auto.Wood" Date: Fri, 7 Aug 2026 09:36:13 +0800 Subject: [PATCH 25/25] =?UTF-8?q?ci(runtime):=20remove=20temporary=20itera?= =?UTF-8?q?tion=20triggers=20=E2=80=94=20delivered=20set=20is=20workflow?= =?UTF-8?q?=5Fdispatch=20+=20tag=20runtime-v*=20(V1.153=20P2=20T2=20final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary push (plan branch) and pull_request (iteration/v1.153) triggers removed per the locked Clarify #3 trigger set; the workflow is verified green on all three matrix legs (windows-x64, macos-arm64, linux-x64) with artifacts + checksums uploaded. workflow_dispatch becomes available once merged to the default branch (E2+ release polish). --- .github/workflows/runtime-build.yml | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/.github/workflows/runtime-build.yml b/.github/workflows/runtime-build.yml index 52532e227..0e908f9d1 100644 --- a/.github/workflows/runtime-build.yml +++ b/.github/workflows/runtime-build.yml @@ -12,33 +12,15 @@ name: Runtime Build # packages `nexus-runtime--.zip` + `.sha256`, uploads them, and # smoke-tests `--version` on the runner itself. # -# Trigger set (locked by Clarify #3 + PM drift writeback 2026-08-07): -# `workflow_dispatch` + tag `runtime-v*` ONLY. GitHub only offers -# `workflow_dispatch` for workflows present on the DEFAULT branch, and a -# workflow file that exists only on a non-default branch is not evaluated -# for ANY event (verified empirically during T2: the workflow never -# registered, no check suite was created for push/tag/pull_request). So -# plan-branch iteration uses the TEMPORARY `push` (plan branch) + -# `pull_request` (spec integration branch) triggers below, with the -# workflow mirrored to the default branch by the PM; both are removed in -# the final T2 commit. +# 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: - # TEMPORARY (T2 iteration only): iteration vehicle while the workflow is - # not on the default branch. REMOVE before merge — the delivered trigger - # set is workflow_dispatch + tag runtime-v* only. - branches: - - plan/v1.153-p2-nexus-runtime-headless-bin-windows-x64 tags: - 'runtime-v*' - pull_request: - # TEMPORARY (T2 iteration only): PR-based iteration vehicle (see header - # note). REMOVE before merge — the delivered trigger set is - # workflow_dispatch + tag runtime-v* only. - branches: - - iteration/v1.153 concurrency: group: runtime-build-${{ github.ref }}