From 57b0d2ec4562017513f03d38698d93a17f87f43e Mon Sep 17 00:00:00 2001 From: MrScripty Date: Thu, 8 Oct 2026 15:43:49 +0000 Subject: [PATCH 1/3] feat(screenplay): edit saved block types with consumed-source review --- crates/core/src/contracts/README.md | 2 +- crates/core/src/contracts/script_document.rs | 30 +++- .../src/contracts/script_impact_review.rs | 3 + crates/server/src/README.md | 2 +- .../server/src/ai_generation_runtime_tests.rs | 1 + crates/server/src/ai_script_context.rs | 10 +- .../src/canonical_generation_service_tests.rs | 2 + .../src/child_plan_memory_service_tests.rs | 3 + .../src/manual_script_workflow_tests.rs | 1 + crates/server/src/prompt_format.rs | 14 +- .../src/script_block_create_impact_tests.rs | 8 + .../src/script_block_create_service_tests.rs | 74 ++++++++ .../server/src/script_block_create_tests.rs | 2 + crates/server/src/script_block_edit.rs | 40 ++++- crates/server/src/script_block_edit_tests.rs | 113 ++++++++++++ crates/server/src/script_context_scope.rs | 28 +++ crates/server/src/script_document_command.rs | 7 + .../server/src/script_generation_lineage.rs | 8 + .../src/script_generation_lineage_tests.rs | 63 +++++++ .../src/script_generation_target_tests.rs | 1 + crates/server/src/script_impact_prompt.rs | 20 +++ .../server/src/script_impact_prompt_tests.rs | 162 ++++++++++++++++++ crates/server/src/script_impact_review.rs | 4 + .../server/src/script_impact_review_tests.rs | 1 + crates/server/src/script_store_codec.rs | 2 +- docs/plans/agent-story-workflows/plan.md | 13 ++ .../screenplay-block-type-memory-audit.md | 11 ++ ui/src/lib/components/editor/README.md | 2 + .../editor/ScriptBlockEditor.svelte | 21 ++- .../editor/ScriptBlockTypeEvidence.svelte | 47 +++++ .../editor/ScriptImpactReview.svelte | 5 + .../editor/scriptBlockEditDraft.svelte.ts | 15 +- .../editor/scriptBlockTypeEdit.client.test.ts | 20 +++ .../editor/scriptBlockTypeEdit.svelte.test.ts | 136 +++++++++++++++ ui/src/lib/propagationProposalTypes.ts | 1 + ui/src/lib/scriptTypes.ts | 2 + 36 files changed, 856 insertions(+), 18 deletions(-) create mode 100644 docs/plans/agent-story-workflows/screenplay-block-type-memory-audit.md create mode 100644 ui/src/lib/components/editor/ScriptBlockTypeEvidence.svelte create mode 100644 ui/src/lib/components/editor/scriptBlockTypeEdit.client.test.ts create mode 100644 ui/src/lib/components/editor/scriptBlockTypeEdit.svelte.test.ts diff --git a/crates/core/src/contracts/README.md b/crates/core/src/contracts/README.md index 947bd8a4..fa101aed 100644 --- a/crates/core/src/contracts/README.md +++ b/crates/core/src/contracts/README.md @@ -15,7 +15,7 @@ This directory defines host-agnostic command, event, revision, and projection co | `bible_render_graph.rs` | Disposable Bevy-facing story-bible graph projection DTOs, deterministic layout helpers, and neighborhood indexes derived from canonical graph rows. | | `context_influence.rs` | Timeline context stack with optional exact saved screenplay evidence and recorded evaluation/influence contracts. | | `graph_proposal.rs` | Generic reviewable graph proposal contracts for agent-proposed bible nodes, fields, edges, and timeline-context links. | -| `script_document.rs` | Canonical script document, segment, block, span, lock, patch, and script projection contracts. | +| `script_document.rs` | Canonical script document, segment, block, span, lock, patch, projection and guarded manual edit contracts, including optional authored type and owned consumed-type receipts. | | `script_impact_review.rs` | Explicit targeted preview request, bounded author-selected recall identities and captured screenplay/world-context evidence for revision-bound propagation proposals. | | `semantic_dependency.rs` | Typed semantic relationships with optional source/target revision bindings for generation lineage. | | `timeline_render.rs` | Timeline renderer projections, including core-derived gaps filtered by the renderer's minimum duration. | diff --git a/crates/core/src/contracts/script_document.rs b/crates/core/src/contracts/script_document.rs index 326849d6..33161f9a 100644 --- a/crates/core/src/contracts/script_document.rs +++ b/crates/core/src/contracts/script_document.rs @@ -197,7 +197,7 @@ pub struct CreateScriptBlockCommand { pub text: String, } -/// A manual edit preserves the canonical block's kind, placement and ownership. +/// A manual edit preserves placement and ownership; an omitted kind preserves type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct EditScriptBlockCommand { @@ -205,6 +205,31 @@ pub struct EditScriptBlockCommand { pub block_id: ScriptBlockId, pub expected_revision_event_id: super::ChangeEventId, pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub block_kind: Option, +} + +#[cfg(test)] +mod manual_type_wire_tests { + #[test] + fn omitted_type_keeps_legacy_wire_signature_and_invalid_types_are_rejected() { + let value = serde_json::json!({"document_id":"main","block_id":"A","expected_revision_event_id":uuid::Uuid::nil(),"text":"Exact"}); + let command: super::EditScriptBlockCommand = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(command.block_kind, None); + assert_eq!(serde_json::to_value(command).unwrap(), value); + let mut invalid = value; + invalid["block_kind"] = serde_json::json!("invented"); + assert!(serde_json::from_value::(invalid).is_err()); + } + #[test] + fn authored_type_can_use_the_existing_guarded_edit_operation() { + let value = serde_json::json!({ + "document_id": "script.document.main", "block_id": "block.A", + "expected_revision_event_id": uuid::Uuid::nil(), + "text": " Exact authored line — 雨.\n\n ", "block_kind": "dialogue" + }); + assert!(serde_json::from_value::(value).is_ok()); + } } /// Remove one exact saved block without changing its timeline placement. @@ -227,6 +252,9 @@ pub struct ScriptContextBlock { pub start_ms: u64, pub end_ms: u64, pub text: String, + /// Supplied type backed by this exact block revision; legacy absence is unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub block_kind: Option, } /// Complete continuity-window selection captured with the screenplay inputs. diff --git a/crates/core/src/contracts/script_impact_review.rs b/crates/core/src/contracts/script_impact_review.rs index f5e07a70..4fd20e49 100644 --- a/crates/core/src/contracts/script_impact_review.rs +++ b/crates/core/src/contracts/script_impact_review.rs @@ -83,6 +83,9 @@ pub struct ScriptImpactProposalBinding { pub cause: ScriptImpactCause, pub target_segment_revision_event_id: ChangeEventId, pub script_inputs: Vec, + /// Original consumed context from existing generation/accepted-preview receipts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script_previous_inputs: Option>, pub bible_context: ProjectionEnvelope, #[serde(default)] pub bible_inputs: Vec, diff --git a/crates/server/src/README.md b/crates/server/src/README.md index f9a38d08..105ef3a4 100644 --- a/crates/server/src/README.md +++ b/crates/server/src/README.md @@ -46,7 +46,7 @@ domain model in `eidetic-core`. | `script_block_create_tests.rs` | Exact persistence/context, locks, replay, refusal, writer rollback and retiming SQLite regressions. | | `script_block_create_impact_tests.rs` | Consumed-source append review/explicit acceptance, refreshed new-block lineage, later-append refusal, replay, rollback and historical retiming regressions; reconciled edits preserve newer text/review; explicitly continued drafts still reject later revisions and update canonical memory only on save. | | `script_block_create_service_tests.rs` | Native AppState save/reopen/preview/retime and queued project replacement regressions; empty canonical schema is initialized before queuing because refusal precedes database access. | -| `script_block_edit.rs` | Manual text-only block edits with expected-revision and transaction-local lock validation, preserving server-owned placement and metadata. | +| `script_block_edit.rs` | Manual text and optional type edits with expected-revision and transaction-local lock validation; type-only writes preserve spans/provenance and placement. | | `script_block_edit_tests.rs` | Save/reopen/history/context, stale/ABA refusal, lock refusal including locks added after a comparison read, and bounded continuity source regressions. | | `manual_script_workflow_tests.rs` | Native AppState preview and shared generation-admission regression with a deliberately stale project mirror and no provider call. | | `ai_temporal_context.rs` | Deterministic per-field fictional-time resolution before prompt construction; excludes future assertions and rejects same-time conflicts. | diff --git a/crates/server/src/ai_generation_runtime_tests.rs b/crates/server/src/ai_generation_runtime_tests.rs index 4b009ecd..b0436dd4 100644 --- a/crates/server/src/ai_generation_runtime_tests.rs +++ b/crates/server/src/ai_generation_runtime_tests.rs @@ -846,6 +846,7 @@ async fn successful_persistence_keeps_captured_input_lineage_after_an_intervenin crate::script_block_edit::apply_edit_script_block( &mut conn, &CommandEnvelope::new(eidetic_core::contracts::EditScriptBlockCommand { + block_kind: None, document_id: captured.document_id.clone(), block_id: captured.block_id.clone(), expected_revision_event_id: captured.revision_event_id, diff --git a/crates/server/src/ai_script_context.rs b/crates/server/src/ai_script_context.rs index b1d19439..dfd0d20e 100644 --- a/crates/server/src/ai_script_context.rs +++ b/crates/server/src/ai_script_context.rs @@ -29,7 +29,10 @@ pub(crate) fn load_script_context( ORDER BY start_ms, sort_order, id LIMIT 2) ) SELECT s.document_id, s.id, b.id, s.source_node_id, b.updated_event_id, - s.updated_event_id, s.start_ms, s.end_ms, b.text + s.updated_event_id, s.start_ms, s.end_ms, b.text, + (SELECT f.new_text FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id + WHERE r.object_kind='script_block' AND r.object_id=b.id AND r.change_event_id=b.updated_event_id + AND f.field_key='block_kind' AND f.new_type='text' AND f.new_text=b.block_kind LIMIT 1) FROM script_segments s JOIN script_blocks b ON b.segment_id = s.id JOIN script_documents d ON d.id = s.document_id WHERE s.id IN (SELECT id FROM selected) AND b.deleted_event_id IS NULL AND d.deleted_event_id IS NULL @@ -56,6 +59,7 @@ pub(crate) fn load_script_context( row.get::<_, u64>(6)?, row.get::<_, u64>(7)?, row.get::<_, String>(8)?, + row.get::<_, Option>(9)?, )) }, )?; @@ -70,6 +74,7 @@ pub(crate) fn load_script_context( start_ms, end_ms, text, + block_kind, ) = row?; Ok(ScriptContextBlock { document_id: ScriptDocumentId::new(document) @@ -90,6 +95,9 @@ pub(crate) fn load_script_context( start_ms, end_ms, text, + block_kind: block_kind + .map(|kind| crate::script_store_codec::decode_block_kind(&kind)) + .transpose()?, }) }) .collect() diff --git a/crates/server/src/canonical_generation_service_tests.rs b/crates/server/src/canonical_generation_service_tests.rs index 012d5823..6bfae1da 100644 --- a/crates/server/src/canonical_generation_service_tests.rs +++ b/crates/server/src/canonical_generation_service_tests.rs @@ -529,6 +529,7 @@ async fn delayed_regeneration_refuses_a_saved_human_edit_and_canonical_preview_s command_service::edit_script_block( &f.state, CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: doc.document.id, block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), @@ -601,6 +602,7 @@ async fn autosave_interleaving_after_public_edit_signature_read_preserves_manual .unwrap(); let manual = "Exact human edit across an autosave commit.\n\n"; let command = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: doc.document.id, block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), diff --git a/crates/server/src/child_plan_memory_service_tests.rs b/crates/server/src/child_plan_memory_service_tests.rs index 4b4f4368..01f92934 100644 --- a/crates/server/src/child_plan_memory_service_tests.rs +++ b/crates/server/src/child_plan_memory_service_tests.rs @@ -67,6 +67,7 @@ async fn fixture() -> Fixture { crate::command_service::edit_script_block( &state, CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: ScriptDocumentId::new("script.document.main").unwrap(), block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), @@ -126,6 +127,7 @@ async fn edit(fixture: &Fixture, text: &str) { crate::command_service::edit_script_block( &fixture.state, CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: ScriptDocumentId::new("script.document.main").unwrap(), block_id: fixture.block.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), @@ -570,6 +572,7 @@ async fn unselected_distant_screenplay_text_edit_does_not_stale_the_pending_plan crate::command_service::edit_script_block( &fixture.state, CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: ScriptDocumentId::new("script.document.main").unwrap(), block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), diff --git a/crates/server/src/manual_script_workflow_tests.rs b/crates/server/src/manual_script_workflow_tests.rs index be505a00..8f85e1da 100644 --- a/crates/server/src/manual_script_workflow_tests.rs +++ b/crates/server/src/manual_script_workflow_tests.rs @@ -59,6 +59,7 @@ async fn manual_edit_reaches_preview_and_generation_admission_with_a_stale_proje let saved = crate::command_service::edit_script_block( &state, CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: seed.payload.document_id, block_id, expected_revision_event_id: seeded.payload.segments[0].blocks[0] diff --git a/crates/server/src/prompt_format.rs b/crates/server/src/prompt_format.rs index 4905e139..b2404f50 100644 --- a/crates/server/src/prompt_format.rs +++ b/crates/server/src/prompt_format.rs @@ -30,11 +30,21 @@ pub(crate) fn append_script_context( "CANONICAL SCREENPLAY CONTEXT (authored text; world interpretations require review):\n", ); for block in blocks { + let kind = block + .block_kind + .as_ref() + .map(|kind| { + format!( + " block_type={}", + crate::script_store_codec::encode_block_kind(kind) + ) + }) + .unwrap_or_default(); user.push_str(&format!( - "--- document={} segment={} block={} block_revision={} segment_revision={} presentation={}..{}ms ---\n{}\n\n", + "--- document={} segment={} block={} block_revision={} segment_revision={} presentation={}..{}ms{} ---\n{}\n\n", block.document_id.as_str(), block.segment_id.as_str(), block.block_id.as_str(), block.revision_event_id.0, block.segment_revision_event_id.0, - block.start_ms, block.end_ms, block.text, + block.start_ms, block.end_ms, kind, block.text, )); } } diff --git a/crates/server/src/script_block_create_impact_tests.rs b/crates/server/src/script_block_create_impact_tests.rs index 5c117cc2..fd707154 100644 --- a/crates/server/src/script_block_create_impact_tests.rs +++ b/crates/server/src/script_block_create_impact_tests.rs @@ -204,6 +204,7 @@ fn consumed_source_append_requires_review_and_explicit_update_refreshes_all_memb .needs_review ); let edit = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: a.document_id, block_id: source.blocks[1].block.id.clone(), expected_revision_event_id: source.blocks[1].revision_event_id.unwrap(), @@ -392,6 +393,7 @@ fn reconciled_manual_edit_preserves_later_exact_text_and_consumed_source_review( .unwrap()[0] .clone(); let original = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: a.document_id.clone(), block_id: first.block_id.clone(), expected_revision_event_id: first.revision_event_id, @@ -422,6 +424,7 @@ fn reconciled_manual_edit_preserves_later_exact_text_and_consumed_source_review( .find(|block| block.block.id == first.block_id) .unwrap(); let newer = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, expected_revision_event_id: current.revision_event_id.unwrap(), text: " Later canonical author text — 雪\n\n ".into(), ..original.payload.clone() @@ -483,12 +486,14 @@ fn retained_draft_from_an_explicit_read_still_refuses_later_edits_then_updates_c .clone(); let draft_text = " Exact retained draft — 雨\n\n "; let stale = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: a.document_id.clone(), block_id: input.block_id.clone(), expected_revision_event_id: input.revision_event_id, text: draft_text.into(), }); let writer = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, text: "Another author's current text".into(), ..stale.payload.clone() }); @@ -507,10 +512,12 @@ fn retained_draft_from_an_explicit_read_still_refuses_later_edits_then_updates_c .unwrap(); assert_eq!(current.block.text, writer.payload.text); let continued = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, expected_revision_event_id: current.revision_event_id.unwrap(), ..stale.payload.clone() }); let later = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, text: "Same text, later revision".into(), ..continued.payload.clone() }); @@ -535,6 +542,7 @@ fn retained_draft_from_an_explicit_read_still_refuses_later_edits_then_updates_c .find(|block| block.block.id == input.block_id) .unwrap(); let confirmed = CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, expected_revision_event_id: reread.revision_event_id.unwrap(), ..stale.payload.clone() }); diff --git a/crates/server/src/script_block_create_service_tests.rs b/crates/server/src/script_block_create_service_tests.rs index 0ca818f6..0aa52cda 100644 --- a/crates/server/src/script_block_create_service_tests.rs +++ b/crates/server/src/script_block_create_service_tests.rs @@ -2,6 +2,79 @@ use crate::state::{AppState, ServerEvent}; use crate::{command_service, project_service, projection_service, script_store, sqlite}; use eidetic_core::contracts::*; +#[tokio::test] +async fn public_type_only_edit_publishes_exact_canonical_prompt_memory_and_replays_without_events() +{ + let (project, first, second) = crate::script_block_create::tests::story_project(); + let directory = crate::persistence::default_project_dir() + .join(format!("manual-type-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("project.db"); + crate::persistence::save_project(&project, &path, None) + .await + .unwrap(); + let state = AppState::new().await; + project_service::replace_active_project(&state, project, path.clone()); + let exact = " Exact authored line — 雨.\n\n "; + let created = command_service::create_script_block( + &state, + CommandEnvelope::new(CreateScriptBlockCommand { + document_id: ScriptDocumentId::new("script.document.main").unwrap(), + source_node_id: first, + expected_start_ms: 1000, + expected_end_ms: 2000, + block_kind: ScriptBlockKind::Action, + text: exact.into(), + }), + ) + .await + .unwrap(); + let block = &created.projection.payload.segments[0].blocks[0]; + let edit = CommandEnvelope::new(EditScriptBlockCommand { + document_id: created.projection.payload.document.id.clone(), + block_id: block.block.id.clone(), + expected_revision_event_id: block.revision_event_id.unwrap(), + text: exact.into(), + block_kind: Some(ScriptBlockKind::Dialogue), + }); + let mut events = state.events_tx.subscribe(); + let changed = command_service::edit_script_block(&state, edit.clone()) + .await + .unwrap(); + assert_eq!( + changed.projection.payload.segments[0].blocks[0].block.text, + exact + ); + assert_eq!( + changed.projection.payload.segments[0].blocks[0] + .block + .block_kind, + ScriptBlockKind::Dialogue + ); + assert_eq!( + changed.projection.payload.segments[0].blocks[0].spans, + block.spans + ); + assert!(matches!( + events.try_recv().unwrap(), + ServerEvent::ScriptChanged + )); + let preview = crate::ai_service::preview_ai_context(&state, second.0) + .await + .unwrap(); + assert!(preview.user.contains("block_type=dialogue")); + assert!(preview.user.contains(exact)); + assert_eq!( + command_service::edit_script_block(&state, edit) + .await + .unwrap() + .outcome, + crate::history_store::RecordChangeOutcome::AlreadyRecorded + ); + assert!(events.try_recv().is_err()); + std::fs::remove_dir_all(directory).unwrap(); +} + #[tokio::test] async fn native_manual_creation_edit_save_reopen_and_retime_reach_exact_prompt_memory() { let (project, first, second) = crate::script_block_create::tests::story_project(); @@ -43,6 +116,7 @@ async fn native_manual_creation_edit_save_reopen_and_retime_reach_exact_prompt_m command_service::edit_script_block( &state, CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: command.payload.document_id.clone(), block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), diff --git a/crates/server/src/script_block_create_tests.rs b/crates/server/src/script_block_create_tests.rs index 484bbc59..110757d0 100644 --- a/crates/server/src/script_block_create_tests.rs +++ b/crates/server/src/script_block_create_tests.rs @@ -122,6 +122,7 @@ fn first_manual_block_persists_exact_text_authorship_and_neighbor_memory_on_reop script_block_edit::apply_edit_script_block( &mut reopened, &CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: payload.document_id, block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), @@ -276,6 +277,7 @@ fn replay_after_edit_and_append_preserves_latest_text_and_rejects_payload_reuse( crate::script_block_edit::apply_edit_script_block( &mut conn, &CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: payload.document_id.clone(), block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), diff --git a/crates/server/src/script_block_edit.rs b/crates/server/src/script_block_edit.rs index a0516a94..e4db375f 100644 --- a/crates/server/src/script_block_edit.rs +++ b/crates/server/src/script_block_edit.rs @@ -52,6 +52,11 @@ pub(crate) fn apply_edit_script_block( } let mut block = existing.block.clone(); block.text = command.payload.text.clone(); + if let Some(kind) = &command.payload.block_kind { + block.block_kind = kind.clone(); + } + let type_changed = block.block_kind != existing.block.block_kind; + let text_changed = block.text != existing.block.text; let span = script_document_command::generated_span_for_block( &block, ScriptSpanProvenance::UserEdited, @@ -62,14 +67,23 @@ pub(crate) fn apply_edit_script_block( format!("edit script block {}", block.id.as_str()), ) .with_created_at_ms(created_at_ms); - let revisions = vec![ - script_document_command::block_revision( - &block, - Some(FieldValue::Text(existing.block.text.clone())), - event.id, - ), - script_document_command::span_revision(&span, event.id), - ]; + let mut revision = script_document_command::block_revision( + &block, + Some(FieldValue::Text(existing.block.text.clone())), + event.id, + ); + revision + .fields + .iter_mut() + .find(|field| field.field_key == "block_kind") + .expect("block snapshot owns type") + .old_value = Some(FieldValue::Text( + crate::script_store_codec::encode_block_kind(&existing.block.block_kind).into(), + )); + let mut revisions = vec![revision]; + if text_changed { + revisions.push(script_document_command::span_revision(&span, event.id)); + } let outcome = history_store::record_change_with( conn, command, @@ -95,6 +109,11 @@ pub(crate) fn apply_edit_script_block( "script block changed; reload before saving".into(), )); } + if type_changed && !current_block.locks.is_empty() { + return Err(HistoryStoreError::InvalidValue( + "cannot change the type of a locked script block".into(), + )); + } // Reuse the existing UTF-8 locked-span validation with server-owned metadata. let payload = SetScriptBlockCommand { document_id: current.document.id.clone(), @@ -115,7 +134,10 @@ pub(crate) fn apply_edit_script_block( script_document_command::validate_locked_spans(Some(¤t), &payload) .map_err(|error| HistoryStoreError::InvalidValue(error.to_string()))?; script_store::upsert_block_in_transaction(tx, &block, event.id)?; - script_store::upsert_span_in_transaction(tx, &span, event.id) + if text_changed { + script_store::upsert_span_in_transaction(tx, &span, event.id)?; + } + Ok(()) }, )?; Ok((outcome, projection(conn, command)?)) diff --git a/crates/server/src/script_block_edit_tests.rs b/crates/server/src/script_block_edit_tests.rs index 028c1507..bf9a92cc 100644 --- a/crates/server/src/script_block_edit_tests.rs +++ b/crates/server/src/script_block_edit_tests.rs @@ -40,6 +40,7 @@ fn edit( .find(|candidate| candidate.block.id == *block) .unwrap(); CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id, block_id: block.clone(), expected_revision_event_id: current.revision_event_id.unwrap(), @@ -52,6 +53,118 @@ fn commands(conn: &Connection) -> i64 { .unwrap() } +#[test] +fn type_only_edit_preserves_exact_text_spans_and_placement_with_owned_old_new_history() { + let mut conn = Connection::open_in_memory().unwrap(); + let node = NodeId::new(); + let exact = " Exact authored line — 雨.\n\n "; + let block = seed(&mut conn, node, 0, exact); + let mut command = edit(&conn, &block, exact); + let before = projection(&conn, &command).unwrap(); + command.payload.block_kind = Some(ScriptBlockKind::Dialogue); + let (_, after) = apply_edit_script_block(&mut conn, &command, 20).unwrap(); + let old = &before.payload.segments[0]; + let new = &after.payload.segments[0]; + assert_eq!(new.segment, old.segment); + assert_eq!(new.blocks[0].block.text, exact); + assert_eq!( + new.blocks[0].block.sort_order, + old.blocks[0].block.sort_order + ); + assert_eq!(new.blocks[0].spans, old.blocks[0].spans); + assert_eq!(new.blocks[0].locks, old.blocks[0].locks); + assert_eq!(new.blocks[0].block.block_kind, ScriptBlockKind::Dialogue); + let delta: (String,String) = conn.query_row("SELECT f.old_text,f.new_text FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id JOIN change_events e ON e.id=r.change_event_id WHERE e.command_id=?1 AND f.field_key='block_kind'", [command.id.0.to_string()], |row| Ok((row.get(0)?,row.get(1)?))).unwrap(); + assert_eq!(delta, ("action".into(), "dialogue".into())); + let captured = crate::ai_script_context::load_script_context(&conn, node, 0, 1000).unwrap(); + assert_eq!(captured[0].block_kind, Some(ScriptBlockKind::Dialogue)); + let mut prompt = String::new(); + crate::prompt_format::append_script_context(&mut prompt, &captured); + assert!(prompt.contains("block_type=dialogue")); + assert!(prompt.contains(exact)); + assert_eq!( + apply_edit_script_block(&mut conn, &command, 30).unwrap().0, + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(projection(&conn, &command).unwrap(), after); +} + +#[test] +fn type_change_aba_and_a_late_lock_refuse_without_partial_history_or_span_changes() { + let mut conn = Connection::open_in_memory().unwrap(); + let block = seed(&mut conn, NodeId::new(), 0, "Exact protected text"); + let mut stale = edit(&conn, &block, "Exact protected text"); + stale.payload.block_kind = Some(ScriptBlockKind::Character); + let mut first = edit(&conn, &block, "Exact protected text"); + first.payload.block_kind = Some(ScriptBlockKind::Dialogue); + apply_edit_script_block(&mut conn, &first, 20).unwrap(); + let mut restored = edit(&conn, &block, "Exact protected text"); + restored.payload.block_kind = Some(ScriptBlockKind::Action); + apply_edit_script_block(&mut conn, &restored, 30).unwrap(); + let before = projection(&conn, &stale).unwrap(); + let count = commands(&conn); + assert!( + apply_edit_script_block(&mut conn, &stale, 40) + .unwrap_err() + .to_string() + .contains("changed") + ); + assert_eq!(projection(&conn, &stale).unwrap(), before); + assert_eq!(commands(&conn), count); + assert_eq!( + apply_edit_script_block(&mut conn, &first, 50).unwrap().0, + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(projection(&conn, &stale).unwrap(), before); + let mut fresh = edit(&conn, &block, "Exact protected text"); + fresh.payload.block_kind = Some(ScriptBlockKind::Dialogue); + script_document_command::apply_set_script_lock( + &mut conn, + &CommandEnvelope::new(SetScriptLockCommand { + lock_id: ScriptLockId::new("type.lock").unwrap(), + span_id: ScriptSpanId::new(format!("{}.span.main", block.as_str())).unwrap(), + reason: "Protected after type read".into(), + }), + 60, + ) + .unwrap(); + let before = projection(&conn, &fresh).unwrap(); + let count = commands(&conn); + assert!( + apply_edit_script_block(&mut conn, &fresh, 70) + .unwrap_err() + .to_string() + .contains("cannot change the type of a locked") + ); + assert_eq!(projection(&conn, &fresh).unwrap(), before); + assert_eq!(commands(&conn), count); +} + +#[test] +fn legacy_missing_type_history_stays_unknown_until_an_explicit_owned_write() { + let mut conn = Connection::open_in_memory().unwrap(); + let node = NodeId::new(); + let block = seed(&mut conn, node, 0, "Legacy exact text"); + conn.execute( + "DELETE FROM object_revision_fields WHERE field_key='block_kind'", + [], + ) + .unwrap(); + let unknown = crate::ai_script_context::load_script_context(&conn, node, 0, 1000).unwrap(); + assert_eq!(unknown[0].block_kind, None); + let mut prompt = String::new(); + crate::prompt_format::append_script_context(&mut prompt, &unknown); + assert!(!prompt.contains("block_type=")); + let mut command = edit(&conn, &block, "Legacy exact text"); + command.payload.block_kind = Some(ScriptBlockKind::Dialogue); + apply_edit_script_block(&mut conn, &command, 20).unwrap(); + assert_eq!( + crate::ai_script_context::load_script_context(&conn, node, 0, 1000).unwrap()[0].block_kind, + Some(ScriptBlockKind::Dialogue) + ); + assert_eq!(unknown[0].block_kind, None); +} + #[test] fn manual_edit_reopens_exact_text_and_reaches_canonical_prompt_context() { let path = diff --git a/crates/server/src/script_context_scope.rs b/crates/server/src/script_context_scope.rs index 246c712b..4c70b929 100644 --- a/crates/server/src/script_context_scope.rs +++ b/crates/server/src/script_context_scope.rs @@ -156,6 +156,34 @@ fn recorded( } } +/// The original screenplay evidence is independent of whether a complete +/// continuity-window receipt was captured. Missing legacy inputs remain unknown. +pub(crate) fn recorded_inputs( + conn: &Connection, + event: ChangeEventId, +) -> Result>, HistoryStoreError> { + let (kind, json): (String, String) = conn.query_row( + "SELECT c.payload_type,c.payload_json FROM commands c JOIN change_events e ON e.command_id=c.id WHERE e.id=?1", + [event.0.to_string()], |row| Ok((row.get(0)?,row.get(1)?)))?; + match kind.as_str() { + "script.generate_block" => { + Ok(serde_json::from_str::(&json)?.script_inputs) + } + "semantic.propagation_accept" => { + let command: AcceptPropagationProposalCommand = serde_json::from_str(&json)?; + let binding: String = conn.query_row( + "SELECT binding_json FROM script_impact_proposal_bindings WHERE proposal_id=?1", + [command.proposal_id.as_str()], + |row| row.get(0), + )?; + Ok(Some( + serde_json::from_str::(&binding)?.script_inputs, + )) + } + _ => Ok(None), + } +} + fn signature( scope: &ScriptContextScope, inputs: &[ScriptContextBlock], diff --git a/crates/server/src/script_document_command.rs b/crates/server/src/script_document_command.rs index b3f4655f..12249734 100644 --- a/crates/server/src/script_document_command.rs +++ b/crates/server/src/script_document_command.rs @@ -496,6 +496,13 @@ pub(crate) fn block_revision( old_text, Some(FieldValue::Text(block.text.clone())), )) + .with_field(FieldDelta::new( + "block_kind", + None, + Some(FieldValue::Text( + crate::script_store_codec::encode_block_kind(&block.block_kind).into(), + )), + )) } pub(crate) fn span_revision( diff --git a/crates/server/src/script_generation_lineage.rs b/crates/server/src/script_generation_lineage.rs index de681a85..24ffcbf7 100644 --- a/crates/server/src/script_generation_lineage.rs +++ b/crates/server/src/script_generation_lineage.rs @@ -293,6 +293,14 @@ pub(crate) fn record_in_transaction( "generation input does not match canonical revision history".into(), )); } + if let Some(kind) = &input.block_kind { + let valid: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id WHERE r.object_kind='script_block' AND r.object_id=?1 AND r.change_event_id=?2 AND f.field_key='block_kind' AND f.new_type='text' AND f.new_text=?3)", params![input.block_id.as_str(),input.revision_event_id.0.to_string(),crate::script_store_codec::encode_block_kind(kind)], |row| row.get(0))?; + if !valid { + return Err(HistoryStoreError::InvalidValue( + "generation input type does not match canonical revision history".into(), + )); + } + } } for input in command.arc_inputs.iter().flatten() { crate::story_arc_lineage::validate_history(tx, input)?; diff --git a/crates/server/src/script_generation_lineage_tests.rs b/crates/server/src/script_generation_lineage_tests.rs index 783e4c92..fd16c203 100644 --- a/crates/server/src/script_generation_lineage_tests.rs +++ b/crates/server/src/script_generation_lineage_tests.rs @@ -82,6 +82,7 @@ fn edit(conn: &mut Connection, block: &SetScriptBlockCommand, text: &str) { script_block_edit::apply_edit_script_block( conn, &CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: block.document_id.clone(), block_id: block.block_id.clone(), expected_revision_event_id: before.revision_event_id, @@ -116,6 +117,68 @@ fn counts(conn: &Connection) -> (i64, i64, i64, i64) { conn.query_row("SELECT (SELECT COUNT(*) FROM commands), (SELECT COUNT(*) FROM object_revisions), (SELECT COUNT(*) FROM script_generations), (SELECT COUNT(*) FROM semantic_dependencies)", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap() } +#[test] +fn type_only_source_change_reaches_precise_consumers_and_validates_actual_historical_type() { + let (mut conn, a, b, c) = fixture(); + let original = input(&conn, &a); + let mut forged = original.clone(); + forged.block_kind = Some(ScriptBlockKind::Dialogue); + let before = counts(&conn); + assert!( + script_document_command::apply_generated_script_block( + &mut conn, + &generation(&b, Some(vec![forged]), "Forged"), + 15 + ) + .is_err() + ); + assert_eq!(counts(&conn), before); + script_document_command::apply_generated_script_block( + &mut conn, + &generation(&b, Some(vec![original.clone()]), "Manual B preserved"), + 15, + ) + .unwrap(); + script_document_command::apply_generated_script_block( + &mut conn, + &generation(&c, Some(vec![]), "Unrelated C"), + 16, + ) + .unwrap(); + let old_b = input(&conn, &b); + let old_c = input(&conn, &c); + script_block_edit::apply_edit_script_block( + &mut conn, + &CommandEnvelope::new(EditScriptBlockCommand { + document_id: a.document_id.clone(), + block_id: a.block_id.clone(), + expected_revision_event_id: original.revision_event_id, + text: original.text.clone(), + block_kind: Some(ScriptBlockKind::Dialogue), + }), + 20, + ) + .unwrap(); + assert_eq!(input(&conn, &a).text, original.text); + assert_eq!(input(&conn, &a).block_kind, Some(ScriptBlockKind::Dialogue)); + assert!(impact(&conn, &b).needs_review); + assert!(!impact(&conn, &c).needs_review); + assert_eq!( + impact(&conn, &b).causes[0].consumed_revision_event_id, + original.revision_event_id + ); + assert_eq!(input(&conn, &b), old_b); + assert_eq!(input(&conn, &c), old_c); + // A late result retains the actual Action read after source becomes Dialogue. + script_document_command::apply_generated_script_block( + &mut conn, + &generation(&b, Some(vec![original]), "Late original type"), + 30, + ) + .unwrap(); + assert!(impact(&conn, &b).needs_review); +} + #[test] fn source_edit_marks_dependent_b_and_leaves_unrelated_c_and_authored_text_unchanged() { let (mut conn, a, b, c) = fixture(); diff --git a/crates/server/src/script_generation_target_tests.rs b/crates/server/src/script_generation_target_tests.rs index f402156d..c354bf1e 100644 --- a/crates/server/src/script_generation_target_tests.rs +++ b/crates/server/src/script_generation_target_tests.rs @@ -176,6 +176,7 @@ fn delayed_generation_refuses_intervening_human_output_even_after_text_aba() { crate::script_block_edit::apply_edit_script_block( &mut conn, &CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: first.payload.block.document_id.clone(), block_id: block.block.id.clone(), expected_revision_event_id: block.revision_event_id.unwrap(), diff --git a/crates/server/src/script_impact_prompt.rs b/crates/server/src/script_impact_prompt.rs index c2d5e328..7e5cb142 100644 --- a/crates/server/src/script_impact_prompt.rs +++ b/crates/server/src/script_impact_prompt.rs @@ -27,6 +27,26 @@ where user: String::new(), }; append_script_context(&mut prompt.user, &binding.script_inputs); + if let eidetic_core::contracts::SemanticDependencyEndpoint::ScriptBlock { block_id } = + &binding.cause.input + { + for previous in binding + .script_previous_inputs + .iter() + .flatten() + .filter(|input| input.block_id == *block_id) + { + if let Some(kind) = &previous.block_kind { + prompt.user.push_str(&format!( + "\nORIGINAL CONSUMED SCREENPLAY TYPE ({}, block_revision={}): {}\n{}\n", + previous.block_id.as_str(), + previous.revision_event_id.0, + crate::script_store_codec::encode_block_kind(kind), + previous.text + )); + } + } + } crate::timeline_title_lineage::append_prompt(&mut prompt.user, binding); if let Some(input) = &binding.timeline_notes_current { prompt.user.push_str(&format!( diff --git a/crates/server/src/script_impact_prompt_tests.rs b/crates/server/src/script_impact_prompt_tests.rs index 762b3b7e..994223c6 100644 --- a/crates/server/src/script_impact_prompt_tests.rs +++ b/crates/server/src/script_impact_prompt_tests.rs @@ -5,6 +5,168 @@ use crate::script_impact_review::{ }; use futures::stream; +#[tokio::test] +async fn type_review_binds_original_and_current_types_and_requires_fresh_explicit_acceptance() { + use eidetic_core::contracts::*; + let (mut conn, _, a, b, c) = fixture(); + let document = crate::script_store::load_document_projection(&conn, &a.document_id) + .unwrap() + .unwrap(); + let source = document + .segments + .iter() + .flat_map(|segment| &segment.blocks) + .find(|block| block.block.id == a.block_id) + .unwrap(); + let exact = source.block.text.clone(); + let change = CommandEnvelope::new(EditScriptBlockCommand { + document_id: a.document_id.clone(), + block_id: a.block_id.clone(), + expected_revision_event_id: source.revision_event_id.unwrap(), + text: exact.clone(), + block_kind: Some(ScriptBlockKind::Dialogue), + }); + crate::script_block_edit::apply_edit_script_block(&mut conn, &change, 25).unwrap(); + let command = request(&conn, &b); + let binding = capture(&conn, &command.payload).unwrap(); + assert_eq!( + binding.script_previous_inputs.as_ref().unwrap()[0].block_kind, + Some(ScriptBlockKind::Action) + ); + let output = preview_with_provider(&binding, |prompt| async move { + assert!(prompt.user.contains("block_type=dialogue")); + assert!( + prompt + .user + .contains("ORIGINAL CONSUMED SCREENPLAY TYPE (block.A") + ); + assert!(prompt.user.contains("): action\nOriginal A")); + assert!(prompt.user.contains(&exact)); + let response: GenerateStream = Box::pin(stream::iter(vec![Ok( + " Synthetic typed preview — 雨.\n\n ".into(), + )])); + Ok(response) + }) + .await + .unwrap(); + crate::script_impact_review::record_proposal( + &mut conn, + &command, + binding.clone(), + output.clone(), + 30, + ) + .unwrap(); + let original_b = crate::script_impact_review::tests::text(&conn, &b); + let original_c = crate::script_impact_review::tests::text(&conn, &c); + let current = binding + .script_inputs + .iter() + .find(|input| input.block_id == a.block_id) + .unwrap(); + crate::script_block_edit::apply_edit_script_block( + &mut conn, + &CommandEnvelope::new(EditScriptBlockCommand { + document_id: a.document_id.clone(), + block_id: a.block_id.clone(), + expected_revision_event_id: current.revision_event_id, + text: current.text.clone(), + block_kind: Some(ScriptBlockKind::Character), + }), + 40, + ) + .unwrap(); + let before: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |row| row.get(0)) + .unwrap(); + assert!(crate::script_impact_review::tests::accept(&mut conn, &command).is_err()); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM commands", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + before + ); + assert_eq!( + crate::script_impact_review::tests::text(&conn, &b), + original_b + ); + let fresh = request(&conn, &b); + let fresh_binding = capture(&conn, &fresh.payload).unwrap(); + assert_eq!( + fresh_binding + .script_inputs + .iter() + .find(|input| input.block_id == a.block_id) + .unwrap() + .block_kind, + Some(ScriptBlockKind::Character) + ); + crate::script_impact_review::record_proposal( + &mut conn, + &fresh, + fresh_binding, + output.clone(), + 50, + ) + .unwrap(); + assert_eq!( + crate::script_impact_review::tests::text(&conn, &b), + original_b + ); + crate::script_impact_review::tests::accept(&mut conn, &fresh).unwrap(); + assert_eq!(crate::script_impact_review::tests::text(&conn, &b), output); + assert_eq!( + crate::script_impact_review::tests::text(&conn, &c), + original_c + ); + let current = crate::ai_script_context::load_script_context( + &conn, + eidetic_core::timeline::node::NodeId( + uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap(), + ), + a.segment_start_ms, + a.segment_end_ms, + ) + .unwrap() + .into_iter() + .find(|input| input.block_id == a.block_id) + .unwrap(); + crate::script_block_edit::apply_edit_script_block( + &mut conn, + &CommandEnvelope::new(EditScriptBlockCommand { + document_id: a.document_id.clone(), + block_id: a.block_id.clone(), + expected_revision_event_id: current.revision_event_id, + text: current.text.clone(), + block_kind: Some(ScriptBlockKind::Dialogue), + }), + 60, + ) + .unwrap(); + let third = capture(&conn, &request(&conn, &b).payload).unwrap(); + assert_eq!( + third + .script_previous_inputs + .as_ref() + .unwrap() + .iter() + .find(|input| input.block_id == a.block_id) + .unwrap() + .block_kind, + Some(ScriptBlockKind::Character) + ); + assert_eq!( + third + .script_inputs + .iter() + .find(|input| input.block_id == a.block_id) + .unwrap() + .block_kind, + Some(ScriptBlockKind::Dialogue) + ); + assert_eq!(crate::script_impact_review::tests::text(&conn, &b), output); +} + #[tokio::test] async fn deterministic_provider_receives_canonical_authored_evidence_and_resolved_context() { let (conn, _, _, b, _) = fixture(); diff --git a/crates/server/src/script_impact_review.rs b/crates/server/src/script_impact_review.rs index 1aaea3b4..af43a1bb 100644 --- a/crates/server/src/script_impact_review.rs +++ b/crates/server/src/script_impact_review.rs @@ -211,6 +211,10 @@ fn capture_in_snapshot( cause, target_segment_revision_event_id: segment_revision, script_inputs, + script_previous_inputs: crate::script_context_scope::recorded_inputs( + conn, + request.generation_event_id, + )?, bible_context, bible_inputs, bible_node_name_inputs, diff --git a/crates/server/src/script_impact_review_tests.rs b/crates/server/src/script_impact_review_tests.rs index f9866287..a352db21 100644 --- a/crates/server/src/script_impact_review_tests.rs +++ b/crates/server/src/script_impact_review_tests.rs @@ -88,6 +88,7 @@ pub(crate) fn edit(conn: &mut Connection, block: &SetScriptBlockCommand, text: & script_block_edit::apply_edit_script_block( conn, &CommandEnvelope::new(EditScriptBlockCommand { + block_kind: None, document_id: block.document_id.clone(), block_id: block.block_id.clone(), expected_revision_event_id: revision, diff --git a/crates/server/src/script_store_codec.rs b/crates/server/src/script_store_codec.rs index daa5c9d9..8de01c2a 100644 --- a/crates/server/src/script_store_codec.rs +++ b/crates/server/src/script_store_codec.rs @@ -141,7 +141,7 @@ fn decode_segment_status(value: &str) -> Result Result { +pub(crate) fn decode_block_kind(value: &str) -> Result { match value { "scene_heading" => Ok(ScriptBlockKind::SceneHeading), "action" => Ok(ScriptBlockKind::Action), diff --git a/docs/plans/agent-story-workflows/plan.md b/docs/plans/agent-story-workflows/plan.md index 3896f639..cbdc35a7 100644 --- a/docs/plans/agent-story-workflows/plan.md +++ b/docs/plans/agent-story-workflows/plan.md @@ -1168,3 +1168,16 @@ Focused checks cover baseline and owned clocks, Unicode, stale/ABA generation an acceptance, deleted-source absence ABA, legacy unknown history and untouched material. Independent source review and native title qualification remain pending; PR26/removal source and evidence are unchanged. + +### Saved screenplay block types (separate successor) + +Admitted from frozen title D20 on feat/screenplay-block-type-memory. Creation +already exposes type, but the guarded saved-block Edit accepts only text and +screenplay prompt receipts omit type. The pre-implementation wire regression +fails against D20. Extend that existing Edit with optional type, exact history, +draft/immutable retry/comparison ownership, owned type receipts and visible +original/current downstream evidence. Preserve text/spans on type-only saves; +late locks and stale/ABA edits refuse without writes. Existing preview and +explicit acceptance remain the downstream replacement authority. Legacy missing +type history stays unknown, without backfill. See screenplay-block-type-memory-audit.md; +independent review and source-bound native qualification remain required. diff --git a/docs/plans/agent-story-workflows/screenplay-block-type-memory-audit.md b/docs/plans/agent-story-workflows/screenplay-block-type-memory-audit.md new file mode 100644 index 00000000..38adf09a --- /dev/null +++ b/docs/plans/agent-story-workflows/screenplay-block-type-memory-audit.md @@ -0,0 +1,11 @@ +# Saved screenplay block type: bounded authoring successor + +Starts from frozen title product d20dcd4126f2317c155b87a2f77db83cac61e10a on separate feat/screenplay-block-type-memory. D20/F573 and the parent's title PR/artifact work remain unchanged. + +Pre-implementation evidence: ScriptBlockComposer allows a block type at creation, while ScriptBlockEditor exposes text only. EditScriptBlockCommand denies unknown fields and accepts only document/block IDs, expected block revision and text; changing saved type through this manual operation is impossible. SetScriptBlockCommand can supply a type, but exposes unrelated document/segment metadata and lacks the narrow manual edit's expected-block guard. Extend the existing manual edit; do not add a competing API. ScriptContextBlock and append_script_context currently carry exact text/revisions but omit the canonical type, so a supplied type cannot participate in review evidence. + +Admitted scope: optional block_kind on the existing Edit command (omitted preserves legacy signatures and canonical kind); type selection in the existing session-owned edit draft; exact old/new type history; type-only save preserves text, spans/provenance, locks, order and placement. Type changes refuse locked blocks. Unknown acknowledgements retry immutable text/type/payload/ID; comparison and explicit continuation retain both draft values. Existing block version, dependency edges, generation/proposal JSON, preview stale guards and explicit acceptance remain authoritative. + +New canonical writes record type in existing block revisions. Optional captured type beside existing screenplay source receipts is supplied in prompts only when that exact block revision has owned type history. Legacy missing type history remains unknown, without backfill. New receipt validation checks owned canonical type as well as text/identity; no parallel durable state or schema. Downstream review must show original/current type and exact text, preserve manual saved targets until explicit acceptance, and refresh ordinary lineage afterward. Type changes do not extract Bible facts or infer story time. + +Qualification: actual SQLite/public command type edit, history, replay, stale/ABA and late lock guards; real client draft/session/controller and rendering tests; strict locked/offline checks and existing ONNX no-download setup. Independent source review precedes ordinary development publication. Separate source-bound native QA must save an exact typed source while Bible/timeline/manual target/unrelated draft remain visible, inspect downstream type evidence, refuse an older preview after another type change, then explicitly accept a fresh selected target only. Responses are labelled synthetic; real-model quality remains unqualified. Parent owns PRs, pixel inspection when local transfer is blocked, and Library delivery. diff --git a/ui/src/lib/components/editor/README.md b/ui/src/lib/components/editor/README.md index 0df5140a..de51ef63 100644 --- a/ui/src/lib/components/editor/README.md +++ b/ui/src/lib/components/editor/README.md @@ -30,6 +30,8 @@ This directory contains the main beat and script viewing workflow, including AI | `ScriptBlockComposer.retry.ssr.test.ts` | Uncertain-save read-only controls, exact retry action and definite-refusal recovery rendering fixtures. | | `ScriptBlockEditor.svelte` | Session-owned per-block edit/save/cancel surface with exact uncertain retry and explicit reload. | | `scriptBlockEditDraft.svelte.ts` | Captured draft, immutable pending edit, read comparison and explicit continuation from that version. | +| `ScriptBlockTypeEvidence.svelte` | Original/current owned screenplay type and exact consumed text in targeted review. | +| `scriptBlockTypeEdit.svelte.test.ts`, `scriptBlockTypeEdit.client.test.ts` | Type-only editing, session/retry/comparison and real browser rune proxy fixtures. | | `scriptBlockEditComparison.svelte.test.ts` | Exact draft/current-text comparison, explicit version continuation, stale/ABA/read failures, navigation, immutable retry and retired-session fixtures. | | `scriptBlockEditLifetime.ssr.test.ts` | Fresh workspace consumers, independent Unicode drafts, delayed acknowledgement, exact command replay, refusal/reload and retired-session fixture regressions. | | `ScriptImpactNotice.svelte` | Read-only Needs review notice with changed/deleted input explanations and historical excerpts. | diff --git a/ui/src/lib/components/editor/ScriptBlockEditor.svelte b/ui/src/lib/components/editor/ScriptBlockEditor.svelte index 971d308f..be79f069 100644 --- a/ui/src/lib/components/editor/ScriptBlockEditor.svelte +++ b/ui/src/lib/components/editor/ScriptBlockEditor.svelte @@ -13,6 +13,23 @@ {#if draft.removal.active} {:else if draft.editing} + - {#if block.locks.length}

