Gå til hovedinnhold

E-09: The change contract

Summary​

Content changes in one way. A caller describes the change as a change set (kapi.change/v1): an envelope of ordered operations, each addressed to one edition of one block of one document and each naming the revision of that edition its sender read. One service in the framework, core/change, applies it. The service reads each document through the home that holds its text, applies the operations in memory with change.ApplyBlock, checks what changed, and commits every document or none. A file in a working tree is one home; the file home commits by renaming a staged file onto the document under an advisory lock, and applies the change again when the file moved since it was read. A flow commits each document it writes through the same home and records it as the flow's edit.

The service lives below every surface. It imports core/model, core/format and core/safeio, and nothing from the host, the CLI or a surface, so the CLI, the agent tools, Kapi Desktop and an application that embeds the engine all build the same service and differ only in the home and the hooks they give it.

Context​

A change to content has to answer the same questions wherever it comes from: which block it means, whether that block still says what the sender read, whether the result keeps the inline codes and structure the format needs, what the change does to the edition's status, and whether governance allows it. A document that changes in two places at once has to end with both changes or with a clear refusal, and never with one change silently replaced by the other.

The engine reads documents into blocks whose editions hold runs (F-02) and writes them back through format writers that replay a skeleton (E-02). Tools in flows change blocks through their views (E-03). The change contract puts both on one footing: a tool's write and a person's edit are the same operations, applied by the same function, under the same rules.

Decision​

The change set​

A change set has an envelope and operations.

FieldMeaning
schemakapi.change/v1
modeapply (the default) or preview, which computes and checks everything, writes nothing, and returns a diff per document
gateenforce (the default) or report: what a failing governance finding the change introduces does
require_basisrefuse a write to a derived edition whose authoritative edition moved since the sender read it
note, evidencewhat a person reads in history, and where the wording behind the change was seen: a file's path, the block key a read reports, a quote or a url
opsthe operations, applied in order; a change set with none changes nothing

The content operations are set_content, replace_text, set_attribute, mark, remove_edition, annotate, unannotate, insert_block, delete_block and native. decide records a review decision, and term, memory and recipe change the project's terms store, content memory and recipe. The envelope carries no actor: the transport that delivers a change set says who sent it. A set_content or replace_text may state how a tool produced its content (origin: the tool, and the kind and engine it drew on), as a run printed with --print-ops states each translation it wrote. The service records no stated origin: an edit takes the origin its sender's edit gives it, a person's or an agent's their own, and a tool in a flow records how it produced a translation with the in-process provenance operation.

change.Decode reads a change set strictly: an unknown field or operation is refused with the JSON pointer of what was wrong. changeschema.Schema is the JSON Schema generated from the same Go types. TypeScript clients use @neokapi/contract-types, whose change set and operations are generated from that schema and whose result, read and description types are generated from the structs the service marshals; a drift gate regenerates them on every change. The note The change applier covers the types, the decoder, the schema and the TypeScript types.

kapi.change/v1 is frozen for release 1.3.0 and is only extended. A frozen file beside each golden holds the contract as it was published: the change set's schema, the result's (kapi.change-result/v1, changeschema.ResultSchema) and the read's (changeschema.ReadSchema), and the MCP tool surface, whose tool names may only grow and whose input schemas are held to the request rule below. A test walks each frozen schema beside the one the Go types generate and refuses a change that breaks the side that depends on it: for a request, a property, $defs entry or operation removed or renamed, a type, const, $ref, pattern, format or bound changed, a property newly required, an enum value removed, or additionalProperties changed; for a result or a read, the same, with a property dropped from required in place of a newly required one. An optional property, an operation, an enum value, a $defs entry and any description extend a schema. A breaking change needs kapi.change/v2 and a new frozen file. Decoding stays strict, so an older binary refuses a field a newer sender adds as invalid, which the rule accepts: a sender learns the field is not understood rather than having it ignored.

Addressing and revisions​

An operation addresses {doc, block, edition}. doc is a project-relative path, container!entry for an archive member, or another home's document key. block is the key a read reports: the durable key where reconciliation assigned one, else the name the format gives the block, else the reader's id (change.BlockKey). edition is an edition key such as fr or en;channel=short; empty means the document's own edition.

An operation that changes existing content carries if_match, the revision of the edition its sender read. model.EditionRevision hashes the edition key and its runs, codes and their attributes included, and nothing else, so the same token holds in every home. absent creates an edition, and * writes whatever is there, a blind write only a person may send. Every if_match is checked against the content as it stood when the change set began, so a sender never computes an intermediate revision, and several operations on one block each send the revision their sender read.

