Capability Gating

Multi-participant sessions need more than presence — they need policy: who may activate what. Knopper's answer, behind the collaboration feature, is capability gating powered by Schubert calculus: capabilities are Schubert conditions on a Grassmannian, grants position principals, and access checks intersect the geometry.

[dependencies]
knopper = { version = "0.1", features = ["collaboration"] }

The payoff: impossible combinations

A set-membership ACL answers "does the principal hold X?" — and silently allows a principal who holds two capabilities that cannot coexist. Geometry knows better. The worked example from the test suite:

use knopper::capability::{AccessController, Capability, CapabilityKind};
use knopper::collaboration::ParticipantId;

// Gr(2,4): review = σ₂, deploy = σ₁₁ — σ₂·σ₁₁ = 0.
let mut acl = AccessController::new(2, 4)?;
acl.register_capability(Capability::new("review", "Approve", vec![2], CapabilityKind::ReadLike))?;
acl.register_capability(Capability::new("deploy", "Ship", vec![1, 1], CapabilityKind::WriteLike))?;

let alice = acl.create_principal("alice")?;
acl.grant(&alice, "review")?;
acl.grant(&alice, "deploy")?;   // both granted — set-membership would allow

let decision = acl.check(&alice, &["review", "deploy"])?;
// AccessDecision::Impossible { conflicting } — separation of duties
// detected by the algebra, not by a hand-written exclusion rule.

Separation of duties stops being a policy you must remember to write and becomes a geometric fact the checker derives.

CapabilityGate: declare the policy

A gate wraps the controller, maps semantic NodeIds to required capability lists, and is bridged from a collaboration ParticipantId (grant-then-gate and gate-then-grant both work). Only AccessDecision::Granted opens a node; underconstrained policies and controller errors fail closed.

let mut gate = CapabilityGate::new(acl, &ParticipantId::new("alice"))?;
gate.gate_node(deploy_button_id, vec!["review".into(), "deploy".into()]);

gated_scene(&scene) projects not-granted gated nodes as disabled — which means focus collection and activation skip them through the same principled path used for ordinary disabled nodes. No new gating machinery exists inside render or focus.

CapabilityRuntime: enforce at activation

CapabilityRuntime wraps a Runtime (it derefs to the plain runtime) and consults the gate on Activate events. Not-granted activations are suppressed — the machine never sees them — and the most recent decision is available for host-side rendering:

runtime.dispatch(RuntimeEvent::Activate(deploy_button_id));
assert!(matches!(runtime.last_denial(), Some(AccessDecision::Impossible { .. })));

With no gate installed, CapabilityRuntime behaves exactly like the plain runtime. The feature is off by default; the default build pays nothing for it.

Layering

The capability seam honors the geometric substrate's boundary (see The Geometric Substrate): GA3 stays the fingerprint layer; this seam consumes collaboration identity at its edge and runs the higher-grade arithmetic in Schubert/amari territory. Nothing pushes its algebra back into GA3.