diff --git a/crates/core/src/contracts/README.md b/crates/core/src/contracts/README.md
index 24022479..de3c15f1 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 4079a6d2..00828a0d 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 6ce9ba1c..4198ca06 100644
--- a/crates/core/src/contracts/script_impact_review.rs
+++ b/crates/core/src/contracts/script_impact_review.rs
@@ -87,6 +87,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 b9a614ee..45adbd41 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. |
@@ -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/crates/server/src/ai_generation_runtime_tests.rs b/crates/server/src/ai_generation_runtime_tests.rs
index 3d7fb97f..4c40eb9d 100644
--- a/crates/server/src/ai_generation_runtime_tests.rs
+++ b/crates/server/src/ai_generation_runtime_tests.rs
@@ -847,6 +847,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 b575d9dc..edc39d14 100644
--- a/crates/server/src/canonical_generation_service_tests.rs
+++ b/crates/server/src/canonical_generation_service_tests.rs
@@ -531,6 +531,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(),
@@ -603,6 +604,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 d2390dd3..08c2cdbb 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(),
@@ -572,6 +574,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.rs b/crates/server/src/script_block_create.rs
index d4b069c1..1c97b5a6 100644
--- a/crates/server/src/script_block_create.rs
+++ b/crates/server/src/script_block_create.rs
@@ -8,7 +8,7 @@ use rusqlite::Connection;
use crate::history_store::{self, HistoryStoreError, RecordChangeOutcome};
use crate::script_document_command::{self, ScriptDocumentCommandError};
-use crate::{script_store, script_store_codec, timeline_node_store};
+use crate::{script_store, timeline_node_store};
const COMMAND_TYPE: &str = "script.create_block";
@@ -94,19 +94,13 @@ pub(crate) fn apply_create_script_block(
}),
)));
revisions.push(
- script_document_command::block_revision(&block, None, event.id)
- .with_field(FieldDelta::new(
- "block_kind",
- None,
- Some(FieldValue::Text(
- script_store_codec::encode_block_kind(&block.block_kind).into(),
- )),
- ))
- .with_field(FieldDelta::new(
+ script_document_command::block_revision(&block, None, event.id).with_field(
+ FieldDelta::new(
"sort_order",
None,
Some(FieldValue::Integer(i64::from(block.sort_order))),
- )),
+ ),
+ ),
);
let mut span_revision = script_document_command::span_revision(&span, event.id)
.with_field(FieldDelta::new(
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..6085d0d1 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(),
@@ -379,3 +381,50 @@ fn timeline_range_write_moves_new_authored_segment_without_changing_text_revisio
assert_eq!(memory[0].text, payload.text);
assert_eq!((memory[0].start_ms, memory[0].end_ms), (6000, 7000));
}
+
+#[test]
+fn manual_creation_review_has_one_owned_kind_delta_and_replay_preserves_history() {
+ let (mut conn, _, mut payload) = fixture();
+ payload.block_kind = ScriptBlockKind::Dialogue;
+ let command = CommandEnvelope::new(payload);
+ apply_create_script_block(&mut conn, &command, 10).unwrap();
+ let before = history_store::load_change_review_changes(&conn, 10).unwrap();
+ let revision = before[0]
+ .revisions
+ .iter()
+ .find(|revision| revision.object_kind == ObjectKind::ScriptBlock)
+ .unwrap();
+ let kinds = revision
+ .fields
+ .iter()
+ .filter(|field| field.field_key == "block_kind")
+ .collect::>();
+ assert_eq!(
+ kinds.len(),
+ 1,
+ "change review requires one key per field delta"
+ );
+ assert_eq!(
+ kinds[0].new_value,
+ Some(FieldValue::Text("dialogue".into()))
+ );
+ for revision in &before[0].revisions {
+ let keys = revision
+ .fields
+ .iter()
+ .map(|field| &field.field_key)
+ .collect::>();
+ assert_eq!(keys.len(), revision.fields.len());
+ }
+ assert_eq!(
+ apply_create_script_block(&mut conn, &command, 20)
+ .unwrap()
+ .0,
+ RecordChangeOutcome::AlreadyRecorded
+ );
+ assert_eq!(
+ serde_json::to_value(history_store::load_change_review_changes(&conn, 10).unwrap())
+ .unwrap(),
+ serde_json::to_value(before).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
Saved revision: {draft.baseRevision}
+ Draft block type: {draft.kind.replaceAll('_', ' ')}
diff --git a/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts b/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts
index a3826fd1..d0241c1a 100644
--- a/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts
+++ b/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts
@@ -8,6 +8,7 @@ import type {
interface SavedComparison {
text: string;
+ kind: ScriptBlockKind;
revisionEventId: string;
}
@@ -29,6 +30,7 @@ export function createScriptBlockEditDraft(options: {
},
editing: false,
text: '',
+ kind: 'action' as ScriptBlockKind,
baseRevision: null as string | null,
saving: false,
comparing: false,
@@ -39,6 +41,7 @@ export function createScriptBlockEditDraft(options: {
let originalKind: ScriptBlockKind = 'action';
let compared: SavedComparison | null = null;
let submitted: { payload: EditScriptBlockCommand; commandId: string } | null = null;
+ let baseKind: ScriptBlockKind = 'action';
let removalCapture: RemoveScriptBlockCommand | null = null;
let submittedRemoval: { payload: RemoveScriptBlockCommand; commandId: string } | null = null;
@@ -107,6 +110,8 @@ export function createScriptBlockEditDraft(options: {
return;
originalKind = block.block.block_kind;
state.text = block.block.text;
+ baseKind = block.block.block_kind;
+ state.kind = baseKind;
state.baseRevision = block.revision_event_id ?? null;
state.error = null;
compared = null;
@@ -131,6 +136,7 @@ export function createScriptBlockEditDraft(options: {
block_id: options.blockId,
expected_revision_event_id: state.baseRevision,
text: state.text,
+ ...(state.kind !== baseKind ? { block_kind: state.kind } : {}),
},
commandId: createCommandId(),
};
@@ -182,7 +188,11 @@ export function createScriptBlockEditDraft(options: {
if (!current?.revision_event_id || current.block.id !== options.blockId) {
throw new Error('Saved block is unavailable.');
}
- compared = { text: current.block.text, revisionEventId: current.revision_event_id };
+ compared = {
+ text: current.block.text,
+ kind: current.block.block_kind,
+ revisionEventId: current.revision_event_id,
+ };
// Display state cannot change the read snapshot used by explicit continuation.
state.comparison = { ...compared };
} catch (failure) {
@@ -203,6 +213,7 @@ export function createScriptBlockEditDraft(options: {
return;
}
state.baseRevision = compared.revisionEventId;
+ baseKind = compared.kind;
state.error = null;
compared = null;
state.comparison = null;
@@ -241,6 +252,7 @@ function isEditRefusal(failure: unknown): boolean {
'script document not found',
'invalid command: script block update would remove locked span text',
'invalid command: script block update would modify locked span text',
+ 'cannot change the type of a locked script block',
].includes(failure.message)
);
}
diff --git a/ui/src/lib/components/editor/scriptBlockTypeEdit.client.test.ts b/ui/src/lib/components/editor/scriptBlockTypeEdit.client.test.ts
new file mode 100644
index 00000000..685b02e8
--- /dev/null
+++ b/ui/src/lib/components/editor/scriptBlockTypeEdit.client.test.ts
@@ -0,0 +1,20 @@
+import { execFileSync } from 'node:child_process';
+import { expect, it } from 'vitest';
+
+it('executes real browser rune proxies with immutable text and type retries', () => {
+ 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(/