JsonUI
GuidesBranch contractsA screen spec can declare each method's branches as a machine-checkable table. The optional `branchContracts` section keeps those declarations inside a closed vocabulary of names the spec has already declared — so `jsonui-doc validate` can check them, the generated documentation renders them as decision tables, and `jsonui-test` can generate unit tests from them. Available since jsonui-cli 1.6.18; existing specs without the section are untouched.8 min read
1. What branch contracts areBehavioral prose in a spec — "if the form is invalid, show an error and skip the API call" — is exactly the part no tool has been able to check, and it drifts silently as the implementation evolves. branchContracts turns that prose into data: per-method tables of when (the state going in) and then (the observable outcome), where every name must come from vocabulary the spec already declares. Data fields come from stateManagement.uiVariables / dataFlow.viewModel.vars / stateManagement.states, method names from dataFlow.viewModel.methods or stateManagement.eventHandlers, API operations from dataFlow repositories/useCases, transition destinations from transitions[]. The section is fully opt-in — leave it out and validate and doc generation behave exactly as before. Branches that genuinely cannot be said in the closed vocabulary are declared as note entries: they stay legal, but the generated docs count them separately, so a contract that is mostly notes is visible at a glance. That count is worth reading as a measurement, not just an escape hatch — when the same shape of branch keeps escaping into notes across screens, the vocabulary is missing something. The `@response.<path>` form below exists because a census of escaped branches found that shape leading the list. Writing the contract also tends to surface rot in the prose around it — a method name left behind by a rename, a description that stopped matching the code — because it is the first time anyone has had to state those facts precisely enough for a machine to check. The same effect crosses platforms: once one face has its correctness written down, the same mistake becomes recognizable in the other face, where nothing was checking for it. That is worth acting on rather than noticing — what a contract pins is a property, not one implementation of it, so every place that makes the same judgement is a candidate. One team wrote a contract for a single rule ("when the list counts its rows, does it count the deselected one?"), then went looking for the rule elsewhere and found it written out four separate times, wrong in two of them — and the two wrong ones sat diagonally, one platform in one project and the other platform in the other. From either face alone it reads as one implementation's slip; across both it is one judgement duplicated four times. So when a contract is written, do not stop at the branch: sweep for the same judgement.
2. A complete exampleOne method, four branches: the guard fails, the save succeeds, the save fails, and one note. `@profile_screen_invalid` is a strings.json key reference, `@data.form` copies another data field, and `api.updateProfile.request` asserts a partial request body. The when value for `api.<op>` names a mock scenario such as "success" or "failure" — validate does not check the name, but test generation binds it to your mock files and fails hard if it does not exist — and since jsonui-cli 1.7.54 the generated test also asserts the route was actually hit, unless your `then` already speaks about that surface (see the Branch tests guide).
ProfileScreen.spec.json — branchContracts
"branchContracts": {
"conditions": {
"formValid": {
"meaning": "every required field is filled",
"witness_true": { "form": { "nickname": "a" } },
"witness_false": { "form": { "nickname": "" } }
}
},
"methods": {
"onTapSave": {
"baseline": { "isSaving": false },
"branches": [
{ "when": { "cond": "!formValid" },
"then": { "data.errorMessage": "@profile_screen_invalid", "api": "none" } },
{ "when": { "cond": "formValid", "api.updateProfile": "success" },
"then": { "transition": "HomeScreen",
"api.updateProfile.request": { "nickname": "@data.form" } } },
{ "when": { "cond": "formValid", "api.updateProfile": "failure" },
"then": { "data.errorMessage": "@profile_screen_failed",
"api.updateProfile": "called" } },
{ "note": "double-tap guard: second tap while isSaving is dropped" }
]
}
}
}
3. Named conditions and witnessesA cond is a named predicate declared in `branchContracts.conditions`. Deliberately, the expression body is NOT declared — it would only mirror the code and drift with it. Instead each condition carries a human `meaning` plus `witness_true` / `witness_false`: example data states that make it true and false. Witness keys are checked against declared data fields, and a witness gives any consumer of the contract a concrete state to replay — test generation arranges each branch from exactly these witnesses. Reference a condition from when as `"cond": "formValid"`, or negated as `"cond": "!formValid"` — negation is first-class, so no mirror-image condition is needed.What validate can and cannot check here (since jsonui-cli 1.6.26) — the honest limit first: the predicate itself exists only as `meaning` prose, so no tool can prove that a witness really makes the condition true; that self-test was considered and rejected rather than half-promised. What can be judged without knowing the predicate are three degenerate states, all warnings: a condition no branch gates on (its witnesses are never exercised — the same class as an unused strings key), a branch using a polarity whose witness is absent (`!cond` without witness_false — test generation already stops there with a hard error, validate now says it first, and only the polarities actually used are required), and witness_true equal to witness_false (the two sides cannot be told apart). An unknown cond reference stays an error, and no second finding is stacked on the same line. When one of these fires on an existing spec, the right fix is usually the asset, not the check — the first thing this check ever caught was one of the tool's own test fixtures, and the fixture was corrected rather than the warning suppressed:
condition usage warnings — one seeded mistake each
[WARNING] branchContracts.conditions.hasStock: Condition is declared but no branch
gates on it — its witnesses are never exercised
[WARNING] ...branches[0].when.cond: Condition 'formValid' has no witness_false, so this
branch cannot be arranged — test generation fails on it
[WARNING] branchContracts.conditions.formValid: witness_true and witness_false arrange
the same state, so they cannot tell the condition's two sides apart
4. The when vocabularyFour key shapes, nothing else — anything outside this closed set is an error, not a guess. Multiple keys in one when are combined with AND. A `data.<field>` value here is a scalar literal — string, number, boolean, null; swapping in a whole object is rejected ("when data.* value must be a scalar literal (string/number/bool/null), got dict"), because a wholesale state substitution is what a named condition's witness is for. `arg.<name>` binds to `dataFlow.viewModel.methods[].params` and nowhere else: eventHandlers have no params in the schema because they belong to the View layer, while the ViewModel's public API is what dataFlow declares — so a contract that fixes an argument requires the method to be part of that public API. Since jsonui-cli 1.6.30 an `arg` with no matching param is an error (test generation stops too); before that the declaration was silently dropped and the test ran with no arguments at all.
when keys
key | value | means
--------------+--------------------------------+-------------------------------------------------
data.<field> | literal | the declared data field holds this value
arg.<name> | literal | the method argument holds this value
api.<op> | named mock scenario | the API operation answers with this scenario
cond | condition name ('!' prefix ok) | a named condition from branchContracts.conditions
5. The then vocabularyOutcomes are equally closed. A data.<field> value is a literal, a `@strings_key` reference, or `@data.<field>` (copy another field). A data.<field> value may also be `null`, asserting that the field returns to unset — an empty string is a different state. This is the standard way to contract a screen with optimistic updates — change the UI first, the API fails, roll back: the branch that stops "the mark appeared locally but the server rejected it" asserts the optimistic field back to null on the failure scenario. Any screen with optimistic updates has this branch; since jsonui-cli 1.6.27 the harness needs nothing special for it (an Optional can be returned as-is). `"api": "none"` asserts that no operation fires at all; `api.<op>` asserts one operation's verdict; `api.<op>.request` matches the request body partially — only the listed entries are checked, extra entries are ignored. The arrange side gained a surface in jsonui-cli 1.7.29: `branchContracts.seedableState` names ViewModel-internal state — `{"canRead": "Bool?"}` — so a branch gated on private state can be set up from the contract with `when: {"state.canRead": false}` instead of not being expressible at all. Naming it in the spec is the point: letting `when` reach an arbitrary property path would bind the contract to the implementation's private vocabulary, and a rename would make the arrange step quietly stop arranging. An undeclared `state.` name is an error, and that is a deliberate difference from the data surface — measured in one spec carrying both, an undeclared `state.canRead` errors while an undeclared `data.nowhereDeclared` only warns. The asymmetry has a reason worth knowing: a data field the spec does not list may still exist on a platform this spec does not describe, whereas internal state is arranged by the generated test itself, so an undeclared name means nothing is seeded and the branch runs against whatever state it started in — green for a reason that has nothing to do with the implementation. On 1.7.28 the same spec is rejected outright, `Unknown branchContracts key`. Since jsonui-cli 1.7.24 one non-scalar value joins that list on a `data.<field>` outcome: the empty list `[]`, asserting that the collection ends up empty. It is there because the alternative was contracting a scalar that moves alongside the list — a pager hiding, a spinner going away — and such a branch stays green when only the clearing is removed, which is the regression the contract was written for. Only the empty list is accepted: a non-empty one is refused with a message that names what would be accepted and says why element-by-element matching is out of scope, since it would bind the contract to the mock body. The exception stops at that leaf. A request leaf takes a list too, from jsonui-cli 1.8.48, and it means something different from the `[]` above: the whole array, order included. Measured on the shipped runtime — a reversed array is a mismatch at index 0, while a view model holding a `Set` where the contract writes a list is compared by membership and passes in any order, on all three faces. Until 1.8.48 a request leaf took no list at all, and the reason given then was two reasons: that matching elements binds the contract to the mock body, and that `[]` under a request leaf had no defined partial-match meaning. The first is still true and still the rule on the `data.*` side, where the list being compared is what the mock handed back — a request list is what your app SENT, so it is the thing under test rather than the fixture. The second was a gap rather than a property, and closing it is what 1.8.48 did. What stays refused inside a list is a reference: `@data.<field>` as an element is rejected, because the generator resolves references only directly under a key and inside a list all three faces emit the literal string `"@data.form"` — a contract that reads like a reference and compares like text. Non-scalar elements are rejected for the same reason they are elsewhere. Measured on one spec carrying both leaves in the same branch: the data field passes, the request entry errors. The same spec is an error on 1.7.23. Since jsonui-cli 1.8.20 a `seedableState` value may be an object or a list as well as a scalar, and the read-back is a partial match on every platform — the seed's keys only, nested, arrays element-wise, and from 1.8.48 unordered collections by membership. The two are not interchangeable: an array is compared by index, so a reversed one is a mismatch, while a `Set` (Swift `Set`/`NSSet`, Kotlin `Set`, JS `Set`) matches in any order. Which one you get is decided by the implementation, not by the contract: a declared type like `[String]` says nothing about whether order is part of the value, so one face may implement it as a set and another as a list from the same declaration — and then the same contract is judged differently on each, because order counts only where the implementation kept it. If order matters to the branch, the contract cannot enforce that on its own. One harness-side consequence, measured upstream on iOS: when the seeded value is a `let` init argument the harness rebuilds the view model to apply it, so data keys the same arrange step wrote before the seed live on the old instance and must be replayed by the harness (or the seed applied first) — the read-back only tells you the seed took.
then keys
key | value | asserts
-----------------+------------------------------------------+--------------------------------------------
data.<field> | literal / "@strings_key" / "@data.<field>" | the field ends up holding this value
transition | destination name | the screen navigates there
api | "none" | no API operation is called at all
api.<op> | "called" or "not-called" | whether that operation fires
api.<op>.request | partial object | the request body contains these entries
Passthrough: `@response.<path>` (since jsonui-cli 1.6.29) — when the value on screen is one the server chose, a message an API hands back verbatim, neither of the other forms fits: a literal would be your wording, not the server's, and `@key` needs an entry in your table. Such branches used to escape into a prose note. They no longer have to, because a branch already names its own scenario, which means the expected value is known at generation time: the generator reads it out of the mock's response body and emits it as a literal — nothing reaches the runtime, and all three platforms get it through the same path. The precondition is that the branch names exactly one `api.<op>` scenario to read from; validate warns first when it names none or several, and generation stops with a hard error there, when the path is absent from the response body (the available keys are listed), or when the value is an object or array — a displayed value is a scalar. The boundary of this form is the mock boundary: a value the client produces — an SDK's own message, a library's exception text — is not determined by the scenario, and contracting it is a decided no. The only claim available would be "non-empty", which stays green even when an unlocalized library string is what reaches the screen; a contract that blesses that state is worse than an acknowledged gap, so such branches stay notes.
@key / @data.<field> / @response.<path>
// the three @ forms, side by side
"data.title": "@profile_screen_saved" // a string from your table
"data.previousName": "@data.form" // a field's value before the act
"data.errorMessage": "@response.error.message" // what the server returned
 
