git-ents.gitmain
⌘K
foforge
commit e63685c
model: add ents-model crate (entity structs, namespaces, trailers, taxonomy)

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.

Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -32,6 +32,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -97,6 +118,12 @@ "hashbrown 0.16.1", ] +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -210,6 +237,20 @@ "cfg-if", ] +[[package]] +name = "ents-model" +version = "0.0.0" +dependencies = [ + "facet", + "facet-git-tree", + "gix", + "gix-hash", + "gix-object", + "proptest", + "rstest", + "thiserror", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -226,6 +267,108 @@ "windows-sys", ] +[[package]] +name = "facet" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3e58a40571ddd336865ec9643ea43d5cf93c35ae4f5ad5d903dc4e427a2e03" +dependencies = [ + "autocfg", + "facet-core", + "facet-macros", + "facet-reflect", +] + +[[package]] +name = "facet-core" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c2ad271d54e76358f179bc5f75483c4ed025d1009a8599f299ac7bee99dbdd" +dependencies = [ + "autocfg", + "const-fnv1a-hash", + "iddqd", + "impls", +] + +[[package]] +name = "facet-git-tree" +version = "0.1.0" +source = "git+https://github.com/git-ents/facet-git-tree#ec7f4a18ff822d148ab9c4c5360564d38f8a4813" +dependencies = [ + "facet", + "gix-hash", + "gix-object", + "gix-odb", + "thiserror", +] + +[[package]] +name = "facet-macro-parse" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a43cd88c3df76b7606194a059973aea078940de8cc442dca7c327f51bbc014" +dependencies = [ + "facet-macro-types", + "proc-macro2", + "quote", +] + +[[package]] +name = "facet-macro-types" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dce57ed093067e4a3a1ab42c8c84eec1a25af7726d43a22e390734b29b947ec" +dependencies = [ + "proc-macro2", + "quote", + "unsynn", +] + +[[package]] +name = "facet-macros" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ae8b8eb7169a41245f31bda2912a3c7d33e0efa7269c0c8847b54377e31ff68" +dependencies = [ + "facet-macros-impl", +] + +[[package]] +name = "facet-macros-impl" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d7d66574a47f07e87058ce6e429a7be58fd77c7686e572cb1f85d8a6a840c6" +dependencies = [ + "facet-macro-parse", + "facet-macro-types", + "proc-macro2", + "quote", + "strsim", + "unsynn", +] + +[[package]] +name = "facet-path" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a21c558bfb27bf957ead130a3b7e5ea2dee7ccbfb31a524053f061efa164d6f" +dependencies = [ + "facet-core", +] + +[[package]] +name = "facet-reflect" +version = "0.50.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8486d2a82dcb2a1bdca472c2cdc513d0feacda3af2ffd50fb9e04f8dedda8735" +dependencies = [ + "facet-core", + "facet-path", + "hashbrown 0.17.1", + "smallvec 2.0.0-alpha.12", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -316,6 +459,18 @@ "version_check", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -324,7 +479,7 @@ dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -385,7 +540,7 @@ "parking_lot", "regex", "signal-hook", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -425,7 +580,7 @@ "gix-quote", "gix-trace", "kstring", - "smallvec", + "smallvec 1.15.2", "thiserror", "unicode-bom", ] @@ -455,7 +610,7 @@ "gix-trace", "gix-traverse", "gix-worktree", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -508,7 +663,7 @@ "gix-path", "gix-ref", "gix-sec", - "smallvec", + "smallvec 1.15.2", "thiserror", "unicode-bom", ] @@ -663,7 +818,7 @@ "gix-quote", "gix-trace", "gix-utils", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -763,7 +918,7 @@ "libc", "memmap2", "rustix", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -819,7 +974,7 @@ "gix-utils", "gix-validate", "itoa", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -859,7 +1014,7 @@ "gix-object", "gix-path", "memmap2", - "smallvec", + "smallvec 1.15.2", "thiserror", "uluru", ] @@ -990,7 +1145,7 @@ "gix-hash", "gix-revision", "gix-validate", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -1025,7 +1180,7 @@ "gix-hash", "gix-hashtable", "gix-object", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -1142,7 +1297,7 @@ "gix-hashtable", "gix-object", "gix-revwalk", - "smallvec", + "smallvec 1.15.2", "thiserror", ] @@ -1291,6 +1446,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" +[[package]] +name = "iddqd" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cf7e72dd082126bd727c1f8bd2d5229c2780258c0b3c7bc33eaf0f4ea5f0fa" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "hashbrown 0.16.1", +] + +[[package]] +name = "impls" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc" + [[package]] name = "indexmap" version = "2.14.0" @@ -1421,12 +1594,27 @@ "libc", ] +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + [[package]] name = "nonempty" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1452,7 +1640,7 @@ "cfg-if", "libc", "redox_syscall", - "smallvec", + "smallvec 1.15.2", "windows-link", ] @@ -1483,6 +1671,15 @@ "portable-atomic", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1512,6 +1709,31 @@ "parking_lot", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.46" @@ -1521,12 +1743,56 @@ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1600,6 +1866,12 @@ "unicode-ident", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1628,6 +1900,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1737,6 +2021,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smallvec" +version = "2.0.0-alpha.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1749,6 +2039,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.118" @@ -1767,7 +2063,7 @@ checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -1853,6 +2149,12 @@ "arrayvec", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-bom" version = "2.0.3" @@ -1874,12 +2176,32 @@ "tinyvec", ] +[[package]] +name = "unsynn" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" +dependencies = [ + "mutants", + "proc-macro2", + "rustc-hash", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1890,6 +2212,15 @@ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1945,6 +2276,32 @@ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zlib-rs" version = "0.6.6"
Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/gix-ref-store"] +members = ["crates/ents-model", "crates/gix-ref-store"] [workspace.package] edition = "2024" @@ -57,6 +57,7 @@ "aws", ] } maud = { version = "0.27", features = ["axum"] } +proptest = "1" pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } rayon = "1" rstest = "0.26"
crates/ents-model/Cargo.toml @@ -1,0 +1,21 @@ +[package] +name = "ents-model" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +facet = { workspace = true } +facet-git-tree = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-object = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true
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); + } +}