Skip to main content

The change applier: core/change

Every change to a block's content goes through one function, change.ApplyBlock. This note covers the package that holds it: the change-set contract, the rules an operation obeys, what a format declares it can write, and how tools in a flow reach it. The package imports core/model, core/format and core/safeio and nothing from the host, the CLI or any surface, so every surface can build on it.

The contract types​

A change set (change.Set) is an envelope of ordered operations (change.Op) under the schema kapi.change/v1. The envelope carries mode (apply or preview), gate (enforce or report), require_basis, a note and evidence (each entry a file's path, the block key a read reports, a quote or a url). It has no actor field: the transport that carries a change set says who sent it, and ApplyBlock takes the sender in its BlockEnv.

An operation is flat on the wire, {"op": kind, "at": …, "if_match": …, …}. Op holds the kind, the address (change.Ref: document, block, edition), the precondition, the basis of a derived edition, and a body whose type matches the kind (*change.SetContent, *change.ReplaceText, and so on). The edition in a reference is parsed with model.ParseEditionKey, which keeps it canonical and refuses a language that is not a locale or a dimension other than tone and channel.

change.Decode reads a change set as one object, as JSONL with the envelope on its first line, or as an array of operations. Decoding is strict: an unknown field, an unknown operation, a missing required field, a null, a value of the wrong type and a run payload that carries native data are refused as invalid, and the error's Pointer is the JSON pointer of the value at fault (/ops/2/edits/0/find).

changeschema.Schema (core/change/changeschema) is the JSON Schema of a change set, generated from the same Go types with github.com/google/jsonschema-go; change.Operations describes each operation for it. It sits in its own package so that the applier, which every tool links, carries no schema generator. Each operation is a oneOf member whose op is a const and which allows no other properties. core/change/testdata/schema.golden.json pins it; regenerate it with go test ./core/change -run TestSchemaGolden -update. A test validates the contract's examples against the schema and the decoder alike, so the two cannot drift apart. changeschema.ResultSchema and changeschema.ReadSchema are the schemas of a result and of a read page, generated from the structs the service marshals with the same library: the first is the output schema of the apply_edits tool, and result.golden.json and read.golden.json pin both (go test ./core/change -run TestReplySchemaGoldens -update).

The contract is frozen. schema.v1.frozen.json, result.v1.frozen.json and read.v1.frozen.json are the goldens at the freeze, each with a $comment naming the release and the date it froze for (1.3.0, 2026-10-03), and nothing regenerates them. changeschema.Extends(frozen, current, side) walks a frozen schema beside the generated one, properties and $defs by name, oneOf members by their op const or their required fields, and reports each difference that breaks the Writer (a request) or the Reader (a reply) side; TestSchemaExtendsFrozenV1, TestResultSchemaExtendsFrozenV1 and TestReadSchemaExtendsFrozenV1 fail on any, and TestExtends_RequestSide holds the walk to the cases it must refuse and allow. kapi/cmd/kapi/testdata/mcp_tools.frozen.json holds the MCP tool surface the same way (TestMCPToolSurfaceExtendsFrozen), its tool names a set that may only grow.

Refusals use a closed set of codes (change.Code), each mapped once to an exit code (ExitCode: 2 for invalid, 5 for unreachable, 3 for the rest) and an HTTP status (HTTPStatus).

TypeScript types​

A TypeScript client imports the contract from @neokapi/contract-types (packages/contract-types, Apache-2.0). Its change.gen.ts is generated by scripts/gen-contract-types from the same sources the service uses. ChangeSet and the ChangeOp union, one interface per operation named after it (SetContentOp, ReplaceTextOp, …), are rendered from changeschema.Schema. A choice of exactly one field becomes a union that refuses the others, so a set_content with both text and runs, an edit that names its text by find and by start at once, or a run that carries native data fails to compile, as the decoder refuses it. Members that a schema tells apart by the fields they hold rather than by a constant (a run is text, ph, pcOpen and so on; a path step is a plural form or a select case) each declare the other members' fields as never, so a run of two kinds fails to compile too. The types carry the structure the schema states; a pattern, a bound or a rule between fields (a revision's form, occurrence only beside find) is documented on the field and checked by the decoder alone. The replies are reflected from the structs the service marshals, with the doc comments of their Go declarations: ChangeResult, OpResult, ChangeError and ResultRef for a result, ReadPage and BlockRead for a read, FormatDescription for Describe, and EditionHistory and HistoryEntry for History. A read's codes carry each code's equiv and disp, which an editor labels a chip with, and leave out either one that repeats the code's native form, as an ARB reader's ICU argument or a printf specifier does; editTextToCoded and codedToEditText in @neokapi/ui-primitives turn a read's placeholder text into the inline-code editor's coded text and back, one token to one chip. A change set refused as a whole is a ChangeResult too, whose error says why. A read's ref is a ChangeRef and its rev a string, so a client copies both into an operation unchanged. The operation kinds, statuses, error codes and guard subcodes are unions with frozen lists of their members, and CHANGE_ERROR_HTTP_STATUS and CHANGE_ERROR_EXIT_CODE hold each code's mapping. An operation's result names a ResultOpKind, which adds the provenance operation a tool applies in process to the kinds a change set carries.

