Skip to main content

heddle_object_model/object/
visibility_tier.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared audience-tier vocabulary.
3//!
4//! [`VisibilityTier`] is the single content-side visibility vocabulary used
5//! across annotations, discussions, and per-state commit visibility. The
6//! *reader's* tier (who is asking) is [`AudienceTier`]; this enum is the
7//! *content's* tier (who the content is for). The who-sees-what mapping
8//! between the two lives in [`visible`](super::visible), beside both
9//! vocabularies.
10//!
11//! `Public` is the default — it matches the pre-unification behavior where
12//! every annotation was effectively public, so legacy data on disk decodes
13//! unchanged.
14
15use serde::{Deserialize, Serialize};
16
17/// Content-side visibility tier. Shared by annotations, discussions, and
18/// states so the per-commit visibility tiers and annotation/discussion
19/// visibility draw from one vocabulary rather than parallel enums.
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub enum VisibilityTier {
22    #[default]
23    Public,
24    Internal,
25    TeamScoped {
26        team_id: String,
27    },
28    Restricted {
29        scope_label: String,
30    },
31    /// The strictest tier: withheld from **every** audience — including the
32    /// otherwise all-seeing `Internal` audience — except the one holder of
33    /// the matching `Restricted(scope_label)`. Used for embargoed per-state
34    /// commit visibility, where even internal callers must not see the
35    /// content. The who-sees-what arm lives in `visible`, placed above the
36    /// `(_, Internal) => true` arm so the embargo holds.
37    Private {
38        scope_label: String,
39    },
40}
41
42impl VisibilityTier {
43    /// Whether every reader of this tier could also read the source tier.
44    /// Labels are separate audiences, so a higher rank alone cannot prove
45    /// narrowing. This follows the audience sets defined by `visible`.
46    pub fn is_no_more_visible_than(&self, source: &Self) -> bool {
47        self == source
48            || matches!(source, Self::Public)
49            || matches!((self, source), (Self::TeamScoped { .. }, Self::Internal))
50            || matches!(
51                (self, source),
52                (Self::Private { scope_label: target }, Self::Restricted { scope_label: source })
53                    if target == source
54            )
55    }
56
57    /// A hidden embargo blocks descendants, even when a descendant's own tier
58    /// is visible. Internal and TeamScoped restrict only their own state.
59    pub fn is_embargo(&self) -> bool {
60        matches!(self, Self::Private { .. } | Self::Restricted { .. })
61    }
62
63    /// Stable wire/storage token for the tier discriminant. The labelled
64    /// variants collapse to their kind name here; the label travels in a
65    /// separate field. Shared by the discussion RPC vocabulary and the
66    /// state-visibility signing payload, so it must stay stable.
67    pub fn as_str(&self) -> &'static str {
68        match self {
69            Self::Public => "public",
70            Self::Internal => "internal",
71            Self::TeamScoped { .. } => "team_scoped",
72            Self::Restricted { .. } => "restricted",
73            Self::Private { .. } => "private",
74        }
75    }
76
77    /// Restrictiveness ordering used by the `visibility promote` monotonicity
78    /// check (heddle#317). **Lower rank = LESS restrictive** (the tier reaches a
79    /// broader audience):
80    ///
81    /// | tier         | rank | audience reach                              |
82    /// |--------------|------|---------------------------------------------|
83    /// | `Public`     | 0    | every audience (least restrictive)          |
84    /// | `Internal`   | 1    | the workspace-internal set (+ every team)   |
85    /// | `TeamScoped` | 2    | one named team                              |
86    /// | `Restricted` | 3    | one named scope label                        |
87    /// | `Private`    | 4    | only the matching scope holder (most restrictive, even `Internal` is excluded) |
88    ///
89    /// This is the *defined* total order for "less restrictive", consistent with
90    /// spike #266 §5.2 (`Internal` content is one of the least-restrictive
91    /// values; `Private` the most — it is the embargo tier that withholds from
92    /// every audience including `Internal`). The labelled variants compare by
93    /// rank only — a lateral move between two teams / two scope labels is the
94    /// **same** rank, hence not *strictly* less restrictive, and must go through
95    /// `set` rather than `promote`.
96    pub fn restrictiveness_rank(&self) -> u8 {
97        match self {
98            Self::Public => 0,
99            Self::Internal => 1,
100            Self::TeamScoped { .. } => 2,
101            Self::Restricted { .. } => 3,
102            Self::Private { .. } => 4,
103        }
104    }
105
106    /// `true` iff `self` is **strictly** less restrictive than `other` — i.e. a
107    /// `promote` from `other` to `self` is a valid opening transition. A
108    /// narrowing (`self` more restrictive) or lateral (equal rank, including a
109    /// different team/scope label at the same rank) change returns `false` and
110    /// must be expressed with `set`. See [`restrictiveness_rank`](Self::restrictiveness_rank).
111    pub fn is_strictly_less_restrictive_than(&self, other: &Self) -> bool {
112        self.restrictiveness_rank() < other.restrictiveness_rank()
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn narrowing_matches_reader_sets_including_distinct_labels() {
122        use crate::object::{AudienceTier, visible};
123        let tiers = [
124            VisibilityTier::Public,
125            VisibilityTier::Internal,
126            team("a"),
127            team("b"),
128            restricted("a"),
129            restricted("b"),
130            VisibilityTier::Private {
131                scope_label: "a".into(),
132            },
133            VisibilityTier::Private {
134                scope_label: "b".into(),
135            },
136        ];
137        let readers = [
138            AudienceTier::Public,
139            AudienceTier::Internal,
140            AudienceTier::Team("a".into()),
141            AudienceTier::Team("b".into()),
142            AudienceTier::Team("other".into()),
143            AudienceTier::Restricted("a".into()),
144            AudienceTier::Restricted("b".into()),
145            AudienceTier::Restricted("other".into()),
146        ];
147        for source in &tiers {
148            for target in &tiers {
149                let subset = readers
150                    .iter()
151                    .all(|reader| !visible(target, reader) || visible(source, reader));
152                assert_eq!(
153                    target.is_no_more_visible_than(source),
154                    subset,
155                    "{source:?} -> {target:?}"
156                );
157            }
158        }
159    }
160
161    fn team(id: &str) -> VisibilityTier {
162        VisibilityTier::TeamScoped { team_id: id.into() }
163    }
164    fn restricted(label: &str) -> VisibilityTier {
165        VisibilityTier::Restricted {
166            scope_label: label.into(),
167        }
168    }
169
170    #[test]
171    fn restrictiveness_rank_orders_public_least_restricted_most() {
172        assert!(
173            VisibilityTier::Public.restrictiveness_rank()
174                < VisibilityTier::Internal.restrictiveness_rank()
175        );
176        assert!(VisibilityTier::Internal.restrictiveness_rank() < team("a").restrictiveness_rank());
177        assert!(team("a").restrictiveness_rank() < restricted("legal").restrictiveness_rank());
178    }
179
180    #[test]
181    fn strictly_less_restrictive_only_when_rank_drops() {
182        // Opening transitions (lower rank) are strictly less restrictive.
183        assert!(
184            VisibilityTier::Public.is_strictly_less_restrictive_than(&VisibilityTier::Internal)
185        );
186        assert!(VisibilityTier::Internal.is_strictly_less_restrictive_than(&restricted("legal")));
187        assert!(VisibilityTier::Internal.is_strictly_less_restrictive_than(&team("infra")));
188
189        // Narrowing transitions (higher rank) are NOT.
190        assert!(!restricted("legal").is_strictly_less_restrictive_than(&VisibilityTier::Internal));
191        assert!(
192            !VisibilityTier::Internal.is_strictly_less_restrictive_than(&VisibilityTier::Public)
193        );
194
195        // Lateral (same rank) is NOT strictly less restrictive — even across
196        // different team/scope labels. A re-scope must go through `set`.
197        assert!(!team("a").is_strictly_less_restrictive_than(&team("b")));
198        assert!(!restricted("legal").is_strictly_less_restrictive_than(&restricted("security")));
199        assert!(
200            !VisibilityTier::Internal.is_strictly_less_restrictive_than(&VisibilityTier::Internal)
201        );
202    }
203}