git-ents.gitmain
⌘K
foforge
commit df225ce
gate: key inbox authorization on the member segment

refs/meta/inbox/<member>/<id> resolves the open inbox shape: the authorize() Inbox arm now mirrors the SelfRun arm — a member of either provenance is admitted only for its own segment, and nobody, admins included, writes another member’s inbox, since adoption is a merge onto the canonical ref. The fail-closed spec-gap refusal is gone; the unscoped legacy shape owns nothing and stays refused. Docs follow the gate.adoption-no-cherry-pick move to sync.adoption-no-cherry-pick, and ents-query’s rev() docs now state the query.rev subset as normative rather than deferred.

query: annotate RevExpr/parse_term with query.rev; docs read the narrowed requirement as normative Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

crates/ents-gate/src/lib.rs @@ -47,21 +47,27 @@ //! and `gate.advisory-local` are caller policies (`ents-receive`, the //! composition roots) — this crate contributes the shared verdict and, //! for the advisory sites, the verdict-time reason rendering including -//! the inbox alternative ([`Refusal`]). `gate.adoption-no-cherry-pick` -//! is a prohibition on adoption *tooling* (`ents-sync`): a cherry-pick -//! produces an ordinary commit by the placer, which no pure function -//! over the result could distinguish, so the gate has nothing to check. +//! the inbox alternative ([`Refusal`]). The prohibition on cherry-pick +//! as an adoption mechanism binds the adoption *tooling* +//! (`sync.adoption-no-cherry-pick`, enforced by `ents-sync`): a +//! cherry-pick produces an ordinary commit by the placer, which no pure +//! function over the result could distinguish, so the gate has nothing +//! to check — gate.sdoc's Adoption section says the same. //! //! # Authorization model //! //! "Signed by a member authorized for that refname" uses exactly the -//! rules the spec pins today: self-run namespaces are owner-only, -//! `refs/meta/effects/*` is admin-only (`effect.admin-only`), and -//! self-attested members are refused canonical refs until promoted -//! (`model.member-provenance`). Finer-grained, config-stored refname -//! rules (for example designating worker keys for one effect's results -//! namespace, `effect.official`) are a later, additive narrowing: they -//! arrive with a Config entity in `ents-model`, not a new gate. +//! rules the spec pins today: self-run and inbox namespaces are +//! owner-only — a member of either provenance writes only its own +//! `refs/meta/self/<member>/*` and `refs/meta/inbox/<member>/*` +//! segments, and nobody, admins included, writes another member's +//! (`meta-ref.inbox`) — `refs/meta/effects/*` is admin-only +//! (`effect.admin-only`), and self-attested members are refused +//! canonical refs until promoted (`model.member-provenance`). +//! Finer-grained, config-stored refname rules (for example designating +//! worker keys for one effect's results namespace, `effect.official`) +//! are a later, additive narrowing: they arrive with a Config entity in +//! `ents-model`, not a new gate. //! //! Signature-time semantics: a signature is judged against the member //! entity in force *at the commit's own timestamp*, recovered by walking
crates/ents-gate/src/verdict.rs @@ -134,7 +134,7 @@ if self.inbox_alternative { write!( f, - "; you can still submit this change under refs/meta/inbox/* for adoption by an authorized member" + "; you can still submit this change under your own refs/meta/inbox/<member>/* segment for adoption by an authorized member" )?; } Ok(())
crates/ents-gate/src/verify.rs @@ -271,15 +271,13 @@ /// (`meta-ref.inbox`, `effect.self-run`). /// - `refs/meta/effects/*` requires an admin-registered member /// regardless of anything else (`effect.admin-only`). +/// - `refs/meta/inbox/<member>/*` is writable only by `<member>`, either +/// provenance — admins included may not write another member's +/// segment, because adoption is a merge onto the canonical ref, never +/// a write into the contributor's inbox (`meta-ref.inbox`). /// - A self-attested member is not authorized for canonical refs — its /// writes are limited to its own inbox and self-run namespaces /// (`model.member-provenance`). -/// - `refs/meta/inbox/*`: the spec's "its own inbox" cannot be keyed on -/// the refname because `refs/meta/inbox/*` encodes no member segment -/// (open question between `meta-ref.inbox` and -/// `model.member-provenance`); until the spec picks a shape, the gate -/// fails closed for self-attested members and admits admin-registered -/// ones, and says so in the refusal. // @relation(gate.tip-signed, effect.admin-only, model.member-provenance, scope=function) fn authorize(name: &FullName, id: &MemberId, member: &Member) -> Option<Refusal> { let namespace = namespace::classify(name.as_ref())?; @@ -305,17 +303,26 @@ ) } } - Namespace::Inbox => match member.provenance { - Provenance::AdminRegistered => None, - Provenance::SelfAttested => refuse( - "inbox authorization for self-attested members is not yet decidable: \ - refs/meta/inbox/* encodes no member segment, so \"its own inbox\" \ - (model.member-provenance) cannot be keyed on the refname; failing closed \ - until the spec picks a shape" - .into(), - false, - ), - }, + // The inbox is owner-keyed exactly like the self-run namespace: + // a member — either provenance — writes only its own + // refs/meta/inbox/<member>/* segment, and nobody, admins + // included, writes another member's (meta-ref.inbox; adoption + // is a merge onto the canonical ref, never a write into the + // contributor's inbox). The legacy unscoped shape has no owner + // segment, so it authorizes no one. + Namespace::Inbox => { + if namespace::inbox_owner(name.as_ref()).as_ref() == Some(id) { + None + } else { + refuse( + format!( + "refs/meta/inbox/<member>/* is writable only by that member; \ + {id} does not own this ref" + ), + false, + ) + } + } Namespace::Effect => match member.provenance { Provenance::AdminRegistered => None, Provenance::SelfAttested => refuse(
crates/ents-gate/tests/gate.rs @@ -280,7 +280,6 @@ #[case::canonical_issue("refs/meta/issues/1", true)] #[case::canonical_result("refs/meta/results/unit/abc", true)] #[case::effects_is_admin_only("refs/meta/effects/unit", true)] -#[case::inbox_shape_is_an_open_spec_question("refs/meta/inbox/xyz", false)] #[case::another_self_run("refs/meta/self/admin/unit/abc", false)] // @relation(model.member-provenance, effect.admin-only, gate.tip-signed, scope=function, role=Verifies) fn self_attested_members_are_limited_to_their_own_namespaces( @@ -309,9 +308,31 @@ expect_pass(&run(&f, refname, Some(new)), AdmissionKind::TipInvariant); } +#[rstest] +#[case::own_segment_self_attested(false, "refs/meta/inbox/guest/issue-1", true)] +#[case::own_segment_admin(true, "refs/meta/inbox/admin/issue-1", true)] +#[case::foreign_segment_self_attested(false, "refs/meta/inbox/admin/issue-1", false)] +#[case::foreign_segment_even_for_admins(true, "refs/meta/inbox/guest/issue-1", false)] +#[case::unscoped_legacy_shape_owns_nothing(true, "refs/meta/inbox/issue-1", false)] +// @relation(meta-ref.inbox, model.member-provenance, gate.tip-signed, scope=function, role=Verifies) +fn inbox_segments_are_owner_only_for_both_provenances( + #[case] as_admin: bool, + #[case] refname: &str, + #[case] admitted: bool, +) { + let f = forge(); + let key = if as_admin { &f.admin } else { &f.guest }; + let new = proposal(&f, vec![], Some(refname), Some(key), 300); + let verdict = run(&f, refname, Some(new)); + if admitted { + expect_pass(&verdict, AdmissionKind::TipInvariant); + } else { + expect_fail(&verdict, Requirement::TipSigned); + } +} + #[rstest] #[case::effects("refs/meta/effects/unit")] -#[case::inbox("refs/meta/inbox/xyz")] #[case::results("refs/meta/results/unit/abc")] // @relation(effect.admin-only, gate.tip-signed, scope=function, role=Verifies) fn admin_registered_members_may_write_canonical_namespaces(#[case] refname: &str) {
crates/ents-query/src/lib.rs @@ -21,11 +21,13 @@ //! - `query.grammar`, `query.set-ops` — [`Query`], the parser, and //! [`SetOp`]; left-associative, one precedence level. //! - `query.rev` — [`RevExpr`]; `refs/meta/*` patterns are rejected at -//! parse time, never silently evaluated. The supported revspec -//! surface is refnames, `refs/` globs, full hex oids, `^negation`, -//! and `A..B`; other revspec forms are an explicit -//! [`ParseError::UnsupportedRev`], a deliberate subset deferred until -//! a consumer needs it. +//! parse time, never silently evaluated. The supported surface is +//! exactly the rev-list-shaped subset the requirement states — +//! refnames (short or full), `refs/` globs, full hex oids, +//! `^negation`, and `A..B` — and every form outside it (`~n`/`^n`, +//! `A...B`, `@{...}`, abbreviated hex) is an explicit +//! [`ParseError::UnsupportedRev`]; growing the subset is a +//! compatible, additive extension. //! - `query.results` — resolution is a refname scan of the effect's //! results namespace; membership tests compare hex prefixes and walk //! no history.
crates/ents-query/src/rev.rs @@ -52,13 +52,15 @@ } /// A parsed `rev()` expression: whitespace-separated terms, `^`-negated -/// terms subtracted, `A..B` sugar for `^A B` — the rev-list shape of -/// `query.rev`'s "ordinary Git revspec or ref glob". +/// terms subtracted, `A..B` sugar for `^A B` — exactly the +/// rev-list-shaped subset of gitrevisions(7) that `query.rev` +/// normatively states. /// -/// Unsupported revspec forms (`~n`/`^n` suffixes, `...`, `@{...}`, +/// The forms outside the subset (`~n`/`^n` suffixes, `A...B`, `@{...}`, /// `^{...}`, abbreviated hex) are an explicit [`ParseError`], never a -/// silent empty set; the supported surface is what the composition -/// idioms and `query.rev`'s own examples use. +/// silent empty or wrong set, exactly as the requirement demands; +/// growing the subset is a compatible, additive extension. +// @relation(query.rev, scope=file) #[derive(Debug, Clone, PartialEq, Eq)] pub struct RevExpr { include: Vec<RevTerm>, @@ -140,7 +142,8 @@ } /// Parse one term, rejecting `refs/meta/*` shapes (`query.rev`) and -/// revspec operators this evaluator does not support. +/// every revspec operator outside the requirement's stated subset. +// @relation(query.rev, scope=function) fn parse_term(term: &str, whole_token: &str) -> Result<RevTerm, ParseError> { let unsupported = || ParseError::UnsupportedRev { token: whole_token.to_owned(),