The generator fails on a schema keyword or a combination of keywords it does not render, on a Go type that marshals itself and has no TypeScript name, on a declared name missing from the schema, and on a constant of a rendered union whose value the source does not spell as a string literal. make check-contract-types regenerates the file and fails when the committed copy differs; the reference-data drift job and make pre-push run it. make test-contract-types runs the generator's tests, compiles the package with tsc (the workspace's vp check skips generated files) and type-checks its tests/, where each structural mistake the decoder refuses is asserted not to compile; the frontend CI job runs the same tsc on every pull request.

Revisions​

An operation names the revision of the edition its sender read. model.EditionRevision(block, key) is r: and 16 hex digits of the SHA-256 of the edition key, a zero byte and the edition's runs as canonical JSON (model.CanonicalRunsJSON, the form the TypeScript mirror writes). Inline codes count, with their data and attributes. Status, origin and every other edition are left out, so a review decision does not move a revision and a source edit leaves a translation's revision where it was. An edition the block does not hold reads as absent.

if_match is a revision, absent to create an edition, or * for a blind write, which only a person may send. A derived edition's write also records its basis, the authoritative edition's revision it was made from: the one the sender names, or else the authoritative edition as the operations before it in the set left it. With require_basis set, a basis that fails to hold is refused as stale.

ApplyBlock​

ApplyBlock(block, ops, env) applies set_content, replace_text, remove_edition, annotate, unannotate and the in-process provenance operation to one block in memory, and returns one result per operation. It checks every precondition against the block as it stood when it was called, applies the operations in order, and writes nothing unless all of them apply. A refusal names the first refused operation in every other result's blocked_by, and a stale refusal carries the edition as it stands. set_attribute, mark and a new code in a runs payload apply where the block's format declares them (see What a format can write); native is refused as unsupported. insert_block and delete_block change which blocks a document holds rather than one block's content, so ApplyBlock refuses them too: the change service applies them through the document's home and the format's writer (E-09). The service builds a new block's editions with ApplyBlock on an empty block, so their content obeys the rules below.

The rules each content operation obeys:

  • Text is a form of runs. Placeholder text (<x id="1"/>) is parsed against a reference code set: the content being replaced, or the authoritative edition's codes when the operation creates a derived edition. A derived edition can also take back a code the authoritative edition holds.
  • Codes keep their constraints. Dropping a code needs deletable, repeating one needs cloneable, moving one past another needs reorderable, resolved from the run or the vocabulary. Paired codes stay as well nested as the reference's. A violation is guard with subcode codes_changed.
  • Structure is kept. Text over an edition that holds a plural or select is refused as structure_lost; path names the branch to replace or edit, and a path to a missing plural form adds it. Runs replace a whole structure.
  • Run flags survive. Rebuilt text runs split where TextRun.NoTranslate changes. Text the edit leaves alone keeps its flag, and replacement text is flagged when everything it replaces was.
  • Native data stays with the format. A wire payload carries no data. A code named by its id takes its data from the reference, and a payload that changes a held code's attributes is refused, since set_attribute is what changes them. A subblock reference is a code like the others: looked up by its id, its ref the one the reference holds. A new paired code of a type the format writes is spelled by the format's writer; any other code the reference does not hold is refused as unsupported.
  • Bytes that are not UTF-8 are never overwritten with U+FFFD. A reader hands on the bytes of a file in another encoding as they are, and kapi reads every file as UTF-8, so a JSON read shows U+FFFD where they stand. On an edition whose text holds such bytes, replace_text and mark, which rebuild the text around their edit, are refused as unsupported with capability encoding, and so is a set_content that carries U+FFFD; a read lists neither replace_text nor mark for that block. A set_content that states every character replaces the bytes, and an operation that leaves the text alone, or an edit of another edition or block, keeps them as the file holds them.
  • Overlays follow the edit on every edition. Spans on the edited edition are remapped across the edit and checked to resolve in the new runs: a span follows the text it covers and is dropped only when the edit deletes all of that text, except a finding, which an overlapping edit drops. A segmentation layer is kept or dropped whole, the segment holding an edit resized around it, because bilingual writers read the layer as the edition's complete segment list.

