Skip to content

channel: encapsulate Server fields and clarify authority boundary#138

Merged
intendednull merged 2 commits into
mainfrom
claude/issue-118-channel-encapsulation
Apr 11, 2026
Merged

channel: encapsulate Server fields and clarify authority boundary#138
intendednull merged 2 commits into
mainfrom
claude/issue-118-channel-encapsulation

Conversation

@intendednull
Copy link
Copy Markdown
Owner

Closes #118
Progresses #108

Summary

crates/channel/src/lib.rs exposed Server::admins, Server::id,
Server::name, and Server::description as pub mutable fields. Any
caller holding &mut Server could do server.admins.insert(me) and
grant itself admin status, quietly bypassing the event-sourced
authority model in willow-state.

This PR makes those four identity-bearing fields private, adds read
accessors (id(), name(), description(), admins()), adds a
Server::with_id(id, name, creator) constructor for the join path,
and adds three #[doc(hidden)] set_*_for_materializer setters whose
names and docs make clear they are a data-shape mirror, not an
enforcement boundary.

A new module-level doc on willow-channel and an expanded doc on
Server spell out that all authority enforcement lives in
willow-state::materialize::apply_*
— direct mutation of a Server
value is not a trust decision.

The other fields on Server (channels, roles, members,
invites) were already private and are untouched.

Design note on the materializer shim

The willow-state materializer lives in a different crate, so
pub(crate) would not be visible to it. I evaluated three options for
letting it (or any future code) mutate the private fields:

  1. pub(crate) — rejected (not visible cross-crate)
  2. #[doc(hidden)] pub fn set_*_for_materializer with an explicit
    contract in the doc comment — chosen
  3. A &mut HashSet<EndpointId> admins_mut accessor — rejected
    (re-introduces the exact footgun we're trying to fix)

In practice, the current willow-state materializer operates on its
own ServerState type and does not actually touch willow_channel:: Server. The setters are therefore unused by the materializer today,
but they are exercised by the new encapsulation regression test and
are ready for any future code that needs them. The #[doc(hidden)]
annotation keeps them out of generated docs, and the
_for_materializer suffix plus the doc comment ("this is a data-shape
mutation, not an enforcement boundary") discourages accidental use.

The one pre-existing mutation site — crates/client/src/joining.rs,
which parsed a server ID from an invite payload and then assigned
server.id = ... after calling Server::new — is rewritten to use
the new Server::with_id constructor. That is the only structural
change outside crates/channel/.

Test plan

  • Added server_id_name_description_admins_are_encapsulated
    regression test in crates/channel/src/lib.rs that exercises
    the accessors, with_id, and each set_*_for_materializer
    setter.
  • admin_has_all_permissions and admins_set_grants_everything
    tests rewritten to use server.admins() / set_admin_for_ materializer so they still cover the admin-permission path.
  • server_description and server_serde_round_trip rewritten
    to use the new accessors / setters; serde round-trip still
    covers all four previously-public fields (visibility does not
    affect the wire format).
  • cargo fmt --check clean.
  • cargo clippy --workspace --all-targets -- -D warnings clean.
  • cargo test -p willow-channel (27 passed).
  • cargo test -p willow-state -p willow-client (155 passed).
  • cargo test -p willow-agent (29 passed).
  • cargo test -p willow-relay -p willow-storage -p willow-replay -p willow-worker all pass.
  • cargo test -p willow-network (integration + unit all pass).
  • cargo test -p willow-actor -p willow-common -p willow-identity -p willow-transport -p willow-messaging -p willow-crypto all
    pass.
  • cargo check --workspace clean.
  • cargo check --target wasm32-unknown-unknown across all WASM
    crates (the just check-wasm set) clean.

Note on cross-crate touches

Per the task scope, the primary change lives in crates/channel/.
Call-site updates in crates/client/ were unavoidable because
Server is used pervasively for reads and (in one place) writes:

  • crates/client/src/util.rsmake_topic now calls server.id().
  • crates/client/src/invite.rsgenerate_invite and four tests
    now read through accessors.
  • crates/client/src/lib.rs — five read sites rewritten to use
    server.id() / server.name().
  • crates/client/src/servers.rscreate_server uses server.id().
  • crates/client/src/state.rsserver_list uses server.name().
  • crates/client/src/joining.rs — one write site rewritten to use
    the new Server::with_id constructor (overlaps with Agent C's
    territory, but this single-location structural change is the
    minimal edit needed after the encapsulation, and an equivalent
    read-through-accessor shim would still have required touching the
    write site).

No files outside crates/channel/ and crates/client/ were modified.
I did not touch crates/state/, crates/storage/, crates/identity/,
crates/crypto/, crates/relay/src/main.rs, or
crates/client/src/listeners.rs.

https://claude.ai/code/session_018TTGhL645aTR4RZWrRBLPS

claude added 2 commits April 11, 2026 07:58
Exposing Server::admins, id, name, description as pub fields would let
any caller with &mut Server do server.admins.insert(me) and grant
itself admin status, bypassing the event-sourced authority model in
willow-state.

Make the four identity-bearing fields private and provide:
- id(), name(), description(), admins() read accessors
- Server::with_id(id, name, creator) constructor for the join path
- #[doc(hidden)] set_*_for_materializer setters documented as
  data-shape mutations, not enforcement boundaries

The materializer lives in a different crate (willow-state), so
pub(crate) setters are not visible to it. The *_for_materializer
setters are pub but explicitly named and #[doc(hidden)] to discourage
accidental use while remaining reachable from outside the crate.

Add a regression test plus a module-level doc clarifying that all
authority enforcement happens in willow-state::materialize::apply_*.

Closes #118
Progresses #108
Follow-up to the willow-channel encapsulation of id/name/description/
admins: update all call sites in willow-client to use the new
accessor methods (id(), name(), description(), admins()).

The one pre-existing mutation site in joining.rs (which set
server.id after construction) is rewritten to use the new
Server::with_id constructor instead.

No behavior change.

Progresses #108
Copy link
Copy Markdown
Owner Author

Audit note: this PR and #139 both edit crates/client/src/joining.rs in the same ~10-line region (the else branch that constructs a Server from an accepted invite). The overlap is real — whichever lands second will need a manual rebase.

Combined intent (for whoever rebases second):

// Validate upfront (from #139)
let parsed_server_uuid = uuid::Uuid::parse_str(&server_id).map_err(|e| {
    crate::ClientError::MalformedInvite(format!("invalid server_id `{server_id}`: {e}"))
})?;
// ...
// Use with_id constructor (from #138) with the validated UUID
let mut server = willow_channel::Server::with_id(
    willow_channel::ServerId(parsed_server_uuid),
    &accepted.server_name,
    accepted.genesis_author,
);
// Propagate channel creation errors (from #139)
let ch_id = server
    .create_channel(name, willow_channel::ChannelKind::Text)
    .map_err(|e| {
        crate::ClientError::MalformedInvite(format!(
            "could not create channel `{name}` from invite: {e}"
        ))
    })?;

Planned merge order: #138 first, then rebase #139 on top of main. No action needed on this PR.


Generated by Claude Code

@intendednull intendednull merged commit 3619c7e into main Apr 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[channel] Make Server::admins and other public mutable fields private

2 participants