Validate direct Epiphany replacement updates

This commit is contained in:
Meta Vens 2026-04-25 12:07:25 +01:00
commit e0f1a46844
10 changed files with 231 additions and 32 deletions

View file

@ -10,7 +10,9 @@ pub use distillation::EpiphanyDistillProposal;
pub use distillation::distill_observation;
pub use promotion::EpiphanyPromotionDecision;
pub use promotion::EpiphanyPromotionInput;
pub use promotion::EpiphanyStateReplacementValidationInput;
pub use promotion::evaluate_promotion;
pub use promotion::validate_state_replacement_patch;
pub use prompt::render_epiphany_state;
pub use proposal::EpiphanyMapProposal;
pub use proposal::EpiphanyMapProposalInput;

View file

@ -30,6 +30,25 @@ pub struct EpiphanyPromotionDecision {
pub reasons: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct EpiphanyStateReplacementValidationInput<'a> {
pub active_subgoal_id: Option<&'a str>,
pub subgoals: Option<&'a [EpiphanySubgoal]>,
pub invariants: Option<&'a [EpiphanyInvariant]>,
pub graphs: Option<&'a EpiphanyGraphs>,
pub graph_frontier: Option<&'a EpiphanyGraphFrontier>,
pub graph_checkpoint: Option<&'a EpiphanyGraphCheckpoint>,
pub churn: Option<&'a EpiphanyChurnState>,
}
pub fn validate_state_replacement_patch(
input: EpiphanyStateReplacementValidationInput<'_>,
) -> Vec<String> {
let mut reasons = Vec::new();
validate_state_replacements(input, &mut reasons);
reasons
}
pub fn evaluate_promotion(input: EpiphanyPromotionInput) -> EpiphanyPromotionDecision {
let mut reasons = Vec::new();
@ -74,7 +93,18 @@ pub fn evaluate_promotion(input: EpiphanyPromotionInput) -> EpiphanyPromotionDec
reasons.push(format!("duplicate observation id {:?}", observation.id));
}
}
validate_state_replacements(&input, &mut reasons);
validate_state_replacements(
EpiphanyStateReplacementValidationInput {
active_subgoal_id: input.active_subgoal_id.as_deref(),
subgoals: input.subgoals.as_deref(),
invariants: input.invariants.as_deref(),
graphs: input.graphs.as_ref(),
graph_frontier: input.graph_frontier.as_ref(),
graph_checkpoint: input.graph_checkpoint.as_ref(),
churn: input.churn.as_ref(),
},
&mut reasons,
);
validate_delta_promotion_policy(&input, &mut reasons);
EpiphanyPromotionDecision {
@ -123,33 +153,36 @@ 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);
fn validate_state_replacements(
input: EpiphanyStateReplacementValidationInput<'_>,
reasons: &mut Vec<String>,
) {
if let Some(subgoals) = input.subgoals {
validate_subgoals(subgoals, input.active_subgoal_id, reasons);
}
if let Some(active_subgoal_id) = input.active_subgoal_id.as_deref() {
if let Some(active_subgoal_id) = input.active_subgoal_id {
require_nonempty(active_subgoal_id, "patch.active_subgoal_id", reasons);
}
if let Some(invariants) = &input.invariants {
if let Some(invariants) = input.invariants {
validate_invariants(invariants, reasons);
}
if let Some(graphs) = &input.graphs {
if let Some(graphs) = input.graphs {
validate_graphs(graphs, reasons);
if let Some(frontier) = &input.graph_frontier {
if let Some(frontier) = input.graph_frontier {
validate_frontier_against_graphs(frontier, graphs, reasons);
}
if let Some(checkpoint) = &input.graph_checkpoint {
if let Some(checkpoint) = input.graph_checkpoint {
validate_checkpoint_against_graphs(checkpoint, graphs, reasons);
}
} else {
if let Some(frontier) = &input.graph_frontier {
if let Some(frontier) = input.graph_frontier {
validate_frontier_shape(frontier, reasons);
}
if let Some(checkpoint) = &input.graph_checkpoint {
if let Some(checkpoint) = input.graph_checkpoint {
validate_checkpoint_shape(checkpoint, reasons);
}
}
if let Some(churn) = &input.churn {
if let Some(churn) = input.churn {
validate_churn(churn, reasons);
}
}
@ -669,6 +702,42 @@ mod tests {
);
}
#[test]
fn validate_state_replacement_patch_rejects_structural_map_errors() {
let graphs = 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()
};
let frontier = EpiphanyGraphFrontier {
active_node_ids: vec!["missing".to_string()],
..Default::default()
};
let reasons = validate_state_replacement_patch(EpiphanyStateReplacementValidationInput {
active_subgoal_id: None,
subgoals: None,
invariants: None,
graphs: Some(&graphs),
graph_frontier: Some(&frontier),
graph_checkpoint: None,
churn: None,
});
assert!(
reasons
.iter()
.any(|reason| reason.contains("graph frontier references missing node"))
);
}
#[test]
fn evaluate_promotion_accepts_verified_map_and_churn_replacement() {
let decision = evaluate_promotion(EpiphanyPromotionInput {

View file

@ -20,7 +20,7 @@ The spine exists in eleven live paths:
- explicit distillation proposals: app-server routes read-only `thread/epiphany/distill` through a loaded-thread handler into `epiphany-core` so one explicit observation can become a patch candidate without mutating state; tool/command/shell/model sources now get typed evidence kinds and bounded salient-output summaries instead of dumping raw output into durable state in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4173) and [distillation.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/distillation.rs:28).
- explicit map/churn proposals: app-server routes read-only `thread/epiphany/propose` through a loaded-thread handler into `epiphany-core` so verified observations with code refs and accepting recent evidence can be selected explicitly or auto-selected as a bounded path cluster, prioritize the strongest selected observation, focus or extend architecture graph nodes, rescue unanchored graph nodes through strict semantic overlap, carry linked dataflow nodes and incident graph edges into the frontier, update churn candidates with match-kind-aware map-delta judgment and pressure, and still avoid mutation until promotion in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4244) and [proposal.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/proposal.rs:115).
- explicit promotion gates: app-server routes `thread/epiphany/promote` through a loaded-thread handler into `epiphany-core` policy evaluation, rejects failed verifier evidence without mutation, applies accepted candidates through the same durable update path, and now treats medium/high/expanded/broadening/semantic churn deltas as needing explicit rationale plus stronger verifier evidence whose kind is token-matched rather than substring-matched in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4316) and [promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:33).
- explicit state updates: app-server routes `thread/epiphany/update` through a loaded `CodexThread` update method that rejects malformed appended observation/evidence graph records, mutates live `SessionState` only after validation, bumps the revision, and persists an immediate `RolloutItem::EpiphanyState` snapshot in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4436), [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:374), and [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:552).
- explicit state updates: app-server routes `thread/epiphany/update` through a loaded `CodexThread` update method that rejects malformed appended observation/evidence graph records and structurally invalid replacement fields, mutates live `SessionState` only after validation, bumps the revision, and persists an immediate `RolloutItem::EpiphanyState` snapshot in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4436), [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:374), [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:552), and [promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:44).
- live state notifications and write responses: app-server declares experimental `thread/epiphany/stateUpdated` and emits it with the updated typed state, typed `source`, event-level `revision`, and typed `changedFields` after successful direct updates and accepted promotions, but not after rejected promotions; direct `thread/epiphany/update` and accepted `thread/epiphany/promote` responses now expose the same revision/change metadata, and accepted promotions always report `Evidence` because verifier evidence is appended even when the patch evidence list is empty, in [common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:1097), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:3992), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4058), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4069), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4379), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4464), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:10509), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:10552).
- retrieval/indexing: app-server routes `thread/epiphany/retrieve` and `thread/epiphany/index` through loaded `CodexThread` host methods into `epiphany-core` in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4046), [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4122), [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:438), and [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:456).
@ -128,7 +128,7 @@ This is the same flow in the language Epiphany is supposed to make the model car
| Distillation | One explicit observation can be normalized into a typed observation/evidence patch, but not promoted automatically. | The clerk drafts a ledger entry in pencil before anyone is allowed to ink it. |
| Proposal | Verified observations with code refs and accepting recent evidence can produce a candidate graph frontier/churn patch without writing it. The caller can provide ids, or the proposal engine can choose a small ready path cluster, then reuse existing map nodes, focus linked context, and report pressure from the actual map delta. | The clerk overlays tracing paper on the old blueprint before drawing a new wall through the plumbing, choosing only the relevant marked pages before measuring how much load the sketch would put on the frame. |
| Promotion | A verifier-backed gate can reject a proposal without mutation or send it through the durable update path. | The foreman stamps the pencil draft before the clerk reaches for the red pen. |
| State updates | A loaded thread can accept explicit typed patches that append validated observations/evidence and replace bounded map/scratch/churn fields. Malformed appended evidence graph records are rejected before mutation. | The clerk finally has the red pen, but the ledger refuses orphan receipts before the ink touches paper. |
| State updates | A loaded thread can accept explicit typed patches that append validated observations/evidence and replace bounded map/scratch/churn fields. Malformed appended evidence graph records and structurally broken replacement fields are rejected before mutation. | The clerk finally has the red pen, but the ledger refuses orphan receipts and impossible blueprint tabs before the ink touches paper. |
| State responses and notifications | Successful direct updates and accepted promotions return the landed revision and changed-field labels to the caller, then publish the updated typed state to app-server clients with source, revision, and changed-field labels saying which control-plane door caused it and which ledger sections changed. | The clerk hands the filing receipt to the person at the counter, then rings the little bell for the room; both say which page number landed and which tabs were touched. |
## Flow 1: State Shape
@ -584,11 +584,12 @@ The app-server handler:
4. enforces `expectedRevision` when supplied.
5. validates appended evidence records have nonempty id/kind/status/summary, do not duplicate ids in the patch, and do not reuse existing durable evidence ids.
6. validates appended observations have nonempty id/summary/source kind/status, do not duplicate ids in the patch, do not reuse existing durable observation ids, and cite at least one evidence id that already exists or is appended in the same patch.
7. rejects invalid append patches before `SessionState`, rollout, response metadata, or notification side effects.
8. applies typed replacements, prepends new observations/evidence, increments `revision`, and records `last_updated_turn_id`.
9. writes the new state back into live `SessionState`.
10. immediately persists `RolloutItem::EpiphanyState`.
11. flushes the rollout durability barrier.
7. validates state replacement shapes through the shared `epiphany-core` structural validator used by promotion: active subgoal ids must resolve against the patch or current subgoals, graph/frontier/checkpoint references must resolve against the patch or current graphs, and churn replacement fields must carry required structural fields.
8. rejects invalid append or replacement patches before `SessionState`, rollout, response metadata, or notification side effects.
9. applies typed replacements, prepends new observations/evidence, increments `revision`, and records `last_updated_turn_id`.
10. writes the new state back into live `SessionState`.
11. immediately persists `RolloutItem::EpiphanyState`.
12. flushes the rollout durability barrier.
The replay helpers now also accept an out-of-band Epiphany snapshot before the first real user turn, so a seed/update written through this path is not lost just because no model turn has happened yet.
@ -600,12 +601,14 @@ Code refs:
- [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:374)
- [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:552)
- [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:645)
- [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:34)
- [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:44)
- [session/rollout_reconstruction.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/session/rollout_reconstruction.rs:45)
- [epiphany-core/src/rollout.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/rollout.rs:18)
### Output
The response contains the updated `epiphanyState`, response-level `revision`, and response-level `changedFields`. The durable side effect is a rollout `EpiphanyState` snapshot. The live side effect is a `thread/epiphany/stateUpdated` notification carrying `source: "update"`, event-level `revision`, typed `changedFields`, and the same updated state to app-server clients. Invalid append patches return an invalid-request error, leave revision unchanged, and emit no state update notification.
The response contains the updated `epiphanyState`, response-level `revision`, and response-level `changedFields`. The durable side effect is a rollout `EpiphanyState` snapshot. The live side effect is a `thread/epiphany/stateUpdated` notification carrying `source: "update"`, event-level `revision`, typed `changedFields`, and the same updated state to app-server clients. Invalid append or replacement patches return an invalid-request error, leave revision unchanged, and emit no state update notification.
### Invariant
@ -925,7 +928,7 @@ Current tests cover the landed flows at useful seams:
- 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/propose`, `thread/epiphany/promote`, `thread/epiphany/update`, and `thread/epiphany/stateUpdated`.
- app-server mapping of core retrieval/index summaries into protocol responses.
- reusable app-server smoke for the richer Phase 5 chain in `tools/epiphany_phase5_smoke.py`, including update/promote response metadata, `thread/epiphany/stateUpdated` notifications with source, revision, changed-field checks, the verifier-only promotion edge where `Evidence` changes because verifier evidence is appended, invalid direct update rejection without mutation or notification, and explicit zero-notification assertions for rejected promotions.
- reusable app-server smoke for the richer Phase 5 chain in `tools/epiphany_phase5_smoke.py`, including update/promote response metadata, `thread/epiphany/stateUpdated` notifications with source, revision, changed-field checks, the verifier-only promotion edge where `Evidence` changes because verifier evidence is appended, invalid direct observation/evidence and replacement update rejection without mutation or notification, and explicit zero-notification assertions for rejected promotions.
Useful commands:

View file

@ -31,7 +31,7 @@ This note tracks Epiphany as a fork of Codex with an opinionated modeling archit
- a first live typed-state notification slice: experimental `thread/epiphany/stateUpdated` fires after successful direct `thread/epiphany/update` and accepted `thread/epiphany/promote`, carrying the updated `epiphanyState`, typed `source`, event-level `revision`, and typed `changedFields` while rejected promotions remain silent and non-mutating; accepted promotions always report `evidence` when verifier evidence is appended
- a direct write-response hardening slice: successful `thread/epiphany/update` and accepted `thread/epiphany/promote` responses now include response-level `revision` and `changedFields`, matching the notification metadata without forcing the initiating caller to wait for or diff the live event
- a rejected-promotion silence smoke hardening slice: the reusable Phase 5 app-server smoke now asserts rejected `thread/epiphany/promote` calls do not emit `thread/epiphany/stateUpdated`, preserving the "no mutation, no bell" contract
- a direct-update integrity hardening slice: `thread/epiphany/update` now validates appended observation/evidence graph records before mutation, rejects empty/duplicate/reused append ids and missing evidence references, and the reusable smoke asserts invalid direct updates leave revision unchanged with no `thread/epiphany/stateUpdated` notification
- a direct-update integrity hardening slice: `thread/epiphany/update` now validates appended observation/evidence graph records and structural replacement fields before mutation, rejects empty/duplicate/reused append ids, missing evidence references, and invalid graph/frontier/checkpoint/subgoal/churn replacement shapes, and the reusable smoke asserts invalid direct updates leave revision unchanged with no `thread/epiphany/stateUpdated` notification
- current phase: post-Phase-5 live typed state/read-surface hardening
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, make promotion notice broken map/churn replacements, ship the first read-only map/churn proposal surface, teach proposal to avoid duplicating already-mapped code surfaces, make proposal frontier focus follow existing graph links, make selected observations evidence-backed with non-haunted churn pressure, rescue unanchored graph nodes with strict semantic matching, let proposal choose a bounded ready observation set when ids are omitted, make proposal pressure understand match kinds, make the distiller summarize noisy source output, make source-output salience prefer final results over generic warnings, make promotion notice risky deltas, make expansion freshness resistant to low-pressure underreporting, prevent substring verifier-kind accidents, prove that richer chain over app-server by hand, add the first live state notification after successful writes, or make that notification say which revision and state sections changed. Those parts are landed or live-smoked. The next job is to use the reusable smoke harness as a guardrail before adding the next smallest source-grounded notification/read/proposal/promotion rule, and to keep `thread/epiphany/retrieve`, `thread/epiphany/distill`, and `thread/epiphany/propose` out of the durable-writer business.
@ -252,7 +252,7 @@ Current bounded shape of that follow-up:
- accepted `thread/epiphany/promote` responses now also carry response-level `revision` and `changedFields`; rejected promotion responses omit revision/change metadata and still carry reasons plus no state
- the richer app-server smoke now asserts direct update responses/notifications publish revision/changed-fields `1`/`observations,evidence`, verifier-only accepted promotion responses/notifications publish `2`/`observations,evidence`, and accepted graph/churn promotion responses/notifications publish `3`/`graphs,graphFrontier,observations,evidence,churn`
- the richer app-server smoke also asserts rejected promotion attempts leave `rejectedPromotionNotificationCount` at `0`
- the richer app-server smoke now also asserts malformed direct `thread/epiphany/update` observation/evidence appends return an invalid-request error, leave revision at `2`, and leave `invalidDirectUpdateNotificationCount` at `0`
- the richer app-server smoke now also asserts malformed direct `thread/epiphany/update` observation/evidence appends and structural replacement patches return invalid-request errors, leave revision at `2`, and leave `invalidDirectUpdateNotificationCount` / `invalidReplacementUpdateNotificationCount` at `0`
Follow-up addition now landed after the update surface:
@ -304,7 +304,7 @@ Add tests for:
## Immediate Next Step
Treat the current retrieval baseline and explicit indexing follow-up as landed Phase 4. Treat the explicit update path, distillation proposal path, read-only map/churn proposal path, verifier-backed promotion gate, structural map/churn promotion validation, graph-node reuse, linked frontier focus, evidence-backed selection, map-delta churn pressure, selected-observation prioritization, strict unanchored-node semantic reuse, automatic bounded observation-set selection, match-kind-aware map-delta judgment, source-output-aware observation distillation, source-output salience ranking, risky-delta promotion policy, expansion-freshness promotion hardening, token-aware verifier-kind promotion hardening, reusable richer app-server smoke harness, and sourced/revisioned/changed-field `thread/epiphany/stateUpdated` notification seam as the active baseline. The next machine gap is no longer "can Epiphany retrieve code?", "can it propose one durable observation patch?", "can it draft one bounded map/churn candidate?", "can it avoid duplicating an existing graph node when an observation points at already-mapped code?", "can it focus linked graph context?", "can it reject selected observations that are not backed by accepting recent evidence?", "can it use graph language when no concrete refs exist yet?", "can it choose a proposal-ready observation set when ids are omitted?", "can it distinguish exact refinement from same-path broadening or semantic anchoring?", "can it summarize noisy tool/model output as typed evidence?", "can it rank final result/error/finished lines above generic warnings?", "can promotion policy reason over richer deltas without silently writing them?", "can expansion freshness resist low-pressure underreporting?", "can verifier kind matching avoid substring accidents?", "does the richer Phase 5 chain behave through the app-server seam?", or "can successful writes notify a client what changed state and why?" It can.
Treat the current retrieval baseline and explicit indexing follow-up as landed Phase 4. Treat the explicit update path, distillation proposal path, read-only map/churn proposal path, verifier-backed promotion gate, structural map/churn promotion validation, direct-update append/replacement integrity validation, graph-node reuse, linked frontier focus, evidence-backed selection, map-delta churn pressure, selected-observation prioritization, strict unanchored-node semantic reuse, automatic bounded observation-set selection, match-kind-aware map-delta judgment, source-output-aware observation distillation, source-output salience ranking, risky-delta promotion policy, expansion-freshness promotion hardening, token-aware verifier-kind promotion hardening, reusable richer app-server smoke harness, and sourced/revisioned/changed-field `thread/epiphany/stateUpdated` notification seam as the active baseline. The next machine gap is no longer "can Epiphany retrieve code?", "can it propose one durable observation patch?", "can it draft one bounded map/churn candidate?", "can it avoid duplicating an existing graph node when an observation points at already-mapped code?", "can it focus linked graph context?", "can it reject selected observations that are not backed by accepting recent evidence?", "can it use graph language when no concrete refs exist yet?", "can it choose a proposal-ready observation set when ids are omitted?", "can it distinguish exact refinement from same-path broadening or semantic anchoring?", "can it summarize noisy tool/model output as typed evidence?", "can it rank final result/error/finished lines above generic warnings?", "can promotion policy reason over richer deltas without silently writing them?", "can direct update reject malformed append and replacement patches before writing?", "can expansion freshness resist low-pressure underreporting?", "can verifier kind matching avoid substring accidents?", "does the richer Phase 5 chain behave through the app-server seam?", or "can successful writes notify a client what changed state and why?" It can.
1. treat the current verified-and-landed query-time hybrid retriever as the Phase 4 slice 1 baseline
2. treat the verified and live-smoked `thread/epiphany/index` slice as the bounded persistent-semantic follow-up

View file

@ -37,7 +37,7 @@ Recent anchor commits:
- current slice - fixed accepted promotion notifications so `changedFields` always includes `evidence` when verifier evidence is appended, even if the accepted patch had no patch evidence
- current slice - added response-level `revision` and `changedFields` to successful `thread/epiphany/update` and accepted `thread/epiphany/promote` responses
- current slice - hardened the reusable Phase 5 smoke so rejected promotions explicitly prove `thread/epiphany/stateUpdated` stays silent
- current slice - hardened direct `thread/epiphany/update` so malformed appended observations/evidence are rejected before mutation and the reusable smoke proves invalid direct updates stay silent
- current slice - hardened direct `thread/epiphany/update` so malformed appended observations/evidence and structurally invalid replacement fields are rejected before mutation, and the reusable smoke proves invalid direct updates stay silent
Do not trust this note for the exact current HEAD; use `git log --oneline -1` if you need the live commit.
@ -95,13 +95,13 @@ Current verified typed state/promotion layer:
- experimental `thread/epiphany/stateUpdated` notifications after successful direct updates and accepted promotions, with typed `source` values of `update` or `promote`, event-level `revision`, and typed `changedFields`
- response-level `revision` and `changedFields` on successful `thread/epiphany/update` and accepted `thread/epiphany/promote`
- typed patch shape for observations, evidence, objective, subgoals, invariants, graphs, graph frontier/checkpoint, scratch, churn, and mode
- direct `thread/epiphany/update` validation for appended observations/evidence: append records need nonempty identity fields, patch ids cannot duplicate or reuse durable ids, and observations must cite existing or same-patch evidence ids before the durable writer mutates state
- direct `thread/epiphany/update` validation for appended observations/evidence and replacement fields: append records need nonempty identity fields, patch ids cannot duplicate or reuse durable ids, observations must cite existing or same-patch evidence ids, and graph/frontier/checkpoint/subgoal/churn replacement shapes must pass the shared `epiphany-core` structural validator before the durable writer mutates state
- deterministic observation/evidence proposal generation in `epiphany-core`
- observation distillation now keeps noisy tool/command/shell/model output bounded by selecting salient output lines, prioritizing final result/failure/error/finished lines above generic warnings, and defaulting evidence kind to `tool-output` or `model-output` when the caller did not provide one
- deterministic map/churn proposal generation in `epiphany-core`, now with first-pass graph-node reuse by exact code-ref, same-path overlap, or deterministic path-node id before new candidate nodes are created, strict semantic reuse for unanchored architecture nodes, linked frontier focus through graph links and named incident edges, evidence-backed selected observations via accepting `recent_evidence`, selected-observation prioritization, automatic bounded observation selection when ids are omitted, and match-kind-aware churn pressure derived from exact-ref refinement, same-path broadening, deterministic-node reuse, semantic anchoring, or new candidate surfaces
- verifier-backed promotion policy evaluation in `epiphany-core`, now rejecting risky churn deltas unless they carry explicit warning rationale and strong verifier evidence kind; graph expansion is risky even if a patch understates `diff_pressure`, and strong verifier kinds are token-matched instead of substring-matched
- core `CodexThread.epiphany_update_state(...)` applies the patch to live `SessionState`, bumps revision, persists `RolloutItem::EpiphanyState`, and flushes rollout
- app-server now returns response-level `revision` and `changedFields` from successful direct update and accepted promotion calls, and emits `thread/epiphany/stateUpdated` after the direct update path and after accepted promotion return an updated state; notifications identify their `source`, expose event-level `revision`, list typed `changedFields`, and the richer smoke now explicitly proves rejected promotions plus invalid direct updates still do not mutate state or emit an update notification
- app-server now returns response-level `revision` and `changedFields` from successful direct update and accepted promotion calls, and emits `thread/epiphany/stateUpdated` after the direct update path and after accepted promotion return an updated state; notifications identify their `source`, expose event-level `revision`, list typed `changedFields`, and the richer smoke now explicitly proves rejected promotions plus invalid direct append/replacement updates still do not mutate state or emit an update notification
- replay helpers in both `codex-core` and `epiphany-core` accept an out-of-band Epiphany snapshot before the first real user turn so seed/update snapshots survive resume
- retrieval remains read-only and indexing remains the only persistent semantic write path
@ -209,7 +209,7 @@ What is wired right now:
- `thread/epiphany/propose` is the first read-only map/churn proposal surface; it uses caller-supplied observation ids or auto-selects a bounded coherent path cluster from verified observations with accepting `recent_evidence` and code refs, focuses existing architecture graph nodes when refs overlap, creates new candidate path nodes only for unmapped surfaces, follows graph links into related architecture/dataflow nodes, marks named incident graph edges active, derives churn pressure from the proposal shape, and returns replacement graphs/frontier/churn for explicit promotion
- `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, validates appended observations/evidence, appends valid observations/evidence, replaces bounded typed fields, increments state revision, and persists immediately
- `thread/epiphany/update` is the first explicit durable Epiphany state write surface; it rejects empty patches, supports optional revision matching, validates appended observations/evidence and structural replacement fields, appends valid observations/evidence, replaces bounded typed fields, increments state revision, and persists immediately
- `thread/epiphany/stateUpdated` is the first live typed-state notification surface; it fires only after successful `thread/epiphany/update` and accepted `thread/epiphany/promote`, carrying the full updated `epiphanyState`, event-level `revision`, typed `changedFields`, plus `source: "update"` or `source: "promote"`; the direct update/promote responses also return `revision` and `changedFields`
- 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
@ -374,7 +374,7 @@ If a future session wakes up from compaction and starts bluffing, this is the pa
- proposal churn now distinguishes exact-ref refinements from same-path broadening, deterministic-node reuse, semantic unanchored-node reuse, and new candidate surfaces before setting graph freshness and diff pressure
- distillation now summarizes noisy tool/command/shell/model output into salient typed evidence rather than preserving raw output sludge as the summary, and final result/failure/error/finished lines outrank generic warnings unless warning is the only central signal
- promotion now rejects medium/high/expanded/broadening/semantic/update churn deltas that lack a warning, carry weak generic verifier evidence, or rely on substring verifier-kind accidents such as `contest`
- richer Phase 5 app-server smoke is now reproducible through `tools/epiphany_phase5_smoke.py`; it verifies `shell-tool` distillation to `tool-output`, read-only proposal, update notification source/revision/changed-fields `update`/1/`observations,evidence`, verifier-only accepted promotion notification source/revision/changed-fields `promote`/2/`observations,evidence`, invalid direct update rejection with unchanged revision and no state update notification, risky-churn rejection without warning, expanded-low-pressure rejection, substring verifier-kind rejection, weak verifier-kind rejection, accepted promotion only with warning plus strong verifier evidence, and graph/churn promote notification source/revision/changed-fields `promote`/3/`graphs,graphFrontier,observations,evidence,churn`
- richer Phase 5 app-server smoke is now reproducible through `tools/epiphany_phase5_smoke.py`; it verifies `shell-tool` distillation to `tool-output`, read-only proposal, update notification source/revision/changed-fields `update`/1/`observations,evidence`, verifier-only accepted promotion notification source/revision/changed-fields `promote`/2/`observations,evidence`, invalid direct observation/evidence update rejection with unchanged revision and no state update notification, invalid direct replacement update rejection with unchanged revision and no state update notification, risky-churn rejection without warning, expanded-low-pressure rejection, substring verifier-kind rejection, weak verifier-kind rejection, accepted promotion only with warning plus strong verifier evidence, and graph/churn promote notification source/revision/changed-fields `promote`/3/`graphs,graphFrontier,observations,evidence,churn`
- the Epiphany algorithmic map has been semantically audited flow-by-flow against the cited code; stale line anchors were fixed, and the current typed spine still describes a coherent machine
- the next bounded follow-up should use the richer smoke harness as a guardrail before the next source-grounded proposal/promotion hardening slice
- There is now a first live `thread/epiphany/stateUpdated` notification seam for successful update/promote writes; it includes source, revision, and changed-field metadata, but there is still no broader watcher/job/specialist event stream.
@ -524,4 +524,4 @@ cmd /c "\"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxi
The repo is in a good state.
Phase 1, Phase 2, the minimal Phase 3 typed read surface, Phase 4 retrieval/indexing/core-extraction, and the current Phase 5 distill/propose/promote/update baseline are landed and verified. `vendor/codex` is first-class in the parent repo now, with the heavier Epiphany organs living in `epiphany-core`. Successful direct updates and accepted promotions now return response-level revision/changed-field metadata and emit experimental `thread/epiphany/stateUpdated` notifications carrying the updated typed state, source labels, event-level revision, and typed changed fields; accepted promotion responses/notifications include `evidence` whenever verifier evidence is appended, even when patch evidence was empty. Direct updates now reject malformed appended observations/evidence before mutation, including missing evidence references, duplicate append ids, and durable id reuse. Read-only distillation now summarizes noisy tool/model output into typed evidence and ranks final result/error/finished lines above generic warnings, read-only proposal can auto-select a bounded evidence-backed observation cluster and report match-kind-aware map-delta pressure, promotion rejects risky deltas without warning plus strong verifier evidence, expansion freshness cannot bypass warning requirements by claiming low pressure, and verifier kinds are token-matched so `contest` does not pass as `test`. The current Epiphany algorithmic map has been source-audited against the code flow it describes. The richer Phase 5 chain is now live-smoked through app-server by `tools/epiphany_phase5_smoke.py`, including update response/notification revision/changed-fields `1`/`observations,evidence`, verifier-only accepted promotion response/notification changed-fields `2`/`observations,evidence`, invalid direct update rejection with `invalidDirectUpdateNotificationCount: 0` and unchanged revision `2`, expanded-low-pressure and substring-verifier rejection checks, explicit rejected-promotion notification silence with `rejectedPromotionNotificationCount: 0`, accepted graph/churn promotion, and promote response/notification revision/changed-fields `3`/`graphs,graphFrontier,observations,evidence,churn`. The next clean move is the next bounded live typed-state/read-surface hardening slice, with that smoke harness as a guardrail; not GUI paint, watcher magic, or re-proving retrieval. Pre-compaction persistence is now an explicit design rule, not a lucky habit.
Phase 1, Phase 2, the minimal Phase 3 typed read surface, Phase 4 retrieval/indexing/core-extraction, and the current Phase 5 distill/propose/promote/update baseline are landed and verified. `vendor/codex` is first-class in the parent repo now, with the heavier Epiphany organs living in `epiphany-core`. Successful direct updates and accepted promotions now return response-level revision/changed-field metadata and emit experimental `thread/epiphany/stateUpdated` notifications carrying the updated typed state, source labels, event-level revision, and typed changed fields; accepted promotion responses/notifications include `evidence` whenever verifier evidence is appended, even when patch evidence was empty. Direct updates now reject malformed appended observations/evidence and structurally invalid replacement fields before mutation, including missing evidence references, duplicate append ids, durable id reuse, and impossible graph frontier/checkpoint/subgoal/churn shapes. Read-only distillation now summarizes noisy tool/model output into typed evidence and ranks final result/error/finished lines above generic warnings, read-only proposal can auto-select a bounded evidence-backed observation cluster and report match-kind-aware map-delta pressure, promotion rejects risky deltas without warning plus strong verifier evidence, expansion freshness cannot bypass warning requirements by claiming low pressure, and verifier kinds are token-matched so `contest` does not pass as `test`. The current Epiphany algorithmic map has been source-audited against the code flow it describes. The richer Phase 5 chain is now live-smoked through app-server by `tools/epiphany_phase5_smoke.py`, including update response/notification revision/changed-fields `1`/`observations,evidence`, verifier-only accepted promotion response/notification changed-fields `2`/`observations,evidence`, invalid direct observation/evidence update rejection with `invalidDirectUpdateNotificationCount: 0` and unchanged revision `2`, invalid direct replacement update rejection with `invalidReplacementUpdateNotificationCount: 0` and unchanged revision `2`, expanded-low-pressure and substring-verifier rejection checks, explicit rejected-promotion notification silence with `rejectedPromotionNotificationCount: 0`, accepted graph/churn promotion, and promote response/notification revision/changed-fields `3`/`graphs,graphFrontier,observations,evidence,churn`. The next clean move is the next bounded live typed-state/read-surface hardening slice, with that smoke harness as a guardrail; not GUI paint, watcher magic, or re-proving retrieval. Pre-compaction persistence is now an explicit design rule, not a lucky habit.

