Part of the "Typed payloads: parse, don't validate" milestone. Follows #416 / PR #417.
Problem
Behavior action metadata is a pass-through dict and the per-actionType validators in models/ai_agent.py (_validate_card_field_metadata, _validate_fields_attributes_entries, _validate_move_card_metadata, _validate_create_table_record_metadata, _validate_send_email_template_metadata) walk it with raw .get() string keys. Its fieldsAttributes entries are untyped dicts.
Wire shape (introspected, dev)
AiBehaviorMetadataInput inputFields: allowTemplateModifications, destinationPhaseId, emailTemplateId, fieldsAttributes (→ FieldMapInput), mcpServerId, pipeId, tableId, toolInputs (→ AiBehaviorMcpToolInput), toolName. All camelCase.
FieldMapInput inputFields: fieldId, inputMode, value — fieldId and inputMode are NON_NULL. This is the same type used by classic-automation field_map (see the sibling issue), so extract a shared model.
Note: mcpServerId / toolName / toolInputs did not exist when the current models were written — metadata grows, so keep extra="allow".
Live proof the wire name is exact (dev, createAutomation)
FieldMapInput rejects the undeclared spelling at GraphQL coercion, before execution:
field_map[0].field_id (snake) → Field is not defined on FieldMapInput
field_map[0].fieldId (camel) → passes coercion (fails later only on business validation)
Scope
- Model
AiBehaviorMetadataInput (typed known fields, extra="allow" for the growing tail) and a shared FieldMapInput-shaped model (fieldId/inputMode/value) reused by behaviors and classic automations.
- Move the per-actionType metadata checks onto the typed fields, preserving the pinned error-message substrings (
pipeId, fieldsAttributes, fieldId, inputMode, tableId, emailTemplateId, allowTemplateModifications, destinationPhaseId) that tests match= on.
- Keep
metadata accepting unknown keys so GET→update round-trips stay byte-identical.
Sequencing
Touches models/ai_agent.py — sequence after #410.
Acceptance
- Metadata checks read typed fields; pinned error messages unchanged.
- GET→update clone round-trip verified on a lab pipe.
uv run pytest -m "not integration" + ruff + import-linter green.
Part of the "Typed payloads: parse, don't validate" milestone. Follows #416 / PR #417.
Problem
Behavior action
metadatais a pass-throughdictand the per-actionType validators inmodels/ai_agent.py(_validate_card_field_metadata,_validate_fields_attributes_entries,_validate_move_card_metadata,_validate_create_table_record_metadata,_validate_send_email_template_metadata) walk it with raw.get()string keys. ItsfieldsAttributesentries are untyped dicts.Wire shape (introspected, dev)
AiBehaviorMetadataInputinputFields:allowTemplateModifications,destinationPhaseId,emailTemplateId,fieldsAttributes(→FieldMapInput),mcpServerId,pipeId,tableId,toolInputs(→AiBehaviorMcpToolInput),toolName. All camelCase.FieldMapInputinputFields:fieldId,inputMode,value—fieldIdandinputModeare NON_NULL. This is the same type used by classic-automationfield_map(see the sibling issue), so extract a shared model.Note:
mcpServerId/toolName/toolInputsdid not exist when the current models were written —metadatagrows, so keepextra="allow".Live proof the wire name is exact (dev, createAutomation)
FieldMapInputrejects the undeclared spelling at GraphQL coercion, before execution:field_map[0].field_id(snake) →Field is not defined on FieldMapInputfield_map[0].fieldId(camel) → passes coercion (fails later only on business validation)Scope
AiBehaviorMetadataInput(typed known fields,extra="allow"for the growing tail) and a sharedFieldMapInput-shaped model (fieldId/inputMode/value) reused by behaviors and classic automations.pipeId,fieldsAttributes,fieldId,inputMode,tableId,emailTemplateId,allowTemplateModifications,destinationPhaseId) that testsmatch=on.metadataaccepting unknown keys so GET→update round-trips stay byte-identical.Sequencing
Touches
models/ai_agent.py— sequence after #410.Acceptance
uv run pytest -m "not integration"+ ruff + import-linter green.