git-ents.gitmain
⌘K
foforge
commit c50003a
model: derive identity from signed content, retiring the trailer module

Bind every meta-ref name to its tree, not a commit trailer (meta-ref.identity-binding): Member and Effect gain the natural-key field their refname’s final segment binds; the result tree becomes a ResultRecord carrying effect + target so a signed pass cannot be replayed for a different effect or commit; reviews move to the composite reviews/<target>/<member> key with a matching parser the gate recomputes against. Advance-ref and the never-used Schema-Version are gone.

remove: Trailers, ADVANCE_REF, SCHEMA_VERSION and trailer module feat: add Member.id natural-key field (model.member-identity) feat: add Effect.name natural-key field (model.effect-definition) feat: add ResultRecord with effect + target (model.result-identity) feat: composite review_ref/review_pin_ref + parse_review_ref/parse_result_ref (model.review) Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/kernel/ents-model/src/effect.rs @@ -24,6 +24,7 @@ /// use ents_model::Effect; /// /// let effect = Effect { +/// name: "unit".to_owned(), /// trigger: "rev(refs/heads/main)".to_owned(), /// toolchains: vec!["rust-stable".to_owned()], /// run: "cargo nextest run".to_owned(), @@ -32,9 +33,13 @@ /// let back: Effect = facet_git_tree::deserialize(&id, &store).expect("deserialize"); /// assert_eq!(back, effect); /// ``` -// @relation(model.effect-definition, effect.definition, effect.deployment-property, meta-ref.typed-tree, model.extensibility, scope=file) +// @relation(model.effect-definition, effect.definition, effect.deployment-property, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file) #[derive(Debug, Clone, PartialEq, Eq, Facet)] pub struct Effect { + /// The effect's own name — the natural key the refname's final segment + /// binds to (`model.effect-definition`, `meta-ref.identity-binding`): + /// the gate recomputes `refs/meta/effects/<name>` from this field. + pub name: String, /// The raw `CommitQuery` text denoting the commit set this effect /// fires for (`query.grammar`). pub trigger: String, @@ -63,6 +68,7 @@ // @relation(model.effect-definition, effect.definition, meta-ref.typed-tree, scope=function, role=Verifies) fn effect_round_trips_through_a_tree() { let effect = Effect { + name: "unit".to_owned(), 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(),
crates/kernel/ents-model/src/lib.rs @@ -1,6 +1,6 @@ -//! 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. +//! The forge's entity vocabulary: structs, refname namespaces, 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`, @@ -12,7 +12,7 @@ //! edge one-directional. //! //! This crate is declarative on purpose: it defines *what* forge state -//! means (entity structs, taxonomy, namespace, trailers), never *how* it is +//! means (entity structs, taxonomy, namespace), 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"). @@ -33,6 +33,7 @@ //! `ents-forge`'s `Issue` and `Comment`. //! - `model.effect-definition` — [`Effect`]. //! - `model.result-taxonomy` — [`Status`]. +//! - `model.result-identity` — [`ResultRecord`]. //! - `model.toolchain` — moved to `ents-kiln` (resolving and materializing //! a toolchain needs `ents-effect`'s toolchain-resolution machinery, //! which a purely declarative vocabulary crate like this one may not @@ -46,7 +47,12 @@ //! `refs/meta/self/<member>/<effect>/<short-oid>` self-run mirror half //! ([`namespace::self_result_ref`], [`namespace::self_run_owner`]). //! - `meta-ref.typed-tree` — every entity module's round-trip test. -//! - `meta-ref.trailers` — [`trailer`]. +//! - `meta-ref.identity-binding` — the natural-key tree fields +//! ([`Member::id`], [`Effect::name`]) and composite key fields +//! ([`ResultRecord::effect`], `ResultRecord::target`) the gate +//! recomputes a refname from, plus the composite review/result refname +//! builders and parsers in [`namespace`]; the recomputation itself is +//! `ents-gate`'s (`gate.identity-binding`). //! //! Two `meta-ref.sdoc` rules are deliberately not implemented here: //! `meta-ref.tip-invariant` (a non-owning reader degrading to opaque @@ -62,28 +68,21 @@ //! # 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. +//! [`Member`], place it under its namespace ref whose final segment its id +//! field binds (`meta-ref.identity-binding`), and round-trip the entity +//! through a tree. //! //! ``` -//! use ents_model::{Member, MemberId, Provenance, namespace, trailer::Trailers}; +//! use ents_model::{Member, MemberId, Provenance, namespace}; //! //! let id = MemberId::new("jdc"); -//! let member = Member::new("ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered); +//! let member = Member::new(&id, "ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered); //! -//! // Where this member's ref lives. +//! // Where this member's ref lives — its final segment is the id field the +//! // gate recomputes from the signed tree (`meta-ref.identity-binding`). //! 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 `Advance-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); +//! assert_eq!(member.id, id); //! //! // The entity itself round-trips through `facet-git-tree` unchanged — //! // the struct is the schema (`meta-ref.typed-tree`). @@ -99,14 +98,13 @@ pub mod namespace; mod redaction; mod result; -pub mod trailer; pub use account::Account; pub use effect::Effect; pub use error::{Error, Result}; pub use member::{Member, MemberId, MemberState, Provenance}; pub use redaction::Redaction; -pub use result::Status; +pub use result::{ResultRecord, Status}; #[cfg(test)] mod tests { @@ -128,6 +126,7 @@ #[case::effect(Effect::SHAPE.type_identifier, "Effect")] #[case::member(Member::SHAPE.type_identifier, "Member")] #[case::redaction(Redaction::SHAPE.type_identifier, "Redaction")] + #[case::result(ResultRecord::SHAPE.type_identifier, "ResultRecord")] #[case::status(Status::SHAPE.type_identifier, "Status")] // @relation(model.extensibility, scope=function, role=Verifies) fn every_entity_shape_name_tracks_its_struct_declaration(
crates/kernel/ents-model/src/member.rs @@ -43,6 +43,24 @@ } } +impl From<String> for MemberId { + fn from(id: String) -> Self { + Self(id) + } +} + +impl From<&str> for MemberId { + fn from(id: &str) -> Self { + Self(id.to_owned()) + } +} + +impl From<&MemberId> for MemberId { + fn from(id: &MemberId) -> Self { + id.clone() + } +} + /// Whether a member's key currently authorizes new signatures. /// /// `model.member-revocation` requires that revoking a member record a state @@ -86,12 +104,14 @@ /// 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-identity` requires a `Member` to carry both the key +/// itself and its member id — the id being the natural key the refname +/// `refs/meta/member/<id>` (`namespace::member_ref`) binds to +/// (`meta-ref.identity-binding`): the gate recomputes the refname's final +/// segment from this tree field and refuses a mismatch, so the id is a +/// total function of signed content, not a refname the tree merely trusts. +/// Enrollment is the signed commit that writes the entity, not a field on +/// the struct. /// /// `model.member-worker` requires that a machine actor (a CI worker or /// other automated signer) be an ordinary `Member` with no privileged @@ -106,17 +126,20 @@ /// use ents_model::{Member, MemberState, Provenance}; /// /// // A human member, admin-registered. -/// let human = Member::new("ssh-ed25519 AAAA... joey", Provenance::AdminRegistered); +/// let human = Member::new("joey", "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); +/// let worker = Member::new("ci-worker", "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) +// @relation(model.member-identity, model.member-worker, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file) #[derive(Debug, Clone, PartialEq, Eq, Facet)] pub struct Member { + /// The member's id — the natural key the refname's final segment binds + /// to (`model.member-identity`, `meta-ref.identity-binding`). + pub id: MemberId, /// 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 @@ -132,10 +155,13 @@ /// 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`). + /// and a machine actor (`model.member-worker`). `id` MUST equal the + /// final segment of the member's refname, the binding the gate + /// recomputes (`meta-ref.identity-binding`). #[must_use] - pub fn new(key: impl Into<String>, provenance: Provenance) -> Self { + pub fn new(id: impl Into<MemberId>, key: impl Into<String>, provenance: Provenance) -> Self { Self { + id: id.into(), key: key.into(), state: MemberState::Active, provenance, @@ -150,7 +176,7 @@ /// ``` /// use ents_model::{Member, MemberState, Provenance}; /// - /// let mut member = Member::new("key", Provenance::AdminRegistered); + /// let mut member = Member::new("jdc", "key", Provenance::AdminRegistered); /// member.revoke(); /// assert_eq!(member.state, MemberState::Revoked); /// ``` @@ -168,7 +194,7 @@ /// ``` /// use ents_model::{Member, MemberState, Provenance}; /// - /// let mut member = Member::new("key", Provenance::AdminRegistered); + /// let mut member = Member::new("jdc", "key", Provenance::AdminRegistered); /// member.revoke(); /// member.unrevoke(); /// assert_eq!(member.state, MemberState::Active); @@ -195,15 +221,15 @@ // 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); + let worker = Member::new("ci-worker", "ssh-ed25519 AAAA... ci-worker", provenance); + let human = Member::new("joey", "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); + let mut member = Member::new("jdc", "key", Provenance::AdminRegistered); assert_eq!(member.state, MemberState::Active); member.revoke(); assert_eq!(member.state, MemberState::Revoked); @@ -217,6 +243,7 @@ // @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 { + id: MemberId::new("jdc"), key: "ssh-ed25519 AAAA... jdc".to_owned(), state, provenance: Provenance::AdminRegistered,
crates/kernel/ents-model/src/namespace.rs @@ -61,35 +61,138 @@ build(format!("refs/meta/comments/{id}")) } -/// The ref holding the review named `id` — `refs/meta/reviews/<id>` -/// (`meta-ref.granularity`, `model.review`). -// @relation(meta-ref.granularity, model.review, scope=function) -pub fn review_ref(id: &str) -> Result<FullName> { - build(format!("refs/meta/reviews/{id}")) +/// The ref holding one reviewer's review of one commit — +/// `refs/meta/reviews/<target>/<member>` (`meta-ref.granularity`, +/// `model.review`), where `<target>` is the oid of the first commit the +/// review judged and `<member>` is the reviewer's member id: a composite +/// natural key (`meta-ref.identity-binding`) with no minted id anywhere, +/// so one review thread lives per (target, reviewer) and all reviews of a +/// commit enumerate by ref prefix. +/// +/// # Examples +/// +/// ``` +/// use ents_model::{MemberId, namespace}; +/// +/// let name = namespace::review_ref("deadbeef", &MemberId::new("jdc")).expect("valid"); +/// assert_eq!(name.as_bstr(), "refs/meta/reviews/deadbeef/jdc"); +/// ``` +// @relation(meta-ref.granularity, model.review, meta-ref.identity-binding, scope=function) +pub fn review_ref(target: &str, member: &MemberId) -> Result<FullName> { + build(format!("refs/meta/reviews/{target}/{member}")) } -/// The retention pin for the review named `id` — -/// `refs/meta/pins/reviews/<id>` (`model.review-pin`): the entity's own -/// canonical suffix (`reviews/<id>`) prefixed with `pins/`, the same way -/// `meta-ref.inbox` prefixes one, so two entity kinds can never collide -/// under the same pin id. +/// The retention pin for one reviewer's review of one commit — +/// `refs/meta/pins/reviews/<target>/<member>` (`model.review-pin`): the +/// entity's own canonical suffix (`reviews/<target>/<member>`) prefixed +/// with `pins/`, the same way `meta-ref.inbox` prefixes one, so two entity +/// kinds can never collide under the same pin id. /// /// A pin ref's commits carry the empty tree, never an entity — the sole /// exception to `meta-ref.namespace`'s tree-is-the-entity shape; the /// commits exist purely to keep the reviewed commit and its ancestry -/// reachable. +/// reachable. Because a pin's ancestry deliberately reaches into code +/// history, the gate's parentless-roots walk is never applied to a pin +/// (`meta-ref.identity-binding`). +/// +/// # Examples +/// +/// ``` +/// use ents_model::{MemberId, namespace}; +/// +/// let name = namespace::review_pin_ref("deadbeef", &MemberId::new("jdc")).expect("valid"); +/// assert_eq!(name.as_bstr(), "refs/meta/pins/reviews/deadbeef/jdc"); +/// ``` +// @relation(model.review-pin, meta-ref.namespace, meta-ref.identity-binding, scope=function) +pub fn review_pin_ref(target: &str, member: &MemberId) -> Result<FullName> { + build(format!("refs/meta/pins/reviews/{target}/{member}")) +} + +/// The `(target, member)` a review or review-pin refname names, or `None` +/// when `name` is not a well-formed `refs/meta/reviews/<target>/<member>` +/// or `refs/meta/pins/reviews/<target>/<member>` ref (`model.review`). +/// +/// The gate recomputes a review's composite key from its signed content +/// and compares it to this parse (`meta-ref.identity-binding`, +/// `gate.identity-binding`), so the parser lives here next to the builder +/// rather than re-derived at the call site. +/// +/// # Examples +/// +/// ``` +/// use ents_model::{MemberId, namespace}; +/// +/// let name: gix::refs::FullName = "refs/meta/reviews/deadbeef/jdc".try_into().expect("valid"); +/// assert_eq!( +/// namespace::parse_review_ref(name.as_ref()), +/// Some(("deadbeef".to_owned(), MemberId::new("jdc"))), +/// ); +/// +/// let pin: gix::refs::FullName = "refs/meta/pins/reviews/deadbeef/jdc".try_into().expect("valid"); +/// assert_eq!( +/// namespace::parse_review_ref(pin.as_ref()), +/// Some(("deadbeef".to_owned(), MemberId::new("jdc"))), +/// ); +/// ``` +// @relation(model.review, meta-ref.identity-binding, scope=function) +#[must_use] +pub fn parse_review_ref(name: &FullNameRef) -> Option<(String, MemberId)> { + let path = name.as_bstr().to_string(); + let rest = path + .strip_prefix("refs/meta/reviews/") + .or_else(|| path.strip_prefix("refs/meta/pins/reviews/"))?; + let (target, member) = rest.split_once('/')?; + if target.is_empty() || member.is_empty() || member.contains('/') { + return None; + } + Some((target.to_owned(), MemberId::new(member))) +} + +/// The `(effect, short_oid)` a result refname names, or `None` when `name` +/// is not a well-formed `refs/meta/results/<effect>/<short-oid>` or +/// `refs/meta/self/<member>/<effect>/<short-oid>` ref +/// (`effect.results-writeback`, `meta-ref.inbox`). +/// +/// The gate recomputes a result's composite key from its signed tree's +/// effect and target fields and compares it to this parse +/// (`model.result-identity`, `gate.identity-binding`). /// /// # Examples /// /// ``` /// use ents_model::namespace; /// -/// let name = namespace::review_pin_ref("7").expect("valid id"); -/// assert_eq!(name.as_bstr(), "refs/meta/pins/reviews/7"); +/// let name: gix::refs::FullName = "refs/meta/results/unit/abc123".try_into().expect("valid"); +/// assert_eq!( +/// namespace::parse_result_ref(name.as_ref()), +/// Some(("unit".to_owned(), "abc123".to_owned())), +/// ); +/// +/// let self_run: gix::refs::FullName = "refs/meta/self/jdc/unit/abc123".try_into().expect("valid"); +/// assert_eq!( +/// namespace::parse_result_ref(self_run.as_ref()), +/// Some(("unit".to_owned(), "abc123".to_owned())), +/// ); /// ``` -// @relation(model.review-pin, meta-ref.namespace, scope=function) -pub fn review_pin_ref(id: &str) -> Result<FullName> { - build(format!("refs/meta/pins/reviews/{id}")) +// @relation(model.result-identity, meta-ref.identity-binding, scope=function) +#[must_use] +pub fn parse_result_ref(name: &FullNameRef) -> Option<(String, String)> { + let path = name.as_bstr().to_string(); + let rest = path.strip_prefix("refs/meta/")?; + let tail = if let Some(canonical) = rest.strip_prefix("results/") { + canonical.to_owned() + } else if let Some(self_run) = rest.strip_prefix("self/") { + // refs/meta/self/<member>/<effect>/<short-oid>: drop the member. + let (_, effect_and_oid) = self_run.split_once('/')?; + effect_and_oid.to_owned() + } else { + return None; + }; + let (effect, short_oid) = tail.split_once('/')?; + if effect.is_empty() || short_oid.is_empty() || short_oid.contains('/') { + return None; + } + Some((effect.to_owned(), short_oid.to_owned())) } /// The ref holding the effect named `name` — `refs/meta/effects/<name>` @@ -445,8 +548,8 @@ member_ref(&id).expect("valid"), issue_ref("42").expect("valid"), comment_ref("abc").expect("valid"), - review_ref("7").expect("valid"), - review_pin_ref("7").expect("valid"), + review_ref("deadbeef", &id).expect("valid"), + review_pin_ref("deadbeef", &id).expect("valid"), effect_ref("unit").expect("valid"), result_ref("unit", "abc123").expect("valid"), self_result_ref(&id, "unit", "abc123").expect("valid"),
crates/kernel/ents-model/src/result.rs @@ -1,8 +1,9 @@ -//! The Result status taxonomy. +//! The Result entity and its status taxonomy. //! -//! Spec coverage: `model.result-taxonomy`. +//! Spec coverage: `model.result-taxonomy`, `model.result-identity`. use facet::Facet; +use gix_hash::ObjectId; /// The fixed set of outcomes a recorded result may carry. /// @@ -35,6 +36,75 @@ Error, } +/// A recorded result: the outcome of running one effect against one +/// commit, living at `refs/meta/results/<effect>/<short-oid>` +/// (`namespace::result_ref`) or the self-run mirror. +/// +/// `model.result-identity` requires the result to carry the effect's name +/// and the full oid of the commit the run judged *as tree fields*, from +/// which the refname's `<effect>` and `<short-oid>` segments derive +/// (`meta-ref.identity-binding`): the gate recomputes the refname from +/// these fields and refuses a mismatch, so a signed `pass` cannot be +/// replayed as the result of a different effect or commit — a result means +/// something with the refname stripped away. The composite key freezes the +/// genesis tree by identity, so this struct evolves additively only. +/// +/// `target` is stored as a raw 20-byte SHA-1 array, the same +/// `facet-git-tree`-native oid representation [`crate::Redaction`] uses; +/// [`ResultRecord::new`] and [`ResultRecord::target`] keep the public API +/// in gitoxide's own type. The fields are not parent edges: a result ref's +/// parents stay prior states of the same result, and a result never +/// retains the judged commit's ancestry the way a pin does +/// (`model.result-identity`, `model.review-pin`). +/// +/// # Examples +/// +/// ``` +/// use ents_model::{ResultRecord, Status}; +/// +/// let target = gix_hash::ObjectId::null(gix_hash::Kind::Sha1); +/// let result = ResultRecord::new("unit", target, Status::Pass); +/// assert_eq!(result.effect, "unit"); +/// assert_eq!(result.target(), target); +/// +/// let (id, store) = facet_git_tree::serialize(&result).expect("serialize"); +/// let back: ResultRecord = facet_git_tree::deserialize(&id, &store).expect("deserialize"); +/// assert_eq!(back, result); +/// ``` +// @relation(model.result-identity, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file) +#[derive(Debug, Clone, PartialEq, Eq, Facet)] +pub struct ResultRecord { + /// The name of the effect this result records — binds the refname's + /// `<effect>` segment (`model.result-identity`). + pub effect: String, + /// The full oid of the commit the run judged, as a raw 20-byte SHA-1 + /// array — binds the refname's `<short-oid>` segment + /// (`model.result-identity`). + target: [u8; 20], + /// The run's outcome. + pub status: Status, +} + +impl ResultRecord { + /// Record `status` for `effect` against the commit `target`. + #[must_use] + pub fn new(effect: impl Into<String>, target: ObjectId, status: Status) -> Self { + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(target.as_slice()); + Self { + effect: effect.into(), + target: bytes, + status, + } + } + + /// The oid of the commit this result judged. + #[must_use] + pub fn target(&self) -> ObjectId { + ObjectId::from_bytes_or_panic(&self.target) + } +} + #[cfg(test)] mod tests { #![allow(clippy::expect_used, reason = "unit test")] @@ -54,4 +124,19 @@ let back: Status = deserialize(&id, &store).expect("deserialize"); assert_eq!(back, status); } + + #[rstest] + #[case::pass(Status::Pass)] + #[case::fail(Status::Fail)] + #[case::error(Status::Error)] + // @relation(model.result-identity, meta-ref.typed-tree, scope=function, role=Verifies) + fn result_round_trips_and_preserves_effect_and_target(#[case] status: Status) { + let target = ObjectId::from_bytes_or_panic(&[9u8; 20]); + let result = ResultRecord::new("unit", target, status); + let (id, store) = serialize(&result).expect("serialize"); + let back: ResultRecord = deserialize(&id, &store).expect("deserialize"); + assert_eq!(back, result); + assert_eq!(back.effect, "unit"); + assert_eq!(back.target(), target); + } }
crates/kernel/ents-model/tests/round_trip.rs @@ -33,11 +33,12 @@ // @relation(meta-ref.typed-tree, scope=function, role=Verifies) #[test] fn member_round_trips_for_any_key_state_and_provenance( + id in any::<String>(), key in any::<String>(), state in member_state(), provenance in provenance(), ) { - let member = Member { key, state, provenance }; + let member = Member { id: ents_model::MemberId::new(id), key, state, provenance }; let (id, store) = serialize(&member).expect("serialize"); let back: Member = deserialize(&id, &store).expect("deserialize"); prop_assert_eq!(back, member);
crates/kernel/ents-model/src/trailer.rs @@ -1,163 +1,0 @@ -//! 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 `Advance-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 ADVANCE_REF: &str = "Advance-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 `Advance-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 `Advance-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\nAdvance-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(ADVANCE_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(), "Advance-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(ADVANCE_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\nAdvance-ref: refs/meta/member/jdc\nSchema-Version: 1\n", - Some("refs/meta/member/jdc"), - Some("1") - )] - #[case::ents_ref_only( - b"Subject\n\nAdvance-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\nadvance-ref: refs/meta/comments/1\n", - Some("refs/meta/comments/1"), - None - )] - #[case::malformed_ref_is_absent(b"Subject\n\nAdvance-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); - } -}