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.
| Field | Meaning |
|---|---|
schema | kapi.change/v1 |
mode | apply (the default) or preview, which computes and checks everything, writes nothing, and returns a diff per document |
gate | enforce (the default) or report: what a failing governance finding the change introduces does |
require_basis | refuse a write to a derived edition whose authoritative edition moved since the sender read it |
note, evidence | what 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 |
ops | the 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.
Readreads a page of a document's blocks. Each block carries the reference to copy into an operation, the revision to send asif_match, the content as placeholder text, its inline codes with their attributes and the attributesset_attributecan 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 asstale. A page of a document a home keeps whole also lists the writes to it that did not land (Page.Divergent, from achange.DivergentSession), each with the blocks a rebase left contested.ReadEachreads 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.Applyapplies a change set in two phases. It checks each operation's body asDecodedoes, 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 isnot_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.Describesays what a format supports.DescribeFormathandsFormatOpsthe operations the format's round trip carries (set_contentin either form,replace_text, andremove_editionwhere 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_blockanddelete_blockamong 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), whichApplyBlockapplies every operation with, less the structural operations its home cannot write there.WithDescriberreplacesDescribeFormat, and that one function is whatDescribereports, what a read lists per block, and whatApplyrefuses outside of.Historylists 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 theEditionHistorieshook 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_blockaddresses a block and carriesif_matchas 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 isinvalid; an edition named at another revision isstale, 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_blocknames the document, the new block's key (name), the key it goesafterorbefore, 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 isstale, a neighbour no block answers to isnot_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 aguardrefusal.
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.
| Hook | Called | Without one |
|---|---|---|
Policy | for every operation, before anything is read | every operation is permitted |
CommitCheck | over the changed editions, before anything is written; it returns findings before and after, and the governance fingerprint it used | nothing is checked |
Assets | to prepare decide, term, memory and recipe before anything is written, and to apply them after the content landed | those operations are refused as unsupported |
Recorder | after the homes committed, with the transitions and the fingerprint | nothing is recorded |
EditionStates | once by each read of a document, with the most blocks the read shows, for the status and basis of its derived editions | a read shows the status the document holds and no basis |
EditionHistories | by History, for the recorded changes to an edition | a 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):
| Code | Means | Exit | HTTP |
|---|---|---|---|
invalid | the change set does not decode, fails its schema or contradicts itself | 2 | 400 |
not_found | a reference resolves to no document, block or edition, or a find matches nothing | 3 | 404 |
ambiguous | a key or a find matches more than one thing | 3 | 409 |
stale | an if_match fails to hold, or under require_basis the basis moved | 3 | 409 |
doc_changed | the document changed under the commit and one more pass could not settle it | 3 | 409 |
guard | the result breaks an inline code, a plural or select, or a position (subcode codes_changed, structure_lost, bad_position or overlap) | 3 | 422 |
gate_failed | the commit check found a failing finding the change introduces | 3 | 422 |
unsupported | the format, the edition or the home lacks the capability the operation needs | 3 | 422 |
not_permitted | the policy refuses the operation to its sender | 3 | 403 |
budget_exceeded | a resource bound was reached | 3 | 413 |
unreachable | a backend did not answer | 5 | 503 |
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
appliedreached 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.