pipeline: a follow-up records the origin of the Persona turn that admitted it

PipelineFollowUp gains origin (absent is not written, so the golden sample is unchanged);
MemoryOrigin's doc says it is a brief's too; the header facts carry it; schemas regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-06 23:38:31 +01:00
commit 5eb1452ca4
10 changed files with 205 additions and 7 deletions

View file

@ -784,9 +784,13 @@ value_types! {
severity: FindingSeverity, claim: Line, invariants: Vec<Label>[8], locations: Vec<CodeLocation>[16],
failure_scenario: Para, evidence: Vec<Evidence>[16], precedents: Vec<ForeignRef>[8], origin: FindingOrigin,
}
/// A follow-up. `origin` is where the Persona turn that admitted it was
/// asked, as the organ derived it: a brief records the conversation whose
/// message started it. Absent on follow-ups no Persona turn admitted and
/// on briefs admitted before it existed.
pub struct PipelineFollowUp {
campaign: Slug, label: Label, source: PipelineRef, repo: OrgRepo, locations: Vec<CodeLocation>[16],
item: Line, why_it_can_wait: Line, owner: Short,
item: Line, why_it_can_wait: Line, owner: Short, origin: Option<MemoryOrigin> = absent,
}
/// The record of how a subject was closed and by what. `sequence` is per
/// subject and set by the writer, `1` for the first, and it is the last
@ -914,8 +918,9 @@ impl PipelineRef {
}
}
/// Where a memory came from and whom she heard it from. The organ derives it
/// from the batch of the turn that wrote the memory; the model never writes it.
/// Where a Persona turn's batch came from and whom she heard it from: a
/// memory's origin and a brief's. The organ derives it from the batch of the
/// turn; the model never writes it.
/// `about` names whom a memory concerns, this names the room and the voices.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum MemoryOrigin {
@ -1598,6 +1603,7 @@ mod tests {
source: PipelineRef { kind: PipelineKind::Finding, id: id("finding", "cut-3a.s2.F4") },
repo: repo(), locations: vec![location()], item: "Per-kind admission rules.".into(),
why_it_can_wait: "The organ owns admission.".into(), owner: s("Hands"),
origin: None,
}), format!("{CAMPAIGN}:follow_up:FU-4")),
(D::Resolution(PipelineResolution {
subject: PipelineRef { kind: PipelineKind::Question, id: id("question", "Q1") }, sequence: 1,
@ -3604,6 +3610,30 @@ mod tests {
Ok(())
}
#[test]
fn follow_up_origin_absent_is_not_written_and_a_direct_origin_round_trips() -> Result<()> {
let sample = samples()
.into_iter()
.find_map(|(document, _)| if let PipelineDocument::FollowUp(follow_up) = document { Some(follow_up) } else { None })
.expect("a follow_up sample");
let json = serde_json::to_value(PipelineDocument::FollowUp(sample.clone()))?;
assert!(json["value"].get("origin").is_none(), "an absent origin is not written");
assert!(!rmp_serde::to_vec_named(&PipelineDocument::FollowUp(sample.clone()))?.windows(6).any(|window| window == b"origin"));
let PipelineDocument::FollowUp(decoded) = serde_json::from_value::<PipelineDocument>(json)? else { unreachable!() };
assert_eq!(decoded.origin, None);
let mut direct = sample;
direct.origin = Some(MemoryOrigin::Direct { room: "!dm:chat".into(), with: vec!["@sample:chat".into()] });
let document = PipelineDocument::FollowUp(direct);
document.validate()?;
let json = serde_json::to_value(&document)?;
assert_eq!(serde_json::from_value::<PipelineDocument>(json)?, document);
let packed = rmp_serde::to_vec_named(&document)?;
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
assert!(packed.windows(6).any(|window| window == b"origin"), "the origin key is written");
Ok(())
}
#[test]
fn target_repos_bounded_and_distinct() {
let with_repos = |repos: Vec<OrgRepo>| {

View file

@ -290,6 +290,7 @@ pub(crate) fn follow_up(label: &str, source: PipelineRef) -> PipelineDocument {
item: "Per-kind admission rules.".into(),
why_it_can_wait: "The organ owns admission.".into(),
owner: s("Hands"),
origin: None,
})
}

View file

@ -140,7 +140,16 @@ pub enum PipelineFacts {
invariants: Vec<Label>,
range: CommitRange,
},
FollowUp { label: Label, source: PipelineRef, repo: OrgRepo, owner: Short, item: Line },
FollowUp {
label: Label,
source: PipelineRef,
repo: OrgRepo,
owner: Short,
item: Line,
/// Where the Persona turn that admitted it was asked; absent on follow-ups no turn admitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
origin: Option<MemoryOrigin>,
},
Resolution { subject: PipelineRef, sequence: u32, outcome: ResolutionOutcome, resolved_on: Date },
Instance { instance: Slug, display_name: Short, host: Short, created_at: Date },
Stewardship { instance: Slug, repo: OrgRepo, sequence: u32, assigned_on: Date, note: Line },
@ -216,6 +225,7 @@ impl PipelineFacts {
repo: value.repo.clone(),
owner: value.owner.clone(),
item: value.item.clone(),
origin: value.origin.clone(),
},
D::Resolution(value) => Self::Resolution {
subject: value.subject.clone(),
@ -1632,6 +1642,27 @@ mod tests {
assert!(matches!(&found[0].facts, PipelineFacts::Memory { sort: MemorySort::Rapport, origin: MemoryOrigin::Room { .. }, salience_permille: 500, .. }), "{:?}", found[0].facts);
}
/// A follow-up's origin reaches its header facts when it has one, and is absent when it has none.
#[test]
fn a_follow_up_header_carries_the_origin_its_turn_recorded() {
let mut mind = seeded();
let q1 = id("question", "Q1");
let D::FollowUp(mut asked) = follow_up("FU-dm", r(K::Question, &q1)) else { panic!() };
asked.origin = Some(MemoryOrigin::Direct { room: s("!dm:chat"), with: vec![s("@op:chat")] });
committed(admit(&mut mind, vec![question_n(1), D::FollowUp(asked), follow_up("FU-old", r(K::Question, &q1))]));
let PipelinePageItems::Headers(found) = mind.query(&of_kinds(&[K::FollowUp])).unwrap().items else { panic!("headers") };
let origin_of = |label: &str| {
found.iter().find_map(|header| match &header.facts {
PipelineFacts::FollowUp { label: found, origin, .. } if found.0 == label => Some(origin.clone()),
_ => None,
})
};
assert_eq!(origin_of("FU-dm"), Some(Some(MemoryOrigin::Direct { room: s("!dm:chat"), with: vec![s("@op:chat")] })));
assert_eq!(origin_of("FU-old"), Some(None));
let wire = serde_json::to_value(found.iter().find(|header| matches!(&header.facts, PipelineFacts::FollowUp { origin: None, .. })).unwrap()).unwrap();
assert!(!wire.to_string().contains("\"origin\""), "an absent origin is not written");
}
/// A withdrawal pulse admitted into the mind is read back as a withdrawal: its
/// facts say so, `voted` selects it, and a later plain vote is not one.
#[test]

View file

@ -1905,6 +1905,7 @@ fn start_self_run(
item: ask.brief.as_str().into(),
why_it_can_wait: "Started from chat; the Self run that claims it works it now.".into(),
owner: "self-run".into(),
origin: None,
});
let claim = PipelineRef { kind: PipelineKind::FollowUp, id: pipeline_key(&follow_up).map_err(|_| anyhow!("the follow-up could not be keyed"))?.as_str().into() };
let mut documents = vec![follow_up];

View file

@ -1160,6 +1160,7 @@ fn open_follow_up(label: &str, source: &PipelineRef, item: &str) -> PipelineDocu
item: item.into(),
why_it_can_wait: "It can.".into(),
owner: "Hands".into(),
origin: None,
})
}

