Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7b6c6e2
docs(design): define web-app run notifications
BenKurrek Aug 13, 2026
64569c3
docs(design): unify WebUI session event transport
BenKurrek Aug 13, 2026
5e3017c
docs(plans): add web-app run-notifications implementation plan
BenKurrek Aug 13, 2026
548806e
feat(contracts): type the product stream selector and event envelopes
BenKurrek Aug 13, 2026
babc6ef
feat(webui): ticketed session WebSocket over a shared stream driver
BenKurrek Aug 13, 2026
3afc071
feat(webui): app-root SessionEventClient with SSE fallback and shared…
BenKurrek Aug 13, 2026
539e7be
test(integration): whole-path session-socket streaming evidence
BenKurrek Aug 13, 2026
b908bc9
chore(arch): re-pin ironclaw_product_contracts size ceiling for the s…
BenKurrek Aug 13, 2026
e1c351c
feat(assistant): run-completion notice store, stream, ingest, operati…
BenKurrek Aug 13, 2026
6d29249
feat(composition,webui): wire run-completion observer, coordinator, a…
BenKurrek Aug 13, 2026
654ee33
feat(outbound): typed RunCompletion push vocabulary and the web-app v…
BenKurrek Aug 13, 2026
dbfaf74
feat(assistant,composition): local_os validation and the authorized W…
BenKurrek Aug 13, 2026
acdb72b
feat(webui-frontend): run-completion notices, arbitration client, and…
BenKurrek Aug 13, 2026
76605a7
test(assistant): coordinator arbitration tests; carry the grant count…
BenKurrek Aug 31, 2026
93b671d
feat(assistant,composition,webui-frontend): reconcile run completions…
BenKurrek Aug 31, 2026
f566fb2
fix(gates): satisfy post-rebase architecture and lint gates
BenKurrek Aug 31, 2026
e204c25
fix(ci): panic-baseline safety rationales and CI-tolerant socket test…
BenKurrek Aug 31, 2026
cb936bb
fix(tests,gates): order-independent socket test; composition budget r…
BenKurrek Aug 31, 2026
cc6eb68
fix,test: address PR #8010 review round
BenKurrek Aug 31, 2026
5815dc6
fix: second review-wave items — grant param struct, retained causes, …
BenKurrek Sep 1, 2026
0bdfbbd
style: crate-qualified path for the cross-module NewGrant reference
BenKurrek Sep 1, 2026
d42ff78
Merge remote-tracking branch 'origin/main' into feat/session-events-r…
BenKurrek Sep 2, 2026
c046f2b
fix(run-completions,webui): review round 3 — evidence gating, replay …
BenKurrek Sep 2, 2026
0df129d
Merge origin/main into feat/session-events-run-completion-notifications
BenKurrek Sep 2, 2026
6bfa24f
docs(assistant): drop the aliased run_completion_store_error charter …
BenKurrek Sep 2, 2026
41d89df
fix(run-completions,webui): review round 4 — arbitration bound, curso…
BenKurrek Sep 2, 2026
5268187
test(run-completions,webui): cover the stream selector, push fail-clo…
BenKurrek Sep 2, 2026
39dbc75
feat(webui): replace the session WebSocket with one session SSE stream
BenKurrek Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ secrecy = { version = "0.10", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum = { version = "0.8", features = ["ws"] }
futures = "0.3"
tower = { version = "0.5", features = ["util"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] }
base64 = "0.22"
Expand Down Expand Up @@ -512,6 +513,14 @@ path = "tests/integration/web_access.rs"
name = "reborn_integration_webui_v2_product_api"
path = "tests/integration/webui_v2_product_api.rs"

[[test]]
name = "reborn_integration_session_events"
path = "tests/integration/session_events.rs"

[[test]]
name = "reborn_integration_run_completion_notifications"
path = "tests/integration/run_completion_notifications.rs"

[[test]]
name = "reborn_integration_webui_v2_router_smoke"
path = "tests/integration/webui_v2_router_smoke.rs"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,22 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() {
// typed notification records, pagination, and lifecycle command DTOs.
// Storage and lifecycle behavior live in ironclaw_notifications;
// this crate only owns the authenticated ProductSurface wire contract.
("ironclaw_product_contracts", 16_581),
// 16_581 -> 16_791 (2026-08-31, session event transport rebased onto
// the inbox vocabulary): +210 lines of vocabulary, no logic — the
// typed `ProductStreamSelector` / `ProductStreamEventEnvelope` /
// `ProductStreamEvent` stream contracts replacing the `stream_id`
// string + JSON-erased events, and the new `session_transport`
// module (the single-use session-socket ticket record and storage
// port; adapters live in webui/composition). Count read from this
// gate.
// 16_791 -> 17_130 (2026-08-31, run-completion notifications): +339
// lines of vocabulary, no logic — the `run_completions` wire module
// (notice/grant/clear stream events, intent/acknowledge/thread-read
// request-responses, unread snapshot, operation descriptors, and
// their bounds). Store, ingest, arbitration, and stream behavior all
// live in `ironclaw_assistant::run_completions`; composition owns
// the observer/coordinator wiring. Count read from this gate.
("ironclaw_product_contracts", 17_130),
// 832 -> 432 (2026-08-12, #7373 refresh merge, main): re-pinned to the
// measured count — this row still carried the +400 seed pad the
// 2026-08-07 re-pin removed from its siblings.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ const FROZEN_OTHER_MODE_TYPES: &[&str] = &[
// JUSTIFIED (Bucket-3 by meaning): "hook-local id" — an identifier local to
// one hook, a genuine domain concept, not a deployment tier.
"HookLocalId",
// JUSTIFIED (Bucket-3 by meaning): the run-completion notification
// `local_os` presentation lane (2026-08-13 design §5.4) — a browser
// OS-notification intent kind mirroring the wire enum's `LocalOs`
// variant, not a deployment tier. The policy trait gates whether a
// `local_os` intent may win arbitration; `Deny*` is the fail-closed
// Phase-1 default until validated grants land.
"DenyLocalOsIntents",
"LocalOsIntentPolicy",
// RebornLocal* composition family — standalone-as-type mode names in the
// composition surface; shrinks with Slice B (deployment mode becomes a
// `DeploymentConfig` value):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ const WS0_COMPOSITION_SHARE_BP: usize = 658;
/// into #7875. Main contributes the resource-block observer wiring and #7875
/// adds the durable auth notification observer wiring. Both lifecycle policies
/// remain in `ironclaw_assistant`; composition only supplies dependencies.
const COMPOSITION_ABSOLUTE_SRC_LOC: usize = 42_935;
const COMPOSITION_ABSOLUTE_SRC_LOC: usize = 43_161;

/// Composition dispatch, from the same `--print` run: "composition dispatch:
/// 827 Arc<dyn> (governed prod, excl slack/extension_host)".
Expand Down
8 changes: 5 additions & 3 deletions crates/app/ironclaw_composition/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,11 @@ and the auth/product-auth mounts below), not in this prose.
base-uri 'self'`.
- **Connection limit (SSE + WS)** — bounded by `ironclaw_webui`'s own
`SseCapacity` (3 streams per `(tenant, user)`, 5-minute max stream
lifetime). The WebSocket stream (`stream_events_ws`) draws from the same
shared `SseCapacity` pool as SSE (pinned by
`stream_events_ws_shares_capacity_with_sse_streams` in
lifetime). The session event stream
(`POST /api/webchat/v2/session/events`, handler `session_events`) draws
from the same shared `SseCapacity` pool as the compatibility SSE route
(pinned by `session_events_shares_capacity_with_sse_streams` and
`session_events_releases_slot_when_the_client_drops_the_stream` in
`ironclaw_webui`'s `webui_v2_handlers_contract.rs`).
- **Caller construction** — `ProductSurfaceCaller` is built from
`config.tenant_id` (trusted host installation) plus the
Expand Down
6 changes: 0 additions & 6 deletions crates/app/ironclaw_composition/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,6 @@ sha2 = "0.11"
tempfile = "3"
testcontainers-modules = { version = "0.12", features = ["postgres"] }
tokio = { version = "1", features = ["macros", "net", "rt", "rt-multi-thread", "time"] }
# Real WebSocket client for the v2 stream-events-ws caller-level test.
# Oneshot via `tower::ServiceExt::oneshot` cannot complete a true WS
# upgrade (no underlying TCP stream → axum's WebSocketUpgrade extractor
# returns 426), so the happy-path test needs a bound listener and a
# proper WS client.
tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] }
tower = { version = "0.5", features = ["util"] }
# `no-env-filter` captures every event regardless of its metadata target —
# the observer test asserts a `target: "ironclaw::reborn::…"` warning, which
Expand Down
2 changes: 2 additions & 0 deletions crates/app/ironclaw_composition/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ mod product_surface;
mod production_runtime_policy;
mod readiness;
mod root;
mod run_completion_push;
mod runtime;
mod runtime_input;
mod runtime_mounts;
Expand Down Expand Up @@ -445,6 +446,7 @@ const PER_USER_ALIASES: &[&str] = &[
// The web-app channel keeps its own alias above rather than moving here,
// because moving it would relocate live enrollment documents.
"/delivery-registrations",
"/run-notices",
"/run-state",
"/checkpoint-state",
"/approvals",
Expand Down
3 changes: 2 additions & 1 deletion crates/app/ironclaw_composition/src/product_surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ pub(crate) fn build_product_surface_with_channel_connection(
.with_auth_interactions(runtime.webui_auth_interaction_service())
.with_diagnostic_store(Arc::clone(&runtime.diagnostic_store))
.with_session_inbound_ledger(Arc::clone(&runtime.session_inbound_ledger))
.with_session_channel_directory(Arc::clone(&runtime.session_channel_directory));
.with_session_channel_directory(Arc::clone(&runtime.session_channel_directory))
.with_run_completions(runtime.run_completion_services());
let default_thread_scope = runtime.product_default_thread_scope();
api = api.with_suggestions(
runtime.suggestions_store(),
Expand Down
81 changes: 81 additions & 0 deletions crates/app/ironclaw_composition/src/run_completion_push.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! Composition half of run-completion external presentation (2026-08-13
//! design §6.1, §7.9): the host-owned web-app enrollment probe the
//! `local_os` policy consults.
//!
//! The probe reads the SAME host-owned delivery registrations the delivery
//! coordinator resolves for actual pushes, so "Enrolled" here can never
//! diverge from what a push would use. The registration document is
//! interpreted through the web-app domain's own grammar
//! ([`RegistrationDocument`]) — the parser the delivery adapter sends with —
//! so composition wires the correlation without owning any of the document
//! shape; records that predate correlation degrade to profile-level presence.

use std::sync::Arc;

use async_trait::async_trait;
use ironclaw_assistant::run_completions::push::{WebAppEnrollmentProbe, WebAppEnrollmentSnapshot};
use ironclaw_assistant::run_completions::store::RunCompletionOwner;
use ironclaw_host_api::ids::ExtensionId;
use ironclaw_product_contracts::delivery::{
DeliveryRegistrationScope, DeliveryRegistrationService,
};
use ironclaw_web_app::RegistrationDocument;

pub(crate) struct RegistrationEnrollmentProbe {
registrations: Arc<dyn DeliveryRegistrationService>,
extension_id: ExtensionId,
}

impl RegistrationEnrollmentProbe {
pub(crate) fn new(
registrations: Arc<dyn DeliveryRegistrationService>,
extension_id: ExtensionId,
) -> Self {
Self {
registrations,
extension_id,
}
}
}

#[async_trait]
impl WebAppEnrollmentProbe for RegistrationEnrollmentProbe {
async fn enrollment(
&self,
owner: &RunCompletionOwner,
) -> Result<WebAppEnrollmentSnapshot, String> {
let scope = DeliveryRegistrationScope {
tenant_id: owner.tenant_id.clone(),
user_id: owner.user_id.clone(),
extension_id: self.extension_id.clone(),
};
let registrations = self
.registrations
.list(&scope)
.await
.map_err(|error| error.to_string())?;
let mut snapshot = WebAppEnrollmentSnapshot::default();
for registration in registrations {
match RegistrationDocument::parse(&registration.document) {
Ok(document) => match document.browser_instance_id {
Some(instance_id) => snapshot.instance_ids.push(instance_id),
None => snapshot.uncorrelated += 1,
},
Err(error) => {
// A document the delivery half cannot parse can never be
// pushed to, so it is not an enrollment either; the
// adapter prunes it on its own path.
tracing::debug!(
target: "ironclaw::reborn::run_completions",
%error,
"enrollment probe skipped an unparseable registration",
);
}
}
}
Ok(snapshot)
}
}

#[cfg(test)]
mod tests;
98 changes: 98 additions & 0 deletions crates/app/ironclaw_composition/src/run_completion_push/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! Tests for `run_completion_push`; a sibling file so the composition mass gate
//! counts production lines only (`scripts/ci/composition-budget.toml`).

use super::*;
use ironclaw_extension_contracts::channel_adapter::DeliveryRegistration;
use ironclaw_host_api::ids::{TenantId, UserId};
use ironclaw_product_contracts::delivery::{
DeliveryRegistrationError, DeliveryRegistrationRequest,
};

/// Keys shaped like a real `PushManager.subscribe()` result: a 65-byte
/// uncompressed P-256 point and a 16-byte auth secret, base64url.
fn valid_keys() -> String {
use base64::Engine as _;
let mut point = vec![0x04u8];
point.extend(std::iter::repeat_n(0x11u8, 64));
let p256dh = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(point);
let auth = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x22u8; 16]);
format!(r#""keys":{{"p256dh":"{p256dh}","auth":"{auth}"}}"#)
}

struct ScriptedRegistrations(Vec<DeliveryRegistration>);

#[async_trait]
impl DeliveryRegistrationService for ScriptedRegistrations {
async fn list(
&self,
_scope: &DeliveryRegistrationScope,
) -> Result<Vec<DeliveryRegistration>, DeliveryRegistrationError> {
Ok(self.0.clone())
}

async fn enroll(
&self,
_scope: &DeliveryRegistrationScope,
_request: DeliveryRegistrationRequest,
) -> Result<DeliveryRegistration, DeliveryRegistrationError> {
unreachable!("the probe only lists")
}

async fn remove(
&self,
_scope: &DeliveryRegistrationScope,
_registration_id: &str,
) -> Result<bool, DeliveryRegistrationError> {
unreachable!("the probe only lists")
}

async fn prune(
&self,
_scope: &DeliveryRegistrationScope,
_registration_ids: &[String],
) -> Result<usize, DeliveryRegistrationError> {
unreachable!("the probe only lists")
}
}

fn registration(id: &str, document: String) -> DeliveryRegistration {
DeliveryRegistration {
registration_id: id.to_string(),
endpoint: "https://push.example/send/a".to_string(),
document,
created_at: "2026-08-13T00:00:00Z".to_string(),
}
}

#[tokio::test]
async fn enrollment_correlates_through_the_web_app_document_grammar() {
let keys = valid_keys();
let probe = RegistrationEnrollmentProbe::new(
Arc::new(ScriptedRegistrations(vec![
registration(
"correlated",
format!(r#"{{{keys},"browser_instance_id":"rbi-1"}}"#),
),
registration("legacy", format!("{{{keys}}}")),
// Not a push subscription at all: the adapter would prune it,
// so the probe must not count it as presence either.
registration(
"malformed",
r#"{"browser_instance_id":"rbi-2"}"#.to_string(),
),
])),
ExtensionId::new("web-app").expect("extension id"),
);
let snapshot = probe
.enrollment(&RunCompletionOwner {
tenant_id: TenantId::new("tenant-alpha").expect("tenant"),
user_id: UserId::new("user-alpha").expect("user"),
})
.await
.expect("probe lists");
assert_eq!(snapshot.instance_ids, vec!["rbi-1".to_string()]);
assert_eq!(
snapshot.uncorrelated, 1,
"legacy records degrade to presence"
);
}
Loading