Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions blueprints/modules/a2ui_core.blueprint.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ agentProcessor.processMessages(generatedLlmPayloadMessages);
| Execution Aspect | Renderer | Agent |
| :---------------------------- | :---------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------- |
| **Architectural Role** | Processes inbound messages and updates surface state. | Optional helper for checking and converting outbound messages. |
| **`catalogs` Parameter** | Passes all renderer-supported catalogs (`catalogs: [catA, catB]`). | Passes single negotiated catalog (`catalogs: [negotiatedCatalog]`). |
| **`catalogs` Parameter** | Passes all renderer-supported catalogs (`catalogs: [catA, catB]`), one validator built per catalog. | Passes single negotiated catalog (`catalogs: [negotiatedCatalog]`). |
| **`actionHandler` Parameter** | UI event callback (`actionHandler: onUiEvent`). | Omitted or `undefined` (`actionHandler: undefined`). |
| **Catalog Compliance** | Matches `createSurface.catalogId` and component/function `catalogId` overrides against renderer's supported list. | Fails if LLM generates payload referencing un-negotiated catalog. |
| **Primary Goal** | Maintains live view models and routes user action events. | Verifies LLM-generated payloads and data path references before sending. |
Expand All @@ -464,9 +464,12 @@ export interface ValidationConfig {
allowedMessages?: string[];
}

