Skip to main content

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}