diff --git a/client/VENDORING.md b/client/VENDORING.md index cdcf08b..3a37caa 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `431f8d9fe4b49372d84ee47c72a322fdae703dd77b3648e680e3a65a46b881f3` +**state digest:** `f8cb1c47d7ab2cddc8187bab5bfd06f746b705d505f90a0848ffaa6f0b10500c` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt index 7f82b8b..277941b 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt @@ -514,6 +514,11 @@ fun CIRISApp( // Peer detail (parameterised) goes back to the peer list, not the hub is Screen.NetworkPeerDetail -> Screen.NetworkPeers + // Sub-screens of layer hubs + is Screen.EnvironmentInfo -> Screen.LayerLocalCommunity + is Screen.Delegation -> Screen.LayerFamily + is Screen.Constitutional -> Screen.LayerGlobalCommons + // Contacts goes back to the picker source (Delegations) or home is Screen.Contacts -> { val src = contactsPickerSourceScreen @@ -4144,7 +4149,7 @@ fun CIRISApp( PlatformLogger.i("CIRISApp", "[Screen.EnvironmentInfo] User triggered refresh") environmentInfoViewModel.refresh() }, - onNavigateBack = { currentScreen = Screen.Interact }, + onNavigateBack = { currentScreen = Screen.LayerLocalCommunity }, onCategorySelected = { category -> PlatformLogger.d("CIRISApp", "[Screen.EnvironmentInfo] Category selected: $category") environmentInfoViewModel.setCategory(category) @@ -4402,12 +4407,33 @@ fun CIRISApp( ) } Screen.Delegation -> { + val delegationsList by delegationsViewModel.delegations.collectAsState() + val isDelegationsLoading by delegationsViewModel.loading.collectAsState() ai.ciris.mobile.shared.ui.screens.federation.DelegationScreen( + delegations = delegationsList, + isLoading = isDelegationsLoading, + onRefresh = { delegationsViewModel.refresh() }, + onNavigateBack = { currentScreen = Screen.LayerFamily }, + onManageDeviceGrants = { currentScreen = Screen.Delegations }, onIssueClick = { url -> uriHandler.openUri(url) }, ) } Screen.Constitutional -> { + val accordFamily by accordViewModel.family.collectAsState() + val accordHolders by accordViewModel.holders.collectAsState() + val accordThreshold by accordViewModel.holderThreshold.collectAsState() + val accordHaltStatus by accordViewModel.haltStatus.collectAsState() + val isAccordLoading by accordViewModel.loading.collectAsState() ai.ciris.mobile.shared.ui.screens.federation.ConstitutionalScreen( + family = accordFamily, + holders = accordHolders, + holderThreshold = accordThreshold, + haltStatus = accordHaltStatus, + isLoading = isAccordLoading, + onRefresh = { accordViewModel.refresh() }, + onNavigateBack = { currentScreen = Screen.LayerGlobalCommons }, + onOpenAccordCeremony = { currentScreen = Screen.AccordCeremony }, + onOpenProvisionHolder = { currentScreen = Screen.ProvisionAccordHolder }, onIssueClick = { url -> uriHandler.openUri(url) }, ) } @@ -4491,18 +4517,24 @@ fun CIRISApp( // ── 2.9.4 — CEG 0.6 layer hubs (Identities · Trust · Policies) ── Screen.LayerAgent -> ai.ciris.mobile.shared.ui.screens.commons.LayerHubScreen( scope = ai.ciris.mobile.shared.ui.nav.CohortScope.AGENT, + hasAgent = clientMode?.isAgent ?: false, onIssueClick = { url -> uriHandler.openUri(url) }, ) Screen.LayerFamily -> ai.ciris.mobile.shared.ui.screens.commons.LayerHubScreen( scope = ai.ciris.mobile.shared.ui.nav.CohortScope.FAMILY, + hasAgent = clientMode?.isAgent ?: false, + onOpenDelegations = { currentScreen = Screen.Delegation }, onIssueClick = { url -> uriHandler.openUri(url) }, ) Screen.LayerLocalCommunity -> ai.ciris.mobile.shared.ui.screens.commons.LayerHubScreen( scope = ai.ciris.mobile.shared.ui.nav.CohortScope.LOCAL_COMMUNITY, + hasAgent = clientMode?.isAgent ?: false, + onOpenEnvironment = { currentScreen = Screen.EnvironmentInfo }, onIssueClick = { url -> uriHandler.openUri(url) }, ) Screen.LayerGlobalCommunities -> ai.ciris.mobile.shared.ui.screens.commons.LayerHubScreen( scope = ai.ciris.mobile.shared.ui.nav.CohortScope.GLOBAL_COMMUNITIES, + hasAgent = clientMode?.isAgent ?: false, onIssueClick = { url -> uriHandler.openUri(url) }, ) // Screen.LayerGlobalCommons handled above alongside Screen.Network — @@ -4619,6 +4651,10 @@ fun CIRISApp( // iOS and web (where PlatformBackHandler is a // no-op) leaves no way out of a chat at all. is Screen.UserChat -> Screen.Contacts + // Layer hub sub-screens + Screen.EnvironmentInfo -> Screen.LayerLocalCommunity + Screen.Delegation -> Screen.LayerFamily + Screen.Constitutional -> Screen.LayerGlobalCommons // Nested sub-screens → their direct parent Screen.GraphMemory -> Screen.Memory Screen.SkillStudio -> Screen.Adapters @@ -4633,7 +4669,6 @@ fun CIRISApp( Screen.Config, Screen.Consent, Screen.DataManagement, - Screen.EnvironmentInfo, Screen.Help, Screen.LLMSettings, Screen.Logs, diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/nav/EpistemicNav.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/nav/EpistemicNav.kt index 8c61728..e66b473 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/nav/EpistemicNav.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/nav/EpistemicNav.kt @@ -301,7 +301,6 @@ sealed class NavSurface( /** Self — the agent itself. Implicit in the user's mental model. */ object LayerAgent : NavSurface( id = "layer-agent", label = "Agent (Self)", icon = CIRISIcons.person, - gate = SubstrateGate.EDGE_PEERRESOLVER, labelKey = "commons.layer.agent.title", ) @@ -315,19 +314,16 @@ sealed class NavSurface( ) object EnvironmentGraph : NavSurface( // Ungated 2.9.6 — routes to the live EnvironmentInfo screen - // (/v1/memory?scope=environment). The cohort-overlay extension remains - // future work; the base environment view ships now. + // (/v1/memory?scope=environment). Reachable under Local Community. id = "environment-graph", label = "Environment Graph", icon = CIRISIcons.snapshot, labelKey = "commons.federation.environment_graph.title", ) object Delegation : NavSurface( id = "delegation", label = "Delegation", icon = CIRISIcons.send, - gate = SubstrateGate.PERSIST_DELEGATES_TO, labelKey = "commons.federation.delegation.title", ) object Constitutional : NavSurface( id = "constitutional", label = "Constitutional", icon = CIRISIcons.instructions, - gate = SubstrateGate.REGISTRY_ACCORD_HOLDER, labelKey = "commons.federation.constitutional.title", ) @@ -367,7 +363,6 @@ sealed class NavSurface( /** Other CIRIS occurrences sharing the operator's identity. */ object LayerFamily : NavSurface( id = "layer-family", label = "Family", icon = CIRISIcons.home, - gate = SubstrateGate.EDGE_PEERRESOLVER, children = listOf(Delegation), labelKey = "commons.layer.family.title", ) @@ -375,14 +370,13 @@ sealed class NavSurface( /** One home channel / Discord guild / household — locally-trusted peers. */ object LayerLocalCommunity : NavSurface( id = "layer-local-community", label = "Local Community", icon = CIRISIcons.location, - gate = SubstrateGate.EDGE_PEERRESOLVER, + children = listOf(EnvironmentGraph), labelKey = "commons.layer.local_community.title", ) /** Cross-community affinity groups the agent has joined (CEG affiliations). */ object LayerGlobalCommunities : NavSurface( id = "layer-global-communities", label = "Global Communities", icon = CIRISIcons.shield, - gate = SubstrateGate.EDGE_PEERRESOLVER, children = listOf(Participate), labelKey = "commons.layer.global_communities.title", ) @@ -390,8 +384,7 @@ sealed class NavSurface( /** The federation as the universal layer (folds species + planet + federation). */ object LayerGlobalCommons : NavSurface( id = "layer-global-commons", label = "Global Commons", icon = CIRISIcons.globe, - gate = SubstrateGate.EDGE_PEERRESOLVER, - children = listOf(EnvironmentGraph, Constitutional), + children = listOf(Constitutional), labelKey = "commons.layer.global_commons.title", ) @@ -418,41 +411,11 @@ enum class SubstrateGate( val prefixFamily: String, val fsdSection: String, ) { - VERIFY_ATTESTATION_LADDER( - repo = "CIRISVerify", issueNumber = 36, - prefixFamily = "attestation:l1..l5 + provenance:* + hardware_custody:*", - fsdSection = "FSD-002 §3.2", - ), - PERSIST_DELEGATES_TO( - repo = "CIRISPersist", issueNumber = 104, - prefixFamily = "federation_directory:* + delegates_to (structural)", - fsdSection = "FSD-002 §3.3 + §2.2.1", - ), - EDGE_PEERRESOLVER( - repo = "CIRISEdge", issueNumber = 22, - prefixFamily = "peer_reachability:* + ContentFetch + VerifiedEnvelope feed", - fsdSection = "FSD-002 §3.4 + §3.6.7", - ), NODECORE_NEEDS( repo = "CIRISNodeCore", issueNumber = 12, prefixFamily = "need:{domain}:{kind} (new primitive, in flight)", fsdSection = "FSD-002 §3.6 (extension)", ), - LENSCORE_CAPACITY( - repo = "CIRISLensCore", issueNumber = 25, - prefixFamily = "capacity:core_identity..sustained_coherence:composite", - fsdSection = "FSD-002 §3.5.4", - ), - LENSCORE_COHORT( - repo = "CIRISLensCore", issueNumber = 25, - prefixFamily = "manifold_conformity:{cohort} + detection:correlated_action:{axis} + detection:distributive:access:*", - fsdSection = "FSD-002 §3.5.2 + §3.5.3 + §3.5.5", - ), - REGISTRY_ACCORD_HOLDER( - repo = "CIRISRegistry", issueNumber = 23, - prefixFamily = "accord:* (reserved to identity_type=accord_holder)", - fsdSection = "FSD-002 §3.9 + §4.1", - ), POST_SUBSTRATE_SUBSTITUTION( repo = "CIRISAgent", issueNumber = 800, prefixFamily = "client / relay / node peer taxonomy (post Step-4)", diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/HealthReputationScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/HealthReputationScreen.kt index 59b1f05..23fdcf5 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/HealthReputationScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/HealthReputationScreen.kt @@ -5,10 +5,12 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -98,9 +100,9 @@ fun HealthReputationScreen( // The σ-maturity explainer (lifted from the old popup) CapacityMaturityNote(state) - // Federation-attestations sub-section gated on CIRISLensCore#25 + // Federation-attestations sub-section (LensCore merged in-tree) Spacer(Modifier.height(8.dp)) - FederationAttestationsGate(onIssueClick) + FederationAttestationsSection(state = state) // Spec link Row( @@ -140,83 +142,107 @@ private fun HealthHeader(state: CellVizState) { @Composable private fun CategoryPill(category: String) { - val (bg, fg, label) = when (category) { - "high_capacity", "healthy" -> Triple(CIRISColors.StatusOk.copy(alpha = 0.15f), CIRISColors.StatusOk, category.uppercase().replace("_", " ")) - "moderate" -> Triple(CIRISColors.StatusWarn.copy(alpha = 0.15f), CIRISColors.StatusWarn, "MODERATE") - "high_fragility" -> Triple(CIRISColors.StatusErr.copy(alpha = 0.15f), CIRISColors.StatusErr, "HIGH FRAGILITY") - "pending" -> Triple(CIRISColors.TextDim.copy(alpha = 0.15f), CIRISColors.TextDim, "WARMING UP") + val (bg, fg, label) = when (category.lowercase()) { + "high_capacity" -> Triple(CIRISColors.SignetTeal.copy(alpha = 0.15f), CIRISColors.SignetTeal, "HIGH CAPACITY") + "healthy" -> Triple(CIRISColors.SignetTeal.copy(alpha = 0.15f), CIRISColors.SignetTeal, "HEALTHY") + "moderate" -> Triple(CIRISColors.BusTool.copy(alpha = 0.15f), CIRISColors.BusTool, "MODERATE") + "high_fragility" -> Triple(CIRISColors.StatusWarn.copy(alpha = 0.15f), CIRISColors.StatusWarn, "HIGH FRAGILITY") + "pending" -> Triple(CIRISColors.BusTool.copy(alpha = 0.15f), CIRISColors.BusTool, "WARMING UP") else -> Triple(CIRISColors.TextDim.copy(alpha = 0.15f), CIRISColors.TextDim, category.uppercase()) } Box( modifier = Modifier .clip(RoundedCornerShape(50)) .background(bg) - .border(1.dp, fg.copy(alpha = 0.40f), RoundedCornerShape(50)) - .padding(horizontal = 10.dp, vertical = 4.dp) - .testTag("capacity_category_pill"), + .padding(horizontal = 10.dp, vertical = 4.dp), ) { - Text(text = label, color = fg, fontSize = 9.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.0.sp) + Text( + text = label, + color = fg, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) } } @Composable private fun CompositeScoreHero(state: CellVizState) { - val local = state.localScore - val fleet = state.compositeScore - Box( + val hasLocal = state.localScore != null + Surface( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(CIRISColors.BackgroundDarker) - .border(1.dp, CIRISColors.SignetTeal.copy(alpha = 0.30f), RoundedCornerShape(12.dp)) - .padding(20.dp) - .testTag("capacity_composite_hero"), + .testable("card_capacity_composite"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(14.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "Composite (𝒞_CIRIS = C · I_int · R · I_inc · S)", - color = CIRISColors.TextTertiary, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - ) - Row(verticalAlignment = Alignment.Bottom) { - Text( - text = if (state.isPreFetch) "…" else fmt(fleet), - color = CIRISColors.AccentCyan, - fontSize = 44.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.testTag("capacity_composite_value"), - ) - Spacer(Modifier.width(12.dp)) - local?.let { - Column { - Text( - text = "local", - color = CIRISColors.TextDim, - fontSize = 9.sp, - letterSpacing = 1.0.sp, - ) + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "COMPOSITE SCORE", + color = CIRISColors.TextDim, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = if (state.isPreFetch) "—" else fmt(state.compositeScore), + color = CIRISColors.TextPrimary, + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + ) + if (!state.isPreFetch && hasLocal) { + Spacer(Modifier.height(2.dp)) Text( - text = fmt(it), - color = CIRISColors.SignetTeal, - fontSize = 22.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.testTag("capacity_local_value"), + text = "Local: ${fmt(state.localScore!!)} · Fleet: ${fmt(state.compositeScore)}", + color = CIRISColors.TextSecondary, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, ) } } + Surface( + color = CIRISColors.SignetTeal.copy(alpha = 0.18f), + shape = RoundedCornerShape(6.dp), + ) { + Text( + text = if (hasLocal) "LOCAL + FLEET" else "FLEET ONLY", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = CIRISColors.SignetTeal, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } } - Text( - text = if (local != null) { - "Local reflects this device's CCA approximation (service health, LLM health, sustainability). Fleet is the template-aggregate across every install of the same agent template — your context." - } else { - "Fleet score across every install of the same agent template. Local score climbs as the device accumulates operational evidence." - }, - color = CIRISColors.TextSecondary, - fontSize = 12.sp, - lineHeight = 18.sp, - ) - // Fragility — only show when measurably elevated + if (!state.isPreFetch) { + LinearProgressIndicator( + progress = { state.compositeScore.coerceIn(0f, 1f) }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = CIRISColors.AccentCyan, + trackColor = Color.White.copy(alpha = 0.08f), + ) + } + // FRAGILITY — only when measurably elevated, and restored here. + // + // The redesigned hero ended at the progress bar and nothing in this + // file read `fragilityIndex` any more, while the API and + // InteractViewModel still populate it. That silently removed the + // only precise elevated-fragility warning on this surface — an + // unbounded risk signal, dropped by a layout change. if (!state.isPreFetch && state.fragilityIndex > 1.2f) { Spacer(Modifier.height(4.dp)) Text( @@ -224,6 +250,7 @@ private fun CompositeScoreHero(state: CellVizState) { color = CIRISColors.StatusWarn, fontSize = 11.sp, fontFamily = FontFamily.Monospace, + modifier = Modifier.testable("txt_fragility_elevated"), ) } } @@ -231,98 +258,98 @@ private fun CompositeScoreHero(state: CellVizState) { } @Composable -private fun FactorRow(short: String, name: String, value: Float, description: String) { - val clamped = value.coerceIn(0f, 1f) - Row( +private fun FactorRow( + symbol: String, + title: String, + score: Float, + description: String, +) { + Surface( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(CIRISColors.BackgroundDarker.copy(alpha = 0.7f)) - .border(1.dp, Color.White.copy(alpha = 0.06f), RoundedCornerShape(10.dp)) - .padding(horizontal = 14.dp, vertical = 12.dp) - .testTag("capacity_factor_$short"), - verticalAlignment = Alignment.CenterVertically, + .testable("factor_row_${symbol.lowercase()}"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(10.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)), ) { - // Code label - Box( - modifier = Modifier - .width(58.dp) - .clip(RoundedCornerShape(6.dp)) - .background(CIRISColors.SignetTeal.copy(alpha = 0.15f)) - .padding(vertical = 6.dp), - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - Text( - text = short, - color = CIRISColors.SignetTeal, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - ) - } - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(name, color = CIRISColors.TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium) - Text(description, color = CIRISColors.TextDim, fontSize = 11.sp, lineHeight = 16.sp) - Spacer(Modifier.height(6.dp)) - LinearProgressIndicator( - progress = clamped, + Box( modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .clip(RoundedCornerShape(50)), + .size(36.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = symbol, + color = CIRISColors.TextPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = CIRISColors.TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = description, + color = CIRISColors.TextSecondary, + fontSize = 11.sp, + lineHeight = 15.sp, + ) + } + Text( + text = fmt(score), color = CIRISColors.AccentCyan, - trackColor = Color.White.copy(alpha = 0.06f), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, ) } - Spacer(Modifier.width(12.dp)) - Text( - text = fmt(clamped), - color = CIRISColors.TextPrimary, - fontSize = 13.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - ) } } @Composable private fun CapacityMaturityNote(state: CellVizState) { - Column( + Surface( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(CIRISColors.SignetTeal.copy(alpha = 0.06f)) - .border(1.dp, CIRISColors.SignetTeal.copy(alpha = 0.25f), RoundedCornerShape(10.dp)) - .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .testable("card_capacity_maturity"), + color = CIRISColors.BackgroundDarker.copy(alpha = 0.6f), + shape = RoundedCornerShape(10.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.04f)), ) { - Text( - text = "Why a fresh install isn't 1.00", - color = CIRISColors.SignetTeal, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = "The sustained-coherence factor (S) needs operational evidence: roughly 30 successful interactions over a 30-day rolling window for a fully computed local score. Until then it floors at 0.30 — by design, so a brand-new agent doesn't claim coherence it hasn't earned. Keep using the agent and the number climbs honestly.", - color = CIRISColors.TextSecondary, - fontSize = 12.sp, - lineHeight = 18.sp, - ) - state.localScore?.let { local -> + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "ℹ", + color = CIRISColors.AccentCyan, + fontSize = 14.sp, + ) Text( - text = "Right now your local score is ${fmt(local)}.", - color = CIRISColors.TextTertiary, + text = "σ-maturity tracks behavioral stability over time. As the agent completes interactions, sustained coherence confidence climbs.", + color = CIRISColors.TextDim, fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + lineHeight = 15.sp, ) } } } @Composable -private fun FederationAttestationsGate(onIssueClick: (String) -> Unit) { - val gate = SubstrateGate.LENSCORE_CAPACITY +private fun FederationAttestationsSection(state: CellVizState) { Column( modifier = Modifier .fillMaxWidth() @@ -330,7 +357,7 @@ private fun FederationAttestationsGate(onIssueClick: (String) -> Unit) { .background(CIRISColors.BackgroundDarker.copy(alpha = 0.5f)) .border(1.dp, Color.White.copy(alpha = 0.06f), RoundedCornerShape(10.dp)) .padding(14.dp) - .testTag("federation_capacity_gate"), + .testable("card_federation_capacity_attestations"), verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -341,25 +368,71 @@ private fun FederationAttestationsGate(onIssueClick: (String) -> Unit) { fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(CIRISColors.BusTool.copy(alpha = 0.15f)) - .border(1.dp, CIRISColors.BusTool.copy(alpha = 0.40f), RoundedCornerShape(50)) - .clickable { onIssueClick(gate.url) } - .padding(horizontal = 8.dp, vertical = 3.dp), - ) { - Text( - text = "COMING SOON", - color = CIRISColors.BusTool, - fontSize = 8.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.0.sp, - ) + if (state.isPreFetch) { + Surface( + color = CIRISColors.BusTool.copy(alpha = 0.18f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = "WARMING UP", + color = CIRISColors.BusTool, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + modifier = Modifier + .testable("federation_capacity_warming_up") + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else if (!state.federationDataPresent) { + // THE FETCH FINISHED; IT DID NOT SUCCEED. refreshCapacity() + // clears isPreFetch in its catch branch too, after computing a + // local score from service health — so keying LIVE on + // `!isPreFetch` announced federation detectors precisely when + // the federation call had failed. + Surface( + color = CIRISColors.BusTool.copy(alpha = 0.18f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = "LOCAL ONLY", + color = CIRISColors.BusTool, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + modifier = Modifier + .testable("federation_capacity_local_only") + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else { + Surface( + color = CIRISColors.SignetTeal.copy(alpha = 0.18f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = "LIVE", + color = CIRISColors.SignetTeal, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + modifier = Modifier + .testable("federation_capacity_live") + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } } } Text( - text = "Per-cohort manifold conformity, correlated-action axes, and distributive-access readings ship when ${gate.shortRef} closes. Today's card shows local + fleet only.", + text = when { + state.isPreFetch -> + "Node capacity detectors warming up. Sustained coherence and manifold conformity readings will appear once initial metrics settle." + !state.federationDataPresent -> + "Federation capacity standing is UNAVAILABLE — /v1/my-data/capacity did not answer, so the score above is a local " + + "service-health estimate. Manifold conformity and distributive access detectors are not reporting." + else -> + "Active federation capacity standing (capacity:sustained_coherence:v1). Coherence ratchet, manifold conformity, and distributive access detectors running in node core." + }, color = CIRISColors.TextDim, fontSize = 11.sp, lineHeight = 16.sp, @@ -394,17 +467,18 @@ private fun fmt2(v: Float): String { * Once the CIRISApp.kt rewire wires every callsite through the state-hoisted * shape, delete this overload. */ +/** + * Deprecated overload — kept as the no-arg path for callers wired up before + * the state-hoisted version landed. Renders the live card with default state. + */ @Deprecated( message = "Use the overload that takes CellVizState — the score now ships as a real card.", replaceWith = ReplaceWith("HealthReputationScreen(state, onIssueClick)"), ) @Composable fun HealthReputationScreen(onIssueClick: (String) -> Unit = {}) { - ComingSoonPlaceholder( - title = NavSurface.HealthReputation.label, - icon = NavSurface.HealthReputation.icon, - description = "Per-agent reputation surface. The state-hoisted overload of this composable ships now; this no-arg fallback renders the substrate-gate placeholder.", - gate = SubstrateGate.LENSCORE_CAPACITY, + HealthReputationScreen( + state = CellVizState(), onIssueClick = onIssueClick, ) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/commons/LayerHubScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/commons/LayerHubScreen.kt index 3865d9c..1545d3f 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/commons/LayerHubScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/commons/LayerHubScreen.kt @@ -1,6 +1,7 @@ package ai.ciris.mobile.shared.ui.screens.commons import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,6 +18,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -24,17 +27,21 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ai.ciris.mobile.shared.localization.localizedString +import ai.ciris.mobile.shared.platform.rememberTestableScrollState import ai.ciris.mobile.shared.platform.testable import ai.ciris.mobile.shared.platform.testableClickable +import ai.ciris.mobile.shared.platform.testableWithHandler import ai.ciris.mobile.shared.ui.components.CIRISIcons import ai.ciris.mobile.shared.ui.nav.CohortScope import ai.ciris.mobile.shared.ui.nav.SubstrateGate -import ai.ciris.mobile.shared.platform.rememberTestableScrollState +import ai.ciris.mobile.shared.ui.theme.CIRISColors /** * Generic layer hub for the 5 UX-facing cohort scopes. Renders three @@ -49,21 +56,16 @@ import ai.ciris.mobile.shared.platform.rememberTestableScrollState * - **Policies** — trust policies that govern automatic trust at this * scope. * - * Phase A (this commit): three section stubs each pinned to the - * substrate gate that needs to ship before per-layer data can flow - * (Edge's PeerResolver for per-scope identity queries). The Global - * Commons scope routes through this generic hub for now; Phase B will - * fold the existing NetworkScreen federation surface into the Global - * Commons hub directly. - * - * CEG 0.6 mapping at the wire level: each scope corresponds to one or - * more `cohort_scope` values from CEG §02 grammar:137 (`self / family / - * community / affiliations / species / planet / federation`). The 7 → - * 5 fold is documented on [CohortScope]. + * EDGE_PEERRESOLVER (CIRISEdge#22) has shipped; the cohort-aware views + * are active. Local Community exposes the Environment & Resources surface + * directly to allow sharing physical resources, tools, and inventory. */ @Composable fun LayerHubScreen( scope: CohortScope, + hasAgent: Boolean = false, + onOpenEnvironment: (() -> Unit)? = null, + onOpenDelegations: (() -> Unit)? = null, onIssueClick: (String) -> Unit = {}, ) { val gate = scopeGate(scope) @@ -83,6 +85,13 @@ fun LayerHubScreen( ) { LayerHeader(scope = scope, icon = scopeIcon(scope)) + // ── Scope-specific feature cards ── + if (scope == CohortScope.LOCAL_COMMUNITY && hasAgent && onOpenEnvironment != null) { + LocalCommunityEnvironmentCard(onOpenEnvironment = onOpenEnvironment) + } else if (scope == CohortScope.FAMILY && onOpenDelegations != null) { + FamilyDelegationsCard(onOpenDelegations = onOpenDelegations) + } + LayerSection( testTag = "layer_section_identities_${scope.id.replace('-', '_')}", icon = CIRISIcons.person, @@ -153,13 +162,133 @@ private fun LayerHeader(scope: CohortScope, icon: ImageVector) { } } +@Composable +private fun LocalCommunityEnvironmentCard(onOpenEnvironment: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_local_community_environment"), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp), + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = CIRISIcons.snapshot, + contentDescription = null, + tint = CIRISColors.AccentCyan, + modifier = Modifier.size(22.dp), + ) + Text( + text = localizedString("commons.federation.environment_graph.title"), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = localizedString("commons.federation.environment_graph.description"), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + Button( + onClick = onOpenEnvironment, + modifier = Modifier + .fillMaxWidth() + .testableWithHandler("btn_open_environment") { onOpenEnvironment() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + imageVector = CIRISIcons.snapshot, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = localizedString("commons.federation.environment_graph.title"), + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun FamilyDelegationsCard(onOpenDelegations: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_family_delegations"), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(12.dp), + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = CIRISIcons.send, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + text = localizedString("commons.federation.delegation.title"), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = localizedString("commons.federation.delegation.description"), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + Button( + onClick = onOpenDelegations, + modifier = Modifier + .fillMaxWidth() + .testableWithHandler("btn_open_delegations") { onOpenDelegations() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + imageVector = CIRISIcons.keySecure, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = localizedString("nav.surface.delegations"), + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + @Composable private fun LayerSection( testTag: String, icon: ImageVector, titleKey: String, descriptionKey: String, - gate: SubstrateGate, + gate: SubstrateGate? = null, onIssueClick: (String) -> Unit, ) { Surface( @@ -190,15 +319,19 @@ private fun LayerSection( fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) - Spacer(modifier = Modifier.weight(1f)) - ComingSoonBadge() + if (gate != null) { + Spacer(modifier = Modifier.weight(1f)) + ComingSoonBadge() + } } Text( text = localizedString(descriptionKey), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), ) - GateRow(gate = gate, onIssueClick = onIssueClick) + if (gate != null) { + GateRow(gate = gate, onIssueClick = onIssueClick) + } } } } @@ -219,6 +352,8 @@ private fun ComingSoonBadge() { } } + + @Composable private fun GateRow(gate: SubstrateGate, onIssueClick: (String) -> Unit) { Surface( @@ -269,16 +404,13 @@ private fun scopeIcon(scope: CohortScope): ImageVector = when (scope) { CohortScope.GLOBAL_COMMONS -> CIRISIcons.globe } -private fun scopeGate(scope: CohortScope): SubstrateGate = when (scope) { - // Per-cohort identity / trust queries land with Edge's PeerResolver - // surface (CIRISEdge#22). The Global Commons surface already has the - // federation transport substrate in place — the gate is the per-scope - // cohort-aware view, not the substrate itself. +private fun scopeGate(scope: CohortScope): SubstrateGate? = when (scope) { + // EDGE_PEERRESOLVER (CIRISEdge#22) has shipped; all cohort scopes are live. CohortScope.AGENT, CohortScope.FAMILY, CohortScope.LOCAL_COMMUNITY, CohortScope.GLOBAL_COMMUNITIES, - CohortScope.GLOBAL_COMMONS -> SubstrateGate.EDGE_PEERRESOLVER + CohortScope.GLOBAL_COMMONS -> null } private fun scopeTitleKey(scope: CohortScope): String = "commons.layer.${scope.id.replace('-', '_')}.title" diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/ConstitutionalScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/ConstitutionalScreen.kt index 4a07b2c..6eca1ba 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/ConstitutionalScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/ConstitutionalScreen.kt @@ -1,27 +1,357 @@ package ai.ciris.mobile.shared.ui.screens.federation +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import ai.ciris.mobile.shared.localization.localizedString -import ai.ciris.mobile.shared.ui.components.ComingSoonPlaceholder +import ai.ciris.mobile.shared.models.federation.AccordFamilyDto +import ai.ciris.mobile.shared.models.federation.AccordHaltStatusResponse +import ai.ciris.mobile.shared.models.federation.AccordHolderDto +import ai.ciris.mobile.shared.platform.rememberTestableScrollState +import ai.ciris.mobile.shared.platform.testable +import ai.ciris.mobile.shared.platform.testableWithHandler +import ai.ciris.mobile.shared.ui.components.CIRISIcons +import ai.ciris.mobile.shared.ui.nav.LocalIsCompactWindow import ai.ciris.mobile.shared.ui.nav.NavSurface -import ai.ciris.mobile.shared.ui.nav.SubstrateGate +import ai.ciris.mobile.shared.ui.theme.CIRISColors /** - * "Constitutional" — accord-holder identity + their reserved-prefix - * attestations. Per FSD-002 §4.1, `accord:*` is the one constitutional - * asymmetry: only `identity_type=accord_holder` may emit those attestations, - * and the federation directory authoritatively lists current holders. + * "Constitutional" — accord-holder identity + reserved-prefix attestations. * - * Gated on CIRISRegistry#23 (accord_holder identity surface + reserved-prefix - * enforcement query API). + * Per FSD-002 §4.1, `accord:*` is the one constitutional asymmetry: only + * `identity_type=accord_holder` may emit those attestations, and the + * federation directory authoritatively lists current holders. + * + * CIRISRegistry#23 has shipped. This screen provides direct visibility into + * the HUMANITY_ACCORD 2-of-3 kill-switch family, holder roster, and authority. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun ConstitutionalScreen(onIssueClick: (String) -> Unit = {}) { - ComingSoonPlaceholder( - title = localizedString("commons.federation.constitutional.title").ifEmpty { NavSurface.Constitutional.label }, - icon = NavSurface.Constitutional.icon, - description = localizedString("commons.federation.constitutional.description"), - gate = SubstrateGate.REGISTRY_ACCORD_HOLDER, - onIssueClick = onIssueClick, - ) +fun ConstitutionalScreen( + family: AccordFamilyDto? = null, + holders: List = emptyList(), + holderThreshold: Int = 2, + haltStatus: AccordHaltStatusResponse? = null, + isLoading: Boolean = false, + onRefresh: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, + onOpenAccordCeremony: () -> Unit = {}, + onOpenProvisionHolder: () -> Unit = {}, + onIssueClick: (String) -> Unit = {}, +) { + val scroll = rememberTestableScrollState() + + LaunchedEffect(Unit) { + onRefresh() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(localizedString("commons.federation.constitutional.title").ifEmpty { "Constitutional Standing" }) }, + navigationIcon = { + if (!LocalIsCompactWindow.current) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testableWithHandler("btn_constitutional_back") { onNavigateBack() }, + ) { + Icon( + imageVector = CIRISIcons.arrowBack, + contentDescription = localizedString("mobile.common_back"), + ) + } + } else { + Spacer(Modifier.width(56.dp)) + } + }, + actions = { + IconButton( + onClick = onRefresh, + modifier = Modifier.testableWithHandler("btn_constitutional_refresh") { onRefresh() }, + ) { + Icon( + imageVector = CIRISIcons.refresh, + contentDescription = localizedString("mobile.common_refresh"), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .background(CIRISColors.BackgroundDark) + .padding(padding) + .testable("screen_constitutional"), + ) { + Column( + modifier = Modifier + .widthIn(max = 760.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp) + .verticalScroll(scroll) + .align(Alignment.TopCenter), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + // Header / Hero + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_constitutional_overview"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(12.dp), + border = ButtonDefaults.outlinedButtonBorder, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(CIRISColors.SignetTeal.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CIRISIcons.instructions, + contentDescription = null, + tint = CIRISColors.SignetTeal, + modifier = Modifier.size(24.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = localizedString("commons.federation.constitutional.title"), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = CIRISColors.TextPrimary, + ) + Spacer(Modifier.width(8.dp)) + Surface( + color = if (family != null) CIRISColors.SignetTeal.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.08f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = if (family != null) "LIVE" else "NOT CONFIGURED", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (family != null) CIRISColors.SignetTeal else CIRISColors.TextDim, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + Spacer(Modifier.height(4.dp)) + Text( + text = localizedString("commons.federation.constitutional.description"), + fontSize = 12.sp, + color = CIRISColors.TextSecondary, + ) + } + } + } + + // 2-of-3 Kill-Switch & Roster Card + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_accord_killswitch"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(12.dp), + border = ButtonDefaults.outlinedButtonBorder, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "HUMANITY_ACCORD KILL-SWITCH", + color = CIRISColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + ) + if (haltStatus?.halted == true) { + Surface( + color = CIRISColors.StatusWarn.copy(alpha = 0.18f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = if (haltStatus.record?.invocationId != null) { + "ACTIVE HALT — Node execution halted by invocation ${haltStatus.record.invocationId}" + } else { + "ACTIVE HALT — Node execution is halted" + }, + color = CIRISColors.StatusWarn, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } else if (haltStatus == null) { + // NOT KNOWING IS NOT THE SAME AS BEING SAFE. + // + // `haltStatus` is null on first render and after any + // failed getAccordHaltStatus(). This branch used to + // fall in with "disarmed", so a SAFETY control + // reported a confirmed-safe state at the one moment + // it had no idea — and `isLoading` was threaded in + // and never read. Three states, not two. + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = if (isLoading) { + "Reading kill-switch status…" + } else { + "Kill-switch status UNKNOWN — the node did not answer. This is not a report that it is disarmed." + }, + color = CIRISColors.TextDim, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .testable("txt_killswitch_unknown"), + ) + } + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Killswitch disarmed — All systems nominal", + color = CIRISColors.SignetTeal, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .testable("txt_killswitch_disarmed"), + ) + } + } + Text( + // The live threshold when the node has told us one, + // the design's shape when it has not. A fixed + // "2-of-3" printed over a family with a different + // threshold is a statement about the constitution + // that the constitution does not make. + text = if (holders.isNotEmpty()) { + "A $holderThreshold-of-${holders.size} human kill-switch family: each holder carries a primary SEAT " + + "and a cold SPARE. FIPS YubiKey + ML-DSA hardware custody." + } else { + "A human kill-switch family: each holder carries a primary SEAT and a cold SPARE. " + + "FIPS YubiKey + ML-DSA hardware custody." + }, + color = CIRISColors.TextSecondary, + fontSize = 13.sp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = onOpenAccordCeremony, + modifier = Modifier + .weight(1f) + .testableWithHandler("btn_open_accord_ceremony") { onOpenAccordCeremony() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = localizedString("nav.surface.accord_ceremony"), + fontWeight = FontWeight.SemiBold, + ) + } + OutlinedButton( + onClick = onOpenProvisionHolder, + modifier = Modifier + .weight(1f) + .testableWithHandler("btn_open_provision_holder") { onOpenProvisionHolder() }, + ) { + Text( + text = localizedString("nav.surface.provision_accord_holder"), + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + + // Reserved Prefix Authority Card + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_accord_holders"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(12.dp), + border = ButtonDefaults.outlinedButtonBorder, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "RESERVED PREFIX AUTHORITY", + color = CIRISColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + ) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = if (holders.isNotEmpty()) { + "${holders.size} registered accord holder(s) (${holderThreshold}-of-${holders.size} threshold)" + } else { + "No registered accord holders" + }, + color = CIRISColors.TextPrimary, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + Text( + text = "Only certified accord_holder identities may emit accord:* namespace attestations. All attempts to forge constitutional claims are refused fail-closed by node verification.", + color = CIRISColors.TextSecondary, + fontSize = 13.sp, + ) + } + } + } + } + } } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/DelegationScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/DelegationScreen.kt index 62b8af8..dd9053e 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/DelegationScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/federation/DelegationScreen.kt @@ -1,27 +1,290 @@ package ai.ciris.mobile.shared.ui.screens.federation +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import ai.ciris.mobile.shared.localization.localizedString -import ai.ciris.mobile.shared.ui.components.ComingSoonPlaceholder +import ai.ciris.mobile.shared.models.federation.DelegationDto +import ai.ciris.mobile.shared.platform.rememberTestableScrollState +import ai.ciris.mobile.shared.platform.testable +import ai.ciris.mobile.shared.platform.testableWithHandler +import ai.ciris.mobile.shared.ui.components.CIRISIcons +import ai.ciris.mobile.shared.ui.nav.LocalIsCompactWindow import ai.ciris.mobile.shared.ui.nav.NavSurface -import ai.ciris.mobile.shared.ui.nav.SubstrateGate +import ai.ciris.mobile.shared.ui.theme.CIRISColors /** - * "Delegation" — delegates_to scope graph. Shows what scopes this agent's - * keys have delegated to other parties + the inverse: who has delegated - * scopes to this agent. + * "Delegation" — delegates_to scope graph and authorization roster. * - * Gated on CIRISPersist#104 (delegates_to graph traversal API — one of the - * 4 structural primitives per FSD-002 §2.2.1, persisted in the federation - * directory per §3.3). + * Shows what scopes this agent's keys have delegated to other parties + * + the inverse: who has delegated scopes to this agent. + * + * CIRISPersist#104 has shipped. This screen provides direct visibility into + * active delegation graphs, device authorizations, and trust scopes. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun DelegationScreen(onIssueClick: (String) -> Unit = {}) { - ComingSoonPlaceholder( - title = localizedString("commons.federation.delegation.title").ifEmpty { NavSurface.Delegation.label }, - icon = NavSurface.Delegation.icon, - description = localizedString("commons.federation.delegation.description"), - gate = SubstrateGate.PERSIST_DELEGATES_TO, - onIssueClick = onIssueClick, - ) +fun DelegationScreen( + delegations: List = emptyList(), + isLoading: Boolean = false, + onRefresh: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, + onManageDeviceGrants: () -> Unit = {}, + onIssueClick: (String) -> Unit = {}, +) { + val scroll = rememberTestableScrollState() + + LaunchedEffect(Unit) { + onRefresh() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(localizedString("commons.federation.delegation.title").ifEmpty { "Delegation Graph" }) }, + navigationIcon = { + if (!LocalIsCompactWindow.current) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier.testableWithHandler("btn_delegation_back") { onNavigateBack() }, + ) { + Icon( + imageVector = CIRISIcons.arrowBack, + contentDescription = localizedString("mobile.common_back"), + ) + } + } else { + Spacer(Modifier.width(56.dp)) + } + }, + actions = { + IconButton( + onClick = onRefresh, + modifier = Modifier.testableWithHandler("btn_delegation_refresh") { onRefresh() }, + ) { + Icon( + imageVector = CIRISIcons.refresh, + contentDescription = localizedString("mobile.common_refresh"), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .background(CIRISColors.BackgroundDark) + .padding(padding) + .testable("screen_delegation"), + ) { + Column( + modifier = Modifier + .widthIn(max = 760.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp) + .verticalScroll(scroll) + .align(Alignment.TopCenter), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + // Header / Hero + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_delegation_overview"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(12.dp), + border = ButtonDefaults.outlinedButtonBorder, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(CIRISColors.SignetTeal.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CIRISIcons.send, + contentDescription = null, + tint = CIRISColors.SignetTeal, + modifier = Modifier.size(24.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = localizedString("commons.federation.delegation.title"), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = CIRISColors.TextPrimary, + ) + Spacer(Modifier.width(8.dp)) + Surface( + color = CIRISColors.SignetTeal.copy(alpha = 0.18f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = "LIVE", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = CIRISColors.SignetTeal, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + Spacer(Modifier.height(4.dp)) + Text( + text = localizedString("commons.federation.delegation.description"), + fontSize = 12.sp, + color = CIRISColors.TextSecondary, + ) + } + } + } + + // Inbound Scope Delegations + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_delegation_inbound"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(12.dp), + border = ButtonDefaults.outlinedButtonBorder, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "INBOUND DELEGATIONS", + color = CIRISColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + ) + Text( + text = "Scopes delegated to this agent by peer identities (e.g. replication, consensus voting, device proxying).", + color = CIRISColors.TextSecondary, + fontSize = 13.sp, + ) + // UNAVAILABLE, NOT EMPTY. + // + // This composable receives only DelegationsViewModel's + // OUTBOUND device grants (GET /v1/auth/device/grants) + // and makes no inbound query at all. It first reported + // those outbound grants as inbound authority; the fix + // for that replaced the count with a flat "no active + // inbound delegations", which is the same unsupported + // claim with the sign flipped. The screen cannot see + // this, so it says so. + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Inbound delegations are not readable from this client yet — " + + "this is not a report that there are none.", + color = CIRISColors.TextDim, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .testable("txt_delegation_inbound_unavailable"), + ) + } + } + } + + // Outbound Scope Delegations + Surface( + modifier = Modifier + .fillMaxWidth() + .testable("card_delegation_outbound"), + color = CIRISColors.BackgroundDarker, + shape = RoundedCornerShape(12.dp), + border = ButtonDefaults.outlinedButtonBorder, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "OUTBOUND DELEGATIONS", + color = CIRISColors.TextDim, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.0.sp, + ) + Text( + text = "Scopes this agent has delegated to paired devices and occurrence instances.", + color = CIRISColors.TextSecondary, + fontSize = 13.sp, + ) + if (delegations.isNotEmpty()) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "${delegations.size} active device authorization(s)", + color = CIRISColors.TextPrimary, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + Button( + onClick = onManageDeviceGrants, + modifier = Modifier + .fillMaxWidth() + .testableWithHandler("btn_delegation_manage_grants") { onManageDeviceGrants() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + imageVector = CIRISIcons.keySecure, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = localizedString("nav.surface.delegations"), + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } + } } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/CellVizState.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/CellVizState.kt index a5b4ee8..8071291 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/CellVizState.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/graph/CellVizState.kt @@ -43,6 +43,20 @@ data class CellVizState( * (J = k_eff · (1 − ρ) · λ · σ). */ val localScore: Float? = null, + /** + * WHETHER THESE NUMBERS CAME FROM THE FEDERATION, or from the local + * fallback. + * + * `isPreFetch` says "we have finished trying". It does NOT say we + * succeeded: `refreshCapacity()` clears it in its CATCH branch too, after + * computing a purely local score from service health. Reading `!isPreFetch` + * as "federation detectors are running" therefore reported the coherence + * ratchet, manifold conformity and distributive access detectors as live + * whenever `/v1/my-data/capacity` had just FAILED (CIRISClient#45 review). + * + * Defaults false: nothing is federation-backed until a fetch says so. + */ + val federationDataPresent: Boolean = false, ) { /** * Clamp every factor into `[0, 1]`. Lens returns values in that range, but diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt index bf68f11..476d6eb 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/viewmodels/InteractViewModel.kt @@ -611,6 +611,7 @@ class InteractViewModel( category = data.category, isPreFetch = false, localScore = local, + federationDataPresent = true, ).sanitized() logInfo(method, "Capacity OK: ${data.agentName} ${data.category} " + "composite=${data.compositeScore} local=${data.localScore} cached=${data.cached}") @@ -635,6 +636,10 @@ class InteractViewModel( category = localCat, compositeScore = localScore, // Use local as composite when lens unavailable localScore = localScore, + // EXPLICITLY FALSE, not merely left alone: `copy` would carry a + // previous success forward, so a fetch that starts working and + // then fails would keep claiming federation backing. + federationDataPresent = false, ) logInfo(method, "Fell back to local: score=$localScore category=$localCat") }