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}