Phase 2 of the development plan: the declarative vocabulary every other
crate imports. Entity structs for Member, Comment, Issue, Effect,
Toolchain, Redaction, and Account; the result Status taxonomy; refname
namespace builders and a classifier under refs/meta/*; and parsing plus
rendering of the two reserved commit trailers (Ents-Ref, Schema-Version)
on top of gix_object own trailer scanner.
Effect.trigger and Comment.anchor are deliberately opaque (a raw
CommitQuery string, a RawTree slot) to keep the ents-query and
ents-anchor dependency edges one-directional per the crate graph.
meta-ref.inbox results-mirror refname (refs/meta/results/tilde-member/…)
cannot be constructed: the tilde byte is one git-check-ref-format (and
gix_validate::reference::name) rejects unconditionally, so no
gix::refs::FullName can ever hold that shape. Documented in namespace.rs
and lib.rs rather than worked around with a substitute character; needs
a spec decision before ents-gate or ents-receive can route this case.
meta-ref.tip-invariant and meta-ref.migration are left to later
reading and writing crates (ents-receive and friends) per the spec
coverage notes in lib.rs.
deps: add proptest as a workspace dev-dependency for the struct-tree
round-trip property tests meta-ref.typed-tree calls for.
crates/ents-model/src/account.rs
@@ -1,0 +1,64 @@
+//! The Account entity: links a member's key to a login identity.
+//!
+//! Spec coverage: `model.account`.
+
+use facet::Facet;
+
+use crate::member::MemberId;
+
+/// Links a member to a login identity, living at the fixed
+/// `refs/meta/account` ref (`namespace::ACCOUNT_REF`,
+/// `meta-ref.granularity`: repository-global state with a single
+/// writer-of-record lives on one fixed ref, not one ref per entity).
+///
+/// `model.account` requires authentication state to live in the
+/// repository as ordinary forge state, never a session database or token
+/// table — this struct is that state, nothing more: which member the
+/// account belongs to, and the login identity it maps to. What that login
+/// identity looks like (an email, an OAuth subject, a passkey credential
+/// id) is left to whatever frontend authenticates against it; `ents-model`
+/// does not constrain its format.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Account, MemberId};
+///
+/// let account = Account {
+/// member: MemberId::new("jdc"),
+/// login: "joseph.carpinelli@icloud.com".to_owned(),
+/// };
+/// let (id, store) = facet_git_tree::serialize(&account).expect("serialize");
+/// let back: Account = facet_git_tree::deserialize(&id, &store).expect("deserialize");
+/// assert_eq!(back, account);
+/// ```
+// @relation(model.account, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Account {
+ /// The member this account belongs to.
+ pub member: MemberId,
+ /// The login identity the member authenticates as.
+ pub login: String,
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(model.account, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn account_round_trips_through_a_tree() {
+ let account = Account {
+ member: MemberId::new("jdc"),
+ login: "joseph.carpinelli@icloud.com".to_owned(),
+ };
+ let (id, store) = serialize(&account).expect("serialize");
+ let back: Account = deserialize(&id, &store).expect("deserialize");
+ assert_eq!(back, account);
+ }
+}
crates/ents-model/src/comment.rs
@@ -1,0 +1,80 @@
+//! The Comment entity: a body anchored to specific content.
+//!
+//! Spec coverage: `model.comment`.
+
+use facet::Facet;
+use facet_git_tree::RawTree;
+
+/// A body of text anchored to the exact content it was written against.
+///
+/// `model.comment` requires a body and an anchor, and that a comment's
+/// author and timestamp come from the mutation commit chain rather than a
+/// stored field — the same rule `meta-ref.trailers` states for ref-level
+/// metadata generally. `Comment` therefore has no author or timestamp
+/// field.
+///
+/// The anchor itself is stored as an opaque [`RawTree`]: `anchor.adoc`
+/// (`anchor.definition`, `anchor.retention`, `anchor.projection`) defines
+/// what it identifies and how it survives force-push and gc, and is owned
+/// by `ents-anchor` (phase 3 — not started by this crate). `ents-model`
+/// only reserves the slot `model.comment` requires; the tree `ents-anchor`
+/// writes there must already exist in the store being serialized into, per
+/// `RawTree`'s own contract.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::Comment;
+/// use facet_git_tree::{ObjectStore, RawTree};
+/// use gix_object::{Kind, Write as _};
+///
+/// // Stand in for what `ents-anchor` will actually write: any pre-existing
+/// // tree, embedded unchanged.
+/// let store = ObjectStore::default();
+/// let anchor_tree = gix_object::Tree { entries: vec![] };
+/// let anchor_oid = store.write(&anchor_tree).expect("tree");
+///
+/// let comment = Comment {
+/// body: "this line looks off by one".to_owned(),
+/// anchor: RawTree::new(anchor_oid),
+/// };
+/// let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize");
+/// let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize");
+/// assert_eq!(back, comment);
+/// ```
+// @relation(model.comment, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Comment {
+ /// The comment's text.
+ pub body: String,
+ /// The anchor identifying the exact content the comment was written
+ /// against (`anchor.definition`), opaque to this crate.
+ pub anchor: RawTree,
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use facet_git_tree::{ObjectStore, deserialize, serialize_into};
+ use gix_object::Write as _;
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(model.comment, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn comment_round_trips_through_a_tree() {
+ let store = ObjectStore::default();
+ let anchor_tree = gix_object::Tree { entries: vec![] };
+ let anchor_oid = store.write(&anchor_tree).expect("tree");
+
+ let comment = Comment {
+ body: "looks off by one".to_owned(),
+ anchor: RawTree::new(anchor_oid),
+ };
+ let root = serialize_into(&comment, &store).expect("serialize");
+ let back: Comment = deserialize(&root, &store).expect("deserialize");
+ assert_eq!(back, comment);
+ }
+}
crates/ents-model/src/effect.rs
@@ -1,0 +1,89 @@
+//! The Effect entity: a declarative subscription to a commit-set query.
+//!
+//! Spec coverage: `model.effect-definition`.
+
+use facet::Facet;
+
+/// A declarative effect definition, living at `refs/meta/effects/<name>`
+/// (`namespace::effect_ref`).
+///
+/// `trigger` is the raw `CommitQuery` text (`query.grammar`): the algebra
+/// itself — parsing, footprint extraction, incremental evaluation — is
+/// `ents-query`'s domain (phase 3). Storing it as `String` here rather than
+/// a parsed AST keeps the dependency edge the crate graph already states:
+/// `ents-query` depends on `ents-model`, never the reverse.
+///
+/// `model.effect-definition` explicitly forbids executor, sandbox, or
+/// retry fields — how an effect runs is a deployment property
+/// (`effect.deployment-property`), decided by `ents-effect` (phase 5) and
+/// the composition root, never stored on the entity itself.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::Effect;
+///
+/// let effect = Effect {
+/// trigger: "rev(refs/heads/main)".to_owned(),
+/// toolchains: vec!["rust-stable".to_owned()],
+/// run: "cargo nextest run".to_owned(),
+/// };
+/// let (id, store) = facet_git_tree::serialize(&effect).expect("serialize");
+/// let back: Effect = facet_git_tree::deserialize(&id, &store).expect("deserialize");
+/// assert_eq!(back, effect);
+/// ```
+// @relation(model.effect-definition, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Effect {
+ /// The raw `CommitQuery` text denoting the commit set this effect
+ /// fires for (`query.grammar`).
+ pub trigger: String,
+ /// The names of the toolchains this effect's run requires, each a
+ /// `refs/meta/toolchains/<name>` reference (`model.toolchain`).
+ pub toolchains: Vec<String>,
+ /// The run command.
+ pub run: String,
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::expect_used,
+ clippy::panic,
+ reason = "unit test; the panic is an assertion the type reflects as a struct at all"
+ )]
+
+ use facet::{Facet as _, Type, UserType};
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(model.effect-definition, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn effect_round_trips_through_a_tree() {
+ let effect = Effect {
+ trigger: "rev(refs/heads/main) & results(unit, pass)".to_owned(),
+ toolchains: vec!["rust-stable".to_owned(), "node-lts".to_owned()],
+ run: "cargo nextest run".to_owned(),
+ };
+ let (id, store) = serialize(&effect).expect("serialize");
+ let back: Effect = deserialize(&id, &store).expect("deserialize");
+ assert_eq!(back, effect);
+ }
+
+ #[rstest]
+ #[case::executor("executor")]
+ #[case::sandbox("sandbox")]
+ #[case::retry("retry")]
+ // @relation(model.effect-definition, scope=function, role=Verifies)
+ fn effect_never_carries_a_deployment_field(#[case] forbidden: &str) {
+ let Type::User(UserType::Struct(struct_ty)) = Effect::SHAPE.ty else {
+ panic!("Effect must reflect as a struct");
+ };
+ assert!(
+ struct_ty.fields.iter().all(|f| f.name != forbidden),
+ "Effect must not carry a {forbidden:?} field: how it runs is a deployment property"
+ );
+ }
+}
crates/ents-model/src/error.rs
@@ -1,0 +1,22 @@
+//! The error type every `ents-model` operation returns.
+
+/// Everything that can go wrong building or parsing `ents-model` values.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// A namespace builder (`namespace::member_ref` and friends) composed a
+ /// refname that gitoxide's own refname validation rejects — for example,
+ /// an id containing `..` or a disallowed control character. The caller
+ /// should reject the offending id before offering it to a namespace
+ /// builder.
+ #[error("invalid refname {name:?}: {source}")]
+ InvalidRefName {
+ /// The composed refname that failed validation.
+ name: String,
+ /// gitoxide's own refname validation error.
+ #[source]
+ source: gix::validate::reference::name::Error,
+ },
+}
+
+/// The `Result` alias every `ents-model` operation returns.
+pub type Result<T> = std::result::Result<T, Error>;
crates/ents-model/src/issue.rs
@@ -1,0 +1,87 @@
+//! The Issue entity: title, body, state, assignees, and labels.
+//!
+//! Spec coverage: `model.issue`.
+
+use facet::Facet;
+
+use crate::member::MemberId;
+
+/// One issue, living at its own `refs/meta/issues/<id>` ref
+/// (`namespace::issue_ref`, `meta-ref.granularity`).
+///
+/// `model.issue` requires `state` to accept custom values and `assignees`
+/// to accept more than one member — "multiple assignees and custom states
+/// are schema, not platform features" — so `state` is a plain `String`
+/// rather than a fixed enum (contrast [`crate::result::Status`], which
+/// *is* a fixed taxonomy because `model.result-taxonomy` says so
+/// explicitly). Extending this struct with further fields later is a
+/// storage migration (`model.extensibility`, `meta-ref.migration`), not a
+/// platform request.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Issue, MemberId};
+///
+/// let issue = Issue {
+/// title: "gate rejects a valid signature".to_owned(),
+/// body: "steps to reproduce...".to_owned(),
+/// state: "triaged".to_owned(),
+/// assignees: vec![MemberId::new("jdc")],
+/// labels: vec!["bug".to_owned(), "gate".to_owned()],
+/// };
+/// let (id, store) = facet_git_tree::serialize(&issue).expect("serialize");
+/// let back: Issue = facet_git_tree::deserialize(&id, &store).expect("deserialize");
+/// assert_eq!(back, issue);
+/// ```
+// @relation(model.issue, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Issue {
+ /// The issue's title.
+ pub title: String,
+ /// The issue's body.
+ pub body: String,
+ /// The issue's current state. Not a fixed enum: custom states are
+ /// schema, not a platform feature (`model.issue`).
+ pub state: String,
+ /// The members assigned to the issue. More than one is ordinary.
+ pub assignees: Vec<MemberId>,
+ /// Free-form labels.
+ pub labels: Vec<String>,
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::default_state_no_assignees("open", vec![], vec![])]
+ #[case::custom_state_one_assignee("triaged", vec![MemberId::new("jdc")], vec!["bug".to_owned()])]
+ #[case::custom_state_many_assignees(
+ "needs-review",
+ vec![MemberId::new("jdc"), MemberId::new("ci-worker")],
+ vec!["bug".to_owned(), "gate".to_owned()]
+ )]
+ // @relation(model.issue, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn issue_round_trips_with_custom_state_and_any_assignee_count(
+ #[case] state: &str,
+ #[case] assignees: Vec<MemberId>,
+ #[case] labels: Vec<String>,
+ ) {
+ let issue = Issue {
+ title: "title".to_owned(),
+ body: "body".to_owned(),
+ state: state.to_owned(),
+ assignees,
+ labels,
+ };
+ let (id, store) = serialize(&issue).expect("serialize");
+ let back: Issue = deserialize(&id, &store).expect("deserialize");
+ assert_eq!(back, issue);
+ }
+}
crates/ents-model/src/lib.rs
@@ -1,0 +1,148 @@
+//! The forge's entity vocabulary: structs, refname namespaces, reserved
+//! commit trailers, and the one closed status taxonomy, all built directly
+//! on `facet-git-tree`'s struct-to-tree mapping.
+//!
+//! Every other library crate in `git-ents` eventually imports this one
+//! (`docs/spec/overview.sdoc`'s crate graph): `ents-query`, `ents-gate`,
+//! `ents-anchor`, `ents-sync`, and `ents-web` all depend on `ents-model`
+//! directly, and nothing here depends back on any of them. That is a
+//! deliberate constraint, not an oversight — see [`Effect::trigger`] and
+//! [`Comment::anchor`] for the two places a richer type would
+//! have been the natural choice and was rejected specifically to keep this
+//! edge one-directional.
+//!
+//! This crate is declarative on purpose: it defines *what* forge state
+//! means (entity structs, taxonomy, namespace, trailers), never *how* it is
+//! verified, queried, or executed. Those verbs belong to `ents-gate`,
+//! `ents-query`, and `ents-effect` respectively (`docs/spec/overview.sdoc`,
+//! "Boundary Rules").
+//!
+//! # Spec coverage
+//!
+//! This crate implements, from `docs/spec/model.sdoc` and
+//! `docs/spec/meta-ref.sdoc`:
+//!
+//! - `model.extensibility` — every entity here is a compile-time
+//! `#[derive(Facet)]` struct; see the crate-level test that reflects each
+//! one's [`facet::Shape`] rather than relying on a runtime schema.
+//! - `model.member-identity`, `model.member-revocation`,
+//! `model.member-provenance`, `model.member-worker` — [`Member`].
+//! - `model.comment` — [`Comment`].
+//! - `model.issue` — [`Issue`].
+//! - `model.effect-definition` — [`Effect`].
+//! - `model.result-taxonomy` — [`Status`].
+//! - `model.toolchain` — [`Toolchain`].
+//! - `model.redaction` — [`Redaction`].
+//! - `model.account` — [`Account`].
+//! - `meta-ref.namespace`, `meta-ref.granularity` — [`namespace`].
+//! - `meta-ref.inbox` — [`namespace`], **partially**: the
+//! `refs/meta/inbox/*` half is implemented and tested
+//! ([`namespace::inbox_ref`], [`namespace::is_inbox`]). The
+//! `refs/meta/results/~<member>/...` results-mirror half is a spec rule
+//! that cannot be implemented as written — `~` is a byte
+//! `git-check-ref-format` (and `gix_validate::reference::name`, which
+//! mirrors it) rejects unconditionally in any refname, so no
+//! `gix::refs::FullName` can ever hold that shape. See the note above
+//! `namespace::inbox_ref` for the full detail; this is flagged as a STOP
+//! CONDITION rather than worked around with a substitute character.
+//! - `meta-ref.typed-tree` — every entity module's round-trip test.
+//! - `meta-ref.trailers` — [`trailer`].
+//!
+//! Two `meta-ref.sdoc` rules are deliberately not implemented here:
+//! `meta-ref.tip-invariant` (a non-owning reader degrading to opaque
+//! display, and surfacing a redaction marker) needs a wired-up
+//! `RefStoreRead` and object access, which belongs to a reading crate
+//! (`ents-receive` or the `git-ents` binary, both later phases) — this
+//! crate only defines the [`Redaction`] entity such a marker would
+//! describe. `meta-ref.migration` is enacted by whichever crate performs a
+//! write (`ents-receive`, phase 4: a signed commit on top of the old tip);
+//! the one constraint that is this crate's to keep — no version-marker
+//! entry in the tree — is `meta-ref.typed-tree`, already covered.
+//!
+//! # Examples
+//!
+//! A worked round trip through every layer this crate owns: build a
+//! [`Member`], place it under its namespace ref, bind a mutation commit to
+//! that ref with a reserved trailer, and round-trip the entity through a
+//! tree.
+//!
+//! ```
+//! use ents_model::{Member, MemberId, Provenance, namespace, trailer::Trailers};
+//!
+//! let id = MemberId::new("jdc");
+//! let member = Member::new("ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered);
+//!
+//! // Where this member's ref lives.
+//! let refname = namespace::member_ref(&id).expect("valid id");
+//! assert_eq!(refname.as_bstr(), "refs/meta/member/jdc");
+//!
+//! // The commit that would write it binds itself to that ref via the
+//! // reserved `Ents-Ref:` trailer (`meta-ref.trailers`).
+//! let trailers = Trailers {
+//! ents_ref: Some(refname),
+//! schema_version: None,
+//! };
+//! let message = format!("Enroll jdc\n\n{}", trailers.render());
+//! assert_eq!(Trailers::parse(message.as_bytes()), trailers);
+//!
+//! // The entity itself round-trips through `facet-git-tree` unchanged —
+//! // the struct is the schema (`meta-ref.typed-tree`).
+//! let (root, store) = facet_git_tree::serialize(&member).expect("serialize");
+//! let back: Member = facet_git_tree::deserialize(&root, &store).expect("deserialize");
+//! assert_eq!(back, member);
+//! ```
+
+mod account;
+mod comment;
+mod effect;
+mod error;
+mod issue;
+mod member;
+pub mod namespace;
+mod redaction;
+mod result;
+mod toolchain;
+pub mod trailer;
+
+pub use account::Account;
+pub use comment::Comment;
+pub use effect::Effect;
+pub use error::{Error, Result};
+pub use issue::Issue;
+pub use member::{Member, MemberId, MemberState, Provenance};
+pub use redaction::Redaction;
+pub use result::Status;
+pub use toolchain::Toolchain;
+
+#[cfg(test)]
+mod tests {
+ use facet::Facet as _;
+ use rstest::rstest;
+
+ use super::*;
+
+ /// `model.extensibility` requires an entity's shape to come from its
+ /// `#[derive(Facet)]` struct at compile time, never from data read at
+ /// runtime. This asserts the concrete, checkable half of that: each
+ /// type's reflected [`facet::Shape::type_identifier`] is exactly its
+ /// Rust struct name, so the shape tracks the source declaration
+ /// automatically — extending an entity is only possible by changing
+ /// the struct and recompiling, never by pointing the same struct at
+ /// different runtime-supplied field data.
+ #[rstest]
+ #[case::account(Account::SHAPE.type_identifier, "Account")]
+ #[case::comment(Comment::SHAPE.type_identifier, "Comment")]
+ #[case::effect(Effect::SHAPE.type_identifier, "Effect")]
+ #[case::issue(Issue::SHAPE.type_identifier, "Issue")]
+ #[case::member(Member::SHAPE.type_identifier, "Member")]
+ #[case::redaction(Redaction::SHAPE.type_identifier, "Redaction")]
+ #[case::status(Status::SHAPE.type_identifier, "Status")]
+ #[case::toolchain(Toolchain::SHAPE.type_identifier, "Toolchain")]
+ // @relation(model.extensibility, scope=function, role=Verifies)
+ fn every_entity_shape_name_tracks_its_struct_declaration(
+ #[case] reflected: &str,
+ #[case] expected: &str,
+ ) {
+ assert_eq!(reflected, expected);
+ }
+}
crates/ents-model/src/member.rs
@@ -1,0 +1,228 @@
+//! The Member entity: an enrolled public key, and the trust state it carries.
+//!
+//! Spec coverage: `model.member-identity`, `model.member-revocation`,
+//! `model.member-provenance`, `model.member-worker`.
+
+use facet::Facet;
+
+/// The stable id naming one member's ref, `refs/meta/member/<id>`
+/// (`namespace::member_ref`).
+///
+/// A newtype rather than a bare `String` because a member id is forge
+/// vocabulary gitoxide has no concept of — unlike a refname or object id, it
+/// is not a git primitive, so wrapping it here does not duplicate one.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Facet)]
+#[facet(transparent)]
+pub struct MemberId(pub String);
+
+impl MemberId {
+ /// Build a member id from any string-like value.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_model::MemberId;
+ ///
+ /// let id = MemberId::new("jdc");
+ /// assert_eq!(id.as_str(), "jdc");
+ /// ```
+ pub fn new(id: impl Into<String>) -> Self {
+ Self(id.into())
+ }
+
+ /// Borrow the id as a string slice.
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl std::fmt::Display for MemberId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+/// Whether a member's key currently authorizes new signatures.
+///
+/// `model.member-revocation` requires that revoking a member record a state
+/// on the entity rather than delete it, and that a signature made before
+/// revocation remain verifiable while one made after is rejected. That
+/// before/after judgment is made by walking the member ref's own commit
+/// history (`meta-ref.namespace`: the commit chain is the audit trail) for
+/// the state in force at the signature's time, exactly as a comment's
+/// author and timestamp come from the mutation commit rather than a stored
+/// field (`model.comment`) — so this type only needs to carry the *current*
+/// state, never a validity window.
+// @relation(model.member-revocation, scope=file)
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
+#[repr(u8)]
+pub enum MemberState {
+ /// The key authorizes new signatures.
+ Active,
+ /// The key does not authorize new signatures made after the commit that
+ /// set this state; signatures it made earlier remain verifiable.
+ Revoked,
+}
+
+/// How a member came to be enrolled.
+///
+/// `model.member-provenance` ties authorization for canonical refs to this
+/// field: a self-attested member is limited to its own inbox and self-run
+/// namespaces (`meta-ref.inbox`) until an admin-registered member promotes
+/// it by an ordinary signed mutation of the member's ref. Enforcing that
+/// restriction is `ents-gate`'s job (`effect.admin-only`); this type only
+/// records which case applies.
+// @relation(model.member-provenance, scope=file)
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
+#[repr(u8)]
+pub enum Provenance {
+ /// Enrolled by an admin-registered member mutating the new member's
+ /// ref directly.
+ AdminRegistered,
+ /// Self-attested through a frontend that lets a key enroll itself.
+ SelfAttested,
+}
+
+/// A public key enrolled into the forge's trust set.
+///
+/// `model.member-identity` requires only that a `Member` carry the key
+/// itself; enrollment is the signed commit that writes it to
+/// `refs/meta/member/<id>` (`namespace::member_ref`), not a field on the
+/// struct. The key's identity (which member id it belongs to) likewise
+/// lives in the refname, not duplicated inside the tree — the same pattern
+/// `model.comment` uses for a comment's identity.
+///
+/// `model.member-worker` requires that a machine actor (a CI worker or
+/// other automated signer) be an ordinary `Member` with no privileged
+/// construction path. This type has exactly one constructor
+/// ([`Member::new`]) for both cases; nothing here distinguishes a human
+/// key from a machine key beyond the [`Provenance`] every member already
+/// carries.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Member, MemberState, Provenance};
+///
+/// // A human member, admin-registered.
+/// let human = Member::new("ssh-ed25519 AAAA... joey", Provenance::AdminRegistered);
+/// assert_eq!(human.state, MemberState::Active);
+///
+/// // A CI worker's key is enrolled through the exact same constructor —
+/// // `model.member-worker` forbids a separate privileged path.
+/// let worker = Member::new("ssh-ed25519 AAAA... ci-worker", Provenance::AdminRegistered);
+/// assert_eq!(worker.provenance, Provenance::AdminRegistered);
+/// ```
+// @relation(model.member-identity, model.member-worker, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Member {
+ /// The member's public key material, in whatever text form the
+ /// deployment's signature verification expects (an OpenSSH public key
+ /// line, an armored PGP key, etc.). `ents-gate` (phase 3) interprets
+ /// this; `ents-model` treats it as opaque.
+ pub key: String,
+ /// Whether the key currently authorizes new signatures.
+ pub state: MemberState,
+ /// How the member was enrolled.
+ pub provenance: Provenance,
+}
+
+impl Member {
+ /// Enroll a new member, active from the start.
+ ///
+ /// This is the sole constructor — used identically for a human member
+ /// and a machine actor (`model.member-worker`).
+ #[must_use]
+ pub fn new(key: impl Into<String>, provenance: Provenance) -> Self {
+ Self {
+ key: key.into(),
+ state: MemberState::Active,
+ provenance,
+ }
+ }
+
+ /// Record a revoked state without deleting the entity
+ /// (`model.member-revocation`).
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_model::{Member, MemberState, Provenance};
+ ///
+ /// let mut member = Member::new("key", Provenance::AdminRegistered);
+ /// member.revoke();
+ /// assert_eq!(member.state, MemberState::Revoked);
+ /// ```
+ pub fn revoke(&mut self) {
+ self.state = MemberState::Revoked;
+ }
+
+ /// Return the key to authorizing new signatures
+ /// (`model.member-revocation`'s unrevoke case). The record of the
+ /// revoked period itself lives in the ref's commit history, not in this
+ /// struct, so unrevoking alters only the current state.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_model::{Member, MemberState, Provenance};
+ ///
+ /// let mut member = Member::new("key", Provenance::AdminRegistered);
+ /// member.revoke();
+ /// member.unrevoke();
+ /// assert_eq!(member.state, MemberState::Active);
+ /// ```
+ pub fn unrevoke(&mut self) {
+ self.state = MemberState::Active;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::admin_human(Provenance::AdminRegistered)]
+ #[case::self_attested_human(Provenance::SelfAttested)]
+ // @relation(model.member-worker, model.member-provenance, scope=function, role=Verifies)
+ fn worker_and_human_share_one_constructor(#[case] provenance: Provenance) {
+ // A "worker" is not a distinct type or constructor — just another
+ // key enrolled the same way, which this parameterization over the
+ // same `Member::new` demonstrates directly.
+ let worker = Member::new("ssh-ed25519 AAAA... ci-worker", provenance);
+ let human = Member::new("ssh-ed25519 AAAA... joey", provenance);
+ assert_eq!(worker.provenance, human.provenance);
+ }
+
+ #[rstest]
+ // @relation(model.member-revocation, scope=function, role=Verifies)
+ fn revoke_then_unrevoke_round_trips_to_active() {
+ let mut member = Member::new("key", Provenance::AdminRegistered);
+ assert_eq!(member.state, MemberState::Active);
+ member.revoke();
+ assert_eq!(member.state, MemberState::Revoked);
+ member.unrevoke();
+ assert_eq!(member.state, MemberState::Active);
+ }
+
+ #[rstest]
+ #[case::active(MemberState::Active)]
+ #[case::revoked(MemberState::Revoked)]
+ // @relation(model.member-identity, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn member_round_trips_through_a_tree(#[case] state: MemberState) {
+ let member = Member {
+ key: "ssh-ed25519 AAAA... jdc".to_owned(),
+ state,
+ provenance: Provenance::AdminRegistered,
+ };
+ let (id, store) = serialize(&member).expect("serialize");
+ let back: Member = deserialize(&id, &store).expect("deserialize");
+ assert_eq!(member, back);
+ }
+}
crates/ents-model/src/namespace.rs
@@ -1,0 +1,283 @@
+//! Refname namespaces under `refs/meta/*`.
+//!
+//! Every builder here composes a refname and validates it through gitoxide's
+//! own [`gix::refs::FullName`] (`arch.no-object-store-trait`'s sibling rule:
+//! never define a parallel refname type). [`classify`] is the inverse
+//! direction — given a refname, which entity's namespace it falls in — for
+//! callers (the gate, `receive`) that need to route on a pushed ref without
+//! duplicating this module's namespace table.
+//!
+//! Spec coverage: `meta-ref.namespace`, `meta-ref.granularity`,
+//! `meta-ref.inbox`, plus the `refs/meta/toolchains/*`
+//! (`model.toolchain`) and `refs/meta/redactions/*` (`model.redaction`)
+//! namespaces.
+
+use gix::refs::{FullName, FullNameRef};
+
+use crate::member::MemberId;
+use crate::{Error, Result};
+
+fn build(name: String) -> Result<FullName> {
+ FullName::try_from(name.clone()).map_err(|source| Error::InvalidRefName { name, source })
+}
+
+/// The fixed ref for repository-global account state (`meta-ref.granularity`:
+/// "Repository-global state with a single writer-of-record MUST instead live
+/// on one fixed ref").
+pub const ACCOUNT_REF: &str = "refs/meta/account";
+
+/// The fixed ref for repository-global configuration
+/// (`meta-ref.granularity`).
+pub const CONFIG_REF: &str = "refs/meta/config";
+
+/// The ref holding the member named `id` — `refs/meta/member/<id>`
+/// (`meta-ref.granularity`).
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{MemberId, namespace};
+///
+/// let name = namespace::member_ref(&MemberId::new("jdc")).expect("valid id");
+/// assert_eq!(name.as_bstr(), "refs/meta/member/jdc");
+/// ```
+// @relation(meta-ref.granularity, scope=function)
+pub fn member_ref(id: &MemberId) -> Result<FullName> {
+ build(format!("refs/meta/member/{id}"))
+}
+
+/// The ref holding the issue named `id` — `refs/meta/issues/<id>`
+/// (`meta-ref.granularity`).
+// @relation(meta-ref.granularity, scope=function)
+pub fn issue_ref(id: &str) -> Result<FullName> {
+ build(format!("refs/meta/issues/{id}"))
+}
+
+/// The ref holding the comment named `id` — `refs/meta/comments/<id>`
+/// (`meta-ref.granularity`).
+// @relation(meta-ref.granularity, scope=function)
+pub fn comment_ref(id: &str) -> Result<FullName> {
+ build(format!("refs/meta/comments/{id}"))
+}
+
+/// The ref holding the effect named `name` — `refs/meta/effects/<name>`
+/// (`meta-ref.granularity`).
+// @relation(meta-ref.granularity, scope=function)
+pub fn effect_ref(name: &str) -> Result<FullName> {
+ build(format!("refs/meta/effects/{name}"))
+}
+
+/// The canonical ref for one effect's result on one tested commit —
+/// `refs/meta/results/<effect>/<short_oid>` (`meta-ref.granularity`).
+// @relation(meta-ref.granularity, scope=function)
+pub fn result_ref(effect: &str, short_oid: &str) -> Result<FullName> {
+ build(format!("refs/meta/results/{effect}/{short_oid}"))
+}
+
+// NOTE: `meta-ref.inbox` also specifies a member's self-run result mirror at
+// `refs/meta/results/~<member>/<effect>/<short_oid>`. That refname cannot be
+// constructed: `~` is one of the bytes `git-check-ref-format` (and
+// `gix_validate::reference::name`, which mirrors it) rejects unconditionally
+// in any refname component, so `gix::refs::FullName::try_from` fails for
+// every value of `<member>`, not just some. This is a spec rule that cannot
+// be implemented as written (per the STOP CONDITION on such rules) — no
+// `inbox_result_ref` builder is provided, and it is not claimed as covered.
+// A spec resolution (a different separator, since `~` itself is not
+// git-legal) is needed before `ents-gate`/`ents-receive` can route this
+// case.
+
+/// The ref holding an inbox entity awaiting adoption —
+/// `refs/meta/inbox/<id>` (`meta-ref.inbox`).
+// @relation(meta-ref.inbox, scope=function)
+pub fn inbox_ref(id: &str) -> Result<FullName> {
+ build(format!("refs/meta/inbox/{id}"))
+}
+
+/// The ref holding the toolchain manifest named `name` —
+/// `refs/meta/toolchains/<name>` (`model.toolchain`).
+// @relation(model.toolchain, scope=function)
+pub fn toolchain_ref(name: &str) -> Result<FullName> {
+ build(format!("refs/meta/toolchains/{name}"))
+}
+
+/// The ref holding the redaction record named `id` —
+/// `refs/meta/redactions/<id>` (`model.redaction`).
+// @relation(model.redaction, scope=function)
+pub fn redaction_ref(id: &str) -> Result<FullName> {
+ build(format!("refs/meta/redactions/{id}"))
+}
+
+/// Which entity namespace a `refs/meta/*` refname falls in.
+///
+/// Deliberately coarser than the refname itself: a canonical result and its
+/// inbox mirror both classify as [`Namespace::Result`], since
+/// `meta-ref.inbox` requires them to "hold the same typed trees as their
+/// canonical counterparts; only the refname rule differs" — that refname
+/// rule (who may write which case) is authorization, [`is_inbox`]'s job and
+/// ultimately the gate's, not a distinct entity kind.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum Namespace {
+ /// `refs/meta/member/*`.
+ Member,
+ /// `refs/meta/issues/*`.
+ Issue,
+ /// `refs/meta/comments/*`.
+ Comment,
+ /// `refs/meta/effects/*`.
+ Effect,
+ /// `refs/meta/results/*`, canonical or inbox (`is_inbox`).
+ Result,
+ /// `refs/meta/toolchains/*`.
+ Toolchain,
+ /// `refs/meta/redactions/*`.
+ Redaction,
+ /// `refs/meta/inbox/*` — general inbox entities awaiting adoption.
+ Inbox,
+ /// The fixed `refs/meta/account` ref.
+ Account,
+ /// The fixed `refs/meta/config` ref.
+ Config,
+}
+
+/// Classify a `refs/meta/*` refname by which entity's namespace it falls in,
+/// or `None` if `name` is not under `refs/meta/*` at all
+/// (`meta-ref.namespace`: "All forge state MUST live under `refs/meta/*`").
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::namespace::{self, Namespace};
+///
+/// let name: gix::refs::FullName = "refs/meta/issues/42".try_into().expect("valid");
+/// assert_eq!(namespace::classify(name.as_ref()), Some(Namespace::Issue));
+///
+/// let outside: gix::refs::FullName = "refs/heads/main".try_into().expect("valid");
+/// assert_eq!(namespace::classify(outside.as_ref()), None);
+/// ```
+// @relation(meta-ref.namespace, meta-ref.granularity, scope=function)
+#[must_use]
+pub fn classify(name: &FullNameRef) -> Option<Namespace> {
+ let path = name.as_bstr().to_string();
+ let rest = path.strip_prefix("refs/meta/")?;
+
+ if rest == "account" {
+ return Some(Namespace::Account);
+ }
+ if rest == "config" {
+ return Some(Namespace::Config);
+ }
+ let (segment, _) = rest.split_once('/').unwrap_or((rest, ""));
+ match segment {
+ "member" => Some(Namespace::Member),
+ "issues" => Some(Namespace::Issue),
+ "comments" => Some(Namespace::Comment),
+ "effects" => Some(Namespace::Effect),
+ "results" => Some(Namespace::Result),
+ "toolchains" => Some(Namespace::Toolchain),
+ "redactions" => Some(Namespace::Redaction),
+ "inbox" => Some(Namespace::Inbox),
+ _ => None,
+ }
+}
+
+/// Whether a `refs/meta/*` refname names an inbox entity or an inbox result
+/// mirror — `refs/meta/inbox/*` — per `meta-ref.inbox`.
+///
+/// The results-mirror half of `meta-ref.inbox`
+/// (`refs/meta/results/~<member>/...`) is not checked here: as the note
+/// above `inbox_ref` explains, `~` is not a legal refname byte, so no
+/// [`FullNameRef`] can ever hold that shape for this function to recognize.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::namespace;
+///
+/// let inbox: gix::refs::FullName = "refs/meta/inbox/abc".try_into().expect("valid");
+/// assert!(namespace::is_inbox(inbox.as_ref()));
+///
+/// let canonical: gix::refs::FullName = "refs/meta/results/unit/abc123".try_into().expect("valid");
+/// assert!(!namespace::is_inbox(canonical.as_ref()));
+/// ```
+// @relation(meta-ref.inbox, scope=function)
+#[must_use]
+pub fn is_inbox(name: &FullNameRef) -> bool {
+ let path = name.as_bstr().to_string();
+ let Some(rest) = path.strip_prefix("refs/meta/") else {
+ return false;
+ };
+ rest.starts_with("inbox/")
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ fn name(s: &str) -> FullName {
+ s.try_into().expect("valid refname in test table")
+ }
+
+ #[rstest]
+ #[case::member("refs/meta/member/jdc", Some(Namespace::Member))]
+ #[case::issue("refs/meta/issues/42", Some(Namespace::Issue))]
+ #[case::comment("refs/meta/comments/abc", Some(Namespace::Comment))]
+ #[case::effect("refs/meta/effects/unit", Some(Namespace::Effect))]
+ #[case::result("refs/meta/results/unit/abc123", Some(Namespace::Result))]
+ #[case::toolchain("refs/meta/toolchains/rust-stable", Some(Namespace::Toolchain))]
+ #[case::redaction("refs/meta/redactions/abc", Some(Namespace::Redaction))]
+ #[case::inbox("refs/meta/inbox/abc", Some(Namespace::Inbox))]
+ #[case::account("refs/meta/account", Some(Namespace::Account))]
+ #[case::config("refs/meta/config", Some(Namespace::Config))]
+ #[case::outside_meta("refs/heads/main", None)]
+ #[case::unrecognized("refs/meta/index/abc", None)]
+ // @relation(meta-ref.namespace, meta-ref.granularity, scope=function, role=Verifies)
+ fn classify_matches_the_namespace_table(
+ #[case] refname: &str,
+ #[case] expected: Option<Namespace>,
+ ) {
+ assert_eq!(classify(name(refname).as_ref()), expected);
+ }
+
+ #[rstest]
+ #[case::inbox_entity("refs/meta/inbox/abc", true)]
+ #[case::canonical_result("refs/meta/results/unit/abc123", false)]
+ #[case::member("refs/meta/member/jdc", false)]
+ // @relation(meta-ref.inbox, scope=function, role=Verifies)
+ fn is_inbox_matches_only_inbox_namespaces(#[case] refname: &str, #[case] expected: bool) {
+ assert_eq!(is_inbox(name(refname).as_ref()), expected);
+ }
+
+ #[rstest]
+ // @relation(meta-ref.namespace, scope=function, role=Verifies)
+ fn every_builder_stays_under_refs_meta() {
+ let id = MemberId::new("jdc");
+ let built = [
+ member_ref(&id).expect("valid"),
+ issue_ref("42").expect("valid"),
+ comment_ref("abc").expect("valid"),
+ effect_ref("unit").expect("valid"),
+ result_ref("unit", "abc123").expect("valid"),
+ inbox_ref("abc").expect("valid"),
+ toolchain_ref("rust-stable").expect("valid"),
+ redaction_ref("abc").expect("valid"),
+ ];
+ for name in built {
+ assert!(
+ name.as_bstr().starts_with(b"refs/meta/"),
+ "{name} must live under refs/meta/*"
+ );
+ }
+ }
+
+ #[rstest]
+ // @relation(meta-ref.namespace, scope=function, role=Verifies)
+ fn invalid_component_is_rejected_not_silently_accepted() {
+ let err = issue_ref("../escape").expect_err("must reject a refname with a `..` component");
+ assert!(matches!(err, Error::InvalidRefName { .. }));
+ }
+}
crates/ents-model/src/redaction.rs
@@ -1,0 +1,106 @@
+//! The Redaction entity: a record of a yank, not the yanked content.
+//!
+//! Spec coverage: `model.redaction`.
+
+use facet::Facet;
+use gix_hash::ObjectId;
+
+/// A record that a specific object was redacted, living at
+/// `refs/meta/redactions/<id>` (`namespace::redaction_ref`).
+///
+/// `model.redaction` requires the target's oid and a human-readable
+/// reason, and forbids carrying the redacted content itself — this struct
+/// has no field that could. It also carries no signature field: "the admin
+/// signature authorizing the yank" is the enclosing mutation commit's own
+/// signature (`receive.redaction-admin-only` is enforced there, by
+/// `ents-receive`, phase 4), the same commit-chain-not-tree-field pattern
+/// `model.comment` and `model.member-revocation` already follow.
+///
+/// `target` is stored as a raw 20-byte SHA-1 array — the one primitive
+/// `facet-git-tree`'s byte-sequence encoding supports directly
+/// (`gix_hash::ObjectId` itself has no `Facet` impl) — the same
+/// representation `facet_git_tree::RawTree` uses internally for its own
+/// wrapped oid. [`Redaction::new`] and [`Redaction::target`] keep the
+/// public API in gitoxide's own type.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::Redaction;
+///
+/// let target = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
+/// let redaction = Redaction::new(target, "leaked credential");
+/// assert_eq!(redaction.target(), target);
+///
+/// let (id, store) = facet_git_tree::serialize(&redaction).expect("serialize");
+/// let back: Redaction = facet_git_tree::deserialize(&id, &store).expect("deserialize");
+/// assert_eq!(back, redaction);
+/// ```
+// @relation(model.redaction, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Redaction {
+ target: [u8; 20],
+ /// A human-readable reason for the redaction.
+ pub reason: String,
+}
+
+impl Redaction {
+ /// Record that `target` was redacted for `reason`.
+ #[must_use]
+ pub fn new(target: ObjectId, reason: impl Into<String>) -> Self {
+ let mut bytes = [0u8; 20];
+ bytes.copy_from_slice(target.as_slice());
+ Self {
+ target: bytes,
+ reason: reason.into(),
+ }
+ }
+
+ /// The redacted object's id.
+ #[must_use]
+ pub fn target(&self) -> ObjectId {
+ ObjectId::from_bytes_or_panic(&self.target)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::expect_used,
+ clippy::panic,
+ reason = "unit test; the panic is an assertion the type reflects as a struct at all"
+ )]
+
+ use facet::{Facet as _, Type, UserType};
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(model.redaction, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn redaction_round_trips_and_preserves_the_target_oid() {
+ let target = ObjectId::from_bytes_or_panic(&[7u8; 20]);
+ let redaction = Redaction::new(target, "leaked credential");
+
+ let (id, store) = serialize(&redaction).expect("serialize");
+ let back: Redaction = deserialize(&id, &store).expect("deserialize");
+
+ assert_eq!(back, redaction);
+ assert_eq!(back.target(), target);
+ }
+
+ #[rstest]
+ // @relation(model.redaction, scope=function, role=Verifies)
+ fn redaction_never_carries_the_redacted_content() {
+ let Type::User(UserType::Struct(struct_ty)) = Redaction::SHAPE.ty else {
+ panic!("Redaction must reflect as a struct");
+ };
+ let names: Vec<_> = struct_ty.fields.iter().map(|f| f.name).collect();
+ assert_eq!(
+ names,
+ vec!["target", "reason"],
+ "Redaction must carry only the target oid and a reason, never the content itself"
+ );
+ }
+}
crates/ents-model/src/result.rs
@@ -1,0 +1,57 @@
+//! The Result status taxonomy.
+//!
+//! Spec coverage: `model.result-taxonomy`.
+
+use facet::Facet;
+
+/// The fixed set of outcomes a recorded result may carry.
+///
+/// `model.result-taxonomy` fixes this taxonomy at exactly three values;
+/// unlike [`crate::issue::Issue::state`], which is intentionally open, this
+/// is a closed enum precisely because the spec closes it. *When* each
+/// status is written, and when nothing is written at all, is run
+/// semantics specified by `effect.result-taxonomy` and owned by
+/// `ents-effect` (phase 5) — this type only names the three values.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::Status;
+///
+/// let (id, store) = facet_git_tree::serialize(&Status::Pass).expect("serialize");
+/// let back: Status = facet_git_tree::deserialize(&id, &store).expect("deserialize");
+/// assert_eq!(back, Status::Pass);
+/// ```
+// @relation(model.result-taxonomy, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
+#[repr(u8)]
+pub enum Status {
+ /// The effect ran and succeeded.
+ Pass,
+ /// The effect ran and reported failure.
+ Fail,
+ /// The effect could not complete a run (as distinct from completing
+ /// and reporting failure).
+ Error,
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::pass(Status::Pass)]
+ #[case::fail(Status::Fail)]
+ #[case::error(Status::Error)]
+ // @relation(model.result-taxonomy, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn every_taxonomy_value_round_trips(#[case] status: Status) {
+ let (id, store) = serialize(&status).expect("serialize");
+ let back: Status = deserialize(&id, &store).expect("deserialize");
+ assert_eq!(back, status);
+ }
+}
crates/ents-model/src/toolchain.rs
@@ -1,0 +1,62 @@
+//! The Toolchain entity: a hash-pinned execution-environment manifest.
+//!
+//! Spec coverage: `model.toolchain`.
+
+use facet::Facet;
+
+/// A toolchain manifest, living at `refs/meta/toolchains/<name>`
+/// (`namespace::toolchain_ref`).
+///
+/// Content addressing makes the manifest hash-pinned for free: its own
+/// tree object id, produced by `facet-git-tree` serialization, already
+/// names the exact bytes `recipe` holds. `recipe` carries whatever
+/// provenance is needed to reproduce the execution environment; its
+/// internal structure (toolchain kind, download vs. embedded binaries,
+/// pinned versions) is `ents-effect`'s domain (phase 5, not started here)
+/// — `model.toolchain` asks only that the manifest exist under this
+/// namespace and carry that provenance, not for a particular schema for
+/// it.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::Toolchain;
+///
+/// let toolchain = Toolchain {
+/// name: "rust-stable".to_owned(),
+/// recipe: "rustup component add ... pinned to 1.90.0".to_owned(),
+/// };
+/// let (id, store) = facet_git_tree::serialize(&toolchain).expect("serialize");
+/// let back: Toolchain = facet_git_tree::deserialize(&id, &store).expect("deserialize");
+/// assert_eq!(back, toolchain);
+/// ```
+// @relation(model.toolchain, meta-ref.typed-tree, model.extensibility, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Toolchain {
+ /// The toolchain's name — the last segment of its ref.
+ pub name: String,
+ /// Opaque provenance needed to reproduce the execution environment.
+ pub recipe: String,
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use facet_git_tree::{deserialize, serialize};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(model.toolchain, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn toolchain_round_trips_through_a_tree() {
+ let toolchain = Toolchain {
+ name: "rust-stable".to_owned(),
+ recipe: "recipe text".to_owned(),
+ };
+ let (id, store) = serialize(&toolchain).expect("serialize");
+ let back: Toolchain = deserialize(&id, &store).expect("deserialize");
+ assert_eq!(back, toolchain);
+ }
+}
crates/ents-model/src/trailer.rs
@@ -1,0 +1,163 @@
+//! Reserved commit trailers (`meta-ref.trailers`).
+//!
+//! Ref-level metadata that is not entity content lives in the mutation
+//! commit's trailers, never inside the tree (`meta-ref.typed-tree`) — a
+//! comment's author and timestamp are the running example
+//! (`model.comment`). Two trailers are reserved: `Schema-Version:`, for
+//! explicit encoding detection if it is ever needed, and `Ents-Ref:`, which
+//! `ents-gate` (phase 3) compares against the refname actually being
+//! updated to bind a signature to its placement.
+//!
+//! Parsing rides on `gix_object`'s own trailer scanner
+//! (`CommitRef::message_trailers`, `git-interpret-trailers`-compatible)
+//! rather than re-implementing trailer-block detection here.
+
+use gix::refs::FullName;
+use gix_object::commit::MessageRef;
+
+/// The reserved trailer key binding a mutation commit to the refname it was
+/// authored for.
+pub const ENTS_REF: &str = "Ents-Ref";
+
+/// The reserved trailer key for explicit encoding detection.
+pub const SCHEMA_VERSION: &str = "Schema-Version";
+
+/// The two reserved trailers read from (or written to) a mutation commit's
+/// message, per `meta-ref.trailers`.
+///
+/// A malformed `Ents-Ref:` value — one that fails gitoxide's own refname
+/// validation — parses as absent rather than as an error: a commit message
+/// is untrusted input, and rejecting a bad binding is `ents-gate`'s job
+/// (`gate.tip-signed`), not this type's.
+// @relation(meta-ref.trailers, scope=file)
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Trailers {
+ /// The refname the commit was authored for, if the `Ents-Ref:` trailer
+ /// is present and well-formed.
+ pub ents_ref: Option<FullName>,
+ /// The raw `Schema-Version:` value, if present.
+ pub schema_version: Option<String>,
+}
+
+impl Trailers {
+ /// Parse the reserved trailers out of a raw commit message.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_model::trailer::Trailers;
+ ///
+ /// let message = b"Enroll jdc\n\nEnts-Ref: refs/meta/member/jdc\n";
+ /// let trailers = Trailers::parse(message);
+ /// assert_eq!(trailers.ents_ref.expect("present").as_bstr(), "refs/meta/member/jdc");
+ /// ```
+ #[must_use]
+ pub fn parse(message: &[u8]) -> Self {
+ let Some(body) = MessageRef::from_bytes(message).body() else {
+ return Self::default();
+ };
+
+ let mut trailers = Self::default();
+ for trailer in body.trailers() {
+ if trailer.token.eq_ignore_ascii_case(ENTS_REF.as_bytes()) {
+ if let Ok(name) = FullName::try_from(trailer.value.to_string()) {
+ trailers.ents_ref = Some(name);
+ }
+ } else if trailer
+ .token
+ .eq_ignore_ascii_case(SCHEMA_VERSION.as_bytes())
+ {
+ trailers.schema_version = Some(trailer.value.to_string());
+ }
+ }
+ trailers
+ }
+
+ /// Render the reserved trailers as a `Key: Value\n` block, suitable for
+ /// appending to a commit message body. Absent fields contribute no
+ /// line; an entirely-empty `Trailers` renders as the empty string.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_model::trailer::Trailers;
+ ///
+ /// let name: gix::refs::FullName = "refs/meta/member/jdc".try_into().expect("valid");
+ /// let trailers = Trailers {
+ /// ents_ref: Some(name),
+ /// schema_version: None,
+ /// };
+ /// assert_eq!(trailers.render(), "Ents-Ref: refs/meta/member/jdc\n");
+ /// ```
+ #[must_use]
+ pub fn render(&self) -> String {
+ let mut out = String::new();
+ if let Some(name) = &self.ents_ref {
+ out.push_str(ENTS_REF);
+ out.push_str(": ");
+ out.push_str(&name.as_bstr().to_string());
+ out.push('\n');
+ }
+ if let Some(version) = &self.schema_version {
+ out.push_str(SCHEMA_VERSION);
+ out.push_str(": ");
+ out.push_str(version);
+ out.push('\n');
+ }
+ out
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::both(
+ b"Subject\n\nBody line.\n\nEnts-Ref: refs/meta/member/jdc\nSchema-Version: 1\n",
+ Some("refs/meta/member/jdc"),
+ Some("1")
+ )]
+ #[case::ents_ref_only(
+ b"Subject\n\nEnts-Ref: refs/meta/issues/42\n",
+ Some("refs/meta/issues/42"),
+ None
+ )]
+ #[case::neither(b"Subject\n\nJust a body, no trailers.\n", None, None)]
+ #[case::case_insensitive_key(
+ b"Subject\n\nents-ref: refs/meta/comments/1\n",
+ Some("refs/meta/comments/1"),
+ None
+ )]
+ #[case::malformed_ref_is_absent(b"Subject\n\nEnts-Ref: not a refname\n", None, None)]
+ // @relation(meta-ref.trailers, scope=function, role=Verifies)
+ fn parse_reads_reserved_trailers_only(
+ #[case] message: &[u8],
+ #[case] expected_ref: Option<&str>,
+ #[case] expected_version: Option<&str>,
+ ) {
+ let trailers = Trailers::parse(message);
+ assert_eq!(
+ trailers.ents_ref.map(|n| n.as_bstr().to_string()),
+ expected_ref.map(str::to_owned)
+ );
+ assert_eq!(trailers.schema_version, expected_version.map(str::to_owned));
+ }
+
+ #[rstest]
+ // @relation(meta-ref.trailers, scope=function, role=Verifies)
+ fn render_then_parse_round_trips() {
+ let name: FullName = "refs/meta/effects/unit".try_into().expect("valid");
+ let trailers = Trailers {
+ ents_ref: Some(name),
+ schema_version: Some("1".to_owned()),
+ };
+ let message = format!("Subject\n\nBody.\n\n{}", trailers.render());
+ let parsed = Trailers::parse(message.as_bytes());
+ assert_eq!(parsed, trailers);
+ }
+}
crates/ents-model/tests/round_trip.rs
@@ -1,0 +1,62 @@
+//! Property-based round-trip tests for `meta-ref.typed-tree`: struct → tree
+//! → struct must be identity over an unenumerable input space (arbitrary
+//! strings, arbitrary-length collections) — the shape of test
+//! `git-ents-engineering` calls out for `proptest` rather than a fixed
+//! `rstest` table. [`Issue`] and [`Member`] are exercised directly, as the
+//! richest and the enum-heaviest of this crate's entities; every other
+//! entity's round trip is covered by the fixed-case table in its own
+//! module (`model.comment`, `model.effect-definition`, `model.toolchain`,
+//! `model.redaction`, `model.account`, `model.result-taxonomy`).
+
+#![allow(clippy::expect_used, reason = "integration test")]
+
+use ents_model::{Issue, Member, MemberId, MemberState, Provenance};
+use facet_git_tree::{deserialize, serialize};
+use proptest::prelude::*;
+
+fn member_state() -> impl Strategy<Value = MemberState> {
+ prop_oneof![Just(MemberState::Active), Just(MemberState::Revoked)]
+}
+
+fn provenance() -> impl Strategy<Value = Provenance> {
+ prop_oneof![
+ Just(Provenance::AdminRegistered),
+ Just(Provenance::SelfAttested)
+ ]
+}
+
+fn member_id() -> impl Strategy<Value = MemberId> {
+ any::<String>().prop_map(MemberId::new)
+}
+
+proptest! {
+ #![proptest_config(ProptestConfig::with_cases(64))]
+
+ // @relation(meta-ref.typed-tree, scope=function, role=Verifies)
+ #[test]
+ fn member_round_trips_for_any_key_state_and_provenance(
+ key in any::<String>(),
+ state in member_state(),
+ provenance in provenance(),
+ ) {
+ let member = Member { key, state, provenance };
+ let (id, store) = serialize(&member).expect("serialize");
+ let back: Member = deserialize(&id, &store).expect("deserialize");
+ prop_assert_eq!(back, member);
+ }
+
+ // @relation(meta-ref.typed-tree, model.issue, scope=function, role=Verifies)
+ #[test]
+ fn issue_round_trips_for_any_fields_and_collection_lengths(
+ title in any::<String>(),
+ body in any::<String>(),
+ state in any::<String>(),
+ assignees in prop::collection::vec(member_id(), 0..8),
+ labels in prop::collection::vec(any::<String>(), 0..8),
+ ) {
+ let issue = Issue { title, body, state, assignees, labels };
+ let (id, store) = serialize(&issue).expect("serialize");
+ let back: Issue = deserialize(&id, &store).expect("deserialize");
+ prop_assert_eq!(back, issue);
+ }
+}