conductor/docs/response-mapping-model.md
2026-07-18 10:23:13 -04:00

402 lines
16 KiB
Markdown

# Response Mapping Model
**Step 17.0 design document — established 2025**
This document records the canonical decisions about how action responses flow into
component state in the Conductor Preview runtime. It is the authoritative
reference for Steps 17.x implementation.
---
## Table of Contents
1. [Why `project.bindings` is canonical](#1-why-projectbindings-is-canonical)
2. [Why actions own runtime responses](#2-why-actions-own-runtime-responses)
3. [Why `actions.<id>.response` is a virtual namespace](#3-why-actionsidresponse-is-a-virtual-namespace)
4. [Source path grammar](#4-source-path-grammar)
5. [Target path grammar](#5-target-path-grammar)
6. [Trigger semantics](#6-trigger-semantics)
7. [Label runtime-value behaviour](#7-label-runtime-value-behaviour)
8. [Compatibility — existing `onClick` bindings](#8-compatibility--existing-onclick-bindings)
9. [Deprecation of `action.responseMapping`](#9-deprecation-of-actionresponsemapping)
10. [Step 17.1 scope](#10-step-171-scope)
11. [Deferred capabilities](#11-deferred-capabilities)
12. [Required diagnostics](#12-required-diagnostics)
---
## 1. Why `project.bindings` is canonical
Two mechanisms could in principle move action response data into components:
| Mechanism | Location | Status |
|---|---|---|
| `action.responseMapping[]` | Inside each `Action` object | **Deprecated** |
| `project.bindings[]` | Top-level project array | **Canonical** |
`project.bindings` was chosen as the sole canonical mechanism for the following
reasons:
**Single locus of data-flow declarations.** All data movement in a Conductor
project — between components, from action responses, from variables — is
expressed in one flat array. Keeping response routing there avoids a split
where "inbound response routing" lives inside actions while all other data
movement lives at the project level.
**Symmetry with component-sourced bindings.** A binding from a TextInput value
to a Label uses the same structure as a binding from an action response to a
JsonViewer. Same schema, same Inspector rendering, same future transform field.
**Decoupled from action definition.** An action definition describes *how to
call* an endpoint (URL, method, auth). Where the response goes is a layout
concern, not an API concern. Separating them means a single action can feed
multiple targets without duplicating the action.
**Better Inspector and diagnostics surface.** A flat array of bindings is easy
to enumerate, validate cross-references for, and display in a panel.
Distributing response mapping rules across individual action objects makes
completeness checks harder.
**Incremental migration path.** Existing documents that carry
`action.responseMapping` values remain valid under the schema; they are simply
ignored at runtime. New documents use only `project.bindings`.
---
## 2. Why actions own runtime responses
Although `project.actions` is a design-time array of REST action definitions,
the Preview runtime must store ephemeral per-action state: the most recent
response, loading flag, and error. This state is:
- **Not stored in canonical project JSON.** The project document is the
design-time source of truth. Mutating it with runtime values would corrupt
the save/load round-trip.
- **Keyed by action ID.** Even though actions are stored as an ordered array
in the project document, the runtime resolves them by `id` for O(1) lookup.
- **Discarded on navigation or refresh.** Preview state is ephemeral React
state (`useState`).
The proposed runtime shape (to be implemented in Step 17.1) is:
```ts
type ActionRuntimeState = {
response?: ProxyResponse; // the full ProxyResponse envelope
loading?: boolean;
error?: string;
};
type ActionRuntimeStateMap = Record<string, ActionRuntimeState>;
```
This is a parallel structure to the existing `componentState` map in
`usePreviewRuntime`. In Step 17.1 it will be introduced alongside it.
> **Step 17.0 note:** This runtime shape is defined here for design purposes.
> It is not implemented in Step 17.0. The current runtime continues to store
> the full `ProxyResponse` envelope directly in `componentState[id].value`.
---
## 3. Why `actions.<id>.response` is a virtual namespace
In binding source and target expressions, paths of the form:
```
actions.<actionId>.response
actions.<actionId>.response.body
actions.<actionId>.response.body.<field>
```
refer to runtime action response data. This is a **virtual namespace** because:
1. `project.actions` is an **array** in the canonical document, not an object
keyed by ID. At design time, `actions.action_httpbin` does not exist as a
JSON key path.
2. The runtime resolves `<actionId>` via `Array.find((a) => a.id === actionId)`,
not by property access.
3. The path is only meaningful during Preview execution. It has no value in the
persisted project document.
The path syntax is chosen for readability and symmetry with `components.<name>.*`
target paths. It is not JSONPath; it is a **Conductor runtime dot-path** with
a fixed `actions.<id>.response[.body[.<fields>]]` prefix.
---
## 4. Source path grammar
Step 17.1 will support the following source path forms for action-response
bindings:
```
actions.<actionId>.response
actions.<actionId>.response.body
actions.<actionId>.response.body.<field>
actions.<actionId>.response.body.<nested>.<field>
```
### Resolution rules
| Path | Resolved value |
|---|---|
| `actions.<id>.response` | The full `ProxyResponse` envelope `{ ok, status, statusText, headers, body, durationMs }` |
| `actions.<id>.response.body` | `ProxyResponse.body` — parsed JSON object or text string |
| `actions.<id>.response.body.<field>` | `ProxyResponse.body.<field>` where `body` is an object |
| `actions.<id>.response.body.<nested>.<field>` | Deep dot-path traversal on `body` |
If a field in the path does not exist on the response body, the resolved value
is `undefined`. Step 17.1 will surface a runtime warning in that case; it
will not throw.
### Deferred source path forms
The following are explicitly deferred and must not be inferred from the above:
- `actions.<id>.response.headers.<header>` — reserved; not in Step 17.1
- `actions.<id>.response.status` — reserved; not in Step 17.1
- Wildcard `*` segments
- Filter expressions `[?(...)]`
- Array index syntax `[0]`, `[-1]`
- Keys containing literal `.` characters
---
## 5. Target path grammar
Step 17.1 will support only:
```
components.<componentName>.value
```
### Semantic enforcement
- `<componentName>` must match `component.name` (not `component.id`) in the
project's page components, consistent with the existing binding model.
- The property segment **must be** `.value`. The Inspector must emit a warning
for targets with any other property segment (e.g. `.data`, `.message`,
`.label`). The runtime must not silently remap these to `.value`.
### Property segment rationale
`.value` is the single runtime-mutable property exposed by `ComponentRuntimeState`
for display purposes. Until per-property runtime targeting is designed:
- `components.<name>.value` is valid.
- `components.<name>.data`, `.message`, `.label`, `.text`, etc. are unsupported.
They must produce an Inspector diagnostic and a runtime warning.
### Deferred target forms
- `variables.<variableName>` — variable runtime support is deferred
- `components.<name>.label` — Label runtime-value override is deferred to Step 17.1
(see §7)
- Per-property routing for non-`.value` segments
---
## 6. Trigger semantics
### Canonical trigger for action-response bindings: `onSuccess`
```json
{ "trigger": "onSuccess" }
```
`onSuccess` fires when the action completes and `ProxyResponse.ok === true`.
This is the trigger all new action-response bindings should use.
### Why not `onChange` (the schema default)?
The schema's `binding.trigger` default is `"onChange"`. This default is
appropriate for component-to-component data flow (e.g. a TextInput value driving
a downstream Label). It is not appropriate for action-response bindings because:
- Action responses are not "change" events on a component; they are completion
events on an async operation.
- `"onChange"` implies reactive/continuous behaviour; action execution is
discrete and user-triggered (or lifecycle-triggered).
The `"onChange"` default is therefore **not the correct default for
action-response bindings**. The Inspector should surface a warning when
`"onChange"` is found on a binding whose source is `actions.*`.
### Deferred triggers
| Trigger | Status |
|---|---|
| `onSuccess` | **Step 17.1** |
| `onError` | Deferred |
| `onLoad` | Deferred |
| `onChange` | Component-change bindings only; deferred |
| Chained actions | Deferred |
---
## 7. Label runtime-value behaviour
**Current (Step 17.0):** Label renders `properties.label` only. It has no
`runtimeState` wiring. Runtime action responses cannot update a Label.
**Planned (Step 17.1):**
- `properties.label` — design-time / configured text. Displayed when no
runtime value is set.
- `componentState[id].value` — runtime display value. If set (not `undefined`),
it overrides `properties.label` in the Preview renderer.
- A binding of the form:
```json
{
"source": "actions.<id>.response.body.<field>",
"target": "components.<labelName>.value",
"trigger": "onSuccess"
}
```
writes the resolved field value (coerced to string for display) into
`componentState[labelId].value`.
- **Runtime mutations must not modify canonical project JSON.** The override
is ephemeral; it lives only in `useState` for the Preview session.
This makes Label consistent with JsonViewer, which already reads
`runtimeState.value` in `PreviewComponent.tsx`.
---
## 8. Compatibility — existing `onClick` bindings
Steps 15 and 16 produced examples and docs using:
```json
{ "trigger": "onClick" }
```
on action-response bindings. These bindings fire when the button's `onClick`
event causes the action to execute.
**Step 17.0 treatment:** `"onClick"` bindings continue to work exactly as they
do today. The runtime (`usePreviewRuntime`) fires all bindings whose source
matches the action ID, regardless of their `trigger` value.
**Recommended migration:** New bindings should use `"onSuccess"` rather than
`"onClick"`. The Inspector may surface an info-level diagnostic recommending
migration on bindings that use `"onClick"` as the trigger on an
`actions.*` source. This is not yet implemented.
**Important:** `"onClick"` on an action-response binding is **legacy**, not
invalid. Documents using it will not fail schema validation and will not be
broken by Step 17.1.
---
## 9. Deprecation of `action.responseMapping`
`action.responseMapping` is **deprecated as of Step 17.0**.
| Property | Status |
|---|---|
| Present in schema | Yes — retained for backward compatibility |
| `x-deprecated: true` annotation | Added in Step 17.0 |
| Schema `description` | Updated to say `project.bindings` is canonical |
| TypeScript `@deprecated` JSDoc | Added in Step 17.0 |
| TS type narrowed | Yes — changed from `unknown[]` to `ResponseMappingRule[]` |
| Executed by Preview runtime | **No** — never was, never will be |
| Executed by backend proxy | **No** — never was, never will be |
| Shown by Inspector | **No** |
| Removal timeline | Not scheduled; retained for backward compatibility only |
**No new examples should use `action.responseMapping`.** Existing examples
(`valid-full.json`) that carry it are not updated; they remain valid because
the field is optional and the schema accepts (but ignores) its contents.
---
## 10. Step 17.1 scope
Step 17.1 will implement execution of `project.bindings` for
action-response-to-component data movement. Specifically:
1. **`ActionRuntimeStateMap`** — introduce alongside the existing
`componentState` map in `usePreviewRuntime`. After each proxy call,
write the `ProxyResponse` into `actionState[actionId].response`.
2. **Source path resolution** — implement dot-path traversal for
`actions.<id>.response`, `.response.body`, and `.response.body.<field...>`.
3. **Binding execution loop** — after a successful proxy call, iterate
`project.bindings` whose `trigger` is `"onSuccess"` (or `"onClick"` for
legacy compatibility) and whose `source` parses to the completed action ID.
Resolve the source path against `actionState[actionId].response`. Write the
resolved value to `componentState[targetId].value`.
4. **Label renderer update** — make `LabelRenderer` read `runtimeState?.value`
as an override over `properties.label`, consistent with `JsonViewerRenderer`.
5. **Inspector diagnostics** — add warnings for:
- Unsupported target property segments (anything other than `.value`)
- `"onChange"` trigger on action-response bindings (recommend `"onSuccess"`)
- Bindings whose source action ID does not exist
- Bindings whose target component name does not exist
6. **`valid-response-mapping-basic.json`** — the example created in Step 17.0
becomes the primary Step 17.1 integration test document.
---
## 11. Deferred capabilities
The following are explicitly out of scope until separately designed and scheduled:
| Capability | Reason deferred |
|---|---|
| `onError` trigger | Requires error-state routing design |
| `onLoad` trigger | Requires page lifecycle event system |
| Component-change data movement (`onChange`) | Requires reactive binding engine |
| Chained actions | Requires action dependency graph |
| Variable runtime support | Requires variable state store |
| Variable template interpolation in binding paths | Depends on variable runtime |
| Wildcard `*` in source paths | JSONPath-style engine not scoped |
| Filter expressions in source paths | Same |
| Array-index syntax `[0]` | Same |
| Keys with literal `.` in name | Requires path quoting syntax |
| `components.<name>.label` as writable target | Deferred to Step 17.1 label work |
| Any target property other than `.value` | Deferred to per-property routing design |
| Dropdown or Table runtime population | Separate step |
| `ComponentType` / `FullComponentType` cleanup | Separate step |
| Component-level `bindings[]` array | Schema field exists but is not wired |
| Duplicate component-name detection at save time | Future validation step |
| Transform expression execution | Deferred (`binding.transform` field retained) |
| Authentication credential injection | Separate step |
| `action.responseMapping` execution | Permanently deferred; field deprecated |
---
## 12. Required diagnostics
The following diagnostics must be implemented by Step 17.1. They are listed
here so the Inspector and runtime can be built consistently against this spec.
### Inspector (static, design-time)
| Condition | Severity | Message guidance |
|---|---|---|
| Binding `source` references action ID that does not exist in `project.actions` | Warning | "Source references action `<id>` which does not exist." |
| Binding `target` references component name that does not exist on any page | Warning | "Target references component `<name>` which does not exist." |
| Binding `target` uses property segment other than `.value` | Warning | "Target property `<segment>` is not supported. Use `.value`." |
| Two or more components share the same `name` on a page | Warning | "Duplicate component name `<name>`. Binding resolution is ambiguous." |
| `trigger: "onChange"` on an `actions.*` source binding | Info | "Consider using `onSuccess` for action-response bindings." |
| `trigger: "onClick"` on an `actions.*` source binding | Info | "Legacy trigger. Consider migrating to `onSuccess`." |
| `action.responseMapping` is non-empty | Info | "Use `project.bindings` instead. `responseMapping` is deprecated and not executed." |
### Runtime (execution-time, Preview only)
| Condition | Behaviour |
|---|---|
| Source path resolves to `undefined` (field not on body) | Write `undefined` to target; emit console warning |
| Target component not found at runtime | Skip write; emit console warning |
| `ProxyResponse.ok === false` and trigger is `onSuccess` | Do not fire binding; optionally fire `onError` bindings (deferred) |
| Action ID in binding source does not match any executing action | Skip silently |