View file

@ -148,3 +148,5 @@
{"ts": "2026-04-25T08:23:35+00:00", "type": "verification", "status": "ok", "note": "Hardened Phase 5 smoke coverage for rejected promotion silence. The reusable app-server smoke now asserts rejected thread/epiphany/promote calls emit no thread/epiphany/stateUpdated notifications and records rejectedPromotionNotificationCount: 0, after reverting an Aetheria-example-only retrieval filter that was not part of the active task."}
{"ts": "2026-04-25T09:29:33+00:00", "type": "implementation", "status": "ok", "note": "Hardened direct thread/epiphany/update integrity validation in codex-core: appended evidence now requires nonempty id/kind/status/summary, patch ids cannot duplicate or reuse durable ids, and appended observations must cite existing or same-patch evidence before mutation."}
{"ts": "2026-04-25T09:29:33+00:00", "type": "verification", "status": "ok", "note": "Verified direct-update integrity slice: py_compile tools/epiphany_phase5_smoke.py passed; cargo test -p codex-core --lib epiphany_update passed 4 tests; rebuilt codex-app-server; tools/epiphany_phase5_smoke.py passed with invalidDirectUpdateNotificationCount 0 and invalidDirectUpdateRevisionAfterReject 2."}
{"ts": "2026-04-25T11:07:05+00:00", "type": "implementation", "status": "ok", "note": "Extended direct thread/epiphany/update validation to structural replacement fields by exposing epiphany-core validate_state_replacement_patch and calling it from codex-core before mutation; graph/frontier/checkpoint/subgoal/churn shape errors now reject like malformed append records."}
{"ts": "2026-04-25T11:07:11+00:00", "type": "verification", "status": "ok", "note": "Verified direct-update structural replacement validation: py_compile tools/epiphany_phase5_smoke.py passed; cargo test epiphany-core promotion structural helper passed; cargo test -p codex-core --lib epiphany_update passed 5 tests; cargo build -p codex-app-server passed; tools/epiphany_phase5_smoke.py passed with invalidReplacementUpdateNotificationCount 0 and invalidReplacementUpdateRevisionAfterReject 2."}

