diff --git a/Cargo.lock b/Cargo.lock
index d501a491a15fc11a50fa5e164ecc80784c608168..57e17cf3434721caf418c6bbd750ce4fd260df31 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -211,6 +211,10 @@ dependencies = [
  "rlimit",
  "serde",
  "tokio",
+ "tor-checkable",
+ "tor-dirmgr",
+ "tor-error",
+ "tor-netdoc",
  "tor-rtcompat",
  "tracing",
  "tracing-appender",
@@ -3515,6 +3519,8 @@ dependencies = [
  "tor-error",
  "tor-llcrypto",
  "tor-protover",
+ "visibility",
+ "visible",
  "weak-table",
 ]
 
@@ -3866,6 +3872,27 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "visibility"
+version = "0.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8881d5cc0ae34e3db2f1de5af81e5117a420d2f937506c2dc20d6f4cfb069051"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "visible"
+version = "0.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a044005fd5c0fc1ebd79c622e5606431c6b879a6a19acafb754be9926a2de73e"
+dependencies = [
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "void"
 version = "1.0.2"
diff --git a/crates/arti-testing/Cargo.toml b/crates/arti-testing/Cargo.toml
index d6427fbe56e45adb3138aa740580cbc1cdf118bf..bc78a082342f0b381e7667cfc2a2260a5f1711c0 100644
--- a/crates/arti-testing/Cargo.toml
+++ b/crates/arti-testing/Cargo.toml
@@ -15,8 +15,19 @@ publish = false
 
 [dependencies]
 arti = { package = "arti", path = "../arti", version = "0.1.0" }
-arti-client = { package = "arti-client", path = "../arti-client", version = "0.1.0" }
+arti-client = { package = "arti-client", path = "../arti-client", version = "0.1.0", features = [
+    "dirfilter",
+] }
+tor-dirmgr = { package = "tor-dirmgr", path = "../tor-dirmgr", version = "0.1.0", features = [
+    "dirfilter",
+] }
+tor-netdoc = { package = "tor-netdoc", path = "../tor-netdoc", version = "0.1.0", features = [
+    "experimental-api",
+    "dangerous-expose-struct-fields",
+] }
+tor-checkable = { path = "../tor-checkable", version = "0.1.0", features = ["experimental-api"] }
 tor-rtcompat = { path = "../tor-rtcompat", version = "0.1.0" }
+tor-error = { path = "../tor-error", version = "0.1.0" }
 arti-config = { path = "../arti-config", version = "0.1.0" }
 
 anyhow = "1.0.23"
diff --git a/crates/arti-testing/src/config.rs b/crates/arti-testing/src/config.rs
index 2d15903ff2b154cf1714384a2a57ca6ee2533ee6..6f215b3932b77061bd0de6d4248cdc3fdc9f255b 100644
--- a/crates/arti-testing/src/config.rs
+++ b/crates/arti-testing/src/config.rs
@@ -91,6 +91,13 @@ pub(crate) fn parse_cmdline() -> Result<Job> {
                 .value_name("SECS")
                 .global(true),
         )
+        .arg(
+            Arg::with_name("dir-filter")
+                .long("dir-filter")
+                .takes_value(true)
+                .value_name("FILTER_NAME")
+                .global(true),
+        )
         .subcommand(
             SubCommand::with_name("connect")
                 .about("Try to bootstrap and connect to an address")
@@ -169,6 +176,12 @@ pub(crate) fn parse_cmdline() -> Result<Job> {
         }
     };
 
+    let dir_filter = matches
+        .value_of("dir-filter")
+        .map(crate::dirfilter::new_filter)
+        .transpose()?
+        .unwrap_or_else(crate::dirfilter::nil_filter);
+
     let action = if let Some(_m) = matches.subcommand_matches("bootstrap") {
         Action::Bootstrap
     } else if let Some(matches) = matches.subcommand_matches("connect") {
@@ -191,6 +204,7 @@ pub(crate) fn parse_cmdline() -> Result<Job> {
         config,
         timeout,
         tcp_breakage,
+        dir_filter,
         console_log,
         expectation,
     })
diff --git a/crates/arti-testing/src/dirfilter.rs b/crates/arti-testing/src/dirfilter.rs
new file mode 100644
index 0000000000000000000000000000000000000000..7df590ee382dbc705957c23e990826d64c5b8b7c
--- /dev/null
+++ b/crates/arti-testing/src/dirfilter.rs
@@ -0,0 +1,193 @@
+//! Support for modifying directories in various ways in order to cause
+//! different kinds of network failure.
+
+use anyhow::{anyhow, Result};
+use rand::Rng;
+use std::sync::{Arc, Mutex};
+use tor_dirmgr::filter::DirFilter;
+use tor_netdoc::{
+    doc::{
+        microdesc::Microdesc,
+        netstatus::{RouterStatus, UncheckedMdConsensus},
+    },
+    types::{family::RelayFamily, policy::PortPolicy},
+};
+
+/// Return a new directory filter as configured by a specified string.
+pub(crate) fn new_filter(s: &str) -> Result<Arc<dyn DirFilter + 'static>> {
+    Ok(match s {
+        "replace-onion-keys" => Arc::new(ReplaceOnionKeysFilter::default()),
+        "one-big-family" => Arc::new(OneBigFamilyFilter::default()),
+        "no-exit-ports" => Arc::new(NoExitPortsFilter::default()),
+        "bad-signatures" => Arc::new(BadSignaturesFilter::default()),
+        "non-existent-signing-keys" => Arc::new(NonexistentSigningKeysFilter::default()),
+        "bad-microdesc-digests" => Arc::new(BadMicrodescDigestsFilter::default()),
+        _ => {
+            return Err(anyhow!(
+                "Unrecognized filter. Options are: 
+    replace-onion-keys, one-big-family, no-exit-ports, bad-signatures,
+    non-existent-signing-keys, bad-microdesc-digests."
+            ));
+        }
+    })
+}
+
+/// A filter that doesn't do anything.
+///
+/// We define this so we can set a filter unconditionally and simplify our code a
+/// little.
+#[derive(Debug)]
+struct NilFilter;
+impl DirFilter for NilFilter {}
+
+/// Return a filter that doesn't do anything.
+pub(crate) fn nil_filter() -> Arc<dyn DirFilter + 'static> {
+    Arc::new(NilFilter)
+}
+
+/// A filter to replace onion keys with junk.
+///
+/// Doing this means that all CREATE2 attempts via ntor will fail.  (If any were
+/// to succeed, they'd fail when they try to extend.)
+#[derive(Debug, Default)]
+struct ReplaceOnionKeysFilter;
+
+impl DirFilter for ReplaceOnionKeysFilter {
+    fn filter_md(&self, mut md: Microdesc) -> tor_dirmgr::Result<Microdesc> {
+        let junk_key: [u8; 32] = rand::thread_rng().gen();
+        md.ntor_onion_key = junk_key.into();
+        Ok(md)
+    }
+}
+
+/// A filter to put all relays into a family with one another.
+///
+/// This filter will prevent the client from generating any mult-hop circuits,
+/// since they'll all violate our path constraints.
+#[derive(Debug, Default)]
+struct OneBigFamilyFilter {
+    /// The family we're going to put all the microdescs into.  We set this to
+    /// contain all the identities, every time we load a consensus.
+    ///
+    /// (This filter won't do a very good job of ensuring consistency between
+    /// this family and the MDs we attach it to, but that's okay for the kind of
+    /// testing we want to do.)
+    new_family: Mutex<Arc<RelayFamily>>,
+}
+
+impl DirFilter for OneBigFamilyFilter {
+    fn filter_consensus(
+        &self,
+        consensus: UncheckedMdConsensus,
+    ) -> tor_dirmgr::Result<UncheckedMdConsensus> {
+        let mut new_family = RelayFamily::new();
+        for r in consensus.dangerously_peek().consensus.relays() {
+            new_family.push(*r.rsa_identity());
+        }
+
+        *self.new_family.lock().expect("poisoned lock") = Arc::new(new_family);
+
+        Ok(consensus)
+    }
+
+    fn filter_md(&self, mut md: Microdesc) -> tor_dirmgr::Result<Microdesc> {
+        let big_family = self.new_family.lock().expect("poisoned lock").clone();
+        md.family = big_family;
+        Ok(md)
+    }
+}
+
+/// A filter to remove all exit policies.
+///
+/// With this change, any attempt to build a circuit connecting for to an
+/// address will fail, since no exit will appear to support it.
+#[derive(Debug)]
+struct NoExitPortsFilter {
+    /// A "reject all ports" policy.
+    reject_all: Arc<PortPolicy>,
+}
+
+impl Default for NoExitPortsFilter {
+    fn default() -> Self {
+        Self {
+            reject_all: Arc::new(PortPolicy::new_reject_all()),
+        }
+    }
+}
+
+impl DirFilter for NoExitPortsFilter {
+    fn filter_md(&self, mut md: Microdesc) -> tor_dirmgr::Result<Microdesc> {
+        md.ipv4_policy = self.reject_all.clone();
+        md.ipv6_policy = self.reject_all.clone();
+        Ok(md)
+    }
+}
+
+/// A filter to replace the signatures on a consensus with invalid ones.
+///
+/// This change will cause directory validation to fail: we'll get good
+/// certificates and discover that our directory is invalid.
+#[derive(Debug, Default)]
+struct BadSignaturesFilter;
+
+impl DirFilter for BadSignaturesFilter {
+    fn filter_consensus(
+        &self,
+        consensus: UncheckedMdConsensus,
+    ) -> tor_dirmgr::Result<UncheckedMdConsensus> {
+        let (mut consensus, time_bounds) = consensus.dangerously_into_parts();
+
+        // We retain the signatures, but change the declared digest of the
+        // document. This will make all the signatures invalid.
+        consensus.siggroup.sha1 = Some(*b"can you reverse sha1");
+        consensus.siggroup.sha256 = Some(*b"sha256 preimage is harder so far");
+
+        Ok(UncheckedMdConsensus::new(consensus, time_bounds))
+    }
+}
+
+/// A filter that (nastily) claims all the authorities have changed their
+/// signing keys.
+///
+/// This change will make us go looking for a set of certificates that don't
+/// exist so that we can verify the consensus.
+#[derive(Debug, Default)]
+struct NonexistentSigningKeysFilter;
+
+impl DirFilter for NonexistentSigningKeysFilter {
+    fn filter_consensus(
+        &self,
+        consensus: UncheckedMdConsensus,
+    ) -> tor_dirmgr::Result<UncheckedMdConsensus> {
+        let (mut consensus, time_bounds) = consensus.dangerously_into_parts();
+        let mut rng = rand::thread_rng();
+        for signature in consensus.siggroup.signatures.iter_mut() {
+            let sk_fingerprint: [u8; 20] = rng.gen();
+            signature.key_ids.sk_fingerprint = sk_fingerprint.into();
+        }
+
+        Ok(UncheckedMdConsensus::new(consensus, time_bounds))
+    }
+}
+
+/// A filter that replaces all the microdesc digests with ones that don't exist.
+///
+/// This filter will let us validate the consensus, but we'll look forever for
+/// valid the microdescriptors it claims are present.
+#[derive(Debug, Default)]
+struct BadMicrodescDigestsFilter;
+
+impl DirFilter for BadMicrodescDigestsFilter {
+    fn filter_consensus(
+        &self,
+        consensus: UncheckedMdConsensus,
+    ) -> tor_dirmgr::Result<UncheckedMdConsensus> {
+        let (mut consensus, time_bounds) = consensus.dangerously_into_parts();
+        let mut rng = rand::thread_rng();
+        for rs in consensus.consensus.relays.iter_mut() {
+            rs.rs.doc_digest = rng.gen();
+        }
+
+        Ok(UncheckedMdConsensus::new(consensus, time_bounds))
+    }
+}
diff --git a/crates/arti-testing/src/main.rs b/crates/arti-testing/src/main.rs
index f12e084e19c960c68b0b3f373562f289f34b7dc4..c43060eb9b7ad66675b963db7e2936395fbf56b4 100644
--- a/crates/arti-testing/src/main.rs
+++ b/crates/arti-testing/src/main.rs
@@ -80,6 +80,7 @@
 #![allow(clippy::print_stdout)] // Allowed in this crate only.
 
 mod config;
+mod dirfilter;
 mod rt;
 mod traces;
 
@@ -87,6 +88,7 @@ use arti::ArtiConfig;
 use arti_client::TorClient;
 use futures::task::SpawnExt;
 use rt::badtcp::BrokenTcpProvider;
+use tor_dirmgr::filter::DirFilter;
 use tor_rtcompat::{PreferredRuntime, Runtime, SleepProviderExt};
 
 use anyhow::{anyhow, Result};
@@ -201,6 +203,9 @@ struct Job {
     /// Describes how (if at all) to break the TCP connections.
     tcp_breakage: TcpBreakage,
 
+    /// Describes how (if at all) to mess with directories.
+    dir_filter: Arc<dyn DirFilter + 'static>,
+
     /// The tracing configuration for our console log.
     console_log: String,
 
@@ -220,6 +225,7 @@ impl Job {
         let config: ArtiConfig = self.config.load()?.try_into()?;
         let client = TorClient::with_runtime(runtime)
             .config(config.tor_client_config()?)
+            .dirfilter(self.dir_filter.clone())
             .create_unbootstrapped()?;
         Ok(client)
     }
diff --git a/crates/tor-checkable/Cargo.toml b/crates/tor-checkable/Cargo.toml
index e95e07865c04b2792f4cf77baa56249737d455e5..949cbc3119cc84cdc0b4a66472400f2a0e337d88 100644
--- a/crates/tor-checkable/Cargo.toml
+++ b/crates/tor-checkable/Cargo.toml
@@ -6,14 +6,15 @@ edition = "2018"
 license = "MIT OR Apache-2.0"
 homepage = "https://gitlab.torproject.org/tpo/core/arti/-/wikis/home"
 description = "Types to ensure that signed or time-bound data is validated before use"
-keywords = [ "tor", "arti", "typestate" ]
-categories = [ "cryptography", "rust-patterns" ]
-repository="https://gitlab.torproject.org/tpo/core/arti.git/"
+keywords = ["tor", "arti", "typestate"]
+categories = ["cryptography", "rust-patterns"]
+repository = "https://gitlab.torproject.org/tpo/core/arti.git/"
+
+[features]
+experimental-api = []
 
 [dependencies]
-tor-llcrypto = { path="../tor-llcrypto", version = "0.1.0"}
+tor-llcrypto = { path = "../tor-llcrypto", version = "0.1.0" }
 
 signature = "1"
 thiserror = "1"
-
-
diff --git a/crates/tor-checkable/src/timed.rs b/crates/tor-checkable/src/timed.rs
index ee8972460b85b0d4c2afa081449684fe191635fe..e83da93c4b033a95afd522129367def72d7158b1 100644
--- a/crates/tor-checkable/src/timed.rs
+++ b/crates/tor-checkable/src/timed.rs
@@ -79,6 +79,41 @@ impl<T> TimerangeBound<T> {
         let start = self.start.map(|t| t - d);
         Self { start, ..self }
     }
+
+    /// Consume this TimeRangeBound, and return its underlying time bounds and
+    /// object.
+    ///
+    /// The caller takes responsibility for making sure that the bounds are
+    /// actually checked.
+    ///
+    /// This is an experimental API. Using it voids your stability guarantees.
+    /// It is only available when this crate is compiled with the
+    /// `experimental-api` feature.
+    #[cfg(feature = "experimental-api")]
+    pub fn dangerously_into_parts(self) -> (T, (Bound<time::SystemTime>, Bound<time::SystemTime>)) {
+        (
+            self.obj,
+            (
+                self.start.map(Bound::Included).unwrap_or(Bound::Unbounded),
+                self.end.map(Bound::Included).unwrap_or(Bound::Unbounded),
+            ),
+        )
+    }
+
+    /// Return a reference to the inner object of this TimeRangeBound, without
+    /// checking the time interval.
+    ///
+    /// The caller takes responsibility for making sure that nothing is actually
+    /// done with the inner object that would rely on the bounds being correct, until
+    /// the bounds are (eventually) checked.
+    ///
+    /// This is an experimental API. Using it voids your stability guarantees.
+    /// It is only available when this crate is compiled with the
+    /// `experimental-api` feature.
+    #[cfg(feature = "experimental-api")]
+    pub fn dangerously_peek(&self) -> &T {
+        &self.obj
+    }
 }
 
 impl<T> crate::Timebound<T> for TimerangeBound<T> {
diff --git a/crates/tor-netdoc/Cargo.toml b/crates/tor-netdoc/Cargo.toml
index 0c63806a9a6be9a00b646cc0fcc757171e0ccc35..49197e7325287c7edd1028fb275e970cac524ee9 100644
--- a/crates/tor-netdoc/Cargo.toml
+++ b/crates/tor-netdoc/Cargo.toml
@@ -29,6 +29,17 @@ ns_consensus = []
 # feature voids your "semver warrantee".
 experimental-api = []
 
+# Expose various struct fields as "pub", for testing.
+#
+# This feature is *super* dangerous for stability and correctness. If you use it
+# for anything besides testing, you are probably putting your users in danger.
+#
+# The struct fields exposed by this feature are not covered by semantic version.
+# In fact, using this feature will give you the opposite of a "semver
+# guarantee": you should be mildly surprised when your code _doesn't_ break from
+# version to version.
+dangerous-expose-struct-fields = ["visible", "visibility"]
+
 [dependencies]
 tor-llcrypto = { path = "../tor-llcrypto", version = "0.1.0" }
 tor-bytes = { path = "../tor-bytes", version = "0.1.0" }
@@ -49,6 +60,8 @@ phf = { version = "0.10.0", features = ["macros"] }
 serde = "1.0.103"
 signature = "1"
 thiserror = "1"
+visible = { version = "0.0.1", optional = true }
+visibility = { version = "0.0.1", optional = true }
 weak-table = "0.3.0"
 
 rand = { version = "0.8", optional = true }
diff --git a/crates/tor-netdoc/src/doc/microdesc.rs b/crates/tor-netdoc/src/doc/microdesc.rs
index 01947dad187820ed28bf8647fd1c1ef6bd8b0366..1aab3962692b7c9b044b5ec853dc2bd8de3ba8a6 100644
--- a/crates/tor-netdoc/src/doc/microdesc.rs
+++ b/crates/tor-netdoc/src/doc/microdesc.rs
@@ -52,6 +52,11 @@ pub type MdDigest = [u8; 32];
 
 /// A single microdescriptor.
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 #[derive(Clone, Debug)]
 pub struct Microdesc {
     /// The SHA256 digest of the text of this microdescriptor.  This
diff --git a/crates/tor-netdoc/src/doc/netstatus.rs b/crates/tor-netdoc/src/doc/netstatus.rs
index 5b4f1ea4e5a72352db6eb889676ac288b4fcabae..7ae6510f26fd9dff5f5a0e14186f09237feb06ca 100644
--- a/crates/tor-netdoc/src/doc/netstatus.rs
+++ b/crates/tor-netdoc/src/doc/netstatus.rs
@@ -235,6 +235,11 @@ impl ConsensusFlavor {
 
 /// The signature of a single directory authority on a networkstatus document.
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 pub struct Signature {
     /// The name of the digest algorithm used to make the signature.
@@ -251,6 +256,11 @@ pub struct Signature {
 
 /// A collection of signatures that can be checked on a networkstatus document
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 pub struct SignatureGroup {
     /// The sha256 of the document itself
@@ -263,6 +273,12 @@ pub struct SignatureGroup {
 
 /// A shared-random value produced by the directory authorities.
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    visibility::make(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 struct SharedRandVal {
     /// How many authorities revealed shares that contributed to this value.
@@ -281,6 +297,12 @@ struct SharedRandVal {
 /// NOTE: this type is separate from the header parts that are only in
 /// votes or only in consensuses, even though we don't implement votes yet.
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    visibility::make(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 struct CommonHeader {
     /// What kind of consensus document is this?  Absent in votes and
@@ -308,6 +330,12 @@ struct CommonHeader {
 
 /// The header of a consensus networkstatus.
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    visibility::make(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 struct ConsensusHeader {
     /// Header fields common to votes and consensuses
@@ -326,6 +354,12 @@ struct ConsensusHeader {
 ///
 /// (Corresponds to a dir-source line.)
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    visibility::make(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 struct DirSource {
     /// human-readable nickname for this authority.
@@ -398,8 +432,8 @@ bitflags! {
 }
 
 /// Recognized weight fields on a single relay in a consensus
-#[derive(Debug, Clone, Copy)]
 #[non_exhaustive]
+#[derive(Debug, Clone, Copy)]
 pub enum RelayWeight {
     /// An unmeasured weight for a relay.
     Unmeasured(u32),
@@ -420,6 +454,12 @@ impl RelayWeight {
 
 /// All information about a single authority, as represented in a consensus
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    visibility::make(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 struct ConsensusVoterInfo {
     /// Contents of the dirsource line about an authority
@@ -433,6 +473,12 @@ struct ConsensusVoterInfo {
 
 /// The signed footer of a consensus netstatus.
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    visibility::make(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 struct Footer {
     /// Weights to be applied to certain classes of relays when choosing
@@ -477,6 +523,11 @@ pub trait RouterStatus: Sealed {
 /// TODO: This should possibly turn into a parameterized type, to represent
 /// votes and ns consensuses.
 #[allow(dead_code)]
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 pub struct Consensus<RS> {
     /// Part of the header shared by all consensus types.
@@ -1401,6 +1452,11 @@ impl<RS: RouterStatus + ParseRouterStatus> Consensus<RS> {
 /// check_signature() on that result with the set of certs that you
 /// have.  Make sure only to provide authority certificates representing
 /// real authorities!
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 pub struct UnvalidatedConsensus<RS> {
     /// The consensus object. We don't want to expose this until it's
diff --git a/crates/tor-netdoc/src/doc/netstatus/rs.rs b/crates/tor-netdoc/src/doc/netstatus/rs.rs
index fde46fe3349e721b19ee5979757a26aa602eb77a..b985187ca789df970faf4315cb426d2c52d840cd 100644
--- a/crates/tor-netdoc/src/doc/netstatus/rs.rs
+++ b/crates/tor-netdoc/src/doc/netstatus/rs.rs
@@ -27,6 +27,12 @@ pub use md::MdConsensusRouterStatus;
 pub use ns::NsConsensusRouterStatus;
 
 /// Shared implementation of MdConsensusRouterStatus and NsConsensusRouterStatus.
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    visibility::make(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 struct GenericRouterStatus<D> {
     /// The nickname for this relay.
@@ -150,6 +156,7 @@ pub(crate) use implement_accessors;
 
 /// Helper to decode a document digest in the format in which it
 /// appears in a given kind of routerstatus.
+#[cfg_attr(feature = "dangerous-expose-struct-fields", visibility::make(pub))]
 trait FromRsString: Sized {
     /// Try to decode the given object.
     fn decode(s: &str) -> Result<Self>;
diff --git a/crates/tor-netdoc/src/doc/netstatus/rs/md.rs b/crates/tor-netdoc/src/doc/netstatus/rs/md.rs
index 8a104211fc7f121b5c9ff8eea1d8f8b16f50604a..7664c3c06bddbb03072347b9d1e651146d337c71 100644
--- a/crates/tor-netdoc/src/doc/netstatus/rs/md.rs
+++ b/crates/tor-netdoc/src/doc/netstatus/rs/md.rs
@@ -17,6 +17,11 @@ use tor_llcrypto::pk::rsa::RsaIdentity;
 use tor_protover::Protocols;
 
 /// A single relay's status, as represented in a microdesc consensus.
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 pub struct MdConsensusRouterStatus {
     /// Underlying generic routerstatus object.
diff --git a/crates/tor-netdoc/src/doc/netstatus/rs/ns.rs b/crates/tor-netdoc/src/doc/netstatus/rs/ns.rs
index a5bc502f6e3a1b64b45f54d237209bba9bd8b38d..56e6bd3eb484087df1ba6d747bb40df721daf9a8 100644
--- a/crates/tor-netdoc/src/doc/netstatus/rs/ns.rs
+++ b/crates/tor-netdoc/src/doc/netstatus/rs/ns.rs
@@ -20,6 +20,11 @@ use std::convert::TryInto;
 /// A single relay's status, as represented in a "ns" consensus.
 ///
 /// Only available if `tor-netdoc` is built with the `ns_consensus` feature.
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 #[derive(Debug, Clone)]
 pub struct NsConsensusRouterStatus {
     /// Underlying generic routerstatus object.
diff --git a/crates/tor-netdoc/src/doc/routerdesc.rs b/crates/tor-netdoc/src/doc/routerdesc.rs
index 7986e62ab13844f46e2d5a6c1bc56678450622e5..c129c23355eb6bb2264c0d3d3c093f746520df6c 100644
--- a/crates/tor-netdoc/src/doc/routerdesc.rs
+++ b/crates/tor-netdoc/src/doc/routerdesc.rs
@@ -91,6 +91,11 @@ pub struct RouterAnnotation {
 /// Before using this type to connect to a relay, you MUST check that
 /// it is valid, using is_expired_at().
 #[allow(dead_code)] // don't warn about fields not getting read.
+#[cfg_attr(
+    feature = "dangerous-expose-struct-fields",
+    visible::StructFields(pub),
+    non_exhaustive
+)]
 pub struct RouterDesc {
     /// Human-readable nickname for this relay.
     ///