A tool in a flow applies with the Report disposition (BlockEnv.Guards): a code guard violation lands with a finding, and so does a code the reference does not hold, kept as sent with no native form, so a translation that names a placeholder the source lacks reaches the placeholder checks rather than failing the flow. An overlay span a detector anchored inside a plural or select, where no run position reaches, is left out with a finding; a segmentation layer holding one is not written.

Positions in replace_text are code points of the text of the sequence a path reaches, in which every code, plural and select has zero width. A path on the operation applies to every edit, and an edit's own path overrides it. An edit names its text by find (with occurrence among several matches), by start and end, or by a range of run positions. A find is placeholder text (find.go): a code it does not name has zero width, so shop guide matches across the codes around it, and a <x id="…"/> token names a code the match must hold at that place, as a read shows it; the replacement text then names, by token, the codes the edit keeps, and the code constraints decide whether one may go. Half of a pair the find passed over without naming it, left out of the replacement while its other half stays, is refused guard/codes_changed naming that half. A find that matches nothing is refused not_found with searched (the path and the text it looked in) and up to three near matches as candidates, which differ from it only in case, white space or punctuation (a typographic apostrophe or quote included), and a token that names a code the sequence does not hold says so. The result's resolved echoes each position as run positions under model.RangeAnchor's attribution, where the end of a text run is the start of the run after it, in the edition as the operation found it; each position (change.Position) prints its run and offset, a zero offset included, and a refused operation reports none.

Operations on one edition compose​

A position (start and end, a range, and the run index of a path) names the edition as the change set found it, the revision its if_match names, even when an earlier operation of the set changed that edition (compose.go). Each operation records what it changed in each sequence: a replace_text its edits, in the sequence's own-text offsets before them, and a set_content the whole sequence its path reaches. A later position is resolved against the edition as it stood, then moved through those changes in the order the operations were sent. The start of a range follows text an earlier operation inserted where it lies and its end stays before it, so neither takes in the other's text, and an insertion goes after an earlier insertion at the same place. A position strictly inside replaced text, a range that encloses an insertion, or any position in a sequence a set_content replaced, is refused as guard/overlap, the message naming both operations by their place in the change set (BlockEnv.Indexes). A path is matched to the same branch by the ordinal of each plural and select in its sequence, which a text edit keeps while their run indexes move. A path through a sequence an earlier set_content replaced whole, or a path the edition did not hold once an earlier set_content replaced all of it, is refused the same way, even when the new content holds a plural at that run, a find under such a path included (TestApplyBlock_APathIntoContentReplacedWholeIsRefused). A find matches the text the earlier operations left, and so does an annotation's anchor. TestApplyBlock_EditsComposeAsIfAppliedOneAtATime applies random edits that do not overlap, each an operation of its own sent in any order, by offsets and by run ranges, and asserts the result equals the same edits applied one at a time.

BlockEnv.Chained reads every position as the operations before it left the edition, for an in-process caller that builds each operation on the one before it: an EditPlan's passes, where the second of two substitutions names text the first wrote. ksed sends its program over the wire, so a later substitution names its text by find and occurrence in the text the earlier ones left; a later match no find can name (an empty match, or one that overlaps another occurrence of its words) makes the program one operation, the change.Diff of what the whole program leaves.

model.TextEdit counts code points too. model.RangeAnchorForBytes converts byte offsets a detector reports.

What a format can write​

BlockEnv.Format (change.Capabilities) is what the writer of the block's format declares it can write beyond what its reader read: the attributes of a code set_attribute may change and the vocabulary types mark may create (E-02). change.WriterCapabilities(name, writer) probes an in-process writer and keeps it as the speller; change.DeclaredCapabilities(name, declared) holds a plugin format's manifest declaration, whose writer spells what it receives when it writes. The zero value declares nothing, and tools apply with it.

  • set_attribute finds every occurrence of the code in the edition, branches included, and refuses an attribute the format does not write for the code's type with a message naming what it does write. The writer's WriteAttr spells the value into the code's native data and updates its attributes; an error from it is the refusal's reason, and a sequence it returns with a different number of runs is refused too, since the later occurrences are found by their index. A value the code already holds is unchanged. The text is untouched, so overlays keep their offsets, and the edit takes the same status consequences as any other.
  • mark resolves its range as replace_text resolves an edit and wraps the text in a new paired code. Where a boundary falls between codes, the new code goes as far inside as it can while every code it encloses stays whole, so it nests inside a link it fills and around a code it covers, and a range that crosses a code's boundary is refused as guard/bad_position. The new code takes an id no edition of the block uses, one above the highest numeric id, so it never collides with a code a translation holds. Its attributes must be ones the format writes for its type.
  • A new code in runs: an opening half whose type the format synthesizes, with attributes it writes for the type, and its closing half in the same sequence, are spelled like a mark. A new code opens once in a sequence, and each plural form and select case is a sequence of its own, so the same new code may wrap words in every branch. Any other code the reference does not hold is refused as unsupported.
  • Script in attributes: a value set_attribute, mark or a new code in runs writes is refused as invalid when it would run code where the document is read, whatever the format declares writable. An event-handler attribute (on followed by letters, such as onclick) and an iframe's srcdoc hold script or a document, so no operation writes them. An attribute whose value a reader follows, loads or submits to as a URL is refused a javascript: or vbscript: URL, or a data: URL that holds anything but a raster image (an SVG image can carry script): href and src in any namespace (xlink:href), action, formaction, data, cite, poster, background and the other URL attributes of HTML, each candidate of a srcset or ping list, and to, from, by and values, which an SVG animation writes into the attribute its attributeName names (pointing attributeName at an href checks them). The URL is read as a browser reads it, so case, leading control characters and a tab or line break inside the scheme change nothing, and it is judged in every reading a reader of the document may make of it: with character references decoded (&#106;avascript:, javascript&colon;) and with CommonMark backslash escapes resolved (javascript\:), since a Markdown renderer passes a destination's references to the browser, which decodes them. A value the document already holds is read, kept and written as it is.