A set_content on a derived edition records its basis on the edition itself, as its derivation (Edition.Derived): the authoritative edition and the revision the operation names as basis, else the revision the operations before it left. An edit that names no basis keeps the derivation the edition has. A read takes a derived edition's basis, and whether the authoritative edition has moved since, from that derivation where the edition carries one, and from the host otherwise. A KBF bundle carries each edition's derivation, so a bundle read back holds the basis it was written with.

A position names that content too. An edit's start and end, a run range, and the run index a path walks through all refer to the edition at the revision the sender read, and the applier moves each through the text the operations before it changed in the same edition, in the order they were sent: two fixes kapi check prints for different words of one block, each guarded by the revision the check read, both land where their sender meant. A position inside text an earlier operation replaced, or after a set_content of the sequence it lies in, has no place in the edition as it stands, and the operation is refused as guard (overlap) with a message naming both operations by their place in the change set. A find matches the text as the earlier operations left it, and an annotation's anchor marks that text. A find is placeholder text, so the text a read shows can be sent back as it reads: an inline code has zero width unless a <x id="…"/> token names it, and a token matches only the code it names. A replace_text takes a path to the plural form or select case its edits are in, on the operation for every edit or on an edit, whose own path overrides it. An in-process caller that builds each operation on the result of the one before it, as a transform's passes do, applies them with BlockEnv.Chained, under which every position reads the edition as the operations before it left it.

The applier​

change.ApplyBlock applies content operations to one block in memory and returns one result per operation. It enforces the rules every content operation obeys: placeholder text is parsed against a reference code set, inline codes keep their editing constraints, plural and select structure is kept, run flags survive, and overlays follow the edit on every edition. change.Consequences says what an applied edit does to an edition's status and origin, and change.Diff turns two blocks into the operations between them. The applier note describes each rule.

The service​

change.NewService(formats, homes, options…) builds the service.

  • Read reads a page of a document's blocks. Each block carries the reference to copy into an operation, the revision to send as if_match, the content as placeholder text, its inline codes with their attributes and the attributes set_attribute can write, its plurals and selects with the path to each branch, its other editions with their status and staleness, each one's own plurals and selects and its codes where they differ from the block's, and the operations it accepts. A read of the file one edition lives in, such as the German file of an English page, shows that edition as each block's own, with the document's own edition among the others, so a reference copied from it edits the German. A page ends with a cursor; a cursor into a document that changed since is refused as stale. A page of a document a home keeps whole also lists the writes to it that did not land (Page.Divergent, from a change.DivergentSession), each with the blocks a rebase left contested. ReadEach reads every block of a document in one pass and hands each to a callback beside the block it was read from, for a command line that streams a whole document.
  • Apply applies a change set in two phases. It checks each operation's body as Decode does, since a sender in the process never passed the decoder, asks the policy about every operation, groups the operations by document, and opens each document in its home. It then prepares every document: the home reads it, the service's editor applies the operations addressed to each block, and the home stages the result. The service refuses an edition the staged file does not change, because the format has no place for it there, and runs the commit check over every changed edition. If any operation of any document is refused, nothing is written: the refused operations say why and every other one is not_applied. Otherwise the service takes the commit locks, settles each document, and checks again any document its home applied a second time, since that pass is the one that lands. It then commits each document and, with the locks still held, applies the decisions and asset operations, which bind to the content that landed, and records the change.
  • Describe says what a format supports. DescribeFormat hands FormatOps the operations the format's round trip carries (set_content in either form, replace_text, and remove_edition where the format holds its editions in one file, or keeps each in a file of its own and its writer removes a block there) and what its writer declares (E-02), insert_block and delete_block among it where the writer can add and remove blocks. For a document the declaration is the one its home reports for the document's writer (DocInfo.Capabilities), which ApplyBlock applies every operation with, less the structural operations its home cannot write there. WithDescriber replaces DescribeFormat, and that one function is what Describe reports, what a read lists per block, and what Apply refuses outside of.
  • History lists the recorded changes to one edition, most recent first, beside the revision the edition holds now. It reads the block from its home, so a reference resolves as a read resolves it, and asks the EditionHistories hook for the changes: each with its record, the revisions around it, the basis a derived edition was made from, who made it (null when nobody knows, as for an edit made outside kapi), through which surface, and when.

