receive: split Identity into signing committer and attributed author
commit
585e1a4receive: split Identity into signing committer and attributed author
Identity.actor stays the committer and signer; a new optional author field carries an attributed member (receive.attributed-author), so a hosted web edit lands as "member via the web" while the gate keeps judging the signature alone. author: None at every existing construction site, a signed_commit test proving the override lands in the author slot only, and rustfmt on layering.rs.
Assisted-by: Claude:claude-fable-5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-lens/src/lens.rs
@@ -263,6 +263,7 @@
let sign = |payload: &[u8]| signer.sign(payload);
let identity = Identity {
actor: signer.actor(),
+ author: None,
sign: &sign,
};
comment::resolve(
@@ -394,6 +395,7 @@
let sign = |payload: &[u8]| signer.sign(payload);
let identity = Identity {
actor: signer.actor(),
+ author: None,
sign: &sign,
};
if let Some(parent) = composed.target.parent {
crates/cli/ents-lens/tests/lens.rs
@@ -88,6 +88,7 @@
let sign = |payload: &[u8]| key.sign(payload);
let identity = Identity {
actor: self.actor(),
+ author: None,
sign: &sign,
};
let (id, _outcome) = comment::add(
@@ -203,6 +204,7 @@
let sign = |payload: &[u8]| key.sign(payload);
let identity = Identity {
actor: fixture.actor(),
+ author: None,
sign: &sign,
};
comment::reply(
crates/cli/git-ents/src/lib.rs
@@ -61,7 +61,7 @@
//! name: "jdc".into(), email: "jdc@ents.test".into(),
//! time: gix::date::Time { seconds: 1_000, offset: 0 },
//! };
-//! let identity = Identity { actor, sign: &|payload| signer.sign(payload) };
+//! let identity = Identity { actor, author: None, sign: &|payload| signer.sign(payload) };
//!
//! let member = ents_model::Member::new("jdc", signer.public_openssh(), Provenance::AdminRegistered);
//! let name = ents_model::namespace::member_ref(&MemberId::new("jdc")).expect("valid");
crates/cli/git-ents/tests/layering.rs
@@ -162,7 +162,10 @@
continue;
}
assert!(
- package.dependencies.iter().all(|dependency| dependency.name != "ents-verify"),
+ package
+ .dependencies
+ .iter()
+ .all(|dependency| dependency.name != "ents-verify"),
"{} must not depend on ents-verify: verify/ is a sink layer nothing else may depend on",
package.name
);
crates/forge/ents-forge/tests/conversations.rs
@@ -72,6 +72,7 @@
offset: 0,
},
},
+ author: None,
sign: &*self.sign,
}
}
crates/kernel/ents-receive/src/propose.rs
@@ -35,11 +35,18 @@
use crate::sink::EventSink;
/// Everything [`propose_entity`] needs about the acting identity: the
-/// commit author/committer signature and a signing function producing the
-/// `gpgsig` header's armored payload.
+/// commit committer signature, an optional attributed author, and a
+/// signing function producing the `gpgsig` header's armored payload.
+// @relation(receive.attributed-author, scope=type)
pub struct Identity<'a> {
- /// The author and committer signature every mutation commit carries.
+ /// The committer signature every mutation commit carries — always the
+ /// signing identity (`receive.attributed-author`).
pub actor: gix::actor::Signature,
+ /// A distinct attributed author, so history reads "member via the
+ /// web" (`receive.attributed-author`, `roots.web-signing`); `None`
+ /// means the author is `actor`, the unattributed common case. The
+ /// gate keys authorization off the signer, never this field.
+ pub author: Option<gix::actor::Signature>,
/// Signs a commit payload, returning the armored SSHSIG PEM block git
/// stores in the `gpgsig` header — a closure rather than a concrete
/// signer type so this crate never depends on how a caller loads or
@@ -92,6 +99,7 @@
/// email: "admin@ents.test".into(),
/// time: gix::date::Time { seconds: 300, offset: 0 },
/// },
+/// author: None,
/// sign: &|payload| admin.sign(payload),
/// };
///
@@ -195,6 +203,7 @@
/// email: "admin@ents.test".into(),
/// time: gix::date::Time { seconds: 300, offset: 0 },
/// },
+/// author: None,
/// sign: &|payload| admin.sign(payload),
/// };
///
@@ -288,6 +297,7 @@
/// email: "admin@ents.test".into(),
/// time: gix::date::Time { seconds: 300, offset: 0 },
/// },
+/// author: None,
/// sign: &|payload| admin.sign(payload),
/// };
///
@@ -393,6 +403,7 @@
/// email: "admin@ents.test".into(),
/// time: gix::date::Time { seconds: 300, offset: 0 },
/// },
+/// author: None,
/// sign: &|payload| admin.sign(payload),
/// };
///
@@ -466,7 +477,10 @@
let mut commit = Commit {
tree,
parents: parents.into(),
- author: identity.actor.clone(),
+ author: identity
+ .author
+ .clone()
+ .unwrap_or_else(|| identity.actor.clone()),
committer: identity.actor.clone(),
encoding: None,
message: message.into(),
crates/kernel/ents-receive/tests/receive.rs
@@ -454,6 +454,7 @@
offset: 0,
},
},
+ author: None,
sign: &|payload| forge.admin.sign(payload),
};
let rounds = chain_commits(&forge.objects, 2, 250);
@@ -530,6 +531,7 @@
offset: 0,
},
},
+ author: None,
sign: &|payload| forge.guest.sign(payload),
};
let reviewed = chain_commits(&forge.objects, 1, 250)[0];
@@ -675,6 +677,7 @@
offset: 0,
},
},
+ author: None,
sign: &|payload| forge.guest.sign(payload),
};
@@ -699,3 +702,67 @@
);
assert!(forge.refs.get(landed.as_ref()).expect("readable").is_some());
}
+
+/// An attributed mutation ("member via the web") carries the attributed
+/// member in the commit's author slot while the committer — and the
+/// signature the gate judges — stays the signing identity; the mandatory
+/// gate admits it exactly as it would the unattributed form
+/// (`receive.attributed-author`).
+// @relation(receive.attributed-author, scope=function, role=Verifies)
+#[test]
+fn an_attributed_author_lands_in_the_author_slot_and_the_gate_judges_the_signer() {
+ use gix_object::Find as _;
+
+ let forge = forge();
+ let identity = Identity {
+ actor: gix::actor::Signature {
+ name: "admin".into(),
+ email: "admin@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 300,
+ offset: 0,
+ },
+ },
+ author: Some(gix::actor::Signature {
+ name: "guest".into(),
+ email: "guest@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 290,
+ offset: 0,
+ },
+ }),
+ sign: &|payload| forge.admin.sign(payload),
+ };
+ let refname = namespace::redaction_ref("r-attr").expect("valid");
+ let redaction = Redaction::new(ObjectId::null(gix_hash::Kind::Sha1), "leaked credential");
+
+ let outcome = ents_receive::propose_entity(
+ &forge.refs,
+ &forge.objects,
+ &NullEventSink,
+ refname.clone(),
+ &redaction,
+ &identity,
+ "Redact, attributed",
+ Mode::Mandatory,
+ )
+ .expect("reaches an outcome");
+ assert_eq!(outcome.result, TxResult::Applied, "{:?}", outcome.verdicts);
+
+ let tip = forge
+ .refs
+ .get(refname.as_ref())
+ .expect("readable")
+ .expect("written");
+ let mut buf = Vec::new();
+ let data = forge
+ .objects
+ .try_find(&tip, &mut buf)
+ .expect("readable")
+ .expect("present");
+ let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("parses");
+ let author = commit.author().expect("author parses");
+ let committer = commit.committer().expect("committer parses");
+ assert_eq!(author.name, "guest", "attributed author");
+ assert_eq!(committer.name, "admin", "signing committer");
+}
crates/cli/git-ents/src/commands/account.rs
@@ -76,6 +76,7 @@
.expect("fixed, valid refname");
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let outcome = propose_entity(
crates/cli/git-ents/src/commands/comment.rs
@@ -59,6 +59,7 @@
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let (id, outcome) = comment::add(
@@ -89,6 +90,7 @@
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let (id, outcome) = comment::reply(
@@ -119,6 +121,7 @@
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let outcome = if resolve {
crates/cli/git-ents/src/commands/effect.rs
@@ -65,6 +65,7 @@
let ref_name = namespace::effect_ref(name)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let outcome = propose_entity(
crates/cli/git-ents/src/commands/issue.rs
@@ -64,6 +64,7 @@
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let new = NewIssue {
@@ -103,6 +104,7 @@
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let edit = EditIssue {
crates/cli/git-ents/src/commands/redact.rs
@@ -60,6 +60,7 @@
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let outcome = propose_entity(
crates/cli/git-ents/src/commands/review.rs
@@ -33,6 +33,7 @@
let member = reviewer_member_id(root, &signer)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let (target, outcome) = review::new(
@@ -65,7 +66,9 @@
/// Propagates a ref-store or object read failure.
fn reviewer_member_id(root: &LocalRoot, signer: &crate::sign::Signer) -> Result<MemberId> {
let pubkey = signer.public_openssh();
- if let Some((username, _state)) = super::members::find_by_key(root, &pubkey)? {
+ if let Some((username, _state)) =
+ super::members::find_by_key(&root.refs, &root.objects, &pubkey)?
+ {
return Ok(MemberId::new(username));
}
Ok(MemberId::new(super::short_fingerprint(signer)))
crates/cli/git-ents/src/commands/toolchain.rs
@@ -37,6 +37,7 @@
let signer = signer(root, key)?;
let identity = Identity {
actor: actor(&signer),
+ author: None,
sign: &|payload| signer.sign(payload),
};
let outcome = toolchain::import(