What a version may change

Graview's stability promise says what a release may change and what it may not. Every @graview/* package shares one version; this page is docs/stability.md in the repository.

The promise

A host runs thousands of stored apps on one build of @graview/*. Before it takes a new version it needs to know three things: whether stored data folds the same, whether a declaration that compiled still compiles, and whether the tools a model calls moved. This page is the promise, and capabilities() is how a host reads what a build ships instead of guessing from a number.

Every @graview/* package shares one version (a changesets fixed group). Before 1.0, a minor may break what this page allows a major to break; each break is still said in a Compatibility: line. From 1.0 on, the rules below hold within a major.

The five surfaces

1. Ops and primitives: the stable contract

An Operation and its Primitives are history, and history is never rewritten. Within a major:

  • Never changed incompatibly. A field may be added if it is optional, and an op without it reads as before (via, author.name, author.onBehalfOf arrived this way).
  • No field is removed or changes meaning. No primitive is removed.
  • A fold of the same log gives the same graph on every version of the major.

A major that must change them ships upgradeOp steps (below).

2. Snapshot and log formats: versioned

What is stored carries the format it was written in (FR-31). FORMATS names the current formats (snapshot, op), and formatStamp() gives { framework, formats }. A store's meta and every bundle are stamped with it.

  • A reader meeting a newer format says so with NewerFormatError and does not fold it. The host refolds from the log or rolls forward. Rollback is therefore always safe: the previous version never misreads what the next one wrote.
  • An older format is brought up by upgradeSnapshot(snapshot, from) and upgradeOp(op, from), one step per format change. Each step ships with a test over a fixture of the format before it (packages/core/tests/fixtures/formats/).
  • Anything written before stamps existed (0.1.0) is format 1.
  • A log compacted behind an undo horizon (FR-23) is not a new format. Its ops are ops, its checkpoint is an epoch marked horizon, and the archive sits beside it. A build before compaction refuses such a store on open, because its log no longer begins at seq 0. It never misreads one.
  • A checkpoint keeps who made each record behind it (Epoch.creators, record id to seat id), so an own sight knows them after a restart without the archive. The field is optional and additive within format 1: a checkpoint without it opens as before and knows its makers from the horizon on, and a build that predates it ignores it, folds the same graph and judges sights as it always did. A build that predates it and compacts again writes its next checkpoint without the field. Epochs are never served to a seat, so the ids it names stay with the host.

3. The wire and live protocols: additive within a major

WIRE (in @graview/ship) lists every route serveStore answers, and createStoreHandler answers the same routes in any runtime (FR-09). WIRE_PROTOCOL numbers the protocol. The guest-view protocol (@graview/guest, FR-04) is held to the same rules, and GUEST_PROTOCOL numbers it.

  • Within a major, a route or a response field may be added. None is removed, renamed or changed in meaning.
  • A request field the server does not know is ignored, never refused.
  • The live socket at /graview/live (FR-05) holds to the same rules: a message type or a field may be added, and a message the server does not know is ignored. hello and welcome carry WIRE_PROTOCOL, so either side can tell what the other speaks. Its seqs mean what /graview/since?seq=N means.
  • A refusal's reason (FR-46) is one of REFUSAL_REASONS — forbidden, missing, invalid, limit, unavailable, refused — on the socket's refused, on every refusing answer of the routes and on an agent tool's refused answer. A code never changes meaning. Before 1.0 a code may be added, and the changelog says so as a change to this surface; from 1.0 none is added within a major, so a program that branches on all six has branched on every refusal. refused (FR-119) is an act's own rule saying no to a well-formed call — a document act's allowedWhen, a TypeScript mutation's ActRefusal, whose reason is refused unless it names another; invalid is the call as sent — its arguments, one the act does not take (FR-121), a call that changes nothing. A client that predates refused treats it as it treats any code but unavailable: final, the change taken back. wouldNeed is the roles that could, when the policy knows them. A call naming a record the seat may not see is refused exactly as one naming a record that does not exist — missing, one sentence that names the act and not the id (FR-55) — and permission is asked of it as if the record were not there, so no refusal tells a guessed id from a real one. What this cannot hide is an act's own logic: a condition that counts records its caller may not see, a refusal worded from one, an effect that copies from one. graview check warns of each act that reads a kind some role allowed to run it may not see (act-reads-hidden-kind, FR-105), reading a document's act for itself and a TypeScript act by the kinds it declares it reads.
  • busy (FR-45) is not a refusal: the host asked for the change again after retryAfter milliseconds (429 with Retry-After over HTTP), nothing was judged, and a client keeps the change. limit is a refusal: the change can never succeed as asked, and is taken back. unavailable is the one refusal that is not final: the host takes no changes for a while and cannot say how long (503 over HTTP), nothing was judged, and a client keeps the change and sends it again, backing off. A call sent again after it landed is answered with its ops before a host's limit is asked, so it is never told busy, limit or unavailable.
  • A field's revision, which a stale write is refused against, is the seq of the op that last wrote it. It is derived from the log and never stored, so it never changes a stored format.
  • WIRE_PROTOCOL moves only when a client of the previous protocol can no longer be served, and that is a major.
  • A host that stops serving an older protocol says so with reload (FR-44): minProtocol names the lowest it serves, and a client carries its unsent calls across the reload. hello.wire and the subprotocol graview.ship.1 name ship's codec, so a host can serve another beside it on one path. build in hello and welcome is the host's opaque string and is never judged. A host numbers its own half of the wire with hello.hostProtocol, which minHostProtocol answers with reload carrying hostProtocol; hello.protocol stays WIRE_PROTOCOL.
  • A client names its batch as a Store mints it, batch:<tag>:<n> or undo:<tag>:<n> (isClientBatch); any other is refused invalid. A batch sent again is answered only with the asking seat's own ops, and one that holds somebody else's is refused invalid. A server mints its own batches outside that shape (served:…), and serves another seat's withheld op under an opaque withheld:<16 hex> batch. A via in a call or a post is a claim, ignored unless the host's viaOf accepts it (FR-52).
  • The declaration version a welcome, a route and a declaration push say is the host's own monotonic number for the declaration it serves, the one a client hands resolveApp; a push with the number a client already serves is ignored.
  • A declaration change is pushed as declaration (FR-43), and every welcome says the declaration version it serves. Neither moves WIRE_PROTOCOL.
  • A host that holds the app read-only says so (FR-66): { t: "held", sentence } to every socket that said hello when the hold starts, sentence: null when it ends, held in every welcome and on every answer a poll reads while it stands, and every change meanwhile refused unavailable in that sentence. Additive to protocol 1, so it does not move WIRE_PROTOCOL or the codec's name: a client that does not know the message ignores it.
  • What a seat is served holds no id of a record it may not see (FR-55): not in the snapshot, the log, an op's primitives, inverse, reads, writes, call or sentence, a presence, a conflict, /graview/health or a tool's answer — beyond the seat's own words. A field whose current value this seat wrote (or the person an agent acts for; an undo is never the author of what it puts back) is served as written, whatever it names, and a record withheld only for such a field is served; so is the seat's own call in its own ops, and an argument it sent that an answer says back. A seat that guessed an id and wrote it is then served the same whether the guess was real, and a write of the value a field already holds, when that value names what the seat may not see, is kept in the log as the seat's. An oracle that checks what a seat is served excludes the strings the seat itself wrote. An act's sentence is worded from its author's view: describe reads the graph as the author is served it, so a record the author may not see is named only by what the author wrote, as one that does not exist is — and a reader who sees more reads that sentence as the author would have. The seat's own words excuse only what it wrote — values in its primitives and the inverse that puts them back, and its own calls; another author's call, sentence and name are judged by sight alone. An op records which records its sentence read (described), and another author's op is served whole only when its reader may see every one of them. An act's own refusal worded from a record its caller may not see is said as "“<act>” could not be done as asked." An answer's reads name only records the seat sees; an id that names no record is left out as a hidden one is. seatLens is the judgment, and seenBy, logSeenBy, the routes, the live socket and the agent tools all read it. A seen record whose field names a hidden one is served with that field cleared when its kind declares the field optional (or does not declare it); when the field is required, clearing it would serve a record that fails its own declaration, so the record is withheld from that seat whole, as if its sight did not reach it — with its links, and with every op that touched it withheld. An op whose only mention of a hidden record is in its reads or writes, and whose primitives are served as written, is served whole with those trimmed. Any other op that names a hidden id is withheld, and its primitives are judged by whether each record was served just before and just after them: served to served is the patch as the seat is served the record (a hidden id written into an optional field arrives as that field cleared, UNSET); not served to served is an add-node of the record as now served, with its links to records the seat is served; served to not served is a remove-node; neither is nothing; and a link is served when both its ends are — an end that is not there at its moment, as a dangling link a repair removes has, is judged as seesId judges its id, so one that names no record is served and one that names a record the seat may not see, removed before, after or with the link, is not (FR-67). A seat from whom nothing is kept (hidesFrom false) is served every op as it is. So the log a seat is served folds — from nothing, from any cursor, or from the epoch base its view serves after a compaction — to exactly the snapshot it is served. /graview/health keeps ok and every count the whole store's — a poller asks whether the store is well, a count names no record, and a count of only what the asker sees would call a broken store well — and its danglingEdges names only the links whose ends the asking seat may be told of; a caller the host cannot tell is judged as a seat with no id and no roles. This narrows what a seat is sent and never adds a field, so it does not move WIRE_PROTOCOL.
  • An act may touch what its seat cannot see (FR-55), and replaces (FR-115) is such an act by design. Before it connects, it severs the subject's links of the relations it names, all but the one it makes — whatever record is on the far end, seen or not — exactly as connecting a cardinality-one relation severs the link it displaces. A seat that moves a component to a new owner may so cut its link to an owner it may not see. That is the policy, not a leak: the seat is served the severed link as nothing (a link is served only when both its ends are), the op as its primitives are judged above, and an agent tool's answer the same way, so the act changes what the seat cannot see without telling it what was there. An installation that must not let a seat displace a hidden record says so in the act's condition or its grants, not by relying on sight.
  • A status board's moves (FR-108) are judged against the graph the page holds, and the host applies against the whole graph. On a served app the page holds the seat's view, so a named step whose condition reads a record the seat may not see is judged without it: a step that requires no hidden record to exist is offered and then refused by the host, and a step that requires one is not offered though the host would have run it. The first is harmless by construction — the call is judged again in full, nothing applies, the refusal is typed (invalid, or the act's own reason, FR-110) in the sentence an act's refusal worded from a hidden record is said in ("“<act>” could not be done as asked."), and the card stays in the column the seat is served — and the second only withholds a move. Neither tells the seat what it cannot see, and neither moves WIRE_PROTOCOL.
  • A worker view's manifest and what it is handed are held to the guest protocol's rules: a key may be added when it is optional and a manifest without it means what it meant; a build that predates a key passes it over (one before 0.1.18 draws a view with replaces as it drew every view of one: alone). replaces arrived this way (FR-149): "page" on a view of one record draws it alone in place of the record's own fields, and checkManifest refuses any other value and the key on a view of many or of the home. What a view of one record replaces changed without the manifest's text: since FR-149 a view of one is drawn above the record's own editable fields on the scene (on Pages it always was), where it was drawn alone; a host that relied on the record being the view alone adds replaces: "page". data-prefill (FR-150) is an attribute a view draws, judged by the host: it fills only an empty input or textarea whose name is the field, from a record the view was shown, read through the viewer's sight, when an act in its fieldset named in the manifest writes that field of that record and the viewer may run it there; a press carries the value only back into that field, and any other press carrying it is refused untyped. A view's props are as they were: each record's fields on it by name, beside id, kind and label (FR-151 corrected the guide, not the props). GUEST_PROTOCOL does not move.

4. The declaration, the document format and check finding codes

A declaration that compiled and checked clean on one version compiles on the next version of the major.

  • A check finding's code never changes meaning. A new code may appear, and a new warning or note is not a break. A new *error* on a declaration that used to pass is a break.
  • The stored-data finding codes of validateGraph (FR-21) hold to the same rule. Each is a record, a link or a rule that no longer fits the declaration, found by id:

| Code | What it says | repairPlan | |------|--------------|--------------| | node-shape | a record's field does not fit its kind (detail names the field) | clear an optional field, coerce a required one to its default, else drop the record with its links | | kind-unknown | a record of a kind the declaration no longer has | drop it, with its links | | edge-dangling | a link to or from a record that is not there | drop the link | | edge-disallowed | a link the declaration does not allow between those kinds | drop the link | | rule-error | a rule threw rather than judged (could-not-judge; detail names the rule) | none: the declaration's to fix | | rule-budget | a rule would have read more than its budget (over-budget) | none: the declaration's to fix |

  • The declaration document format (FR-01) is versioned like a stored format, and capabilities().documentFormats lists what a build parses.
  • A key may be added to a document format within its version when it is optional and a document without it means what it meant: kinds.<kind>.computed (FR-83) arrived in graview-document@1 this way. Every key is closed, so a build that predates one refuses a document that uses it as unknown-key, by path, and never misreads it. A host that keeps documents for more than one build reads capabilities().shipped before it writes the key.
  • The app's money arrived the same way (FR-100). Its one home is the brand: brand.currency (a three-letter code) and brand.locale (a language tag) in a document, Brand.currency and Brand.locale in a declaration, and a document's brand may now carry them without an accent. A figure or a field shown as money takes currency of its own; one that names none, and a template's {x | money}, take the brand's, written by Intl.NumberFormat in the brand's locale ("en-US" when it names none). A document without them says a sum as it always did: a figure with a currency in that currency, anything else as a number with no symbol. A figure's and a meter's label became a template (FR-99): a label without braces says what it said. The rule language gained out(S, 'edge'), in(S, 'edge') and &, | and - between sets (FR-101); an expression that parsed before means what it meant, except that a | inside brackets within a template's braces, which was a formatter and refused, is now a join. A build that predates them refuses brand.currency, brand.locale, a field's currency and a set operator by path, but draws a templated label as its words and a two-argument walk as "—", so a host reads capabilities().shipped for FR-99, FR-100 and FR-101 before it writes them.
  • A number's range arrived the same way (FR-114): min, max and step on a number or integer field (and on an act's declared argument), each optional, a field without them taking any number as before. The edit vocabulary gained set-range, and DocumentDiff gained narrowedRanges. A range is judged by the checker (default-range, field-range), not by readDocument. A build that predates them refuses the keys by path, so a host reads capabilities().shipped for FR-114 before it writes them.
  • An act's setsOther and replaces arrived the same way (FR-115), each optional, an act without them doing what it did. One meaning changed with them: an act's connects or severs shorthand on a subject that is only the far end of the relation (connects: "owns" on a component, where owns is declared on person) now links from the record it is given to the subject, as the declaration says the relation goes; before, it tried the link the other way round, which the graph refused, so no document that worked reads differently. effectsOf may return a ReplaceEffect beside the effects a document lists. A host reads capabilities().shipped for FR-115 before it writes the keys.
  • The rest of the brand arrived the same way (FR-124): brand.logo (inline SVG, or a path under /graview/assets/ or relative to the page; a string, or { "src", "alt" }), brand.favicon (the same forms), brand.typography (display, body, mono: system-serif, system-sans, system-mono, or a stack of faces every system has and the web fonts on DOCUMENT_FONTS), brand.shape (radius 0–32, density 0.75–1.5), brand.accents (a hue per kind) and brand.scheme (light, dark or auto), each optional and each mapped onto the TypeScript Brand (which gains logoAlt, favicon, subtitle and scheme). A document's description is now said on both faces — the app name's hover on the app bar and the line on the routed face's home (FR-131) — and every document compiles to a brand, so a document without one is called by its own name where it was called Graview. The checker judges the new keys (brand-mark, brand-font errors; brand-accent a warning about an accent that does not read as given, FR-126). The edit vocabulary gained set-name and set-description, and set-brand takes every key, null clearing one; DocumentDiff's sentences for the name and the description changed words ("The app is now called…"). A build that predates them refuses the keys by path, so a host reads capabilities().shipped for FR-124 and FR-125 before it writes them. A compiled app carries the new keys in its brand within graview-compiled@1: a page that predates them draws the brand it knows.
  • A compiled app (FR-123) is versioned like a stored format. It is what a host hands its page so the page need not compile: serializeCompiled(compileDocument(document)), plain JSON with every template and expression already parsed, which appFrom (from @graview/core/compiled) builds into the same app compileDocument made. It says "format": "graview-compiled@1". appFrom refuses another format as compiled-format, by name, and parts that do not match their document as compiled-shape, and never misreads either; appFromOrCompile then compiles the document in the page, so a compiled app cached from another build costs one request, not a broken page. It does the same for one torn on its way (refused compiled-shape, never thrown) and for one compiled from another document than the one handed beside it — two documents are the same when their canonical text is (canonicalize), so a host hands the document the compiled app was made from, in the current document format. Within a version the format is additive: a key an older page ignores, and a page that lacks it can do without, may be added. Anything a page would need to build the same app moves the version: a new effect, a new kind of argument, a change to what a parsed expression or template part holds. So the rule language's tree (Expr) and TemplatePart are part of this format, and a parser change that alters them is a format change. A compiled app is a cache of its document, never its source. A host keeps the document, compiles it at its own build, and may drop a compiled app at any time. It is the host's own data, checked for its format and shape and not judged again.
  • A check finding code added this way (FR-99's view-braces, FR-100's brand-currency and brand-locale, FR-105's act-reads-hidden-kind, FR-108's lens-column-unreached, FR-114's default-range and field-range, FR-115's act-sets-other and act-replaces, FR-124's brand-mark and brand-font, FR-126's brand-accent, FR-132's pages-overview-taken, FR-137's pages-faces-alike, FR-148's page-field, page-field-twice, page-group-twice, page-unknown-field and page-too-large) is a warning, a note or an error only about what is new: act-reads-hidden-kind and view-braces are warnings, lens-column-unreached is a note, and the brand, range, other-end and page codes judge keys a declaration could not hold before.
  • The scene's name arrived the same way (FR-132), and its key was respelled when the scene left the places for the bar's switch (FR-137): pages.scene and pages.pages (at most 40 characters each, as pages.overview was) are what the switch calls the two faces, "Scene" and "Pages" when unsaid, and arrange-pages takes scene and pages. The scene keeps the address /places/overview whatever it is called. pages.overview, which named the scene's tab in 0.1.15 and 0.1.16, is read as pages.scene (a line in RESPELLED), so a stored document keeps its word, and arrange-pages still reads overview as scene, so a chat or a tool written before 0.1.17 still renames the scene. pages-faces-alike is a warning about a declaration whose two words are the same. pages-overview-taken is a warning about a declared place whose address is the scene's; that place keeps its page on the routed face, and the place list does not offer it. A build that predates the keys passes them over.
  • Where a declaration opens changed without its text (FR-136): an app with a home view (the document's views.home, or a worker view attached to "home") and no other pages.first opens on Pages at its home when the host names no face, where it opened on the scene, and placesOf marks the home first. Under address routing the bare address, naming no face, is the home's whatever face the host names, and the host is told the pages (onFace). A host that wants the scene there links to /places/overview, or hands a stop. An app without a home view opens as before.
  • A kind's page arrived the same way (FR-148): kinds.<kind>.page, { "fields": [...], "groups": [{ "title", "fields" }] }, each part optional, says which facts a record's page shows first and under which headings; the rest follow in the kind's declared order, under "Details" when there are groups. The declaration's display.page is the same choice. The edit vocabulary gained set-page-fields ({ kind, fields, groups? }; an empty fields and no groups take the choice away), and the compile and graview check refuse a name the kind does not have or one named twice (page-field, page-field-twice, page-group-twice in a document; page-unknown-field and page-field-twice in TypeScript). A build that predates the key refuses it as unknown-key. One reading changed without a key: a record's facts are read in the kind's declared order on every face, in describePlace and in readableFields, where they were read in the order the record's own keys were written.
  • What a declaration offers may grow without its text changing. An act that sets a status board's field to a value of its own (book setting status to "booked") is a move to that value's column (FR-108), where a board offered only a free set-<field> act; where a named step reaches a column, the free act no longer does. A TypeScript mutation says the constants it sets as sets, optional, and one without it reads as before.
  • No key is taken away from a document format within its version, and none is renamed without the old name still being read. 0.1.16 broke this: its American spelling pass renamed a setting's honored (was honoured) in the document as well as in the declaration, and its notes said the document format was unchanged, so every document toDocument wrote before 0.1.16 that carried settings failed to compile ("Nothing knows how to apply undefined"). Since 0.1.17 (FR-134), a key renamed within a format is a line in RESPELLED (@graview/core/document): every reader — readDocument, compileDocument, appFrom and appFromOrCompile on a compiled app an older build made, diffDocuments and editDocument — reads the old name as the new one, keeps the new one when a document says both, and never changes the document handed; toDocument and an edit write the new name. respellDocument does the same for a host and says which paths it respelled. A document stored with the old name is read the same on every later build of the major, so a host need not rewrite it, though its next save writes the new name. packages/core/tests/document/fixtures/ keeps documents and compiled apps as older builds wrote them, made by those builds from npm, and each must compile; a test records every key path a document has been able to carry and fails when one is no longer read, by either name.

5. Derived tool names and input schemas

Tools are derived from the declaration (createToolRuntime, graview mcp), and a model that listed them before a change sends what it learned then.

  • A change to a derived tool's name, description or input schema for an unchanged declaration is called out in a Compatibility: line, even when it is a fix.
  • A change caused by the declaration (a renamed field renames an argument) is the app's change, not the framework's. The framework's part is saying which tools moved.
  • FR-110 is such a change. Every derived edit-<kind> says additionalProperties: false, so an argument it does not take is refused as invalid where it was dropped. A document's act always says its writes, read off what its sets and effects set on its subject ([] when that is nothing), so an argument that only feeds a record the act makes no longer counts as a write of the subject, and edit-<kind> offers that field again. The conformance kit announces the two fixtures it moved (document:vendors, document:two-lines, 0.1.14). A TypeScript mutation that declares no writes is read as before.
  • FR-121 is such a change. Every act's tool says additionalProperties: false — a document's act, a TypeScript mutation whose input is a plain z.object, remove-<kind> — and a call with an argument the act does not take is refused as invalid, naming what it takes, where the argument was dropped and the act ran without it. An act that creates still takes id. An input its author made z.looseObject, or gave a catchall, keeps what it is not told about and its tool says nothing new; one that is not an object's shape is not judged. An argument given as undefined is no argument. The conformance kit announces the two fixtures it moved (document:vendors, document:two-lines, 0.1.15).
  • Drawing a view and keeping it as a lens arrived as two tools a host opts into (the seat as a guide, B3): draft_view, a read, and keep_lens, which hands the host's drafts.keep the add-lens edit. A runtime lists them only when given drafts (keep_lens only with drafts.keep, and never on a read-only seat), and toolDefinitions only when told drafts: "draw" or "keep", so a surface that did not ask keeps its tools and its hash. Their names, input schemas and answers are held to this section from now on. A lens either tool takes or returns is a declared lens ({ title, lens, on, bindings?, options? }) and is judged as one under the seat's sight; a kind the seat may not see is answered as one that is not there.

Not a surface: the look

The values the theme writes — the shipped palettes, the default faces, weights, radius and spacing, the kit's defaults — are not one of the five, and a patch may move them; the custom properties' names, the Brand keys and what each means are kept, and a change to a value is said on the changeset's Compatibility: line. A host that wants its look held still declares its own brand (schemes, typography, shape), which the framework never overrides.

The design kit's revision 03 is such a change. The shipped schemes are built on Graview's identity — paper #f7f7f2, ink #18213a, muted #586174, En Dash navy #001769 as the light accent, #9aabff on a navy ground #0a0f1f in the dark — and paint no wash (wash: "none"); TYPOGRAPHY.body names Montserrat first (never fetched: the host loads it, or the system sans stands in), with three new properties for its weights (--graview-weight-display, -body, -label: 550, 450 and 600 on the framework's face, 600, 400 and 600 on a face a brand names); h1 and h2 are tracked at −0.025em; the default radius is 8 px (6 small) where it was 12 (9). A brand that names its own schemes, faces and radius draws exactly as before but for the heading weight (--graview-weight-display, 600 where the browser's bold was 700), the two largest headings' tracking and buttons' labels at 600. A brand derived with brandFromAccent over the shipped schemes takes the new grounds, panels and inks with its own accent. system-sans in a document is the system's own stack as it always was.

How the app bar lays itself out is the look too. Since it fits its box, it reads its own width and never the screen's (a phone's bar below 640 px of its own), Find is a small box that says its shortcut and is drawn wide while it is used, and the switch draws its icons alone when its words do not fit. What a host's page or harness reaches is kept as it was and said on the Compatibility: line when it moves: the test ids (app-faces, app-face-scene, app-face-pages, app-find, app-find-open, find-box, nav-find, app-places-open, app-place-current), the embed's switch option ("words", the default, or "icons"), and the switch's data-switch (what was asked) and data-switch-drawn (what is drawn). A host that wants the switch held to its icons asks for switch: "icons"; nothing holds it to its words at every width, because a bar too narrow for them would run out of its box.

Where the places stand is the look as well (FR-145): on a bar with room after the name, the switch and the tools, the first places in their order stand on the row as words — the one the reader is on always among them — and the rest fold into "More"; with too little room the one control returns; a phone's bar keeps the one control. On the scene the bar's places are the scene's (FR-144): "The whole thing" (app-place-scene:whole) and each picture (app-place-scene:<kind>:<as>), each with the scene's address for it as its data-place-path (/places/overview#overview=1, /places/overview#focus=…&in.view=<as>). How many stand changes with the width, the brand's face and the app's places, so a harness never counts on it. What is kept, at every width and on both faces, is how a place is reached — one recipe:

  1. Every place the bar offers is one element in the embed, [data-testid="app-place-<key>"], carrying its data-place-path (the same key and path as before FR-145; a list's kind:<kind>, a picture's place:<kind>:<as>, the home's home, connections).
  2. If that element is visible, press it (it stands on the row).
  3. If not, press [data-testid="app-places-open"] — "More" where places stand, the one control where none do, on a phone the page's first line — and press it in [data-testid="app-places"], the list it opens.
  4. [data-testid="app-place-current"] says the place the reader is on: the one control's words, or the place standing on the row, marked aria-current="page".

The list is grouped as before (data-place-group: home, lists, pictures), holding the places that do not stand; app-places-open is absent only when every place stands. The places standing on the row are inside nav[data-testid="app-places-standing"].

What a page draws while a part it fetches has not arrived is kept the same way (FR-139): the line [data-testid="lazy-part-missing"] in the part's place, a polite status, with its button [data-testid="lazy-part-retry"] ("Try again"); a part that never arrives draws that line and throws nothing into the embed. retryingImport is on its own entry, @graview/core/retry, for a page only: the main entry, tools and ship carry no import() of a computed URL, which workerd refuses. A selected record in the scene marks its host data-graview-record-focus, under a declared page is one [data-graview-primitive="panel"] holding [data-graview-spec="page"], and a district's name is [data-graview-district-name] (FR-141, FR-143). In an embed, the pages face's ask field (the box page-seat, the seat inside it), the panes the bar opens and the notices at the foot stay inside the embed's box where they fit there; a harness finds them there, never at the window's foot.

How the seat looks is the look too: one ask field at the foot of both faces that grows into a panel over the app when asked, a bottom sheet below 640 px of its box. What a host's page or harness reaches is kept and said on the Compatibility: line when it moves: the region [data-testid="seat"] (role="region", named "Ask <the app>", data-graview-foot, data-graview-seat open or closed, data-graview-seat-side left or right, data-graview-seat-shape desk or phone), its field seat-field, the panel seat-panel with its line seat-where, its questions seat-suggestion (at most three), its acts seat-act (at most three), seat-side (⇄), seat-close and on a phone seat-grab; in the conversation a proposal's seat-apply ("Do it") and seat-decline ("Not now"), a name's seat-pick, a question's seat-question, and under an answer a model gave, seat-answered-with-ai ("Answered with AI"); Find's last row find-ask; the lines' key beside Up, lines-key and its pane lines-key-pane. The embed's seat option ("field", the default, or "hidden") and the Shell's ask say whether it is drawn. Nothing in it asks a reader which machine answers: the host gives the seat its AI once, as ai on GraviewProvider, mount, <Embed> or a routed face's context (HostAi: complete, decide, onDevice, name); with none, an open question is told "I can answer about what's in this app. Open questions need AI, which isn't on here.", and a change a model proposed is logged with via: "ai:<name>", which no reader is shown. The conversation is the app's (useGraview().seatTalk), kept in the tab's session storage under graview:seat:<the app's name>, so a face switch, in place or by a page load, keeps it.

Sentences a program may match are said in the changeset when they move: the insight observation insight:load:<id>:<edge> reads "<name> <the relation's words>: all <n> <plural>" (or "<n> of the <m> <plural>") since FR-142, a relation's words from its description or inverse, else its key spoken (edgeWords, @graview/core).

The conformance kit

@graview/core/conformance ships fixtures: declaration documents with the check findings and tool schemas this framework made of them, and op logs with the snapshot hash each folds to. conformance() runs them against this build, or against a build a host hands it, and returns the differences by fixture id. A host runs it before it takes a version.

The fixtures are append-only. node scripts/conformance-fixtures.mjs only ever adds a fixture with a new id, and a lock holds the rest. A change to a recorded fixture is an announced difference (ANNOUNCED), with the version that made it and what changed.

The Compatibility line

A pull request that touches a file on one of the five surfaces asks every changeset it adds for a line:

Compatibility: additive — `Operation.via` is a new optional field; ops without it read as before.

The line says the surface, whether the change is *additive*, *breaking* or *unchanged*, and for whom. scripts/require-changeset.mjs refuses a changeset without one (CI's changeset job), and the files that count as each surface are in scripts/lib/surfaces.mjs. The line goes into each package's CHANGELOG with the rest of the changeset, so every release lists what it did to compatibility.

capabilities()

import { capabilities } from "@graview/core";

capabilities();
// { version: "0.1.2", protocol: 1, documentFormats: [], formats: { snapshot: 1, op: 1 },
//   shipped: ["FR-06", "FR-11", …] }

shipped lists the ids of the seams this build ships, by the ids Graview Cloud filed them under. A host retires its interim for a seam when the id appears. A test holds the list to the FR ids the changesets name, so a seam cannot ship unannounced or be announced without shipping.

FRAMEWORK_VERSION is the version alone, for a host that records which framework folded a store.