fuaran-ui
A headless Python host of the Fuaran UI wire format — a dependency-light, idiomatic-Python reference implementation of the canonical-JSON contract a Python AI orchestrator needs to read and write Fuaran UI trees.
Documentation: get started with Python · Python authoring guide · fuaran-ui.io (the language docs, all hosts) · fuaran-ui.live (the playground)
fuaran-ui is a sibling reference implementation, not a transpile of any
other host: it is built to the language-neutral wire-format specification
(WIRE_FORMAT.md) and certified against the shared conformance corpus. Conformance
to the spec is the contract; idiomatic Python is the deliverable. The core is
headless (codec + validator only); an optional, dependency-light server-HTML
renderer ships alongside for hosts that want to render a decoded tree to HTML
without a client runtime.
What's here
| Module | Role |
|---|---|
fuaran_ui.ui |
The ergonomic, typed authoring surface — smart constructors over a typed per-kind model (fuaran.metric(...), binding.static(...), format.currency(...)), plus the polars-like Compute authoring API (frame(...).filter(col("x") > 0).group_by(...).agg(...)) that emits canonical Transform JSON. Its terse sibling fuaran_ui.ui.quick is the notebook shape — title-first, records-in, ids derived. See docs/AUTHORING.md, examples/quickstart_reactive_data_app.py and examples/quickstart_terse_dashboard.py. |
fuaran_ui.schema |
The typed tree + decode_node / encode_node (canonical Node codec); schema.types is the typed per-kind authoring model. |
fuaran_ui.ops |
The TreeOp algebra: decode_op / encode_op + apply(op, tree) (the reducer over all 11 ops), plus the placement helpers — placed insert / move / nudge and the clone verbs, which emit only those 11 ops. |
fuaran_ui.dataframe |
The Compute-layer columnar strand — the typed Cell/Column/Table/DataSource model + the serializable Transform/ColExpr algebra, a byte-exact canonical codec, and a pure reference evaluator certified byte-identical to the reference over the parity fixtures. |
fuaran_ui.validator |
A pre-emit, default-deny-by-shape structural validator. |
fuaran_ui.op_stream |
The hash-chained provenance log — the StreamEntry envelope, a host-side SHA-256 chain, an in-memory sink, and replay. Reproduces the committed cross-host chain hashes byte-for-byte. |
fuaran_ui.canonical |
The canonical-JSON encoder (key sort, number form, escaping). |
fuaran_ui.conformance |
A corpus round-trip smoke harness. |
fuaran_ui.renderer |
Optional server-HTML renderer (render_html) + the byte-copied reference stylesheet. |
fuaran_ui.runtime |
Interactive Pyodide client runtime — the in-browser mount + dispatch→apply→re-render loop, behind an injectable BrowserDeps seam. |
fuaran_ui.cli |
The fuaran-ui console script — validate / render / export / corpus-sync, each a thin wrapper over the library call beside it. validate matches the TypeScript host's fuaran validate in exit code and printed verdict. See Start here — from the command line. |
fuaran_ui.client |
Typed client over the Fuaran generation endpoint — FuaranClient.generate + the FuaranSession turn loop (holds the tree → repair diffs). See Generate below and examples/quickstart_client.py. |
Install
pip install fuaran-ui # the published distribution
pip install -e ".[dev]" # editable + dev tooling (pytest / mypy / ruff)
Requires CPython 3.12+. The runtime codec has no third-party dependencies — it uses only the standard library.
The distribution and import names (BREAKING in 0.7.0)
This host published as fuaran-py and imported as fuaran_py up to
0.5.0. From 0.7.0 the distribution, the import package and the console
script are all fuaran-ui / fuaran_ui: registry names carry the domain
and drop the language, as every other tier already does (Fuaran.UI.*,
@fuaran-ui/*, the fuaran-ui crate). The repository is still
fuaran-ui/fuaran-py, and the host id in the capability manifest and in
refusal reports is still fuaran-py — both of those name the repo, and neither
moves.
-pip install fuaran-py==0.5.0
-from fuaran_py.ui import fuaran
+pip install fuaran-ui==0.7.0
+from fuaran_ui.ui import fuaran
There is no compatibility shim, and that is deliberate: nothing has ever
consumed this host as a dependency of another package, so a shim would exist only
to make the old name keep working in the one place — a direct install — where
changing it is a one-line edit. The fuaran-py project on PyPI is left exactly as
it stands: its releases up to 0.5.0 stay downloadable, and it is never re-tagged
or published to again. A consumer moves both names in one commit.
0.6.0 was never released. It was an untagged draft slot carrying additive
work, and the rename above is a higher class than the draft carried, so the
number advanced rather than the change riding it. 0.7.0 is therefore the first
release under the new name and the first to carry everything since 0.5.0 —
including 0.6.0's breaking BindingSources record (below).
Start here — from the command line
Installing the package installs a fuaran-ui command. Every verb is a thin
wrapper over a library call below, so nothing here is a second implementation of
anything:
pipx run fuaran-ui validate tree.json # -> valid (node) exit 0
fuaran-ui validate tree.json --json # -> the machine-readable report
fuaran-ui render tree.json > body.html # -> server-HTML body fragment
fuaran-ui export tree.json --format markdown # -> a crawlable markdown document
fuaran-ui export tree.json --format email-document --subject "Weekly"
fuaran-ui corpus-sync --check # (a checkout only) snapshot vs the authority
Exit codes are 0 the document is good, 1 it is not (or cannot be read),
2 a usage error — and for validate those codes and the printed verdict are
the same as the TypeScript host's npx @fuaran-ui/cli validate <file>, so the two
get-started tracks read the same. tests/test_cli_parity.py pins that agreement
and, on a machine holding both hosts, re-checks it by running both CLIs over the
same fixtures.
Two things validate says that are worth reading:
- Its structural posture is
subset, and it says so on a clean run too. The rule set is a documented subset of the reference tier's, so silence means "no rule in this subset matched" and never "this tree is clean". The--jsonreport carriesposture/postureNote; the plain form prints them to stderr. - Structural findings do not move the exit code. The exit code answers "did
this document decode", exactly as the reference front-end's does; findings ride
stderr (or the report's
findingsarray) so a script's success test means the same thing against either host.
stdout carries the artefact — the verdict, the report, the HTML, the document — so every verb pipes; stderr carries the commentary.
Rendering applies the destination policy at its default, which denies non-local destinations. A host wanting a wider posture declares one through the library; the CLI takes no flag for it.
Start here — from Python
examples/getting_started.py is a six-lesson tour of
what this language is for, and it runs:
python examples/getting_started.py # the whole tour
python examples/getting_started.py replay # just one lesson
| Lesson | What it shows | |
|---|---|---|
| 1 | authoring |
A user interface is a value — build it, encode it, render it to HTML. |
| 2 | ops |
Edit the tree, don't regenerate it. A typed, addressed edit that fails by name. |
| 3 | replay |
A hash-chained session replays exactly, time-travels, and detects tampering — with the same hashes the other hosts compute. |
| 4 | safety |
Default-deny by shape. Malformed emissions are refused because there is no code case to strip. |
| 5 | operations |
Declared operations dispatch by structural search, with no model and no network. |
| 6 | ai |
Bring your own key: prompt → wire JSON → strict decode → render. |
Five of the six need no key, no network and no browser. Only ai calls a provider,
and only when you set ANTHROPIC_API_KEY (or pass --key).
Use
from fuaran_ui import decode_node, encode_node, decode_op, encode_op
result = decode_node('{"id":"a","kind":{"$type":"Markdown","text":{"$type":"Literal","text":"hi"}}}')
if result.ok:
canonical = encode_node(result.value) # byte-identical canonical wire form
else:
print(result.error.code, result.error.path) # structured, recoverable
Decoding never throws on malformed input — it returns Ok(value) or
Err(DecodeError) carrying one of the canonical codes (INVALID_JSON,
MISSING_FIELD, WRONG_TYPE, UNKNOWN_DU_CASE, WRONG_NODE_KIND,
EMPTY_NODE_ID, LIMIT_EXCEEDED) and a $-rooted path.
That claim covers every reader here that takes wire text, not only
decode_node and decode_op: the versioned envelope, the DAG record, the
elicitation documents, the teleport bundle, the dataframe source and pipeline,
the theme manifest and the client's reply parser all share one guarded parse. So
each is total on hostile input, and each answers the decode-determinism rules the
same way — a repeated object member, an unpaired surrogate, a bare NaN, a
number outside the RFC 8259 grammar and content after the root value are refused
at all of them, and the resource limits are enforced before the document is
built rather than measured after.
Author (ergonomic, typed)
fuaran_ui.ui is the Python analogue of @fuaran-ui/ui / Fuaran.UI — smart
constructors over a typed per-kind model, with per-kind defaults + ARIA injection.
A human developer authors a tree the same way an F#/TS developer does; encode
serialises it byte-identically to the corpus.
from fuaran_ui.ui import fuaran, format, encode
tree = fuaran.dashboard(
"root",
children=[
fuaran.metric("rev", label="Revenue", value=1234.5, format=format.currency("GBP")),
fuaran.markdown("note", "Updated hourly."),
],
)
wire = encode(tree) # canonical JSON
This is the human authoring surface; the AI's emission surface is the wire format itself, for every host. Full guide: docs/AUTHORING.md.
…and terser, from a notebook
fuaran_ui.ui.quick is a thin layer over those constructors for the case where the
data arrives as records and the ids do not matter to you: title-first, records-in,
ids derived.
from fuaran_ui.ui import quick
app = quick.dashboard(
"Regional revenue",
quick.metric_strip(totals), # {label: value}, pairs, or records
quick.chart(rows, x="region", y="revenue", kind="Bar"),
quick.grid(rows), # df.to_dict("records")
)
Ids are derived from each node's kind and label and hashed, so re-running the same
cell produces the same ids — and fuaran_ui.ops.diff between two runs is then a
short, typed op script a host can apply to the rendered page rather than a
rebuild. See docs/AUTHORING.md
and examples/quickstart_terse_dashboard.py.
…and it can answer back
fuaran_ui.ui.control declares a state slot; a pipeline reads that slot through
param(name); and a host re-derives the rows when the slot changes — a Transform and
its parameters are ordinary wire data, so this happens wherever the tree is rendered and
needs no Python there.
from fuaran_ui.ui import col, control, frame, param
region = control.select("region", options=col("region").unique(), source=frame(rows))
fr = frame(rows).filter(col("region").eq(param("region"))).bind(region)
select / multi_select / range / date_range; the slot is seeded with the declared
default (WIRE_FORMAT §24.4), an unseeded one is an absent constraint rather than a zero,
and a parameter no control fills is refused when the binding is lowered rather than
silently dropped in a browser. See
docs/AUTHORING.md.
…and every handler and value is optional
A control's handler and its value are each optional on the wire, and the two
absences say different things a host acts on. Every constructor in fuaran_ui.ui can
now reach both, where several used to hard-code one:
from fuaran_ui.ui import binding, fuaran
from fuaran_ui.schema import types as t
# No handler: the renderer's write-back default arms, so the control writes its own slot.
t.TextField(binding.state("profileName", ""), on_change=False)
# No value either: `{"$type":"Text"}` — the canonical MINIMAL control, and a BOUND one.
# A decoder synthesises the context's auto-binding (`Filter(name)` on a filter chip,
# `State(field id, <typed placeholder>)` on a form field).
t.TextField(on_change=False)
fuaran.tabs("t", on_select=False) # the index channel writes back
fuaran.modal("m", dismissable=True, on_dismiss=None) # a decoded modal closes itself
The handler flag defaults to present on every control that emitted one before this
change, so no tree authored against the earlier surface moves a byte; reaching the
shorter document is an explicit False (or, for Modal.on_dismiss, an explicit None,
which is why the omitted argument still yields the no-op Chain). Tabs gained
on_select_tag and Select gained on_change_multi — a second channel each, arming
independently of the first — and the Range pair-valued control record joined the
FormFieldKind roster it had been missing from.
This host declares no stability policy yet (pre-1.0), so the change is recorded here
rather than in a STABILITY.md it does not have. It rides the 0.2.0 slot: it is
additive — new keyword arguments, and every prior call encodes identically.
…and the records are no longer narrower than the wire
Several records reached fewer slots than the wire declares, so a document every other host can read had no spelling here at all. They now carry the whole set:
from fuaran_ui.schema import types as t
from fuaran_ui.ui import fuaran, node
# A grid's DECLARATIVE behaviours — each names a host State key, which is what makes
# the affordance survive the wire where a closure cannot.
fuaran.grid(
"ledger",
source=t.State("ledger", rows),
columns=[t.Column(label="Month", field_name="month"), t.Column(label="Note", field_name="note", sortable=False)],
row_key_field="month",
sort_state_key="ledger-sort",
default_sort=t.DefaultSort(1, "desc"),
page_size=20,
page_state_key="ledger-page",
edit_state_key="ledger-edits",
reorderable=True,
)
# A chart's eight: the value-axis format, the two axis names and the subtitle, the
# legend edge, data labels, what the x column MEANS, and the §4l annotations.
fuaran.chart(
"revenue",
source=t.Static(rows),
x_field="quarter",
y_fields=["revenue"],
kind="Bar",
subtitle="Millions",
x_title="Quarter",
y_title="Revenue",
value_format=t.FmtCurrency("GBP"),
legend_position="Bottom",
data_labels="Ends",
x_scale="Category",
annotations=[
t.ReferenceLine(140, "Target"),
t.EventMarker(t.AnnotationCategory("Q3"), "Repricing"),
t.RangeBand(t.ValueRange(0, 100), "Tolerance"),
],
)
node.with_tooltip("Takes about a minute.", fuaran.button("b", label="Rebuild"))
fuaran.link("m", href="mailto:a@example.com", label="Email us", protection="email")
fuaran.table("t", headers=[...], rows=[...], sortable=True, default_sort=t.DefaultSort(1, "desc"))
Three details are decisions rather than mechanics:
- The annotation union is CLOSED at three members, and each carries an ADDRESS and
a LABEL and nothing else: where in the data a threshold or an episode sits is the
author's meaning, while the strokes and offsets that draw it are the host's. The
addresses are declared rather than sniffed, which is what lets the pre-emit validator
ground them — a category key no row carries, a date on a band axis, an
unparseable date, a non-finite value and a band whose pair runs backwards are each
refused by name (
FUARAN137–FUARAN141) instead of drawing a picture nobody meant. Column.sortable/Column.editableandTable.sortableare TRI-STATE. Absent,trueandfalseare three different documents: a column that explicitly declines a sort has said something a column that was never asked has not.tooltipis a NODE trait, not a per-kind keyword — every kind can be pointed at — so it is reached throughnode.with_tooltip(...)beside the other postfix modifiers. It is never a substitute for an accessible name: an icon-only control whose only name is a tooltip has no name.
Additive on the same terms as the section above: every slot is absent by default, so a tree authored before any of them existed encodes byte-for-byte as it did. Rides 0.2.0.
0.3.0 — Drawing, Fact and Mount become authorable
The codec has decoded all three for a long time; what it did not have was a spelling.
encode needs a .to_wire() root, so a node kind the typed model omitted could not be
written from Python at all — thirteen corpus fixtures were readable and unwritable.
from fuaran_ui.schema import types as t
from fuaran_ui.ui import fuaran, encode
# Placed geometry: a closed shape vocabulary, no raw SVG. Geometry is STATIC —
# a chart lowering hands over concrete coordinates — and only DrawStyle binds.
fuaran.drawing(
"chart",
view_box=t.ViewBox(0, 0, 200, 100),
title="Quarterly revenue",
shapes=[
t.Rectangle(10, 10, 80, 40, corner_radius=4, style=t.DrawStyle(fill=t.Static("#3366cc"))),
t.Curve((t.MoveTo(t.DrawPoint(0, 0)), t.LineTo(t.DrawPoint(40, 20)), t.Close())),
t.Label(100, 90, t.LiteralText("Revenue"), style=t.DrawStyle(rotation=-30, text_anchor="Middle")),
],
)
# The labelled TEXT statement beside metric()'s number. Its value is a TextSource,
# so it binds to the host's clock or to a grid selection.
fuaran.fact("today", label="Today", value=t.Bound(t.Now("Day")))
fuaran.fact("patient", label="Patient", value="Alice Smith", tone="Brand", emphasis=True)
# The isolation boundary: a guest tree, a channel, and the whole of what it may do.
fuaran.mount(
"metrics",
scope_id="guest-metrics",
channel=t.GuestChannel("TwoWay", "MetricsMsg"),
capabilities=["notify"],
inputs={"seed": t.SlotArg(fuaran.markdown("seed", "Initial guest state"))},
)
Four details are decisions rather than mechanics:
- Geometry accepts the non-finite sentinels.
NaN/Infinityreach every typed float slot and encode as the quoted tokens. Nothing refuses them here: a degenerate box is a document a conformant host must be able to carry and refuse for itself, and a record that raised would make this the one tier unable to read a fixture the corpus ships. - An explicit
rotation=0is a document; an absent one is not. OnlyNoneomits the key — an upright label the author wrote and a label never asked about are two different trees. - A mount's empty capability list is written, never omitted. Default-deny is the posture the boundary exists for, so the empty grant says something; an absent key would read as "unspecified" to the host that has to decide.
Mount.onBubbleis optional on the wire, and this host's decoder had it required — so a mount whose bubbles the host does not take was a document every other host accepts and this one refused. Both corpus fixtures carry the sentinel, which is why no fixture caught it; the generative floor did, on the first run after the authoring surface could spell the absence. Decode-side only: nothing that encoded before encodes differently.
Additive: the three kinds are new constructors and new records, and no existing tree moves a byte. The version advances because the public surface does — 0.2.0 is tagged and published, so a widened surface rides a new slot rather than being repacked over an old one.
0.3.0 — Query, Invoke, Call, AiTool and I18n become authorable
The other half of the same gap, one level down: the decoder has carried all five for a
long time, and the typed Binding / Action / TextSource unions did not. A case that
is not a member of its union has no spelling, so a Query-sourced grid, a capability
invocation, an endpoint call, an AI-tool dispatch and a localised caption were all
readable and unwritable.
from fuaran_ui.schema import types as t
from fuaran_ui.ui import action, binding, fuaran, node
# A value the HOST resolves, re-run when a named dependency changes.
fuaran.grid(
"settlements",
source=binding.query("settlements"),
row_key_field="reference",
columns=[t.Column("Reference", field_name="reference")],
)
fuaran.metric("rev", label="Revenue", value=binding.query("orders", "status", "region"))
# A host-registered capability, BY ID with typed string arguments — never code.
# The same record in both positions: a value source, and an effect.
fuaran.metric("forecast", label="Forecast", value=binding.invoke("forecast.revenue", horizon="12"))
fuaran.button("run", label="Run model", on_click=action.invoke("model.score", rows="all"))
# An endpoint call, with the DECLARATIVE result target that survives the wire.
action.call("/api/total", into=action.into_state("total"))
action.call("/api/orders", into=action.into_query("orders"))
action.call("/api/preferences") # reads nothing back
action.call("/api/refresh", on_result=True) # the emitting host holds a closure
# A named tool on the host's AI surface, with a JSON argument bag.
action.ai_tool("summarise", {"rows": 20, "tone": "brief"})
# A catalog key the READER's host resolves, with placeholder values. It is a
# TextSource, so it goes wherever text goes — including the node-level tooltip.
fuaran.image(
"harbour", src="/harbour.jpg", alt="Fishing boats", caption=binding.i18n("gallery.caption.harbour", year=1908)
)
node.with_tooltip(binding.i18n("metric.latency.hint"), fuaran.metric("p50", label="p50", value=128))
Five details are decisions rather than mechanics:
Invokeis ONE record in two unions. The wire shape is identical in a value position and an effect position; the reference tier spells that as two cases carrying the same fields, and one class in both aliases is the same statement with nothing to keep in step. It also moved: it was already shipping from the capability seam and lowering to exactly these bytes, while sitting outside the unions — which is how a record can encode correctly and still be invisible to every consumer that asks the union what this host models.dependsOnomits at empty;argsis written at empty. Opposite answers to what looks like one question, and both follow the wire: a dependency-free query is the bare two-key document, while anI18nkey with no placeholders and a capability with no arguments each carry their empty container, because the emptiness is a statement and an absent key would read as "unspecified".- An invocation argument's value is a string. The alias narrowed from
str | int | float | bool, which was never right: both reference decoders require a string, so the wider spelling was an authoring surface for documents this host emits and the reference host refuses. An argument's real type lives in the capability's own signature, which validates it on the host that owns the body. - A
Callresult target isinto_state/into_query, not a binding. Their wire tags areStateandQuery— the names the two reading bindings already hold in this flat namespace — so the Python names differ deliberately. It is what stopsinto=being handed aFilter, which no host could honour. Callis a gated effect, and was not. It was named first in the dispatch gate's own description of the gated set and absent from the set itself. The gate's verdict was never wrong — an unclassified shape is default-denied anyway — but a host askingis_gated_effectwhich shapes it must decide about was told an HTTP call to a host endpoint was not one of them.
Additive for trees: nothing that encoded before encodes differently, and the fifteen
fixtures these constructs unlock now encode byte-identically and decode back byte-stably.
The two narrowings above (InvokeArgValue, and is_gated_effect("Call") answering
True) ride the same 0.3.0 slot the section above opened.
Render (optional)
A decoded tree renders to a sanitised HTML body fragment from Python — no
client runtime — emitting the reference fuaran-* class vocabulary so the output
is styled by the byte-copied reference stylesheet exactly as every other Fuaran
host styles it. This is what makes a Python web host (e.g. FastAPI) render Fuaran
chrome end-to-end.
from fuaran_ui import decode_node
from fuaran_ui.renderer import render_html, reference_css_path
result = decode_node(wire_json)
if result.ok:
body = render_html(result.value) # body-fragment HTML string
stylesheet = reference_css_path().read_text() # the canonical reference CSS
The renderer is stdlib-only and inert by design: Action-bearing nodes render
dead until a client hydrates them, a Link is a real crawlable <a href>, and
every string-to-DOM seam (URLs, markdown, attributes) is sanitised. The host owns
the document shell (<html> / <head> / the <link> to the stylesheet); the
renderer emits the body fragment only.
Binding sources — the host's values, its clock and its locale (BREAKING in 0.6.0)
BindingSources was a bare dict[str, object]. From 0.6.0 it is a frozen
record with three members, and the host clock is the reason:
from fuaran_ui.renderer.bindings import BindingSources
sources = BindingSources(
values={"selected-row": "inv-2291"}, # what the dict used to be, whole
now="2026-08-02T06:59:24Z", # the host instant, ISO-8601 UTC
locale="en-GB", # the ambient BCP-47 tag
)
body = render_html(tree, sources)
A bare mapping is still accepted and normalises to BindingSources(values=…),
so render_html(tree, {"selected-row": "inv-2291"}) reads exactly as before. What
does not carry over is code that constructed the type by annotation or that
indexed a BindingSources value directly — sources["k"] is now
sources.values["k"].
values is unchanged in meaning: a State key, a Query or Filter name, a
Selection nodeId → the host's resolved value, doubling as the compute-parameter
store.
now is what Binding.Now and Format.Since resolve against, and it is the
member this release exists for — before it, both rendered nothing here. The clock
lives in the host, never on the wire and never read during resolution: resolve it
once per render pass and hold it, or two Now slots in one tree can disagree,
and a replayed op-stream re-supplies the instant it recorded so a replay
reproduces the original render instead of drifting to replay-time "now". A
declared grain truncates it before anything projects it — Minute gives
2026-08-02T06:59:00Z, Hour gives 2026-08-02T06:00:00Z, Day gives
2026-08-02.
The default now="" means this host furnishes no clock, and the slot then
resolves to absence — an empty text slot, an em-dash in a numeric one.
Deliberately loud: a relative time computed against an invented "now" is a
confidently wrong answer, and a raw epoch integer where a reader expects "3 hours
ago" is worse. (It is not the WireSurvivabilityError channel — the document is
answerable, the host simply furnished nothing, which is the same fact as an
unwritten Query.)
locale is the tag a LocaleSource.Ambient reads; "" means the runtime
default. The Format cases this host renders — Since, RelativeTime,
Duration — are locale-independent by declaration (unit glyphs and English
words, not CLDR forms), so they consult no tag. The four that are not
(Number / Currency / Percent / Date) resolve to absence here, exactly as
they did before: their text comes out of a locale database, a stdlib-only host has
no canonical answer to give, and the corpus's render-text family enumerates that
exclusion with its reason. resolve_locale_tag(binding, sources) is the public
seam for a host that wants to render them itself with babel or Intl — the
Explicit-wins precedence is the seam's rule, not each caller's.
Conformance for all of the above is the corpus's render-text family
(render-text.json, named by the manifest's renderText pointer): every vector
pins a fixture, the host sources, and the exact text this host must produce.
tests/test_render_text_corpus.py is this host's leg.
Destination policy — ambient, and default-deny
The scheme floor answers is this URL safe to have. It does not answer is this
destination one the composition declared, and only the second question closes
exfiltration: https://collector.example/?s=… passes every scheme rule, and in
an <img src> the browser contacts it with no user act at all, because
rendering is the request.
So every href / src the renderer emits — Link, Image (its src, and
each srcSet candidate), Media (its src and its poster frame), and every
destination inside a Markdown body — is checked against an egress policy
carried on the render context. Every one of those is a URL fetched with no user
act, so they take one rule; what differs is only what a refusal means. A slot
the element cannot do without collapses to the refusal URL and carries its
marker; a slot it can — a srcSet candidate, a poster frame, an expansion anchor
— is dropped instead, because offering a rendition or an affordance that
cannot work is worse than offering one fewer. It defaults to deny-non-local: a decoded tree
cannot declare its own egress, so absent a host's declaration it gets none. There
is no caller opt-in anywhere on the path; the guarantee does not depend on a call
site having remembered to ask.
A host that means to reach off-origin declares it, by name:
from fuaran_ui.renderer import (
DENY_NON_LOCAL_EGRESS,
EgressClass,
HostSuffix,
allow_origin,
render_html,
)
policy = allow_origin(HostSuffix("cdn.example"), [EgressClass.MEDIA], DENY_NON_LOCAL_EGRESS)
body = render_html(result.value, egress_policy=policy)
PERMISSIVE_EGRESS — every destination, for a hand-authored tree where the
author is the trust boundary — is reached by that name and no other, so a grep
finds every host that widened it. The same keyword rides
FuaranRuntime(..., egress_policy=…), because a client re-render re-issues every
<img src> fetch and a policy holding only on the server half would leak on the
first dispatch.
Two consequences on adoption, both deliberate:
- A
mailto:/tel:href is refused under the default. Those are egress channels with no host for a rule to name, so they can only be permitted wholesale — and permitting them by omission is the failure the default exists to prevent. - Same-origin destinations (a relative path, a fragment) are allowed, so ordinary in-app links and assets render unchanged. The default denies leaving, not linking.
A refused destination renders as a refusal — href/src becomes the inert
about:blank#fuaran-egress-refused and the element carries a trailing
data-fuaran-egress-refused attribute naming the class and the host
(media:collector.example) — never as a silent neuter: "nothing happened" and
"this was refused" are different facts, and only one of them is debuggable. The
marker value never carries the path or query, which is exactly where an
exfiltrated payload sits.
Where this host's shape differs — declared, not incidental
The policy model, the verdicts, the refusal URL and the marker spelling are identical to the reference host's, and the shared markdown corpus pins them byte-for-byte. Four things about how this host carries the policy differ, and each is a decision rather than an omission:
- The policy is a keyword argument on the existing entry point, not a second
entry point beside it. The reference tier mints a separate
render…AndEgressfunction because its context record has five other optional fields and a parameter per permutation is combinatorial; this host's entry point takes two arguments, so the parameter is the declaration and stays greppable at the call site.Rendereralready existed as the per-render context, so no new object was introduced to hold the field. - The
unsafeUrlverdict now renders the marked refusal at theLink/Imagecall sites, where before this host emitted a bareabout:blank. The floor refuses the URL at exactly the same point; what changed is that the seam those call sites go through renders every refusal visibly, with the marker valueunsafe-urldistinguishing a floor refusal from a policy one. Any local test that pinned the bare form was updated in the same change. - The markdown seam deliberately keeps the bare
about:blankfor that same verdict, with no marker. Those bytes are pinned by the shared corpus and have read that way in every conformant host since the markdown renderer shipped; re-spelling them would churn a conformance corpus inside a change about egress, which is where a genuine divergence hides. The reference host draws the line in the same place. - Two of the reference host's call sites have no counterpart here, because
the emissions do not exist in this host: a
DataGridlink column (this host's grid renders each cell's text projection, inert server semantics, so it emits no per-row anchor) and therouteclass (this renderer emits no navigation —Action-bearing nodes are dead until a client hydrates them). Both are absences of a sink, not unchecked sinks; if either emission ever lands here it arrives already owing a policy consultation.
Bound-grid rendering — the completeness posture
A DataGrid bound to data renders its rows, server-side. The source is
resolved through the same render-time compute path every other bound slot uses
(a Transform pipeline is evaluated by the certified evaluator; a Selection /
Filter / State default resolves), and the resolved rows are emitted as the
reference grid's own <table class="fuaran-grid"> markup — the same element
shape and class vocabulary a client renders, so a page that is later hydrated
attaches to markup it already agrees with rather than replacing a placeholder.
The posture is completeness: a static host that holds the rows and prints a row count withholds what it already has, and a no-JS surface — an email digest, an ops report, a crawler — can never recover it.
One boundary remains, and it is declared rather than incidental. A column
projects its cell either declaratively, by field (a row property name that
rides the wire), or through a host closure (value) — and a closure does not
survive serialisation; it decodes as an opaque sentinel. So:
| Bound grid | Rendered |
|---|---|
at least one field-projected column, source resolves to rows |
the rows, as a fuaran-grid table (closure-projected cells empty) |
no field-projected column (including no columns at all) |
the [Grid: N rows — hydrates client-side] placeholder, with N the resolved row count |
| source does not resolve to rows | the same placeholder |
Rich cell kinds (TonedPill, Checkbox, Link, Progress, …) render their
text projection — the renderer's inert server semantics for every
interactive node, not a special case for grids.
Form-field rendering — what a declared rule reaches
A FormField.rule declares the accepted set (FormFieldKind names the
control). A static emitter's job is to project that into the platform's own
constraint vocabulary, so the platform — here, the browser receiving this HTML —
is what enforces it rather than a script that may never load:
| Rule slot | Rendered as |
|---|---|
format (email / url / tel) |
the input's type, so the browser enforces the shorthand |
pattern |
the HTML pattern attribute (ECMA-262 source, anchored to the whole value) |
minLength / maxLength |
minlength / maxlength |
compare |
declared, not enforced — data-fuaran-field-compare="<op>:<key>" |
message |
not rendered — see the note below |
Three boundaries, each declared rather than incidental.
compare has no HTML equivalent. It is emitted as a declaration matching the
reference renderers' marker so a reader can see the constraint was carried and
not dropped, and it is explicitly not claimed as coverage: nothing in the
platform reads that attribute, and this emitter produces inert markup with no
gate of its own. A cross-field comparison is enforced by a rendering host's
submit gate and, non-bypassably, by a server-side re-check.
message is not rendered, for the same reason it is not rendered by the
reference server host: the unmet message needs an element for the field to be
described by, and minting that markup means minting class vocabulary that is
parity-locked across every renderer and both stylesheet copies. That is a
renderer change with its own cross-host change-set.
pattern is omitted on a TextArea, which has no such attribute in HTML.
Emitting one would look like coverage and be inert.
One narrowing is this host's own and worth stating: the reference host always
emits a control type, and this baseline never has, so type appears here only
where a format rule declares it. Every form rendered before the rule slot
existed is therefore byte-unchanged. The wider gap — this baseline projects one
generic <input> per field rather than a per-control element — is the baseline's
and not the rule slot's.
Chart lowering coverage
fuaran_ui.charts lowers a resolved Chart to a canonical Drawing subtree
(first-party inline SVG, headless included), byte-identical to the shared
chart-lowering/* goldens the reference implementation generates. Lowered
arms: Bar (grouped + stacked), Line, Area (overlaid + stacked
bands), Scatter (linear numeric x-scale, point marks), Pie (polar,
cubic-approximated wedges; single-series). Heatmap renders the
client-hydration placeholder. Data-bearing shapes carry a derivation-based
markId (series|category, stable under row reorder) emitted as
data-fuaran-mark for mark addressability; chrome stays unstamped. The pytest
suite certifies every golden pair byte-for-byte, including canonical-float
formatting of pie arc control points and stacked cumulative sums.
Data-addressed annotations are lowered too — ChartSpec.annotations carries a
closed union of a horizontal ReferenceLine at a value, a vertical EventMarker
at an x address (a category key or an ISO-8601 date), and a shaded RangeBand
over a pair on either axis. An annotation names a place in the data's
coordinates and, optionally, a label; it carries no geometry and no style, so it
survives a data change, a theme flip and a resize. Three rules the lowering
applies, each pinned by the shared goldens: an address participates in the
domain it addresses before the axis is nice-d (a target above every bar still
draws, and the axis moves to say so); the draw order is part of the lowering
— bands behind everything including the grid, lines and markers in front of the
series, every label last, because in inline SVG z-order is emission order; and
a label is fit-gated and suppressed, never clipped, with the gate asked only
of the literal arm since the text behind a bound or i18n arm is not known at
lowering time. A suppressed label never suppresses its annotation. Pie is
neutralised for all three members — a polar arm has neither axis for an address
to name.
The codec carries the slot structurally and checked: a conformant document round-trips byte-for-byte, while three refusals still bite at the wire boundary — a non-finite reference-line value or value-band end, an unparseable event date, and an unordered value or date pair. Each is refused rather than normalised for one reason: an address participates in the domain it addresses, so a non-finite one would take every gridline, tick and mark to NaN and a typo'd date would drag the axis back to the epoch. Two category keys order only through the rows, so that pair's order is the authoring path's question rather than the wire's.
Sparkline lowering coverage
A Sparkline whose source resolves to a series is drawn, server-side, as
first-party inline SVG — byte-identical to the shared sparkline-lowering/*
goldens the reference implementation generates. Before this it was a placeholder:
this renderer emitted an em-dash and never read the series at all. The geometry
comes from fuaran_ui.charts.try_lower_sparkline, which produces a canonical
Drawing kind, and the markup from the same builder the Drawing node uses — so
there is no second sparkline renderer to drift.
This is a deliberate change in what the server emits:
Resolved source |
Rendered |
|---|---|
| a non-empty series | <div class="fuaran-sparkline"> wrapping the lowered fuaran-drawing SVG — a 100 × 30 canvas, one currentColor polyline at stroke-width 1.5 |
| an empty series | the fuaran-sparkline fuaran-sparkline-empty em-dash element, as before |
a source that does not resolve to a series (an unbound Query, a foreign host value) |
the same em-dash element |
Three properties are worth stating because each is a contract rather than an accident, and each is pinned by a golden:
The geometry is the corpus's. Over n values with min / max:
x = i/(n-1)·100 (a lone point centred at 50), y = 30 − (v−min)/range·28 − 1,
with range = max − min except below a 1e-9 flat guard where it is 1.0, so a
constant series sits on its own line instead of dividing by zero. Both
coordinates round half-up to 2 dp. There is no title and no description: a
sparkline has no spec to summarise, so it carries no accessible name of its own.
Non-finite values are not filtered. The "NaN" / "Infinity" /
"-Infinity" sentinels a series may carry propagate through that arithmetic and
reach the canvas as 0 through the drawing builder's number form — the same
thing every other geometry-bearing kind does with them. That is the input class
where a hand-written copy drifts first, so it has its own golden.
Nothing to draw is not an empty canvas. The em-dash fallback is a host
element rather than a drawing shape, so the lowering cannot express it and
returns nothing at all; the empty golden is the JSON literal null, which is
that fact. The renderer's fallback branch is what supplies the element.
The Sparkline row of the corpus's render-fidelity.json reads "class": "none" accordingly — the parity-checked fallback is the whole render, as it has
been for Drawing.
Project (document + digest, optional)
The same tree, rendered for two targets that run nothing at all. render_html above is the
page a browser paints and a client hydrates; these two are the other things a decoded tree
can be, from the same bytes:
from fuaran_ui import decode_node
from fuaran_ui.renderer import render_markdown, render_email_document
tree = decode_node(wire_json).value
open("report.md", "w", encoding="utf-8", newline="
").write(render_markdown(tree, title="Weekly"))
open("digest.html", "w", encoding="utf-8", newline="
").write(render_email_document(tree, "Weekly"))
Both are pure functions of the tree and its resolved bindings: same tree, same options,
same sources ⇒ same bytes, on every run and every platform. Both resolve text and figures
through the functions render_html uses, so a document, a digest and the page cannot disagree
about what a number is.
The scope line is the feature
Neither target can execute anything, so each kind needs an answer to a question the browser
renderer never asks: what does this become when nothing runs? Each projection declares one,
per canonical wire kind, in a SCOPE table alongside the code —
fuaran_ui.renderer.document.SCOPE and fuaran_ui.renderer.email.SCOPE, four dispositions
from fuaran_ui.renderer.projection:
| Disposition | Meaning |
|---|---|
rendered |
painted in full by the projection |
structural |
a carrier: the node paints nothing beyond layout, its children render |
openLive |
a labelled "open live" affordance. Never a half-working control |
omitted |
zero-paint in this target, deliberately |
The distinction between the last two carries weight: openLive says "this exists and you have
to leave the document to use it", omitted says "this carries nothing a static target can
convey". A reader can act on the first.
The tables are checked, not asserted. Completeness is measured against the corpus's
render-fidelity.json kind list, so a new NodeKind cannot arrive with no declared posture;
and every kind that artefact marks behavioural — "inert server-side, gains its behaviour at
hydration" — must be openLive in both, derived from the manifest rather than restated. A new
interactive kind therefore reddens the suite instead of shipping a dead button to an inbox.
The digest (render_email, render_email_document)
HTML email is the most hostile render target in computing: no JavaScript, no external stylesheet, no flexbox or grid worth relying on, and a rendering engine per client (Outlook desktop still lays out through Word). The projection is bounded hard to the Display subset, lays out entirely in presentation tables with inline styles, and never emits a control.
EmailOptions is deliberately small — a live URL, a column width (600px, what the Outlook
reading pane fits), a webfont-free font stack, and the destination policy. An email projection
with a theme engine is a CSS framework, and the client fragmentation this exists to survive is
what defeats one.
fuaran_ui.renderer.lint(html) is the falsifiable half of "email-safe". The client-matrix
question cannot be answered offline and it does not pretend to: it scans for constructs the
matrix is known to break on — flexbox, grid, positioning, <style>, <script>, controls,
<svg>, <iframe>, and an apostrophe entity inside a style attribute. A clean lint is not a
certificate; a dirty one is proof of the opposite, and that asymmetry is worth automating.
The document (render_markdown)
Markdown is what a tree becomes when it has to be read, diffed, committed, pasted into an issue
or indexed by something that will never run JavaScript. The output stays inside §14's own IN
bucket — CommonMark core plus GFM tables — so fuaran_ui.renderer.markdown.to_html, the
renderer this host already certifies against the shared corpus, is a valid reader of it.
MarkdownOptions.charts is the one place that loop does not close, and it is a choice rather
than a default:
charts |
What a chart becomes | Right when |
|---|---|---|
"svg" (default) |
the picture, lowered through this host's own Chart → Drawing lowering and inlined as raw HTML |
the reader passes HTML through — a docs site, a browser preview |
"table" |
the chart's resolved rows as a GFM table under a caption | the reader is §14, a plain-text reader, or a diff |
§14 escapes raw HTML by construction, so an <svg> prints as visible angle brackets through
it. Neither mode is the safe one and neither is a fallback: they are two honest readings of a
picture, and the caller knows which reader is downstream. Nothing is lost in "table" mode
that the tree did not already carry as data — which is the argument for lowering charts from
data in the first place.
Destination policy applies to both
Every href and image src either projection emits is checked against the same ambient
policy the page uses, defaulting to deny-non-local. In a digest this matters more than on a
page the reader chose to load: an undeclared image src is the tracking pixel, fetched on
open, reporting that this named person read this message. A refused destination becomes the
inert about:blank#fuaran-egress-refused; neither projection emits the data-* marker the
page carries, because data-* attributes do not survive the sanitisers most mail clients run
and markdown has no spelling for one at all. The refusal itself is not dropped — the
destination is still inert, which is the half that stops it being reached.
What these are NOT
Neither is a second conformant rendering surface, and the digest is not byte-parity with the reference host's own email projection: that implementation is pinned by a golden corpus living inside its own test project rather than in the shared conformance corpus, so no cross-host gate exists for this surface in either direction. What is deliberately shared is the part worth a corpus — the four dispositions, the scope table row for row with its reasoning, the derivation of the interactive set from the fidelity manifest, the inline style vocabulary's values, the option defaults, and the lint's code and token set. Two hosts agreeing about what an email projection is is the property that matters; agreeing about which pixel a padding lands on is not, and claiming it without a gate would be the worse failure.
The markdown projection has no reference at all: no language tier carries one. What every host
carries is the opposite direction — §14's GFM → HTML renderer for the Markdown node's own
body. This one is written to be ported rather than re-derived, and its SCOPE table is the
half a second implementation would agree with.
Run (interactive, optional)
Under Pyodide (CPython-on-WASM), fuaran_ui.runtime adds the live loop the F#
(Fable) and TypeScript (React) hosts provide: mount a decoded tree, wire DOM events
to a host update function, fold the returned TreeOps through apply, and
re-render — reusing the renderer (markup + class vocabulary) and the apply engine
(op semantics), never a parallel copy.
from fuaran_ui.runtime import counter_runtime
counter_runtime().mount("fuaran-root") # clicking "+1" re-renders the count
Browser-API access is behind an injectable BrowserDeps seam (default: the Pyodide
js interop module), so the package stays stdlib-only and importable under plain
CPython; tests drive the loop against a fake DOM.
Op-stream (hash-chained provenance, optional)
A stream's applied TreeOp edits form an append-only, hash-chained sequence of
OpRecord envelopes: each record folds its op, timestamp, author, prompt
correlation, and apply outcome into a versioned StreamEntry envelope, and a
host-side SHA-256 chain (sha256(previousHash | payload)) links records so the
stream is tamper-evident and its authorship answerable from the record sequence
alone. apply_and_persist is the write path (apply once, then persist a chained
record on success); replay_stream folds a stream back into a tree; verify_chain
proves integrity.
from fuaran_ui import decode_node
from fuaran_ui.model import Obj
from fuaran_ui.op_stream import InMemorySink, PersistContext, apply_and_persist, verify_chain
sink = InMemorySink()
ctx = PersistContext(stream_id="doc-1", user_id="alice")
tree = decode_node(wire_json).value
result = apply_and_persist(sink, ctx, Obj("RemoveNode", {"target": "leaf"}), tree)
records = sink.replay("doc-1", 1, sink.latest_sequence("doc-1"))
assert verify_chain(records) is None # a clean, untampered chain
The chain is byte-stable across hosts: the pre-image envelope leads with
{"v":2,…} (the chain format version, folded in first so the format is
self-describing) and this host reproduces the committed golden hashes in the shared
chain/ conformance corpus exactly — the same golden the F# and TypeScript hosts
certify against. The module is stdlib-only (hashlib.sha256); a genuinely
I/O-backed sink is a follow-up implementing the same OpStreamSink protocol.
Replay skips recorded refusals
A record's result_envelope is Success or Failure, and a Failure record is
the point of the field: it says an op was refused, and therefore never touched
the tree. So apply_to and replay_stream fold only the successes by default, and
a chain carrying a refusal replays cleanly:
from fuaran_ui.op_stream import replay_stream
replay_stream(sink, "doc-1", tree) # accepted records only
replay_stream(sink, "doc-1", tree, include_refused=True) # the literal fold
Folding every record is still reachable, by name. It is what an audit rebuild
wants — where would the refused op have landed? — and what a host whose Failure
records are advisory rather than final wants. It is not a sensible default: the
refused op is by construction the one the tree could not take, so the literal fold
fails on the very record that says it failed.
Compare-and-append — the concurrent-writer write path
OpStreamSink.append alone only supports a proposal: a caller reads
latest_sequence and appends at +1, and two callers racing the same stream can
propose the same sequence. One of them wins; historically the loser's append
raised and apply_and_persist swallowed it by default — a real edit, silently
gone, with the caller told the apply succeeded (which it had — only the durable
record of it was lost).
InMemorySink also implements the optional CasOpStreamSink extension — a typed
compare-and-append:
from fuaran_ui.op_stream import Appended, StaleHead
expected = sink.head("doc-1") # the chain head, or GENESIS_PREVIOUS_HASH
outcome = sink.append_if(record, expected) # built against `expected`
match outcome:
case Appended(receipt):
... # `receipt` names the record now at `receipt.sequence`
case StaleHead(expected, actual):
... # nothing was persisted; rebuild the record against `actual` and retry
apply_and_persist uses this automatically whenever sink supports it
(isinstance(sink, CasOpStreamSink)), retrying against the sink-reported actual
head — bounded, not unbounded spinning — instead of the plain read-then-append. A
sink that does not implement the extension keeps the read-then-append path
unchanged. Either way, a durability failure (a stale race exhausting its retries, a
rejected append, or a detected gap in the stream) now reaches
PersistContext.on_sink_error, whose default — default_sink_error_reporter — logs
it rather than staying silent; pass on_sink_error=None to opt back into silence
deliberately.
This host declares no stability policy yet (pre-1.0), so the change is
recorded here rather than in a STABILITY.md it does not have.
Generate (client for the hosted endpoint, optional)
The Fuaran generation endpoint is a paid, stateless, bring-your-own-key
(BYOK) HTTPS surface: it takes a prompt (+ an optional current tree) and returns
a new canonical wire-format tree. fuaran_ui.client is a thin, typed,
stdlib-only layer over it that collapses the integration to call, hold the
tree, repair:
import os
from fuaran_ui.client import FuaranClient, FuaranSession, Produced
client = FuaranClient(
"https://<your-endpoint>/generate",
access_token=os.environ["FUARAN_ACCESS_TOKEN"], # the paid credential
provider_key=os.environ["PROVIDER_API_KEY"], # your BYOK LLM key
)
session = FuaranSession(client)
result = session.next("a metric card showing revenue") # fresh generation
if isinstance(result, Produced):
tree = result.decode_tree() # typed Node via the wire codec
result = session.next("rename the metric to ARR") # a cheap repair diff
Every call returns a typed three-way result — Produced (the new tree JSON +
the ops applied + the surface-version echo), AccessDenied (the token was
rejected at the edge, before your BYOK key was touched), or TurnFailed (a
recoverable stage-tagged envelope; for the apply stage its message carries the
hint the next prompt can re-emit against). The client never raises for an
endpoint-level outcome. Produced.decode_tree() / AppliedOp.decode() hand
back typed values through the same codec the corpus certifies — you never parse
raw model output by hand.
generate_detailed(...) returns the same result plus what the deployment
reported: ops_applied (a count — the endpoint returns how much changed, not
the op list), provider (which allowlisted provider it chose), served_model
(what the provider's own reply said actually answered; None means
unreported, deliberately not the model the deployment asked for), and the
grounding snapshot state.
Failures the CLIENT reports, as distinct from the endpoint's
RecoverableError.code carries the endpoint's own code whenever there is one
(ACCESS_DENIED, APPLY_REJECTED, SECRETS_IN_BODY, MISSING_PROVIDER_KEY,
…). Three codes are this client's own, on ClientCode:
| Code | Means |
|---|---|
NETWORK |
the call did not complete — the transport raised, or timeout= elapsed. The message is FIXED: an exception string can quote a URL, a header, or a proxy's internal hostname, and this result is routinely rendered into a page. The detail belongs in your log. |
MALFORMED_RESPONSE |
a 200 with no usable tree. Not a success — accepting it would leave the session holding "" and silently repairing nothing on every later turn. |
INSECURE_ENDPOINT |
the endpoint is plaintext http:// and not loopback, so both credentials would travel in the clear. Refused before the request is built. Loopback and a relative same-origin path are admitted; allow_insecure_endpoint=True is the written-down opt-out. |
The session holds the current tree between turns, so each subsequent prompt is
a repair against it (a cheap diff) rather than a from-scratch regeneration
— the token-saving ergonomic the loop is built around. session.reset()
forgets the tree; FuaranSession(client, initial_tree_json=...) seeds it so
the first turn is already a repair.
BYOK key and access token — where each credential lives
Two credentials cross the wire, and they are not the same kind of secret:
- the access token — the paid credential for the endpoint. Sent as
Authorization: Bearer <token>. - the BYOK provider key — your own LLM-provider API key. Sent as
X-Fuaran-Provider-Key; the endpoint uses it in memory for the one call and never stores, logs, or meters it.
Both travel as HEADERS, and neither is ever in the request body. A body is
the thing most likely to be logged wholesale by an intermediary; a header is the
thing most likely to be redacted by one. The endpoint enforces it: a body
carrying ByokKey or AccessToken is refused 400 SECRETS_IN_BODY and the
value is not read — so if you get that code, treat the key you just sent as
exposed and rotate it. to_wire_body has no credential parameter at all, so
this client cannot produce such a body. The key also appears in no error
envelope and no repr: a logged client object cannot leak it.
Pick the placement by who can see the calling environment:
- Direct (a server-side script, a notebook, a backend service you control):
pass both credentials to
FuaranClient(...), sourced from environment variables or a secret store. Never commit either; never bundle the BYOK key into anything you ship. - Server-proxied (anything user-facing or multi-user — a web app, a
Pyodide/browser host, a shared tool): point
endpointat your own proxy path and pass no credentials client-side. Your proxy injects both server-side (wire.to_wire_body/wire.parse_turn_responseare exported for exactly this), so the BYOK key never reaches the calling environment.
The contract this client is built against is stamped
fuaran_ui.client.SURFACE_VERSION; a produced result echoes the live surface's
version, and is_surface_version_compatible(echoed) tells you whether the
shape is one this client understands (major-version check).
The canonical number form (the make-or-break)
The encoder reproduces the canonical float layout directly — it does not
delegate number or key formatting to json.dumps, whose output would not match.
CPython's shortest repr(float) yields the same significant digits as the other
hosts; fuaran_ui.canonical.format_finite_double re-lays-out those digits into the
canonical fixed-point/scientific form (the cross-host divergence zone — large
exponents, sign padding, -0 collapse — is pinned by the corpus float fixtures).
Retired wire vocabulary — the positional slot on InsertChild / MoveNode
InsertChild and MoveNode both append; ReorderChildren states order by naming
child ids. The integer position / newPosition these two ops once carried was removed
from the wire format, and this host REFUSES it: WRONG_TYPE at $.position /
$.newPosition, with a message naming ReorderChildren. Placing a node anywhere but
last is Batch [InsertChild …, ReorderChildren …].
There was a migration window during which every host accepted and ignored the field so the hosts could adopt independently. It is closed. How it closed is worth knowing, because it is not the obvious thing: the op decoder walks each op's schema and never looks at anything else, so not reading the ordinal was the tolerance — there was never a read to delete. Closing the window therefore meant ADDING a refusal, not removing an acceptance; a host that merely stopped mentioning the field would have gone on accepting it forever, indistinguishable from one that had never adopted.
The refusal is by name and is the enumerated-near-miss narrowing of WIRE_FORMAT §2
rule 2: a genuinely unknown key is still tolerated, because a slot a future profile may
add must stay addable. It is checked before the schema loop, so an op carrying both a
retired ordinal and another defect names the ordinal — identically ordered in every host,
so which defect surfaces first is deterministic. Certified by the corpus fixtures
reject-op-insertchild-retired-position / reject-op-movenode-retired-newposition and
pinned by tests/test_retired_position.py.
The encoder applies no schema filter, so this refusal is the decode-side guarantee only: a construction site that leaves a dead key on an op still reaches the wire, where a conformant decoder — including this one — now refuses it. Whether the encoder should filter to the schema is an open question, recorded here rather than implied closed.
This host declares no stability policy yet (pre-1.0), so the change is recorded
here rather than in a STABILITY.md it does not have.
Wire vocabulary — the dataframe algebra spells the column out (0.7.0)
A member of the Compute Transform algebra whose only honest name is the
column or the columns is now spelled in full. A project step's rename list is
columns (was cols); a sort key's field, and a window's frame-ordering
entry's field, are column (was col).
This host emits the canonical spelling and decodes either, so a tree
written by any host at any version still reads. A step carrying both
spellings is refused by name rather than silently resolved — an ambiguity a
decoder resolves by precedence is one the two hosts can disagree about forever.
The emitted bytes therefore change for a pipeline that carries a project step
or an order entry; nothing stored stops decoding.
Three members look like this one and are not it: the col expression
$type tag (a column reference); the Grid / Masonry box-layout cols
integer (a column count, where cols stays canonical and columns is its
alias, the canonical winning rather than the pair being refused); and an
aggregate entry's of, which already aliased column in the other direction.
Recorded here for the same reason the section above is: pre-1.0, there is no
STABILITY.md to record it in.
Placement helpers — fuaran_ui.ops.placement
The section above leaves every caller deriving the sibling permutation itself. That
derivation is shipped once, in fuaran_ui.ops.placement:
from fuaran_ui.ops import After, Last, Target, duplicate_op, move_op, nudge_op, place_op
place_op(tree, child, Target("sidebar", After("filters"))) # placed insert
move_op(tree, "chart", Target("main", Last())) # placed move
nudge_op(tree, "chart", -1) # keyboard move-up
duplicate_op(tree, "chart", Target("main", After("chart"))) # clone beside its source
Placement is Last() | First() | Before(anchor) | After(anchor) — an id, never
an ordinal, for the reason the section above gives. can_place is the same verdict
without the op, for greying out an illegal drop without a dry-run apply.
These helpers emit only existing TreeOp shapes — InsertChild, MoveNode,
ReorderChildren, and Batch of those. There is no new wire vocabulary, no new
fixture family, and no conformance obligation attached to any of it: the emitted op
goes through the ordinary apply gate exactly as a hand-written one does, and a host
that never imports this module reads the same bytes. The reorder leg is dropped whenever
appending already yields the wanted order, so the common case stays a single bare op.
Two behaviours are worth knowing before you rely on them:
- An anchor that is not among the destination's post-op children is REFUSED
(
UnknownAnchor), not silently appended. The only op that could honour such an anchor is aReorderChildrennaming it, whichapplyrefuses asOrderingMismatch; saying so before emission beats a rejection after it. Every other refusal is a pre-statement of the apply-time refusal the emitted op would have met, so a helper verdict and an apply verdict never disagree. - The clone verbs remap ids across the whole traversal surface, not just the
structural child lists — the id-uniqueness contract is tree-wide, so a clone keeping an
old id inside a
Switchcase or aStateslot would smuggle a duplicate past it. Colliding ids are remapped and non-colliding ones preserved, sopaste_opkeeps a lifted subtree's identity where it can whileduplicate_op(every id collides) remaps all of them. The minting strategy is injectable —derived_ids(the default,<id>-copy,-copy-2, …) orsequential_ids(prefix)for deterministic replay.
Conformance
fuaran-ui round-trips the shared wire-format corpus byte-for-byte and surfaces
the canonical reject code + path for every malformed fixture. Run the smoke
harness:
pytest
A standalone offline corpus snapshot + drift guard, schema validation, a language-agnostic certification bridge, and CI integration all ship.
Two generative layers, and neither replaces the other
The curated corpus pins named traps. Beyond it there are two distinct floors, often conflated:
- Within-host (
tests/test_generative_parity.py) — over ≥1000hypothesisgenerated trees,encode(decode(encode x)) == encode x: this host's canonical form is a fixed point. It provesfuaran-uiis self-consistent, and it runs under a plainpytestwith no other toolchain. - Cross-host (
fuaran_ui.conformance.fuzz_exchange) — one host's canonical bytes are checked by a different host's codec, in both directions. That is the whole value of it: a shared misreading of the spec is invisible to any within-host property and shows up here immediately.
The cross-host exchange needs a sibling host's emitter, so it is driven by hand:
# emit the other host's canonical samples into <dir>/fsharp/, then:
python -m fuaran_ui.conformance.fuzz_exchange <dir> # decode + re-encode + write <dir>/python/
Exit 0 all samples agree, 1 a divergence (named, with the first differing
byte), 2 the input set is missing. The runner itself is pinned by
tests/test_fuzz_exchange.py, which drives it over real corpus payloads and
asserts a deliberately corrupted sample is rejected.
The published capability manifest
conformance/host-capability-manifest.json declares, per the wire specification's
host-capability-manifest section, the set of wire constructs this release can
author — its node kinds and its union cases, as dotted tokens keyed to the wire
vocabulary. It exists so a harness that runs this host against the corpus can
compute which fixtures it cannot be expected to reproduce, rather than keeping a
hand-written list of fixture ids that is only as honest as its last
re-measurement.
python -m fuaran_ui.conformance.host_capability # is the published artefact current?
python -m fuaran_ui.conformance.host_capability --write # regenerate it
Three things about it are worth knowing before you read it:
- It is generated from this host's authoring model, never written by hand, and
tests/test_host_capability_manifest.pyfails when the committed artefact is stale. The same file falsifies the generation rather than asserting it: removing a kind from the model must remove exactly that kind's tokens. - It declares what this host can WRITE, not what it accepts. The decoder is wider in places — it recognises cases the authoring surface has no spelling for — and a manifest built from the decode side would claim constructs no document from this host can contain.
- It says what it does not cover, and means it. The per-field families are declared uncovered, and two unions sit outside the declared scope, each with its reason in the document. Absence inside an uncovered family is not a claim that the host lacks something; it is this host declining to guess.
A consumer must check hostVersion against the release it is actually running:
the manifest describes one release, and applying it to another is exactly the
stale claim it replaces, made harder to see because it looks computed.
License
Apache-2.0. See LICENSE.
Release files for fuaran-ui 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fuaran_ui-0.7.0.tar.gz | 1.1 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fuaran_ui-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.8 MB
Release files / fuaran_ui-0.7.0.tar.gz
| Download URL | fuaran_ui-0.7.0.tar.gz |
|---|---|
| Size | 1.1 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5bbea94e5d15874a814b9c9fec35248742f962fbe367d3e9cc20316328a524c0
|
|
BLAKE2b-256 checksum How to use checksums |
77e0f8d95c6e42e114a8fe7f35a260403aab70db9c287286fd5763578bd1ba70
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency logRelease files / fuaran_ui-0.7.0-py3-none-any.whl
| Download URL | fuaran_ui-0.7.0-py3-none-any.whl |
|---|---|
| Size | 647.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7e8fc778750f25565349527db0fb92390bc3db3bccccf14c3c67a0894e4e65aa
|
|
BLAKE2b-256 checksum How to use checksums |
2cbdd185221a135263f7f951bbfbf725736b82f7a6e052ecf353254584d9dbe8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency log