Selecto Domain Schema v1

Copy Markdown

Selecto domain schema v1 is the first small, documented contract for authored domain maps. It is intentionally compatibility-safe: Selecto.Domain.normalize/1 and Selecto.Domain.validate/1 expose diagnostics and normalized projections, but Selecto.configure/3 does not consume the normalized contract yet.

Version

Generated domains should declare the current schema version:

%{
  schema_version: 1,
  domain_version: "0.1.0",
  domain_fingerprint: "sha256:9f5d...",
  name: "Orders",
  source: %{
    source_table: "orders",
    primary_key: :id,
    fields: [:id],
    columns: %{id: %{type: :integer}}
  },
  schemas: %{},
  joins: %{}
}

When schema_version is missing, Selecto.Domain.normalize/1 infers version 1 and returns a :schema_version_inferred warning. Invalid versions fall back to the current version with an :invalid_schema_version warning. Newer positive integer versions are preserved and receive an :unsupported_schema_version warning.

schema_version is the machine compatibility version for the canonical Selecto domain schema, and should remain a positive integer.

domain_version is optional authored-domain metadata. It is an opaque non-empty atom, string, or integer that a host can use for semantic versions, date-based releases, or generated build ids:

domain_version: "0.5.0"
domain_version: "2026-05-12"

domain_fingerprint is optional authored-domain identity metadata. It is an opaque non-empty string, usually a content hash or stable generated-artifact fingerprint:

domain_fingerprint: "sha256:9f5d..."

Selecto core preserves a supplied fingerprint but does not compute one during normalization.

Top-Level Sections

The normalizer classifies authored top-level keys into four categories.

Canonical

Canonical sections are part of the current domain contract:

  • schema_version
  • domain_version
  • domain_fingerprint
  • name
  • source
  • schemas
  • joins
  • default_selected
  • required_selected
  • required_filters
  • required_order_by
  • required_group_by
  • filters
  • functions
  • query_members
  • published_views
  • detail_actions
  • domain_data
  • extensions

Projection

Projection sections are recognized implementation or consumer-facing sections. They are not unknown, but diagnostics call them out because future normalized projections may reshape them:

  • columns
  • custom_columns
  • jsonb_schemas
  • subfilters
  • window_functions
  • pagination
  • retarget
  • redact_fields

Proposed

Proposed sections are reserved for the write/action/reference contract that is still being formalized:

  • writes
  • actions
  • capabilities
  • source_relationships
  • choice_sources

Unknown

Any other top-level key is unknown and appears in diagnostics. Unknown keys are not preserved as a legacy contract. Selecto is still pre-0.5 and has no shipped domain compatibility burden; old experimental write-like keys should be migrated or removed rather than carried forward as legacy support.

Core Relation Shape

The first strict contract validates source and every entry in schemas as relation maps. A relation map uses this shape:

%{
  source_table: "orders",
  primary_key: :id,
  fields: [:id, :status, :total],
  columns: %{
    id: %{type: :integer},
    status: %{type: :string},
    total: %{type: :decimal}
  },
  associations: %{
    customer: %{
      queryable: :customers,
      owner_key: :customer_id,
      related_key: :id
    }
  }
}

Validation checks:

  • source and schemas must be present in the authored domain.
  • source_table must be an atom or string.
  • primary_key must be an atom or string and must appear in fields.
  • fields must be a list.
  • columns must be a map.
  • Every listed field must have a matching column definition.

joins must be a map when present. Each join key must be declared as an association on its parent relation, and each association must point at a schema available in schemas unless it explicitly targets :source.

Query Field Lists

The normalized contract validates query field-list metadata before runtime query execution:

  • default_selected
  • required_selected
  • required_order_by
  • required_group_by

Each section must be a list when present. Direct atom/string field references must refer to known fields from the root source, joined schemas using "schema.field" paths, or custom_columns.

Explicit UDF references using {:udf, function_id, args} are checked against the function registry when they appear in selected, ordered, or grouped query field lists. Aliased selectors such as {:field, {:udf, function_id, args}, alias} are checked the same way. The validator checks that the function id is a non-empty atom or string, exists in functions, and is allowed for the query call site when allowed_in is declared. It does not inspect UDF argument values or compile SQL.