// a passthrough branch names exactly one scenario:
{ "when": { "api.createOrder": "declined" },
"then": { "data.errorMessage": "@response.error.message" } }
generation stops, and validate warns first
Error: ...branches[2]: '@response.<path>' needs exactly one `api.<op>` in `when` to read
the response from, found 0
Error: ...then.data.errorMessage: scenario 'declined' has no 'error.detail' in its response
body — at error the available keys are: code, message
Error: ...then.data.errorMessage: 'error' in scenario 'declined' is a dict, and a displayed
value must be a scalar
 
[WARNING] ...branches[2].then: '@response.<path>' reads the response of the branch's own
scenario, but `when` names 0 `api.<op>` scenario(s) — test generation needs
exactly one
6. Platform-scoped branchesA branch may carry `platforms` alongside when/then — a non-empty subset of ios / android / web (since jsonui-cli 1.6.22) — declaring that this outcome exists only on those platforms. Real implementations do diverge: one platform may surface an alert field of its own where the others share one, and scoping the branch keeps the divergence declared instead of papered over. validate rejects anything else with "platforms must be a non-empty array of ['ios', 'android', 'web']"; test generators skip out-of-scope branches with an explicit comment and count (see the Branch tests guide), and since 1.6.24 the rendered decision table shows the scope in its own Platforms column.
a branch that exists only on iOS
{ "platforms": ["ios"],
"when": { "cond": "formValid", "api.updateProfile": "failure" },
"then": { "data.errorMessage": "@profile_screen_failed" } }
7. What validate enforces`jsonui-doc validate spec` (also reachable through the doc MCP tools) draws a deliberate line between errors and warnings. Every message below is real validator output, each produced by seeding one mistake into the example spec:Errors — structural violations: an unknown when/then key, a cond referencing an undeclared condition, a note entry carrying when/then alongside, a method name the spec never declares, and type violations. These fail validation.Warnings — name-existence findings: a data field, API operation, or transition destination the spec never declares. Warning-level on purpose: VM-internal state and operation ids may legitimately stay undeclared.Ambiguity — the one API-operation finding that is an error, and it is the opposite case to the warning above: the name is declared twice, not never. `api.updateProfile` when two owners both declare `updateProfile` fails validation naming both, because the bare name does not say which route the generator should bind. Since jsonui-cli 1.8.49 only an owner that declares a bindable endpoint counts as a candidate: a second owner whose method carries `endpoint: null`, a free-text signature, or a path with no verb gives the generator nothing to bind and no longer makes the name ambiguous. Read `bindable` literally — the test is a SHAPE, not a protocol: an uppercase word, whitespace, then a token with no whitespace. A non-HTTP route written in that shape, `"RTDB onValue(rooms/{id})"`, parses as method `RTDB` and lands on the same shelf as `GET /x`, so it too can make a name ambiguous. This page said `an HTTP endpoint` from 2026-09-08 until jsonui-cli 1.8.50, which is narrower than the predicate ever was; the wording came from the internal name `_declares_http_endpoint`, and 1.8.50 renamed it `_declares_bindable_endpoint` for exactly that reason. Worth keeping as a caution about reading any tool's source: a function name is the author's summary of a predicate, not the predicate. Measured across the two pins on one spec carrying the page's own vocabulary: with the second owner's endpoint set to null, 1.8.48 reports 1 error and 1.8.49 reports 0, while a second owner that does name a route still errors on both — the check narrowed, it was not removed. The narrowing also changed who the advice names: with a third, endpointless owner present, 1.8.48 listed all three, so the qualification it told you to write routed to nothing. Worth knowing about the collateral too — dropping the twin from the ambiguity count does not drop the bare name from the declared set, so the error is not quietly traded for a `not declared in dataFlow` warning; measured at 0 warnings on that path. Before 1.8.49 the validator counted every declaration while the test generator had always counted only endpoint-bearing ones, so the two tools answered the same spec differently and the error's own stated reason did not hold for the shapes it fired on.Promoting a warning into a declaration — when you resolve an undeclared-field warning by adding the field to dataFlow.viewModel.vars, match `observable` to what the implementation actually is. `observable: true` (the default) makes the generated protocol demand the reactive form — @Published on iOS, StateFlow on Android, the Data object on web — so declaring a plain var that way fails to compile against the generated protocol; declare it `observable: false` instead. A field that exists on only one platform can be declared with `platforms` (omit it for all platforms; `[]` means nowhere and warns). Visibility must match too: the declared var becomes a requirement of the generated ViewModel protocol, which a private field cannot satisfy — promoting one typically means opening it as `private(set)` — accepted directly by protocol-sync since jsonui-cli 1.6.24 (stacked and reordered forms like `public private(set)` / `weak private(set)` included), so the old workaround of a private backing var behind a computed property is no longer needed. Weigh the promotion before you make it: a declaration is a change to the app's own API, and the generated protocol grows a requirement on every platform the screen targets — one platform's gates passing is not evidence, since the other one may now fail to compile. When the declaration exists only to let a test reach some state, the better fix is usually to change how the test reaches it: arrange through an already-declared field and let the harness drive the real interaction, rather than publishing an internal flag to satisfy a witness.Skip — when the referenced declaration section is absent entirely (for example the spec has no transitions[] at all), the existence check is skipped: a dangling reference cannot be proven without a contract to check against.Cross-face warnings (since jsonui-cli 1.6.23) — once branchContracts exists, validate also holds the prose faces against it. Two seams: a validation.serverSide entry whose prose mentions a snake_case token that the rest of the spec does not know anywhere (component ids, request fields, and note-demoted codes all appear elsewhere, so they never fire — that whole-spec difference-set definition is what kills false positives), and a userActions entry that talks about a contracted method yet routes to a transitions[] destination no branch declares with then.transition. Prose about non-contracted actions is ignored. The design principle: absence of prose is always legal, nothing asks you to add prose, and branchContracts is the canonical side — the check only lights up what the prose says and the contract does not know. Specs without branchContracts are completely untouched (structurally opt-in) — and that is a normal state to be in, not a gap waiting to be filled. Whether a screen deserves a spec and whether it deserves a contract are separate questions: the first asks whether it is an independent thing to declare, the second whether it has failure paths worth pinning. A display-only sheet can legitimately answer yes then no, and the useful habit is to say so in the spec's notes — a recorded reason reads as a decision, while silence reads as an oversight.
jsonui-doc validate spec — findings
[ERROR] ...when.flag.debug: Unknown when key 'flag.debug' — allowed: 'data.<field>',
'arg.<name>', 'api.<op>', 'cond'
[ERROR] ...when.cond: cond references undeclared condition 'ghostCondition' — declare it
in branchContracts.conditions
[ERROR] ...branches[3]: A note branch must contain only 'note' (found ['when']). Declare
the contract half as a separate {when, then} branch.
[ERROR] branchContracts.methods.ghostMethod: Method 'ghostMethod' not found in
dataFlow.viewModel.methods or stateManagement.eventHandlers
[ERROR] ...when.api.updateProfile: API operation 'updateProfile' is declared by
ProfileRepository.updateProfile and AccountUseCase.updateProfile — the bare
name does not say which endpoint this means, and the generator would bind it
to one of them. Qualify it, e.g. 'ProfileRepository.updateProfile'
[WARNING] ...then.data.typoField: Data field 'typoField' is not declared in
stateManagement.uiVariables / dataFlow.viewModel.vars / stateManagement.states
[WARNING] ...when.api.deleteProfile: API operation 'deleteProfile' is not declared in
dataFlow.repositories[].methods or dataFlow.useCases[].methods
[WARNING] ...then.transition: Transition destination 'GhostScreen' does not match any
transitions[].destination
cross-face warnings — one seeded drift each
[WARNING] validation.serverSide[0]: Prose mentions branch-like token 'payment_expired'
that branchContracts does not know — declare the branch (or scenario) or
update the stale prose
[WARNING] userActions[0]: Prose routes to transition 'SettingsScreen' but no branch of
the contracted method(s) declares `then.transition` to it — declare the
branch or update the stale prose
8. What the generated docs show`jsonui-doc generate spec` renders the section as decision tables: a summary line counting methods, declared branches, and note-only branches ("outside the machine-checkable contract"), a Named Conditions table showing both witnesses, and one numbered when/then table per method with its baseline row. Since jsonui-cli 1.6.24, a method whose contract uses `platforms` also gets a Platforms column, and the summary line counts them ("N branch(es) are scoped to specific platforms") — tables without platforms declarations render exactly as before. The text below is the actual generated HTML for the example above, flattened to text:
generated HTML — decision table (as text)
Branch Contracts
1 method(s) — 3 declared branch(es), 1 note-only branch(es) outside the
machine-checkable contract.
 
