objects/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 `repo::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 `repo::visibility::visible`.
9//!
10//! `Public` is the default — it matches the pre-unification behavior where
11//! every annotation was effectively public, so legacy data on disk decodes
12//! unchanged.
13
14use serde::{Deserialize, Serialize};
15
16/// Content-side visibility tier. Shared by annotations, discussions, and
17/// states so the per-commit visibility tiers and annotation/discussion
18/// visibility draw from one vocabulary rather than parallel enums.
19#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub enum VisibilityTier {
21 #[default]
22 Public,
23 Internal,
24 TeamScoped {
25 team_id: String,
26 },
27 Restricted {
28 scope_label: String,
29 },
30 /// The strictest tier: withheld from **every** audience — including the
31 /// otherwise all-seeing `Internal` audience — except the one holder of
32 /// the matching `Restricted(scope_label)`. Used for embargoed per-state
33 /// commit visibility, where even internal callers must not see the
34 /// content. The who-sees-what arm lives in `repo::visibility::visible`,
35 /// placed above the `(_, Internal) => true` arm so the embargo holds.
36 Private {
37 scope_label: String,
38 },
39}
40
41impl VisibilityTier {
42 /// Stable wire/storage token for the tier discriminant. The labelled
43 /// variants collapse to their kind name here; the label travels in a
44 /// separate field. Shared by the discussion RPC vocabulary and the
45 /// state-visibility signing payload, so it must stay stable.
46 pub fn as_str(&self) -> &'static str {
47 match self {
48 Self::Public => "public",
49 Self::Internal => "internal",
50 Self::TeamScoped { .. } => "team_scoped",
51 Self::Restricted { .. } => "restricted",
52 Self::Private { .. } => "private",
53 }
54 }
55
56 /// Restrictiveness ordering used by the `visibility promote` monotonicity
57 /// check (heddle#317). **Lower rank = LESS restrictive** (the tier reaches a
58 /// broader audience):
59 ///
60 /// | tier | rank | audience reach |
61 /// |--------------|------|---------------------------------------------|
62 /// | `Public` | 0 | every audience (least restrictive) |
63 /// | `Internal` | 1 | the workspace-internal set (+ every team) |
64 /// | `TeamScoped` | 2 | one named team |
65 /// | `Restricted` | 3 | one named scope label |
66 /// | `Private` | 4 | only the matching scope holder (most restrictive, even `Internal` is excluded) |
67 ///
68 /// This is the *defined* total order for "less restrictive", consistent with
69 /// spike #266 §5.2 (`Internal` content is one of the least-restrictive
70 /// values; `Private` the most — it is the embargo tier that withholds from
71 /// every audience including `Internal`). The labelled variants compare by
72 /// rank only — a lateral move between two teams / two scope labels is the
73 /// **same** rank, hence not *strictly* less restrictive, and must go through
74 /// `set` rather than `promote`.
75 pub fn restrictiveness_rank(&self) -> u8 {
76 match self {
77 Self::Public => 0,
78 Self::Internal => 1,
79 Self::TeamScoped { .. } => 2,
80 Self::Restricted { .. } => 3,
81 Self::Private { .. } => 4,
82 }
83 }
84
85 /// `true` iff `self` is **strictly** less restrictive than `other` — i.e. a
86 /// `promote` from `other` to `self` is a valid opening transition. A
87 /// narrowing (`self` more restrictive) or lateral (equal rank, including a
88 /// different team/scope label at the same rank) change returns `false` and
89 /// must be expressed with `set`. See [`restrictiveness_rank`](Self::restrictiveness_rank).
90 pub fn is_strictly_less_restrictive_than(&self, other: &Self) -> bool {
91 self.restrictiveness_rank() < other.restrictiveness_rank()
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 fn team(id: &str) -> VisibilityTier {
100 VisibilityTier::TeamScoped { team_id: id.into() }
101 }
102 fn restricted(label: &str) -> VisibilityTier {
103 VisibilityTier::Restricted {
104 scope_label: label.into(),
105 }
106 }
107
108 #[test]
109 fn restrictiveness_rank_orders_public_least_restricted_most() {
110 assert!(
111 VisibilityTier::Public.restrictiveness_rank()
112 < VisibilityTier::Internal.restrictiveness_rank()
113 );
114 assert!(VisibilityTier::Internal.restrictiveness_rank() < team("a").restrictiveness_rank());
115 assert!(team("a").restrictiveness_rank() < restricted("legal").restrictiveness_rank());
116 }
117
118 #[test]
119 fn strictly_less_restrictive_only_when_rank_drops() {
120 // Opening transitions (lower rank) are strictly less restrictive.
121 assert!(
122 VisibilityTier::Public.is_strictly_less_restrictive_than(&VisibilityTier::Internal)
123 );
124 assert!(VisibilityTier::Internal.is_strictly_less_restrictive_than(&restricted("legal")));
125 assert!(VisibilityTier::Internal.is_strictly_less_restrictive_than(&team("infra")));
126
127 // Narrowing transitions (higher rank) are NOT.
128 assert!(!restricted("legal").is_strictly_less_restrictive_than(&VisibilityTier::Internal));
129 assert!(
130 !VisibilityTier::Internal.is_strictly_less_restrictive_than(&VisibilityTier::Public)
131 );
132
133 // Lateral (same rank) is NOT strictly less restrictive — even across
134 // different team/scope labels. A re-scope must go through `set`.
135 assert!(!team("a").is_strictly_less_restrictive_than(&team("b")));
136 assert!(!restricted("legal").is_strictly_less_restrictive_than(&restricted("security")));
137 assert!(
138 !VisibilityTier::Internal.is_strictly_less_restrictive_than(&VisibilityTier::Internal)
139 );
140 }
141}