For registered UDFs with args metadata, query-list validation also checks argument count. Arguments declared with source: :selector get static field reference validation for direct atom/string selectors and nested UDF references. Arguments declared as :value or :literal are left to runtime execution validation.

Order entries may use a direct field, {field, direction}, or {direction, field}. Supported directions are :asc, :desc, :asc_nulls_first, :asc_nulls_last, :desc_nulls_first, and :desc_nulls_last.

Group entries may use direct fields or wrapper tuples such as {:rollup, fields} and {:grouping_set, fields}. Tuple/map expressions that are not direct field references are left permissive in this slice.

Invalid query list metadata produces :invalid_section_shape, :invalid_query_field_reference, :query_field_not_found, :invalid_query_order_direction, :invalid_query_group_wrapper, :invalid_query_function_id, :query_function_not_found, or :query_function_call_site_not_allowed, or :query_function_arg_count_mismatch diagnostics.

Filter References

The first contract also validates filter registry metadata and filter references. filters must be a map. Each filter id must be a non-empty atom or string, and each filter config must be a map. Virtual filters may omit field. When present, field must be a non-empty atom or dotted string path and type must be a non-empty atom or string.

Registered filters with a field and expressions in required_filters must refer to known fields from:

  • the root source
  • entries in schemas, addressed as "schema.field"
  • custom_columns

Unknown filter fields produce :filter_field_not_found diagnostics. Invalid filter registry metadata produces :invalid_filter_id, :invalid_filter_config, :invalid_filter_field, or :invalid_filter_type diagnostics.

Function Registry

functions must be a map when present. Each function id must be a non-empty atom or string, and each function spec must be a map. Function specs validate the current UDF metadata contract:

  • kind must be :scalar, :predicate, or :table.
  • sql_name must be a safe SQL function identifier such as "lower" or "public.similarity".
  • optional allowed_in must be a list of supported call sites.
  • optional args must be a list of arg maps with non-empty name, declared type, and source set to :selector, :value, or :literal.
  • predicate functions must return :boolean.
  • table functions must return %{columns: %{...}}.
  • scalar function returns may be omitted or declared as an atom or {:array, type} tuple.

Invalid function metadata produces diagnostics such as :invalid_function_id, :invalid_function_spec, :invalid_function_kind, :invalid_function_sql_name, :invalid_function_call_site, :invalid_function_arg_source, or :invalid_function_returns.

Query Members

query_members must be a map when present. The normalized contract recognizes the current named member groups:

  • ctes
  • values
  • subqueries
  • laterals
  • unnests

Each group must be a map of non-empty atom or string ids to member specs. Specs must be maps. The first query-member contract validates metadata shape only; it does not execute member functions or compile SQL.

Current member checks:

  • CTE members require query or query_builder as a function with arity 0 or 1; recursive CTE members require base_query arity 0 or 1 and recursive_query arity 1 or 2.
  • VALUES members require rows or data as a list; optional columns must be a list.
  • Subquery members require query or query_builder as a function with arity 0 or 1; optional kind must be :join, optional on must be a list, optional type must be :left, :inner, :right, or :full, and optional join_id must be a non-empty atom or string.
  • LATERAL members require query, source, or lateral_source as a tuple or function with arity 0, 1, or 2; optional join_type or type must be :left, :inner, :right, or :full.
  • UNNEST members require array_field or field as a non-empty atom, string, or tuple expression; optional ordinality must be a non-empty atom or string.
  • CTE and VALUES optional join metadata must be true, false, nil, a list, or a map.
  • VALUES, LATERAL, and UNNEST aliases via as, alias, or alias_name must be non-empty atoms or strings when provided.
  • LATERAL and UNNEST optional options must be a list or map.

Invalid query-member metadata produces diagnostics such as :invalid_query_member_group, :invalid_query_member_id, :invalid_query_member_spec, :invalid_query_member_query, :invalid_query_member_rows, :invalid_query_member_join_type, :invalid_query_member_source, or :invalid_query_member_field.

Published Views

published_views must be a map when present. The normalized contract validates published-view metadata shape only; it does not compile the query or generate DDL.

Each published view id must be a non-empty atom or string, and each spec must be a map with:

  • database_name as a non-empty string
  • kind as :view or :materialized_view
  • query as a function with arity 1
  • columns as a non-empty map of non-empty atom/string ids to column spec maps

