From fd8118fabba9218554df8a0c0730823e7f8a2286 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Sat, 5 Sep 2026 10:13:56 -0700 Subject: [PATCH 1/2] - --- blueprints/modules/a2ui_core.blueprint.md | 32 ++- dart/a2ui_core/CHANGELOG.md | 19 +- .../lib/src/processing/processor.dart | 91 ++++--- .../lib/src/validation/validator.dart | 223 +++++++++--------- dart/a2ui_core/test/common_types_test.dart | 9 +- .../validator_conformance_test.dart | 27 +-- dart/a2ui_core/test/processor_test.dart | 97 ++++++++ .../test/validator_basic_catalog_test.dart | 2 +- dart/a2ui_core/test/validator_test.dart | 97 ++++---- 9 files changed, 369 insertions(+), 228 deletions(-) diff --git a/blueprints/modules/a2ui_core.blueprint.md b/blueprints/modules/a2ui_core.blueprint.md index 9ac2ff8149..261ee29964 100644 --- a/blueprints/modules/a2ui_core.blueprint.md +++ b/blueprints/modules/a2ui_core.blueprint.md @@ -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. | @@ -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[], validationConfig?: ValidationConfig); + constructor(catalog: Catalog, 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; @@ -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` | | **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` | diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index 47f55f17d3..4da9143078 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -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 @@ -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. diff --git a/dart/a2ui_core/lib/src/processing/processor.dart b/dart/a2ui_core/lib/src/processing/processor.dart index 564f5ae3f5..d2fee95273 100644 --- a/dart/a2ui_core/lib/src/processing/processor.dart +++ b/dart/a2ui_core/lib/src/processing/processor.dart @@ -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'; @@ -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 { final SurfaceGroupModel groupModel; final List> 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 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 commonTypesSchema; + + /// One validator per catalog, built on first use. + final Map> _validators = {}; MessageProcessor({ required this.catalogs, - A2uiValidator? validator, + this.protocolVersion = A2uiProtocolVersion.v0_9, + Map? commonTypesSchema, void Function(A2uiClientAction)? onAction, - }) : validator = - validator ?? - A2uiValidator(catalogs: catalogs), + }) : commonTypesSchema = + commonTypesSchema ?? A2uiValidator.commonTypesFor(protocolVersion), groupModel = SurfaceGroupModel() { 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 validatorFor( + Catalog catalog, + ) => _validators.putIfAbsent( + catalog.id, + () => A2uiValidator( + 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 processPayload(List> payload) { - final List messages = validator.parseMessages(payload); + final List messages = A2uiValidator.parseMessagesFor( + payload, + protocolVersion: protocolVersion, + ); processMessages(messages); return messages; } @@ -115,7 +153,7 @@ class MessageProcessor { // 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( message.surfaceId, @@ -159,7 +197,7 @@ class MessageProcessor { // 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); } } @@ -168,13 +206,12 @@ class MessageProcessor { // 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 c in message.components) c.cast(), ], [for (final ComponentModel c in surface.componentsModel.all) c.toJson()], - surface.catalog, ); // Data-model paths and nested function calls, which need no surface state. diff --git a/dart/a2ui_core/lib/src/validation/validator.dart b/dart/a2ui_core/lib/src/validation/validator.dart index 1ef714b0e0..419bd51a01 100644 --- a/dart/a2ui_core/lib/src/validation/validator.dart +++ b/dart/a2ui_core/lib/src/validation/validator.dart @@ -79,12 +79,19 @@ class _SurfacePayload { } } -/// Validates A2UI payloads against the protocol schemas and a set of catalogs. +/// Validates A2UI payloads against the protocol schemas and one catalog. /// /// Lives in `a2ui_core` because renderers and agents validate the same /// payloads against the same catalogs. Implements v0.9 only: [checkVersion] /// and [parseMessages] reject any other version, or none. /// +/// A validator is scoped to a single [catalog], because a component belongs to +/// exactly one. A renderer supports several catalogs at once, but each surface +/// is created against one of them, so `MessageProcessor` keeps a validator per +/// catalog and reaches for the one the surface was created with. An agent +/// negotiates a catalog before it generates anything, so agent-side there is +/// one to begin with. +/// /// Both sides, agent and renderer, are meant to use it, through different /// entry points. [validate] checks a payload on its own, which is what an /// agent has before it sends anything to the renderer. Renderer @@ -98,7 +105,7 @@ class _SurfacePayload { /// /// Validation runs in three stages, which [validate] performs in order: /// [parseMessages] checks envelopes, [validateStructure] checks the component -/// graph, and [validateAgainstCatalogs] checks each component against its +/// graph, and [validateAgainstCatalogs] checks each component against the /// catalog's schema. /// /// A payload that creates a surface is a full render: it must declare a @@ -108,8 +115,12 @@ class _SurfacePayload { /// components the client already holds; duplicate ids, self-references and /// cycles still fail. class A2uiValidator { - /// The catalogs payloads are validated against, keyed by catalog id. - final Map> catalogs; + /// The catalog payloads are validated against. + /// + /// Every component a payload declares is checked against this catalog. A + /// payload that creates a surface against a different one is rejected + /// rather than skipped, so nothing passes unchecked. + final Catalog catalog; /// The protocol version this validator accepts. final A2uiProtocolVersion protocolVersion; @@ -124,18 +135,17 @@ class A2uiValidator { /// map to leave the shared types unchecked. final Map commonTypesSchema; - /// Child-referencing properties per catalog id, derived on first use. - final Map> _refFields = {}; + /// Child-referencing properties of [catalog], derived on first use. + Map? _refFields; - /// Component schemas with their `$ref`s inlined, keyed by catalog id. - final Map> _resolvedComponents = {}; + /// [catalog]'s component schemas with their `$ref`s inlined, on first use. + Map? _resolvedComponents; A2uiValidator({ - List> catalogs = const [], + required this.catalog, Map? commonTypesSchema, this.protocolVersion = A2uiProtocolVersion.v0_9, - }) : catalogs = {for (final Catalog c in catalogs) c.id: c}, - commonTypesSchema = commonTypesSchema ?? commonTypesFor(protocolVersion); + }) : commonTypesSchema = commonTypesSchema ?? commonTypesFor(protocolVersion); /// The `common_types.json` document this package publishes for [version]. /// @@ -156,10 +166,10 @@ class A2uiValidator { /// implement. factory A2uiValidator.forVersion( Object? version, { - List> catalogs = const [], + required Catalog catalog, Map? commonTypesSchema, }) => A2uiValidator( - catalogs: catalogs, + catalog: catalog, commonTypesSchema: commonTypesSchema, protocolVersion: A2uiProtocolVersion.fromJson(version), ); @@ -187,10 +197,36 @@ class A2uiValidator { /// /// Throws [A2uiValidationError] for any envelope that is not a well-formed /// message of the accepted version. - List parseMessages(List> payload) { + List parseMessages(List> payload) => + parseMessagesFor(payload, protocolVersion: protocolVersion); + + /// Parses payload envelopes into typed messages, without a catalog. + /// + /// An envelope declares its protocol version and exactly one update type; + /// neither depends on a catalog. A caller holding several catalogs — a + /// renderer, through `MessageProcessor` — therefore parses a payload before + /// it knows which surface, and so which catalog, each message belongs to. + /// + /// Throws [A2uiValidationError] for any envelope that is not a well-formed + /// message of [protocolVersion], including one carrying more than a single + /// update type. + static List parseMessagesFor( + List> payload, { + A2uiProtocolVersion protocolVersion = A2uiProtocolVersion.v0_9, + }) { final messages = []; for (final envelope in payload) { - checkVersion(envelope); + final A2uiProtocolVersion version = A2uiProtocolVersion.fromJson( + envelope['version'], + details: envelope, + ); + if (version != protocolVersion) { + throw A2uiValidationError( + "Payload declares version '${version.jsonValue}' but this validator " + "accepts only '${protocolVersion.jsonValue}'.", + details: envelope, + ); + } messages.add(A2uiMessage.fromJson(Map.from(envelope))); } return messages; @@ -202,10 +238,8 @@ class A2uiValidator { /// Throws [A2uiIntegrityError] for graph defects, [A2uiRecursionError] for /// cycles and depth overruns, and [A2uiValidationError] for a malformed /// data-model path. - void validateStructure( - List messages, { - Map surfaceCatalogs = const {}, - }) { + void validateStructure(List messages) { + _checkDeclaredCatalogs(messages); for (final message in messages) { checkPathsAndRecursion(message.toJson()); // Two components sharing an id in one message contradict each other. @@ -221,18 +255,12 @@ class A2uiValidator { } } - for (final MapEntry entry in _groupBySurface( - messages, - ).entries) { - final _SurfacePayload surface = entry.value; + for (final _SurfacePayload surface in _groupBySurface(messages).values) { if (surface.components.isEmpty) continue; - final Map refFields = _refFieldsFor( - _catalogFor(entry.key, surface, surfaceCatalogs), - ); checkComponentIntegrity( surface.components, - refFields, + _catalogRefFields, requireRoot: surface.created, // A payload that creates the surface must satisfy every reference // itself. One that does not cannot know what the client already @@ -241,37 +269,28 @@ class A2uiValidator { ); checkComponentTopology( surface.components, - refFields, + _catalogRefFields, requireRoot: surface.created, allowOrphans: !surface.isSingleRender, ); } } - /// Checks each component and function call against its surface's catalog. + /// Checks each component and function call against [catalog]. /// - /// Throws [A2uiCatalogError] if a message names a catalog this validator - /// does not hold, and [A2uiValidationError] for schema violations. + /// Throws [A2uiCatalogError] if the payload creates a surface against a + /// catalog other than this validator's, and [A2uiValidationError] for schema + /// violations. /// /// A surface the payload only updates carries no catalog id, because v0.9 - /// declares one on `createSurface` alone. Name it in [surfaceCatalogs], - /// keyed by surface id; a caller that sent the `createSurface` knows it. - /// Without it, a validator holding one catalog uses that catalog and one - /// holding several throws rather than leave the components unchecked. - void validateAgainstCatalogs( - List messages, { - Map surfaceCatalogs = const {}, - }) { - final Map surfaces = _groupBySurface(messages); - - for (final MapEntry entry in surfaces.entries) { - final Catalog catalog = _catalogFor( - entry.key, - entry.value, - surfaceCatalogs, - ); - for (final Map component in entry.value.components) { - validateComponent(component, catalog); + /// declares one on `createSurface` alone. That used to leave the catalog + /// ambiguous; scoping the validator to one settles it, so an incremental + /// payload is checked rather than skipped. + void validateAgainstCatalogs(List messages) { + _checkDeclaredCatalogs(messages); + for (final _SurfacePayload surface in _groupBySurface(messages).values) { + for (final Map component in surface.components) { + validateComponent(component); } } } @@ -280,13 +299,10 @@ class A2uiValidator { /// schemas. /// /// Returns the parsed messages, and throws as the individual steps do. - List validate( - List> payload, { - Map surfaceCatalogs = const {}, - }) { + List validate(List> payload) { final List messages = parseMessages(payload); - validateStructure(messages, surfaceCatalogs: surfaceCatalogs); - validateAgainstCatalogs(messages, surfaceCatalogs: surfaceCatalogs); + validateStructure(messages); + validateAgainstCatalogs(messages); return messages; } @@ -311,12 +327,10 @@ class A2uiValidator { void validateComponentBatch( List> incoming, List> existing, - Catalog catalog, ) { - final Map refFields = _refFieldsFor(catalog); checkComponentIntegrity( incoming, - refFields, + _catalogRefFields, // The root may arrive in a later message, so its absence is not an // error at this point; the surface is not yet claimed to be complete. requireRoot: false, @@ -327,7 +341,7 @@ class A2uiValidator { ); checkComponentTopology( [...existing, ...incoming], - refFields, + _catalogRefFields, requireRoot: false, // A component left unreachable by an update is the residue of a // replacement rather than a defect. @@ -346,7 +360,7 @@ class A2uiValidator { /// /// Throws [A2uiValidationError] if the theme does not match the schema. @internal - void validateTheme(Map? theme, Catalog catalog) { + void validateTheme(Map? theme) { final Schema? schema = catalog.themeSchema; if (schema == null || theme == null) return; @@ -370,10 +384,7 @@ class A2uiValidator { /// Throws [A2uiValidationError] if the component names no type, names one /// the catalog does not declare, or does not match its schema. @internal - void validateComponent( - Map component, - Catalog catalog, - ) { + void validateComponent(Map component) { final Object? type = component['component']; if (type is! String) { throw A2uiValidationError( @@ -381,7 +392,7 @@ class A2uiValidator { details: component, ); } - final Schema? schema = _resolvedComponentSchemas(catalog)[type]; + final Schema? schema = _resolvedComponentSchemas[type]; if (schema == null) { throw A2uiValidationError( "Catalog '${catalog.id}' declares no component named '$type'.", @@ -425,60 +436,46 @@ class A2uiValidator { return surfaces; } - /// The catalog a surface's components belong to, when it can be determined. - /// The catalog a surface's components are checked against. + /// Checks that every `createSurface` in [messages] names [catalog]. /// - /// Settled in order: the id the payload declares on `createSurface`, then - /// the id [surfaceCatalogs] gives for a surface the payload only updates, - /// then the sole catalog when this validator holds one. + /// A validator holds one catalog, so a surface created against another one + /// cannot be checked here at all. Rejecting the payload is the honest + /// answer: skipping those components would report it valid when nothing had + /// looked at them. A renderer supporting several catalogs reaches the right + /// validator through `MessageProcessor`, which knows the catalog each + /// surface was created with. /// - /// Throws [A2uiCatalogError] when none of those settles it. Skipping the - /// surface instead would report a payload valid that nothing had checked. - Catalog _catalogFor( - String surfaceId, - _SurfacePayload surface, - Map surfaceCatalogs, - ) { - final String? declared = surface.catalogId ?? surfaceCatalogs[surfaceId]; - if (declared != null) { - final Catalog? catalog = catalogs[declared]; - if (catalog != null) return catalog; + /// Throws [A2uiCatalogError] for a surface created against another catalog. + void _checkDeclaredCatalogs(List messages) { + for (final message in messages) { + if (message is! CreateSurfaceMessage) continue; + if (message.catalogId == catalog.id) continue; throw A2uiCatalogError( - "Unknown catalog '$declared'. This validator holds: " - '${catalogs.keys.join(', ')}.', - catalogId: declared, + "Surface '${message.surfaceId}' is created against catalog " + "'${message.catalogId}', but this validator is scoped to " + "'${catalog.id}'.", + catalogId: message.catalogId, ); } - if (catalogs.length == 1) return catalogs.values.first; - throw A2uiCatalogError( - "Cannot tell which catalog surface '$surfaceId' uses: the payload does " - 'not create it, so it carries no catalog id, and this validator holds ' - '${catalogs.length} catalogs. Pass surfaceCatalogs to name it, or use ' - 'MessageProcessor, which tracks the catalog each surface was created ' - 'with.', - ); } - Map _refFieldsFor(Catalog? catalog) { - if (catalog == null) return const {}; - return _refFields.putIfAbsent( - catalog.id, - () => extractComponentRefFields(catalog), - ); + Map get _catalogRefFields => + _refFields ??= extractComponentRefFields(catalog); + + Map get _resolvedComponentSchemas => + _resolvedComponents ??= _resolveComponentSchemas(); + + Map _resolveComponentSchemas() { + final Map document = catalog.catalogSchema; + return { + for (final MapEntry entry in catalog.components.entries) + entry.key: Schema.fromMap( + resolveSchemaRefs( + entry.value.schema.value, + document, + commonTypes: commonTypesSchema, + ), + ), + }; } - - Map _resolvedComponentSchemas(Catalog catalog) => - _resolvedComponents.putIfAbsent(catalog.id, () { - final Map document = catalog.catalogSchema; - return { - for (final MapEntry entry in catalog.components.entries) - entry.key: Schema.fromMap( - resolveSchemaRefs( - entry.value.schema.value, - document, - commonTypes: commonTypesSchema, - ), - ), - }; - }); } diff --git a/dart/a2ui_core/test/common_types_test.dart b/dart/a2ui_core/test/common_types_test.dart index 39b901eae1..6481912548 100644 --- a/dart/a2ui_core/test/common_types_test.dart +++ b/dart/a2ui_core/test/common_types_test.dart @@ -63,16 +63,16 @@ void main() { test('is what a validator resolves against by default', () { expect( - A2uiValidator().commonTypesSchema, + A2uiValidator( + catalog: MinimalCatalog(), + ).commonTypesSchema, A2uiValidator.commonTypesFor(A2uiProtocolVersion.v0_9), ); }); test('is what a processor resolves against by default', () { expect( - MessageProcessor( - catalogs: [MinimalCatalog()], - ).validator.commonTypesSchema, + MessageProcessor(catalogs: [MinimalCatalog()]).commonTypesSchema, A2uiValidator.commonTypesFor(A2uiProtocolVersion.v0_9), ); }); @@ -80,6 +80,7 @@ void main() { test('an empty document leaves the shared types unchecked', () { expect( A2uiValidator( + catalog: MinimalCatalog(), commonTypesSchema: const {}, ).commonTypesSchema, isEmpty, diff --git a/dart/a2ui_core/test/conformance/validator_conformance_test.dart b/dart/a2ui_core/test/conformance/validator_conformance_test.dart index 19690f0cab..39030674d5 100644 --- a/dart/a2ui_core/test/conformance/validator_conformance_test.dart +++ b/dart/a2ui_core/test/conformance/validator_conformance_test.dart @@ -68,7 +68,7 @@ void _runCase(Map testCase) { // A fresh validator per step, as the reference Python harness does: each // step is an independent payload, not a continuation of the previous one. final A2uiValidator validator = A2uiValidator( - catalogs: _catalogsFor(catalogDocument, payload), + catalog: _catalogFor(catalogDocument, payload), commonTypesSchema: commonTypes, ); @@ -110,29 +110,28 @@ Map _document(Object? value) { throw StateError('Case declares no catalog schema.'); } -/// Builds the catalogs a payload needs from the one document a case declares. +/// Builds the catalog a payload is validated against from the one document a +/// case declares. /// /// The suite's fixtures name the catalog `standard` in the document but `std` -/// in the payloads that use it. A validator that indexes catalogs by id would -/// reject those payloads outright, which is not what these cases are testing — -/// they are about the component graph. So the document is registered under -/// every id the payload actually names, and the unknown-catalog check keeps -/// its own coverage in `validator_test.dart`. -List _catalogsFor( +/// in the payloads that use it. A validator checks that a `createSurface` +/// names its own catalog, so the mismatch would reject those payloads +/// outright, which is not what these cases are testing — they are about the +/// component graph. So the document is taken under the id the payload names, +/// and the wrong-catalog check keeps its own coverage in `validator_test.dart`. +SchemaCatalog _catalogFor( Map document, List> payload, ) { - final ids = {document['catalogId'] as String? ?? 'standard'}; + String id = document['catalogId'] as String? ?? 'standard'; for (final envelope in payload) { final Object? body = envelope['createSurface']; if (body is Map && body['catalogId'] is String) { - ids.add(body['catalogId']! as String); + id = body['catalogId']! as String; + break; } } - return [ - for (final String id in ids) - Catalog.fromJson({...document, 'catalogId': id}), - ]; + return Catalog.fromJson({...document, 'catalogId': id}); } /// Matches the error a case expects, by category and message. diff --git a/dart/a2ui_core/test/processor_test.dart b/dart/a2ui_core/test/processor_test.dart index f1d51f856d..b82ceda845 100644 --- a/dart/a2ui_core/test/processor_test.dart +++ b/dart/a2ui_core/test/processor_test.dart @@ -20,9 +20,83 @@ import 'package:a2ui_core/src/core/minimal_catalog.dart'; import 'package:a2ui_core/src/core/surface_model.dart'; import 'package:a2ui_core/src/primitives/errors.dart'; import 'package:a2ui_core/src/processing/processor.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:test/test.dart'; void main() { + group('MessageProcessor catalog scope', () { + Catalog namedCatalog( + String id, + String component, + ) => Catalog( + id: id, + components: [ + ComponentApi( + name: component, + schema: Schema.object( + properties: { + 'id': Schema.string(), + 'component': Schema.string(), + 'a': Schema.string(), + }, + required: ['component', 'a'], + additionalProperties: false, + ), + ), + ], + ); + + late MessageProcessor processor; + + setUp(() { + processor = MessageProcessor( + catalogs: [namedCatalog('cat1', 'Alpha'), namedCatalog('cat2', 'Beta')], + ); + processor.processMessages([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: 'cat1'), + CreateSurfaceMessage(surfaceId: 's2', catalogId: 'cat2'), + ]); + }); + + void update(String surfaceId, String component) => + processor.processMessages([ + UpdateComponentsMessage( + surfaceId: surfaceId, + components: [ + {'id': 'root', 'component': component, 'a': 'x'}, + ], + ), + ]); + + test('checks each surface against the catalog it was created with', () { + // A processor supports several catalogs at once, but a component belongs + // to exactly one. Each surface is checked against its own catalog, not + // against the union of everything the processor supports. + expect(() => update('s1', 'Alpha'), returnsNormally); + expect(() => update('s2', 'Beta'), returnsNormally); + }); + + test('rejects a component from another surface\'s catalog', () { + expect(() => update('s1', 'Beta'), throwsA(isA())); + expect(() => update('s2', 'Alpha'), throwsA(isA())); + }); + + test('builds one validator per catalog and reuses it', () { + final Catalog cat1 = + processor.catalogs.first; + expect(processor.validatorFor(cat1).catalog.id, 'cat1'); + expect( + processor.validatorFor(cat1), + same(processor.validatorFor(cat1)), + reason: 'resolved component schemas are cached on the validator', + ); + expect( + processor.validatorFor(processor.catalogs.last).catalog.id, + 'cat2', + ); + }); + }); + group('MessageProcessor', () { late MinimalCatalog catalog; late MessageProcessor processor; @@ -172,6 +246,29 @@ void main() { expect(processor.groupModel.getSurface('s1'), isNull); }); + test('processPayload rejects an envelope mixing update types', () { + // An envelope carries exactly one update type. Two of them name no + // single surface, so the message cannot be matched to the catalog its + // components must be checked against. + expect( + () => processor.processPayload([ + { + 'version': 'v0.9', + 'createSurface': {'surfaceId': 's1', 'catalogId': catalog.id}, + 'updateComponents': {'surfaceId': 's2', 'components': []}, + }, + ]), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('exactly one of'), + ), + ), + ); + expect(processor.groupModel.getSurface('s1'), isNull); + }); + test('processPayload parses and processes a valid payload', () { final List messages = processor.processPayload([ { diff --git a/dart/a2ui_core/test/validator_basic_catalog_test.dart b/dart/a2ui_core/test/validator_basic_catalog_test.dart index 0b9fcddd8c..a3e6ff5d36 100644 --- a/dart/a2ui_core/test/validator_basic_catalog_test.dart +++ b/dart/a2ui_core/test/validator_basic_catalog_test.dart @@ -43,7 +43,7 @@ Map basicCatalogDocument() => /// `common_types.json` the package publishes — the same document a caller /// installing from pub.dev gets. A2uiValidator basicValidator() => - A2uiValidator(catalogs: [Catalog.fromJson(basicCatalogDocument())]); + A2uiValidator(catalog: Catalog.fromJson(basicCatalogDocument())); /// A payload declaring one surface against the basic catalog. List> render(List> components) => [ diff --git a/dart/a2ui_core/test/validator_test.dart b/dart/a2ui_core/test/validator_test.dart index 2efb3849f8..e034896381 100644 --- a/dart/a2ui_core/test/validator_test.dart +++ b/dart/a2ui_core/test/validator_test.dart @@ -118,7 +118,7 @@ Map commonTypes() => { A2uiValidator newValidator({ bool withCommonTypes = false, }) => A2uiValidator( - catalogs: [testCatalog()], + catalog: testCatalog(), commonTypesSchema: withCommonTypes ? commonTypes() : const {}, ); @@ -193,25 +193,28 @@ void main() { test('is constructed for a supported version by name', () { expect( - A2uiValidator.forVersion('v0.9').protocolVersion, + A2uiValidator.forVersion( + 'v0.9', + catalog: testCatalog(), + ).protocolVersion, A2uiProtocolVersion.v0_9, ); }); test('cannot be constructed for an unsupported version', () { expect( - () => A2uiValidator.forVersion('v1.0'), + () => A2uiValidator.forVersion('v1.0', catalog: testCatalog()), throwsA(isA()), ); expect( - () => A2uiValidator.forVersion(null), + () => A2uiValidator.forVersion(null, catalog: testCatalog()), throwsA(isA()), ); }); - test('indexes the catalogs it validates against by id', () { + test('is scoped to the catalog it validates against', () { final A2uiValidator validator = newValidator(); - expect(validator.catalogs.keys, [catalogId]); + expect(validator.catalog.id, catalogId); }); }); @@ -514,7 +517,7 @@ void main() { }, reason: 'id must not be read as a child reference'); final A2uiValidator validator = A2uiValidator( - catalogs: [inlined], + catalog: inlined, ); expect( () => validator.validateStructure( @@ -800,7 +803,7 @@ void main() { }); }); - group('A2uiValidator surface-to-catalog resolution', () { + group('A2uiValidator catalog scope', () { SchemaCatalog namedCatalog(String id, String component) => Catalog.fromJson({ 'catalogId': id, @@ -841,70 +844,50 @@ void main() { 'totally': 'bogus', }; - A2uiValidator over(List ids) => - A2uiValidator( - catalogs: [ - for (final String id in ids) - namedCatalog(id, id == 'cat1' ? 'Alpha' : 'Beta'), - ], - ); + A2uiValidator over(String id) => A2uiValidator( + catalog: namedCatalog(id, id == 'cat1' ? 'Alpha' : 'Beta'), + ); - test('uses the only catalog when the validator holds one', () { - expect( - () => over(['cat1']).validate(incremental(valid)), - returnsNormally, - ); + test('checks an incremental payload against its own catalog', () { + // A payload that only updates a surface carries no catalog id. Scoping + // the validator to one settles which catalog applies, so the components + // are checked rather than skipped. + expect(() => over('cat1').validate(incremental(valid)), returnsNormally); expect( - () => over(['cat1']).validate(incremental(bogus)), + () => over('cat1').validate(incremental(bogus)), throwsA(isA()), ); }); - test('throws rather than skip when several catalogs are ambiguous', () { - // Reporting a payload valid that nothing checked is the worse failure. - expect( - () => over(['cat1', 'cat2']).validate(incremental(bogus)), - throwsA(isA()), - ); - expect( - () => over(['cat1', 'cat2']).validate(incremental(valid)), - throwsA(isA()), - ); - }); - - test('checks against the catalog surfaceCatalogs names', () { - expect( - () => over([ - 'cat1', - 'cat2', - ]).validate(incremental(valid), surfaceCatalogs: const {'s1': 'cat1'}), - returnsNormally, - ); + test('rejects a component belonging to another catalog', () { expect( - () => over([ - 'cat1', - 'cat2', - ]).validate(incremental(bogus), surfaceCatalogs: const {'s1': 'cat1'}), + () => over('cat2').validate(incremental(valid)), throwsA(isA()), ); }); - test('rejects a component belonging to another catalog', () { + test('rejects a surface created against another catalog', () { + final List> payload = [ + { + 'version': 'v0.9', + 'createSurface': {'surfaceId': 's1', 'catalogId': 'cat2'}, + }, + ]; + expect( - () => over([ - 'cat1', - 'cat2', - ]).validate(incremental(valid), surfaceCatalogs: const {'s1': 'cat2'}), - throwsA(isA()), + () => over('cat1').validate(payload), + throwsA( + isA().having( + (e) => e.catalogId, + 'catalogId', + 'cat2', + ), + ), ); - }); - - test('throws when surfaceCatalogs names a catalog it does not hold', () { expect( - () => over([ + () => over( 'cat1', - 'cat2', - ]).validate(incremental(valid), surfaceCatalogs: const {'s1': 'nope'}), + ).validateStructure(A2uiValidator.parseMessagesFor(payload)), throwsA(isA()), ); }); From e5ec310f15380429582c4d76987f77f338d046d8 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Sat, 5 Sep 2026 10:21:14 -0700 Subject: [PATCH 2/2] Update a2ui_core.blueprint.md --- blueprints/modules/a2ui_core.blueprint.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/blueprints/modules/a2ui_core.blueprint.md b/blueprints/modules/a2ui_core.blueprint.md index 261ee29964..43cd2056df 100644 --- a/blueprints/modules/a2ui_core.blueprint.md +++ b/blueprints/modules/a2ui_core.blueprint.md @@ -495,10 +495,10 @@ Expose envelope parsing without a catalog: the protocol version tag and the sing 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. +- 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.