model: derive identity from signed content, retiring the trailer module
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
No reviews of this commit yet — record a verdict below.
Start a review
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);
- }
-}