Optional metadata:

  • indexes must be a list when present. Each index spec must be a map with columns as a non-empty list of atom/string names. Optional unique and concurrently flags must be booleans.
  • refresh must be a map when present.

Invalid published-view metadata produces diagnostics such as :invalid_published_view_id, :invalid_published_view_spec, :invalid_published_view_database_name, :invalid_published_view_kind, :invalid_published_view_query, :invalid_published_view_columns, :invalid_published_view_index_columns, or :invalid_published_view_refresh.

Detail Actions

detail_actions must be a map when present. The normalized contract validates detail-row action metadata only; it does not render modals, resolve LiveView components, or execute links.

Each detail action id must be a non-empty atom or string, and each action spec must be a map with:

  • name as a non-empty string
  • type as :modal, :iframe_modal, :external_link, or :live_component

Optional metadata:

  • payload must be a map when provided.
  • required_fields must be a list when provided. Each entry must be a non-empty atom or string and must refer to a known source, schema, or custom column field.

Type-specific payload checks:

  • :external_link and :iframe_modal require payload.url_template as a non-empty string.
  • :live_component requires payload.module as an atom.

Invalid detail-action metadata produces diagnostics such as :invalid_detail_action_id, :invalid_detail_action_spec, :invalid_detail_action_name, :invalid_detail_action_type, :invalid_detail_action_payload, :missing_detail_action_url_template, :missing_detail_action_module, or :detail_action_field_not_found.

Write Transitions

writes.transitions is the first proposed write contract section with strict validation. It is a direct state graph keyed by a known domain field:

%{
  writes: %{
    transitions: %{
      status: %{
        "pending" => ["ready", "cancelled"],
        "ready" => [:complete, "cancelled"],
        complete: []
      }
    }
  }
}

Validation checks:

  • writes must be a map when present.
  • writes.transitions must be a map when present.
  • each transition field key must be an atom or string
  • each transition field must exist in the source, schemas, or custom columns
  • each transition graph must be a map
  • each source state must be an atom or string
  • each target list must be a list of atoms or strings

This validation does not execute writes and does not make Selecto.configure/3 depend on the write contract.

Capability Catalog

capabilities declares the stable capability names a domain can reference. It does not decide which actors have those capabilities; host applications and future resolver adapters own that policy decision.

%{
  capabilities: %{
    "order.view" => %{
      label: "View orders",
      operations: [:select, :detail],
      target: :order
    },
    "order.approve" => %{
      label: "Approve order",
      operations: [:action],
      action: :approve_order
    },
    "order.export" => %{
      label: "Export orders",
      operations: [:export],
      sensitivity: :high
    }
  }
}

Validation checks:

  • capabilities must be a map when present.
  • capability ids must be atoms or strings.
  • each capability entry must be a map.
  • each capability must declare a non-empty operations list.
  • each operation must be an atom or string.

The domain contract also validates optional capability references on query-facing metadata:

  • filters
  • functions
  • query members
  • published views
  • detail actions

When present, the value must be an atom or string and must exist in the domain capability catalog. These checks only validate metadata references; they do not perform authorization or alter Selecto.configure/3 behavior.

Runtime capability checks use a shared request/decision value shape:

request =
  Selecto.Capabilities.request(
    actor: current_user,
    tenant: tenant_context,
    domain: :orders,
    capability: "order.approve",
    operation: :execute_action,
    target: %{type: :row, id: order_id},
    context: %{surface: :components}
  )

decision =
  Selecto.Capabilities.allow(:role_allowed,
    effects: [{:required_filter, "tenant_id", {:eq, tenant_id}}],
    obligations: [:audit_action]
  )

Decision statuses are :allow, :deny, :conditional, and :not_applicable. Visibility recommendations are :enabled, :disabled, :hidden, and :preview_only.

Direct Transition Actions

actions declares named business commands. The first strict action shape is a row action that directly references a writes.transitions edge:

%{
  actions: %{
    complete_order: %{
      target: :order,
      scope: :row,
      capability: "order.complete",
      transition: %{
        field: :status,
        from: "ready",
        to: "complete"
      },
      execution: %{
        kind: :updato,
        operation: :update,
        set: %{status: "complete"}
      }
    }
  }
}

