git-ents.gitmain
⌘K
foforge
commit e5ef2c1
model: pin the resolver's member record into comment state mutations

A resolve or reopen whose signing key matches an enrolled member now carries a Key-for-<member-id> trailer naming the member ref’s tip commit oid at mutation time, so which member acted, and under which key, survives any later rotation or revocation. An unenrolled signer writes no trailer. The CLI, the web UI, and the lens all pass their signer’s key through the shared seam.

spec: add model.comment-provenance docs: correct the plan’s serve item to printing only 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

docs/web-workbench-plan.adoc @@ -33,11 +33,11 @@ Parallel-safe, each its own commit; land on `web-ui-polish` or a successor branch. -* `serve` prints and opens `http://<repo-dir>.localhost:<port>` instead +* `serve` prints `http://<repo-dir>.localhost:<port>` instead of `127.0.0.1` — `*.localhost` resolves in-browser (RFC 6761) and is a secure context, so no hosts file, no DNS, no "Not secure" chip. Verified live 2026-07-13. Safari caveat documented (system resolver; - one `/etc/hosts` line). + one `/etc/hosts` line). Opening the browser is Phase D's `--web` work. * Fix the commit diff view repeating every subdirectory as its own entry in the changed-file list. * Effect adder: form + POST route (there is currently no way to add an
docs/spec/model.adoc @@ -120,6 +120,15 @@ (<<meta-ref.identity-binding>>), never from stored fields. -- +[role="requirement", id="model.comment-provenance"] +.Comment State Provenance +-- +A mutation that changes a comment's state MUST, when the mutating tool's signing key matches an enrolled member's stored key, carry a `Key-for-<member-id>` trailer in its commit message. +The trailer's value MUST be the member ref's tip commit oid at mutation time, pinning the resolver's whole enrolled record — key, state, provenance — into the chain, so which member acted, and under which key, stays answerable after any later key rotation or revocation (<<meta-ref.identity-binding>>). +A signer with no enrolled member writes no trailer; the mutation itself is still recorded. +The trailer is the writing tool's duty; the gate stays content-agnostic (<<model.comment-state>>). +-- + [role="requirement", id="model.comment-context"] .Context Aggregates, Never Contains --
crates/cli/ents-lens/src/lens.rs @@ -272,6 +272,7 @@ &id, &identity, self.mode, + Some(signer.public_openssh()), )?; Ok(Outcome { refresh: true,
crates/forge/ents-forge/tests/conversations.rs @@ -12,8 +12,10 @@ )] use ents_forge::comment::{self, ListFilter, NewComment}; +use ents_model::{Member, MemberId, Provenance}; use ents_receive::{Identity, Mode, NullEventSink, TxResult}; use ents_testutil::{Keypair, MemRefStore, ObjectStore}; +use gix_ref_store::RefStoreRead as _; use rstest::rstest; /// A throwaway on-disk repository holding one committed file — the @@ -208,6 +210,7 @@ &id, &fixture.identity(), Mode::Advisory, + None, ) .expect("resolves"); assert_eq!(outcome.result, TxResult::Applied); @@ -220,6 +223,7 @@ &id, &fixture.identity(), Mode::Advisory, + None, ) .expect("reopens"); assert_eq!(outcome.result, TxResult::Applied); @@ -310,6 +314,7 @@ &unanchored, &fixture.identity(), Mode::Advisory, + None, ) .expect("resolves"); @@ -420,3 +425,97 @@ } ); } + +// --------------------------------------------------------------------- +// model.comment-provenance: a state change pins the resolver's record. +// --------------------------------------------------------------------- + +/// The commit message at `id`'s comment ref tip. +fn tip_message(fixture: &Fixture, id: &str) -> String { + use gix_object::Find as _; + let name = ents_model::namespace::comment_ref(id).expect("valid"); + let tip = fixture + .refs + .get(name.as_ref()) + .expect("reads") + .expect("exists"); + let mut buf = Vec::new(); + let data = fixture + .objects + .try_find(&tip, &mut buf) + .expect("finds") + .expect("exists"); + gix_object::CommitRef::from_bytes(data.data, tip.kind()) + .expect("commit") + .message + .to_string() +} + +/// Resolving with an enrolled member's key writes a `Key-for-<id>` +/// trailer naming the member ref's tip commit at resolve time; a signer +/// whose key matches no enrolled member writes none. +#[rstest] +// @relation(model.comment-provenance, scope=function, role=Verifies) +fn resolving_pins_the_resolvers_member_record() { + let fixture = Fixture::new(); + let id = fixture.add(fixture.draft()); + + // Enroll the fixture's signer as member `joey` through the real + // proposal path, then capture the member ref's tip. + let member = Member::new( + MemberId::new("joey"), + Keypair::from_seed(1).public_openssh(), + Provenance::AdminRegistered, + ); + let name = ents_model::namespace::member_ref(&MemberId::new("joey")).expect("valid"); + let outcome = ents_receive::propose_entity( + &fixture.refs, + &fixture.objects, + &NullEventSink, + name.clone(), + &member, + &fixture.identity(), + "Enroll joey", + Mode::Advisory, + ) + .expect("enrolls"); + assert_eq!(outcome.result, TxResult::Applied); + let member_tip = fixture + .refs + .get(name.as_ref()) + .expect("reads") + .expect("exists"); + + comment::resolve( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &id, + &fixture.identity(), + Mode::Advisory, + Some(&Keypair::from_seed(1).public_openssh()), + ) + .expect("resolves"); + let message = tip_message(&fixture, &id); + assert!( + message.contains(&format!("Key-for-joey: {member_tip}")), + "the resolve mutation pins the member record: {message}" + ); + + // A key matching no enrolled member (seed 2) writes no trailer. + comment::reopen( + &fixture.refs, + &fixture.objects, + &NullEventSink, + &id, + &fixture.identity(), + Mode::Advisory, + Some(&Keypair::from_seed(2).public_openssh()), + ) + .expect("reopens"); + let message = tip_message(&fixture, &id); + assert!( + !message.contains("Key-for-"), + "an unenrolled signer writes no trailer: {message}" + ); +}
crates/cli/ents-web/src/pages/comments.rs @@ -292,6 +292,7 @@ &id, &crate::receive_identity!(identity), state.mode, + Some(&identity.public_openssh()), )?; crate::error::outcome_to_result(outcome)?; Ok(redirect_back(&form.return_to, &id)) @@ -323,6 +324,7 @@ &id, &crate::receive_identity!(identity), state.mode, + Some(&identity.public_openssh()), )?; crate::error::outcome_to_result(outcome)?; Ok(redirect_back(&form.return_to, &id))
crates/cli/git-ents/src/commands/comment.rs @@ -129,6 +129,7 @@ id, &identity, root.mode(), + Some(&signer.public_openssh()), )? } else { comment::reopen( @@ -138,6 +139,7 @@ id, &identity, root.mode(), + Some(&signer.public_openssh()), )? }; outcome_to_result(outcome, None)?;
crates/forge/ents-forge/src/comment/command.rs @@ -379,13 +379,17 @@ /// `git ents comment resolve`: record state `resolved` as an ordinary /// mutation commit on the comment's own ref — never a deletion, so the -/// conversation stays auditable (`model.comment-state`). +/// conversation stays auditable (`model.comment-state`). When +/// `resolver_key` (the signer's openssh public key) matches an enrolled +/// member, the mutation carries a `Key-for-<member-id>` trailer naming +/// that member ref's tip commit oid (`model.comment-provenance`); an +/// unenrolled signer writes no trailer. /// /// # Errors /// /// [`Error::NotFound`] if `id` has no comment ref; otherwise propagates /// read, serialization, or `receive` failures. -// @relation(model.comment-state, lens.parity, scope=function) +// @relation(model.comment-state, model.comment-provenance, lens.parity, scope=function) pub fn resolve( refs: &dyn RefStore, objects: &(impl Find + Write), @@ -393,8 +397,18 @@ id: &str, identity: &Identity<'_>, mode: Mode, + resolver_key: Option<&str>, ) -> Result<Outcome> { - set_state(refs, objects, events, id, "resolved", identity, mode) + set_state( + refs, + objects, + events, + id, + "resolved", + identity, + mode, + resolver_key, + ) } /// `git ents comment reopen`: record state `open` again, the same way @@ -411,13 +425,29 @@ id: &str, identity: &Identity<'_>, mode: Mode, + resolver_key: Option<&str>, ) -> Result<Outcome> { - set_state(refs, objects, events, id, "open", identity, mode) + set_state( + refs, + objects, + events, + id, + "open", + identity, + mode, + resolver_key, + ) } /// The shared state mutation [`resolve`] and [`reopen`] are: read the /// comment at `id`, set `state`, and propose the new tree on top of the -/// old tip. +/// old tip -- carrying the resolver's `Key-for-<member-id>` trailer when +/// `resolver_key` names an enrolled member (`model.comment-provenance`). +// @relation(model.comment-provenance, scope=function) +#[expect( + clippy::too_many_arguments, + reason = "the shared mutation seam plus the provenance key; the only callers are resolve/reopen's thin forwards" +)] fn set_state( refs: &dyn RefStore, objects: &(impl Find + Write), @@ -426,22 +456,50 @@ state: &str, identity: &Identity<'_>, mode: Mode, + resolver_key: Option<&str>, ) -> Result<Outcome> { let mut comment = comment_at(refs, objects, id)?; comment.state = state.to_owned(); let ref_name = ents_model::namespace::comment_ref(id)?; + let mut message = format!("Mark comment {id} {state}"); + if let Some(trailer) = resolver_key.and_then(|key| key_trailer(refs, objects, key)) { + message.push_str("\n\n"); + message.push_str(&trailer); + } Ok(propose_entity( - refs, - objects, - events, - ref_name, - &comment, - identity, - &format!("Mark comment {id} {state}"), - mode, + refs, objects, events, ref_name, &comment, identity, &message, mode, )?) } +/// The `Key-for-<member-id>: <oid>` trailer for the enrolled member whose +/// stored key matches `pubkey` (`model.comment-provenance`): the oid is +/// the member ref's tip commit at this moment, pinning the resolver's +/// whole enrolled record -- key, state, provenance -- into the mutation +/// chain. `None` when no member's key matches (an unenrolled signer +/// writes no trailer) or when the member listing cannot be read; a state +/// mutation never fails for want of provenance. +// @relation(model.comment-provenance, scope=function) +fn key_trailer(refs: &dyn RefStore, objects: &impl Find, pubkey: &str) -> Option<String> { + let entries = refs.iter_prefix("refs/meta/member/").ok()?; + for entry in entries.flatten() { + let (name, tip) = entry; + let path = name.as_bstr().to_string(); + let Some(id) = path.strip_prefix("refs/meta/member/") else { + continue; + }; + let Ok(tree) = commit_tree(objects, tip) else { + continue; + }; + let Ok(member) = facet_git_tree::deserialize::<ents_model::Member>(&tree, objects) else { + continue; + }; + if member.key == pubkey { + return Some(format!("Key-for-{id}: {tip}")); + } + } + None +} + /// `git ents comment show`: `id`'s comment and — when it carries an /// anchor — that anchor, projected onto `rev` or (with `worktree`) onto /// the working tree (`anchor.working-tree`).