Skip to main content

vela_protocol/
access_tier.rs

1//! v0.51: Access tiers — the dual-use deposition channel.
2//!
3//! The Constellations essay's hardest paragraph commits the substrate
4//! to a governed channel for content where readability is itself part
5//! of the harm: gain-of-function trial readouts, model-generated
6//! protein designs in dual-use space, certain synthesis routes for
7//! controlled compounds. Today most scientific repositories are
8//! either fully open or fully closed — not "open by default with a
9//! permissioned tier above it."
10//!
11//! v0.51 ships the structural shape so any future maintainer
12//! consortium can plug in a real DURC review pipeline without
13//! renegotiating the protocol surface.
14//!
15//! Three tiers, ordered by sensitivity:
16//!
17//! - `Public` (default) — open read. The substrate's normal mode.
18//! - `Restricted` — read access requires an `ActorRecord` with
19//!   `access_clearance >= Restricted`. The IBC review level: dual-use
20//!   research that the host institution has declared subject to
21//!   incident-response review but not capability-gated.
22//! - `Classified` — read access requires an `ActorRecord` with
23//!   `access_clearance == Classified`. Aligned with the federal DURC
24//!   framework and the capability gates frontier AI labs already
25//!   publish under their own safety frameworks (Anthropic's
26//!   Responsible Scaling Policy, OpenAI's Preparedness Framework,
27//!   Google DeepMind's Frontier Safety Framework). Content above
28//!   those internal thresholds is excluded from public deposit
29//!   entirely; the substrate's openness default fails closed on
30//!   ambiguous cases, with the operational cost borne by depositors.
31//!
32//! The composition risk — capability uplift from aggregation across
33//! the dependency graph rather than any single deposit — is the
34//! harder problem and v0.51 does not claim to solve it. Treating it
35//! as solved would be the wrong move. v0.51 carries the
36//! per-object tier; the composition graph is a follow-up.
37
38use serde::{Deserialize, Serialize};
39
40/// Access tier — the read-side gate on a single kernel object.
41///
42/// Ordering: `Public < Restricted < Classified`. An actor with
43/// clearance `T` can read every object with tier `<= T`. Pre-v0.51
44/// actors and objects load with `Public` and behave exactly as
45/// before — the tier system is purely additive.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AccessTier {
49    #[default]
50    Public,
51    Restricted,
52    Classified,
53}
54
55impl AccessTier {
56    /// Stable canonical string. Used in event payloads, schema
57    /// validation, and CLI argument parsing.
58    pub fn canonical(&self) -> &'static str {
59        match self {
60            AccessTier::Public => "public",
61            AccessTier::Restricted => "restricted",
62            AccessTier::Classified => "classified",
63        }
64    }
65
66    /// Parse the canonical string. Unknown values are rejected
67    /// loudly; a typo'd `"restrictd"` must not silently fall back to
68    /// `Public`.
69    pub fn parse(s: &str) -> Result<Self, String> {
70        match s {
71            "public" => Ok(AccessTier::Public),
72            "restricted" => Ok(AccessTier::Restricted),
73            "classified" => Ok(AccessTier::Classified),
74            other => Err(format!(
75                "unknown access tier '{other}'; valid: public, restricted, classified"
76            )),
77        }
78    }
79}
80
81/// Whether an actor with the given clearance is permitted to read an
82/// object with the given tier. The check is `tier <= clearance`.
83/// Anonymous reads (clearance `None`) are equivalent to clearance
84/// `Some(Public)` — they may read public-tier objects only.
85pub fn actor_may_read(tier: AccessTier, clearance: Option<AccessTier>) -> bool {
86    let effective = clearance.unwrap_or(AccessTier::Public);
87    tier <= effective
88}
89
90/// Apply the read gate to a `Project`, producing a redacted clone
91/// containing only the kernel objects readable under the requesting
92/// actor's `clearance`. Used by `serve.rs` MCP/HTTP handlers and any
93/// external client that wants to surface "what would actor X see?"
94///
95/// Doctrine:
96/// - Objects above the actor's clearance are removed from the
97///   collection entirely. Their existence is not revealed.
98/// - The `stats` summary (e.g. `findings: usize`) is left as-is —
99///   downstream callers must recompute stats off the redacted
100///   collection if they want the redacted counts. The substrate
101///   surfaces the unredacted aggregate so the existence of
102///   restricted material remains accountable in the abstract; the
103///   *content* is what the tier protects.
104/// - Events targeting objects the actor cannot see are also dropped.
105///   The audit trail for restricted material is itself restricted.
106pub fn redact_for_actor(
107    project: &crate::project::Project,
108    clearance: Option<AccessTier>,
109) -> crate::project::Project {
110    let findings: Vec<_> = project
111        .findings
112        .iter()
113        .filter(|f| actor_may_read(f.access_tier, clearance))
114        .cloned()
115        .collect();
116    let visible_finding_ids: std::collections::BTreeSet<&str> =
117        findings.iter().map(|f| f.id.as_str()).collect();
118
119    let negative_results: Vec<_> = project
120        .negative_results
121        .iter()
122        .filter(|n| actor_may_read(n.access_tier, clearance))
123        .cloned()
124        .collect();
125    let visible_nr_ids: std::collections::BTreeSet<&str> =
126        negative_results.iter().map(|n| n.id.as_str()).collect();
127
128    let trajectories: Vec<_> = project
129        .trajectories
130        .iter()
131        .filter(|t| actor_may_read(t.access_tier, clearance))
132        .cloned()
133        .collect();
134    let visible_traj_ids: std::collections::BTreeSet<&str> =
135        trajectories.iter().map(|t| t.id.as_str()).collect();
136
137    let artifacts: Vec<_> = project
138        .artifacts
139        .iter()
140        .filter(|a| actor_may_read(a.access_tier, clearance))
141        .cloned()
142        .collect();
143    let visible_artifact_ids: std::collections::BTreeSet<&str> =
144        artifacts.iter().map(|a| a.id.as_str()).collect();
145
146    let events: Vec<_> = project
147        .events
148        .iter()
149        .filter(|e| match e.target.r#type.as_str() {
150            "finding" => visible_finding_ids.contains(e.target.id.as_str()),
151            "negative_result" => visible_nr_ids.contains(e.target.id.as_str()),
152            "trajectory" => visible_traj_ids.contains(e.target.id.as_str()),
153            "artifact" => visible_artifact_ids.contains(e.target.id.as_str()),
154            _ => true, // frontier-level events (frontier.created, etc.) stay visible
155        })
156        .cloned()
157        .collect();
158
159    crate::project::Project {
160        findings,
161        negative_results,
162        trajectories,
163        artifacts,
164        events,
165        // Everything else passes through. Sources, evidence atoms,
166        // condition records, signatures, and actors aren't tiered in
167        // v0.51 — the tiering is on the load-bearing claim objects.
168        // v0.51.x can extend if a downstream auditor needs it.
169        ..clone_project_metadata(project)
170    }
171}
172
173/// Helper that clones the non-tiered fields of a Project for the
174/// `redact_for_actor` rebuild. Kept private to this module so the
175/// redaction is the only path that splits the Project struct.
176fn clone_project_metadata(p: &crate::project::Project) -> crate::project::Project {
177    crate::project::Project {
178        vela_version: p.vela_version.clone(),
179        schema: p.schema.clone(),
180        frontier_id: p.frontier_id.clone(),
181        project: crate::project::ProjectMeta {
182            name: p.project.name.clone(),
183            description: p.project.description.clone(),
184            compiled_at: p.project.compiled_at.clone(),
185            compiler: p.project.compiler.clone(),
186            papers_processed: p.project.papers_processed,
187            errors: p.project.errors,
188            dependencies: p.project.dependencies.clone(),
189        },
190        stats: serde_json::from_value(serde_json::to_value(&p.stats).unwrap_or_default())
191            .unwrap_or_default(),
192        findings: Vec::new(),
193        sources: p.sources.clone(),
194        evidence_atoms: p.evidence_atoms.clone(),
195        condition_records: p.condition_records.clone(),
196        review_events: p.review_events.clone(),
197        confidence_updates: p.confidence_updates.clone(),
198        events: Vec::new(),
199        proposals: p.proposals.clone(),
200        proof_state: p.proof_state.clone(),
201        signatures: p.signatures.clone(),
202        actors: p.actors.clone(),
203        replications: p.replications.clone(),
204        datasets: p.datasets.clone(),
205        code_artifacts: p.code_artifacts.clone(),
206        artifacts: Vec::new(),
207        predictions: p.predictions.clone(),
208        resolutions: p.resolutions.clone(),
209        peers: p.peers.clone(),
210        negative_results: Vec::new(),
211        trajectories: Vec::new(),
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn ordering_is_public_lt_restricted_lt_classified() {
221        assert!(AccessTier::Public < AccessTier::Restricted);
222        assert!(AccessTier::Restricted < AccessTier::Classified);
223    }
224
225    #[test]
226    fn anonymous_reader_sees_only_public() {
227        assert!(actor_may_read(AccessTier::Public, None));
228        assert!(!actor_may_read(AccessTier::Restricted, None));
229        assert!(!actor_may_read(AccessTier::Classified, None));
230    }
231
232    #[test]
233    fn restricted_clearance_excludes_classified() {
234        assert!(actor_may_read(
235            AccessTier::Public,
236            Some(AccessTier::Restricted)
237        ));
238        assert!(actor_may_read(
239            AccessTier::Restricted,
240            Some(AccessTier::Restricted)
241        ));
242        assert!(!actor_may_read(
243            AccessTier::Classified,
244            Some(AccessTier::Restricted)
245        ));
246    }
247
248    #[test]
249    fn classified_clearance_reads_everything() {
250        assert!(actor_may_read(
251            AccessTier::Public,
252            Some(AccessTier::Classified)
253        ));
254        assert!(actor_may_read(
255            AccessTier::Restricted,
256            Some(AccessTier::Classified)
257        ));
258        assert!(actor_may_read(
259            AccessTier::Classified,
260            Some(AccessTier::Classified)
261        ));
262    }
263
264    #[test]
265    fn parse_round_trips_canonical() {
266        for tier in [
267            AccessTier::Public,
268            AccessTier::Restricted,
269            AccessTier::Classified,
270        ] {
271            assert_eq!(AccessTier::parse(tier.canonical()).unwrap(), tier);
272        }
273    }
274
275    #[test]
276    fn parse_rejects_unknown() {
277        assert!(AccessTier::parse("restrictd").is_err());
278        assert!(AccessTier::parse("").is_err());
279        assert!(AccessTier::parse("CLASSIFIED").is_err());
280    }
281}