A document's edition lives in the document (its own edition, or one a bilingual file holds), in a file of its own (a project's target file), or nowhere (a monolingual document outside a project, or an edition with a tone or a channel in a bilingual file, which keeps one translation per language). An operation on an edition with no home is refused as unsupported.

Adding and removing blocks​

insert_block and delete_block change which blocks a document holds. A writer replays a skeleton with a slot for every block its reader read and none for a new one, so these two are written by the format: the service hands them to the home, and the format's writer writes the shell of each block added or removed. A writer declares the operations it writes and writes them through format.StructureEditor. Describe reports them where a writer declares them, and every other format refuses them as unsupported. The JSON, YAML and ARB writers declare both: in a key-value catalog a block's shell is its key and the value beside it. A writer's declaration can depend on its configuration, so describing a document reports the operations its home can write in that document (StructuralSession), and a read's per-block operations and Apply follow the same list.

  • delete_block addresses a block and carries if_match as a map from edition to revision. The map names the block's own edition, and may name any other edition its sender read. A map without the own edition is invalid; an edition named at another revision is stale, with the edition as it stands, and so is an edition the map names and the block lacks. The block goes with every edition it holds, its own and each translation in a file of its own, whether the map names it or not.
  • insert_block names the document, the new block's key (name), the key it goes after or before, and the content of each edition. The new block goes in the object or mapping its key path names, beside its anchor or, with neither, last there. The document's own edition is required, and any other must live in a file of its own. A key a block already answers to is stale, a neighbour no block answers to is not_found, and the content obeys the rules of every content operation: a new block has no inline codes to name, so text with a code placeholder is a guard refusal.

Operations apply in order, and each sees what the ones before it left: a block can go beside one an earlier operation added, and a block is replaced in its place by adding the new one beside it and then removing the old. An operation that changes the content of a block the change set adds or removes is refused as invalid; a new block's content travels in its editions.

A home writes the structural edits first and then applies the rest of the change set to what the writer wrote. A new block's content is therefore what the format reads back (an ICU argument in an ARB message reads as the protected code it is), and the result's after is the revision a later read reports. A new block the format reads differently from its content, or does not read at all, and a removed block it still reads, are refused as unsupported. The commit check sees each new edition as created, and the record holds every edition added or removed.

Homes​

A home holds the text of documents and decides when two writers conflict.

type Home interface {
Name() string
Open(ctx context.Context, doc string) (Session, error)
}

Name is the home as a result reports it: file, workspace or stream:<id>. A session reads the document at its head, stages a change by running the service's editor over its blocks, and hands back a staged change that names the commit locks it needs, settles (makes sure the change still applies with those locks held), commits and releases. A home that reads a document whole streams every block past the editor; one that keeps rows looks up only the blocks the change names. The service takes the locks of every document of a change set in one order, by their keys, so two change sets take the locks they share in that order and wait for each other without deadlock. A change set that names one file through two documents, such as two members of one archive or a file and a link to it, is refused as invalid, because each document would stage the whole file from what it read.

changetest.Run is the conformance suite every home passes: an edit lands and reads back, a replayed change set is stale and carries the current content while writing nothing, edits to different blocks commute, also when one lands between the other's stage and commit, a refusal in one document leaves every document as it was, whether it is found at the stage or at the commit, a preview writes nothing, a missing block is not found, the same content said again is unchanged, a file keeps its mode, a removed translation reads back absent while a replay of its removal is stale and writes nothing and a set_content creates it again, and a change set with no operation applies and writes nothing. Each home runs the removal on each kind of translation it holds: the stream's own rows; for the file home a PO catalog and a JSON file's translation in a file of its own; for the project homes the catalog a PO source's target template names (po/fr.po beside po/en.po) and the JSON file a JSON source's names; and for the workspace home the French of a PO catalog and of a JSON file, which it keeps while their files do not exist. Where the translation lives apart from its source, the suite also checks that the removal is written there and the source keeps its bytes.