Validation checks:

  • actions must be a map when present.
  • action ids must be atoms or strings.
  • each action entry must be a map.
  • capability, when present, must be an atom or string and must exist in the domain capability catalog.
  • actions with type: :transition must declare a direct transition map.
  • transition must be a map with field, from, and to.
  • the transition field must exist in the source, schemas, or custom columns.
  • the transition edge must exist in writes.transitions.
  • optional direct execution metadata currently supports only %{kind: :updato, operation: :update}.
  • optional execution set must set the transition field to the target state.

This validates that preview and execution can ask the same domain question; it does not execute actions.

Source Relationships And Choice Sources

source_relationships declares the first compact working-domain to source-domain binding shape. It is used by choice_sources to describe context-safe option providers.

%{
  source: %{
    columns: %{
      customer_id: %{
        type: :integer,
        reference: %{
          choice_source: :customer_choices,
          value_source: "customers.id",
          caption_source: "customers.name"
        }
      }
    }
  },
  source_relationships: %{
    customer: %{
      target_domain: :customers,
      source_field: :customer_id,
      target_field: :id,
      source_path: "customers",
      virtual_join: [
        %{working_field: :customer_id, source_field: "customers.id", required: true}
      ],
      filters: [
        {:eq, "customers.active", true}
      ]
    }
  },
  choice_sources: %{
    customer_choices: %{
      domain: :customers,
      value_field: :id,
      label_field: :name,
      source_path: "customers",
      value_source: "customers.id",
      caption_source: "customers.name",
      description_source: "customers.description",
      filters: [{:eq, "customers.active", true}],
      order_by: ["customers.name", {"customers.id", :desc}],
      presentation: %{
        control: :autocomplete,
        mode: :searchable,
        cardinality: :one
      },
      source_relationship: :customer,
      capability: "customer.choose"
    }
  }
}

Source relationship validation checks:

  • source_relationships must be a map when present.
  • source relationship ids must be atoms or strings.
  • each source relationship entry must be a map.
  • each source relationship must declare target_domain, source_field, and target_field.
  • target_domain, source_field, and target_field must be atoms or strings.
  • source_field must exist in the working domain source, schemas, or custom columns.
  • optional source_path must be a non-empty atom or dotted string path.
  • optional virtual_join must be a list of maps with working_field and source_field; working_field must exist in the working domain, source_field must be a non-empty atom or dotted string path, and optional required must be a boolean.
  • optional filters must be a list of static filter expressions using the same operator and path syntax as choice-source filters.

Choice source validation checks:

  • choice_sources must be a map when present.
  • choice source ids must be atoms or strings.
  • each choice source entry must be a map.
  • each choice source must declare domain, value_field, and label_field.
  • domain, value_field, and label_field must be atoms or strings.
  • optional source_relationship must reference a declared source relationship.
  • optional capability must reference a declared capability.
  • optional source_path, value_source, caption_source, and description_source must be non-empty atom or dotted string paths.
  • optional filters must be a list of static filter expressions. Field operators such as :eq, :gt, :between, and :in, plus logical :and, :or, and :not, may be atoms or strings.
  • choice-source filter field operands must be non-empty atom or dotted string paths. Literal, context, and runtime values are preserved without evaluation.
  • optional order_by must be a list of paths or {path, direction} entries; direction must be :asc or :desc.
  • optional presentation must be a map. Known presentation hints are: control: :select | :autocomplete | :table_picker, mode: :static | :searchable | :async | :inline, and cardinality: :one | :many.

Field binding validation checks:

  • source, schema, and projection column metadata may use choice_source: choice_source_id as compact field binding.
  • source, schema, and projection column metadata may use reference: %{choice_source: choice_source_id} for richer bindings.
  • field-level choice_source references must be atoms or strings and must reference a declared choice source.
  • reference, when present, must be a map.
  • reference.choice_source, when present, must be an atom or string and must reference a declared choice source.
  • optional reference.value_source and reference.caption_source must be atoms or strings.
  • optional reference.caption_field must be an atom or string and must refer to a known working-domain field.

Authoring Shorthand

For authoring ergonomics, a field may declare choice_source: %{...} directly. Selecto.Domain.normalize/1 expands that supported shorthand into the canonical registries without changing Selecto.configure/3 behavior:

