crates/kernel/ents-gate/src/lib.rs
| 1 | //! The gate: the one pure admission judgment over ref-store reads |
| 2 | //! (`docs/spec/gate.sdoc`). |
| 3 | //! |
| 4 | //! This crate owns exactly one verb — [`verify`] — evaluated identically |
| 5 | //! at the three call sites the design names (`gate.call-sites`): hosted |
| 6 | //! CAS (mandatory, a failing verdict aborts the transaction), local UI |
| 7 | //! verdict (advisory, a failing verdict annotates), and push pre-flight |
| 8 | //! (advisory, a prediction that can only go stale). It is deliberately a |
| 9 | //! separate crate from `receive` (`arch.gate-receive-split`) so the two |
| 10 | //! advisory call sites link no effect-matching or enqueue logic, and it |
| 11 | //! consumes only the *read* half of the ref store |
| 12 | //! (`arch.refstore-read-cas-split`) plus gitoxide's `Find` seam for |
| 13 | //! objects, so it is statically incapable of writing. |
| 14 | //! |
| 15 | //! # Spec coverage |
| 16 | //! |
| 17 | //! From `docs/spec/gate.sdoc`: |
| 18 | //! |
| 19 | //! - `gate.tip-signed`, `gate.identity-binding`, `gate.owner-mutation`, |
| 20 | //! `gate.fast-forward` — [`verify`]. Identity binding recomputes each |
| 21 | //! refname from the tip's signed content per namespace |
| 22 | //! (`meta-ref.identity-binding`) — a natural-key tree field, a |
| 23 | //! hash-identified genesis oid enforced by the all-roots walk, a |
| 24 | //! composite review/result key, an inbox owner — and strictly decodes a |
| 25 | //! genesis tree, an unknown entry refusing; owner mutation keys an |
| 26 | //! advance to the genesis signer (∪ admins) or, for a review, the |
| 27 | //! member the refname names. |
| 28 | //! - `gate.atomic-cas` — [`verify`] reads the old tip once and returns |
| 29 | //! it as [`Admission::cas`], the precondition the writer MUST hand to |
| 30 | //! `RefStore::transaction`; the CAS itself is the store's. |
| 31 | //! - `gate.signature-artifact` — signatures are read from the commit's |
| 32 | //! `gpgsig` header and verified in-process; no push certificate is |
| 33 | //! consulted, and no API here could accept one. |
| 34 | //! - `gate.policy-as-state` — members and the epoch are read only from |
| 35 | //! `refs/meta/*` through `RefStoreRead`, so any clone evaluates the |
| 36 | //! actual policy offline. |
| 37 | //! - `gate.epoch` — [`Config`]; the tip invariant applies once an epoch |
| 38 | //! is recorded, and the epoch-setting commit is itself the first gated |
| 39 | //! tip of the config ref. |
| 40 | //! - `gate.call-sites`, `gate.verdict-reason` — [`Verdict`], [`Refusal`], |
| 41 | //! [`Requirement`]; proven identical across call sites by this crate's |
| 42 | //! parameterized call-site test. |
| 43 | //! - `gate.adoption-merge`, `gate.adoption-no-fast-forward`, |
| 44 | //! `gate.same-actor-divergence` — consequences of judging only the tip |
| 45 | //! plus DAG descent; pinned by the verdict-table tests. |
| 46 | //! - `gate.principled-split` — refs outside `refs/meta/*` pass as |
| 47 | //! [`AdmissionKind::CodeRef`]; the tip invariant never applies to |
| 48 | //! branch refs. |
| 49 | //! - `gate.bootstrap` — the empty-member-list window admits only a |
| 50 | //! self-admitting first enrollment; an all-revoked member set fails |
| 51 | //! closed and never reopens it. |
| 52 | //! |
| 53 | //! Partially here, completed by later phases: `gate.mandatory-hosted` |
| 54 | //! and `gate.advisory-local` are caller policies (`ents-receive`, the |
| 55 | //! composition roots) — this crate contributes the shared verdict and, |
| 56 | //! for the advisory sites, the verdict-time reason rendering including |
| 57 | //! the inbox alternative ([`Refusal`]). The prohibition on cherry-pick |
| 58 | //! as an adoption mechanism binds the adoption *tooling* |
| 59 | //! (`sync.adoption-no-cherry-pick`, enforced by `ents-sync`): a |
| 60 | //! cherry-pick produces an ordinary commit by the placer, which no pure |
| 61 | //! function over the result could distinguish, so the gate has nothing |
| 62 | //! to check — gate.sdoc's Adoption section says the same. |
| 63 | //! |
| 64 | //! # Authorization model |
| 65 | //! |
| 66 | //! "Signed by a member authorized for that refname" uses exactly the |
| 67 | //! rules the spec pins today: self-run and inbox namespaces are |
| 68 | //! owner-only — a member of either provenance writes only its own |
| 69 | //! `refs/meta/self/<member>/*` and `refs/meta/inbox/<member>/*` |
| 70 | //! segments, and nobody, admins included, writes another member's |
| 71 | //! (`meta-ref.inbox`) — `refs/meta/effects/*` is admin-only |
| 72 | //! (`effect.admin-only`), and self-attested members are refused |
| 73 | //! canonical refs until promoted (`model.member-provenance`). |
| 74 | //! Finer-grained, config-stored refname rules are a later, additive |
| 75 | //! narrowing read from the same [`Config`] the epoch already lives on — |
| 76 | //! e.g. designating worker keys for one effect's canonical results |
| 77 | //! namespace (`effect.official`) — unbuilt until a caller needs it; |
| 78 | //! `Config` itself moves to `ents-model` only once configuration grows |
| 79 | //! fields no gate rule reads. |
| 80 | //! |
| 81 | //! Acceptance-time semantics: a signature is judged against the member |
| 82 | //! entity *currently in force* — the member ref's tip in the same |
| 83 | //! snapshot the gate reads (`model.member-revocation`). No |
| 84 | //! commit-supplied timestamp participates, so a revoked key's new |
| 85 | //! pushes are refused even with a backdated committer date; refs |
| 86 | //! accepted before the revocation stay valid because acceptance is |
| 87 | //! never re-judged. A verdict is therefore a pure function of the |
| 88 | //! proposed update and current repository state: any clone reproduces |
| 89 | //! it against the same snapshot, and reconstructing what a *past* |
| 90 | //! acceptance saw is an audit function over the deployment's op log — |
| 91 | //! explicitly out of scope for this crate and every other crate here. |
| 92 | //! |
| 93 | //! # Examples |
| 94 | //! |
| 95 | //! A hosted-shaped round trip: enroll a member (pre-epoch, archival), |
| 96 | //! turn verification on by setting the epoch (the first gated tip of the |
| 97 | //! config ref), then verify a signed mutation and use the admission's |
| 98 | //! CAS precondition. |
| 99 | //! |
| 100 | //! ``` |
| 101 | //! use ents_gate::{AdmissionKind, Config, Update, Verdict, verify}; |
| 102 | //! use ents_model::{Provenance, namespace}; |
| 103 | //! use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity}; |
| 104 | //! use gix_ref_store::Expected; |
| 105 | //! |
| 106 | //! let refs = MemRefStore::default(); |
| 107 | //! let objects = ObjectStore::default(); |
| 108 | //! let key = Keypair::from_seed(1); |
| 109 | //! |
| 110 | //! // 1. Enrollment lands pre-epoch: history before the epoch is archival. |
| 111 | //! enroll_member(&refs, &objects, "jdc", &key, Provenance::AdminRegistered, 100); |
| 112 | //! |
| 113 | //! // 2. The epoch-setting commit is the first gated tip of refs/meta/config. |
| 114 | //! let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid"); |
| 115 | //! let epoch_tip = write_meta_entity( |
| 116 | //! &refs, &objects, config_ref, &Config { epoch: Some(200) }, Some(&key), 200, |
| 117 | //! ); |
| 118 | //! |
| 119 | //! // 3. From here on, every meta-ref update is judged by the tip invariant. |
| 120 | //! // (A stand-in for `ents-forge`'s `Issue`: this crate cannot depend on |
| 121 | //! // `ents-forge`, which itself depends on this crate.) |
| 122 | //! # #[derive(facet::Facet)] |
| 123 | //! # struct Issue { title: String, body: String, state: String } |
| 124 | //! # |
| 125 | //! let issue = Issue { |
| 126 | //! title: "t".into(), body: "b".into(), state: "open".into(), |
| 127 | //! }; |
| 128 | //! // A hash-identified entity's id is its genesis commit's own oid, so the |
| 129 | //! // ref is named from the signed commit (`meta-ref.identity-binding`). |
| 130 | //! let tree = facet_git_tree::serialize_into(&issue, &objects).expect("serializes"); |
| 131 | //! let tip = ents_testutil::write_commit(&objects, &ents_testutil::CommitSpec { |
| 132 | //! tree, parents: vec![], message: "Open an issue".into(), seconds: 300, |
| 133 | //! }, Some(&key)); |
| 134 | //! let name: gix::refs::FullName = format!("refs/meta/issues/{tip}").try_into().expect("valid"); |
| 135 | //! |
| 136 | //! // Judge the tip as a proposal against a store that does not yet have |
| 137 | //! // the ref, the way hosted CAS or pre-flight would. |
| 138 | //! let before = refs.fetched_copy(); |
| 139 | //! let verdict = verify(&before, &objects, &Update { name, new: Some(tip) }) |
| 140 | //! .expect("evaluates"); |
| 141 | //! let Verdict::Pass(admission) = verdict else { panic!("authorized update passes") }; |
| 142 | //! assert_eq!(admission.kind, AdmissionKind::TipInvariant); |
| 143 | //! assert_eq!(admission.cas, Expected::MustNotExist); |
| 144 | //! # let _ = epoch_tip; |
| 145 | //! ``` |
| 146 | |
| 147 | mod config; |
| 148 | mod error; |
| 149 | mod object; |
| 150 | mod policy; |
| 151 | mod signature; |
| 152 | mod verdict; |
| 153 | mod verify; |
| 154 | |
| 155 | pub use config::Config; |
| 156 | pub use error::{Error, Result}; |
| 157 | pub use verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict}; |
| 158 | pub use verify::{Update, verify}; |