git-ents.gitmain
⌘K
foforge
verify.rs869 lines · 34.4 KB · rusthistorycomment on this file
1//! The pure verify function — the one admission judgment
2//! (`gate.tip-signed` through `gate.fast-forward`, `gate.epoch`,
3//! `gate.bootstrap`), identical at every call site (`gate.call-sites`).
4
5use ents_model::namespace::{self, Namespace};
6use ents_model::{Claim, Member, MemberId, MemberState, Provenance, ResultRecord};
7use facet::{Facet, Type, UserType};
8use gix::refs::FullName;
9use gix_hash::ObjectId;
10use gix_object::Find;
11use gix_ref_store::{Expected, RefStoreRead};
12
13use crate::config;
14use crate::error::Result;
15use crate::object::{
16 CommitData, all_roots, descends_from, read_commit, read_tree_entry, tree_entry_names,
17};
18use crate::policy::{self, Enrolled};
19use crate::signature;
20use crate::verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict};
21
22/// One proposed ref update, as every call site sees it: the refname and
23/// the tip it should come to point at (`None` proposes deletion).
24///
25/// There is deliberately no `old` field: the gate reads the current tip
26/// itself, from the same store snapshot its fast-forward check uses, and
27/// returns it as the CAS precondition ([`Admission::cas`]) — binding
28/// `gate.fast-forward` and `gate.atomic-cas` to one read.
29///
30/// # Examples
31///
32/// ```
33/// use ents_gate::Update;
34///
35/// let update = Update {
36/// name: "refs/meta/issues/42".try_into().expect("valid"),
37/// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)),
38/// };
39/// assert!(update.new.is_some());
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Update {
43 /// The ref being updated.
44 pub name: FullName,
45 /// The proposed new tip, or `None` to delete the ref.
46 pub new: Option<ObjectId>,
47}
48
49/// Verify one proposed ref update against current repository state.
50///
51/// This is a pure function over the read half of the ref store and
52/// gitoxide's object-find seam: same inputs, same verdict, no writes, no
53/// clock, no transport state. The three call sites — hosted CAS
54/// (`gate.mandatory-hosted`), local UI verdict (`gate.advisory-local`),
55/// and push pre-flight (`sync.pre-flight`) — call exactly this function
56/// (`gate.call-sites`) and differ only in what they do with a failing
57/// verdict.
58///
59/// The checks, in spec order, for a `refs/meta/*` ref once the epoch is
60/// in force (`gate.epoch`):
61///
62/// 1. `gate.tip-signed` — the new tip carries a `gpgsig` SSHSIG that
63/// verifies against the key of an enrolled member whose entity,
64/// *currently in force* at the member ref's tip in this same
65/// snapshot, is active (`model.member-revocation`: acceptance-time
66/// semantics — no commit-supplied timestamp participates) and whose
67/// provenance authorizes this refname (`model.member-provenance`,
68/// `effect.admin-only`).
69/// 2. `gate.identity-binding` — the refname is recomputed from the
70/// proposed tip's signed content, per namespace exactly as
71/// `meta-ref.identity-binding` tabulates (a natural-key tree field, a
72/// hash-identified genesis oid enforced by the all-roots walk, a
73/// composite review/result key, an inbox owner), and must match
74/// `update.name`; a hash-identified or composite genesis additionally
75/// strictly decodes as its entity type, an unknown tree entry
76/// refusing.
77/// 3. `gate.owner-mutation` — a hash-identified entity's ref advances
78/// only under its genesis signer (∪ admins); a review advances only
79/// under the member its refname names. Creation stays provenance-keyed.
80/// 4. `gate.fast-forward` — the new tip descends from the current tip.
81///
82/// Refs outside `refs/meta/*` pass as [`AdmissionKind::CodeRef`]: branch
83/// refs keep transport-level authorization instead of the tip invariant
84/// (`gate.principled-split`).
85///
86/// # Errors
87///
88/// An [`crate::Error`] means the gate could not evaluate (store or
89/// object failure) — distinct from a [`Verdict::Fail`], which is a
90/// reached judgment. The mandatory call site must treat both as
91/// blocking.
92///
93/// # Examples
94///
95/// ```
96/// use ents_gate::{AdmissionKind, Update, Verdict, verify};
97/// use ents_testutil::{MemRefStore, ObjectStore};
98///
99/// let refs = MemRefStore::default();
100/// let objects = ObjectStore::default();
101///
102/// // A code ref is not subject to the tip invariant.
103/// let verdict = verify(&refs, &objects, &Update {
104/// name: "refs/heads/main".try_into().expect("valid"),
105/// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)),
106/// }).expect("evaluates");
107/// let Verdict::Pass(admission) = verdict else { panic!("code refs pass") };
108/// assert_eq!(admission.kind, AdmissionKind::CodeRef);
109/// ```
110// @relation(gate.tip-signed, gate.identity-binding, gate.owner-mutation, gate.fast-forward, gate.atomic-cas, gate.epoch, gate.call-sites, gate.principled-split, scope=function)
111pub fn verify(refs: &dyn RefStoreRead, objects: &dyn Find, update: &Update) -> Result<Verdict> {
112 let old = refs.get(update.name.as_ref())?;
113 let cas = old.map_or(Expected::MustNotExist, Expected::MustExistAndMatch);
114 let pass = |kind: AdmissionKind| {
115 Ok(Verdict::Pass(Admission {
116 kind,
117 refname: update.name.clone(),
118 cas: cas.clone(),
119 }))
120 };
121
122 // The principled split: content signatures authorize only
123 // single-writer appends, which only meta-refs guarantee.
124 // @relation(gate.principled-split, scope=function)
125 if !update.name.as_bstr().starts_with(b"refs/meta/") {
126 return pass(AdmissionKind::CodeRef);
127 }
128
129 // The verification epoch: the tip invariant applies only once an
130 // epoch is recorded in refs/meta/config — or for the update that
131 // records it, which must itself be the first gated tip of the
132 // config ref (`gate.epoch`).
133 let epoch = config::current_epoch(refs, objects)?;
134 let epoch_setting = epoch.is_none()
135 && update.name.as_bstr() == ents_model::namespace::CONFIG_REF
136 && match update.new {
137 // A new config that does not parse (or has no epoch) is not
138 // epoch-setting; pre-epoch, it passes as archival anyway.
139 Some(new) => matches!(config::epoch_at_commit(objects, new), Ok(Some(_))),
140 None => false,
141 };
142 if epoch.is_none() && !epoch_setting {
143 return pass(AdmissionKind::PreEpoch);
144 }
145
146 let refuse = |requirement: Requirement, detail: String, inbox_alternative: bool| {
147 Ok(Verdict::Fail(Refusal {
148 requirement,
149 refname: update.name.clone(),
150 detail,
151 inbox_alternative,
152 }))
153 };
154
155 // Meta-refs advance fast-forward-only; deletion is not a descent
156 // and would discard the audit trail.
157 let Some(new) = update.new else {
158 return refuse(
159 Requirement::FastForward,
160 "meta-refs advance fast-forward-only; deletion is refused".into(),
161 false,
162 );
163 };
164
165 let Some(commit) = read_commit(objects, new)? else {
166 return refuse(
167 Requirement::TipSigned,
168 "the proposed tip is not a commit object, so it cannot carry a member signature".into(),
169 false,
170 );
171 };
172
173 // gate.tip-signed / gate.signature-artifact: the signature is a data
174 // artifact inside the commit object; no push certificate is read.
175 // @relation(gate.signature-artifact, scope=function)
176 let Some((payload, sig)) = signature::split_signed(&commit.raw) else {
177 return refuse(
178 Requirement::TipSigned,
179 "the proposed tip is unsigned; meta-ref mutations must be author-signed commits".into(),
180 false,
181 );
182 };
183
184 let members = policy::members(refs)?;
185 if members.is_empty() {
186 return bootstrap(objects, update, new, &commit, &payload, &sig, old, &cas);
187 }
188
189 // Identify the signer: the member whose entity *currently in
190 // force* — the member ref's tip in this same snapshot — carries the
191 // verifying key (`model.member-revocation`). No commit-supplied
192 // timestamp participates: a backdated committer date cannot reach
193 // back past a revocation.
194 let mut signer: Option<(MemberId, Member)> = None;
195 for enrolled in &members {
196 let member = policy::member_current(objects, enrolled.tip)?;
197 if signature::verifies(&member.key, &payload, &sig) {
198 signer = Some((enrolled.id.clone(), member));
199 break;
200 }
201 }
202 // A tip whose signature belongs to no authorized member is refused
203 // even when it fast-forwards cleanly — which is exactly why
204 // fast-forwarding a canonical ref directly to a contributor's
205 // commit cannot be adoption (gate.adoption-no-fast-forward).
206 // @relation(gate.tip-signed, gate.bootstrap, gate.adoption-no-fast-forward, scope=function)
207 let Some((id, member)) = signer else {
208 return refuse(
209 Requirement::TipSigned,
210 "the tip's signature does not verify against any member key currently enrolled".into(),
211 false,
212 );
213 };
214
215 // A revoked key authorizes no new pushes, full stop
216 // (`model.member-revocation`): the judgment uses the member entity
217 // currently in force, so a claimed pre-revocation committer
218 // timestamp changes nothing. Refs the key placed before the
219 // revocation landed stay valid — acceptance is never re-judged.
220 if member.state == MemberState::Revoked {
221 return refuse(
222 Requirement::TipSigned,
223 format!(
224 "member {id}'s key is revoked; new pushes are refused regardless of the \
225 commit's claimed timestamp"
226 ),
227 false,
228 );
229 }
230
231 if let Some(refusal) = authorize(&update.name, &id, &member) {
232 return Ok(Verdict::Fail(refusal));
233 }
234
235 // gate.identity-binding: recompute the refname from the tip's signed
236 // content per namespace and refuse a mismatch — a signed commit
237 // cannot be replayed as the tip of a different meta-ref than the one
238 // its content names. This is what the retired Advance-ref trailer
239 // used to assert by side channel; now the refname is a total function
240 // of the tree, the genesis oid, and the signer.
241 // @relation(gate.identity-binding, scope=function)
242 if let Some(refusal) = identity_binding(objects, &update.name, new, &commit, &id)? {
243 return Ok(Verdict::Fail(refusal));
244 }
245
246 // gate.owner-mutation: a hash-identified entity's ref advances only
247 // under its genesis signer (∪ admins); a review advances only under
248 // the member its refname names. Creation stays provenance-keyed,
249 // already judged by `authorize` above.
250 // @relation(gate.owner-mutation, scope=function)
251 if let Some(refusal) = owner_mutation(objects, &update.name, old, new, &members, &id, &member)?
252 {
253 return Ok(Verdict::Fail(refusal));
254 }
255
256 // gate.fast-forward: the parent hash is the anti-replay freshness
257 // binding; the CAS precondition below pins the same old tip.
258 // Descent through *any* parent suffices, and only the tip is
259 // signature-checked — which is what makes an authorized member's
260 // merge the adoption mechanism (gate.adoption-merge) and the
261 // resolution for a member's own racing machines
262 // (gate.same-actor-divergence).
263 // @relation(gate.fast-forward, gate.adoption-merge, gate.same-actor-divergence, scope=function)
264 if let Some(old) = old
265 && !descends_from(objects, new, old)?
266 {
267 return refuse(
268 Requirement::FastForward,
269 "the new tip does not descend from the current tip; merge the divergent heads \
270 (adoption and same-actor divergence are merges, never rewrites)"
271 .into(),
272 false,
273 );
274 }
275
276 pass(AdmissionKind::TipInvariant)
277}
278
279/// Refname-keyed authorization for an identified, active signer
280/// (`gate.tip-signed`'s "authorized for that refname").
281///
282/// The rules are exactly the ones the spec itself fixes:
283///
284/// - `refs/meta/self/<member>/*` is writable only by `<member>`
285/// (`meta-ref.inbox`, `effect.self-run`).
286/// - `refs/meta/effects/*` requires an admin-registered member
287/// regardless of anything else (`effect.admin-only`).
288/// - `refs/meta/inbox/<member>/*` is writable only by `<member>`, either
289/// provenance — admins included may not write another member's
290/// segment, because adoption is a merge onto the canonical ref, never
291/// a write into the contributor's inbox (`meta-ref.inbox`).
292/// - A self-attested member is not authorized for canonical refs — its
293/// writes are limited to its own inbox and self-run namespaces
294/// (`model.member-provenance`).
295// @relation(gate.tip-signed, effect.admin-only, model.member-provenance, scope=function)
296fn authorize(name: &FullName, id: &MemberId, member: &Member) -> Option<Refusal> {
297 let namespace = namespace::classify(name.as_ref())?;
298 let refuse = |detail: String, inbox_alternative: bool| {
299 Some(Refusal {
300 requirement: Requirement::TipSigned,
301 refname: name.clone(),
302 detail,
303 inbox_alternative,
304 })
305 };
306 match namespace {
307 Namespace::SelfRun => {
308 if namespace::self_run_owner(name.as_ref()).as_ref() == Some(id) {
309 None
310 } else {
311 refuse(
312 format!(
313 "refs/meta/self/<member>/* is writable only by that member; \
314 {id} does not own this ref"
315 ),
316 false,
317 )
318 }
319 }
320 // The inbox is owner-keyed exactly like the self-run namespace:
321 // a member — either provenance — writes only its own
322 // refs/meta/inbox/<member>/* segment, and nobody, admins
323 // included, writes another member's (meta-ref.inbox; adoption
324 // is a merge onto the canonical ref, never a write into the
325 // contributor's inbox). The legacy unscoped shape has no owner
326 // segment, so it authorizes no one.
327 Namespace::Inbox => {
328 if namespace::inbox_owner(name.as_ref()).as_ref() == Some(id) {
329 None
330 } else {
331 refuse(
332 format!(
333 "refs/meta/inbox/<member>/* is writable only by that member; \
334 {id} does not own this ref"
335 ),
336 false,
337 )
338 }
339 }
340 Namespace::Effect => match member.provenance {
341 Provenance::AdminRegistered => None,
342 Provenance::SelfAttested => refuse(
343 format!(
344 "authoring an effect schedules code execution on canonical \
345 infrastructure; {id} is not admin-registered"
346 ),
347 true,
348 ),
349 },
350 _ => match member.provenance {
351 Provenance::AdminRegistered => None,
352 Provenance::SelfAttested => refuse(
353 format!(
354 "{id}'s membership is self-attested and not authorized for canonical \
355 refs until promoted by an admin-registered member"
356 ),
357 true,
358 ),
359 },
360 }
361}
362
363/// The empty-member-list bootstrap window (`gate.bootstrap`): with no
364/// `refs/meta/member/*` ref present, a first enrollment is
365/// self-admitting — and only an enrollment. Self-admitting is taken
366/// literally: the enrollment commit must be signed by the key inside the
367/// Member tree it pushes, must bind to its refname, and must fast-forward,
368/// so even the bootstrap write satisfies every mechanically-checkable
369/// part of the tip invariant. Because this path is reachable only while
370/// the member set is empty, a member set whose keys are all revoked
371/// never reopens it: those updates take the ordinary path and fail
372/// closed on the revoked state.
373// @relation(gate.bootstrap, scope=function)
374#[expect(
375 clippy::too_many_arguments,
376 reason = "a private continuation of verify(); grouping these into a struct would only rename the arguments"
377)]
378fn bootstrap(
379 objects: &dyn Find,
380 update: &Update,
381 new: ObjectId,
382 commit: &CommitData,
383 payload: &[u8],
384 sig: &str,
385 old: Option<ObjectId>,
386 cas: &Expected,
387) -> Result<Verdict> {
388 let refuse = |detail: String| {
389 Ok(Verdict::Fail(Refusal {
390 requirement: Requirement::TipSigned,
391 refname: update.name.clone(),
392 detail,
393 inbox_alternative: false,
394 }))
395 };
396 if namespace::classify(update.name.as_ref()) != Some(Namespace::Member) {
397 return refuse(
398 "no members are enrolled; only a first member enrollment is self-admitting".into(),
399 );
400 }
401 let Ok(pushed) = facet_git_tree::deserialize::<Member>(&commit.tree, objects) else {
402 return refuse("a first enrollment must push a readable Member entity".into());
403 };
404 if !signature::verifies(&pushed.key, payload, sig) {
405 return refuse("a first enrollment must be signed by the key it enrolls".into());
406 }
407 // The same natural-key identity binding as the ordinary path
408 // (`gate.identity-binding`, `model.member-identity`): the enrolled
409 // member's own id field must recompute the refname being written, so
410 // even a bootstrap write names its ref from signed content, not a
411 // trailer.
412 // @relation(gate.identity-binding, scope=function)
413 let bound = namespace::member_ref(&pushed.id).ok();
414 if bound.as_ref() != Some(&update.name) {
415 return Ok(Verdict::Fail(Refusal {
416 requirement: Requirement::IdentityBinding,
417 refname: update.name.clone(),
418 detail: format!(
419 "the enrollment's id field is {}, which names {}, not {}",
420 pushed.id,
421 bound.map_or_else(|| "an invalid ref".to_owned(), |n| n.as_bstr().to_string()),
422 update.name.as_bstr()
423 ),
424 inbox_alternative: false,
425 }));
426 }
427 if let Some(old) = old
428 && !descends_from(objects, new, old)?
429 {
430 return Ok(Verdict::Fail(Refusal {
431 requirement: Requirement::FastForward,
432 refname: update.name.clone(),
433 detail: "the enrollment does not descend from the ref's current tip".into(),
434 inbox_alternative: false,
435 }));
436 }
437 Ok(Verdict::Pass(Admission {
438 kind: AdmissionKind::Bootstrap,
439 refname: update.name.clone(),
440 cas: cas.clone(),
441 }))
442}
443
444/// Build an [`Requirement::IdentityBinding`] refusal for `name`.
445fn binding_refusal(name: &FullName, detail: String) -> Option<Refusal> {
446 Some(Refusal {
447 requirement: Requirement::IdentityBinding,
448 refname: name.clone(),
449 detail,
450 inbox_alternative: false,
451 })
452}
453
454/// The value of a scalar tree field as UTF-8, or `None` if the entry is
455/// absent or not valid UTF-8.
456fn field_str(objects: &dyn Find, tree: ObjectId, field: &str) -> Result<Option<String>> {
457 Ok(read_tree_entry(objects, tree, field)?.and_then(|bytes| String::from_utf8(bytes).ok()))
458}
459
460/// The hex form of a raw-oid (`[u8; 20]`) tree field, or `None` when the
461/// entry is absent or not 20 bytes.
462fn field_oid_hex(objects: &dyn Find, tree: ObjectId, field: &str) -> Result<Option<String>> {
463 Ok(read_tree_entry(objects, tree, field)?.and_then(|bytes| {
464 (bytes.len() == 20).then(|| ObjectId::from_bytes_or_panic(&bytes).to_string())
465 }))
466}
467
468/// The final `/`-delimited segment of a refname.
469fn final_segment(name: &FullName) -> String {
470 name.as_bstr()
471 .to_string()
472 .rsplit('/')
473 .next()
474 .unwrap_or_default()
475 .to_owned()
476}
477
478/// A natural-key binding: the tree field `field` must equal `expected`
479/// (the refname's final segment).
480fn bind_natural_key(
481 objects: &dyn Find,
482 name: &FullName,
483 tree: ObjectId,
484 field: &str,
485 expected: &str,
486) -> Result<Option<Refusal>> {
487 match field_str(objects, tree, field)? {
488 Some(value) if value == expected => Ok(None),
489 Some(value) => Ok(binding_refusal(
490 name,
491 format!(
492 "the tree's `{field}` field is `{value}`, which names a different ref than {}",
493 name.as_bstr()
494 ),
495 )),
496 None => Ok(binding_refusal(
497 name,
498 format!(
499 "the tree carries no `{field}` field to bind {}",
500 name.as_bstr()
501 ),
502 )),
503 }
504}
505
506/// A hash-identified binding: the refname's final segment must be the
507/// genesis commit's oid, and every parentless commit reachable from the
508/// proposed tip must be that genesis (`meta-ref.identity-binding`'s
509/// all-roots rule). This, not a creation-time-only check, is what refuses
510/// replaying a signed mutation commit as a doppelgänger genesis.
511fn bind_hash_identified(
512 objects: &dyn Find,
513 name: &FullName,
514 new: ObjectId,
515) -> Result<Option<Refusal>> {
516 let segment = final_segment(name);
517 let Ok(expected) = ObjectId::from_hex(segment.as_bytes()) else {
518 return Ok(binding_refusal(
519 name,
520 format!("`{segment}` is not a genesis commit oid"),
521 ));
522 };
523 let roots = all_roots(objects, new)?;
524 if roots.is_empty() {
525 return Ok(binding_refusal(
526 name,
527 "the proposed tip has no readable genesis root to bind its id".into(),
528 ));
529 }
530 for root in roots {
531 if root != expected {
532 return Ok(binding_refusal(
533 name,
534 format!(
535 "a parentless commit {root} reachable from the proposed tip is not the \
536 genesis {expected} the refname names — a signed mutation cannot be replayed \
537 as a new entity's genesis"
538 ),
539 ));
540 }
541 }
542 Ok(None)
543}
544
545/// Reject a genesis tree that carries an entry which is not a field of its
546/// namespace's entity type, or that does not decode as that type at all
547/// (`gate.identity-binding`: strict genesis decode — the pairwise-disjoint
548/// structs, held by test, are what let this stand in for a stored
549/// `.schema` marker).
550fn strict_decode<T: for<'facet> Facet<'facet>>(
551 objects: &dyn Find,
552 name: &FullName,
553 tree: ObjectId,
554) -> Result<Option<Refusal>> {
555 let Type::User(UserType::Struct(st)) = T::SHAPE.ty else {
556 return Ok(None);
557 };
558 let fields: Vec<&str> = st.fields.iter().map(|f| f.name).collect();
559 for entry in tree_entry_names(objects, tree)? {
560 if !fields.contains(&entry.as_str()) {
561 return Ok(binding_refusal(
562 name,
563 format!(
564 "the genesis tree carries an unknown entry `{entry}` for a \
565 {} entity; strict decode refuses it",
566 T::SHAPE.type_identifier
567 ),
568 ));
569 }
570 }
571 if facet_git_tree::deserialize::<T>(&tree, objects).is_err() {
572 return Ok(binding_refusal(
573 name,
574 format!(
575 "the genesis tree does not decode as a {} entity",
576 T::SHAPE.type_identifier
577 ),
578 ));
579 }
580 Ok(None)
581}
582
583/// Recompute `name` from the proposed tip's signed content, per namespace
584/// exactly as `meta-ref.identity-binding` tabulates, returning a refusal
585/// on mismatch (`gate.identity-binding`). `None` means the binding holds.
586///
587/// The recomputation reads binding fields by tree-entry name generically
588/// (`read_tree_entry`), so it never depends on a non-kernel entity crate
589/// to bind a review's target or a toolchain's name; the one exception is
590/// strict genesis decode of a result, whose type this crate owns.
591// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function)
592fn identity_binding(
593 objects: &dyn Find,
594 name: &FullName,
595 new: ObjectId,
596 commit: &CommitData,
597 signer: &MemberId,
598) -> Result<Option<Refusal>> {
599 let Some(namespace) = namespace::classify(name.as_ref()) else {
600 return Ok(None);
601 };
602 let is_genesis = commit.parents.is_empty();
603 match namespace {
604 // Singleton state binds by its fixed name, which `classify`
605 // already established, and an unknown namespace cannot be bound by
606 // a vocabulary that does not know it (`model.extensibility`).
607 Namespace::Account | Namespace::Config | Namespace::Unknown => Ok(None),
608 Namespace::Member => {
609 bind_natural_key(objects, name, commit.tree, "id", &final_segment(name))
610 }
611 Namespace::Effect | Namespace::Toolchain => {
612 bind_natural_key(objects, name, commit.tree, "name", &final_segment(name))
613 }
614 Namespace::Comment | Namespace::Issue => bind_hash_identified(objects, name, new),
615 Namespace::Review => {
616 let Some((target, member)) = namespace::parse_review_ref(name.as_ref()) else {
617 return Ok(binding_refusal(
618 name,
619 "not a well-formed reviews/<target>/<member> refname".into(),
620 ));
621 };
622 match field_oid_hex(objects, commit.tree, "target")? {
623 Some(hex) if hex == target => {}
624 other => {
625 return Ok(binding_refusal(
626 name,
627 format!(
628 "the review's target field {} does not name the reviewed commit \
629 {target} in its refname",
630 other.unwrap_or_else(|| "(absent)".into())
631 ),
632 ));
633 }
634 }
635 if signer.as_str() != member.as_str() {
636 return Ok(binding_refusal(
637 name,
638 format!(
639 "the review is signed by {signer}, not the reviewer {member} its \
640 refname names"
641 ),
642 ));
643 }
644 Ok(None)
645 }
646 Namespace::Result | Namespace::SelfRun => {
647 let Some((effect, short_oid)) = namespace::parse_result_ref(name.as_ref()) else {
648 return Ok(binding_refusal(
649 name,
650 "not a well-formed results/<effect>/<short-oid> refname".into(),
651 ));
652 };
653 match field_str(objects, commit.tree, "effect")? {
654 Some(value) if value == effect => {}
655 other => {
656 return Ok(binding_refusal(
657 name,
658 format!(
659 "the result's effect field {} does not name {effect} in its refname",
660 other.unwrap_or_else(|| "(absent)".into())
661 ),
662 ));
663 }
664 }
665 match field_oid_hex(objects, commit.tree, "target")? {
666 Some(hex) if hex.starts_with(&short_oid) => {}
667 other => {
668 return Ok(binding_refusal(
669 name,
670 format!(
671 "the result's target field {} does not begin with the short oid \
672 {short_oid} in its refname",
673 other.unwrap_or_else(|| "(absent)".into())
674 ),
675 ));
676 }
677 }
678 if namespace == Namespace::SelfRun
679 && namespace::self_run_owner(name.as_ref()).as_ref() != Some(signer)
680 {
681 return Ok(binding_refusal(
682 name,
683 format!("a self-run result under refs/meta/self/* must be signed by {signer}"),
684 ));
685 }
686 if is_genesis
687 && let Some(refusal) = strict_decode::<ResultRecord>(objects, name, commit.tree)?
688 {
689 return Ok(Some(refusal));
690 }
691 Ok(None)
692 }
693 // A pin mirrors its entity's segments by construction and carries
694 // the empty tree; its ancestry deliberately reaches into code
695 // history, so the all-roots walk is NEVER applied to it
696 // (`meta-ref.identity-binding`).
697 Namespace::Pin => Ok(None),
698 // A claim ref is append-once: the tip IS its own genesis, so the
699 // proposed tip's own oid — never an ancestor root — must equal the
700 // refname's segment. This deliberately does NOT use `all_roots`: a
701 // claim's parents are its binding's witness commits, whose
702 // ancestry reaches into code history exactly like a pin's
703 // (`Namespace::Pin`, above), so the all-roots walk must never run
704 // on a claim.
705 Namespace::Claim => {
706 let segment = final_segment(name);
707 let Ok(expected) = ObjectId::from_hex(segment.as_bytes()) else {
708 return Ok(binding_refusal(
709 name,
710 format!("`{segment}` is not a genesis commit oid"),
711 ));
712 };
713 if new != expected {
714 return Ok(binding_refusal(
715 name,
716 format!(
717 "the proposed tip {new} is not the genesis {expected} its refname \
718 names; a changed assertion is a new claim, never an advance of an \
719 existing one"
720 ),
721 ));
722 }
723 if commit.parents.is_empty() {
724 return Ok(binding_refusal(
725 name,
726 "a claim's tip must carry at least one parent — its binding's witness; a \
727 parentless claim retains nothing"
728 .into(),
729 ));
730 }
731 // The tip is always the genesis (rule above), so strict decode
732 // always applies, unconditionally.
733 if let Some(refusal) = strict_decode::<Claim>(objects, name, commit.tree)? {
734 return Ok(Some(refusal));
735 }
736 match field_str(objects, commit.tree, "signer")? {
737 Some(value) if value == signer.as_str() => Ok(None),
738 other => Ok(binding_refusal(
739 name,
740 format!(
741 "the claim's signer field {} does not match its actual signer {signer}",
742 other.unwrap_or_else(|| "(absent)".into())
743 ),
744 )),
745 }
746 }
747 // An inbox ref binds by its owner segment equal to the signer
748 // (already enforced by `authorize`), with the canonical suffix
749 // bound exactly as its canonical namespace binds — recurse on the
750 // synthesized canonical refname.
751 Namespace::Inbox => {
752 if namespace::inbox_owner(name.as_ref()).as_ref() != Some(signer) {
753 return Ok(binding_refusal(
754 name,
755 format!("an inbox ref's owner segment must equal its signer {signer}"),
756 ));
757 }
758 let path = name.as_bstr().to_string();
759 let Some(rest) = path.strip_prefix("refs/meta/inbox/") else {
760 return Ok(None);
761 };
762 let Some((_, suffix)) = rest.split_once('/') else {
763 return Ok(None);
764 };
765 let Ok(canonical) = FullName::try_from(format!("refs/meta/{suffix}")) else {
766 return Ok(None);
767 };
768 identity_binding(objects, &canonical, new, commit, signer)
769 }
770 // `Namespace` is `#[non_exhaustive]`; a variant this build does
771 // not know is treated like `Unknown` — unbindable by a vocabulary
772 // that cannot interpret it (`model.extensibility`).
773 _ => Ok(None),
774 }
775}
776
777/// Ownership keys mutation (`gate.owner-mutation`): a hash-identified
778/// entity's ref advances only under its genesis signer or an
779/// admin-registered member; a review advances only under the member its
780/// refname names. Creation stays provenance-keyed (judged by `authorize`),
781/// so this fires only on an advance.
782// @relation(gate.owner-mutation, scope=function)
783fn owner_mutation(
784 objects: &dyn Find,
785 name: &FullName,
786 old: Option<ObjectId>,
787 new: ObjectId,
788 members: &[Enrolled],
789 signer_id: &MemberId,
790 signer: &Member,
791) -> Result<Option<Refusal>> {
792 let Some(namespace) = namespace::classify(name.as_ref()) else {
793 return Ok(None);
794 };
795 let refuse = |detail: String| {
796 Some(Refusal {
797 requirement: Requirement::TipSigned,
798 refname: name.clone(),
799 detail,
800 inbox_alternative: false,
801 })
802 };
803 let is_admin = signer.provenance == Provenance::AdminRegistered;
804 match namespace {
805 // A comment or issue's mutation owner is exactly its genesis
806 // signer (∪ admins).
807 Namespace::Comment | Namespace::Issue => {
808 // Creation is provenance-keyed; only an advance is owner-keyed.
809 if old.is_none() {
810 return Ok(None);
811 }
812 if is_admin {
813 return Ok(None);
814 }
815 let genesis = all_roots(objects, new)?;
816 let genesis_signer = match genesis.first() {
817 Some(root) => commit_signer(objects, members, *root)?,
818 None => None,
819 };
820 if genesis_signer.as_ref() == Some(signer_id) {
821 Ok(None)
822 } else {
823 Ok(refuse(format!(
824 "{signer_id} is neither the member whose signature this entity's genesis \
825 carries nor an admin-registered member, so may not advance {}",
826 name.as_bstr()
827 )))
828 }
829 }
830 Namespace::Review => {
831 let Some((_, member)) = namespace::parse_review_ref(name.as_ref()) else {
832 return Ok(None);
833 };
834 if signer_id.as_str() == member.as_str() {
835 Ok(None)
836 } else {
837 Ok(refuse(format!(
838 "a review advances only under the signature of {member}, the reviewer its \
839 refname names, not {signer_id}"
840 )))
841 }
842 }
843 _ => Ok(None),
844 }
845}
846
847/// The enrolled member whose currently-in-force key signed `oid`, or
848/// `None` when the commit is unsigned or signed by no enrolled member —
849/// used to recover a hash-identified entity's genesis signer
850/// (`gate.owner-mutation`).
851fn commit_signer(
852 objects: &dyn Find,
853 members: &[Enrolled],
854 oid: ObjectId,
855) -> Result<Option<MemberId>> {
856 let Some(commit) = read_commit(objects, oid)? else {
857 return Ok(None);
858 };
859 let Some((payload, sig)) = signature::split_signed(&commit.raw) else {
860 return Ok(None);
861 };
862 for enrolled in members {
863 let member = policy::member_current(objects, enrolled.tip)?;
864 if signature::verifies(&member.key, &payload, &sig) {
865 return Ok(Some(enrolled.id.clone()));
866 }
867 }
868 Ok(None)
869}