customer_id: %{
  type: :integer,
  choice_source: %{
    id: :customer_choices,
    domain: :customers,
    source_relationship: %{
      id: :customer,
      virtual_join: [
        %{working_field: :customer_id, source_field: "customers.id", required: true}
      ]
    },
    value_source: "customers.id",
    caption_source: "customers.name",
    filters: [{:eq, "customers.active", true}],
    presentation: :select
  }
}

The normalized form contains:

  • source_relationships.customer
  • choice_sources.customer_choices
  • reference: %{choice_source: :customer_choices, ...} on the field
  • compact choice_source: :customer_choices on the field

If id values are omitted, the normalizer generates deterministic string ids from the field path. This is canonical shorthand only; pre-0.5 legacy sections are still not preserved or expanded.

The current slice validates static choice-source metadata and filter expression syntax plus static source-relationship metadata. It does not resolve external source-domain schemas, apply filters, fetch options, or execute membership checks.

Domain Composition

Selecto.Domain.compose/2 is the opt-in Stage 2 boundary for combining an authored domain with overlays before projecting or validating it. It does not change Selecto.configure/3 behavior.

{:ok, normalized, diagnostics} =
  Selecto.Domain.compose(base_domain, [
    %{
      source: %{
        columns: %{total: %{label: "Total", format: :currency}},
        redact_fields: [:tenant_secret]
      },
      filters: %{"status" => %{field: :status}}
    }
  ])

Composition semantics are deterministic:

  • maps deep-merge by section.
  • redact_fields, including source.redact_fields, are unioned.
  • extensions are appended uniquely.
  • other lists and scalar values are replaced by later overlays.
  • governance/reference registry collisions, such as choice_sources or source_relationships, produce :domain_composition_collision warnings.

After overlays merge, declared extension merge_domain/2 callbacks run in declaration order and the result is normalized again.

Domain Projections

Selecto.Domain.project/2 turns a normalized domain into read-only consumer views. These projections are opt-in and do not change Selecto.configure/3 behavior.

Supported projections are:

  • :query for query/runtime-facing sections
  • :write for write/action/reference metadata
  • :ui for display defaults, choices, actions, and detail actions
  • :api for combined read/write/action API-style consumers
  • :query_contract for constrained query metadata used by tools, Components, and AI query contracts

The :query_contract projection is intentionally summary-only. It exposes:

  • source table and primary key
  • selectable fields from source, schemas, and custom_columns
  • join summaries with target schemas and target field ids
  • query defaults and required query lists
  • filter, function, query-member, and published-view summaries
  • source relationship and choice-source summaries
  • field-to-choice-source bindings
  • declared capability ids

It does not include write/action/detail-action sections, raw authored unknown keys, or function captures from query members and published views.

For consumers that do not need the lower-level projection API, Selecto.Domain.query_contract/1 accepts either an authored domain or an already-normalized domain and returns {:ok, query_contract, diagnostics}.

Domain Inspection

Selecto.Domain.describe/1 returns a compact structured inspection map for an authored or normalized domain. The output is intended for generators, docs, Studio, tests, and other tools that need stable metadata without walking the full normalized domain shape.

{:ok, inspection, diagnostics} = Selecto.Domain.describe(domain)

inspection.counts.choice_sources
inspection.registries.source_fields
inspection.source_relationships
inspection.field_choice_bindings

The inspection output includes:

  • section categories and normalization diagnostics summary
  • counts for source fields, registries, writes, actions, capabilities, source relationships, choice sources, and field choice bindings
  • sorted registry ids for filters, functions, query members, joins, schemas, actions, capabilities, source relationships, and choice sources
  • compact summaries of writes, actions, capabilities, source relationships, choice sources, and field-to-choice-source bindings

Choice Membership API

Selecto.Domain.Choices is the first shared API for asking whether a submitted value belongs to a field's declared choice source. In this slice it resolves domain metadata and builds a membership request, but it does not query source domains or databases unless a caller supplies an explicit resolver.

{:ok, request} =
  Selecto.Domain.Choices.request(domain, :customer_id, 42,
    actor: current_user,
    tenant: tenant_context,
    record: %{customer_id: 42},
    context: %{surface: :components}
  )