View file

@ -510,6 +510,7 @@ pub(crate) fn follow_up(label: &str, source: &PipelineRef) -> PipelineDocument {
item: "Later.".into(),
why_it_can_wait: "It can.".into(),
owner: "Hands".into(),
origin: None,
})
}

View file

@ -45,6 +45,95 @@
"maxLength": 1000,
"type": "string"
},
"MemoryOrigin": {
"description": "Where a Persona turn's batch came from and whom she heard it from: a\nmemory's origin and a brief's. The organ derives it from the batch of the\nturn; the model never writes it.\n`about` names whom a memory concerns, this names the room and the voices.",
"oneOf": [
{
"additionalProperties": false,
"description": "A turn in rooms: their aliases comma-joined, and the batch's other senders.",
"properties": {
"Room": {
"properties": {
"heard_from": {
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 8,
"type": "array"
},
"rooms": {
"$ref": "#/$defs/Short"
}
},
"required": [
"rooms",
"heard_from"
],
"type": "object"
}
},
"required": [
"Room"
],
"type": "object"
},
{
"additionalProperties": false,
"description": "A direct message: the DM's room id, and the others who spoke in it (whom\nshe heard it from in confidence).",
"properties": {
"Direct": {
"properties": {
"room": {
"$ref": "#/$defs/Short"
},
"with": {
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 8,
"type": "array"
}
},
"required": [
"room",
"with"
],
"type": "object"
}
},
"required": [
"Direct"
],
"type": "object"
},
{
"const": "Wake",
"description": "A wake: no one spoke.",
"type": "string"
},
{
"additionalProperties": false,
"description": "A Self run of hers, by its label.",
"properties": {
"SelfRun": {
"properties": {
"run": {
"$ref": "#/$defs/Short"
}
},
"required": [
"run"
],
"type": "object"
}
},
"required": [
"SelfRun"
],
"type": "object"
}
]
},
"OrgRepo": {
"description": "A GitHub repository as `Org/Repo`, at most 140 UTF-8 bytes (39 + 1 +\n100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or\ntrailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never\n`.` or `..`, and does not end in `.git`, ASCII-case-insensitively.\nIdentity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany`\nare the same repository.",
"maxLength": 140,
@ -109,6 +198,7 @@
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.follow_up.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "A follow-up. `origin` is where the Persona turn that admitted it was\nasked, as the organ derived it: a brief records the conversation whose\nmessage started it. Absent on follow-ups no Persona turn admitted and\non briefs admitted before it existed.",
"properties": {
"campaign": {
"$ref": "#/$defs/Slug"
@ -126,6 +216,16 @@
"maxItems": 16,
"type": "array"
},
"origin": {
"anyOf": [
{
"$ref": "#/$defs/MemoryOrigin"
},
{
"type": "null"
}
]
},
"owner": {
"$ref": "#/$defs/Short"
},

View file

@ -11,7 +11,7 @@
"type": "string"
},
"MemoryOrigin": {
"description": "Where a memory came from and whom she heard it from. The organ derives it\nfrom the batch of the turn that wrote the memory; the model never writes it.\n`about` names whom a memory concerns, this names the room and the voices.",
"description": "Where a Persona turn's batch came from and whom she heard it from: a\nmemory's origin and a brief's. The organ derives it from the batch of the\nturn; the model never writes it.\n`about` names whom a memory concerns, this names the room and the voices.",
"oneOf": [
{
"additionalProperties": false,

View file

@ -578,7 +578,7 @@
"maxLength": 1000
},
"MemoryOrigin": {
"description": "Where a memory came from and whom she heard it from. The organ derives it\nfrom the batch of the turn that wrote the memory; the model never writes it.\n`about` names whom a memory concerns, this names the room and the voices.",
"description": "Where a Persona turn's batch came from and whom she heard it from: a\nmemory's origin and a brief's. The organ derives it from the batch of the\nturn; the model never writes it.\n`about` names whom a memory concerns, this names the room and the voices.",
"oneOf": [
{
"description": "A turn in rooms: their aliases comma-joined, and the batch's other senders.",
@ -1405,6 +1405,7 @@
]
},
"PipelineFollowUp": {
"description": "A follow-up. `origin` is where the Persona turn that admitted it was\nasked, as the organ derived it: a brief records the conversation whose\nmessage started it. Absent on follow-ups no Persona turn admitted and\non briefs admitted before it existed.",
"type": "object",
"properties": {
"campaign": {
@ -1423,6 +1424,16 @@
},
"maxItems": 16
},
"origin": {
"anyOf": [
{
"$ref": "#/$defs/MemoryOrigin"
},
{
"type": "null"
}
]
},
"owner": {
"$ref": "#/$defs/Short"
},

View file

@ -705,7 +705,7 @@
"maxLength": 1000
},
"MemoryOrigin": {
"description": "Where a memory came from and whom she heard it from. The organ derives it\nfrom the batch of the turn that wrote the memory; the model never writes it.\n`about` names whom a memory concerns, this names the room and the voices.",
"description": "Where a Persona turn's batch came from and whom she heard it from: a\nmemory's origin and a brief's. The organ derives it from the batch of the\nturn; the model never writes it.\n`about` names whom a memory concerns, this names the room and the voices.",
"oneOf": [
{
"description": "A turn in rooms: their aliases comma-joined, and the batch's other senders.",
@ -3083,6 +3083,17 @@
"label": {
"$ref": "#/$defs/Label"
},
"origin": {
"description": "Where the Persona turn that admitted it was asked; absent on follow-ups no turn admitted.",
"anyOf": [
{
"$ref": "#/$defs/MemoryOrigin"
},
{
"type": "null"
}
]
},
"owner": {
"$ref": "#/$defs/Short"
},
@ -3582,6 +3593,7 @@
]
},
"PipelineFollowUp": {
"description": "A follow-up. `origin` is where the Persona turn that admitted it was\nasked, as the organ derived it: a brief records the conversation whose\nmessage started it. Absent on follow-ups no Persona turn admitted and\non briefs admitted before it existed.",
"type": "object",
"properties": {
"campaign": {
@ -3600,6 +3612,16 @@
},
"maxItems": 16
},
"origin": {
"anyOf": [
{
"$ref": "#/$defs/MemoryOrigin"
},
{
"type": "null"
}
]
},
"owner": {
"$ref": "#/$defs/Short"
},