gate: key inbox authorization on the member segment
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
No reviews of this commit yet — record a verdict below.
Start a review
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-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(),