View file

@ -112,7 +112,7 @@ accepted_design:
- harden accepted-promotion notification accuracy: because accepted `thread/epiphany/promote` always appends verifier evidence to durable state, promotion notifications must include `evidence` in `changedFields` even when the submitted patch evidence list was empty
- harden direct write responses: successful `thread/epiphany/update` and accepted `thread/epiphany/promote` responses now include response-level `revision` and `changedFields`, matching the live notification metadata for initiating callers
- harden rejected-promotion notification silence: the richer Phase 5 app-server smoke now asserts rejected `thread/epiphany/promote` calls do not emit `thread/epiphany/stateUpdated`
- harden direct update integrity: `thread/epiphany/update` now rejects malformed appended observations/evidence before durable persistence, including empty append record fields, duplicate append ids, durable id reuse, and observations that cite missing evidence ids; invalid direct updates leave revision unchanged and emit no state update notification
- harden direct update integrity: `thread/epiphany/update` now rejects malformed appended observations/evidence and structurally invalid replacement fields before durable persistence, including empty append record fields, duplicate append ids, durable id reuse, observations that cite missing evidence ids, and graph/frontier/checkpoint/subgoal/churn replacement shape errors; invalid direct updates leave revision unchanged and emit no state update notification
rejected_paths:
- name: pinned_map_only
@ -129,5 +129,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, handoff notes, architecture maps, and a rolling implementation plan against vendored Codex. Phase 1 durable state, Phase 2 prompt integration, the minimal Phase 3 typed read surface, and Phase 4 retrieval/indexing/core-extraction are landed on `main`. Phase 5 semantic distillation/promotion/proposal is now active and chewed enough for the first live typed-state stream seam: the current baseline includes explicit Qdrant indexing with BM25 fallback, typed `thread/epiphany/update`, read-only `thread/epiphany/distill`, read-only `thread/epiphany/propose`, verifier-backed `thread/epiphany/promote`, response-level `revision` and `changedFields` on successful direct updates and accepted promotions, experimental `thread/epiphany/stateUpdated` notifications after successful direct updates and accepted promotions with typed `source` values of `update` or `promote`, event-level `revision`, typed `changedFields`, structural map/churn promotion validation, direct-update observation/evidence integrity validation, source-output-aware distillation with result/error/finished lines prioritized over generic warnings, graph-node reuse, linked frontier focus, evidence-backed proposal selection, selected-observation prioritization, strict unanchored-node semantic reuse, automatic bounded observation selection, match-kind-aware map-delta churn pressure, risky-delta promotion policy in `epiphany-core`, expansion-freshness promotion hardening so new map surfaces cannot bypass warning requirements by claiming low pressure, token-aware verifier-kind matching so substring accidents cannot satisfy strong verifier evidence, a reusable richer Phase 5 app-server smoke harness, and thin vendored Codex host seams. Promotion responses and notifications now include `evidence` in `changedFields` whenever accepted promotion appends verifier evidence, even if the patch evidence list was empty. The richer smoke now verifies source-output distillation, read-only proposal, update response/notification revision/changed-fields `1`/`observations,evidence`, verifier-only accepted promotion response/notification `2`/`observations,evidence`, invalid direct update rejection with `invalidDirectUpdateNotificationCount: 0` and unchanged revision `2`, risky-delta rejection, expanded-low-pressure rejection, weak-verifier rejection, substring-verifier rejection, rejected-promotion notification silence with `rejectedPromotionNotificationCount: 0`, accepted graph/churn promotion, and graph/churn promote response/notification revision/changed-fields `3`/`graphs,graphFrontier,observations,evidence,churn` through the app-server seam. The Windows app-server stack-pressure regression was narrowed to test-thread stack size; repo-local Cargo config sets `RUST_MIN_STACK=67108864`, and boxed-future request-dispatch experiments were removed after config-only verification passed. The repo also has source-available/commercial licensing plus a CLA gate for external contributions.
summary: Workspace initialized with protocol files, typed state, handoff notes, architecture maps, and a rolling implementation plan against vendored Codex. Phase 1 durable state, Phase 2 prompt integration, the minimal Phase 3 typed read surface, and Phase 4 retrieval/indexing/core-extraction are landed on `main`. Phase 5 semantic distillation/promotion/proposal is now active and chewed enough for the first live typed-state stream seam: the current baseline includes explicit Qdrant indexing with BM25 fallback, typed `thread/epiphany/update`, read-only `thread/epiphany/distill`, read-only `thread/epiphany/propose`, verifier-backed `thread/epiphany/promote`, response-level `revision` and `changedFields` on successful direct updates and accepted promotions, experimental `thread/epiphany/stateUpdated` notifications after successful direct updates and accepted promotions with typed `source` values of `update` or `promote`, event-level `revision`, typed `changedFields`, structural map/churn promotion validation, direct-update observation/evidence integrity validation, direct-update structural replacement validation, source-output-aware distillation with result/error/finished lines prioritized over generic warnings, graph-node reuse, linked frontier focus, evidence-backed proposal selection, selected-observation prioritization, strict unanchored-node semantic reuse, automatic bounded observation selection, match-kind-aware map-delta churn pressure, risky-delta promotion policy in `epiphany-core`, expansion-freshness promotion hardening so new map surfaces cannot bypass warning requirements by claiming low pressure, token-aware verifier-kind matching so substring accidents cannot satisfy strong verifier evidence, a reusable richer Phase 5 app-server smoke harness, and thin vendored Codex host seams. Promotion responses and notifications now include `evidence` in `changedFields` whenever accepted promotion appends verifier evidence, even if the patch evidence list was empty. The richer smoke now verifies source-output distillation, read-only proposal, update response/notification revision/changed-fields `1`/`observations,evidence`, verifier-only accepted promotion response/notification `2`/`observations,evidence`, invalid direct observation/evidence update rejection with `invalidDirectUpdateNotificationCount: 0` and unchanged revision `2`, invalid direct replacement update rejection with `invalidReplacementUpdateNotificationCount: 0` and unchanged revision `2`, risky-delta rejection, expanded-low-pressure rejection, weak-verifier rejection, substring-verifier rejection, rejected-promotion notification silence with `rejectedPromotionNotificationCount: 0`, accepted graph/churn promotion, and graph/churn promote response/notification revision/changed-fields `3`/`graphs,graphFrontier,observations,evidence,churn` through the app-server seam. The Windows app-server stack-pressure regression was narrowed to test-thread stack size; repo-local Cargo config sets `RUST_MIN_STACK=67108864`, and boxed-future request-dispatch experiments were removed after config-only verification passed. The repo also has source-available/commercial licensing plus a CLA gate for external contributions.
next_action: Continue the next bounded live typed-state/read-surface hardening slice from the now-notified update/promote seam; keep retrieval/distillation/proposal read-only, keep accepted writes routed through promotion/update, and avoid GUI, watcher, or specialist-agent machinery.

