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.9 and @openuidev/lang-core@0.2.10 come 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 own library.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.x API 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, no onAction, no onStateUpdate, no tools in the prompt, and no component calling useTriggerAction(). 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-courses branch.

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 = defaultstateDeclarations, 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, or Action.

(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 in node_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 through additionalRules, 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.json nor pnpm-workspace.yaml, so a tools/openui-compat/ directory with its own package.json is not installed by pnpm install in apps/skillnet-web nor 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 + peer zod (7.2 MB), plus rewriting the 10 block components as renderers in its registry, plus moving parsing to the client, plus parse_partial streaming 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.x was 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 in meta.errors; defineComponent requires component).
  • 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.x dependency that will break, and that is exactly what a drift test turns into a CI failure instead of a surprise.

What is gained

  1. A single place where the catalog is declared. apps/skillnet-web/src/components/courses/kit/ (zod + defineComponent). apps/skillnet-web/scripts/generate-openui-prompt.mjs calls library.prompt() and writes two versioned artifacts that Python reads as data: apps/skillnet-api/src/render/openui_prompt.txt and openui_catalog.json. Node doesn’t enter the request path: it’s a build step.
  2. 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 through additionalRules, so nothing from §5.2 is lost.
  3. Real streaming in the browser, for free. <Renderer isStreaming> re-parses each chunk and reveals top to bottom. We write no runtime.
  4. 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

  1. 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.
  2. +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, and SkillNet 13, 1506 characters of deliberate contradiction. Ugly, and measured: without SkillNet 4 a small model emits booleans, null, and objects because the standard has them.
  3. A 0.2.x dependency on the render critical path. None of the security properties we rely on (RESERVED_CALLS, open_url delegated to onAction, absence of fetch in the bundle) is a public contract. Mitigation: exact versions, PINNED_VERSIONS in tests/test_render_prompt_artifact.py, and the obligation to re-run sec-sinks.mjs, sec-runtime.mjs, and sec-builtins.mjs on every upgrade.
  4. 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.
  5. 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 from kit.py and compared with the artifact on every pytest. Today they match byte for byte (skillnet-ui/1+ecaa7d56dcff comes 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).
  6. What was already lost with (a) and remains lost: its repair loop (mergeStatements, enrichErrors) isn’t used — the retry is still UI_REPAIR_SYSTEM in Python — and @Each/@Filter/@Sort remain unexplored, so the max of 12 blocks is still a max of 12 statements.
  7. Its ## Important Rules block 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) and SystemPromptOptions has no flag to turn it off. Overridden with SkillNet 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.
  8. Its syntax rule 6 teaches a FALSE signature of the most-used component“Write Stack([children], \"row\", \"l\") NOT …” — with three arguments and a gap of "l", when ours is Stack(children, gap) with gap in sm|md|lg. Stack is 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 into fallback_seed. It’s also hardcoded (offset 9470) and also not suppressible. Overridden with SkillNet 12, with a safety net underneath: the tests in test_render_prompt_artifact.py walk through every Component(...) call in the prompt and check arity and enum values against UI_KIT, with the two bad examples from the vendor pinned in VENDOR_SYNTAX_EXAMPLES so 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 tools activates 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" -q1 failed, 1736 passed (the failure is test_grade_open_answer_fallback, pre-existing on main). Frontend: pnpm exec tsc -b → 0 · pnpm test272 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:

  1. The browser never sees the model’s text — only the canonical re-serialization. A UISpec cannot represent a reactive AST, so the property is structural.
  2. toolProvider absent from <Renderer>createQueryManager(null) and lang-core’s guards cut queries and mutations to zero. This is the hard cut.
  3. onAction and onStateUpdate absent@OpenUrl and @ToAssistant are no-ops (the runtime doesn’t navigate, it just forwards to the prop) and @Set isn’t persisted. Reinforcement: no registered component calls useTriggerAction(), so an ActionPlan isn’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 sidesgate.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:

  1. The parser rejected inline nesting (root = Stack([TextContent("Hello.", "lead")], "md")) while the signature block generated by library.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.
  2. 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:

  • parse accepts nested calls where it currently accepts a reference, and flattens them into the UISpec’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.
  • serialize doesn’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 the UISpec, and they reach the browser as normal declarations, which is what node_render_views needs 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-core splits 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.py joins 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_SYSTEM gains three WRONG/RIGHT pairs, because these are mistakes the model made even though the prompt already forbade them in prose. PROMPT_VERSION becomes runtime/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_VERSION is runtime/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
Level (c) 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:

  • toolProvider with MCP shape ({ callTool({name, arguments}) }) and a Set of allowed names. Never a function map: map[toolName] is property access and Mutation("constructor") returns status:"success" with errores=[], which breaks the halt on failure of @Run chains. Measured.
  • Zero Querys allowed, only Mutation. Queries self-trigger in a useEffect as soon as isStreaming becomes false, with no click at all, and accept an uncapped refreshInterval (4 calls in 3.3 s with refreshInterval=1, measured). The “the employee needs to click something” reasoning holds for Mutation and not for Query.
  • 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 as BinOp/StateRef).
  • onAction, if wired up, filters by type and drops open_url and continue_conversation. The parser accepts javascript: in @OpenUrl without complaint.
  • Authorization always on the server: grade_answer checks that the node_id belongs 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’s False. 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 createParser in 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 lets Mutation("delete_all_users", {...}) through with meta.errors=[]. Adopting the dependency wasn’t adopting its validation.
  • Serving raw_dsl to the browser. It’s the dominant risk of this migration, not the package. The column no longer exists under that name.
  • Replacing prompt_fragment() with library.prompt(). Accepted 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 through additionalRules; what wasn’t solved is the language of their own blocks, and it’s accepted as a cost (§3).
  • Adding a Simulation component. It’s Chart + 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.