Skip to main content

quillmark_core/document/
edit.rs

1//! Typed mutators for [`Document`] and [`Card`] with invariant enforcement.
2//!
3//! Every successful mutator leaves the document with every user field name
4//! matching `[A-Za-z_][A-Za-z0-9_]*` and every composable `$kind` passing
5//! `meta::is_valid_kind_name`, so the result is safely serializable via
6//! [`Document::to_plate_json`]. Mutators never modify `warnings` — those
7//! are immutable parse-time observations.
8//!
9//! Payload/body mutators (field store/fill/remove, `$ext` and `$seed`
10//! namespace writers, body replacement) live on [`Card`]; [`Document`] keeps
11//! document-level ops (quill-ref, push/insert/remove/move card).
12//!
13//! The `$ext` mutators carry no field-name invariant ($ext is an opaque
14//! mapping that never reaches the plate JSON backends consume), but they do
15//! enforce the §8 value-depth bound: `$ext` flows through the recursive
16//! emit and DTO paths like any other value.
17
18use unicode_normalization::UnicodeNormalization;
19
20use quillmark_content::delta::diff_import;
21use quillmark_content::import::ImportError;
22use quillmark_content::{ApplyError, Delta, LineOp, MarkOp, Content};
23
24use crate::document::meta::{validate_composable_kind, CardKindError};
25use crate::document::{Card, Document, Payload};
26use crate::quill::{CoercionError, FieldSchema, Leniency, QuillConfig};
27use crate::value::QuillValue;
28use crate::version::QuillReference;
29
30/// `true` if `name` matches `[A-Za-z_][A-Za-z0-9_]*` after NFC normalisation.
31///
32/// Lowercase is the recommended (canonical) convention, but uppercase ASCII
33/// letters are accepted and preserved verbatim. Collision-safety with system
34/// metadata comes entirely from the `$`-prefix exclusion — `$`-prefixed keys
35/// are reserved, so a user field can never shadow one regardless of case.
36pub fn is_valid_field_name(name: &str) -> bool {
37    let normalized: String = name.nfc().collect();
38    if normalized.is_empty() {
39        return false;
40    }
41    let mut chars = normalized.chars();
42    let first = chars.next().unwrap();
43    if !first.is_ascii_alphabetic() && first != '_' {
44        return false;
45    }
46    for ch in chars {
47        if !ch.is_ascii_alphanumeric() && ch != '_' {
48            return false;
49        }
50    }
51    true
52}
53
54/// Errors returned by document and card mutators.
55#[derive(Debug, Clone, PartialEq, thiserror::Error)]
56pub enum EditError {
57    #[error("invalid field name '{0}': must match [A-Za-z_][A-Za-z0-9_]*")]
58    InvalidFieldName(String),
59
60    /// A typed write ([`TypedWriter::set`](crate::TypedWriter::set) /
61    /// [`CardWriter::set`](crate::CardWriter::set)) addressed a well-formed name
62    /// that the bound schema does not declare (or a card whose `$kind` carries
63    /// no schema). The typed path resolves every name to a schema type, so an
64    /// undeclared name is a typo, not a fallback — it fails here instead of
65    /// landing silently in the opaque store. Reach for the raw
66    /// [`Card::store_field`](Card::store_field) when opaque storage is the intent.
67    #[error("field '{0}' is not declared in the schema")]
68    UnknownField(String),
69
70    #[error("invalid card kind '{0}': must match [a-z_][a-z0-9_]*")]
71    InvalidKindName(String),
72
73    #[error("card kind 'main' is reserved for the document root")]
74    ReservedKind,
75
76    #[error("index {index} is out of range (len = {len})")]
77    IndexOutOfRange { index: usize, len: usize },
78
79    #[error("value nests deeper than the maximum of {max} levels")]
80    ValueTooDeep { max: usize },
81
82    /// Markdown import failed — the content codec rejected the input for a body
83    /// *or* a field path (e.g. container nesting past
84    /// [`MAX_NESTING_DEPTH`](quillmark_content::MAX_NESTING_DEPTH)). Returned
85    /// instead of silently degrading the target to empty on a rejected import.
86    #[error("markdown import failed: {0}")]
87    Import(ImportError),
88
89    /// A richtext field value in the content-or-markdown encoding could not be
90    /// decoded: a JSON object that is not a canonical richtext content, a
91    /// markdown string that failed to import, or a shape that is neither
92    /// object, string, nor null. Returned by
93    /// [`Card::commit_field`](Card::commit_field) on a richtext field, by
94    /// [`Card::revise_field`](Card::revise_field) on a present non-content field,
95    /// and by [`Card::apply_field_richtext_change`](Card::apply_field_richtext_change).
96    #[error("richtext field '{field}' decode failed: {message}")]
97    FieldRichtextDecode { field: String, message: String },
98
99    /// A richtext field written under the `richtext(inline)` constraint decoded
100    /// to a multi-block content (more than one line, a container, or an island).
101    /// The write-time counterpart of the coercion/validation `richtext(inline)`
102    /// check; returned by [`Card::commit_field`](Card::commit_field) when the
103    /// field's schema is `richtext` with `inline: true`.
104    #[error("richtext field '{0}' is not inline: richtext(inline) requires a single paragraph line with no list/quote container and no islands")]
105    FieldRichtextNotInline(String),
106
107    /// A typed write ([`Card::commit_field`](Card::commit_field)) could not
108    /// conform the value to the field's schema type — the general write-commit
109    /// failure for scalar/array/object types (a `"x"` for an `integer`, a
110    /// non-object for an `object`, …). Richtext fields report through the
111    /// dedicated [`FieldRichtextDecode`](Self::FieldRichtextDecode) /
112    /// [`FieldRichtextNotInline`](Self::FieldRichtextNotInline) variants
113    /// instead, so the richtext write surface is unchanged.
114    #[error("field '{field}' does not conform to its schema type: {message}")]
115    FieldConform { field: String, message: String },
116
117    /// A content field-change bundle (text delta, line ops, mark ops) applied
118    /// out of bounds or broke an invariant normalization could not repair.
119    #[error("content apply failed: {0:?}")]
120    ContentApply(ApplyError),
121}
122
123impl EditError {
124    /// The bare variant name (e.g. `"InvalidFieldName"`). The wasm and Python
125    /// bindings each surface it as the `[EditError::<Variant>]` message prefix;
126    /// defined once here so a new variant cannot drift between the two
127    /// binding error mappers.
128    pub fn variant_name(&self) -> &'static str {
129        match self {
130            EditError::InvalidFieldName(_) => "InvalidFieldName",
131            EditError::UnknownField(_) => "UnknownField",
132            EditError::InvalidKindName(_) => "InvalidKindName",
133            EditError::ReservedKind => "ReservedKind",
134            EditError::IndexOutOfRange { .. } => "IndexOutOfRange",
135            EditError::ValueTooDeep { .. } => "ValueTooDeep",
136            EditError::Import(_) => "Import",
137            EditError::FieldRichtextDecode { .. } => "FieldRichtextDecode",
138            EditError::FieldRichtextNotInline(_) => "FieldRichtextNotInline",
139            EditError::FieldConform { .. } => "FieldConform",
140            EditError::ContentApply(_) => "ContentApply",
141        }
142    }
143}
144
145/// A field-level invariant violation, shared by every payload ingestion path.
146///
147/// Each boundary maps it to its own error type (`ParseError`,
148/// `StorageError`, `WireError`, `EditError`), so the invariant — every user
149/// field name matches `[A-Za-z_][A-Za-z0-9_]*` and no value nests past the §8
150/// depth limit — is enforced once, here, and a constructed `Document` can
151/// never violate it.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum FieldViolation {
154    /// The field name does not match `[A-Za-z_][A-Za-z0-9_]*` (spec §3.4 / §10).
155    InvalidName,
156    /// The value nests deeper than [`MAX_YAML_DEPTH`](crate::document::limits::MAX_YAML_DEPTH)
157    /// (spec §8).
158    TooDeep,
159}
160
161/// Map a [`FieldViolation`] to the mutator error surface — the single
162/// translation the `Card` mutators and the validating
163/// [`Payload::insert`](crate::document::Payload::insert) both route through.
164pub(crate) fn edit_error_from_violation(name: &str, v: FieldViolation) -> EditError {
165    match v {
166        FieldViolation::InvalidName => EditError::InvalidFieldName(name.to_string()),
167        FieldViolation::TooDeep => EditError::ValueTooDeep {
168            max: crate::document::limits::MAX_YAML_DEPTH,
169        },
170    }
171}
172
173/// Validate a user field at the mutator boundary, mapping a violation to the
174/// mutator error surface.
175fn check_field(name: &str, value: &serde_json::Value) -> Result<(), EditError> {
176    validate_field(name, value).map_err(|v| edit_error_from_violation(name, v))
177}
178
179/// Depth-bound an out-of-band meta map (`$ext` / `$seed`). Both ride the same
180/// recursive emit/DTO paths, so they carry the same §8 depth bound.
181fn check_meta_depth(map: &serde_json::Map<String, serde_json::Value>) -> Result<(), EditError> {
182    crate::value::depth_check_meta_map(map.clone(), |max| EditError::ValueTooDeep { max })?;
183    Ok(())
184}
185
186/// Validate a user field at the payload boundary: name conformance and
187/// value-depth bound. See [`FieldViolation`] for the invariant.
188pub fn validate_field(key: &str, value: &serde_json::Value) -> Result<(), FieldViolation> {
189    if !is_valid_field_name(key) {
190        return Err(FieldViolation::InvalidName);
191    }
192    if crate::value::json_depth_exceeds(value, crate::document::limits::MAX_YAML_DEPTH) {
193        return Err(FieldViolation::TooDeep);
194    }
195    Ok(())
196}
197
198/// Map a strict-write [`CoercionError`] to the field-write [`EditError`] surface.
199///
200/// A failed richtext coercion routes to the dedicated `FieldRichtext*` variants
201/// — the same surface [`Card::apply_field_richtext_change`] produces, and the
202/// one the wasm/Python error mappers (and their tests) key on. This keys on the
203/// coercion `target`, not the top-level field type, because the richtext
204/// constraint can be **nested**: an `array` of `richtext(inline)` items fails
205/// with `target == "richtext(inline)"` while the field's own type is `Array`.
206/// The richtext coercion emits exactly `"richtext"` / `"richtext(inline)"`
207/// (see `QuillConfig::conform_value`); every other target uses the general
208/// [`EditError::FieldConform`].
209fn conform_error_to_edit(name: &str, err: CoercionError) -> EditError {
210    let CoercionError::Uncoercible { target, reason, .. } = err;
211    match target.as_str() {
212        "richtext(inline)" => EditError::FieldRichtextNotInline(name.to_string()),
213        "richtext" => EditError::FieldRichtextDecode {
214            field: name.to_string(),
215            message: reason,
216        },
217        _ => EditError::FieldConform {
218            field: name.to_string(),
219            message: reason,
220        },
221    }
222}
223
224/// Compute the canonical stored form of a typed field write **without applying
225/// it** — the dry-run shared by [`Card::commit_field`] and the batched,
226/// all-or-nothing [`TypedWriter::set_all`](crate::TypedWriter::set_all).
227///
228/// Strict `Leniency::Write` conform against `schema`; the name and stored-value
229/// depth are validated too, so a batch can collect every violation before any
230/// mutation. The unknown-name case never reaches here — the editor rejects it
231/// with [`EditError::UnknownField`] before there is a schema to conform against.
232pub(crate) fn resolve_field_write(
233    name: &str,
234    value: QuillValue,
235    schema: &FieldSchema,
236) -> Result<QuillValue, EditError> {
237    if !is_valid_field_name(name) {
238        return Err(EditError::InvalidFieldName(name.to_string()));
239    }
240    let stored = QuillConfig::conform_value(&value, schema, name, Leniency::Write)
241        .map_err(|e| conform_error_to_edit(name, e))?;
242    // Depth-bound the stored form (name already validated above).
243    check_field(name, stored.as_json())?;
244    Ok(stored)
245}
246
247impl Document {
248    pub fn set_quill_ref(&mut self, reference: QuillReference) {
249        self.main_mut().payload_mut().set_quill(reference);
250    }
251
252    pub fn card_mut(&mut self, index: usize) -> Option<&mut Card> {
253        self.cards_mut().get_mut(index)
254    }
255
256    /// Append a composable card. Its `$kind` must be a valid, non-reserved
257    /// composable kind ([`EditError::InvalidKindName`] /
258    /// [`EditError::ReservedKind`] otherwise) — the invariant for any card in
259    /// the cards list, enforced here so every entry path shares it.
260    pub fn push_card(&mut self, card: Card) -> Result<(), EditError> {
261        Self::check_composable_kind(&card)?;
262        self.cards_vec_mut().push(card);
263        Ok(())
264    }
265
266    /// Insert a composable card at `index` (`index > len` →
267    /// [`EditError::IndexOutOfRange`]; invalid `$kind` →
268    /// [`EditError::InvalidKindName`] / [`EditError::ReservedKind`]).
269    pub fn insert_card(&mut self, index: usize, card: Card) -> Result<(), EditError> {
270        let len = self.cards().len();
271        if index > len {
272            return Err(EditError::IndexOutOfRange { index, len });
273        }
274        Self::check_composable_kind(&card)?;
275        self.cards_vec_mut().insert(index, card);
276        Ok(())
277    }
278
279    /// Validate that `card`'s `$kind` is a valid, non-reserved composable kind.
280    /// A card with no `$kind` is rejected as an invalid (empty) name.
281    fn check_composable_kind(card: &Card) -> Result<(), EditError> {
282        let kind = card.kind().unwrap_or("");
283        validate_composable_kind(kind).map_err(|e| match e {
284            CardKindError::InvalidName => EditError::InvalidKindName(kind.to_string()),
285            CardKindError::Reserved => EditError::ReservedKind,
286        })
287    }
288
289    pub fn remove_card(&mut self, index: usize) -> Option<Card> {
290        if index >= self.cards().len() {
291            return None;
292        }
293        Some(self.cards_vec_mut().remove(index))
294    }
295
296    /// Replace the `$kind` of the composable card at `index`.
297    ///
298    /// Only the `$kind` metadata changes; the payload and body are untouched
299    /// (field-bag semantics). Old-schema fields linger in the bag; new-schema
300    /// fields are absent until set explicitly. Schema migration is the caller's
301    /// responsibility — this is a structural primitive.
302    ///
303    /// Returns [`EditError::IndexOutOfRange`], [`EditError::InvalidKindName`],
304    /// or [`EditError::ReservedKind`] on constraint violations.
305    pub fn set_card_kind(
306        &mut self,
307        index: usize,
308        new_kind: impl Into<String>,
309    ) -> Result<(), EditError> {
310        let new_kind = new_kind.into();
311        validate_composable_kind(&new_kind).map_err(|e| match e {
312            CardKindError::InvalidName => EditError::InvalidKindName(new_kind.clone()),
313            CardKindError::Reserved => EditError::ReservedKind,
314        })?;
315        let len = self.cards().len();
316        let card = self
317            .card_mut(index)
318            .ok_or(EditError::IndexOutOfRange { index, len })?;
319        card.payload_mut().set_kind(new_kind);
320        Ok(())
321    }
322
323    /// Move card at `from` to position `to`. No-op when `from == to`.
324    /// Either index out of range → [`EditError::IndexOutOfRange`].
325    pub fn move_card(&mut self, from: usize, to: usize) -> Result<(), EditError> {
326        let len = self.cards().len();
327        if from >= len {
328            return Err(EditError::IndexOutOfRange { index: from, len });
329        }
330        if to >= len {
331            return Err(EditError::IndexOutOfRange { index: to, len });
332        }
333        if from == to {
334            return Ok(());
335        }
336        let card = self.cards_vec_mut().remove(from);
337        self.cards_vec_mut().insert(to, card);
338        Ok(())
339    }
340}
341
342impl Card {
343    /// Create a composable card with the given kind, no fields, and an empty body.
344    pub fn new(kind: impl Into<String>) -> Result<Self, EditError> {
345        let kind = kind.into();
346        validate_composable_kind(&kind).map_err(|e| match e {
347            CardKindError::InvalidName => EditError::InvalidKindName(kind.clone()),
348            CardKindError::Reserved => EditError::ReservedKind,
349        })?;
350        let mut payload = Payload::new();
351        payload.set_kind(kind);
352        Ok(Card::from_parts(
353            payload,
354            quillmark_content::Content::empty(),
355        ))
356    }
357
358    /// Store a payload field verbatim, clearing any `!must_fill` marker on that
359    /// key — the opaque store (**store** = verbatim, coercion deferred to render;
360    /// contrast the typed [`TypedWriter::set`](crate::TypedWriter::set)). Scalars
361    /// convert in place (`store_field("qty", 3)`); see the `From` impls on
362    /// [`QuillValue`].
363    ///
364    /// Returns [`EditError::InvalidFieldName`] when `name` does not match
365    /// `[A-Za-z_][A-Za-z0-9_]*`.
366    pub fn store_field(&mut self, name: &str, value: impl Into<QuillValue>) -> Result<(), EditError> {
367        self.payload_mut()
368            .insert(name.to_string(), value.into())
369            .map_err(|v| edit_error_from_violation(name, v))?;
370        Ok(())
371    }
372
373    /// Store a payload field verbatim and mark it as a `!must_fill` placeholder.
374    /// `Null` emits as `key: !must_fill`; scalars/sequences as `key: !must_fill <value>`.
375    /// The opaque store's fill variant (quill-free, verbatim); same validation as
376    /// [`Card::store_field`].
377    pub fn store_fill(&mut self, name: &str, value: impl Into<QuillValue>) -> Result<(), EditError> {
378        self.payload_mut()
379            .insert_fill(name.to_string(), value.into())
380            .map_err(|v| edit_error_from_violation(name, v))?;
381        Ok(())
382    }
383
384    /// Store several payload fields verbatim and atomically, clearing any
385    /// `!must_fill` marker on each key — the opaque store's batch (contrast the
386    /// typed [`TypedWriter::set_all`](crate::TypedWriter::set_all)). The whole
387    /// batch is validated first — on any violation nothing is applied and every
388    /// offending field is reported as a `(name, error)` pair, so a caller feeding
389    /// externally-sourced names (database columns, form keys) sees all violations
390    /// in one pass instead of fix-rerun-repeat. Per-field rules are those of
391    /// [`Card::store_field`]; insertion order follows the iterator, and a
392    /// repeated name behaves like repeated `store_field` calls (last value
393    /// wins, first position kept).
394    pub fn store_fields<K, V, I>(&mut self, fields: I) -> Result<(), Vec<(String, EditError)>>
395    where
396        K: Into<String>,
397        V: Into<QuillValue>,
398        I: IntoIterator<Item = (K, V)>,
399    {
400        let fields: Vec<(String, QuillValue)> = fields
401            .into_iter()
402            .map(|(k, v)| (k.into(), v.into()))
403            .collect();
404        let errors: Vec<(String, EditError)> = fields
405            .iter()
406            .filter_map(|(name, value)| {
407                check_field(name, value.as_json())
408                    .err()
409                    .map(|e| (name.clone(), e))
410            })
411            .collect();
412        if !errors.is_empty() {
413            return Err(errors);
414        }
415        // Batch validated above; apply through the unchecked insert so the
416        // whole-batch check is not re-run per field.
417        for (name, value) in fields {
418            self.payload_mut().insert_unchecked(name, value);
419        }
420        Ok(())
421    }
422
423    /// Remove a payload field; returns `Ok(None)` if the name is absent.
424    /// Removal has no lane — the one verb serves every write path. Same
425    /// validation as [`Card::store_field`].
426    pub fn remove_field(&mut self, name: &str) -> Result<Option<QuillValue>, EditError> {
427        if !is_valid_field_name(name) {
428            return Err(EditError::InvalidFieldName(name.to_string()));
429        }
430        Ok(self.payload_mut().remove(name))
431    }
432
433    /// Replace the card's opaque `$ext` map wholesale, inserting it at the
434    /// canonical position (after `$quill`/`$kind`/`$id`, before user fields)
435    /// when none existed. Passing an empty map records an explicit `$ext: {}`.
436    ///
437    /// `$ext` carries out-of-band consumer state (editor renames, agent
438    /// annotations, …) and is stripped from [`Document::to_plate_json`], so a
439    /// write here can never affect a render. Any nested comments attached to a
440    /// replaced `$ext` are dropped.
441    /// Returns [`EditError::ValueTooDeep`] when the map nests past the §8
442    /// depth limit — `$ext` never reaches the plate JSON, but it does flow
443    /// through the recursive emit and DTO paths, so it carries the same
444    /// depth bound as user fields.
445    ///
446    /// Quill-free and never coerced — an opaque `store_*` verb by the vocabulary
447    /// rule, not a typed `set`.
448    pub fn store_ext(
449        &mut self,
450        value: serde_json::Map<String, serde_json::Value>,
451    ) -> Result<(), EditError> {
452        check_meta_depth(&value)?;
453        self.payload_mut().set_ext(value);
454        Ok(())
455    }
456
457    /// Remove the card's `$ext` map *entirely*, returning the previous map if
458    /// present. This is a blunt escape hatch — it discards every namespace
459    /// (`$ext.editor`, `$ext.agent`, …) at once. To clear consumer
460    /// state, prefer [`Card::remove_ext_namespace`], which drops only your
461    /// own slot and leaves sibling consumers' state intact.
462    pub fn remove_ext(&mut self) -> Option<serde_json::Map<String, serde_json::Value>> {
463        self.payload_mut().take_ext()
464    }
465
466    /// Merge `value` into the card's `$ext` map under `namespace`, creating
467    /// the map when absent and replacing any existing value at that key.
468    ///
469    /// This is the recommended way to write `$ext`: it preserves sibling
470    /// namespaces, so independent consumers keying on their own slot
471    /// (`$ext.editor`, `$ext.agent`, …) don't clobber each other.
472    /// Returns [`EditError::ValueTooDeep`] when the merged map nests past
473    /// the §8 depth limit (see [`Card::store_ext`]); the card's `$ext` is
474    /// unchanged on error. Quill-free and never coerced — an opaque `store_*`
475    /// verb.
476    pub fn store_ext_namespace(
477        &mut self,
478        namespace: impl Into<String>,
479        value: serde_json::Value,
480    ) -> Result<(), EditError> {
481        let mut map = self.payload_mut().ext().cloned().unwrap_or_default();
482        map.insert(namespace.into(), value);
483        check_meta_depth(&map)?;
484        self.payload_mut().take_ext();
485        self.payload_mut().set_ext(map);
486        Ok(())
487    }
488
489    /// Remove `namespace` from the card's `$ext` map, returning the value
490    /// that was stored there (or `None` when the map or the key was absent).
491    ///
492    /// This is the recommended way to clear `$ext` state: it is the
493    /// namespace-scoped inverse of [`Card::store_ext_namespace`] and preserves
494    /// sibling namespaces, where [`Card::remove_ext`] would wipe them all.
495    /// When removing the last namespace empties the map, the `$ext` entry is
496    /// dropped entirely (not left as `$ext: {}`), so
497    /// `store_ext_namespace(ns, v)` followed by `remove_ext_namespace(ns)`
498    /// restores a card that had no `$ext` to its original state.
499    pub fn remove_ext_namespace(&mut self, namespace: &str) -> Option<serde_json::Value> {
500        let mut map = self.payload_mut().take_ext()?;
501        let removed = map.remove(namespace);
502        if !map.is_empty() {
503            self.payload_mut().set_ext(map);
504        }
505        removed
506    }
507
508    /// The raw `$seed` map (keyed by card-kind), or `None`. For a parsed,
509    /// per-kind overlay, index this map by kind and pass the entry to
510    /// [`crate::SeedOverlay::from_json`]. Only the main card carries `$seed`.
511    pub fn seed(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
512        self.payload().seed()
513    }
514
515    /// Merge a card-kind's seed overlay `value` into the card's `$seed` map
516    /// under `card_kind`, creating the map when absent and replacing any
517    /// existing overlay for that kind. Sibling kinds are preserved — this is
518    /// the per-kind-safe writer, the seed analogue of
519    /// [`Card::store_ext_namespace`]. `card_kind` must be a valid, non-reserved
520    /// composable kind ([`EditError::InvalidKindName`] / [`EditError::ReservedKind`]
521    /// otherwise) — `$seed` is keyed by composable card-kind, unlike the
522    /// free-form namespaces of `$ext`. Returns [`EditError::ValueTooDeep`] when
523    /// the merged map nests past the §8 depth limit; the card is unchanged on
524    /// error. Quill-free and never coerced — an opaque `store_*` verb.
525    pub fn store_seed_namespace(
526        &mut self,
527        card_kind: impl Into<String>,
528        value: serde_json::Value,
529    ) -> Result<(), EditError> {
530        let card_kind = card_kind.into();
531        validate_composable_kind(&card_kind).map_err(|e| match e {
532            CardKindError::InvalidName => EditError::InvalidKindName(card_kind.clone()),
533            CardKindError::Reserved => EditError::ReservedKind,
534        })?;
535        let mut map = self.payload_mut().seed().cloned().unwrap_or_default();
536        map.insert(card_kind, value);
537        check_meta_depth(&map)?;
538        self.payload_mut().take_seed();
539        self.payload_mut().set_seed(map);
540        Ok(())
541    }
542
543    /// Remove `card_kind` from the card's `$seed` map, returning the overlay
544    /// stored there (or `None`). When removing the last kind empties the map,
545    /// the `$seed` entry is dropped entirely (not left as `$seed: {}`).
546    /// The seed analogue of [`Card::remove_ext_namespace`].
547    pub fn remove_seed_namespace(&mut self, card_kind: &str) -> Option<serde_json::Value> {
548        let mut map = self.payload_mut().take_seed()?;
549        let removed = map.remove(card_kind);
550        if !map.is_empty() {
551            self.payload_mut().set_seed(map);
552        }
553        removed
554    }
555
556    /// Install the body content directly from a pre-built [`Content`] — **value
557    /// semantics**, the native richtext writer. A content is valid by
558    /// construction, so this is infallible: no markdown import, no diff, no
559    /// schema check; the identity anchors of the previous body are *gone*
560    /// (install-this-exact-value, so a `to_markdown → install` round-trip cannot
561    /// resurrect them). Use it when the caller already holds a content (a decoded
562    /// canonical-JSON body, another field's value, an editor's serialized state).
563    /// For "here's new authored markdown," use [`revise_body`](Self::revise_body),
564    /// which rebases surviving anchors; the cold-import path is spelled at the
565    /// call site as `install_body(import_body(md)?)`.
566    pub fn install_body(&mut self, content: Content) {
567        self.overwrite_body(content);
568    }
569
570    /// Install a richtext field's content directly from a pre-built [`Content`]
571    /// — the field-level twin of [`install_body`](Self::install_body). Value
572    /// semantics: stores the canonical content JSON verbatim (identity marks and
573    /// content-only marks such as `underline` intact), no diff, no schema check
574    /// (schema-blind, like [`apply_field_richtext_change`](Self::apply_field_richtext_change)
575    /// — [`commit_field`](Self::commit_field) is the typed door). Returns
576    /// [`EditError::InvalidFieldName`] for a malformed name.
577    pub fn install_field(&mut self, name: &str, content: Content) -> Result<(), EditError> {
578        if !is_valid_field_name(name) {
579            return Err(EditError::InvalidFieldName(name.to_string()));
580        }
581        self.store_field_content(name, &content);
582        Ok(())
583    }
584
585    /// Store `content` as the canonical content-JSON value of field `name` — the
586    /// one place a richtext field's content is committed to the payload, shared by
587    /// [`install_field`](Self::install_field), [`revise_field`](Self::revise_field),
588    /// and [`apply_field_richtext_change`](Self::apply_field_richtext_change).
589    /// Assumes `name` is already validated (all three callers check it or resolve
590    /// an existing field first).
591    fn store_field_content(&mut self, name: &str, content: &Content) {
592        let canonical = quillmark_content::serial::to_canonical_value(content);
593        self.payload_mut()
594            .insert_unchecked(name.to_string(), QuillValue::from_json(canonical));
595    }
596
597    /// Write-time commit: validate and normalize `value` per the field's schema
598    /// `type` and store the canonical form. The typed sibling of the opaque
599    /// [`store_field`](Self::store_field) — the one write verb for *every* field
600    /// type (richtext today, any future content model tomorrow), dispatching on
601    /// the [`FieldSchema`] rather than growing a per-type method.
602    ///
603    /// The two write disciplines: [`store_field`](Self::store_field) stores the
604    /// value opaquely and defers coercion to render (keystroke-level state,
605    /// data-in-flight); `commit_field` canonicalizes now and fails now (an
606    /// editor blur/save, an agent write). Neither is forced on the other.
607    ///
608    /// Behavior by `type`:
609    /// - **richtext** — imports a markdown string / adopts a content object and
610    ///   stores canonical content JSON, so identity marks (anchors, island ids)
611    ///   live on the stored value from the write; a `richtext(inline)` schema
612    ///   rejects a multi-block value with [`EditError::FieldRichtextNotInline`].
613    /// - **scalars** (`string`/`integer`/`number`/`boolean`/`datetime`) — stores
614    ///   the coerced canonical (`"3"` → `3`), applying only value-parsing
615    ///   normalizations; a cross-type value that the render floor would coerce
616    ///   (e.g. `1` → `true`) or a shape mismatch fails here instead.
617    /// - **array** / **object** — coerces each element/property against the
618    ///   element/property schema.
619    /// - **null** — passes through unchanged (the null ≡ absent rule); nothing
620    ///   is coerced (a richtext `null` reads back as the empty content via
621    ///   [`field_richtext`](Self::field_richtext)).
622    ///
623    /// The caller supplies the `schema` because a [`Document`] holds only a
624    /// `$quill` *reference*, not the resolved schema; an editor holds it (see
625    /// [`crate::TypedWriter`], which resolves the schema per field and calls
626    /// this).
627    ///
628    /// Returns [`EditError::InvalidFieldName`] for a malformed name,
629    /// [`EditError::FieldRichtextDecode`] / [`EditError::FieldRichtextNotInline`]
630    /// for a richtext field, [`EditError::FieldConform`] for any other type
631    /// mismatch, and [`EditError::ValueTooDeep`] when the stored value nests
632    /// past the §8 depth limit.
633    pub fn commit_field(
634        &mut self,
635        name: &str,
636        value: impl Into<QuillValue>,
637        schema: &FieldSchema,
638    ) -> Result<(), EditError> {
639        let stored = resolve_field_write(name, value.into(), schema)?;
640        // `resolve_field_write` already validated name + stored-value depth.
641        self.payload_mut().insert_unchecked(name.to_string(), stored);
642        Ok(())
643    }
644
645    /// Revise the body from an authored markdown string — **edit semantics**,
646    /// the whole-document (stale-text / LLM / MCP) writer, and the receipt-
647    /// returning default write path. Imports the markdown, diffs it against the
648    /// current body, and rebases surviving identity anchors onto the new text
649    /// (cold import + [`diff_import`]), then returns the text [`Delta`] from the
650    /// old body to the new one — the change an editor bridge maps its own
651    /// positions through across a whole-document replace ([`Delta::map_pos`]).
652    /// Surviving identity anchors rebase; formatting marks are re-derived by the
653    /// fresh import. A pathologically over-nested input (`> MAX_NESTING_DEPTH`)
654    /// returns [`EditError::Import`] rather than silently degrading to the
655    /// empty content. Discard the receipt with `let _ = card.revise_body(md)?;`
656    /// when caret stability is not needed.
657    pub fn revise_body(&mut self, body: impl Into<String>) -> Result<Delta, EditError> {
658        let (content, delta) =
659            diff_import(self.body(), &body.into()).map_err(EditError::Import)?;
660        self.overwrite_body(content);
661        Ok(delta)
662    }
663
664    /// Decode the field's current content (an absent field imports from empty),
665    /// diff `body` against it so surviving anchors rebase, and return the new
666    /// content with its text [`Delta`] — the shared preamble of
667    /// [`revise_field`](Self::revise_field) and
668    /// [`revise_field_checked`](Self::revise_field_checked). Neither stores; the
669    /// caller lands the diffed content (raw, or schema-checked).
670    fn diff_field(
671        &self,
672        name: &str,
673        body: impl Into<String>,
674    ) -> Result<(Content, Delta), EditError> {
675        if !is_valid_field_name(name) {
676            return Err(EditError::InvalidFieldName(name.to_string()));
677        }
678        let base = match self.field_richtext(name) {
679            Some(Ok(rt)) => rt,
680            Some(Err(e)) => {
681                return Err(EditError::FieldRichtextDecode {
682                    field: name.to_string(),
683                    message: e.into_message(),
684                })
685            }
686            None => Content::empty(),
687        };
688        diff_import(&base, &body.into()).map_err(EditError::Import)
689    }
690
691    /// Revise a richtext field from an authored markdown string — the
692    /// field-level twin of [`revise_body`](Self::revise_body), and the
693    /// field-level `diff_import` the write surface previously lacked (the only
694    /// field-content writers were the cold [`commit_field`](Self::commit_field)
695    /// and the splice [`apply_field_richtext_change`](Self::apply_field_richtext_change),
696    /// so an LLM rewriting a richtext field's markdown had no anchor-preserving
697    /// path). Decodes the field's current content as the diff base (an **absent**
698    /// field cold-imports from empty), rebases surviving anchors onto the new
699    /// text, re-stores the canonical content, and returns the text [`Delta`].
700    ///
701    /// Schema-blind by design — the content-writer stratum splices without the
702    /// quill (like [`apply_field_richtext_change`](Self::apply_field_richtext_change));
703    /// [`commit_field`](Self::commit_field) is the typed door that enforces
704    /// `richtext(inline)`, and a violation otherwise surfaces at validate/render.
705    ///
706    /// Returns [`EditError::InvalidFieldName`] for a malformed name,
707    /// [`EditError::FieldRichtextDecode`] when the field is present but is not a
708    /// richtext content (a scalar a `store_field` wrote), and
709    /// [`EditError::Import`] on an over-nested markdown input.
710    pub fn revise_field(&mut self, name: &str, body: impl Into<String>) -> Result<Delta, EditError> {
711        let (content, delta) = self.diff_field(name, body)?;
712        self.store_field_content(name, &content);
713        Ok(delta)
714    }
715
716    /// Revise a richtext field from markdown **with schema enforcement** — the
717    /// typed *and* anchor-preserving field write that neither
718    /// [`revise_field`](Self::revise_field) nor [`commit_field`](Self::commit_field)
719    /// provides alone. [`revise_field`](Self::revise_field) rebases anchors but is
720    /// schema-blind; [`commit_field`](Self::commit_field) enforces the schema but
721    /// cold-imports (the previous value's anchors are gone). This does both: diff
722    /// the markdown against the field's current content so surviving anchors rebase
723    /// (as [`revise_field`](Self::revise_field)), then enforce `schema` on the
724    /// *diffed result* through the same typed-conform path
725    /// [`commit_field`](Self::commit_field) runs — so a `richtext(inline)` schema
726    /// rejects a multi-block result with [`EditError::FieldRichtextNotInline`],
727    /// the error surface unchanged, while the anchors survive. Returns the text
728    /// [`Delta`] receipt.
729    ///
730    /// The primitive that [`TypedWriter::revise_field`](crate::TypedWriter::revise_field)
731    /// and [`CardWriter::revise_field`](crate::CardWriter::revise_field) wrap: they
732    /// resolve `schema` from the bound quill and call here. The schema runs on the
733    /// content the diff produced, so a non-richtext `schema` (nothing to preserve)
734    /// fails with the same [`EditError::FieldConform`]
735    /// [`commit_field`](Self::commit_field) would raise.
736    ///
737    /// Errors: [`EditError::InvalidFieldName`], [`EditError::FieldRichtextDecode`]
738    /// when the field is present but not a richtext content, [`EditError::Import`]
739    /// on an over-nested markdown input, and the conform errors of
740    /// [`commit_field`](Self::commit_field) on the diffed result. On any error the
741    /// field is unchanged.
742    pub fn revise_field_checked(
743        &mut self,
744        name: &str,
745        body: impl Into<String>,
746        schema: &FieldSchema,
747    ) -> Result<Delta, EditError> {
748        let (content, delta) = self.diff_field(name, body)?;
749        // Enforce `schema` on the diffed (anchor-rebased) content through the same
750        // typed path `commit_field` uses: re-canonicalizing a content object keeps
751        // its identity marks (`decode_richtext_value`), so the inline check fires
752        // on the value anchors survived onto and the error surface is identical.
753        let canonical = quillmark_content::serial::to_canonical_value(&content);
754        let stored = resolve_field_write(name, QuillValue::from_json(canonical), schema)?;
755        self.payload_mut().insert_unchecked(name.to_string(), stored);
756        Ok(delta)
757    }
758
759    /// Apply a committed field-change bundle to the body content — the native
760    /// form-editor writer. Order is text delta → line ops → mark ops, then one
761    /// terminal normalization ([`Content::apply_field_change`]); mark ranges are
762    /// in final-text coordinates. Returns
763    /// [`EditError::ContentApply`] when an op is out of bounds; the apply is
764    /// all-or-nothing ([`Content::apply_field_change`]), so the body is
765    /// unchanged on error — apply the bundle against the body the delta was
766    /// computed from.
767    pub fn apply_body_change(
768        &mut self,
769        text_delta: &Delta,
770        line_ops: &[LineOp],
771        mark_ops: &[MarkOp],
772    ) -> Result<(), EditError> {
773        self.body_mut()
774            .apply_field_change(text_delta, line_ops, mark_ops)
775            .map_err(EditError::ContentApply)
776    }
777
778    /// Splice a content field-change bundle into a **richtext-valued field**'s
779    /// stored content — the field-path twin of [`apply_body_change`](Self::apply_body_change),
780    /// and what lets identity marks (anchors, island ids) persist on field
781    /// content across incremental edits. Decodes the field's canonical content,
782    /// applies the text delta plus any line/mark ops in the same all-or-nothing
783    /// bundle, and re-stores the canonical result.
784    ///
785    /// Returns [`EditError::FieldRichtextDecode`] when the field is absent or its
786    /// stored value is not a richtext content (the caller addresses a field it
787    /// knows is richtext, exactly as when writing it), and
788    /// [`EditError::ContentApply`] when the bundle applies out of bounds.
789    pub fn apply_field_richtext_change(
790        &mut self,
791        name: &str,
792        text_delta: &Delta,
793        line_ops: &[LineOp],
794        mark_ops: &[MarkOp],
795    ) -> Result<(), EditError> {
796        let mut content = match self.field_richtext(name) {
797            Some(Ok(rt)) => rt,
798            Some(Err(e)) => {
799                return Err(EditError::FieldRichtextDecode {
800                    field: name.to_string(),
801                    message: e.into_message(),
802                })
803            }
804            None => {
805                return Err(EditError::FieldRichtextDecode {
806                    field: name.to_string(),
807                    message: "field is absent".to_string(),
808                })
809            }
810        };
811        content
812            .apply_field_change(text_delta, line_ops, mark_ops)
813            .map_err(EditError::ContentApply)?;
814        self.store_field_content(name, &content);
815        Ok(())
816    }
817}