View file

@ -474,6 +474,50 @@ def run_smoke(args: argparse.Namespace) -> dict[str, Any]:
"invalid direct update should not mutate state",
)
invalid_replacement_update_notification_start = len(client.notifications)
invalid_replacement_update_error = client.send_expect_error(
"thread/epiphany/update",
{
"threadId": thread_id,
"expectedRevision": 2,
"patch": {
"graphFrontier": {
"active_node_ids": ["missing-direct-update-node"],
}
},
},
)
invalid_replacement_update_message = str(
invalid_replacement_update_error.get("message", "")
)
require(
"invalid epiphany update patch" in invalid_replacement_update_message,
"invalid replacement update should return update validation error",
)
require(
"graph frontier references missing node" in invalid_replacement_update_message,
"invalid replacement update should name the missing graph frontier node",
)
client.require_no_notification(
"thread/epiphany/stateUpdated",
start_index=invalid_replacement_update_notification_start,
)
invalid_replacement_update_notification_count = client.count_notifications(
"thread/epiphany/stateUpdated",
start_index=invalid_replacement_update_notification_start,
)
read_after_invalid_replacement_update = client.send(
"thread/read", {"threadId": thread_id, "includeTurns": False}
)
assert read_after_invalid_replacement_update is not None
require(
read_after_invalid_replacement_update["thread"]
.get("epiphanyState", {})
.get("revision")
== 2,
"invalid replacement update should not mutate state",
)
propose = client.send("thread/epiphany/propose", {"threadId": thread_id})
assert propose is not None
proposal_patch = propose["patch"]
@ -715,6 +759,13 @@ def run_smoke(args: argparse.Namespace) -> dict[str, Any]:
"invalidDirectUpdateRevisionAfterReject": read_after_invalid_direct_update["thread"]
.get("epiphanyState", {})
.get("revision"),
"invalidReplacementUpdateMessage": invalid_replacement_update_message,
"invalidReplacementUpdateNotificationCount": invalid_replacement_update_notification_count,
"invalidReplacementUpdateRevisionAfterReject": read_after_invalid_replacement_update[
"thread"
]
.get("epiphanyState", {})
.get("revision"),
"proposalExpectedRevision": propose["expectedRevision"],
"proposalObservationId": proposal_patch["observations"][0]["id"],
"proposalChurn": proposal_patch["churn"],

