OpenUI adoption: decision
Educational components: the progressive adoption of the public Didact library, its capability-based filtering, and the boundary with this runtime are documented in
didact-integration.md.
CURRENT DECISION (2026-07-26, afternoon): full adoption — level (c), without reactivity.
The real dependencies
@openuidev/react-lang@0.2.9and@openuidev/lang-core@0.2.10come in (+zod@4.4.3, exact versions, no^). The browser receives the dialect and paints it with<Renderer>over the components we register; the prompt is generated by its ownlibrary.prompt()from the frontend catalog, in a build step, and Python reads it as data. We stop maintaining our own prompt generator.Reason, in the product owner’s words: “I want to use their dependency and stop racking our brains inventing it ourselves”. Translated into the decision: we prefer someone else’s maintenance over our own. The cost of the library (a
0.2.xAPI that will break) is paid with money and a drift test; the cost of our own grammar, prompt, and runtime is paid with our time, on every change, forever.The reactive layer stays off and gated: no
toolProvider, noonAction, noonStateUpdate, notoolsin the prompt, and no component callinguseTriggerAction(). The conditions to eventually turn it on are in §6, and they are not negotiable.This overturns recommendation (a) that this document made in the morning. The evidence in §1 and §2 remains valid and is what made the decision possible; what changes is the conclusion, and §3 says exactly why. It replaces, regarding the runtime, the vault note
a2ui_protocol.md(Jul 1) and refines the July 24 synthesis (_sintesis_para_repo.md,docs/research/generative-ui/README.md).Status: decided and executed on the
feat/dynamic-coursesbranch.
All the evidence in this document was produced by running real
@openuidev/lang-core@0.2.10 and @openuidev/react-lang@0.2.9 against our 16 fixtures. The
scripts live in a temporary sandbox (.../scratchpad/openui-probe/, ~25 .mjs files counting the
ones from the afternoon security review: sec-*.mjs and SEGURIDAD-MUTACIONES.md). They will
be lost when the sandbox is cleaned up; preserving them in tools/openui-compat/ with its own
package.json remains the way to keep compatibility and the 15 payloads re-verifiable, and it
still hasn’t been done.
1. What’s true and what was false
| Claim that was circulating | Verdict | Evidence |
|---|---|---|
| “The grammar we generate matches OpenUI Lang” | True, and verified, not assumed. The standard’s positional order comes from Object.keys(def.properties) in the JSON Schema, i.e. the key order of the z.object() — the same convention as ComponentSpec.props in src/render/kit.py. The 9 signatures of the kit in §5.3 come out identical prop by prop and position by position, same enum literals. |
kit.mjs |
| “Their parser would accept our fixtures” | True: 10 of 10 valid, zero errors. meta.errors=[], incomplete=false, unresolved=[], orphaned=[] on all ten. Even the ```openui fence doesn’t bother it. |
compat.mjs |
| “Their parser is the standard, it will be more complete than ours” | FALSE. Our Python parser is strictly stricter, and that is the advantage. The real parser silently accepts 3 of our 6 malformed fixtures, one of them silently dropping an entire line (malformed_missing_assign discards Stack([intro],"md"), statementCount=1, and the root becomes another node). It does not check types, nor enums (gap="huge", item_type="does_not_exist" pass), nor duplicate ids, nor injected HTML (<script>alert(1)</script> passes), nor any of the 7 contract rules in §5.2, nor the max-block rule of rule 4. The reason is structural, not a bug: compileSchema() only stores {name, required, defaultValue} — the real parser doesn’t know types or enums, no matter how much Zod is put in front of it. Its 5 error codes (unknown-component, missing-required, null-required, excess-args, inline-reserved) are a subset of ours. |
compat.mjs, detail.mjs, syntax.mjs (block C) |
| “Our grammar is OpenUI Lang” | Half false, and it has to be said. The real dialect is not line-by-line: it accepts statements split across several lines, literal line breaks inside a string, inline nesting without an id, booleans, null, {k: v} objects, arithmetic, // comments, and markdown fences. To it, \n is whitespace, not a separator. Our escape rule #3 (“every real line break closes a block”) is ours, not the standard’s — and it’s fine that it is, it’s what makes parse_partial trivial. But the prompt should not sell it as part of OpenUI Lang. |
syntax.mjs (block A) |
“The package’s AST converts to our UISpec” |
True and lossless on the 10 valid ones: 10 identical, 0 differ. Same ids, types, props, children, and the same format inferred by the same heuristic. Its root is a tree with embedded children and ours is a flat list; the flattening by statementId is total (30 lines). Escapes and serialization also match: jsonToOpenUI() produces the same text as our serialize() (except the trailing \n, which we add). |
to-uispec.mjs, diff-ir.mjs, esc.mjs |
| “…lossless in every case” | False: 4 edge cases do lose information. (1) Orphans: meta.orphaned gives only the name, the node isn’t in the tree — reconstructing its props is impossible, and our UISpec does keep it and counts it toward the max of 12. (2) Inline nodes: statementId: null, ids have to be synthesized, so block identity isn’t stable between an attempt and its repair — and that is exactly what node_render_views needs in order to measure. (3) DAG: a node with two parents appears twice in the tree with the same statementId. (4) Cycles: the tree is silently truncated, reported as unresolved with errors=[]. |
lossy.mjs |
| “OpenUI Lang has no data binding or lifecycle: it’s one-shot, render only” (vault) | OBSOLETE AND FALSE in 0.2.10. The language has mutable state ($var = default → stateDeclarations, createStore() with get/set/subscribe), expressions that survive parsing (BinOp, Ternary, Member, StateRef, RuntimeRef; hasDynamicProps: true), 13 builtins (Count, Sum, Round, Filter, Sort…) plus @Each, actions (Action([...]) → an executable ActionPlan with Run/ToAssistant/OpenUrl/Set/Reset), and real two-way binding: markReactive(schema) makes the prop evaluate to ReactiveAssign and come out as $binding<number> in the prompt. The only thing it doesn’t have is lifecycle: no onMount, no effects, no timers except Query(refresh). |
reactive.mjs, binding.mjs, mutation.mjs |
“Their library.prompt() could replace our prompt_fragment()” |
The facts still stand; the verdict was revised that same afternoon (§3): yes, it replaces it, by feeding it our rules through additionalRules. What remains true: the 9 signatures are equivalent, but: it has no place for the 7 contract rules of §5.2 (only a free-text additionalRules: string[]), it has no escape rules, it has no EBNF, ~80% is hardcoded in English (generateSystemPrompt doesn’t accept a language), and it teaches syntax our parser rejects (booleans, null, objects, inline nesting). Its rule 5 (“any unreferenced variable is silently discarded”) is true in its runtime and false in ours: a semantic divergence, not a wording one. |
prompt-real.txt, prompt-bindings.txt, prompt-python.txt |
“We’re missing a Simulation component” |
False. A simulation with adjustable parameters is a Chart with values expressed via bound Sliders: demonstrated running ($price/$discount/$units → [1200,960] → [3000,1500] → [1350,675], without re-parsing). The inline-SVG ChartBlock is untouched. §5.3 left Simulation out and is still right to. |
reactive.mjs, program 2 |
Not verified, and I say so in these words: (1) actual React rendering. By its types,
@openuidev/react-lang wires up Renderer, reactive(), useStateField(), and
useTriggerAction(), but I have not mounted a React component or painted a single pixel: my
reactivity demonstration uses lang-core’s framework-agnostic runtime (createStore +
evaluateElementProps + evaluate). What’s proven is that the language and the runtime
express and compute it. (2) The vault’s exact wording: I don’t have access to the Obsidian
vault from this machine, and grep -rniE 'data binding|one-shot|lifecycle' docs/ doesn’t find it
in the repo; whoever has the vault should locate the exact note and correct it, citing this
document. (3) Token density: §5.4 justifies the dialect with “≈50% less than equivalent JSON”
and it is not measured — neither against the UISpec’s JSON, nor between prompts (theirs is
3254-3330 characters and ours is 3722, but characters aren’t tokens). (4) mergeStatements(),
enrichErrors(), @Each/@Filter/@Sort: read in the .d.mts files, not executed.
2. What “adopting OpenUI” would mean, at three levels
(a) Grammar only, parsing in Python — what existed until 2026-07-26
src/render/backends/openui.py (568 lines) parses the dialect, src/render/spec.py validates the
IR with Pydantic, React renders our own components from JSON.
- Marginal cost: 0. It’s already written, with 981 lines of tests and 16 fixtures.
- Risk: low and known. The only real risk is one of expectations: the name “OpenUI Lang” in
the prompt and the docstring suggests full conformance, and point 1 shows there isn’t any, nor
do we want it. An 8B model that has seen OpenUI Lang in its training will emit booleans,
null, objects, and inline nesting, because the standard has them — and today the prompt doesn’t forbid them: only the EBNF omits them, which isn’t the same thing for a small model. - What it doesn’t give: reactivity. No
$state, ternaries, orAction.
(b) @openuidev/lang-core in the backend or in a build step, keeping the IR
Two sub-variants, and neither survives analysis as production code:
- b1 — their parser in front of or in place of ours. Validation gain: zero (their 5 codes
are a subset of ours) and real loss: the 7 rules of §5.2, types, enums, and 3 of the 6 malformed
fixtures would pass, one dropping an entire line. Cost: Node in the Python backend runtime or a
subprocess, plus
@openuidev/lang-core+zod(7.2 MB innode_modules, 5.9 MB of which is zod). Rejected. - b2 — their
library.prompt()as the prompt generator in a build step. ADOPTED (§3 and §4). The diagnosis was correct and the conclusion wasn’t: §5.2 and the escape rules go in throughadditionalRules, the syntax we reject is forbidden imperatively with a rule that overrides theirs, and the language is partly lost and accepted as a cost. - b3 — the package only as a compatibility harness, outside the production build. A script
that checks our valid fixtures are still accepted by the real parser. This does have value (it’s
the only proof that “we adopted OpenUI Lang” isn’t marketing), and it is not a dependency:
the repo has no root
package.jsonnorpnpm-workspace.yaml, so atools/openui-compat/directory with its ownpackage.jsonis not installed bypnpm installinapps/skillnet-webnor copied by any Dockerfile. Accepted as optional, step 4.
(c) Full adoption: @openuidev/react-lang and <Renderer> in the browser — THIS IS THE DECISION
- Cost:
@openuidev/react-lang@0.2.9+@openuidev/lang-core@0.2.10+ peerzod(7.2 MB), plus rewriting the 10 block components as renderers in its registry, plus moving parsing to the client, plusparse_partialstreaming in the browser. - Risk, and the one that had to be resolved: it changes the security posture. Before, the
browser received JSON validated by Pydantic and never a DSL it had to interpret. The
objection was “the only validation left in the path is theirs, the one that accepts
<script>alert(1)</script>, made-up enums, and wrong types”. It was resolved, and here’s how: client-side parsing does not replace validation — the server still parses and validates with Pydantic before persisting, and the browser receives the canonical re-serialization of what was validated, never the model’s raw output (§4). Its parser on the client paints; ours decides. Also,0.2.xwas published on July 24, 2026: its API will break, and the harness already found three signature bugs in one day (createParser(library)is a silent type error that empties the catalog; the errors are inmeta.errors;defineComponentrequirescomponent). - What it would give: real reactivity, for free, without writing a runtime.
3. Decision: (c) without reactivity. Why, what’s gained, and what’s lost
This document’s earlier recommendation was (a) and it was defensible: our own parser is strictly stricter (§1), compatibility had already been demonstrated without installing anything, and the browser received validated JSON. The product owner decided the opposite, and the deciding criterion is not technical but about who pays for maintenance:
- With (a) we maintain, forever: the frozen grammar, the prompt generator, the EBNF, the catalog
duplicated in Python and TypeScript, and any v2 of the dialect the product asks for (
$state,Action, iteration). Every kit change gets paid for twice. - With (c) Thesys maintains the language, the parser, the streaming, and the prompt; we maintain
a list of components and a gate. The price is a
0.2.xdependency that will break, and that is exactly what a drift test turns into a CI failure instead of a surprise.
What is gained
- A single place where the catalog is declared.
apps/skillnet-web/src/components/courses/kit/(zod +defineComponent).apps/skillnet-web/scripts/generate-openui-prompt.mjscallslibrary.prompt()and writes two versioned artifacts that Python reads as data:apps/skillnet-api/src/render/openui_prompt.txtandopenui_catalog.json. Node doesn’t enter the request path: it’s a build step. - The prompt is written by their library. Their syntax blocks, signatures,
hoisting/streaming, and Final Verification — including the rule “write
root = Stack(...)on the first line”, which we didn’t have and which is what makes the skeleton appear earlier in streaming. Our 7 contract rules, the 3 escape rules, and the language go in throughadditionalRules, so nothing from §5.2 is lost. - Real streaming in the browser, for free.
<Renderer isStreaming>re-parses each chunk and reveals top to bottom. We write no runtime. - Reactivity is a switch away, with the conditions in §6 written and measured, instead of a v2 of our own dialect.
What is lost, stated plainly
- The prompt stops being fully in Spanish.
library.prompt()hardcodes its blocks in English and doesn’t accept a language: it contributes 3254 of the artifact’s 6420 characters (~51%), and those 3254 are theirs and in English. Before it was 3722, all ours and in Spanish. It’s the most visible loss and there’s no fix short of an upstream PR. - +2698 characters of prompt per generation request, and with them three blocks that teach
things our gate rejects (see points 7 and 8). Each needs one of our rules to explicitly override
it —
SkillNet 4,SkillNet 12, andSkillNet 13, 1506 characters of deliberate contradiction. Ugly, and measured: withoutSkillNet 4a small model emits booleans,null, and objects because the standard has them. - A
0.2.xdependency on the render critical path. None of the security properties we rely on (RESERVED_CALLS,open_urldelegated toonAction, absence offetchin the bundle) is a public contract. Mitigation: exact versions,PINNED_VERSIONSintests/test_render_prompt_artifact.py, and the obligation to re-runsec-sinks.mjs,sec-runtime.mjs, andsec-builtins.mjson every upgrade. - The browser now receives a language it has to interpret. That was argument (iii) of §5.4 and it’s no longer true. What replaces it is not a promise, it’s four stacked controls (§5), the first of which is that the browser never sees the model’s raw text.
- Two catalogs that can diverge — the zod one (frontend) and
src/render/kit.py(validation). The duplication hasn’t been eliminated; it’s become detectable: the normalized catalog digest is recomputed fromkit.pyand compared with the artifact on everypytest. Today they match byte for byte (skillnet-ui/1+ecaa7d56dcffcomes out the same from the frontend’s zod kit and from the bootstrap transcribed in §5.3, which is the cheapest cross-check there was). - What was already lost with (a) and remains lost: its repair loop (
mergeStatements,enrichErrors) isn’t used — the retry is stillUI_REPAIR_SYSTEMin Python — and@Each/@Filter/@Sortremain unexplored, so the max of 12 blocks is still a max of 12 statements. - Its
## Important Rulesblock asks the model to make up data — “When asked about data, generate realistic/plausible data” — and offers “forms for input”, a component category the catalog doesn’t have. In a regulatory compliance training generator that is the opposite of the product: every data point must come from the customer’s document (§5.1). It’s hardcoded in the bundle (offset 23931) andSystemPromptOptionshas no flag to turn it off. Overridden withSkillNet 13, which explicitly forbids inventing figures, deadlines, amounts, penalties, and regulatory references, and orders omitting the data point when the source doesn’t provide it. Detected during the 2026-07-26 review; this cost had been accepted without noticing it, it wasn’t on this list. - Its syntax rule 6 teaches a FALSE signature of the most-used component — “Write
Stack([children], \"row\", \"l\")NOT …” — with three arguments and agapof"l", when ours isStack(children, gap)withgapinsm|md|lg.Stackis the root of every program, so it’s the pattern a small model copies first, and copying it is rejected outright: it spends the only retry (MAX_UI_RETRIES = 1) and falls intofallback_seed. It’s also hardcoded (offset 9470) and also not suppressible. Overridden withSkillNet 12, with a safety net underneath: the tests intest_render_prompt_artifact.pywalk through everyComponent(...)call in the prompt and check arity and enum values againstUI_KIT, with the two bad examples from the vendor pinned inVENDOR_SYNTAX_EXAMPLESso that a new bad example fails instead of going unnoticed.
What is NOT adopted, and this is half of the decision
The reactive layer. Not because it can’t be bounded — it can, the cut is clean and it’s
measured in SEGURIDAD-MUTACIONES.md — but for two concrete reasons:
- There’s no partial profile in the prompt. Passing
toolsactivates the entire block of expressions ($var, ternary,@Set,@Run, builtins) and{tools:[...], bindings:false}still teaches$var. Measured: 12601 characters with tools versus 3254 without them. Teaching reactivity is all or nothing. - It swaps a structural property for a contract. Today “the grammar cannot express a mutation” is a property of the frozen grammar. With reactivity it becomes “the package behaves this way in 0.2.10”, which has to be verified on every release.
With the profile off, a poisoned PDF cannot trigger even a single network request: there is no
toolProvider to execute it, no component to fire the action, the backend does not persist the
model’s program, and the browser never sees its raw text.
On instant client-side quiz grading: the price, stated clearly
Fully client-side grading breaks rule 5 of §5.2. In the demonstration, the verdict is written
as $chosen == 1: the correct answer and the explanation travel to the client as plain text, which
is exactly what ANSWER_KEY_KEYS and “answer_key never serialized to the client” forbid. Instant
grading without a server and keeping the answer key secret are incompatible by construction —
it’s not a defect of OpenUI Lang, it’s arithmetic.
The path that does respect the rule has been demonstrated (mutation.mjs): Button("Check", Action([@Run(grade)])) + grade = Mutation("grade_answer", {item_id:"q1", choice:$chosen}) +
Callout(grade.correct ? "success" : "warn", grade.explanation). The evaluated args are
{"item_id":"q1","choice":1} — they don’t contain the correct answer. With
createQueryManager pointing literally to POST /nodes/{id}/answer: 1 round trip to the server,
the key stays in answer_key, and local state (selection, “already answered”, correct-answer
counter) remains instant. It’s the same architecture we already have, just expressed in the
dialect instead of TSX. That’s why there’s no urgency.
4. What has been executed (backend), and how it’s verified without keys or a database
| What | Where | Note |
|---|---|---|
| Prompt build step | apps/skillnet-web/scripts/generate-openui-prompt.mjs |
Reads the frontend kit’s catalog, calls library.prompt({preamble, additionalRules, examples}), and writes the artifacts. --check fails if they’re stale (for CI). It resolves @openuidev/lang-core the same way react-lang resolves it, so the build compiles the catalog with the same parser the browser will use. |
| Versioned artifacts | apps/skillnet-api/src/render/openui_prompt.txt, openui_catalog.json |
The .json carries the normalized catalog, catalog_digest, catalog_version, prompt_sha256, the package versions, and which file the catalog was read from (catalog_source). |
| Python reader | apps/skillnet-api/src/render/prompt.py |
render_prompt(), catalog_version(), library_version(), artefact_drift(). No Node at request time. |
| Drift test | apps/skillnet-api/tests/test_render_prompt_artifact.py (57 tests) |
Recomputes the normalized catalog digest from src/render/kit.py and compares it against the artifact; also checks that the prompt doesn’t teach reactivity, that the versions are the audited ones, and — since the 2026-07-26 review — that every Component(...) call in the prompt matches UI_KIT in arity and enums. |
| Render budget | apps/skillnet-api/src/render/spec.py (MAX_RENDERED_NODES = 64) + kit/assertStaticOnly.ts (too-many-elements) |
Recovers the cap our own renderer used to carry as MAX_RENDERED. Rule 4 counts components and the list is a DAG, so 12 components can expand into an unbounded tree: measured with lang-core 0.2.10, 370 bytes → 29,526 elements and 550 bytes → V8 heap OOM (not catchable: the tab dies). The server-side cap is the one that counts, because the client-side one needs a ParseResult that in that case no longer exists. |
| Lightweight textual gate | apps/skillnet-api/src/render/gate.py |
Size caps + reactivity rejection over the skeleton (text literals are emptied out first). canonicalize() is the only thing that produces the text that gets served. |
| Gate tests | apps/skillnet-api/tests/test_render_gate.py (66 tests) |
15 reactive payloads rejected, 6 legitimate contents accepted (including prose that mentions Query() and $300). |
| Traceability in the DB | node_renders.dialect, catalog_version, library_version + ck_node_renders_served_provenance |
raw_dsl disappears as a name: the temptation the security review documents was to serve it. Migration 0005 modified in place, not a new 0006. |
| Removal | prompt_fragment, GRAMMAR, ESCAPE_RULES, _PREAMBLE, _catalogue(), _contract(), _EXAMPLE |
Out of src/render/backends/openui.py and base.py. The parser stays: it’s the structural gate. |
src/render/kit.py is kept and still does real work: it’s the catalog src/render/spec.py
uses to validate types, enums, and the 7 rules — things OpenUI’s parser doesn’t check (§1) — and
it’s the Python side of the drift test.
Verification run, without Docker, without Postgres, and without keys:
uv run ruff check src tests → the 5 pre-existing errors, none new.
uv run pytest -m "not integration" -q → 1 failed, 1736 passed (the failure is
test_grade_open_answer_fallback, pre-existing on main).
Frontend: pnpm exec tsc -b → 0 · pnpm test → 272 passed across 14 files · pnpm lint → 0
errors, 7 pre-existing warnings.
The architectural rule that matters most
The browser only receives text re-serialized from the already-validated UISpec. Never the
model’s text. <Renderer response> accepts text, and sending the raw output would skip both
gates at once. That’s why serialize() now covers all ten components (Markdown included:
fallback_seed is written by the server and also needs a dialect form) while parse() keeps
rejecting Markdown, because the model cannot emit it. The asymmetry didn’t disappear: it
moved.
The four controls that replace “the browser receives validated JSON”
Stacked from outside in, all measured in SEGURIDAD-MUTACIONES.md:
- The browser never sees the model’s text — only the canonical re-serialization. A
UISpeccannot represent a reactive AST, so the property is structural. toolProviderabsent from<Renderer>→createQueryManager(null)andlang-core’s guards cut queries and mutations to zero. This is the hard cut.onActionandonStateUpdateabsent →@OpenUrland@ToAssistantare no-ops (the runtime doesn’t navigate, it just forwards to the prop) and@Setisn’t persisted. Reinforcement: no registered component callsuseTriggerAction(), so anActionPlanisn’t even reachable.
Controls 2 and 3 are absences of props, the kind of correctness a test suite doesn’t notice:
until the 2026-07-26 review only a comment protected them. Since then it’s guarded by
apps/skillnet-web/src/components/courses/UiSpecRenderer.runtime.test.tsx, which mocks
<Renderer>, captures its props, and requires that toolProvider, onAction, and
onStateUpdate not be present as a key (so toolProvider={undefined} also fails), that
<Renderer> is mounted from a single module, and that no file imports useTriggerAction,
reactive, markReactive, createQueryManager, createStore, or useStateField from
@openuidev.
4. The gate, on both sides — gate.py + the Python parser before persisting;
assertStaticOnly(parseResult) in onParseResult on the client. Keyword grepping over the raw
text is forbidden as a hard rejection: it rejects legitimate lessons.
All four are about reactivity, and that’s why none of them caught the resource exhaustion: a
tree of 29,526 elements without a single Query passes all four. That gap is closed by
MAX_RENDERED_NODES (§4), and the general lesson is that the client-side gate cannot be the only
one for anything that kills the parser: a V8 heap OOM happens inside parseBothWays, before
a ParseResult even exists to inspect, and gateProgram’s try/catch doesn’t catch it.
And the fifth, the cheapest: the prompt. Without tools and without markReactive,
library.prompt() doesn’t mention the reactive syntax. It’s defense in depth, not a barrier: if
the model emits it from memory, the gate is what rejects it.
4 bis. 2026-07-27 correction: the inline nesting was ours, not the model’s
Testing the real prompt against a local model (qwen2.5:7b-instruct, Ollama, CPU) turned up two
failures that weren’t the model’s fault:
- The parser rejected inline nesting (
root = Stack([TextContent("Hello.", "lead")], "md")) while the signature block generated bylibrary.prompt()offers it literally: “Sub-components can be inline or referenced; prefer references for better streaming”. We taught it a construction and then rejected it, which is exactly what §1 says not to do. - The error message misdiagnosed. Faced with the parenthesis of a nested call it responded “the usual cause is an unescaped double quote”. The repair loop reinjects that text, the model moves quotes that were fine, and repeats: 0 of 2 programs rescued in 3 attempts.
What changes, and what doesn’t:
parseaccepts nested calls where it currently accepts a reference, and flattens them into theUISpec’s flat list with deterministic synthetic ids (root_1,root_1_1, …). The max of 12 blocks and 5 root children is counted after flattening, so ten inline blocks cost exactly what ten declared ones cost. Per-line depth is capped (16) so a pathological program can’t pick CPython’s recursion limit as a denial of service.serializedoesn’t change: it still writes the referenced form, one declaration per line. It’s the canonical form and the only one the browser can receive. This also removes loss case #2 from §1 (“inline nodes:statementId: null, ids have to be synthesized”): the server synthesizes the ids, they’re persisted in theUISpec, and they reach the browser as normal declarations, which is whatnode_render_viewsneeds in order to measure block by block.- The parser’s error messages stop guessing. Each names a verified cause and says what to
write instead: the keyword argument (
gap = "md") is called a keyword argument, and the unescaped-quote diagnosis only appears when a text literal has actually just closed. They’re part of the contract with the LLM, because the repair loop forwards them literally. 2026-07-27 correction: a “statement split across two lines” is no longer an error.lang-coresplits statements at line breaks at bracket depth 0 and we were splitting at every one, which is exactly the “strict subset” failure again from §1.src/render/lines.pyjoins physical lines while a bracket is open; a text literal still has to close its quote on its own line. - The SkillNet 4 prompt rule no longer lists inline nesting among the forms that reject the whole program (it was false from the moment the parser accepts it); SkillNet rule 1 still prefers references, which is the library’s own recommendation and what lets the screen mount while it’s being generated.
UI_REPAIR_SYSTEMgains three WRONG/RIGHT pairs, because these are mistakes the model made even though the prompt already forbade them in prose.PROMPT_VERSIONbecomesruntime/2, so it invalidates the render cache. 2026-07-27 correction: the “call split across several lines” pair was removed — it taught a rule that doesn’t exist and spent the only retry reformatting a valid program. In its place there’s a pair for an accented identifier and one for the answer key.PROMPT_VERSIONisruntime/3.
What the test does not change and is worth not forgetting: 60-150 s per generation on CPU and ~4 minutes per repair loop confirm that the local model is fine for tests, not for serving an employee; and a 7B model invented allergens outside the EU’s 14 mandatory-declaration ones, which reinforces that the creator’s gate over the schema is not bureaucracy.
5. What gets decided later, and by what signal
| Deferred decision | Concrete signal that triggers it | What we would do |
|---|---|---|
| Decided and executed on 2026-07-26. No longer a deferred decision. | — | |
Turn on reactivity (Mutation to grade without leaking the answer_key) |
node_render_views showing that nodes with QuizItem have worse abandonment or time-to-first-answer than the rest, or explicit feedback of “I don’t know if I got it right”. A product decision backed by data. |
The non-negotiable conditions are in §6. Never a v2 of our own dialect: that path no longer exists |
Copy mergeStatements/enrichErrors into Python |
The first-retry success rate of UI_REPAIR_SYSTEM measured in production. If the model doesn’t recover with the error in hand, its hint with the correct signature is the first thing to copy. |
Reimplement in openui.py, don’t import |
| Fix or drop the “≈50% less than JSON” claim in §5.4 | Someone asks for it in a review, or the token cost of genera_ui shows up on the bill. Today it’s an unmeasured claim, and now there are also +1660 fixed prompt characters that are measured. |
Measure it with tiktoken over the 10 valid fixtures against their UISpec in JSON, and either fix the number in §5.4 or delete it |
| Return the prompt to Spanish | An upstream PR that accepts a language in generateSystemPrompt, or the small model starting to answer in English. |
Measure the English-response rate before writing code |
Explore @Each/@Filter/@Sort |
The 12-block max of rule 4 starts rejecting legitimate specs (a 6-item quiz is already 7 blocks). | Evaluate whether iteration over arrays belongs in the v2 of the dialect |
| Fix the vault note | Nobody with vault access has done it yet. | Locate the note that says “no data binding / one-shot / render only”, mark it obsolete, and point here |
6. If reactivity is ever turned on: non-negotiable conditions
From SEGURIDAD-MUTACIONES.md, and none of them is optional:
toolProviderwith MCP shape ({ callTool({name, arguments}) }) and aSetof allowed names. Never a function map:map[toolName]is property access andMutation("constructor")returnsstatus:"success"witherrores=[], which breaks the halt on failure of@Runchains. Measured.- Zero
Querys allowed, onlyMutation. Queries self-trigger in auseEffectas soon asisStreamingbecomesfalse, with no click at all, and accept an uncappedrefreshInterval(4 calls in 3.3 s withrefreshInterval=1, measured). The “the employee needs to click something” reasoning holds forMutationand not forQuery. - The allowlist goes inside
callTool, not in a text grep: the tool’s name can be a computed expression (Mutation($t + "_all_users", ...)comes out asBinOp/StateRef). onAction, if wired up, filters by type and dropsopen_urlandcontinue_conversation. The parser acceptsjavascript:in@OpenUrlwithout complaint.- Authorization always on the server:
grade_answerchecks that thenode_idbelongs to a course the user has access to, and is idempotent. The client allowlist is attack-surface reduction, not authorization. RENDER_ALLOW_REACTIVE(src/config.py) is the server-side switch, and today it’sFalse. Turning it on only relaxes the textual check: the Python parser still rejects, because its grammar cannot represent a mutation. To turn it on for real, the grammar has to change, and that is a PR with this list in front of it.
Appendix: what was rejected, and why
- Replacing our parser with
createParserin the backend. Still rejected, and now it’s the decision that underpins the whole security posture: it silently accepts 3 of 6 malformed fixtures (one with data loss), doesn’t check types, enums, or the 7 rules of §5.2, and letsMutation("delete_all_users", {...})through withmeta.errors=[]. Adopting the dependency wasn’t adopting its validation. - Serving
raw_dslto the browser. It’s the dominant risk of this migration, not the package. The column no longer exists under that name. ReplacingAccepted on 2026-07-26. The reason for the rejection (it would lose §5.2, the 3 escape rules, and the language) was solved by feeding them throughprompt_fragment()withlibrary.prompt().additionalRules; what wasn’t solved is the language of their own blocks, and it’s accepted as a cost (§3).- Adding a
Simulationcomponent. It’sChart+Sliders + expressions. §5.3 is still right. - Fully client-side grading in this PR. It works; it requires serializing the correct answer to the client and breaks rule 5.
- Changing the frozen grammar in this PR. It’s the right path when the interactive quiz comes up, but it’s a v2, not a patch.