The file home (core/change/filehome) keeps each document as a file. A stage reads the document through its format's reader with the writer's skeleton store wired, and writes the result through the same format's writer into a temporary file beside the document with the document's mode. Every file a stage reads is hashed before it is read. A commit takes the advisory lock (core/storage/filelock) of each file, hashes the files again, and renames the staged files onto them when each is still what the stage read. When one moved, the home reads them again under the locks, applies the change once more and renames that result; an operation whose if_match the new content breaks is refused as stale, and a file that moves during that second pass as well is doc_changed. Two processes that edit different blocks of one file both land. An edition kept in a file of its own is written through that file's skeleton, so the file is never rewritten from the document; a file that does not exist yet is written from the document's skeleton. A translation created for a block the file does not hold arrives, in a file in its own language, with a block of its own that the format's writer adds beside the block it sits by in the document, under the key the file gives its keys; in a bilingual file, or where the writer adds no block, the edit is refused. A change set that adds or removes blocks has the format's writer write those edits into the document's file and into the file of each edition the blocks hold or name, keeping them in memory until the commit, and the stage's pass reads the result. A stage that removes a translation a bilingual file holds, the document's own or the translation's file the target template names, reads its write back, and refuses the removal as unsupported when the translation is still there: the XLIFF and TMX writers write a translation of every unit, from the source where a block holds none, so a removal there would put the source in the translation's place. Their description lists remove_edition, which the stage then refuses. The read-back belongs to a change set's stage: a flow that writes a translation's file through the writer, such as kapi exec remove-target writing fr/a.xlf, gets what the writer writes, the source in the removed translation's place. A translation kept in a file in its own language is a block of that file, so a removal takes the block out of the file through the format's writer (format.StructureEditor), as delete_block takes a block out of an edition's file: the JSON, YAML and ARB writers remove a key with the comment or metadata beside it. A format whose writer removes no block, such as Markdown or HTML, does not list remove_edition, and a removal there is refused as unsupported. A home never deletes a file: a translation's file whose last block is removed keeps what the format writes for no block ({} for JSON). A removal or a creation the writer cannot make in a translation's file, such as a YAML sequence item's, refuses that operation alone, and the other operations of the change set are not_applied, blocked by it. Where the reader and the writer both stream and no block is added or removed, the document is never held whole. The home reports what the document's writer declares: an in-process writer's declaration, with the writer spelling a changed attribute or a new code itself (change.WriterCapabilities), or a plugin format's manifest declaration, whose writer spells them when it writes (change.DeclaredCapabilities). A home given a backup suffix copies each file a commit replaces beside it, under the lock, from the bytes the change was applied to. The implementation note The file home has the details.

The workspace home (core/workhome) keeps the editions that have no file yet: a held-back locale's drafts and the edits made to them (C-03). Such an edition belongs to a document whose own edition is a file, so the file home reaches it: the layout names the file the edition will be delivered to, and a keeper (filehome.Keeper) that holds it until then. A read joins the kept edition to the document's blocks by block key, with the status and origin the workspace home keeps beside its runs, and reports the workspace home as its home (change.Place.Home, Page.Home). A stage hands the editions the editor changed to the keeper, the commit lock is the lock of the file the edition will be delivered to, and the commit appends the change's record only while the edition's head in the log is still the one the stage read (workspace.Backend.RecordIf); a head that moved in between makes the stage read and apply again, as a moved file does, and a head that moves after the stage settled refuses the change set doc_changed with nothing landed. The record is the commit: change.RecordingStaged hands the staged change the record of its document before Commit, the result names the workspace home for that edition (StagedFile.Home), and the service's recorder records nothing more for it (StagedFile.Recorded). The conformance suite runs on the workspace home with the edition files of a held-back locale as its documents, and removes the French it keeps of a PO catalog and of a JSON file. A removal drops the kept block, for the formats whose description lists remove_edition, as it would leave the file the translation will be delivered to. The recipe picks the home of a translation whose file does not exist: under materialize: on-converge it is the workspace home, whether or not the workspace holds anything of it yet, and under manual it is the file, unless the workspace still keeps a draft of it. A project that declares redaction records a kept edition redacted and reads it back from the vault (C-03).

A stream home keeps the documents of one stream of a server's project as rows, one per block, each holding every edition of its block. Its commit lock is the rows a stage read, held on one database transaction for every document of the change set; a settle applies the operations again to rows that moved, and the commit stores them with their history and commits the transaction, so the change set lands whole or not at all. A result names it stream:<id>.

While a write that did not land still names a kept block (a conflict two machines' logs left), the file home's session answers Contested for it (change.ContestedSession). A person's or an agent's operation that leaves that block as it stands is then reported unchanged and still recorded, as the decision to keep the wording the head holds, so every way of deciding a conflict is a change set with if_match like any other.

The workspace home also serves whole documents (workhome.Documents): a native document opened for editing that has no file, kept as a blob in the log with its head in document_head. A KPZ's sources are its documents, named work.kpz!guide.md; the kapi host routes such a reference to the KPZ's workspace home, so kapi apply and the MCP edit tools edit a KPZ as they edit a file. A session reads the head into a private working copy that the file home stages against; Settle reads the head again when it moved and puts it in the copy, where the file home applies the change once more; and Commit records the copy's bytes with the head the settle found as its expected head. Edits to different blocks of one document commute, and a document a KPZ carries as a skeleton alone holds no text to edit and is refused unsupported. The conformance suite runs on it with two JSON documents and a bilingual catalog.

A whole document diverges when a write to it was made from a version that has stopped being its head. Two machines that each edited one document from one head leave one write as the head and the other beside it once their logs meet. The kapi host records a third case the same way: a .kpz replaced on disk while its cache holds edits nobody packed. Each edited document the new file carries with other bytes becomes a write made from the version the cache opened. A read reports each such write in Page.Divergent. A person or an agent then settles it in one of two ways. Documents.Rebase reads the document at its head and sends the write's changes through the change service, block by block and edition by edition (change.Diff between the version the write began from and the one it left). Each operation is guarded by the revision its block had where the write began, so it lands only on a block the head left alone. A block the write added is an insert_block anchored before the next block, or after the previous one, that the write's base and the head both hold, so blocks added in a run keep their order. A block the write removed is a delete_block guarded by the revision of every edition it had at the base. A block stays contested when the head changed it too, or when the structure around it conflicts: the format adds or removes no block, the head holds neither neighbour of an added block or a block of the same key with other content, or the block holds a translation an insert does not write. A write naming the divergent write as its cause then records the contested blocks, or settles the write when none is left. Documents.Discard records that cause with nothing contested and keeps the head. Each contested block is decided as an edition's conflict is: a change set guarded by the head's revision, where keeping the head's wording is recorded as the decision (change.ContestedSession). kapi status lists each divergent write with the kapi resolve command line that settles it, and Kapi Desktop offers both choices on the project's conflicts card and, for every .kpz on the machine, on the workspace home (S-02).

Flows​

A flow writes a document whole: its writer renders every block the run passed through, into the file it reads or into a target-language file built from the source's skeleton. The file home commits that document too (filehome.Home.Produce): the run digests the destination before it reads, the writer's output is staged beside the destination, and the commit renames it under the destination's lock only while the destination still holds what the run began from (E-01). Every flow the kapi host runs commits this way: kapi translate, pseudo-translate, run, exec and up, the same runs in Kapi Desktop and over MCP, a pull's target files, and the delivery of a convergence pass's drafts. The host's flow home takes its locks where the project's change service takes them, so a flow and a change set on one file take turns. A gated run that holds a locale back writes that locale's drafts into the workspace home instead (workhome.Home.Produce): one write per document, each draft guarded by the revision the run first read of it, with its basis and the stamp its producer serves it again by; a block another writer changed meanwhile, or one a person or an agent wrote from the same source, keeps that writer's edition. A run that delivers a locale replaces the drafts the workspace kept of it from an earlier run the same way first. A delivery (kapi merge, or kapi up for a locale that cleared its gate) writes each edition's file from what the workspace home keeps, each draft with the basis it was made from, and then releases what it read there (workhome.Home.Release), only while the edition's head is still the one it read.

Inside a project the host records each document a flow wrote as one content.edit, through the recorder a change set's commit records through, with the actor tool:<flow> and the origin flow:<flow>. kapi exec takes the project the command resolves, as kapi apply does, for a file the project holds. The record keeps revisions and hashes, no text. Its transitions are the run's effect on the file as the service reads it: the document is read through the service before the run and again after the commit (a commit that finds the file holding the run's bytes already, as it did when the run read it, reuses the first read), and each edition whose revision moved is a transition, with the stamp the producing tool left as its producer, the kind of operation the change comes to (change.EditionKind, as change.Diff would send it: set_content for an edition created or rewritten, replace_text for text moved around the same codes, remove_edition), and the tool that made it (the tool named in its stamp, or the run's only tool, leaving out the counter a convergence pass adds). The read before the run is an ordinary read, so it records an edit made outside kapi since the last recorded change as observed before the run's own record; the read after the commit is left unobserved, because the run records that change itself. A translation's basis is the source the run read before it ran, with that source's content hash: the commit guards the file the run writes, so a source edited while the run worked is drift against the basis rather than the basis. A translation the run reproduced unchanged is recorded too, once, when the block history does not already say a flow wrote it from the source the block holds, and not over a person's or an agent's write of the same wording, which stays theirs. So the history keeps the basis of every translation the loop made. That basis is what coverage grades an undecided translation by, what a decision on it starts from, and where the staleness gate finds what governed it. The decision ledger holds decisions only, so the block history is the only place that basis is kept.

A destination that moved while the run worked is applied again through the service from the run's operations (set_content on each edition the run changed, guarded by the revision read before the run, followed by the provenance the producing tool left). The service reads the file under its lock, and the document lands when no edition the run changed has moved too, and is refused as a whole otherwise: when one has moved (stale), or when the run wrote a block the file does not hold yet. The run then reports the document as moved, and the next run writes it. A target-language edition the run left without a translation is rendered from the source by the writer, and is left as the file holds it when the run's changes are applied again.

Under --print-ops the run commits through a home that writes nothing (filehome.Options.WriteNothing) and records nothing. What it changed in each document becomes operations, the difference between each block as the reader gave it and as the writer received it (change.Diff), each guarded by the revision the service read before the run and each set_content of a translation carrying its basis and, as each operation on a translation does, the origin the producing tool left. Before a document's operations join the change set, they are applied to a private copy of the file through the service kapi apply reaches, and they are printed only when the copy then holds the bytes the run would have written. A run writes a target-language file whole from its source, while the service edits the file as it stands, so the two differ when the file holds an entry or an order of its own, or when the source gained a block the file's writer adds nowhere, or beside another neighbour than the run gives it; such a file is named on standard error and left out. So is a file the run would write where the service does not keep the edition (an output path given on the command line in place of the recipe's target), a conversion, an export and an archive. kapi apply of the change set writes the bytes the run would have written (under materialize: on-converge, a translation whose file does not exist yet lands in the workspace home, its home until a delivery, and kapi merge writes those bytes), and records the edit as its applier's: a person's or an agent's edit is theirs, so the origin the operations state is not kept on the translation, and kapi apply prints one line naming the tools and saying so. A run with nothing to change prints a change set with no operation, which applies and writes nothing.

A printing kapi up runs one pass. A gated pass drafts into its private tree as any pass does (Options.WriteUnder lets the printing home write there), the gate evaluates the drafts, and delivery prints what it would commit for each locale that clears its gate; a held-back locale is named and prints nothing. A printing run records no change, absorbs nothing into the content memory and stamps nothing. As any pass does, it extracts the source into the derived store and caches under .kapi/work, and records the identity of a document it reads for the first time (document.adopt). In a project a kapi translate, pseudo-translate or run without -o writes no file, and prints nothing with a note.

Hooks​

The service calls six hooks a host supplies. Each is optional.

HookCalledWithout one
Policyfor every operation, before anything is readevery operation is permitted
CommitCheckover the changed editions, before anything is written; it returns findings before and after, and the governance fingerprint it usednothing is checked
Assetsto prepare decide, term, memory and recipe before anything is written, and to apply them after the content landedthose operations are refused as unsupported
Recorderafter the homes committed, with the transitions and the fingerprintnothing is recorded
EditionStatesonce by each read of a document, with the most blocks the read shows, for the status and basis of its derived editionsa read shows the status the document holds and no basis
EditionHistoriesby History, for the recorded changes to an editiona history lists nothing

The service refuses a change only for a failing finding it introduces (change.Introduced). Under report the change lands with its findings, and a person's overridden findings go to the record.

The kapi host's policy (ChangePolicy) lets a person send every operation. An agent may change content, and is refused as not_permitted when it sends a term, memory or recipe operation, a decide with any outcome but advise, an if_match of *, or the report gate. A tool in a flow may choose report and decides only with advise, and only a tool sends the in-process provenance operation. A sender of any other kind is refused every operation.

The kapi host's recorder writes each change set into the workspace's log, one content.edit operation per document, in one write, and the projector writes it into the block history: who changed which edition, from which revision to which, through which surface (the origin), and under which governance fingerprint. A person's or an agent's record keeps the runs around each change and the change set as sent; a tool's keeps revisions and hashes, and the file holds the text. A document the workspace home holds is recorded by that home's commit, and the recorder records the rest of the change set. A project that declares redaction records the runs and the note redacted, or neither under a policy that detects entities, and keeps no copy of the change set as sent (C-10).

The kapi host builds the service for a surface with App.Changes and App.ChangeService. Inside a project the file home's layout resolves a reference the way the recipe does: a source file is read with the format and configuration its content item binds, the file of a translation is that edition of its source, joined by key, then by translation-invariant address, then by position, and a translation with no file yet is written from the source's skeleton. Outside a project a reference is a path under the working directory, read with the format detection finds by name, then by content. A file of a translation interchange format, or of a multilingual string catalog (an Xcode .xcstrings file), holds its editions in the file, unless the recipe writes the source's translations to files of their own: with a target po/{lang}.po, the French of po/en.po or of po/messages.pot is read from and written to po/fr.po, in French, and the source catalog holds no edition, for every surface. Decisions and asset operations land through the host's review-queue and asset functions, a decision bound to the wording the change set landed rather than to a later read of the file; on an edition with no content in its home, such as a held-back locale's draft, the decision binds to the draft the project store holds. Such a decision names the edition it read as absent, and is refused as stale once the home holds the edition. The hooks each plug in at one function of the host: the commit check is App.CommitCheck, which holds a service outside a project to hygiene alone; the policy is ChangePolicy; the recorder is App.EditRecorder, inside a project; a read takes the basis of a derived edition that carries no derivation from the project's block history, where the most recent recorded change to the edition left the content it holds; and a history lists the edition's rows of that block history. A decision binds to the revision of the edition it decides and of the authoritative edition beside it (DecisionTarget.Rev and SourceRev), which the decision ledger pairs (C-04).

On the command line, kapi apply hands a decoded change set to the service, kapi inspect prints the service's read records, and ksed compiles its substitutions into replace_text operations and applies them through it (S-01). All three build the service once per run: for the project the command names or discovery finds, for the files under its root, and over the working directory otherwise, where a reference may lead out of it as a path on a command line does. Inside a project a file outside the root is named by its absolute path, and kapi apply edits a change set of such files over the working directory. The hooks of a service outside a project are given a command that resolves no project, so discovery never puts an edit made outside one under a project's governance. Only ksed reads a file no format claims as plain text. A command that prints an edited document (ksed without -i) applies the change to a private copy, located and read exactly as the file itself, and writes the copy out. A read takes no lock and leaves a project as it was: the lock directory, and the ignore rule that keeps it out of a commit, are written when a change first commits, and the recorder opens the project store then, before anything is written. A read takes a derived edition's basis from a project store that exists and creates none. A preview takes no lock and records nothing, and its commit check reads the project store when the project has one and creates none. When the project has no store yet, a preview checks an edit without the voice and the terms a store binds from the workspace.

A set_content on a code comment, in a source file only the comment layer reads, goes through the comment write path, which keeps its own guarantees: the file is read again before it is written, the result must parse, and the language's formatter must agree. A file is read for its comments when no format reads it: none that --format names, the recipe binds, or detection finds by extension or content, as a Qt Linguist catalog is found in a .ts file. What was written is checked, scoped to the change, and under the enforcing gate a failing finding, or a check that read nothing, exits 3. The comment path runs neither the commit check nor the recorder: the check runs after the write, and nothing records a comment edit.

The MCP tools read_blocks, apply_edits and describe_format build the service for each call's project and send every change set as the calling agent (S-03). The browser engine's kapiRead, kapiApply and kapiDescribe build it through the same function and carry the contract as JSON in and out: a refusal, a change set that does not decode included, is answered as a result. A call of either surface reads a bilingual file that holds its translation, such as a PO catalog, in the one language other than the source that its read's editions or its operations name, as kapi apply does. The page names the sender, a person unless it says an agent, and in a project an applied change is recorded in the browser's workspace log (WASM Engine ABI).

The verbs that write whole translations build the service with Materialize set, so the file home writes each translation's file from its source's skeleton, keeping what each block's partner in the file held where the change set leaves it, and a translation follows the source's structure, even where the change set changes no content in a file whose blocks have stopped pairing with the source's. A bilingual translation file that still holds every unit of its source keeps its own skeleton, header included. kapi merge -i compiles a returned XLIFF, PO or .kpz into set_content operations carrying the if_match and basis each entry was extracted against, under require_basis and the enforce gate, and sends them as a person; kapi extract stamps each entry with the revision of the source it carries and the translation's revision a read through the service gives (M-01). kapi merge with no -i sends the targets the block store holds for a source and language as one change set from the tool merge, and kapi pull sends the runs the server holds for each pulled translation as the tool pull, each if_match the revision the read before it found. A pull hands the writer of a document with locale-variant media through ChangeServiceOptions.WriterHook, and writes a target in another format than its source, which no edition reaches, with the target's own writer. The record names the surface as the origin: merge or pull.

Kapi Desktop reaches the service through four bindings, Read, Apply, Describe and History, each taking and returning the contract's JSON as a string. It builds the service for the tab's project with desktop as the origin and sends every change set as the person at the keyboard: an edit in the review pane or the document view, a check finding's fix, and Approve and Reject as decide (S-02).

kapi check gives a finding whose rule names a replacement the operation that applies it (check.Fix): a replace_text of the words the finding objects to, by the run range the checker reported, under the revision of the block's own edition the check read. Sent as it is, it lands while the block still says what the check read, and is refused as stale once it does not. The fixes of one block name places in the same text, so they compose as the edits of one operation; as separate operations of one change set, each would see the text the one before it left. A replacement is plain text, and ApplyTextEdits keeps a code only at either end of the text it replaces, so a finding whose words have an inline code among them gets no fix. The fix names its document as the project's change service resolves one, by its path from the project root, and the file of a translation gets no fix: the service reads that file as the translation's edition, while the check read it as source and held it to the source language's rules.

Results and errors​

A result has a status (applied, refused, previewed, or partial when an I/O error stopped the final renames after some files landed, or a store refused a decision or an asset operation after the content landed), a record id, one entry per file written or read with its home, its digests before and after and whether it was written, and one result per operation. An operation's status is applied, unchanged (its if_match holds and the edition already says what it sends), refused, not_applied (blocked by another operation's refusal) or previewed; it carries its revisions, the positions it resolved, the derived editions it left on an older basis (invalidates), and on a refusal an error from a closed set of codes, each mapped once to an exit code and an HTTP status (Code.ExitCode, Code.HTTPStatus):

CodeMeansExitHTTP
invalidthe change set does not decode, fails its schema or contradicts itself2400
not_founda reference resolves to no document, block or edition, or a find matches nothing3404
ambiguousa key or a find matches more than one thing3409
stalean if_match fails to hold, or under require_basis the basis moved3409
doc_changedthe document changed under the commit and one more pass could not settle it3409
guardthe result breaks an inline code, a plural or select, or a position (subcode codes_changed, structure_lost, bad_position or overlap)3422
gate_failedthe commit check found a failing finding the change introduces3422
unsupportedthe format, the edition or the home lacks the capability the operation needs3422
not_permittedthe policy refuses the operation to its sender3403
budget_exceededa resource bound was reached3413
unreachablea backend did not answer5503

A refused operation reports no revision or position, since nothing was written; a change set refused after its documents were read lists each with written: false. A resolved position prints its run and its offset, a zero offset included. Each document carries the findings the edit introduced, failing or not (change.NewFindings), each with the range it found where the rule locates one and, for a term rule, the wording it asks for: an empty list when the check ran and found nothing, and none where no check applies. A gate_failed refusal counts the failing findings and leaves them on the document. A find that matches nothing is refused not_found with the path and the text it searched and up to three candidates. In a partial result the operations on the files that were not written are not_applied, and so are the decisions and asset operations, which wait for content that all landed; the record holds what did. A stale refusal carries the edition as it stands, so the sender can rebase without another read. A resource bound core/safeio reports is budget_exceeded. A change set refused before any operation is considered, because it does not decode or the request carrying it is refused, is answered with the same result shape (change.ErrorResult): status refused, no record, no files or operations, and the error. kapi apply --json prints that result for a change set that does not decode or contradicts itself, and exits 2, as MCP and the browser answer one.

Consequences​

  • A sender always learns whether its change landed and, when it did not, which of a few things to do next: retarget, re-read and resend, fix the content, or ask a person.
  • Concurrent kapi writers of one file lose nothing: each file a change reads is hashed before the read and again under the lock. The lock orders kapi's own processes, and in the browser, which runs one, the calls of the page; an editor that saves between the re-hash and the rename remains a conflict the next read sees.
  • An operation reported applied reached the file. One the format has no place for in that file is refused.
  • Bytes kapi could not read as UTF-8, in a file in another encoding, are never overwritten with the U+FFFD a read shows for them: an edit that would write it there is refused as unsupported, and edits elsewhere in the file keep them.
  • A change set is all or nothing across documents up to the final renames. Records follow the commit, so a record that fails to write leaves content that the next read finds and records as observed.
  • A surface that adds a write path adds a home or a hook, and the contract, the applier and the rules stay one.