# Composing real-app search
This guide is the canonical v1.22 story for reusable search defaults, metadata-driven host rendering, and multi-search composition. The contract stays narrow: request-edge helpers normalize plain data, `Scrypath.Composition` lowers that data into `Scrypath.search/3` or `Scrypath.search_many/2`, and your context remains the application boundary.
If you have not normalized browser params yet, start with [Request-edge search](request-edge-search.md). If you want the first-hour setup instead, start with the [Golden path](golden-path.md).
## Why composition exists after request-edge normalization
`Scrypath.QueryParams` solves request-edge normalization. Composition solves a different problem: once several screens or callers share the same search defaults, where do those reusable rules live without turning Scrypath into a second runtime or a framework facade?
The answer is plain data:
- contexts or feature modules define presets and scopes as maps
- `Scrypath.Composition.compose/2` resolves `defaults` plus `fixed` constraints
- `Scrypath.Composition.to_search_args/1` lowers that result into `{text, keyword_opts}`
- your context still calls `Scrypath.search/3`
`Scrypath.Composition` does not execute search, move search ownership onto schemas, or replace Phoenix, controllers, LiveView, or your context boundary.
## Presets, scopes, `defaults`, and `fixed`
The public vocabulary is intentionally small:
- `defaults` fill in search-shaped values when the caller omitted them
- `fixed` locks filter-bearing fields and fails on conflicts
- `applied`, `defaulted`, `fixed`, and `unsupported` remain inspectable in the result
That keeps the seam useful for tests, logs, and honest host rendering without exposing `%Scrypath.Query{}` or inventing schema-generated runtime verbs.
```elixir
base_catalog =
%{
defaults: %{
sort: [desc: :published_at],
page: [size: 24],
facets: [:category, :author]
},
fixed: %{
filter: [published: true]
}
}
criteria = %{
text: "ecto",
facet_filter: [category: ["books"]]
}
{:ok, composition} = Scrypath.Composition.compose(base_catalog, criteria)
{text, search_opts} = Scrypath.Composition.to_search_args(composition)
MyApp.Catalog.search_books(text, search_opts)
```
## Metadata supports host-rendered honest controls
Composition is only half of the real-app seam. Hosts often need to know what the schema declares and what the current search input resolved to before they render controls.
Use:
- `Scrypath.schema_capabilities/1` for declaration-backed support
- `Scrypath.reflect_search/2` for resolved `applied`, `defaulted`, `fixed`, and `unsupported` state
- `Scrypath.reflect_search_many/2` for entry-scoped multi-search reflection
```elixir
capabilities = Scrypath.schema_capabilities(MyApp.Catalog.Book)
reflection =
Scrypath.reflect_search(MyApp.Catalog.Book, %{
text: "ecto",
facets: [:category],
facet_filter: [category: ["books"], region: ["eu"]]
})
```
Those helpers return plain data for host rendering. They do not generate UI, claim tenant-safe authz, or promise related-data propagation or rebuild correctness. Those remain host-owned concerns.
## One runtime, two proof flows
Phase 85 freezes two flagship real-app proofs on top of the same runtime boundary:
### Single-schema catalog flow
Use one schema, one context-owned `Scrypath.search/3` call, and metadata-driven controls for a searchable Phoenix catalog page. Composition reduces repeated query glue, while `schema_capabilities/1` and `reflect_search/2` keep the control state inspectable and honest.
Read [Faceted search with Phoenix LiveView](faceted-search-with-phoenix-liveview.md) for the concrete single-schema proof.
### Multi-schema global-search flow
Use `Scrypath.Composition.compose_many/2` when several schemas share some defaults but still need entry-scoped criteria, capabilities, and failure handling. The helper lowers into the existing tuple/shared-option contract for `Scrypath.search_many/2`; it does not create a merged capability graph or a fake universal ranking scale.
Read [Multi-index search](multi-index-search.md) for the concrete multi-schema proof.
## `compose_many/2` lowers into `search_many/2`
Multi-search composition follows the same boundary discipline:
- per-entry composition is canonical
- shared composition lowers `defaults` only
- shared `fixed` is intentionally unsupported
- entry-scoped capability differences stay visible
- partial failures stay explicit
```elixir
{:ok, many} =
Scrypath.Composition.compose_many(
[
%{
schema: MyApp.Post,
text: "release",
fragments: [%{defaults: %{filter: [published: true]}}],
criteria: %{facets: [:status]}
},
%{
schema: MyApp.User,
text: "release",
criteria: %{filter: [active: true]}
}
],
shared: %{defaults: %{page: [size: 8]}}
)
{entries, shared_opts} = Scrypath.Composition.to_search_many_args(many)
Scrypath.search_many(entries, Keyword.merge(shared_opts, repo: MyApp.Repo))
```
The output stays inspectable plain data all the way to the runtime call.
## Non-goals
This guide is also the canonical boundary page for what v1.22 does not promise:
- no public `%Scrypath.Query{}`
- no schema-generated runtime verbs
- no generated UI widgets, forms, or components
- no tenant/authz guarantees
- no related-data propagation or rebuild correctness claims
Scrypath helps you compose search-shaped data and reflect honest state. Your app still owns policy, rendering, and operational follow-through.
## Continue
- [Faceted search with Phoenix LiveView](faceted-search-with-phoenix-liveview.md)
- [Multi-index search](multi-index-search.md)
- [JTBD and user flows](jtbd-and-user-flows.md)