The Calculator Fallacy in Generative UI
"An LLM is a phenomenal semantic reasoning engine, but it is an awful, slow, and expensive pocket calculator."
If you haven't watched Andrew "Red" Brogdon's walkthrough on the Flutter channel (Client side functions for Flutter GenUI), stop and watch it. In five minutes, Red exposes the fundamental flaw lurking beneath most early Generative UI demos: the round-trip calculation trap.
Suppose an AI agent generates a recipe or shopping cart card for a user. The user drags a slider to scale the recipe from 2 servings to 6 servings, or edits the quantity of an ingredient.
What happens next in naive GenUI systems?
- The client captures the input change.
- It serializes the state and sends a network payload back to the remote model over Server-Sent Events (SSE) or WebSockets.
- The LLM burns hundreds of tokens computing
3.50 * 3, occasionally hallucinates floating-point arithmetic or loses formatting consistency, and responds over a multi-second latency gap. - The client finally repaints the screen.
Making a remote AI call to multiply two numbers on a mobile device equipped with an Apple Silicon or Snapdragon processor capable of billions of operations per second is an architectural anti-pattern.
As Red demonstrated with the Commis kitchen assistant app, the solution is Client-Side Functions: the agent designs the interface and binds expressions, but delegates deterministic execution to local Dart code registered in the client's catalog.
In bloc_signals_genui and bloc_signals_genui_flutter, we have taken this concept to its logical architectural conclusion by pairing Google's official A2UI protocol with synchronous reactive signals.
The Missing Link: Reactivity Without the Async Tax
Running a Dart function locally fixes the latency and token cost of LLM round-trips. But it creates a secondary architectural challenge: how does the UI react when local state changes?
In asynchronous architectures, developers often end up wiring StreamController pipelines, debouncers, or manual setState() callbacks to propagate local math back into generated widgets. Every slider notch pays an event-loop and microtask tax.
Because bloc_signals_genui is built directly on Google's official a2ui_core engine—which natively uses preact_signals—client-side functions are not just callable routines: they are first-class reactive computed() signals.
flowchart TD
User["User Drags Slider / Edits Field"] -->|"1. Updates Local DataModel (0ms)"| Data["DataModel Signal ('/recipe/servings')"]
Data -->|"2. Synchronous Dependency Trigger"| Computed["computed(() => calculateCost(...))"]
Computed -->|"3. Same-Frame Leaf Rebuild (120 FPS)"| Widget["Text Widget ('$14.50')"]
When a user adjusts a quantity, the calculation runs in the exact same frame. There are zero network round-trips, zero microtask hops, and zero UI flickers.
Step-by-Step: Implementing Client-Side Functions
Here is how you implement and wire client-side functions using bloc_signals_genui.
1. Define the Function in Pure Dart
Subclass FunctionImplementation from bloc_signals_genui (re-exported from a2ui_core). Declare the function's name, return type, and typed JSON argument schema using CommonSchemas.dynamicNumber:
import 'package:bloc_signals_genui/bloc_signals_genui.dart';
import 'package:json_schema_builder/json_schema_builder.dart'
hide ValidationResult;
class CalculateCostFunction extends FunctionImplementation {
CalculateCostFunction()
: super(
name: 'calculateCost',
returnType: A2uiReturnType.number,
argumentSchema: Schema.object(
properties: {
'unitPrice': CommonSchemas.dynamicNumber,
'quantity': CommonSchemas.dynamicNumber,
'discount': CommonSchemas.dynamicNumber,
},
required: ['unitPrice', 'quantity'],
),
);
@override
Object? execute(
Map<String, Object?> args,
DataContext context, [
CancellationSignal? cancellationSignal,
]) {
final unitPrice = (args['unitPrice'] as num?)?.toDouble() ?? 0.0;
final quantity = (args['quantity'] as num?)?.toDouble() ?? 0.0;
final discount = (args['discount'] as num?)?.toDouble() ?? 0.0;
return (unitPrice * quantity) * (1.0 - discount);
}
}
Notice that execute() receives a DataContext. That means your function can read or scope additional local surface state if needed, completely decoupled from Flutter widgets.
2. Register the Function in Your Catalog
Add your function to StandardCatalog using .copyWith(...) and pass it to your A2uiSurfaceBloc:
final catalog = StandardCatalog().copyWith(
functions: [
CalculateCostFunction(),
...BasicCatalog.v0_9().functions.values,
],
);
final surfaceBloc = A2uiSurfaceBloc(
catalogs: [catalog],
);
When your app advertises its capabilities or system prompt to the agent, the agent is informed of calculateCost and its required parameters.
3. The Agent Streams the Reactive Binding
When the AI agent streams down an A2UI JSON component, it doesn't hardcode a static string. Instead, it binds the text property of a Text component to the function call, referencing paths in the local DataModel:
{
"id": "cost_display",
"component": "Text",
"text": {
"call": "calculateCost",
"args": {
"unitPrice": 4.50,
"quantity": { "path": "/cart/items/0/qty" },
"discount": 0.10
}
}
}
4. Zero-Latency Execution Under the Hood
When bloc_signals_genui and bloc_signals_genui_flutter resolve this component, DataContext.resolveSignal() from a2ui_core evaluates the dynamic function call:
// Conceptual resolution inside DataContext.resolveSignal():
ReadonlySignal<Object?> resolveSignal(Object? value) {
if (value is Map && value.containsKey('call')) {
final callName = value['call'] as String;
final rawArgs = value['args'] as Map<String, dynamic>? ?? {};
// 1. Recursively resolve arguments into signals
final argSignals = rawArgs.map(
(key, val) => MapEntry(key, resolveSignal(val)),
);
// 2. Wrap catalog execution in a synchronous computed() signal
return computed(() {
final evaluatedArgs = {
for (final entry in argSignals.entries)
entry.key: entry.value.value,
};
return catalog.invoke(callName, evaluatedArgs, this);
});
}
// Literal values or data bindings ({'path': ...}) resolve directly
// ...
}
Notice what happens:
- Every argument pointing to a
DataModelpath (for example/cart/items/0/qty) resolves to an underlying reactive signal. - The entire function call compiles into a native
computed()signal. - Whenever the user alters the quantity in a
TextFieldorSlider,bloc.add(UpdateFormField('/cart/items/0/qty', newValue))mutates theDataModelsynchronously. - The
computed()signal marks itself dirty and recalculates immediately in the exact same frame (0ms latency, zero microtasks). - The Flutter widget (
NodeResolver/A2uiSurfaceView) performs a surgical leaf rebuild at 120 FPS.
5. Interactive Actions vs. Bidirectional RPC
In addition to dynamic display properties, client-side functions integrate seamlessly with user actions and remote agent coordination. It is crucial to understand the two distinct operational paths:
Path A: User Interaction via SubmitAction
When a user taps an interactive component (such as a Button), the client dispatches a SubmitAction event to A2uiSurfaceBloc. The action can either trigger a local client-side function (for example applying a local coupon code or validating form inputs) or emit an A2uiRendererAction outbound to the remote agent:
{
"id": "apply_coupon_btn",
"component": "Button",
"text": "Apply 20% Discount",
"action": {
"functionCall": {
"call": "applyDiscountCode",
"args": { "code": "SAVE20" }
}
}
}
Path B: Bidirectional RPC (CallRendererFunctionMessage & callAgentFunction)
Generative UI is not a one-way street. A2uiSurfaceBloc provides full bidirectional RPC across the client-agent boundary:
-
Inbound RPC (
CallRendererFunctionMessage): The remote agent can actively invoke registered client-side functions on demand. If the agent needs to compute a verified total using client business rules, read device GPS coordinates, or query local SQLite storage, it sends aCallRendererFunctionMessage. The client executes the catalog function locally and responds with a typedRendererFunctionResponseMessage. -
Outbound RPC (
callAgentFunction): The renderer can invoke remote tool capabilities on the agent viabloc.callAgentFunction(), resolving asynchronously when the matchingAgentFunctionResponseMessagereturns.
Architectural Boundaries for Modern GenUI
Client-side functions draw a clean, resilient line across your Generative UI architecture:
| Responsibility | Handled By | Latency & Reliability |
|---|---|---|
| Generative Layout & Reasoning | Remote LLM (Gemini, Claude, GPT) | Async streaming over network (SSE) |
| Deterministic Business Logic & Math | Local Dart Functions (FunctionImplementation) |
0ms synchronous execution on-device |
| Reactivity & State Synchronization | Signals Graph (BlocSignal + a2ui_core) |
Same-frame UI update without microtasks |
| Hardware & Native Platform APIs | Bidirectional RPC (CallRendererFunctionMessage) |
Local platform channels & secure sandbox |
By adopting Andrew Brogdon's client-side functions pattern and grounding it in a synchronous signal engine, you get the best of both worlds: the creative flexibility of Generative UI with the speed, accuracy, and reliability of native Flutter.
Explore bloc_signals_genui and bloc_signals_genui_flutter on pub.dev, and check out the interactive samples at blocsignal.dev.
Top comments (44)
The round-trip trap is the sharpest framing here — paying hundreds of tokens and seconds of latency for 3.50 x 3 is hard to defend once you see it. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. Do you see teams resisting this because the agent has to be told about the catalog, or is the catalog the easy part?
The catalog is actually the easy part—especially since modern LLMs are already trained extensively on tool calling and JSON schemas. When an agent connects to an A2UI renderer, advertising registered catalog functions and their
CommonSchemasis no different than handing a standard function-calling schema to Gemini or Claude. The models pick up the bindings immediately and rarely hallucinate the arguments once they have typed schemas.Where I see teams hesitate initially isn't the catalog—it is the paradigm shift regarding boundaries:
StreamControllerpipelines, debouncers, or manualsetState()webs across generated widgets. Once they see that the catalog function compiles directly into a nativecomputed()signal—updating the leaf widget in the exact same frame with 0ms latency and zero microtasks—that resistance evaporates into relief.Telling the agent what functions exist is just schema advertising. The real win is deciding up front that your app's math, device hardware, and business rules belong to the local processor, not the token budget.
The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. And the async-plumbing fear is real only until someone sees the computed() signal update in the same frame; that's the demo that converts skeptics. The boundary decision up front is the actual architecture work — everything else is just wiring.
"The boundary decision up front is the actual architecture work — everything else is just wiring."
That summary shows how well the message resonated. When readers adopt your framing ("The Oracle Fallacy") and independently play back the core principle—that the demo converting skeptics is seeing the synchronous computed() signal update in the exact same frame without the async latency tax—that's how architectural ideas take root.
High-signal discussion from start to finish!
That's the best compliment a framing can get — when someone else starts using it back at you. The demo point is exactly right: seeing the computed() signal update in the same frame is what makes the abstraction click for skeptics. Where does the boundary decision get hardest for you — when the catalog itself is dynamic and the agent has to discover functions at runtime?
Dynamic runtime discovery is actually the straightforward mechanical part—we already solve that cleanly with capability handshakes, role-based scoping, or MCP-style tool discovery (for example, unlocking privileged catalog functions only when a user authenticates or navigates into a specific enterprise surface).
Where the boundary decision gets genuinely difficult is semantic grain size:
1. The "Assembly Language" vs. "God Function" Dilemma
multiply(),add(), orformatCurrency()), you force the agent to compose arithmetic ASTs in JSON. You end up accidentally reinventing a fragile Lisp interpreter in JSON, and the agent will eventually botch business-rule order (for example, calculating regional sales tax before versus after applying manufacturer rebates).calculateEntireCheckoutState()), you strip away the agent's generative flexibility. At that point, you aren't doing Generative UI anymore; you're just writing a classic hardcoded wizard with an AI decorator.Finding that sweet spot—functions that encapsulate a complete, inviolable domain rule (like
calculateCartItemCostorvalidatePostalCode) while still leaving the layout and data-flow topology to the agent—is where real domain modeling happens.2. Referential Transparency vs. Local Side Effects
The other hard edge is side-effect boundaries. Pure math (
price * qty * (1 - discount)) is trivial because it is referentially transparent—it can recalculate fifty times a second with zero consequences. But as soon as a client function touches device hardware (GPS coordinates, biometric auth) or mutates local persistence (reserving local cache slots), you have to strictly quarantine it out of reactivecomputed()signals and route it strictly through explicit, deliberate user actions (SubmitAction).If a function has side effects, it cannot live in the reactive rendering graph. That separation is where architecture either holds or falls apart.
The grain-size framing is the part I'll carry forward — 'a complete, inviolable domain rule' is a much better test for a catalog function than 'does it save a round trip.' And the side-effect point quietly corrects my computed() enthusiasm: the signal trick only works for pure functions, and the moment a function touches hardware or persistence it has to leave the reactive graph. Where do you draw that line for functions that only read local state, like validatePostalCode against a cached table — pure enough for computed(), or does any I/O at all push it to SubmitAction?
The boundary comes down to a simple, physical rule of thumb: is it a synchronous in-memory read, or does it cross an async boundary (disk, network, or IPC)?
1. In-Memory Tables: 100% Safe for
computed()If
validatePostalCodequeries an in-memory lookup table—like aSet<String>, Trie, or hash map pre-loaded in memory or cached in repository state—it is completely synchronous, deterministic, and executes in sub-microsecond time (O(1)).It has zero side effects and never leaves the CPU cache. That is completely pure enough for
computed(). The user types the fifth digit of a ZIP code, the signal evaluates synchronously, and the validation checkmark or error text repaints in the exact same frame at 120 FPS.2. Actual I/O (Async Futures): Must Leave the Synchronous Graph
The moment "cached table" means an async call—like querying an on-disk SQLite database, reading file storage, or invoking an async platform channel (
Future<bool>)—it must not live directly inside a synchronouscomputed()signal.Dart signals propagate synchronously in the UI isolate. If a function returns a
Future, you are forced to introduce microtask hops, debouncers, or async state wrappers (AsyncSignal<T>), which reintroduces the very frame-budget jitter and UI flicker you set out to avoid.The Architectural Compromise: Pre-warm the Cache
In production architectures, we solve this by separating cache population from catalog evaluation:
DataContext.If a lookup genuinely requires on-demand async I/O that cannot be cached in advance (for example, verifying an address against a live postal API), that is no longer a rendering-time expression—it is an explicit intent. You route it through
SubmitAction, let the BLoC handle the loading state, and write the verified result back into theDataModelwhen the future resolves.That's the rule of thumb I'll steal — synchronous in-memory read versus async boundary is a test anyone on the team can apply without arguing about purity. The pre-warm compromise is the real answer to my catalog question: the catalog stays a pure read, and the hard part moves to keeping the cache fresh. How do you handle staleness there — TTL on the pre-warmed table, or event-driven refresh when the source changes?
The classic adage is undefeated here: there are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one errors.
In practice, I strongly favor event-driven refresh over passive TTL, with a pragmatic stale-while-revalidate hybrid when network calls are unavoidable. Here is why:
1. The Trouble with Pure TTL in UI Systems
A passive TTL (for example, expiring an entry after five minutes) is almost always a guessing game:
2. Event-Driven Refresh Fits Unidirectional Architecture
Because
bloc_signalsis already built on unidirectional data flow, event-driven updates are natural:TaxRatesUpdatedorInventorySnapshotReceived).DataContext, any dependentcomputed()signal marks itself dirty and recalculates synchronously in that exact same frame. The UI repaints seamlessly without spinners or polling loops.3. Stale-While-Revalidate for Semi-Static Domain Data
For domain data that changes occasionally (like exchange rates or shipping rate tiers):
0ms), keeping the UI responsive at 120 FPS.And for truly static reference data (like standard postal code formats, unit conversions, or ISO country codes)? Version-stamp it with the app release or bundle it in the asset catalog. The fastest, freshest cache is the one that never has to invalidate at all!
The mid-drag invalidation point is the one that sells it — a TTL expiring mid-keystroke reads as a UI glitch, not a cache bug, so nobody debugs the right layer. Event-driven fits the unidirectional flow so cleanly that the TTL starts to look like a workaround for not having events. Do you version-stamp the catalog itself, or does the event carry the version tag?
"TTL starts to look like a workaround for not having events"—that is an absolute gem of a phrase, and I am stealing that one!
Regarding version tags: we strictly separate the Catalog's semantic version from the Event's data version.
Here is why coupling them causes pain, and how we split the responsibilities:
1. The Catalog Version is the Schema Contract
In Google's A2UI protocol and
bloc_signals_genui, the catalog is versioned semantically (BasicCatalog.v0_9(),v1_0(), or your app'sAppCatalog.v2()).This represents the structural capability contract advertised to the agent during session handshake:
Card,Text,Slider)calculateCost,validatePostalCode)You do not want to bump the catalog version when runtime data changes (like updating tax rates or zip code tables). Bumping the catalog version mid-flight forces the agent to renegotiate its tool schemas, discard in-flight prompts, or re-parse its system instructions.
2. The Event Carries the Monotonic Data Version
Runtime data updates belong entirely in the event stream:
PostalCodeDirectoryUpdated(version: 42, entries: ...)orTaxRatesRefreshed(etag: "3a8f9b", rates: ...).DataContext.If you ever need auditability (for example, proving which tax table was used when the user saw a quote), the function can simply attach that data version tag to the result object.
The Summary Rule:
That keeps the agent's schema static and predictable while letting your domain data mutate at whatever velocity the business requires!
That's a clean split — the catalog version is the contract, the event version is the data, and coupling them would tax every data change with a renegotiation. The part that convinces me is the mid-flight cost: bumping the catalog forces the agent to discard in-flight work for what is really just a data update. Same principle as the TTL point, really: don't let a data problem wear a schema costume.
"Don't let a data problem wear a schema costume."
Print that out and tape it to the monitor of every engineer building agentic tools. That sums up the entire architectural lesson in eight words.
Terrific discussion—thanks for digging so deeply into the real mechanics!
That line deserves the tape treatment — the schema costume is exactly what happens when the catalog describes the shape but not the meaning. I'm curious where you see it most: teams over-validating inputs that are wrong at the source, or the catalog itself carrying the wrong model?
Hands down, it is the catalog carrying the wrong model—specifically, the classic trap of Primitive Obsession.
When teams first build an agent catalog, they almost always port over their raw database DTOs or loose JSON dictionaries. You see schemas full of
String status,double discount, andMap<String, dynamic> metadata.The schema describes the physical shape of the bytes, but completely strips the semantic meaning:
statusis just an open string, the model can happily stream"pending_but_cancelled".discountis just a raw float, the model can emit1.50(150% discount) or-0.20.The Retaliatory Band-Aid: Over-Validating
Once the LLM inevitably emits one of those nonsensical combinations, teams panic. But instead of fixing the catalog model, they react by piling on a mountain of defensive validation checks, regex assertions, and runtime exception traps.
They try to cure an anemic domain model with procedural bureaucracy.
The Cure: Make Invalid States Unrepresentable
The enduring lesson from typed functional programming (Alexis King’s famous "Parse, don't validate") applies double to LLM catalogs:
draft,submitted, orsettled, declare an enum in the JSON schema. The LLM's constrained decoding grammar won't even generate an invalid token.5%,10%,20%), expose the allowed promo codes or tier enum in the schema rather than accepting an unboundeddoubleand then scolding the user when they type500%.When the catalog carries the true domain model, 90% of the input-validation glue code simply evaporates. If the agent can only express states that are mathematically and legally valid in your domain, you never have to play whack-a-mole with edge-case checkers!
That reframes my question nicely — the catalog isn't the easy part, it's where the anemic model hides. 'Parse, don't validate' fits the version split we talked about too: if the catalog carries closed enums and bounded tiers, a data change stays a data change instead of forcing a catalog renegotiation. Do you see teams actually adopting that, or does the DTO-port habit survive because the catalog is usually generated from the backend?
You identified the exact gravity well that keeps the bad habit alive: automated backend code generation.
For the past decade, teams have spent millions building slick OpenAPI/Swagger, Protobuf, and GraphQL generation pipelines. It is seductively easy to point a code generator at a Swagger spec and spit out fifty DTO classes with zero human effort.
Teams proudly say, "Look, our agent catalog is auto-generated from our backend API!"
What they have actually built is an automated pipe that blasts anemic primitive types directly into the LLM prompt. The generator knows what a JSON string and float are; it has zero concept of domain invariants.
How Teams Actually Break the Habit
Teams only abandon the raw DTO-port reflex after they hit what I call the "Validator Tipping Point":
taxRate: -0.05or conflicting status strings).The Breakthrough: The Catalog as an Anti-Corruption Layer
The teams that successfully adopt bounded catalogs treat the catalog the way Eric Evans described an Anti-Corruption Layer (ACL) in Domain-Driven Design:
It takes an organizational mindset shift: moving from "the catalog is just an export of our REST endpoints" to "the catalog is a high-level domain interface designed specifically for cognitive reasoning." Once a team makes that jump, they never go back to raw DTO dumps.
The ACL framing is the piece that makes this click — a catalog designed for cognitive reasoning, not exported from the wire format. And the validator tipping point is a genuinely useful diagnostic: when the validation glue costs more than the domain model, the catalog was wrong, not the model. Do you see the bounded types as hand-curated per domain, or is there a repeatable way to derive them from the invariants teams already have?
You don't have to invent them from a blank sheet of paper—and you definitely shouldn't hand-craft an arbitrary bespoke schema for the AI.
In every mature codebase, those invariants already exist. They're just buried in places developers forget to look when building AI tools.
Here is the repeatable 3-step extraction recipe I use to derive bounded catalog types from invariants the team already owns:
1. Mine the Domain Types, Not the Wire DTOs
Look at how your core business logic represents the domain internally, away from the network edge:
sealed class OrderState { Draft, Submitted, Fulfilled }, your catalog schema should project that exact enum—not the loose string column from your legacy REST serializer.Quantitywith an assertion (value >= 1 && value <= 99), that immediately translates toSchema.integer(minimum: 1, maximum: 99).2. Inspect the Database CHECK Constraints
Before an API is written, the database engineer usually locked down the actual business physics:
CHECK (discount >= 0.0 AND discount <= 0.50),CHECK (status IN ('draft', 'active')), and foreign key lookup tables.Schema.number(minimum: 0.0, maximum: 0.50)takes five minutes and permanently eliminates hallucinated boundary bugs.3. Mine the Unit Test Assertions
Your unit test suite is literally a catalog of invariants that someone was once paid to defend:
throwsArgumentErrororasserts:expect(() => applyCoupon("INVALID"), throws...)orexpect(order.calculateTax(), ...).The Summary Recipe
Don't ask "What schema should we make up for the agent?"
Ask: "What is the intersection of our domain enums, database constraints, and test assertions?"
When you mechanically compile those existing boundaries into your catalog schemas, the agent isn't learning an artificial grammar—it is conforming to the exact same engineering physics your backend and mobile app already enforce!
The DB constraints step is the one I'd trust most — domain types can drift from what's actually enforced, but a CHECK constraint is the business physics as shipped. One tension in the intersection idea: what happens when the three sources disagree — say the domain enum allows a state the database rejects, or a test asserts a boundary the DB never enforces? Does one source win, or is the disagreement itself the finding, a bug in one of the three?
The disagreement itself is the finding. Never silently pick a winner and move on!
When your domain types, database constraints, and unit tests disagree, mining them for the agent catalog just paid for itself tenfold: you have uncovered a latent production bug before an LLM ever touched it.
Every type of mismatch reveals a specific, real architectural failure:
OrderStatus.archived) that triggers an unhandled SQLcheck_violation500 error the moment a user hits Save.FormatExceptionor crash modern clients on deserialization.How We Handle Drift in Practice
When those three sources clash, the workflow splits into two immediate actions:
CHECKconstraint, run the cleanup migration, or prune the zombie enum value.Treating schema drift as a first-class bug rather than quietly overriding it turns the agent catalog onboarding process into one of the best static audits a codebase can ever go through!
The strictest-intersection rule is the part I'd steal — containment that can't silently pass one layer and crash another is exactly what an agent catalog needs while the bug gets fixed. And you're right that the disagreement itself is the finding: a catalog that surfaces migration drift before any LLM touches the code has already earned its keep. Do the teams you see actually open that reconciliation bug, or does the strictest intersection quietly become the permanent answer?
In most teams without a mechanical forcing function, the strictest intersection becomes the permanent answer.
As the old adage goes, nothing in software is more permanent than a temporary workaround. Once the agent stops throwing 500 errors against the database, the immediate pain vanishes. Everyone marks the ticket done, moves on to the next sprint, and six months later nobody remembers why the agent catalog refuses to acknowledge an enum variant that clearly exists in the UI code.
Why Containment Hardens Into Debt
Containment solves the production crash, which removes the urgency to reconcile the underlying drift. Without pain, backlogs rot.
How Disciplined Teams Force the Reconciliation
The teams that manage to close that loop do it by trading an uncontrolled production crash for a controlled, visible test debt:
// Bounded to intersection pending #412: Postgres CHECK drift).If your containment workaround feels painless, nobody will ever clean up the drift. Putting a small, annoying, ticket-bound lint check in CI keeps the reconciliation honest!
The 'temporary workaround' line is the whole answer — once the 500s stop, the pain that would have forced reconciliation is gone. Of the three, the CI drift check is the one I'd bet on, because it converts the debt from invisible to annoying on every PR. Which of the three do you see teams actually keep alive past the first sprint — or does even the CI check get allowlisted into silence?
A plain allowlist file almost always rots into a silent graveyard. A developer hits a deadline on a Friday afternoon, appends their new field to
allowlist.jsonto get a green checkmark, and moves on. Two sprints later, the allowlist has forty entries and protects nothing.And
// TODO(#412)comments? Those survive purely as digital archaeology.What survives past the first sprint is a Ratchet Gate—specifically, a CI drift check built around two mechanical rules:
1. Freeze Legacy Debt, Block New Drift Zero-Tolerance
Teams abandon CI checks when a check screams about forty historical sins on every unrelated PR. The build turns into background noise, and people disable it.
The version that survives splits old from new:
Because the check only yells at the author who created the new mismatch, nobody resents it and nobody turns it off.
2. The Allowlist Is a One-Way Ratchet (With a Deadman's Switch)
To prevent the legacy manifest from expanding:
#412closes, leaving#412in the allowlist fails the build until someone deletes the stale entry.When you combine a zero-new-drift gate with a ratchet-only allowlist, the CI check pays for itself on every PR without turning into noisy spam—and teams keep it turned on for years!
The ratchet framing answers it — the trick is the check only yells at whoever created the new mismatch, so nobody has a reason to turn it off. The deadman's switch is the part I'd steal: an allowlist entry that outlives its tracking issue failing the build turns cleanup from a chore into a gate. Have you seen teams actually wire the issue-status query into CI, or does that part usually stay a manual review rule?
Most teams avoid putting a live issue-tracker API call directly inside blocking per-PR checks, because two things go wrong immediately:
In practice, the cleanest production setups split the deadman's switch into two deterministic pieces instead:
1. A hermetic "no stale exemptions" check in per-PR CI
Every PR runs an offline check verifying that every allowlist entry still matches an actual existing mismatch in the repo. That makes cleanup self-enforcing with zero network calls: the PR that finally fixes the underlying issue cannot merge until the developer also deletes the now-stale allowlist line.
2. An async nightly reconciler instead of a blocking PR gate
If you want issue-status reconciliation too, put the API query in a scheduled nightly job rather than on the PR critical path. When an allowlist entry points to a closed ticket, the nightly job opens a tiny auto-generated cleanup issue or PR targeting the entry. That keeps the pressure on without punishing unrelated PRs or coupling
git pushto an external SaaS uptime SLA.The ambush point is the one that settles it — a ticket marked Done at 2pm breaking unrelated branches five minutes later is a worse failure mode than the drift the check was meant to prevent. The split is clean: the hermetic check makes cleanup self-enforcing on the PR that fixes the issue, and the nightly job keeps the pressure without coupling git push to Jira's uptime. Do the nightly cleanup PRs actually get merged in practice, or do they quietly become a second queue nobody reads?
Unowned bot PRs almost always turn into a second Dependabot graveyard—everybody assumes somebody else will click Approve, and thirty notifications later the whole team mutes the bot channel.
The teams whose cleanup PRs get merged treat bot PR design like human attention economics and enforce three rules:
1. Auto-Merge Trivial Deletions When CI Passes
If the actual domain fix has already landed on
main, and the nightly job is strictly deleting a now-obsolete allowlist entry, enable automatic merge once the hermetic test suite turns green. Requiring a busy engineer to spend cognitive load reviewing a 1-line allowlist deletion is pure ceremony. If all tests pass without the exemption, the code proves the exemption is dead.2. When Compliance Requires a Human, Assign the Exact Issue Closer
In regulated shops where branch protection requires a human reviewer, never dump bot PRs into a generic team channel. Have the script read the issue event metadata and assign the PR directly to the person who closed the ticket (or authored the fixing commit). When a 1-line cleanup PR lands in your personal review queue twenty-four hours after you solved the bug, the context is fresh and you click Approve in ten seconds.
3. One Rolling PR, Never Twenty Individual Pings
If five items cleared that day, fold them into one single consolidated hygiene PR rather than firing five separate PR notifications. Engineers will gladly clear one small, clean batch PR during morning coffee; they will mercilessly ignore a flood of twenty fragmented ones.
Combine targeted ownership with green-CI auto-merge (where allowed), and the cleanup queue stays at zero without nagging anybody!
The assign-to-the-issue-closer rule is the one I'd steal — a cleanup PR landing in your own queue a day after you fixed the bug is a ten-second approve, not a chore. Has the auto-merge of trivial deletions ever backfired on you — a case where the hermetic suite was green but the deletion still broke something the tests didn't cover?
Yes—and when it backfires, it is almost always because of anything living outside the hermetic test boundary.
Hermetic suites only prove that current code inside the repository agrees with itself. They cannot see three real-world ghosts:
The Distinction That Prevents Backfires
To make auto-merge safe, you draw a sharp line between Static Hygiene and Wire Compatibility:
Auto-merge works cleanly when the diff only removes static scaffolding. The moment a deletion alters what crosses a network or disk boundary, a human and production telemetry belong in the loop!
The three ghosts are the whole argument — a hermetic suite can only ever prove the repo agrees with itself. The telemetry-clearance rule is the part I'd steal: zero hits across a full client upgrade cycle is a real bar, not a vibe. How long is a full upgrade cycle in practice on your mobile surface — weeks, or does the long tail of old clients make it effectively never?
Without a deliberate floor, the mobile long tail is effectively forever—someone will always have a phone in a drawer running an eighteen-month-old binary.
That is why production mobile teams pair Telemetry Clearance with a scheduled Minimum Supported Version Gate:
And with that minimum-version floor, we have walked the entire lifecycle—from a
0mssynchronouscomputed()signal in Flutter all the way out to schema drift ratchets and mobile deprecation in the wild!Thanks for pushing this thread into a full production blueprint.
The 98-99% threshold is the honest part — most teams delete the adapter at 'mostly migrated' and then rediscover the tail in their error logs. The floor is what turns deprecation from a hope into a schedule, and it pairs neatly with the computed() point: in both cases the client declares what it can do locally instead of asking something else to figure it out. Good thread — this lifecycle arc is the part I'd want in the article itself.
The Oracle Fallacy is the right name for it — demos that let the model do everything look magical until the bill and the latency arrive. Making the function a first-class computed() signal rather than a callback is the part I'd steal: it kills the flicker, not just the network call. One thing I'm curious about: when the agent invents a call to a function that isn't in the catalog, what does the surface do — fail loud, or quietly render nothing?
Great question, Paul. It depends on whether the call happens via Inbound RPC or Reactive UI Binding, but in both cases it fails explicitly and loudly rather than silently swallowing the error into a ghost screen.
Here is what happens under the hood:
1. Inbound RPC (
CallRendererFunctionMessage)If the remote agent sends a direct RPC message requesting a function that doesn't exist in the catalog,
A2uiSurfaceBlocintercepts it and immediately returns a structuredRendererFunctionResponseMessagewith a typed error:The agent gets immediate feedback that the tool isn't registered, and the client application continues running without a hitch.
2. Reactive UI Bindings (
{"call": "unknownFunction"})When a streamed component binds a dynamic property (like a
TextorCard) to an unregistered function:catalog.invoke()throws a typedA2uiCatalogResolutionError: Function not found: <name>.computed()signal captures this as aSignalEffectException.bloc_signals_genui_flutter), component resolvers are wrapped in defensive error boundaries. The individual offending node renders its fallback error state (or standard debug error tile), while the rest of the surface tree continues to mount and render cleanly.No silent black holes or phantom state!
The grain-size discussion is the part I keep coming back to — the warning against both primitive-obsession catalogs and god functions is exactly the tension I have hit when exposing tools to a model. And the fail-loudly point matters more than it looks: a typed 'Function not found' error the agent can act on is a completely different failure mode from a silent ghost screen. The pre-warm-the-cache compromise for lookups is a neat resolution too — it keeps the synchronous graph pure without giving up on real data.
It is wonderfully rewarding to see the comment section turning into an architectural playbook!
You hit on a profound point about agent feedback loops: when an agent receives a typed error like
A2uiFunctionCallError(code: "FUNCTION_NOT_FOUND", ...)or an explicit schema rejection, the model actually has an actionable gradient to self-correct—it can read the error payload, inspect its catalog prompt, and retry with a valid function call.With a silent ghost screen or a deceptive
$0.00, the feedback loop is completely broken: the agent thinks it succeeded, the user thinks the app is broken, and nobody has a stack trace.Finding that balance between expressive catalog grain size, defensive typed boundaries, and synchronous signal performance is really where modern Generative UI transitions from "cool tech demo" to durable production engineering.
Thanks for reading through the threads and pulling together the key lessons!
Nice article. “An awful, slow, and expensive pocket calculator” is a good way to put the problem, and the round-trip trap you describe is easy to miss in demos where the model is doing everything. Splitting the work so the agent designs the layout and binds expressions while local Dart code does the math is a sound boundary.
What I like most is that the functions compile into computed() signals instead of being plain callbacks. That removes the glue code (stream controllers, debouncers, manual setState) that usually creeps in once local calculations need to feed back into generated widgets, and the same-frame update on a slider drag follows directly from that design.
The two-path breakdown at the end was helpful too. Keeping SubmitAction for user taps separate from the bidirectional RPC for agent-initiated calls makes it clear who is calling what.
One question: since the agent only learns about calculateCost through the advertised catalog or system prompt, what happens when it binds a call with a wrong argument type, or references a DataModel path that doesn’t exist? Does execute() just fall back to the ?? 0.0 defaults, or is there a validation step against the argumentSchema that surfaces the error back to the agent? With deterministic business logic like pricing, I’d worry about a silent 0.0 showing up as a real total.
Thanks for the clear walkthrough and for pointing to Red’s video.
You have put your finger right on the critical difference between a pedagogical tutorial snippet and a production-grade catalog function.
You are 100% right to worry: in real-world commerce, a silent fallback to
0.0is dangerous because displaying a$0.00price tag or a free cart item is the worst kind of defect—it looks plausible to the user, but it is completely wrong.Here is how schema validation and path failures are handled in practice across the two layers:
1. Inbound RPC: Strict Schema Validation
When the remote agent calls a function directly over the wire via
CallRendererFunctionMessage, the A2UI engine validates the incoming JSON against the function'sargumentSchemabeforeexecute()is ever invoked. If the agent sends a string where a number was required, or omits a required field likeunitPrice, the engine rejects the call immediately and returns an explicitA2uiFunctionCallError(withcode: "INVALID_ARGUMENT") back to the agent. The function never runs with junk data.2. Reactive UI Expressions: Don't Fall Back, Throw!
In the article's snippet, I wrote
(args['unitPrice'] as num?)?.toDouble() ?? 0.0for brevity, but in production code, you should never silently default a missing required field.Instead, your production
execute()should assert required invariants:Here is what happens when it throws:
DataModelpath (like/cart/missing_item/qty),context.resolveSignal()resolves that path tonull.execute()runs, it detects the missing value and throws anArgumentError.computed()signal captures the exception.NodeResolvercatches the failed evaluation at the component boundary and displays an explicit error fallback (for example, displaying an error tile or "Price unavailable" skeleton) rather than deceptively painting a green$0.00.The Rule: Fail Loud on Broken Contracts
If an optional field is missing (like a coupon discount), defaulting to
0.0is sound domain logic. But if an essential, structural input is missing or malformed, fail loud at the boundary. A broken component card is easy to spot in testing and telemetry; a silent$0.00invoice is a financial disaster!Paying hundreds of tokens to compute 3.50 times 3 is the part that should bother people, the phone does it in the same frame at 0ms. The 20 percent coupon button is the flip side though, once that discount runs locally with no server check, a tampered client can apply it as often as it likes.
Spot on, and this touches the first law of client-server security: never trust the client for final transactional authority.
Client-side functions exist for responsive user ergonomics and 120 FPS visual feedback, not for final financial settlement.
It is the exact same invariant web developers have lived with for thirty years:
{"total_to_charge": 0.00}directly to Stripe!In
bloc_signals_genui, this is why we separated Path A (Interactive Actions) and Path B (Bidirectional RPC):calculateCosthandle instant preview arithmetic in the UI signal graph.SubmitActionemits anA2uiRendererActionor invokescallAgentFunction()to validate against the server before committing state.The phone gives you 0ms interactive rendering; the server keeps the ledger honest.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.