Named Conditions
Name | Meaning | Witness (true) | Witness (false)
formValid | every required field is filled | form = {"nickname":"a"} | form = {"nickname":""}
 
onTapSave — Baseline: isSaving = false
# | When | Then
1 | cond = "!formValid" | data.errorMessage = "@profile_screen_invalid", api = "none"
2 | cond = "formValid", api.updateProfile = "success" | transition = "HomeScreen", api.updateProfile.request = {...}
3 | cond = "formValid", api.updateProfile = "failure" | data.errorMessage = "@profile_screen_failed", api.updateProfile = "called"
4 | note (not machine-checked): double-tap guard: second tap while isSaving is dropped
9. Generating tests from the contractThe same declarations generate real-stack unit tests for all three platforms — vitest on web, JUnit4 (Robolectric) on Android, XCTest on iOS — one test per declared branch, mocking only the HTTP boundary, with hard errors whenever a declared reference cannot be bound to a real mock scenario. The generation machinery, the harness you own, and the red-check discipline have their own guide:Branch tests — generating unit tests from branchContracts →
Keep going
Writing your first specThe spec sections branchContracts builds on — data, methods, transitions./guides/writing-your-first-spec
Verifying implementation against docsThe wider check suite that keeps spec and implementation from drifting./guides/verifying-implementation-against-docs
Writing screen testsScreen and flow tests — the executable side of behavior documentation./guides/testing