diff --git a/specification/0.9/docs/a2ui_protocol.md b/specification/0.9/docs/a2ui_protocol.md index bb818307..e4fa741c 100644 --- a/specification/0.9/docs/a2ui_protocol.md +++ b/specification/0.9/docs/a2ui_protocol.md @@ -82,11 +82,11 @@ A2UI v0.9 is defined by three interacting JSON schemas. The [`common_types.json`] schema defines reusable primitives used throughout the protocol. -- **`DynamicString` / `DynamicNumber` / `DynamicBoolean` / `DynamicStringList`**: The core of the data binding system. Any property that can be bound to data is defined as a `Dynamic*` type. It accepts either a literal value, a `path` string ([JSON Pointer]), or a `FunctionCall` (function call). +- **`DynamicString` / `DynamicNumber` / `DynamicBoolean` / `DynamicStringList`**: The core of the data binding system. Any property that can be bound to data is defined as a `Dynamic*` type. It accepts either a literal value, a `binding` object (Node Binding), or a `FunctionCall` (function call). - **`ChildList`**: Defines how containers hold children. It supports: - `array`: A static array of string component IDs. - - `object`: A template for generating children from a data binding list (requires a template `componentId` and a data binding `path`). + - `object`: A template for generating children from a data binding list (requires a template `componentId` and a data `binding` object). - **`id`**: The unique identifier for a component. Defined here so that all IDs are consistent and can be used for data binding. - **`weight`**: The relative weight of a component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column. Defined here so that all weights are consistent and can be used for data binding. @@ -163,13 +163,14 @@ This message provides a list of UI components to be added to or updated within a ### `updateDataModel` -This message is used to send or update the data that populates the UI components. It allows the server to change the UI's content without resending the entire component structure. +This message is used to send or update the data that populates the UI components. It uses the **Hybrid Adjacency Map** format, which treats data as a flat graph of nodes. **Properties:** - `surfaceId` (string, required): The unique identifier for the UI surface this data model update applies to. -- `path` (string, optional): A JSON Pointer to a specific location within the data model (e.g., `/user/name`). If omitted or set to `/`, the entire data model for the surface will be replaced. -- `value` (object): The data to be updated in the data model. If present, the value at `path` is updated/created. If this field is omitted, the data at `path` is **removed**. +- `nodes` (object, required): A map where keys are Node IDs and values are the node content. + - To **delete** a node, prefix the key with `!` and set the value to `null`. + - To **reference** another node (inside a list or object), use a string prefixed with `*` (e.g., `"*user_profile"`). **Example:** @@ -177,10 +178,12 @@ This message is used to send or update the data that populates the UI components { "updateDataModel": { "surfaceId": "user_profile_card", - "path": "/user", - "value": { - "name": "Jane Doe", - "title": "Software Engineer" + "nodes": { + "root": { "user": "*user_data" }, + "user_data": { + "name": "Jane Doe", + "title": "Software Engineer" + } } } } @@ -210,8 +213,8 @@ The following example demonstrates a complete interaction to render a Contact Fo ```jsonl {"createSurface":{"surfaceId":"contact_form_1","catalogId":"https://a2ui.dev/specification/0.9/standard_catalog_definition.json"}} -{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Column","children":["first_name_label","first_name_field","last_name_label","last_name_field","email_label","email_field","phone_label","phone_field","notes_label","notes_field","submit_button"]},{"id":"first_name_label","component":"Text","text":"First Name"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"path":"/contact/firstName"},"variant":"shortText"},{"id":"last_name_label","component":"Text","text":"Last Name"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"path":"/contact/lastName"},"variant":"shortText"},{"id":"email_label","component":"Text","text":"Email"},{"id":"email_field","component":"TextField","label":"Email","value":{"path":"/contact/email"},"variant":"shortText","checks":[{"call":"email","message":"Please enter a valid email address."}]},{"id":"phone_label","component":"Text","text":"Phone"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"path":"/contact/phone"},"variant":"shortText"},{"id":"notes_label","component":"Text","text":"Notes"},{"id":"notes_field","component":"TextField","label":"Notes","value":{"path":"/contact/notes"},"variant":"longText"},{"id":"submit_button_label","component":"Text","text":"Submit"},{"id":"submit_button","component":"Button","child":"submit_button_label","action":{"name":"submitContactForm"}}]}} -{"updateDataModel": {"surfaceId": "contact_form_1", "path": "/contact", "value": {"firstName": "John", "lastName": "Doe", "email": "john.doe@example.com"}}} +{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Column","children":["first_name_label","first_name_field","last_name_label","last_name_field","email_label","email_field","phone_label","phone_field","notes_label","notes_field","submit_button"]},{"id":"first_name_label","component":"Text","text":"First Name"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"binding":{"node":"contact","key":"firstName"}},"variant":"shortText"},{"id":"last_name_label","component":"Text","text":"Last Name"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"binding":{"node":"contact","key":"lastName"}},"variant":"shortText"},{"id":"email_label","component":"Text","text":"Email"},{"id":"email_field","component":"TextField","label":"Email","value":{"binding":{"node":"contact","key":"email"}},"variant":"shortText","checks":[{"call":"email","message":"Please enter a valid email address."}]},{"id":"phone_label","component":"Text","text":"Phone"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"binding":{"node":"contact","key":"phone"}},"variant":"shortText"},{"id":"notes_label","component":"Text","text":"Notes"},{"id":"notes_field","component":"TextField","label":"Notes","value":{"binding":{"node":"contact","key":"notes"}},"variant":"longText"},{"id":"submit_button_label","component":"Text","text":"Submit"},{"id":"submit_button","component":"Button","child":"submit_button_label","action":{"name":"submitContactForm"}}]}} +{"updateDataModel": {"surfaceId": "contact_form_1", "nodes": {"contact": {"firstName": "John", "lastName": "Doe", "email": "john.doe@example.com"}}}} ``` ## Component Model @@ -266,121 +269,92 @@ flowchart TD ``` -## Data Binding, Scope, and State Management - -A2UI relies on a strictly defined relationship between the UI structure (Components) and the state (Data Model). This section defines the precise mechanics of path resolution, variable scope during iteration, and the specific behaviors of two-way binding for interactive components. - -### Path Resolution & Scope +## Data Binding with Hybrid Adjacency Map -Data bindings in A2UI are defined using **JSON Pointers** ([RFC 6901]). How a pointer is resolved depends on the current **Evaluation Scope**. +A2UI v0.9 uses the **Hybrid Adjacency Map (HAM)** for data management. This system treats data as a graph of nodes rather than a monolithic JSON trees, eliminating the need for complex path pointers and list indices. -#### The Root Scope +### The Hybrid Adjacency Map Structure -By default, all components operate in the **Root Scope**. +Data is flattened into a single map of **ID-to-Value**. -- The Root Scope corresponds to the top-level object of the `value` provided in `updateDataModel`. -- Paths starting with `/` (e.g., `/user/profile/name`) are **Absolute Paths**. They always resolve from the root of the Data Model, regardless of where the component is nested in the UI tree. +1. **Container:** A single JSON Object (`nodes`). +2. **Keys:** The Node IDs (e.g., `"user_data"`, `"role_admin"`). +3. **Values:** The Node Content. + * **Literals:** Strings, Numbers, Booleans, Nulls. + * **Structures:** Lists or Maps that can contain literals or **Pointers**. +4. **Pointers:** A string prefixed with `*` is a pointer to another node ID (e.g., `"*user_profile"`). Pointers can only appear inside Lists or Maps. +5. **Hoisting:** If a literal string starts with `*`, it must be hoisted to its own node and referenced via pointer to avoid ambiguity. +### Node Binding +Components bind to data using a **Node Binding** object, not a path string. -#### Collection Scopes (Relative Paths) +```json +"text": { + "binding": { + "node": "user_data", + "key": "name" + } +} +``` -When a container component (such as `Column`, `Row`, or `List`) utilizes the **Template** feature of `ChildList`, it creates a new **Child Scope** for each item in the bound array. +- **`node` (Optional):** The absolute ID of the node to bind to. If omitted, it binds to the current **Data Context**. +- **`key` (Optional):** The property name to lookup on the target node. Required if the node is an object/map. Omitted if the node is a primitive or if you want the value/object itself. -- **Template Definition:** When a container binds its children to a path (e.g., `path: "/users"`), the client iterates over the array found at that location. -- **Scope Instantiation:** For every item in the array, the client instantiates the template component. -- **Relative Resolution:** Inside these instantiated components, any path that **does not** start with a forward slash `/` is treated as a **Relative Path**. +**Rule: No Implicit Deep Traversal** +The `key` MUST be a direct property. You cannot use paths like `"key": "address/city"`. You must bind to the distinct node. - - A relative path `firstName` inside a template iterating over `/users` resolves to `/users/0/firstName` for the first item, `/users/1/firstName` for the second, etc. +### Handling Lists (ChildList) -- **Mixing Scopes:** Components inside a Child Scope can still access the Root Scope by using an Absolute Path. +Iterating over lists involves the `ChildList` component. -#### Example: Scope Resolution +1. **Bind:** The `ChildList` binds to a property containing a list of items (literals or pointers). +2. **Iterate:** For each item in the list: + * If it's a **Pointer** (`*id`), the Data Context for the child template becomes the node `id`. + * If it's a **Literal**, the Data Context becomes that literal value. -**Data Model:** +**Example:** ```json -{ - "company": "Acme Corp", - "employees": [ - { "name": "Alice", "role": "Engineer" }, - { "name": "Bob", "role": "Designer" } - ] +// Data Model +"nodes": { + "root": { "users": ["*u1", "*u2"] }, + "u1": { "name": "Alice" }, + "u2": { "name": "Bob" } } -``` -**Component Definition:** - -```json +// UI Component { - "id": "employee_list", "component": "List", "children": { - "path": "/employees", - "componentId": "employee_card_template" + "binding": { "node": "root", "key": "users" }, + "template": "user_card" } -}, -{ - "id": "employee_card_template", - "component": "Column", - "children": ["name_text", "company_text"] -}, -{ - "id": "name_text", - "component": "Text", - "text": { "path": "name" } - // "name" is Relative. Resolves to /employees/N/name -}, -{ - "id": "company_text", - "component": "Text", - "text": { "path": "/company" } - // "/company" is Absolute. Resolves to "Acme Corp" globally. } ``` ### Two-Way Binding & Input Components -Interactive components that accept user input (`TextField`, `CheckBox`, `Slider`, `ChoicePicker`, `DateTimeInput`) establish a **Two-Way Binding** with the Data Model. - -#### The Read/Write Contract +Input components (`TextField`, `CheckBox`, etc.) imply **Two-Way Binding**. -Unlike static display components (like `Text`), input components modify the client-side data model immediately upon user interaction. +1. **Read:** The component reads the value from the `binding`. +2. **Write:** When the user interacts, the client updates the **Node** in the local HAM. +3. **Sync:** Changes are sent to the server via `action` messages. The action's `context` can also use bindings to send updated data. -1. **Read (Model -> View):** When the component renders, it reads its value from the bound `path`. If the Data Model is updated via `updateDataModel`, the component re-renders to reflect the new value. -2. **Write (View -> Model):** When the user interacts with the component (e.g., types a character, toggles a box), the client **immediately** updates the value at the bound `path` in the local Data Model. - -#### Reactivity - -Because the local Data Model is the single source of truth, updates from input components are **reactive**. - -- If a `TextField` is bound to `/user/name`, and a separate `Text` label is also bound to `/user/name`, the label must update in real-time as the user types in the text field. - -#### Server Synchronization - -It is critical to note that Two-Way Binding is **local to the client**. - -- User inputs (keystrokes, toggles) do **not** automatically trigger network requests to the server. -- The updated state is sent to the server only when a specific **User Action** is triggered (e.g., a `Button` click). -- When a `action` is dispatched, the `context` property of the action can reference the modified data paths to send the user's input back to the server. - -#### Example: Form Submission Pattern - -1. **Bind:** `TextField` is bound to `/formData/email`. -2. **Interact:** User types "jane@example.com". The local model at `/formData/email` is updated. -3. **Action:** A "Submit" button has the following action definition: - - ```json - "action": { - "name": "submit_form", - "context": { - "email": { "path": "/formData/email" } - } - } - ``` - -4. **Send:** When clicked, the client resolves `/formData/email` (getting "jane@example.com") and sends it in the `action` payload. +```json +// TextField Binding +"value": { + "binding": { "node": "form_data", "key": "email" } +} +// Button Action +"action": { + "name": "submit", + "context": { + "email": { "binding": { "node": "form_data", "key": "email" } } + } +} +``` ## Client-Side Logic & Validation @@ -396,13 +370,13 @@ Input components (like `TextField`, `CheckBox`) can define a list of checks. Eac "checks": [ { "call": "required", - "args": { "value": { "path": "/formData/zip" } }, + "args": { "value": { "binding": {"node": "form_data", "key": "zip"} } }, "message": "Zip code is required" }, { "call": "regex", "args": { - "value": { "path": "/formData/zip" }, + "value": { "binding": {"node": "form_data", "key": "zip"} }, "pattern": "^[0-9]{5}$" }, "message": "Must be a 5-digit zip code" @@ -421,11 +395,11 @@ Buttons can also define `checks`. If any check fails, the button is automaticall "checks": [ { "and": [ - { "call": "required", "args": { "value": { "path": "/formData/terms" } } }, + { "call": "required", "args": { "value": { "binding": {"node": "root", "key": "terms"} } } }, { "or": [ - { "call": "required", "args": { "value": { "path": "/formData/email" } } }, - { "call": "required", "args": { "value": { "path": "/formData/phone" } } } + { "call": "required", "args": { "value": { "binding": {"node": "root", "key": "email"} } } }, + { "call": "required", "args": { "value": { "binding": {"node": "root", "key": "phone"} } } } ] } ], @@ -493,7 +467,7 @@ If validation fails, the client (or the system acting on behalf of the client) s "code": "VALIDATION_FAILED", "surfaceId": "user_profile_card", "path": "/components/0/text", - "message": "Expected stringOrPath, got integer" + "message": "Expected stringOrBinding, got integer" } } ``` diff --git a/specification/0.9/docs/data_proposal.md b/specification/0.9/docs/data_proposal.md new file mode 100644 index 00000000..43524509 --- /dev/null +++ b/specification/0.9/docs/data_proposal.md @@ -0,0 +1,330 @@ +# **Design Proposal: Hybrid Adjacency Map** + +This proposal outlines a JSON data structure designed to represent graph data (such as nested objects and lists) in a way that is optimized for Large Language Model (LLM) generation and manipulation. + +## **The Logical Data** + +Consider this simple nested data structure that we wish to represent and update: + +```json +{ + "user": { + "name": "Jane Doe", + "roles": ["Admin", "Editor"] + } +} +``` + +## **Current solution** + +The current `updateDataModel` (for v0.9) looks like this: + +```json +{ + "updateDataModel": { + "surfaceId": "user_profile_card", + "path": "/", + "value": { + "user": { + "name": "Jane Doe", + "roles": ["Admin", "Editor"] + } + } + } +} +``` + +When we want to update this object, we send the path to the object, and the value to be updated. The "path" is the path to be replaced, in JSON pointer format, which can include indices. For instance to replace the "Editor" item with "Owner", the path would be "/user/roles/1": + +```json +{ + "updateDataModel": { + "surfaceId": "user_profile_card", + "path": "/user/roles/1", + "value": "Owner" + } +} +``` + +But if we had already removed the "Admin" item, the index would actually be "0", and the LLM would have to track that, and be informed of any external mutations to the list. + +## **The Problem** + +LLMs struggle with manipulating standard JSON arrays because they rely on numeric indices. + +1. **Hallucination:** Even without mutations, LLMs often lose count in long lists, e.g. modifying index `5` instead of `4`. +2. **Non-Local Scope:** Indices are not local to the item in the list, but rather a property of the list. If we add or remove an item in the list, the indices of all subsequent items change, and the LLM needs to be informed of this mutation if it isn't responsible for the mutation. +3. **Volatility:** Even if it is responsible for the mutation, adding or removing an item shifts the indices of all subsequent items, requiring the model to mentally re-index the entire list to perform further updates. + +## **The Solution** + +A robust solution is to treat the data as a graph (Adjacency List) where every item has a unique, stable ID. This removes the concept of "index" entirely. Standard adjacency lists are verbose, however. + +This proposal introduces the **Hybrid Adjacency Map**, a format that retains the stability of explicit IDs while minimizing token overhead. + +## **The Hybrid Adjacency Map Format** + +The data is flattened into a single map of ID-to-Value. + +1. **Container:** A single JSON Object. +2. **Keys:** The Node IDs (e.g., `"user_data"`, `"role_admin"`). +3. **Values:** The Node Content. + + - **Top-Level Primitive (String, Number, Boolean, Null):** ALWAYS a Literal Value. `null` is a valid value. + - **List/Map:** A structure that defines relationships. + +4. **Deletion:** To delete a node `foo`, you must send the node ID prefixed with `!` and any value (e.g., `"!user_data": null`). Node IDs cannot start with a “!”. The value is ignored, and is typically sent as `null`. +5. **Pointers:** References to other nodes are allowed _only_ inside Lists or Maps. They are prefixed with a sigil (default: `*`). +6. **No Escaping (Hoisting Rule):** There is no escape character (LLMs are not good at escaping). If a literal string inside a list or map happens to start with `*`, it **MUST** be hoisted to a top-level node and referenced via pointer. + +## **Comparison** + +### **Option A: Standard Adjacency List (Verbose)** + +_A traditional graph representation using an array of node objects. High structural overhead due to repeated keys (`"id"`, `"value"`), and no hybrid representation that allows for literals in lists and maps._ + +```json +{ + "updateDataModel": { + "surfaceId": "user_profile_card", + "nodes": [ + { "id": "root", "value": { "user": "user_data" } }, + { + "id": "user_data", + "value": { + "name": "user_name", + "roles": "user_roles", + "tags": "user_tags", + "settings": "user_settings" + } + }, + { "id": "user_name", "value": "Jane Doe" }, + { "id": "user_roles", "value": ["role_admin", "role_editor"] }, + { "id": "user_tags", "value": ["tag_active", "tag_premium"] }, + { "id": "role_admin", "value": { "title": "val_admin", "access": "access_all" } }, + { "id": "role_editor", "value": { "title": "val_editor", "access": "access_rw" } }, + { "id": "val_admin", "value": "Admin" }, + { "id": "val_editor", "value": "Editor" }, + { "id": "access_all", "value": ["val_all"] }, + { "id": "access_rw", "value": ["val_read", "val_write"] }, + { "id": "val_all", "value": "all" }, + { "id": "val_read", "value": "read" }, + { "id": "val_write", "value": "write" }, + { "id": "tag_active", "value": "Active" }, + { "id": "tag_premium", "value": "Premium" }, + { "id": "user_settings", "value": null }, + { "id": "note_ref", "value": "* This is a literal string starting with an asterisk" } + ] + } +} +``` + +### **Option B: Hybrid Adjacency Map (HAM) (Recommended)** + +_Minimal overhead. Keys act as definitions. Type is inferred from context. Literals can be inlined or referenced via pointers._ + +```json +{ + "updateDataModel": { + "surfaceId": "user_profile_card", + "nodes": { + "root": { "user": "*user_data" }, + "user_data": { + "name": "*user_name", + "roles": "*user_roles", + "tags": ["Active", "Premium"], + "settings": "*user_settings" + }, + "user_roles": ["*role_admin", "*role_editor"], + "user_name": "Jane Doe", + "role_admin": { "title": "Admin", "access": ["all"] }, + "role_editor": { "title": "Editor", "access": ["read", "write"] }, + // Example of a null node + "user_settings": null, + // Example of the hoisting rule for a restricted character + "note_ref": "* This is a literal string starting with an asterisk" + } + } +} +``` + +## **Hybrid Approach: Efficiency vs. Atomicity** + +The Hybrid Adjacency Map format encourages a mixed strategy that balances token efficiency with update granularity: + +1. **Use Literals for Static/Simple Data:** Primitives (strings, numbers) and simple lists (like tags or enums) should remain as literals. This avoids the overhead of creating definitions for every distinct string. +2. **Use Pointers (`*`) for Complex/Mutable Data:** Entities that are shared, frequently updated, or complex (like `user_roles`) should be extracted to nodes. This allows you to update a single role (e.g. changing permissions) without re-sending the entire user object or list of roles. +3. **LLM Resilience:** LLMs heavily favor standard JSON patterns and may accidentally output literals (e.g. `["Admin"]`) even when instructed to use IDs. A strict "IDs-only" system would break on these "lazy" generations. This hybrid format allows them, so long as they follow the hoisting rule. + +## **Key Benefits** + +1. **No Indexing:** The LLM never needs to calculate an index or path to update a node. It simply provides the ID and the new value. To update list order, it rewrites the list node, reordering the IDs. Mutations are localized. +2. **Safety:** Leaf nodes (the bulk of the data) are treated as raw values. The parser never scans root nodes for pointers, so no accidental "broken link" errors occur if the text content happens to start with `*`. +3. **Zero Ambiguity:** A string starting with `*` not at the top level (i.e. inside a list or map) is _always_ a pointer. A string starting with `*` at the root is _always_ a literal. + +## **Handling Updates** + +**Scenario: Reorder Roles** The LLM defines the list node with a new order of pointers. + +```json +{ + "updateDataModel": { + "surfaceId": "user_profile_card", + "nodes": { + "user_roles": ["*role_editor", "*role_admin"] + } + } +} +``` + +**Scenario: Deleting a Node** To delete a node (e.g., `role_editor`), the LLM provides the key prefixed with `!` and sets the value to `null`. + +- This removes the ID `role_editor` from the registry. + +```json +{ + "updateDataModel": { + "surfaceId": "user_profile_card", + "nodes": { + "!role_editor": null, + "user_roles": ["*role_admin"] + } + } +} +``` + +**Scenario: Literal Null** To store a literal `null` at the top level (e.g. to signify "Unset but present"), simply set the value to `null`. + +```json +{ + "updateDataModel": { + "surfaceId": "user_profile_card", + "nodes": { + "user_settings": null + } + } +} +``` + +## **Data Binding** + +Data binding in the Hybrid Adjacency Map system replaces JSON Pointers with a graph-based lookup strategy called **Node Binding**. This system is designed to completely eliminate the need for list indices in binding definitions. + +### **The `binding` Object** + +Instead of a string path, components use a `binding` object to resolve values: + +```json +"text": { + "binding": { + "node": "user_data", + "key": "name" + } +} +``` + +- **`node` (Optional):** The absolute ID of the node to bind to. If omitted, the binding applies to the current **Data Context**. +- **`key` (Optional):** The property name to lookup on the target node. + - If the target node is a Map/Object, `key` is required to access a property. + - If the target node is a Primitive (String/Number) or if you want the object itself, `key` is omitted. + +### **Rule: No Implicit Deep Traversal** + +To prevent "index shifting" issues, the `key` property **MUST NOT** be a path. It can only reference a direct property of the node. + +- **Valid:** `"key": "address"` (returns the value of address, which might be a pointer `*addr_1`). +- **Valid:** `"key": "tags"` (returns the list of tags). +- **Prohibited:** `"key": "address/city"` (Deep traversals must be done by following pointers or binding to the specific node `addr_1`). +- **Prohibited:** `"key": "tags/0"` (Indices are strictly forbidden). + +### **Handling Lists (ChildList)** + +Since we cannot use indices (e.g. `users/0`), iterating over lists is handled exclusively by the `ChildList` component type. + +1. **Bind to List:** The `ChildList` binds to a property that contains a list (e.g. `user_list`). +2. **Iterate:** The client iterates over the list. +3. **Set Context:** For each item, the client sets the **Data Context** for the child template. + - If the item is a pointer (`*u1`), the context becomes the node `u1`. + - If the item is a literal (e.g. `{"name": "Alice"}`), the context becomes that literal map. + +**Example: Templated List** + +```json +// Data +"nodes": { + "root": { "users": ["*u1", "*u2"] }, + "u1": { "name": "Alice" }, + "u2": { "name": "Bob" } +} + +// UI +{ + "component": "List", + "children": { + "binding": { "node": "root", "key": "users" }, + "template": "user_card" + } +}, +{ + "id": "user_card", + "component": "Text", + "text": { + // Omitting "node" binds to the current item (u1 or u2) + "binding": { "key": "name" } + } +} +``` + +## **Example Parsing Logic (Dart)** + +The parser logic handles the special `!` prefix for deletion. + +```dart +// A registry of all active nodes +Map nodeRegistry = {}; + +/// Resolves a pointer to its value. +/// If the ID is missing (deleted), it returns NULL. +dynamic resolve(String id) { + return nodeRegistry[id]; +} + +/// Parses values inside a collection (List/Map) where pointers are allowed. +dynamic parseInnerValue(dynamic value) { + if (value is String) { + if (value.startsWith('*')) { + // It's a pointer: Resolve it immediately + return resolve(value.substring(1)); + } + return value; // Literal string + } + + if (value is List) return value.map(parseInnerValue).toList(); + if (value is Map) return value.map((k, v) => MapEntry(k, parseInnerValue(v))); + + return value; +} + +void applyUpdate(Map updates) { + updates.forEach((key, rawValue) { + // 1. DELETE CHECK + // If key starts with "!" it is a deletion command. + if (key.startsWith('!')) { + String targetId = key.substring(1); + nodeRegistry.remove(targetId); + return; + } + + // 2. UPSERT LITERAL (Primitives + Null) + // Top-level values (Strings, Nulls, Numbers) are literals. + if (rawValue is! Map && rawValue is! List) { + nodeRegistry[key] = rawValue; + } + // 3. UPSERT STRUCTURE (List / Map) + // Collections require parsing to find pointers. + else { + nodeRegistry[key] = parseInnerValue(rawValue); + } + }); +} +``` diff --git a/specification/0.9/eval/src/prompts.ts b/specification/0.9/eval/src/prompts.ts index c754cf7f..04a6f6e8 100644 --- a/specification/0.9/eval/src/prompts.ts +++ b/specification/0.9/eval/src/prompts.ts @@ -53,19 +53,20 @@ The dog generator is another card which is a form that generates a fictional dog name: "loginForm", description: 'A simple login form with username, password, a "remember me" checkbox, and a submit button.', - promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a login form. It should have a "Login" text (variant 'h1'), two text fields for username and password (bound to /login/username and /login/password), a checkbox for "Remember Me" (bound to /login/rememberMe), and a "Sign In" button. The button should trigger a 'login' action, passing the username, password, and rememberMe status in the dynamicContext.`, + promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a login form. It should have a "Login" text (variant 'h1'), two text fields for username and password (bound to node 'login' key 'username' and node 'login' key 'password'), a checkbox for "Remember Me" (bound to node 'login' key 'rememberMe'), and a "Sign In" button. The button should trigger a 'login' action, passing the username, password, and rememberMe status in the dynamicContext.`, }, { name: "productGallery", description: "A gallery of products using a list with a template.", - promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a product gallery. It should display a list of products from the data model at '/products'. Use a template for the list items. Each item should be a Card containing a Column. The Column should contain an Image (from '/products/item/imageUrl'), a Text component for the product name (from '/products/item/name'), and a Button labeled "Add to Cart". The button's action should be 'addToCart' and include a context with the product ID, for example, 'productId': 'static-id-123' (use this exact literal string). You should create a template component and then a list that uses it.`, + promptText: `Generate a 'createSurface' message and a 'updateComponents' message with surfaceId 'main' for a product gallery. It should display a list of products from the data model, bound to node 'root' key 'products'. Use a template for the list items. Each item should be a Card containing a Column. The Column should contain an Image (bound to key 'imageUrl'), a Text component for the product name (bound to key 'name'), and a Button labeled "Add to Cart". The button's action should be 'addToCart' and include a context with the product ID, for example, 'productId': 'static-id-123' (use this exact literal string). You should create a template component and then a list that uses it.`, }, { name: "productGalleryData", description: "An updateDataModel message to populate the product gallery data.", - promptText: `Generate a 'createSurface' message with surfaceId 'main', followed by an updateDataModel message to populate the data model for the product gallery. The update should target the path '/products' and include at least two products. Each product in the map should have keys 'id', 'name', and 'imageUrl'. For example: + promptText: `Generate a 'createSurface' message with surfaceId 'main', followed by an updateDataModel message to populate the data model for the product gallery. The update should provide a 'nodes' map. The 'root' node should have a 'products' key containing a list of pointers to product nodes (e.g. ["*product1", "*product2"]). Then define the product nodes ('product1', 'product2') with keys 'id', 'name', and 'imageUrl'. For example: { + "root": { "products": ["*product1"] }, "product1": { "id": "product1", "name": "Awesome Gadget", @@ -81,7 +82,7 @@ The dog generator is another card which is a form that generates a fictional dog { name: "updateDataModel", description: "An updateDataModel message to update user data.", - promptText: `Generate a 'createSurface' message with surfaceId 'main', followed by an updateDataModel message. This is used to update the client's data model. The scenario is that a user has just logged in, and we need to populate their profile information. Create a single data model update message to set '/user/name' to "John Doe" and '/user/email' to "john.doe@example.com".`, + promptText: `Generate a 'createSurface' message with surfaceId 'main', followed by an updateDataModel message. This is used to update the client's data model. The scenario is that a user has just logged in, and we need to populate their profile information. Create a single data model update message with a 'nodes' map. Define a 'user' node with keys 'name' set to "John Doe" and 'email' set to "john.doe@example.com". Ensure the 'root' node has a reference to this user node if necessary, or just treat 'user' as a known node ID.`, }, { name: "animalKingdomExplorer", @@ -325,29 +326,27 @@ Each activity in the inner lists should be a 'Row' containing a 'CheckBox' (to m { name: "nestedDataBinding", description: "A project dashboard with deeply nested data binding.", - promptText: `Generate a stream of JSON messages for a Project Management Dashboard. - The output must consist of exactly three JSON objects, one after the other. - - Generate a createSurface message with surfaceId 'main'. + promptText: `Generate a createSurface message with surfaceId 'main'. Generate an updateComponents message with surfaceId 'main'. It should have a 'Text' (variant 'h1') "Project Dashboard". - Then a 'List' of projects bound to '/projects'. - Inside the list template, each item should be a 'Card' containing: - - A 'Text' (variant 'h2') bound to the project 'title'. - - A 'List' of tasks bound to the 'tasks' property of the project. + Then a 'List' of projects. The list should bind to node 'root' key 'projects'. + Inside the list template, using 'ChildList', each item should be a 'Card' containing: + - A 'Text' (variant 'h2') bound to key 'title' (omit 'node' to bind to current project). + - A 'List' of tasks bound to key 'tasks' (omit 'node' to bind to current project). Inside the tasks list template, each item should be a 'Column' containing: - - A 'Text' bound to the task 'description'. + - A 'Text' bound to key 'description'. - A 'Row' for the assignee, containing: - - A 'Text' bound to 'assignee/name'. - - A 'Text' bound to 'assignee/role'. - - A 'List' of subtasks bound to 'subtasks'. - Inside the subtasks list template, each item should be a 'Text' bound to 'title'. + - A 'Text' bound to key 'assigneeName' (flattened on task node). + - A 'Text' bound to key 'assigneeRole' (flattened on task node). + - A 'List' of subtasks bound to key 'subtasks'. + Inside the subtasks list template, each item should be a 'Text' bound to key 'title'. Then generate an 'updateDataModel' message. - Populate this dashboard with sample data: - - At least one project. - - The project should have a title, and a list of tasks. - - The task should have a description, an assignee object (with name and role), and a list of subtasks.`, + Populate this dashboard with a 'nodes' map containing: + - A 'root' node with a 'projects' list of pointers (e.g. ["*p1"]). + - Project nodes (e.g. "p1") with 'title' and 'tasks' (list of pointers). + - Task nodes with 'description', 'assigneeName', 'assigneeRole', and 'subtasks' (list of pointers). + - Subtask nodes with 'title'.`, }, { diff --git a/specification/0.9/eval/src/validator.ts b/specification/0.9/eval/src/validator.ts index 18a04409..5125e3ee 100644 --- a/specification/0.9/eval/src/validator.ts +++ b/specification/0.9/eval/src/validator.ts @@ -279,10 +279,29 @@ export class Validator { private validateUpdateDataModel(data: any, errors: string[]) { // Schema validation handles types and basic structure. - // 'op' is removed in v0.9, so we don't need to validate it or its relationship with 'value'. - // We strictly rely on the schema for this message type now. - // Check if 'value' is present. If it is NOT present, it implies a deletion (if path is present). - // If path is missing and value is missing, it deletes the entire root (valid but rare). + if (!data.nodes || typeof data.nodes !== "object") { + errors.push("updateDataModel must have a 'nodes' object."); + return; + } + + for (const key of Object.keys(data.nodes)) { + // Validate node IDs + if (key.startsWith("*")) { + errors.push( + `Node ID '${key}' must not start with '*'. Pointers are values, not keys.` + ); + } + // Deletions start with '!', value must be null + if (key.startsWith("!")) { + if (data.nodes[key] !== null) { + errors.push( + `Deletion node '${key}' must have a null value, but got: ${JSON.stringify( + data.nodes[key] + )}` + ); + } + } + } } private validateComponent( diff --git a/specification/0.9/json/common_types.json b/specification/0.9/json/common_types.json index 58c43ad1..7bc13e49 100644 --- a/specification/0.9/json/common_types.json +++ b/specification/0.9/json/common_types.json @@ -4,6 +4,21 @@ "title": "A2UI Common Types", "description": "Common type definitions used across A2UI schemas.", "$defs": { + "NodeBinding": { + "type": "object", + "description": "Binds a property to a value in the data model.", + "properties": { + "node": { + "type": "string", + "description": "The ID of the node to bind to. If omitted, binds to the current data context (e.g. the current item in a list)." + }, + "key": { + "type": "string", + "description": "The property key to access on the target node. Required if the target is a Map/Object. Omit if the target is a primitive or if you want the object itself." + } + }, + "additionalProperties": false + }, "FunctionCall": { "type": "object", "description": "Invokes a named function on the client.", @@ -29,15 +44,15 @@ "required": ["call"] }, "DynamicString": { - "description": "Represents a value that can be either a literal string, a path to a string in the data model, or a function call returning a string.", + "description": "Represents a value that can be either a literal string, a binding to a string in the data model, or a function call returning a string.", "oneOf": [ { "type": "string" }, { "type": "object", "properties": { - "path": { "type": "string" } + "binding": { "$ref": "#/$defs/NodeBinding" } }, - "required": ["path"], + "required": ["binding"], "additionalProperties": false }, { @@ -54,15 +69,15 @@ ] }, "DynamicNumber": { - "description": "Represents a value that can be either a literal number, a path to a number in the data model, or a function call returning a number.", + "description": "Represents a value that can be either a literal number, a binding to a number in the data model, or a function call returning a number.", "oneOf": [ { "type": "number" }, { "type": "object", "properties": { - "path": { "type": "string" } + "binding": { "$ref": "#/$defs/NodeBinding" } }, - "required": ["path"], + "required": ["binding"], "additionalProperties": false }, { @@ -79,15 +94,15 @@ ] }, "DynamicBoolean": { - "description": "Represents a value that can be either a literal boolean, a path to a boolean in the data model, or a function call returning a boolean.", + "description": "Represents a value that can be either a literal boolean, a binding to a boolean in the data model, or a function call returning a boolean.", "oneOf": [ { "type": "boolean" }, { "type": "object", "properties": { - "path": { "type": "string" } + "binding": { "$ref": "#/$defs/NodeBinding" } }, - "required": ["path"], + "required": ["binding"], "additionalProperties": false }, { @@ -103,7 +118,7 @@ ] }, "DynamicStringList": { - "description": "Represents a value that can be either a literal array of strings, a path to a string array in the data model, or a function call returning a string array.", + "description": "Represents a value that can be either a literal array of strings, a binding to a string array in the data model, or a function call returning a string array.", "oneOf": [ { "type": "array", @@ -112,9 +127,9 @@ { "type": "object", "properties": { - "path": { "type": "string" } + "binding": { "$ref": "#/$defs/NodeBinding" } }, - "required": ["path"], + "required": ["binding"], "additionalProperties": false }, { @@ -157,16 +172,15 @@ } }, "DynamicValue": { - "description": "A value that can be a literal, a path, or a function call returning any type.", + "description": "A value that can be a literal, a binding, or a function call returning any type.", "oneOf": [ { "type": "string" }, { "type": "number" }, { "type": "boolean" }, - { "type": "object", - "properties": { "path": { "type": "string" } }, - "required": ["path"], + "properties": { "binding": { "$ref": "#/$defs/NodeBinding" } }, + "required": ["binding"], "additionalProperties": false }, { "$ref": "#/$defs/FunctionCall" } @@ -186,12 +200,12 @@ "componentId": { "$ref": "#/$defs/id" }, - "path": { - "type": "string", - "description": "The path to the list of component property objects in the data model." + "binding": { + "$ref": "#/$defs/NodeBinding", + "description": "The binding to the list of data nodes in the Hybrid Adjacency Map." } }, - "required": ["componentId", "path"], + "required": ["componentId", "binding"], "additionalProperties": false } ] diff --git a/specification/0.9/json/contact_form_example.jsonl b/specification/0.9/json/contact_form_example.jsonl index 6d9d04b1..05cdc9ea 100644 --- a/specification/0.9/json/contact_form_example.jsonl +++ b/specification/0.9/json/contact_form_example.jsonl @@ -1,3 +1,3 @@ {"createSurface":{"surfaceId":"contact_form_1","catalogId":"https://a2ui.dev/specification/0.9/standard_catalog_definition.json"}} -{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Column","children":["first_name_label","first_name_field","last_name_label","last_name_field","email_label","email_field","phone_label","phone_field","notes_label","notes_field","submit_button"]},{"id":"first_name_label","component":"Text","text":"First Name"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"path":"/contact/firstName"},"variant":"shortText"},{"id":"last_name_label","component":"Text","text":"Last Name"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"path":"/contact/lastName"},"variant":"shortText"},{"id":"email_label","component":"Text","text":"Email"},{"id":"email_field","component":"TextField","label":"Email","value":{"path":"/contact/email"},"variant":"shortText","checks":[{"call":"email","message":"Please enter a valid email address."}]},{"id":"phone_label","component":"Text","text":"Phone"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"path":"/contact/phone"},"variant":"shortText"},{"id":"notes_label","component":"Text","text":"Notes"},{"id":"notes_field","component":"TextField","label":"Notes","value":{"path":"/contact/notes"},"variant":"longText"},{"id":"submit_button_label","component":"Text","text":"Submit"},{"id":"submit_button","component":"Button","child":"submit_button_label","action":{"name":"submitContactForm"}}]}} -{"updateDataModel": {"surfaceId": "contact_form_1", "path": "/contact", "value": {"firstName": "John", "lastName": "Doe", "email": "john.doe@example.com"}}} +{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Column","children":["first_name_label","first_name_field","last_name_label","last_name_field","email_label","email_field","phone_label","phone_field","notes_label","notes_field","submit_button"]},{"id":"first_name_label","component":"Text","text":"First Name"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"binding":{"node":"contact","key":"firstName"}},"variant":"shortText"},{"id":"last_name_label","component":"Text","text":"Last Name"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"binding":{"node":"contact","key":"lastName"}},"variant":"shortText"},{"id":"email_label","component":"Text","text":"Email"},{"id":"email_field","component":"TextField","label":"Email","value":{"binding":{"node":"contact","key":"email"}},"variant":"shortText","checks":[{"call":"email","message":"Please enter a valid email address."}]},{"id":"phone_label","component":"Text","text":"Phone"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"binding":{"node":"contact","key":"phone"}},"variant":"shortText"},{"id":"notes_label","component":"Text","text":"Notes"},{"id":"notes_field","component":"TextField","label":"Notes","value":{"binding":{"node":"contact","key":"notes"}},"variant":"longText"},{"id":"submit_button_label","component":"Text","text":"Submit"},{"id":"submit_button","component":"Button","child":"submit_button_label","action":{"name":"submitContactForm"}}]}} +{"updateDataModel": {"surfaceId": "contact_form_1", "nodes": {"contact": {"firstName": "John", "lastName": "Doe", "email": "john.doe@example.com"}}}} diff --git a/specification/0.9/json/server_to_client.json b/specification/0.9/json/server_to_client.json index 90640f78..5ecef10f 100644 --- a/specification/0.9/json/server_to_client.json +++ b/specification/0.9/json/server_to_client.json @@ -68,22 +68,19 @@ "properties": { "updateDataModel": { "type": "object", - "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model.", "properties": { "surfaceId": { "type": "string", "description": "The unique identifier for the UI surface this data model update applies to." }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." - }, - "value": { - "description": "The data to be updated in the data model. If present, the value at 'path' is replaced (or created). If omitted, the key at 'path' is removed.", + "nodes": { + "type": "object", + "description": "A map where keys are unique Node IDs and values are the node content. Top-level primitive values (strings, numbers, booleans, null) are always treated as literals. Lists and Maps can contain 'pointers' to other nodes. A pointer is a node ID prefixed with '*' (e.g. '*user_profile'). To delete a previously defined node, prefix its key with '!' and set the value to null. IMPORTANT: If a literal string inside a nested list or map starts with '*', it MUST be extracted to a top-level node and referenced via a pointer to avoid being interpreted as a pointer.", "additionalProperties": true } }, - "required": ["surfaceId"], + "required": ["surfaceId", "nodes"], "additionalProperties": false } }, diff --git a/specification/0.9/json/standard_catalog_definition.json b/specification/0.9/json/standard_catalog_definition.json index fec2e90e..cfa153ac 100644 --- a/specification/0.9/json/standard_catalog_definition.json +++ b/specification/0.9/json/standard_catalog_definition.json @@ -181,9 +181,11 @@ { "type": "object", "properties": { - "path": { "type": "string" } + "binding": { + "$ref": "common_types.json#/$defs/NodeBinding" + } }, - "required": ["path"], + "required": ["binding"], "additionalProperties": false } ] @@ -452,7 +454,7 @@ "name": { "type": "string" }, "context": { "type": "object", - "description": "A JSON object containing the key-value pairs for the action context. Values can be literals or paths. Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.", + "description": "A JSON object containing the key-value pairs for the action context. Values can be literals or bindings. Use literal values unless the value must be dynamically bound to the data model. Do NOT use bindings for static IDs.", "additionalProperties": { "$ref": "common_types.json#/$defs/DynamicValue" } diff --git a/specification/0.9/json/standard_catalog_rules.txt b/specification/0.9/json/standard_catalog_rules.txt index 33e60134..4345dad0 100644 --- a/specification/0.9/json/standard_catalog_rules.txt +++ b/specification/0.9/json/standard_catalog_rules.txt @@ -1,5 +1,5 @@ **REQUIRED PROPERTIES:** You MUST include ALL required properties for every component, even if they are inside a template or will be bound to data. -- For 'Text', you MUST provide 'text'. If dynamic, use { "path": "..." }. -- For 'Image', you MUST provide 'url'. If dynamic, use { "path": "..." }. +- For 'Text', you MUST provide 'text'. If dynamic, use { "binding": { ... } }. +- For 'Image', you MUST provide 'url'. If dynamic, use { "binding": { ... } }. - For 'Button', you MUST provide 'action'. - For 'TextField', 'CheckBox', etc., you MUST provide 'label'.