View file

@ -1,5 +1,7 @@
use crate::agent::AgentStatus;
use crate::config::ConstraintResult;
use crate::epiphany_promotion::EpiphanyStateReplacementValidationInput;
use crate::epiphany_promotion::validate_state_replacement_patch;
use crate::epiphany_retrieval;
use crate::epiphany_retrieval::EpiphanyRetrieveQuery;
use crate::epiphany_retrieval::EpiphanyRetrieveResponse;
@ -629,9 +631,41 @@ fn epiphany_state_update_validation_errors(
}
}
errors.extend(epiphany_state_replacement_validation_errors(state, update));
errors
}
fn epiphany_state_replacement_validation_errors(
state: &EpiphanyThreadState,
update: &EpiphanyStateUpdate,
) -> Vec<String> {
let validates_subgoal_target = update.subgoals.is_some() || update.active_subgoal_id.is_some();
let validates_graph_target = update.graphs.is_some()
|| update.graph_frontier.is_some()
|| update.graph_checkpoint.is_some();
validate_state_replacement_patch(EpiphanyStateReplacementValidationInput {
active_subgoal_id: update.active_subgoal_id.as_deref(),
subgoals: if validates_subgoal_target {
update
.subgoals
.as_deref()
.or(Some(state.subgoals.as_slice()))
} else {
None
},
invariants: update.invariants.as_deref(),
graphs: if validates_graph_target {
update.graphs.as_ref().or(Some(&state.graphs))
} else {
None
},
graph_frontier: update.graph_frontier.as_ref(),
graph_checkpoint: update.graph_checkpoint.as_ref(),
churn: update.churn.as_ref(),
})
}
fn nonempty_id(id: &str) -> Option<&str> {
if id.is_empty() { None } else { Some(id) }
}
@ -695,6 +729,8 @@ fn prepend_recent<T>(items: &mut Vec<T>, mut new_items: Vec<T>) {
#[cfg(test)]
mod epiphany_update_tests {
use super::*;
use codex_protocol::protocol::EpiphanyGraph;
use codex_protocol::protocol::EpiphanyGraphNode;
fn evidence(id: &str) -> EpiphanyEvidenceRecord {
EpiphanyEvidenceRecord {
@ -859,6 +895,40 @@ mod epiphany_update_tests {
.any(|error| error.contains("patch.observations.id must not be empty"))
);
}
#[test]
fn validate_epiphany_state_update_rejects_structurally_invalid_replacements() {
let state = EpiphanyThreadState {
graphs: 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()
},
..Default::default()
};
let update = EpiphanyStateUpdate {
graph_frontier: Some(EpiphanyGraphFrontier {
active_node_ids: vec!["missing".to_string()],
..Default::default()
}),
..Default::default()
};
let errors = epiphany_state_update_validation_errors(&state, &update);
assert!(
errors
.iter()
.any(|error| error.contains("graph frontier references missing node"))
);
}
}
fn pending_message_input_item(message: &ResponseItem) -> CodexResult<ResponseInputItem> {

View file

@ -1,3 +1,5 @@
pub use epiphany_core::EpiphanyPromotionDecision;
pub use epiphany_core::EpiphanyPromotionInput;
pub use epiphany_core::EpiphanyStateReplacementValidationInput;
pub use epiphany_core::evaluate_promotion;
pub use epiphany_core::validate_state_replacement_patch;