effect: write results as a ResultRecord naming effect and commit
commit
de2c1c6effect: write results as a ResultRecord naming effect and commit
write_result now serializes a ResultRecord carrying the effect name and judged commit oid (model.result-identity), from which the gate recomputes the results refname; a signed pass can no longer be replayed for a different effect or commit. The query evaluator reads the status out of that record.
feat: write_result builds a ResultRecord (model.result-identity)
refactor: read ResultRecord.status in the query evaluator
Assisted-by: Claude:claude-opus-4-8
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/kernel/ents-effect/src/definition.rs
@@ -33,6 +33,7 @@
/// use ents_model::Effect;
///
/// let good = Effect {
+/// name: "unit".into(),
/// trigger: "rev(refs/heads/main)".into(),
/// toolchains: vec!["rust-stable".into()],
/// run: "cargo test".into(),
@@ -65,6 +66,7 @@
fn effect(trigger: &str, toolchains: &[&str]) -> Effect {
Effect {
+ name: "unit".to_owned(),
trigger: trigger.to_owned(),
toolchains: toolchains.iter().map(|s| (*s).to_owned()).collect(),
run: "true".to_owned(),
crates/kernel/ents-effect/src/lib.rs
@@ -99,6 +99,7 @@
//! // is the composition root's job (via `ents-kiln`), not this crate's —
//! // `run_effect` only ever receives an already-materialized slice.
//! let effect = Effect {
+//! name: "unit".into(),
//! trigger: "rev(refs/heads/main)".into(),
//! toolchains: vec![],
//! run: "true".into(),
crates/kernel/ents-effect/src/results.rs
@@ -2,26 +2,33 @@
//! `effect.identity`): an ordinary [`ents_receive::receive`] client, never a
//! privileged write outside the gate.
//!
-//! [`write_result`] builds the [`ents_model::Status`] typed tree, seals it
-//! into a signed commit exactly the way [`ents_sync::resolve::merge_heads`]
-//! seals a merge tip (`sign` is a caller-injected closure, so this crate
-//! never holds key material — the composition root injects the worker's own
-//! member key, `effect.identity`: "its result commit MUST be signed with
-//! its own member key"), and hands the result to `receive` like any other
-//! frontend.
+//! [`write_result`] builds the [`ents_model::ResultRecord`] typed tree,
+//! seals it into a signed commit exactly the way
+//! [`ents_sync::resolve::merge_heads`] seals a merge tip (`sign` is a
+//! caller-injected closure, so this crate never holds key material — the
+//! composition root injects the worker's own member key, `effect.identity`:
+//! "its result commit MUST be signed with its own member key"), and hands
+//! the result to `receive` like any other frontend.
-use ents_model::Status;
-use ents_model::trailer::Trailers;
+use ents_model::{ResultRecord, Status};
use ents_receive::{EventSink, Mode, Outcome, Proposal, RefTransition};
use gix::refs::FullName;
+use gix_hash::ObjectId;
use gix_object::{Commit, Find, Kind, Write, WriteTo as _};
use gix_ref_store::RefStore;
use crate::error::{Error, Result};
-/// Build a signed commit recording `status` on `results_ref`, and push it
-/// through [`ents_receive::receive`] — the sole path an effect's outcome
-/// may re-enter the repository (`effect.results-writeback`).
+/// Build a signed commit recording `status` for `effect` on the commit
+/// `target` at `results_ref`, and push it through
+/// [`ents_receive::receive`] — the sole path an effect's outcome may
+/// re-enter the repository (`effect.results-writeback`).
+///
+/// The tree is a [`ResultRecord`] carrying `effect` and `target`
+/// (`model.result-identity`), from which the gate recomputes
+/// `results_ref`'s `<effect>` and `<short-oid>` segments
+/// (`gate.identity-binding`): a signed `pass` cannot be replayed as the
+/// result of a different effect or commit.
///
/// `results_ref` is the caller's choice: the canonical
/// `refs/meta/results/<effect>/<short-oid>` for a designated worker, or the
@@ -54,15 +61,16 @@
/// time: gix::date::Time { seconds: 1_000, offset: 0 },
/// };
///
+/// let target = gix_hash::ObjectId::from_hex(b"abc123456789000000000000000000000000abcd").expect("hex");
/// let name: gix::refs::FullName =
/// "refs/meta/results/unit/abc123456789".try_into().expect("valid");
/// let outcome = write_result(
-/// &refs, &objects, &NullEventSink, name, Status::Pass, &author,
+/// &refs, &objects, &NullEventSink, name, "unit", target, Status::Pass, &author,
/// |payload| key.sign(payload), Mode::Advisory,
/// ).expect("evaluates");
/// assert_eq!(outcome.result, ents_receive::TxResult::Applied);
/// ```
-// @relation(effect.results-writeback, effect.identity, effect.result-taxonomy, effect.self-run, scope=function)
+// @relation(effect.results-writeback, effect.identity, effect.result-taxonomy, effect.self-run, model.result-identity, scope=function)
#[expect(
clippy::too_many_arguments,
reason = "one input per commit-building step, mirrors ents_sync::resolve::merge_heads's shape"
@@ -72,25 +80,24 @@
objects: &(impl Find + Write),
events: &dyn EventSink,
results_ref: FullName,
+ effect: &str,
+ target: ObjectId,
status: Status,
author: &gix::actor::Signature,
sign: impl FnOnce(&[u8]) -> String,
mode: Mode,
) -> Result<Outcome> {
- let tree = facet_git_tree::serialize_into(&status, objects)?;
+ let record = ResultRecord::new(effect, target, status);
+ let tree = facet_git_tree::serialize_into(&record, objects)?;
let old = refs.get(results_ref.as_ref())?;
let parents: Vec<_> = old.into_iter().collect();
- let trailers = Trailers {
- ents_ref: Some(results_ref.clone()),
- schema_version: None,
- };
let summary = match status {
Status::Pass => "Record pass",
Status::Fail => "Record fail",
Status::Error => "Record error",
};
- let message = format!("{summary}\n\n{}", trailers.render());
+ let message = summary.to_owned();
let mut commit = Commit {
tree,
parents: parents.clone().into(),
@@ -179,11 +186,15 @@
);
let name = namespace::result_ref("unit", "deadbeefcafe").expect("valid");
+ let target =
+ ObjectId::from_hex(b"deadbeefcafe0000000000000000000000000000").expect("valid hex");
let outcome = write_result(
&refs,
&objects,
&NullEventSink,
name.clone(),
+ "unit",
+ target,
status,
&author(),
|payload| worker.sign(payload),
@@ -203,9 +214,11 @@
.expect("readable")
.expect("present");
let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("decodes");
- let landed: Status =
+ let landed: ResultRecord =
facet_git_tree::deserialize(&commit.tree(), &objects).expect("deserializes");
- assert_eq!(landed, status);
+ assert_eq!(landed.status, status);
+ assert_eq!(landed.effect, "unit");
+ assert_eq!(landed.target(), target);
}
#[rstest]
@@ -225,11 +238,15 @@
let name = namespace::self_result_ref(&ents_model::MemberId::new("bob"), "unit", "abc")
.expect("valid");
+ let target =
+ ObjectId::from_hex(b"abc0000000000000000000000000000000000000").expect("valid hex");
let outcome = write_result(
&refs,
&objects,
&NullEventSink,
name.clone(),
+ "unit",
+ target,
Status::Fail,
&author(),
|payload| member.sign(payload),
crates/kernel/ents-effect/src/run.rs
@@ -116,7 +116,7 @@
/// enroll_member(&refs, &objects, "worker", &worker, Provenance::AdminRegistered, 100);
/// let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 200);
///
-/// let effect = Effect { trigger: "rev(refs/heads/main)".into(), toolchains: vec![], run: "true".into() };
+/// let effect = Effect { name: "unit".into(), trigger: "rev(refs/heads/main)".into(), toolchains: vec![], run: "true".into() };
/// let results_ref = namespace::result_ref("unit", "abcabcabcabc").expect("valid");
/// let author = gix::actor::Signature {
/// name: "worker".into(), email: "worker@ents.test".into(),
@@ -182,6 +182,8 @@
objects,
events,
results_ref,
+ &effect.name,
+ oid,
status,
author,
sign,
@@ -351,6 +353,7 @@
let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 200);
let effect = Effect {
+ name: "unit".into(),
trigger: "rev(refs/heads/main)".into(),
toolchains: vec![],
run: "true".into(),
@@ -405,6 +408,7 @@
let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 200);
let effect = Effect {
+ name: "unit".into(),
trigger: "rev(refs/heads/main)".into(),
toolchains: vec![],
run: "true".into(),
@@ -451,6 +455,7 @@
let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 200);
let effect = Effect {
+ name: "unit".into(),
trigger: "rev(refs/heads/main)".into(),
toolchains: vec![],
run: "true".into(),
@@ -498,6 +503,7 @@
advance_ref(&refs, &objects, "refs/heads/main", 2, 200);
let effect = Effect {
+ name: "unit".into(),
trigger: "rev(refs/heads/main)".into(),
toolchains: vec![],
run: "true".into(),
@@ -577,6 +583,7 @@
let first = *commits.first().expect("advance_ref produced a commit");
let effect = Effect {
+ name: "unit".into(),
trigger: "rev(refs/heads/main)".into(),
toolchains: vec![],
run: "true".into(),
crates/kernel/ents-query/src/eval.rs
@@ -34,7 +34,7 @@
use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet};
use std::rc::Rc;
-use ents_model::Status;
+use ents_model::{ResultRecord, Status};
use gix::refs::FullName;
use gix_hash::ObjectId;
use gix_object::{CommitRef, Find, Kind};
@@ -370,21 +370,24 @@
}
/// The recorded status behind one results ref tip, cached: the tip
- /// commit's tree deserialized as the [`Status`] typed tree.
+ /// commit's tree deserialized as a [`ResultRecord`], of which the
+ /// status is one field (`model.result-identity`: the tree also carries
+ /// the effect and judged commit, so a signed status means something
+ /// with the refname stripped away).
fn result_status(&self, name: &str, tip: ObjectId) -> EvalResult<Status> {
if let Some(status) = self.status.borrow().get(&tip) {
return Ok(*status);
}
let tree = self.commit_tree(tip)?;
- let status: Status =
+ let record: ResultRecord =
facet_git_tree::deserialize(&tree, self.objects).map_err(|source| {
EvalError::Status {
name: name.to_owned(),
source,
}
})?;
- self.status.borrow_mut().insert(tip, status);
- Ok(status)
+ self.status.borrow_mut().insert(tip, record.status);
+ Ok(record.status)
}
/// The tip commits of every author-written meta-ref matching the