/** Stateless validator executing envelope structure, component property schema, theme schema, and path syntax checks. */
/** Stateless validator executing envelope structure, component property schema, theme schema, and path syntax checks. Scoped to a single catalog. */
export class A2uiValidator {
constructor(catalogs: Catalog<any, any>[], validationConfig?: ValidationConfig);
constructor(catalog: Catalog<any, any>, validationConfig?: ValidationConfig);

/** Parses envelopes without a catalog: version tag and single update type. */
static parseMessages(payload: object[], targetVersion?: string): AgentToRendererMessage[];

/** Single public entry point: performs catalog property schema validation. */
validate(messages: AgentToRendererMessage[]): void;
Expand All @@ -482,18 +485,37 @@ export class A2uiValidator {
}
```

#### Catalog Scope

Scope every validator to a single catalog. Take that catalog in the constructor and check every component against it.

Raise `A2uiCatalogError` from `validate` and `validateStructure` when a payload creates a surface against any other catalog.

Expose envelope parsing without a catalog: the protocol version tag and the single-update-type rule read no catalog, so parsing must not require one.

In `MessageProcessor`:

- Build one validator per supported catalog. Build each on first use and reuse it, so resolved component schemas are cached across messages.
- Record the catalog on the `SurfaceModel` when the surface is created.
- Parse the payload first, without a catalog, then dispatch each message to the validator for its surface's catalog.
- Check each surface against the catalog it was created with, never against the full supported set.

In an agent, pass the negotiated catalog.

#### Validation Implementation Matrix

The matrix below details the specific validation checks, their responsible component/method in `a2ui_core`, and the specific error class raised upon failure:

| Validation Category | Specific Validation Check | Responsible Component / Implementation | Raised Error Type |
| :----------------------- | :------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------ | :-------------------- |
| **Protocol Envelope** | Single update type per message (`createSurface`, `updateComponents`, etc.) | `A2uiValidator` (Zod envelope schema) | `A2uiValidationError` |
| **Protocol Envelope** | Valid `version` tag (`v0.8`, `v0.9`, `v1.0`) & required envelope keys | `A2uiValidator` (Zod envelope schema) | `A2uiValidationError` |
| **Protocol Envelope** | Single update type per message (`createSurface`, `updateComponents`, etc.) | `A2uiValidator.parseMessages()` (envelope schema, no catalog needed) | `A2uiValidationError` |
| **Protocol Envelope** | Valid `version` tag (`v0.8`, `v0.9`, `v1.0`) & required envelope keys | `A2uiValidator.parseMessages()` (envelope schema, no catalog needed) | `A2uiValidationError` |
Comment on lines +511 to +512

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The blueprint matrix refers to 'A2uiValidator.parseMessages()' for envelope validation, but the static method added in the Dart implementation is named 'parseMessagesFor' (and 'parseMessages' is kept as an instance method). Consider aligning the blueprint or documenting this language-specific naming difference to avoid confusion.

@polina-c polina-c Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment suggests a fresh validator per batch. This memoizes one per catalog id instead. The validator caches its catalog's resolved component schemas, and resolveSchemaRefs runs over every component in the catalog; a fresh instance per batch would rebuild that on every streamed update. Same scoping, no per-message rebuild.

As schemas are not supposed to change in runtime, caching is ok.

| **Surface Lifecycle** | Surface non-existence on `createSurface` (no duplicates) | `MessageProcessor.processCreateSurface()` (`SurfaceGroupModel`) | `A2uiIntegrityError` |
| **Surface Lifecycle** | Surface existence on `updateComponents`, `updateDataModel`, `deleteSurface` | `MessageProcessor.processUpdateComponents()` / `processUpdateDataModel()` | `A2uiIntegrityError` |
| **Catalog Negotiation** | `createSurface.catalogId` and component/function `catalogId` match negotiated renderer capability | `new MessageProcessor({ catalogs: [negotiatedCatalog] })` | `A2uiCatalogError` |
| **Catalog Resolution** | `createSurface.catalogId` and component/function `catalogId` exist in supported catalogs list | `MessageProcessor.processCreateSurface()` | `A2uiCatalogError` |
| **Catalog Scope** | `createSurface.catalogId` matches the catalog the validator is scoped to | `A2uiValidator.validate()` / `validateStructure()` | `A2uiCatalogError` |
| **Catalog Scope** | Components checked against their own surface's catalog, not the supported set | `MessageProcessor.validatorFor(surface.catalog)` | `A2uiValidationError` |
| **Component Keys** | Required `id` and `component` (type name) on creation | `A2uiValidator` (Zod envelope schema) | `A2uiValidationError` |
| **Component Properties** | Property schema validation against catalog definition | `A2uiValidator(CatalogSchemaValidator.validateComponents())` | `A2uiValidationError` |
| **Theme / Properties** | `Theme` / `surfaceProperties` validation against catalog schema | `A2uiValidator(CatalogSchemaValidator.validateSurfaceProperties())` | `A2uiValidationError` |
Expand Down
19 changes: 12 additions & 7 deletions dart/a2ui_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

- **Breaking:** `MessageProcessor` validates messages as it processes them.
A message that does not match its catalog now throws instead of being
applied. Added `processPayload` and an optional `validator` constructor
parameter.
applied. Added `processPayload`, and the `protocolVersion` and
`commonTypesSchema` constructor parameters that configure the validators it
builds. It keeps one validator per catalog, reachable through
`validatorFor`, and checks each surface against the catalog it was created
with rather than against every catalog the processor supports.
- **Breaking:** `MessageProcessor` checks each batch of components as a graph
against the surface it joins, so duplicate ids, references naming no
component, cycles and over-deep chains now throw. References resolve against
Expand Down Expand Up @@ -34,11 +37,13 @@
- Added `A2uiRendererCapabilities` and `A2uiVersionCapabilities`.
- Added `A2uiValidator`, which validates a payload in three synchronous
stages, and `A2uiValidator.commonTypesSchema`.
- `A2uiValidator.validate`, `validateStructure` and `validateAgainstCatalogs`
take an optional `surfaceCatalogs` map naming the catalog each surface uses.
A payload that only updates a surface carries no catalog id, so without it a
validator holding several catalogs now throws `A2uiCatalogError` where it
previously skipped those components and reported the payload valid.
- `A2uiValidator` is scoped to a single `catalog`, since a component belongs to
exactly one. A payload that only updates a surface carries no catalog id, so
a validator holding several catalogs could not tell which one applied and
skipped those components while reporting the payload valid. With one catalog
the question does not arise, and a payload creating a surface against any
other catalog throws `A2uiCatalogError`. Added
`A2uiValidator.parseMessagesFor`, which checks envelopes without a catalog.
- The package now publishes the specification's `common_types.json` as
`A2uiValidator.commonTypesFor`, and `commonTypesSchema` defaults to it, so
the shared types are checked without the caller supplying the document.
Expand Down
91 changes: 64 additions & 27 deletions dart/a2ui_core/lib/src/processing/processor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import '../core/messages.dart';
import '../core/surface_group_model.dart';
import '../core/surface_model.dart';
import '../primitives/errors.dart';
import '../primitives/protocol_version.dart';
import '../validation/component_graph.dart';
import '../validation/validator.dart';

Expand All @@ -36,49 +37,86 @@ import '../validation/validator.dart';
/// Checks a batch against the surface it joins, which needs that state. For
/// checking a payload on its own, before any surface exists, see
/// [A2uiValidator.validate].
///
/// Validation is phased rather than a single pass: [processPayload] checks
/// envelopes as it parses, a surface's theme is checked when the surface is
/// created, and each batch of components is checked against its surface's
/// catalog and against the surface's existing component graph as the batch
/// arrives. The graph checks resolve references against what the surface
/// already holds, which a payload-scoped validator cannot see, so an
/// incremental update is checked rather than waved through. Each of those
/// checks runs on the validator for that surface's catalog, from
/// [validatorFor].
class MessageProcessor<T extends ComponentApi> {
final SurfaceGroupModel<T> groupModel;
final List<Catalog<T, FunctionImplementation>> catalogs;

/// Validates messages as they are processed.
///
/// Validation is phased rather than a single pass: [processPayload] checks
/// envelopes as it parses, [processMessages] checks a surface's theme when
/// the surface is created, and each batch of components against its
/// surface's catalog and against the surface's existing component graph as
/// the batch arrives. The graph checks resolve references against what the
/// surface already holds, which a payload-scoped validator cannot see, so an
/// incremental update is checked rather than waved through.
/// The protocol version this processor accepts, on envelopes and in the
/// validators it builds.
final A2uiProtocolVersion protocolVersion;

/// The shared `common_types.json` definitions the validators resolve
/// against.
///
/// Defaults to a validator over [catalogs], resolving the shared types
/// against the `common_types.json` this package publishes. Supply one to
/// configure it — to override that document, or to accept a different
/// protocol version.
final A2uiValidator<T, FunctionImplementation> validator;
/// Defaults to the copy this package publishes for [protocolVersion]; pass
/// a different document to override it, or an empty map to leave the shared
/// types unchecked.
final Map<String, Object?> commonTypesSchema;

/// One validator per catalog, built on first use.
final Map<String, A2uiValidator<T, FunctionImplementation>> _validators = {};

MessageProcessor({
required this.catalogs,
A2uiValidator<T, FunctionImplementation>? validator,
this.protocolVersion = A2uiProtocolVersion.v0_9,
Map<String, Object?>? commonTypesSchema,
void Function(A2uiClientAction)? onAction,
}) : validator =
validator ??
A2uiValidator<T, FunctionImplementation>(catalogs: catalogs),
}) : commonTypesSchema =
commonTypesSchema ?? A2uiValidator.commonTypesFor(protocolVersion),
groupModel = SurfaceGroupModel<T>() {
if (onAction != null) {
groupModel.onAction.addListener(onAction);
}
}

/// The validator for [catalog].
///
/// A component belongs to exactly one catalog, so a validator is scoped to
/// one rather than handed the whole supported set: a processor that
/// supports several catalogs must not accept a component of one surface's
/// catalog on a surface created against another. Each surface records the
/// catalog it was created with, and every check below goes through the
/// validator for that catalog.
///
/// Built once per catalog and reused. The validator caches resolved
/// component schemas, which a fresh instance per batch would rebuild on
/// every message.
A2uiValidator<T, FunctionImplementation> validatorFor(
Catalog<T, FunctionImplementation> catalog,
) => _validators.putIfAbsent(
catalog.id,
() => A2uiValidator<T, FunctionImplementation>(
catalog: catalog,
commonTypesSchema: commonTypesSchema,
protocolVersion: protocolVersion,
),
);

/// Parses a raw payload, then processes it.
///
/// Envelope validation happens here, as the payload is parsed: every message
/// must declare a protocol version this SDK implements and be a well-formed
/// message of it. Returns the parsed messages.
/// must declare a protocol version this SDK implements and carry exactly one
/// update type. Neither check needs a catalog, which is what lets a payload
/// be parsed before each message is matched to the surface, and so the
/// catalog, it belongs to. Returns the parsed messages.
///
/// Throws [A2uiValidationError] for a malformed envelope, before any message
/// reaches the models.
/// Throws [A2uiValidationError] for a malformed envelope, including one
/// mixing update types, before any message reaches the models.
List<A2uiMessage> processPayload(List<Map<String, Object?>> payload) {
final List<A2uiMessage> messages = validator.parseMessages(payload);
final List<A2uiMessage> messages = A2uiValidator.parseMessagesFor(
payload,
protocolVersion: protocolVersion,
);
processMessages(messages);
return messages;
}
Expand Down Expand Up @@ -115,7 +153,7 @@ class MessageProcessor<T extends ComponentApi> {

// The theme arrives once, with the surface, so it is checked here rather
// than on every later message.
validator.validateTheme(message.theme, catalog);
validatorFor(catalog).validateTheme(message.theme);

final surface = SurfaceModel<T>(
message.surfaceId,
Expand Down Expand Up @@ -159,7 +197,7 @@ class MessageProcessor<T extends ComponentApi> {
// that names none is an update to one this surface already holds, which
// the catalog was consulted for when it first arrived.
if (type != null) {
validator.validateComponent(compJson, surface.catalog);
validatorFor(surface.catalog).validateComponent(compJson);
}
}

Expand All @@ -168,13 +206,12 @@ class MessageProcessor<T extends ComponentApi> {
// and over-deep chains. Resolving against the surface is what a
// payload-scoped validator cannot do, so an incremental update is checked
// here rather than waved through.
validator.validateComponentBatch(
validatorFor(surface.catalog).validateComponentBatch(
[
for (final Map<String, dynamic> c in message.components)
c.cast<String, Object?>(),
],
[for (final ComponentModel c in surface.componentsModel.all) c.toJson()],
surface.catalog,
);

// Data-model paths and nested function calls, which need no surface state.
Expand Down
Loading
Loading