Harden Epiphany promotion state replacements

This commit is contained in:
Meta Vens 2026-04-24 19:07:08 +01:00
commit bb557d1484
8 changed files with 519 additions and 35 deletions

View file

@ -1,10 +1,24 @@
use codex_protocol::protocol::EpiphanyChurnState;
use codex_protocol::protocol::EpiphanyEvidenceRecord;
use codex_protocol::protocol::EpiphanyGraph;
use codex_protocol::protocol::EpiphanyGraphCheckpoint;
use codex_protocol::protocol::EpiphanyGraphFrontier;
use codex_protocol::protocol::EpiphanyGraphs;
use codex_protocol::protocol::EpiphanyInvariant;
use codex_protocol::protocol::EpiphanyObservation;
use codex_protocol::protocol::EpiphanySubgoal;
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct EpiphanyPromotionInput {
pub has_state_replacements: bool,
pub active_subgoal_id: Option<String>,
pub subgoals: Option<Vec<EpiphanySubgoal>>,
pub invariants: Option<Vec<EpiphanyInvariant>>,
pub graphs: Option<EpiphanyGraphs>,
pub graph_frontier: Option<EpiphanyGraphFrontier>,
pub graph_checkpoint: Option<EpiphanyGraphCheckpoint>,
pub churn: Option<EpiphanyChurnState>,
pub observations: Vec<EpiphanyObservation>,
pub evidence: Vec<EpiphanyEvidenceRecord>,
pub verifier_evidence: EpiphanyEvidenceRecord,
@ -22,6 +36,19 @@ pub fn evaluate_promotion(input: EpiphanyPromotionInput) -> EpiphanyPromotionDec
if !input.has_state_replacements && input.observations.is_empty() && input.evidence.is_empty() {
reasons.push("promotion patch must contain at least one mutation".to_string());
}
if input.has_state_replacements {
if input.observations.is_empty() {
reasons.push(
"state replacement promotions must include at least one observation".to_string(),
);
}
if input.evidence.is_empty() {
reasons.push(
"state replacement promotions must include at least one patch evidence record"
.to_string(),
);
}
}
validate_evidence_record(&input.verifier_evidence, "verifierEvidence", &mut reasons);
if !is_accepting_status(&input.verifier_evidence.status) {
@ -47,6 +74,7 @@ pub fn evaluate_promotion(input: EpiphanyPromotionInput) -> EpiphanyPromotionDec
reasons.push(format!("duplicate observation id {:?}", observation.id));
}
}
validate_state_replacements(&input, &mut reasons);
EpiphanyPromotionDecision {
accepted: reasons.is_empty(),
@ -94,6 +122,306 @@ fn validate_observation(
}
}
fn validate_state_replacements(input: &EpiphanyPromotionInput, reasons: &mut Vec<String>) {
if let Some(subgoals) = &input.subgoals {
validate_subgoals(subgoals, input.active_subgoal_id.as_deref(), reasons);
}
if let Some(active_subgoal_id) = input.active_subgoal_id.as_deref() {
require_nonempty(active_subgoal_id, "patch.active_subgoal_id", reasons);
}
if let Some(invariants) = &input.invariants {
validate_invariants(invariants, reasons);
}
if let Some(graphs) = &input.graphs {
validate_graphs(graphs, reasons);
if let Some(frontier) = &input.graph_frontier {
validate_frontier_against_graphs(frontier, graphs, reasons);
}
if let Some(checkpoint) = &input.graph_checkpoint {
validate_checkpoint_against_graphs(checkpoint, graphs, reasons);
}
} else {
if let Some(frontier) = &input.graph_frontier {
validate_frontier_shape(frontier, reasons);
}
if let Some(checkpoint) = &input.graph_checkpoint {
validate_checkpoint_shape(checkpoint, reasons);
}
}
if let Some(churn) = &input.churn {
validate_churn(churn, reasons);
}
}
fn validate_subgoals(
subgoals: &[EpiphanySubgoal],
active_subgoal_id: Option<&str>,
reasons: &mut Vec<String>,
) {
let mut ids = HashSet::new();
for subgoal in subgoals {
require_nonempty(&subgoal.id, "patch.subgoals.id", reasons);
require_nonempty(&subgoal.title, "patch.subgoals.title", reasons);
require_nonempty(&subgoal.status, "patch.subgoals.status", reasons);
if !ids.insert(subgoal.id.as_str()) {
reasons.push(format!("duplicate subgoal id {:?}", subgoal.id));
}
}
if let Some(active_subgoal_id) = active_subgoal_id
&& !active_subgoal_id.trim().is_empty()
&& !ids.contains(active_subgoal_id)
{
reasons.push(format!(
"active subgoal id {:?} is not present in patch.subgoals",
active_subgoal_id
));
}
}
fn validate_invariants(invariants: &[EpiphanyInvariant], reasons: &mut Vec<String>) {
let mut ids = HashSet::new();
for invariant in invariants {
require_nonempty(&invariant.id, "patch.invariants.id", reasons);
require_nonempty(
&invariant.description,
"patch.invariants.description",
reasons,
);
require_nonempty(&invariant.status, "patch.invariants.status", reasons);
if !ids.insert(invariant.id.as_str()) {
reasons.push(format!("duplicate invariant id {:?}", invariant.id));
}
}
}
fn validate_graphs(graphs: &EpiphanyGraphs, reasons: &mut Vec<String>) {
let architecture_ids =
validate_graph("patch.graphs.architecture", &graphs.architecture, reasons);
let dataflow_ids = validate_graph("patch.graphs.dataflow", &graphs.dataflow, reasons);
let mut links = HashSet::new();
for link in &graphs.links {
require_nonempty(
&link.dataflow_node_id,
"patch.graphs.links.dataflow_node_id",
reasons,
);
require_nonempty(
&link.architecture_node_id,
"patch.graphs.links.architecture_node_id",
reasons,
);
if !link.dataflow_node_id.trim().is_empty()
&& !dataflow_ids.contains(link.dataflow_node_id.as_str())
{
reasons.push(format!(
"graph link references missing dataflow node {:?}",
link.dataflow_node_id
));
}
if !link.architecture_node_id.trim().is_empty()
&& !architecture_ids.contains(link.architecture_node_id.as_str())
{
reasons.push(format!(
"graph link references missing architecture node {:?}",
link.architecture_node_id
));
}
let key = (
link.dataflow_node_id.as_str(),
link.architecture_node_id.as_str(),
);
if !links.insert(key) {
reasons.push(format!(
"duplicate graph link {:?} -> {:?}",
link.dataflow_node_id, link.architecture_node_id
));
}
}
}
fn validate_graph<'a>(
label: &str,
graph: &'a EpiphanyGraph,
reasons: &mut Vec<String>,
) -> HashSet<&'a str> {
let mut node_ids = HashSet::new();
for node in &graph.nodes {
require_nonempty(&node.id, &format!("{label}.nodes.id"), reasons);
require_nonempty(&node.title, &format!("{label}.nodes.title"), reasons);
require_nonempty(&node.purpose, &format!("{label}.nodes.purpose"), reasons);
if !node_ids.insert(node.id.as_str()) {
reasons.push(format!("duplicate {label} node id {:?}", node.id));
}
}
let mut edge_ids = HashSet::new();
for edge in &graph.edges {
require_nonempty(
&edge.source_id,
&format!("{label}.edges.source_id"),
reasons,
);
require_nonempty(
&edge.target_id,
&format!("{label}.edges.target_id"),
reasons,
);
require_nonempty(&edge.kind, &format!("{label}.edges.kind"), reasons);
if !edge.source_id.trim().is_empty() && !node_ids.contains(edge.source_id.as_str()) {
reasons.push(format!(
"{label} edge references missing source node {:?}",
edge.source_id
));
}
if !edge.target_id.trim().is_empty() && !node_ids.contains(edge.target_id.as_str()) {
reasons.push(format!(
"{label} edge references missing target node {:?}",
edge.target_id
));
}
if let Some(edge_id) = edge.id.as_deref()
&& !edge_id.trim().is_empty()
&& !edge_ids.insert(edge_id)
{
reasons.push(format!("duplicate {label} edge id {:?}", edge_id));
}
}
node_ids
}
fn validate_frontier_shape(frontier: &EpiphanyGraphFrontier, reasons: &mut Vec<String>) {
require_unique_nonempty(
"patch.graph_frontier.active_node_ids",
frontier.active_node_ids.iter().map(String::as_str),
reasons,
);
require_unique_nonempty(
"patch.graph_frontier.active_edge_ids",
frontier.active_edge_ids.iter().map(String::as_str),
reasons,
);
require_unique_nonempty(
"patch.graph_frontier.open_question_ids",
frontier.open_question_ids.iter().map(String::as_str),
reasons,
);
require_unique_nonempty(
"patch.graph_frontier.open_gap_ids",
frontier.open_gap_ids.iter().map(String::as_str),
reasons,
);
}
fn validate_frontier_against_graphs(
frontier: &EpiphanyGraphFrontier,
graphs: &EpiphanyGraphs,
reasons: &mut Vec<String>,
) {
validate_frontier_shape(frontier, reasons);
let node_ids = graph_node_ids(graphs);
let edge_ids = graph_edge_ids(graphs);
for node_id in &frontier.active_node_ids {
if !node_id.trim().is_empty() && !node_ids.contains(node_id.as_str()) {
reasons.push(format!(
"graph frontier references missing node {:?}",
node_id
));
}
}
for edge_id in &frontier.active_edge_ids {
if !edge_id.trim().is_empty() && !edge_ids.contains(edge_id.as_str()) {
reasons.push(format!(
"graph frontier references missing edge {:?}",
edge_id
));
}
}
}
fn validate_checkpoint_shape(checkpoint: &EpiphanyGraphCheckpoint, reasons: &mut Vec<String>) {
require_nonempty(
&checkpoint.checkpoint_id,
"patch.graph_checkpoint.checkpoint_id",
reasons,
);
require_unique_nonempty(
"patch.graph_checkpoint.frontier_node_ids",
checkpoint.frontier_node_ids.iter().map(String::as_str),
reasons,
);
require_unique_nonempty(
"patch.graph_checkpoint.open_question_ids",
checkpoint.open_question_ids.iter().map(String::as_str),
reasons,
);
require_unique_nonempty(
"patch.graph_checkpoint.open_gap_ids",
checkpoint.open_gap_ids.iter().map(String::as_str),
reasons,
);
}
fn validate_checkpoint_against_graphs(
checkpoint: &EpiphanyGraphCheckpoint,
graphs: &EpiphanyGraphs,
reasons: &mut Vec<String>,
) {
validate_checkpoint_shape(checkpoint, reasons);
let node_ids = graph_node_ids(graphs);
for node_id in &checkpoint.frontier_node_ids {
if !node_id.trim().is_empty() && !node_ids.contains(node_id.as_str()) {
reasons.push(format!(
"graph checkpoint references missing frontier node {:?}",
node_id
));
}
}
}
fn validate_churn(churn: &EpiphanyChurnState, reasons: &mut Vec<String>) {
require_nonempty(
&churn.understanding_status,
"patch.churn.understanding_status",
reasons,
);
require_nonempty(&churn.diff_pressure, "patch.churn.diff_pressure", reasons);
}
fn graph_node_ids(graphs: &EpiphanyGraphs) -> HashSet<&str> {
graphs
.architecture
.nodes
.iter()
.chain(graphs.dataflow.nodes.iter())
.map(|node| node.id.as_str())
.collect()
}
fn graph_edge_ids(graphs: &EpiphanyGraphs) -> HashSet<&str> {
graphs
.architecture
.edges
.iter()
.chain(graphs.dataflow.edges.iter())
.filter_map(|edge| edge.id.as_deref())
.collect()
}
fn require_unique_nonempty<'a>(
label: &str,
values: impl Iterator<Item = &'a str>,
reasons: &mut Vec<String>,
) {
let mut seen = HashSet::new();
for value in values {
require_nonempty(value, label, reasons);
if !value.trim().is_empty() && !seen.insert(value) {
reasons.push(format!("duplicate {label} {:?}", value));
}
}
}
fn require_nonempty(value: &str, label: &str, reasons: &mut Vec<String>) {
if value.trim().is_empty() {
reasons.push(format!("{label} must not be empty"));
@ -110,6 +438,8 @@ fn is_accepting_status(status: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::EpiphanyGraphEdge;
use codex_protocol::protocol::EpiphanyGraphNode;
fn verifier(status: &str) -> EpiphanyEvidenceRecord {
EpiphanyEvidenceRecord {
@ -121,26 +451,55 @@ mod tests {
}
}
fn evidence() -> EpiphanyEvidenceRecord {
EpiphanyEvidenceRecord {
id: "ev-1".to_string(),
kind: "verification".to_string(),
status: "ok".to_string(),
summary: "Smoke returned revision 1".to_string(),
code_refs: Vec::new(),
}
}
fn observation() -> EpiphanyObservation {
EpiphanyObservation {
id: "obs-1".to_string(),
summary: "Smoke passed".to_string(),
source_kind: "smoke".to_string(),
status: "ok".to_string(),
code_refs: Vec::new(),
evidence_ids: vec!["ev-1".to_string()],
}
}
fn promotion_input(
has_state_replacements: bool,
observations: Vec<EpiphanyObservation>,
evidence: Vec<EpiphanyEvidenceRecord>,
verifier_status: &str,
) -> EpiphanyPromotionInput {
EpiphanyPromotionInput {
has_state_replacements,
active_subgoal_id: None,
subgoals: None,
invariants: None,
graphs: None,
graph_frontier: None,
graph_checkpoint: None,
churn: None,
observations,
evidence,
verifier_evidence: verifier(verifier_status),
}
}
#[test]
fn evaluate_promotion_accepts_verified_evidence_linked_observation() {
let decision = evaluate_promotion(EpiphanyPromotionInput {
has_state_replacements: false,
observations: vec![EpiphanyObservation {
id: "obs-1".to_string(),
summary: "Smoke passed".to_string(),
source_kind: "smoke".to_string(),
status: "ok".to_string(),
code_refs: Vec::new(),
evidence_ids: vec!["ev-1".to_string()],
}],
evidence: vec![EpiphanyEvidenceRecord {
id: "ev-1".to_string(),
kind: "verification".to_string(),
status: "ok".to_string(),
summary: "Smoke returned revision 1".to_string(),
code_refs: Vec::new(),
}],
observations: vec![observation()],
evidence: vec![evidence()],
verifier_evidence: verifier("ok"),
..promotion_input(false, Vec::new(), Vec::new(), "ok")
});
assert!(decision.accepted);
@ -149,12 +508,7 @@ mod tests {
#[test]
fn evaluate_promotion_rejects_non_accepting_verifier_status() {
let decision = evaluate_promotion(EpiphanyPromotionInput {
has_state_replacements: true,
observations: Vec::new(),
evidence: Vec::new(),
verifier_evidence: verifier("failed"),
});
let decision = evaluate_promotion(promotion_input(true, Vec::new(), Vec::new(), "failed"));
assert!(!decision.accepted);
assert!(
@ -169,6 +523,13 @@ mod tests {
fn evaluate_promotion_rejects_observation_without_existing_evidence() {
let decision = evaluate_promotion(EpiphanyPromotionInput {
has_state_replacements: false,
active_subgoal_id: None,
subgoals: None,
invariants: None,
graphs: None,
graph_frontier: None,
graph_checkpoint: None,
churn: None,
observations: vec![EpiphanyObservation {
id: "obs-1".to_string(),
summary: "Smoke passed".to_string(),
@ -189,4 +550,88 @@ mod tests {
.any(|reason| reason.contains("missing evidence id"))
);
}
#[test]
fn evaluate_promotion_rejects_state_replacement_without_observation_evidence() {
let decision = evaluate_promotion(EpiphanyPromotionInput {
churn: Some(EpiphanyChurnState {
understanding_status: "grounded".to_string(),
diff_pressure: "low".to_string(),
..Default::default()
}),
..promotion_input(true, Vec::new(), Vec::new(), "ok")
});
assert!(!decision.accepted);
assert!(
decision
.reasons
.iter()
.any(|reason| reason.contains("state replacement promotions"))
);
}
#[test]
fn evaluate_promotion_validates_graph_references_for_map_edits() {
let decision = evaluate_promotion(EpiphanyPromotionInput {
graphs: Some(EpiphanyGraphs {
architecture: EpiphanyGraph {
nodes: vec![EpiphanyGraphNode {
id: "state".to_string(),
title: "State".to_string(),
purpose: "Carry the explicit map".to_string(),
..Default::default()
}],
edges: vec![EpiphanyGraphEdge {
source_id: "state".to_string(),
target_id: "missing".to_string(),
kind: "feeds".to_string(),
id: Some("edge-1".to_string()),
..Default::default()
}],
},
..Default::default()
}),
..promotion_input(true, vec![observation()], vec![evidence()], "ok")
});
assert!(!decision.accepted);
assert!(
decision
.reasons
.iter()
.any(|reason| reason.contains("missing target node"))
);
}
#[test]
fn evaluate_promotion_accepts_verified_map_and_churn_replacement() {
let decision = evaluate_promotion(EpiphanyPromotionInput {
graphs: Some(EpiphanyGraphs {
architecture: EpiphanyGraph {
nodes: vec![EpiphanyGraphNode {
id: "state".to_string(),
title: "State".to_string(),
purpose: "Carry the explicit map".to_string(),
..Default::default()
}],
edges: Vec::new(),
},
..Default::default()
}),
graph_frontier: Some(EpiphanyGraphFrontier {
active_node_ids: vec!["state".to_string()],
..Default::default()
}),
churn: Some(EpiphanyChurnState {
understanding_status: "grounded".to_string(),
diff_pressure: "low".to_string(),
graph_freshness: Some("fresh".to_string()),
..Default::default()
}),
..promotion_input(true, vec![observation()], vec![evidence()], "ok")
});
assert!(decision.accepted, "{:?}", decision.reasons);
}
}

View file

@ -699,13 +699,15 @@ The app-server handler:
7. calls `CodexThread.epiphany_update_state`.
8. returns the updated `EpiphanyThreadState`.
`evaluate_promotion` currently enforces a deliberately small policy:
`evaluate_promotion` currently enforces a deliberately small but now map-aware policy:
1. the patch must contain at least one mutation.
2. verifier evidence must have nonempty id/kind/status/summary.
3. verifier status must be accepting: `ok`, `accepted`, `verified`, `pass`, or `passed`.
4. patch evidence records must be nonempty and unique by id.
5. observations must be nonempty, unique by id, and cite existing evidence ids.
6. state replacement patches, including map/frontier/checkpoint/churn edits, must include at least one explicit observation and at least one patch evidence record.
7. subgoal, invariant, graph, frontier, checkpoint, and churn replacements get lightweight structural validation before they can reach the durable update path.
Code refs:
@ -714,6 +716,7 @@ Code refs:
- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4280)
- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4308)
- [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:19)
- [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:122)
### Output
@ -758,9 +761,9 @@ model/tool observations
-> durable evidence/map/churn mutation
```
That remaining missing organ is richer map/churn promotion. The typed write path exists, the first deterministic distillation proposal path exists, and the first verifier-backed promotion gate exists. What does not exist yet is a policy that turns accepted observations into graph/frontier/churn edits without drifting into automatic graph fanfic.
That remaining missing organ is richer map/churn promotion. The typed write path exists, the first deterministic distillation proposal path exists, and the first verifier-backed promotion gate exists. The first map/churn safety layer now exists too: promotion can reject state replacement patches that are not tied to explicit observations/evidence or that contain structurally broken subgoal, invariant, graph, frontier, checkpoint, or churn fields.
In natural language: current Epiphany can preserve a map, show a map, retrieve evidence for the map, draft one explicit observation/evidence patch, reject or accept a verified candidate, and apply explicit map/evidence/churn edits. It still cannot safely derive richer map/churn edits from observations by itself. Teeth are installed; chewing is still supervised.
In natural language: current Epiphany can preserve a map, show a map, retrieve evidence for the map, draft one explicit observation/evidence patch, reject or accept a verified candidate, apply explicit map/evidence/churn edits, and sanity-check those edits before they hit the ledger. It still does not derive graph edits by itself. Teeth are installed; chewing is still supervised.
## Verification Hooks
@ -771,6 +774,7 @@ Current tests cover the landed flows at useful seams:
- explicit out-of-band Epiphany snapshot replay before the first user turn.
- deterministic observation/evidence distillation in `epiphany-core`.
- verifier-backed promotion policy in `epiphany-core`.
- map/churn promotion validation for evidence-backed state replacements in `epiphany-core`.
- typed state-update patch application in `codex-core`.
- retrieval ranking, fallback, stale manifest detection, and mocked Qdrant/Ollama indexing in `epiphany-core`.
- app-server protocol serde for `thread/epiphany/retrieve`, `thread/epiphany/index`, `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`.

View file

@ -14,8 +14,9 @@ This note tracks Epiphany as a fork of Codex with an opinionated modeling archit
- the heavy Epiphany-owned prompt/replay/retrieval implementation has now been extracted into the repo-owned `epiphany-core` crate, leaving vendored Codex with thin host adapters plus the typed integration seam
- a first typed state-update slice that adds explicit loaded-thread-only `thread/epiphany/update`, appends observations/evidence, replaces bounded map/scratch/churn fields, bumps the state revision, and persists an immediate rollout `EpiphanyState` snapshot
- a first typed distillation/proposal slice that adds read-only loaded-thread-only `thread/epiphany/distill`, producing deterministic observation/evidence patches for explicit promotion through `thread/epiphany/update`
- a first richer promotion-policy slice that treats map/churn/frontier/checkpoint replacements as higher-risk state edits: accepted promotions must be tied to explicit observations and patch evidence, and structural graph/churn mistakes are rejected before the durable update path
The next job is no longer to prove the retriever exists, sketch the persistent follow-up in prose, invent the first red-pen path, or build the first observation proposal surface. Those parts are landed. The next job is to keep the landing zone honest: do not casually turn `thread/epiphany/retrieve` or `thread/epiphany/distill` into durable Epiphany-state writers, and do not widen the explicit indexing/update paths into watcher-driven or GUI-shaped machinery before they earn it.
The next job is no longer to prove the retriever exists, sketch the persistent follow-up in prose, invent the first red-pen path, build the first observation proposal surface, or make promotion notice broken map/churn replacements. Those parts are landed. The next job is to keep the landing zone honest: do not casually turn `thread/epiphany/retrieve` or `thread/epiphany/distill` into durable Epiphany-state writers, and do not widen the explicit indexing/update paths into watcher-driven or GUI-shaped machinery before they earn it.
## Summary
@ -227,6 +228,19 @@ Current bounded shape of that follow-up:
- it returns deterministic observation/evidence records inside a `ThreadEpiphanyUpdatePatch`
- callers must pass the patch to `thread/epiphany/update` to make it durable
Follow-up addition now landed after the promotion gate:
- richer `thread/epiphany/promote` policy for state replacement patches
Current bounded shape of that follow-up:
- it keeps the wire protocol stable
- it still rejects failed verifier evidence without mutation
- it now passes replacement fields into `epiphany-core` policy evaluation instead of reducing them to a generic boolean
- map/churn/frontier/checkpoint replacements must include explicit observations and patch evidence, so state edits are tied to a verified observation trail
- subgoals, invariants, graphs, graph links, frontier ids, checkpoint ids, and churn fields get lightweight structural validation before accepted patches are sent to `thread/epiphany/update`
- it still does not infer graph edits automatically; callers must submit explicit typed replacements
### 5. Reuse existing exact-search substrate where practical
Codex already has fuzzy/exact-ish repo search substrate. Reuse what is useful instead of building a second exact-search toy from scratch.
@ -258,9 +272,9 @@ Treat the current retrieval baseline, explicit indexing follow-up, explicit upda
2. treat the verified and live-smoked `thread/epiphany/index` slice as the bounded persistent-semantic follow-up
3. keep the new `epiphany-core` boundary honest instead of letting vendored Codex re-accumulate the heavy implementation
4. do not add durable retrieval-summary writes from `thread/epiphany/retrieve` without a clean out-of-band rollout/update semantic
5. build the next layer above the landed distill/update surfaces:
5. build the next layer above the landed distill/update/promote surfaces:
- richer typed observation distillation from tool/model outputs
- promotion policy that decides when to call `thread/epiphany/update` explicitly
- promotion policy beyond structural validation, but only when it can stay evidence-backed
- verifier-backed acceptance/rejection evidence
- no hidden retrieval writes
- not GUI work

View file

@ -190,6 +190,7 @@ What is wired right now:
- app-server message handling now routes those methods through the core host seam
- `thread/epiphany/distill` is the first read-only proposal surface; it returns an `expectedRevision` plus a patch and requires explicit promotion through `thread/epiphany/update`
- `thread/epiphany/promote` is the first explicit promotion gate; it rejects failed verifier evidence without mutation and applies accepted candidates through the update path
- `thread/epiphany/promote` now has a first richer map/churn safety layer: state replacement patches must carry explicit observations and patch evidence, and subgoal/invariant/graph/frontier/checkpoint/churn structure is validated before the durable update path is called
- `thread/epiphany/update` is the first explicit durable Epiphany state write surface; it rejects empty patches, supports optional revision matching, appends observations/evidence, replaces bounded typed fields, increments state revision, and persists immediately
- live hydrated `thread.epiphanyState` reads now backfill a lightweight retrieval summary when the thread already has Epiphany state but no persisted retrieval summary yet
- basic protocol/core/app-server unit tests were added for the new types and mapping layer
@ -429,7 +430,7 @@ Without that, you get to learn about `symlink_dir failed: ... A required privile
Do not restart verification from superstition. The current Phase 4 retrieval/indexing/core-extraction baseline is already verified, committed, and pushed.
The durable state seam exists, the turn loop reads it, clients can load typed Epiphany state directly, the first retrieval slice is real, the explicit persistent-semantic indexing path is landed, and the first distill/promote/update path is live-smoked. The next clean move is richer map/churn promotion policy, not pretending the already-shipped slices still need ceremony.
The durable state seam exists, the turn loop reads it, clients can load typed Epiphany state directly, the first retrieval slice is real, the explicit persistent-semantic indexing path is landed, the first distill/promote/update path is live-smoked, and promotion now has a first structural map/churn safety layer. The next clean move is richer observation-to-map proposal machinery, not pretending the already-shipped slices still need ceremony.
Current next implementation move:
@ -438,7 +439,7 @@ Current next implementation move:
3. keep `thread/epiphany/retrieve` and `thread/epiphany/distill` read-only
4. build the next layer above the distill/promote/update surfaces:
- richer map/churn patch generation from verified observations
- promotion policy beyond simple evidence-linked observation acceptance
- promotion policy beyond structural validation only when the evidence path stays explicit
- verifier-backed acceptance/rejection evidence
- no GUI, watcher, or specialist-agent machinery yet

View file

@ -89,3 +89,5 @@
{"ts": "2026-04-24T17:27:49+00:00", "type": "verification", "status": "ok", "note": "Live app-server stdio smoke verified thread/epiphany/promote. Distilled a promotion smoke patch in ephemeral loaded thread 019dc087-6d3d-7323-9a2f-2487a591ef7a, rejected failed verifier evidence with no state mutation, then accepted ok verifier evidence, promoted through the durable update path to revision 1, and thread/read returned observation obs-77248505f492 plus evidence ev-77248505f492 and ev-promote-smoke-verifier. Smoke result is .epiphany-smoke/promote-smoke-result.json."}
{"ts": "2026-04-24T17:49:26+00:00", "type": "implementation", "status": "ok", "note": "Updated notes/epiphany-current-algorithmic-map.md for the landed thread/epiphany/promote flow. The current model now includes promote as a verifier-backed gate between distill proposals and the durable update path, with source-grounded refs to app-server protocol DTOs, app-server routing/handler code, and epiphany-core promotion policy evaluation."}
{"ts": "2026-04-24T17:52:09+00:00", "type": "decision", "status": "ok", "note": "Documented compact-rehydrate-continue as an explicit Epiphany architecture primitive. The cycle is: compact from a checkpoint when possible, rehydrate from canonical map/handoff/evidence rather than prompt residue, restate the persisted next action, and continue with one bounded move."}
{"ts": "2026-04-24T18:05:25+00:00", "type": "implementation", "status": "ok", "note": "Landed first richer promotion safety layer: state replacement promotions now require explicit observation/evidence records and validate subgoal/invariant/graph/frontier/checkpoint/churn structure in epiphany-core before app-server forwards accepted patches to the durable update path."}
{"ts": "2026-04-24T18:06:30+00:00", "type": "verification", "status": "ok", "note": "Verified richer promotion safety layer with epiphany-core full tests, codex-core epiphany tests, and codex-app-server epiphany tests. Full codex-app-server --lib run compiled but hit an unrelated stack overflow in in_process_start_uses_requested_session_source_for_thread_start; Epiphany-scoped app-server tests passed."}

View file

@ -49,7 +49,7 @@ active_subgoals:
- Keep the extracted Phase 4 persistent-semantic follow-up aligned with reality in both code and repo memory.
- Keep vendored Codex as a thin typed host seam while Epiphany-owned prompt and retrieval/indexing logic live in the repo-owned `epiphany-core` crate.
- Preserve exact/path lookup and BM25 fallback while the Qdrant path stays explicit and bounded.
- Build the next layer above the landed typed distill/promote/update paths: richer map/churn promotion and verifier-backed acceptance/rejection beyond simple observation patches.
- Build the next layer above the landed typed distill/promote/update paths: richer observation-to-map proposal machinery while keeping verifier-backed acceptance/rejection explicit.
accepted_design:
- external typed state files
@ -93,6 +93,7 @@ accepted_design:
- land Epiphany's first explicit typed state-update surface as `thread/epiphany/update`: loaded-thread-only, revision-checked when requested, append observations/evidence, replace bounded map/scratch/churn fields, persist an immediate `RolloutItem::EpiphanyState`, and keep retrieval read-only
- land Epiphany's first typed distillation/proposal surface as `thread/epiphany/distill`: loaded-thread-only, read-only, deterministic observation/evidence patch generation with a concrete `expectedRevision`, and explicit promotion through `thread/epiphany/update`
- land Epiphany's first explicit promotion gate as `thread/epiphany/promote`: loaded-thread-only, verifier-backed, rejects failed candidates without mutation, and applies accepted candidates through the existing durable update path
- land the first richer map/churn promotion safety layer: state replacement promotions must include explicit observations and patch evidence, and subgoal/invariant/graph/frontier/checkpoint/churn replacements get structural validation in `epiphany-core` before reaching the durable update path
rejected_paths:
- name: pinned_map_only
@ -109,5 +110,5 @@ rejected_paths:
reason: audited candidates were either terminal-only, stale/aspirational, or incoherent wrappers; the comparison clones were removed from vendor to avoid building on untrusted drift
current_status:
summary: Workspace initialized with protocol files, typed state, a tiny CLI, a git repo, fresh-workspace handoff notes, architecture rationale, a Codex algorithmic map note, an Epiphany core-harness surface spec, and a rolling implementation plan against vendored Codex. Phase 1 durable Epiphany state, Phase 2 prompt integration, a minimal Phase 3 typed client read surface, and the Phase 4 retrieval/indexing/core-extraction work are landed on `main` at `80c29e0`. The current working tree adds the first explicit typed state-update surface, first read-only typed distillation/proposal surface, and first explicit verifier-backed promotion gate: experimental loaded-thread-only `thread/epiphany/update`, `thread/epiphany/distill`, and `thread/epiphany/promote`; typed patch application through `CodexThread.epiphany_update_state(...)`; deterministic observation/evidence proposal generation and promotion policy evaluation in `epiphany-core`; immediate rollout persistence only on accepted promotion/update; and replay support for pre-turn out-of-band Epiphany snapshots. Live app-server stdio smokes verified update, distill-to-update, and promote policy end-to-end: failed verifier evidence is rejected without state mutation, accepted verifier evidence promotes through the durable update path, and thread/read returns revision 1 with the promoted observation/evidence. The current baseline includes explicit `thread/epiphany/index`, manifest-backed Qdrant semantic persistence under `codex_home`, local Ollama embeddings by default, stale/fresh retrieval metadata, and read-only retrieval that still preserves exact/path search plus BM25 fallback instead of turning the machine into vector-only soup. A live app-server smoke on 2026-04-24 verified the explicit indexing path against real local Qdrant/Ollama using an ephemeral loaded thread rooted at `epiphany-core`: 5 files indexed into 198 chunks, a Qdrant collection was created, and persistent semantic retrieval returned `src/prompt.rs` chunks from the fresh index. Env-scoped backend config is good enough for now. The heavy Epiphany-owned prompt renderer, rollout replay helper, retrieval/indexing engine, observation distiller, and promotion evaluator live in the repo-owned `epiphany-core` crate, while vendored Codex carries thin adapters and the typed host seam. The repo also now has an operative source-available/commercial licensing notice and a CLA gate for external contributions.
next_action: Build the next bounded layer above `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`: richer promotion of map/churn edits from verified observations, while keeping retrieval and distillation read-only and avoiding watcher, GUI, or specialist-agent machinery.
summary: Workspace initialized with protocol files, typed state, a tiny CLI, a git repo, fresh-workspace handoff notes, architecture rationale, a Codex algorithmic map note, an Epiphany core-harness surface spec, and a rolling implementation plan against vendored Codex. Phase 1 durable Epiphany state, Phase 2 prompt integration, a minimal Phase 3 typed client read surface, and the Phase 4 retrieval/indexing/core-extraction work are landed on `main` at `80c29e0`. The current working tree adds the first explicit typed state-update surface, first read-only typed distillation/proposal surface, first explicit verifier-backed promotion gate, and first richer map/churn promotion safety layer: experimental loaded-thread-only `thread/epiphany/update`, `thread/epiphany/distill`, and `thread/epiphany/promote`; typed patch application through `CodexThread.epiphany_update_state(...)`; deterministic observation/evidence proposal generation; promotion policy evaluation in `epiphany-core`; explicit observation/evidence requirements for state replacement promotions; structural validation for subgoal, invariant, graph, frontier, checkpoint, and churn replacements; immediate rollout persistence only on accepted promotion/update; and replay support for pre-turn out-of-band Epiphany snapshots. Live app-server stdio smokes verified update, distill-to-update, and promote policy end-to-end before the richer structural layer: failed verifier evidence is rejected without state mutation, accepted verifier evidence promotes through the durable update path, and thread/read returns revision 1 with the promoted observation/evidence. The current baseline includes explicit `thread/epiphany/index`, manifest-backed Qdrant semantic persistence under `codex_home`, local Ollama embeddings by default, stale/fresh retrieval metadata, and read-only retrieval that still preserves exact/path search plus BM25 fallback instead of turning the machine into vector-only soup. A live app-server smoke on 2026-04-24 verified the explicit indexing path against real local Qdrant/Ollama using an ephemeral loaded thread rooted at `epiphany-core`: 5 files indexed into 198 chunks, a Qdrant collection was created, and persistent semantic retrieval returned `src/prompt.rs` chunks from the fresh index. Env-scoped backend config is good enough for now. The heavy Epiphany-owned prompt renderer, rollout replay helper, retrieval/indexing engine, observation distiller, and promotion evaluator live in the repo-owned `epiphany-core` crate, while vendored Codex carries thin adapters and the typed host seam. The repo also now has an operative source-available/commercial licensing notice and a CLA gate for external contributions.
next_action: Build the next bounded layer above `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`: richer observation-to-map proposal machinery from verified observations, while keeping retrieval and distillation read-only and avoiding watcher, GUI, or specialist-agent machinery.

View file

@ -4,7 +4,7 @@ This file is intentionally disposable.
## Current Subgoal
- Move from the landed explicit typed state-update, distillation, and promotion surfaces toward richer verified map/churn promotion without hidden writes.
- Move from structural map/churn promotion validation toward richer observation-to-map proposal machinery without hidden writes.
## Working Notes
@ -126,10 +126,20 @@ This file is intentionally disposable.
- accepted verifier evidence promoted through update to revision `1`
- `thread/read` returned observation `obs-77248505f492`, distill evidence `ev-77248505f492`, and verifier evidence `ev-promote-smoke-verifier`
- smoke result: `.epiphany-smoke/promote-smoke-result.json`
- Richer promotion safety layer on 2026-04-24:
- `epiphany-core/src/promotion.rs` now receives the actual replacement fields instead of only a `has_state_replacements` boolean
- state replacement promotions must include explicit observations and patch evidence before verifier evidence can stamp them into the durable update path
- subgoal, invariant, graph, graph link, frontier, checkpoint, and churn replacements get lightweight structural validation
- `vendor/codex/codex-rs/app-server/src/codex_message_processor.rs` still keeps the host seam thin: it passes the patch fields into `evaluate_promotion`, then accepted promotions go through `CodexThread.epiphany_update_state(...)`
- verification passed:
- `cargo test --manifest-path .\\epiphany-core\\Cargo.toml`
- `cargo test -p codex-core --lib epiphany`
- `cargo test -p codex-app-server --lib epiphany`
- attempted full `cargo test -p codex-app-server --lib` compiled but hit a pre-existing-looking stack overflow in `in_process_start_uses_requested_session_source_for_thread_start`; Epiphany-scoped app-server tests passed
## Open Questions
- How much more can move into `epiphany-core` without sacrificing the typed Codex host seam that makes the integration first-class?
- How should verified observations imply map/churn edits without turning promotion into automatic graph fanfic?
- How should verified observations propose map/churn edits without turning promotion into automatic graph fanfic?
Do not promote anything from here into the map unless it survives verification or repeated reuse without contradiction.

View file

@ -4269,6 +4269,13 @@ impl CodexMessageProcessor {
let decision = evaluate_promotion(EpiphanyPromotionInput {
has_state_replacements: thread_epiphany_patch_has_state_replacements(&patch),
active_subgoal_id: patch.active_subgoal_id.clone(),
subgoals: patch.subgoals.clone(),
invariants: patch.invariants.clone(),
graphs: patch.graphs.clone(),
graph_frontier: patch.graph_frontier.clone(),
graph_checkpoint: patch.graph_checkpoint.clone(),
churn: patch.churn.clone(),
observations: patch.observations.clone(),
evidence: patch.evidence.clone(),
verifier_evidence: verifier_evidence.clone(),