Protected text must remain unchanged.

{/if} + {#if block.locks.length}

Protected text and block type must remain unchanged.

{/if} {#if draft.error}

{draft.error} Your draft is still here.

{/if} {#if draft.uncertain}

The save may have completed. Retry to confirm it before changing this text. @@ -52,6 +69,7 @@ {#if draft.comparison}

Saved text +

Saved block type: {draft.comparison.kind.replaceAll('_', ' ')}

{draft.comparison.text}
{#if block.revision_event_id !== draft.comparison.revisionEventId}

Saved text changed again. Compare it before continuing.

@@ -69,6 +87,7 @@
{/if} {:else} +

Block type: {block.block.block_kind.replaceAll('_', ' ')}

+ import type { ScriptContextBlock } from '$lib/scriptTypes.js'; + let { + previous, + current, + }: { previous?: ScriptContextBlock[] | null; current: ScriptContextBlock[] } = $props(); + let changes = $derived( + (previous ?? []).filter( + (input) => + input.block_kind && + current.some( + (now) => + now.block_id === input.block_id && + now.block_kind && + now.block_kind !== input.block_kind, + ), + ), + ); + + +{#each changes as original (original.block_id)} + {@const now = current.find((input) => input.block_id === original.block_id)!} +
+

Consumed screenplay type changed.

+

Originally consumed type:

+
{original.block_kind?.replaceAll(
+        '_',
+        ' ',
+      )}
+
{original.text}
+

Current type:

+
{now.block_kind?.replaceAll('_', ' ')}
+
{now.text}
+
+{/each} + + diff --git a/ui/src/lib/components/editor/ScriptImpactReview.svelte b/ui/src/lib/components/editor/ScriptImpactReview.svelte index 9a0509a9..01473ea1 100644 --- a/ui/src/lib/components/editor/ScriptImpactReview.svelte +++ b/ui/src/lib/components/editor/ScriptImpactReview.svelte @@ -2,6 +2,7 @@ import ScriptArcEvidence from './ScriptArcEvidence.svelte'; import ScriptTimelineNotesEvidence from './ScriptTimelineNotesEvidence.svelte'; import ScriptTimelineTitleEvidence from './ScriptTimelineTitleEvidence.svelte'; + import ScriptBlockTypeEvidence from './ScriptBlockTypeEvidence.svelte'; import ScriptRecallFacts from './ScriptRecallFacts.svelte'; import type { ScriptSegmentProjection } from '$lib/scriptTypes.js'; import { @@ -137,6 +138,10 @@ current={proposal.script_review_binding?.timeline_title_current} absent={proposal.script_review_binding?.timeline_title_absence_revisions} /> + { + const output = execFileSync(process.execPath, ['--input-type=module'], { + encoding: 'utf8', + input: ` +import assert from 'node:assert/strict';import fs from 'node:fs';import ts from 'typescript';import {compileModule} from 'svelte/compiler'; +let source=fs.readFileSync('src/lib/components/editor/scriptBlockEditDraft.svelte.ts','utf8').replace("import { createCommandId } from '$lib/commandTransport.js';","const createCommandId = () => 'fixed-type-id';"); +const js=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ESNext,module:ts.ModuleKind.ESNext}}).outputText; +let compiled=compileModule(js,{filename:'type.svelte.js',generate:'client'}).js.code; +compiled=compiled.replace(/(['"])svelte\\/internal\\/client\\1/g,JSON.stringify(import.meta.resolve('svelte/internal/client'))); +const {createScriptBlockEditDraft}=await import('data:text/javascript;base64,'+Buffer.from(compiled).toString('base64')); +const calls=[];let fail=true;const draft=createScriptBlockEditDraft({documentId:'main',blockId:'A',readCurrent:async()=>null,reload:async()=>{},save:async(payload,id)=>{calls.push(structuredClone({payload,id}));if(fail){fail=false;throw new Error('Lost acknowledgement');}}}); +draft.begin({block:{id:'A',segment_id:'s',block_kind:'action',text:' Exact — 雨.\\n\\n ',sort_order:0},revision_event_id:'event.A',spans:[],locks:[]});draft.state.kind='dialogue';await draft.save();assert.equal(draft.state.uncertain,true); +draft.state.kind='note';draft.state.text='Later mutation';await draft.save();assert.deepEqual(calls[0],calls[1]);assert.equal(calls[1].payload.block_kind,'dialogue');assert.equal(draft.state.editing,false);console.log('Browser typed edit custody passed'); +`, + }); + expect(output).toContain('Browser typed edit custody passed'); +}); diff --git a/ui/src/lib/components/editor/scriptBlockTypeEdit.svelte.test.ts b/ui/src/lib/components/editor/scriptBlockTypeEdit.svelte.test.ts new file mode 100644 index 00000000..73046a32 --- /dev/null +++ b/ui/src/lib/components/editor/scriptBlockTypeEdit.svelte.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, afterEach, expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import ScriptBlockEditor from './ScriptBlockEditor.svelte'; +import ScriptBlockTypeEvidence from './ScriptBlockTypeEvidence.svelte'; +import { + getSessionScriptBlockEditDraft, + resetSessionScriptBlockEditDrafts, +} from '$lib/stores/scriptBlockEditSession.svelte.js'; +import type { ScriptBlockProjection, ScriptContextBlock } from '$lib/scriptTypes.js'; + +const block: ScriptBlockProjection = { + block: { + id: 'A', + segment_id: 'segment.A', + block_kind: 'action', + text: ' Exact line — 雨.\n\n ', + sort_order: 0, + }, + revision_event_id: 'event.A', + spans: [], + locks: [], +}; +const invoke = vi.fn(); +beforeEach(() => { + resetSessionScriptBlockEditDrafts(); + invoke.mockReset(); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); +}); +afterEach(() => vi.unstubAllGlobals()); + +it('retains typed intent in the real session owner and retries an immutable type-only save after a lost acknowledgement', async () => { + const draft = getSessionScriptBlockEditDraft('main', 'A'); + draft.begin(block); + draft.state.kind = 'dialogue'; + const body = render(ScriptBlockEditor, { props: { documentId: 'main', block } }).body; + expect(body).toContain('Block type'); + expect(body).toContain('value="dialogue" selected'); + invoke.mockRejectedValueOnce(new Error('lost acknowledgement')); + await draft.save(); + const submitted = structuredClone(invoke.mock.calls[0]); + expect(submitted?.[1].command.payload).toEqual({ + document_id: 'main', + block_id: 'A', + expected_revision_event_id: 'event.A', + text: block.block.text, + block_kind: 'dialogue', + }); + const uncertain = render(ScriptBlockEditor, { props: { documentId: 'main', block } }).body; + expect(uncertain).toMatch(/]*disabled/); + expect(uncertain).toContain('Retry same save'); + expect(getSessionScriptBlockEditDraft('main', 'A')).toBe(draft); + draft.state.kind = 'shot'; + draft.state.text = 'Mutable later draft'; + invoke.mockResolvedValueOnce({ + outcome: 'already_recorded', + projection: { + version: 2, + payload: { document: { id: 'main', title: 'Story', sort_order: 0 }, segments: [] }, + }, + }); + await draft.save(); + expect(invoke.mock.calls[1]).toEqual(submitted); +}); + +it('compares saved type without silently rebasing either draft value and explicitly continues only from the read version', async () => { + const draft = getSessionScriptBlockEditDraft('main', 'A'); + draft.begin(block); + draft.state.kind = 'dialogue'; + invoke.mockRejectedValueOnce({ + kind: 'conflict', + message: 'cannot change the type of a locked script block', + }); + await draft.save(); + expect(draft.state.uncertain).toBe(false); + expect(draft.state.kind).toBe('dialogue'); + const current: ScriptBlockProjection = { + ...block, + revision_event_id: 'event.B', + block: { ...block.block, block_kind: 'shot', text: 'New canonical text' }, + }; + invoke.mockResolvedValueOnce({ + version: 2, + payload: { + document: { id: 'main', title: 'Story', sort_order: 0 }, + segments: [{ segment: { id: 'segment.A' }, blocks: [current] }], + }, + }); + await draft.compare(); + expect(draft.state.baseRevision).toBe('event.A'); + expect(draft.state.kind).toBe('dialogue'); + expect(draft.state.text).toBe(block.block.text); + const body = render(ScriptBlockEditor, { props: { documentId: 'main', block: current } }).body; + expect(body).toContain('Saved block type: shot'); + draft.state.comparison!.kind = 'note'; + draft.useComparedRevision(current); + expect(draft.state.baseRevision).toBe('event.B'); + expect(draft.state.kind).toBe('dialogue'); + invoke.mockRejectedValueOnce({ + kind: 'conflict', + message: 'script block changed; reload before saving', + }); + await draft.save(); + expect(invoke.mock.calls[2]?.[1].command.payload.block_kind).toBe('dialogue'); + expect(invoke.mock.calls[2]?.[1].command.payload.expected_revision_event_id).toBe('event.B'); +}); + +it('renders original/current owned type evidence and exact text, while legacy unknown and unchanged types remain absent', () => { + const original: ScriptContextBlock = { + document_id: 'main', + segment_id: 's', + block_id: 'A', + source_node_id: null, + revision_event_id: 'event.A', + segment_revision_event_id: 'event.S', + start_ms: 0, + end_ms: 1, + text: block.block.text, + block_kind: 'action', + }; + const current = { ...original, revision_event_id: 'event.B', block_kind: 'dialogue' as const }; + const body = render(ScriptBlockTypeEvidence, { + props: { previous: [original], current: [current] }, + }).body; + expect(body).toContain('Originally consumed block type'); + expect(body).toContain('>action'); + expect(body).toContain('>dialogue'); + expect(body).toContain(' Exact line — 雨.\n\n '); + expect( + render(ScriptBlockTypeEvidence, { + props: { previous: [{ ...original, block_kind: null }], current: [current] }, + }).body, + ).not.toContain('Consumed screenplay type changed'); + expect( + render(ScriptBlockTypeEvidence, { props: { previous: [original], current: [original] } }).body, + ).not.toContain('Consumed screenplay type changed'); +}); diff --git a/ui/src/lib/propagationProposalTypes.ts b/ui/src/lib/propagationProposalTypes.ts index f321b505..a6a260c4 100644 --- a/ui/src/lib/propagationProposalTypes.ts +++ b/ui/src/lib/propagationProposalTypes.ts @@ -81,6 +81,7 @@ export interface PropagationProposal { cause: ScriptImpactCause; target_segment_revision_event_id: string; script_inputs: ScriptContextBlock[]; + script_previous_inputs?: ScriptContextBlock[] | null; bible_context: ProjectionEnvelope; bible_inputs?: BibleFieldInput[]; bible_node_name_inputs?: { node_id: string; name: string; revision_event_id: string }[] | null; diff --git a/ui/src/lib/scriptTypes.ts b/ui/src/lib/scriptTypes.ts index f68f2462..d8c1c56d 100644 --- a/ui/src/lib/scriptTypes.ts +++ b/ui/src/lib/scriptTypes.ts @@ -17,6 +17,7 @@ export interface ScriptContextBlock { start_ms: number; end_ms: number; text: string; + block_kind?: ScriptBlockKind | null; } export interface ScriptDocument { @@ -160,6 +161,7 @@ export interface EditScriptBlockCommand { block_id: ScriptBlockId; expected_revision_event_id: string; text: string; + block_kind?: ScriptBlockKind | null; } export interface ScriptDocumentCommandResponse { From 447b56308a7ce6e67b6a3d76fd2c8cfecdd9a900 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 14 Oct 2026 22:52:56 +0000 Subject: [PATCH 2/3] docs(authoring): record current composition repairs and acceptance limits --- .../authoring-integration-readiness.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/reports/authoring-integration-readiness.md b/docs/reports/authoring-integration-readiness.md index be2ba12b..d3dc74a7 100644 --- a/docs/reports/authoring-integration-readiness.md +++ b/docs/reports/authoring-integration-readiness.md @@ -70,3 +70,34 @@ integration, synchronized only by reviewed forward commits, and retired after parent merge/rejection with their exact heads reachable from retained remote refs. The isolated producer checkout exists only to test main's unchanged exact pin; the user's existing Pumas checkout remains untouched. + +## Stage B current composition and repaired regressions + +Integration `7e138823` retains both Stage A and product `57b0d2ec` as parents. +The plan conflict was additive: preserve Notes, Bible exact-text and missing-draft +scope alongside the saved-block type scope. No production conflict was discarded. + +Independent review found two reachable composition defects. A typed edit changed +from Action to Dialogue, followed by canonical block disappearance, copied the +original Action type. Missing-draft copy now preserves the actual draft type, +displays it, and still requires explicit ordinary creation Save while retaining +the original. Its rendered/controller regression failed with Action before repair. +The shared block revision now owns kind history; manual creation redundantly +added another `block_kind`, breaking the change-review UI's keyed field rendering. +Remove that redundant create-layer addition. The actual persisted review/replay +regression failed with two kind deltas before repair and now requires one owned +Dialogue delta, unique review keys and immutable replay history. + +Current local Stage B validation passes 129 core and 621 server all-target tests, +113 files / 641 UI tests, lint/format/typecheck (zero errors and warnings), static +build, and the inherited high/critical audit policy. The first unisolated rerun +hit 23 existing read-only-host-data failures; the normal isolated XDG rerun passes +the complete unchanged test population. No assertion or discovery filter changed. + +A current Chromium production-widget smoke was prepared with an explicitly +synthetic frontend transport fixture. Chromium aborted before page execution: +its installed SUID sandbox helper is not correctly configured. No sandbox was +disabled or launch-denial workaround used. There is no current browser capture or +native interaction acceptance from that attempt. Current compiled-client/SSR +regressions and public-service/SQLite tests are the actual local evidence; hosted +native workspace CI and parent disposition of interaction limits remain separate. From c9cc017f222d3ab30b86e2f3b7db7de6fa1d2e9d Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 14 Oct 2026 22:55:17 +0000 Subject: [PATCH 3/3] docs(authoring): clarify typed recovery and single history ownership --- crates/server/src/README.md | 8 ++++++++ ui/src/lib/components/editor/README.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/server/src/README.md b/crates/server/src/README.md index c9b498f3..45adbd41 100644 --- a/crates/server/src/README.md +++ b/crates/server/src/README.md @@ -710,3 +710,11 @@ command receipts preserve replay after later edits, deletion or ABA. those original receipts instead of rebuilding them from today's timeline. Existing generation/proposal dependency rows supply targeted review; saved screenplay is replaced only by ordinary explicit acceptance. + +## One owned block-type history delta + +The shared `script_document_command::block_revision` records the canonical +`block_kind` once. Manual creation adds its sparse order field without adding +another kind delta; Change Review receives unique field keys and replay retains +the original revision. Missing-draft copies preserve the authored draft type, +while explicit creation remains the canonical Save authority. diff --git a/ui/src/lib/components/editor/README.md b/ui/src/lib/components/editor/README.md index 163849e0..c2586614 100644 --- a/ui/src/lib/components/editor/README.md +++ b/ui/src/lib/components/editor/README.md @@ -272,7 +272,7 @@ Missing saved blocks do not hide their session-owned manual drafts or uncertain original saves. ScriptMissingDraft labels absence from the current view without asserting deletion after failed reads. It preserves original block/revision identity and offers existing read/retry guards. Explicit copy captures exact text -and original kind into the idle existing composer for the visibly selected clip; +and authored draft kind into the idle existing composer for the visibly selected clip; it neither restores the old block nor discards the source draft. A busy/uncertain source or active composer refuses copy. Cancel of the copy leaves the source intact. Only ordinary Save creates new canonical UserEdited material, using the