SynthesizeCode receives a format.CodeSite: the type and attributes, the block, the codes the new one sits inside, and the runs before, inside and after it, so the writer can refuse a place its syntax cannot carry. Markup the block sits inside reaches the writer through the block, as a property its reader records: the HTML reader names an a or button element around the block (html.interactive-ancestor), where HTML allows no new link. With no in-process speller (a plugin), the new code carries its type, attributes and the vocabulary's display and constraints and no data.

change.FormatOps(change.FormatDecl) builds the change service's description of a format: the operations the format's round trip carries, as the change service names them from the operations matrix, and the declarations, with every operation nothing supports as null.

Status consequences and Diff​

change.Consequences says what an applied edit does to an edition's status and origin. An edit to the authoritative edition drops an established edition to written and lists the derived editions in invalidates. A person's edit of a derived edition makes it translated with a human origin, and an agent's makes it translated with an agent origin. A tool's change to a derived edition's wording makes it a draft and keeps the origin the edition records, or gives it the tool's when it records none; an edition a tool creates takes the tool's origin and no status. A tool that produced the content records its own status and origin with the provenance operation, which never reaches the edition the block was read in. A set_content or replace_text may state the tool that produced its content in origin (change.ToolOrigin), as --print-ops prints it; the applier records none of it, and every edit keeps the origin Consequences gives it.

change.Diff(before, after) returns the operations that turn one block's editions into another's, each guarded by the revision it changes. It emits a replace_text when one text edit with every code kept describes the change exactly, and a set_content otherwise. A property test applies the diff of every fixture and variant, codes, plurals, selects and translations included, and asserts the result equals the changed block, then sends the same operations through JSON and Decode and asserts the same. A second pass applies the changes that keep every protected code as a person, under Enforce, so a diff that dropped or moved a code would be refused. Diff pairs the edition each block was read in through the zero key and every other edition by its key, so a same-language target pairs only with a target.

Tools write through it​

A tool's view (core/tool/view.go) applies each target write, provenance stamp and overlay write as an operation at once, through ApplyBlock, as the tool, with guard violations landing as findings. Applying at once keeps read-your-writes: a handler that sets a new target and stamps it finds the target it set. A write the applier refuses becomes the handler's error. A tool that overrides Process writes through tool.WriteAs, which returns the same refusal, and a Transform returns an EditPlan whose Ops compile to set_content operations on the source and each replaced target, and to a replace_text for each pass of in-place text edits (EditPlan.TextEdits), which keeps the inline codes, structure and run flags the edits leave alone. search-replace and case-transform edit this way. case-transform makes one edit per run of changed characters and none across a code. search-replace leaves a match that runs across a plural or select as it was and reports it as a finding that does not fail; it keeps a placeholder or other standalone code inside a match, before the replacement; and a paired code inside a match stays while any of its text is left and goes when the match replaces all of it. With replace-all off it replaces the first match in reading order: the text before a plural or select, then its branches (plural forms from zero to other), then the text after it.

The service and its homes​

ApplyBlock changes one block in memory. The change service in the same package (change.NewService) applies a whole change set to documents: it reads each document through the home that holds it, runs ApplyBlock on the blocks the operations address, checks the result, and commits every document or none. decide and the asset operations reach the host's stores through the service's Assets hook, after the content they refer to. E-09 describes the service, and The file home the home that keeps documents as files.

The edit guard​

scripts/editguard type-checks every module and reports each call that points a format writer at a file (SetOutput(path string) error) outside the format packages and the places it lists, each with what it writes. make check-edit-writes runs it in make lint, make pre-push and CI.