request.choice_source
#=> :customer_choices

{:error, result} =
  Selecto.Domain.Choices.validate_choice(domain, :customer_id, 42)

result.status
#=> :unknown

result.reason_code
#=> :resolver_required

Callers that have a membership implementation can pass a resolver function:

resolver = fn request ->
  if source_member?(request) do
    Selecto.Domain.Choices.valid(:source_member)
  else
    Selecto.Domain.Choices.invalid(:not_in_choice_source)
  end
end

{:ok, result} =
  Selecto.Domain.Choices.validate_choice(domain, :customer_id, 42,
    resolver: resolver
  )

The request/result shape lets Components, API, GraphQL, AI, actions, and Updato share one membership question later. Core Selecto remains conservative: without a resolver, membership is :unknown, not assumed valid.

Choice sources may declare resolver-facing constraint policy metadata:

choice_sources: %{
  customer_choices: %{
    domain: :customers,
    value_field: :id,
    label_field: :name,
    constraint_policy: %{
      domain_of_interest: :fail_closed
    }
  }
}

The policy is carried on membership and option-list requests. A resolver can use :fail_closed to reject a request when server-owned Domain-of-Interest filters are present but cannot be enforced. The default is best-effort when no policy is declared.

Choice Option Lists

Selecto.Domain.Choices also exposes the sibling option-list request shape for surfaces that need to ask "what options should this field show?" The request can be built from a field binding or directly from a declared choice source.

{:ok, request} =
  Selecto.Domain.Choices.options_request(domain, :customer_id,
    search: "acme",
    limit: 20,
    offset: 0,
    actor: current_user,
    tenant: tenant_context,
    record: %{customer_id: 42},
    context: %{surface: :components}
  )

request.choice_source
#=> :customer_choices

{:ok, direct_request} =
  Selecto.Domain.Choices.options_request(domain, :customer_choices,
    by: :choice_source,
    search: "acme"
  )

As with membership checks, core Selecto does not fetch option rows without an explicit resolver:

{:error, result} =
  Selecto.Domain.Choices.list_options(domain, :customer_id, search: "acme")

result.status
#=> :unknown

resolver = fn request ->
  options =
    fetch_options(request)
    |> Enum.map(&%{value: &1.id, label: &1.name})

  Selecto.Domain.Choices.options_resolved(options)
end

{:ok, result} =
  Selecto.Domain.Choices.list_options(domain, :customer_id,
    search: "acme",
    resolver: resolver
  )

The option-list API is a projection contract for future Components, API, GraphQL, AI, operations, and Updato integrations. It does not change Selecto.configure/3 behavior.

Elixir Example

domain = %{
  schema_version: 1,
  domain_version: "0.5.0",
  domain_fingerprint: "sha256:9f5d...",
  name: "Orders",
  source: %{
    source_table: "orders",
    primary_key: :id,
    fields: [:id, :status, :customer_id],
    columns: %{
      id: %{type: :integer},
      status: %{type: :string},
      customer_id: %{
        type: :integer,
        reference: %{
          choice_source: :customer_choices,
          value_source: "customers.id",
          caption_source: "customers.name"
        }
      }
    },
    associations: %{
      customer: %{queryable: :customers}
    }
  },
  schemas: %{
    customers: %{
      source_table: "customers",
      primary_key: :id,
      fields: [:id, :name],
      columns: %{
        id: %{type: :integer},
        name: %{type: :string}
      },
      associations: %{}
    }
  },
  joins: %{
    customer: %{}
  },
  filters: %{
    "customer_name" => %{field: "customers.name"}
  },
  source_relationships: %{
    customer: %{
      target_domain: :customers,
      source_field: :customer_id,
      target_field: :id,
      source_path: "customers",
      virtual_join: [
        %{working_field: :customer_id, source_field: "customers.id", required: true}
      ],
      filters: [{:eq, "customers.active", true}]
    }
  },
  choice_sources: %{
    customer_choices: %{
      domain: :customers,
      value_field: :id,
      label_field: :name,
      source_path: "customers",
      value_source: "customers.id",
      caption_source: "customers.name",
      filters: [{:eq, "customers.active", true}],
      order_by: ["customers.name"],
      presentation: %{
        control: :autocomplete,
        mode: :searchable,
        cardinality: :one
      },
      source_relationship: :customer,
      capability: "customer.choose"
    }
  },
  capabilities: %{
    "order.view" => %{operations: [:select, :detail]},
    "order.approve" => %{operations: [:action], action: :approve_order},
    "customer.choose" => %{operations: [:choice_source]}
  },
  writes: %{
    transitions: %{
      status: %{
        "pending" => ["ready", "cancelled"],
        "ready" => ["complete", "cancelled"],
        "complete" => []
      }
    }
  },
  actions: %{
    complete_order: %{
      target: :order,
      scope: :row,
      capability: "order.approve",
      transition: %{field: :status, from: "ready", to: "complete"},
      execution: %{kind: :updato, operation: :update, set: %{status: "complete"}}
    }
  }
}

{:ok, normalized, diagnostics} = Selecto.Domain.validate(domain)

JSON Equivalent

JSON domains use string keys and string field identifiers:

{
  "schema_version": 1,
  "domain_version": "0.5.0",
  "domain_fingerprint": "sha256:9f5d...",
  "name": "Orders",
  "source": {
    "source_table": "orders",
    "primary_key": "id",
    "fields": ["id", "status", "customer_id"],
    "columns": {
      "id": {"type": "integer"},
      "status": {"type": "string"},
      "customer_id": {
        "type": "integer",
        "reference": {
          "choice_source": "customer_choices",
          "value_source": "customers.id",
          "caption_source": "customers.name"
        }
      }
    },
    "associations": {
      "customer": {"queryable": "customers"}
    }
  },
  "schemas": {
    "customers": {
      "source_table": "customers",
      "primary_key": "id",
      "fields": ["id", "name"],
      "columns": {
        "id": {"type": "integer"},
        "name": {"type": "string"}
      },
      "associations": {}
    }
  },
  "joins": {
    "customer": {}
  },
  "filters": {
    "customer_name": {"field": "customers.name"}
  },
  "source_relationships": {
    "customer": {
      "target_domain": "customers",
      "source_field": "customer_id",
      "target_field": "id",
      "source_path": "customers",
      "virtual_join": [
        {"working_field": "customer_id", "source_field": "customers.id", "required": true}
      ],
      "filters": [["eq", "customers.active", true]]
    }
  },
  "choice_sources": {
    "customer_choices": {
      "domain": "customers",
      "value_field": "id",
      "label_field": "name",
      "source_path": "customers",
      "value_source": "customers.id",
      "caption_source": "customers.name",
      "filters": [["eq", "customers.active", true]],
      "order_by": ["customers.name"],
      "presentation": {
        "control": "autocomplete",
        "mode": "searchable",
        "cardinality": "one"
      },
      "source_relationship": "customer",
      "capability": "customer.choose"
    }
  },
  "capabilities": {
    "order.view": {"operations": ["select", "detail"]},
    "order.approve": {"operations": ["action"], "action": "approve_order"},
    "customer.choose": {"operations": ["choice_source"]}
  },
  "writes": {
    "transitions": {
      "status": {
        "pending": ["ready", "cancelled"],
        "ready": ["complete", "cancelled"],
        "complete": []
      }
    }
  },
  "actions": {
    "complete_order": {
      "target": "order",
      "scope": "row",
      "capability": "order.approve",
      "transition": {"field": "status", "from": "ready", "to": "complete"},
      "execution": {
        "kind": "updato",
        "operation": "update",
        "set": {"status": "complete"}
      }
    }
  }
}

Diagnostics Example

{:ok, _normalized, diagnostics} = Selecto.Domain.normalize(%{
  source: %{
    source_table: "orders",
    primary_key: :id,
    fields: [:id],
    columns: %{id: %{type: :integer}}
  },
  schemas: %{},
  joins: %{},
  custom_columns: %{},
  writes: %{},
  old_write_flag: true
})

diagnostics.schema_version_inferred
#=> true

Enum.map(diagnostics.warnings, & &1.code)
#=> [:schema_version_inferred, :projection_sections, :proposed_sections, :unknown_sections]

diagnostics.unknown_sections
#=> [:old_write_flag]

Use Selecto.Domain.validate/1 when callers want contract errors in addition to normalization diagnostics.