Packages

Route policy and runtime contracts for Phoenix apps that go mobile.

Current section

Files

Jump to
crosswake lib crosswake manifest validator.ex
Raw

lib/crosswake/manifest/validator.ex

defmodule Crosswake.Manifest.Validator do
@moduledoc """
Validates manifest contract truth before serialization or release artifact output.
"""
alias Crosswake.Compatibility
alias Crosswake.Manifest.Types
alias Crosswake.Policy.Error
alias Crosswake.SupportMatrix
alias Crosswake.Transfer.Contracts
@commerce_role_values Crosswake.Policy.Schema.commerce_role_values()
@provider_specific_commerce_terms ["storekit", "play_billing", "play-billing", "play billing", "revenuecat"]
@nested_commerce_semantic_keys [
"entitlement",
"entitlements",
"entitlement_snapshot",
"evidence",
"reconciliation",
"authority",
"access",
"freshness",
"effective",
"lifecycle",
"state",
"status"
]
@additive_compatibility_hint "commerce corridor fields are additive in manifest schema 1.0.0 and only required when a route declares commerce"
@spec validate(Types.Root.t()) :: [Error.t()]
def validate(%Types.Root{} = manifest) do
[]
|> validate_top_level_sections(manifest)
|> validate_compatibility(manifest.compatibility)
|> validate_support_matrix(manifest.support_matrix)
|> validate_capability_registry(manifest.capability_registry)
|> validate_commerce_corridors(manifest.commerce_corridors)
|> validate_routes(
manifest.routes,
manifest.capability_registry,
manifest.pack_registry,
manifest.commerce_corridors
)
end
defp validate_top_level_sections(errors, %Types.Root{} = manifest) do
[
{:manifest_schema_version, manifest.manifest_schema_version},
{:crosswake_version, manifest.crosswake_version},
{:generated_at, manifest.generated_at},
{:host, manifest.host},
{:compatibility, manifest.compatibility},
{:support_matrix, manifest.support_matrix},
{:capability_registry, manifest.capability_registry},
{:pack_registry, manifest.pack_registry},
{:commerce_corridors, manifest.commerce_corridors},
{:routes, manifest.routes}
]
|> Enum.reduce(errors, fn {key, value}, acc ->
if present?(key, value) do
acc
else
[
build_error(
key: key,
message: "manifest is missing required top-level section #{inspect(key)}",
hint: "populate #{inspect(key)} before manifest serialization"
)
| acc
]
end
end)
end
defp validate_compatibility(errors, compatibility) do
Compatibility.validate_contract(compatibility)
|> Enum.map(&build_error/1)
|> Kernel.++(errors)
end
defp validate_support_matrix(errors, support_matrix) do
SupportMatrix.validate(support_matrix)
|> Enum.map(&build_error/1)
|> Kernel.++(errors)
end
defp validate_capability_registry(errors, capability_registry) when is_map(capability_registry) do
Enum.reduce(capability_registry, errors, fn {_id, capability}, acc ->
capability
|> capability_errors()
|> Enum.map(&build_error/1)
|> Kernel.++(acc)
end)
end
defp validate_commerce_corridors(errors, commerce_corridors) when is_map(commerce_corridors) do
Enum.reduce(commerce_corridors, errors, fn {corridor_ref, corridor}, acc ->
corridor
|> commerce_corridor_errors(corridor_ref)
|> Enum.map(&build_error/1)
|> Kernel.++(acc)
end)
end
defp validate_routes(errors, routes, capability_registry, pack_registry, commerce_corridors) do
Enum.reduce(routes, errors, fn {_id, route}, acc ->
route
|> route_errors(capability_registry, pack_registry, commerce_corridors)
|> Enum.map(&build_error(&1, route))
|> Kernel.++(acc)
end)
end
defp route_errors(route, capability_registry, pack_registry, commerce_corridors) do
[]
|> validate_route_field(route, :path, route.path)
|> validate_route_field(route, :runtime, route.runtime)
|> validate_route_entry(route)
|> validate_route_capabilities(route, capability_registry)
|> validate_route_packs(route, pack_registry)
|> validate_route_commerce(route, commerce_corridors)
|> validate_route_transfers(route)
end
defp validate_route_entry(errors, route) do
if route.entry in [:internal_only, :external] do
errors
else
[
%{
key: :entry,
route_id: route.id,
path: route.path,
message: "route #{route.id} declares unsupported entry policy #{inspect(route.entry)}",
hint: "use entry :internal_only or :external in manifest route truth"
}
| errors
]
end
end
defp validate_route_field(errors, _route, _key, value) when not is_nil(value), do: errors
defp validate_route_field(errors, route, key, _value) do
[
%{
key: key,
route_id: route.id,
path: route.path,
message: "route #{route.id} is missing required field #{inspect(key)}",
hint: "populate #{inspect(key)} for route #{route.id} before manifest generation"
}
| errors
]
end
defp validate_route_capabilities(errors, route, capability_registry) do
Enum.reduce(route.capabilities, errors, fn capability, acc ->
if route_capability_declared?(capability_registry, capability) do
acc
else
[
%{
key: :capabilities,
route_id: route.id,
path: route.path,
message:
"route #{route.id} declares capability #{inspect(capability)} outside the manifest registry",
hint:
"register #{inspect(capability)} in capability_registry before validation passes"
}
| acc
]
end
end)
end
defp validate_route_commerce(errors, %{commerce: nil}, _commerce_corridors), do: errors
defp validate_route_commerce(errors, route, commerce_corridors) do
errors
|> validate_route_commerce_corridor_ref(route, commerce_corridors)
|> validate_route_commerce_role(route)
|> validate_route_commerce_vocab(route)
end
defp validate_route_commerce_corridor_ref(errors, route, commerce_corridors) do
corridor_ref = commerce_field(route.commerce, :corridor_ref)
cond do
not non_empty_string?(corridor_ref) ->
[
%{
key: :commerce,
route_id: route.id,
path: route.path,
message: "route #{route.id} declares commerce without a corridor_ref",
hint:
"declare route commerce with a canonical corridor_ref from commerce_corridors; #{@additive_compatibility_hint}"
}
| errors
]
Map.has_key?(commerce_corridors, corridor_ref) ->
errors
true ->
[
%{
key: :commerce,
route_id: route.id,
path: route.path,
message:
"route #{route.id} declares undeclared corridor_ref #{inspect(corridor_ref)} outside commerce_corridors",
hint:
"declare #{inspect(corridor_ref)} in Crosswake.Policy.CorridorProfiles before manifest validation"
}
| errors
]
end
end
defp validate_route_commerce_role(errors, route) do
role = commerce_field(route.commerce, :role)
cond do
is_nil(role) ->
[
%{
key: :commerce,
route_id: route.id,
path: route.path,
message: "route #{route.id} declares commerce without a role",
hint:
"declare role with route commerce (for example :paywall_entry); #{@additive_compatibility_hint}"
}
| errors
]
role in @commerce_role_values ->
errors
true ->
[
%{
key: :commerce,
route_id: route.id,
path: route.path,
message: "route #{route.id} declares unsupported commerce role #{inspect(role)}",
hint: "use provider-neutral roles #{inspect(@commerce_role_values)}"
}
| errors
]
end
end
defp validate_route_commerce_vocab(errors, route) do
commerce = route.commerce
if provider_specific_vocabulary?(commerce) do
message =
if provider_specific_semantic_vocabulary?(commerce) do
"route #{route.id} uses provider-specific commerce vocabulary in nested entitlement or evidence semantics"
else
"route #{route.id} uses provider-specific commerce vocabulary in corridor_ref or role"
end
[
%{
key: :commerce,
route_id: route.id,
path: route.path,
message: message,
hint: "use provider-neutral commerce corridor vocabulary in core manifest seams"
}
| errors
]
else
errors
end
end
defp commerce_corridor_errors(corridor, corridor_ref) do
[]
|> validate_commerce_corridor_id(corridor, corridor_ref)
|> validate_commerce_corridor_required_string(corridor, corridor_ref, :denial, corridor.denial)
|> validate_commerce_corridor_required_string(corridor, corridor_ref, :fallback, corridor.fallback)
|> validate_commerce_corridor_prerequisites(corridor, corridor_ref)
|> validate_commerce_corridor_vocab(corridor, corridor_ref)
end
defp validate_commerce_corridor_id(errors, corridor, corridor_ref) do
if corridor.id == corridor_ref and non_empty_string?(corridor.id) do
errors
else
[
%{
key: :commerce_corridors,
message:
"commerce corridor #{inspect(corridor_ref)} must declare matching non-empty id metadata",
hint: "set commerce corridor id to match the registry key"
}
| errors
]
end
end
defp validate_commerce_corridor_required_string(errors, _corridor, corridor_ref, key, value) do
if non_empty_string?(value) do
errors
else
[
%{
key: :commerce_corridors,
message:
"commerce corridor #{inspect(corridor_ref)} must provide non-empty #{inspect(key)} posture",
hint: "declare explicit denial and fallback posture for every commerce corridor entry"
}
| errors
]
end
end
defp validate_commerce_corridor_prerequisites(errors, corridor, corridor_ref) do
if is_list(corridor.prerequisites) and corridor.prerequisites != [] and
Enum.all?(corridor.prerequisites, &non_empty_string?/1) do
errors
else
[
%{
key: :commerce_corridors,
message:
"commerce corridor #{inspect(corridor_ref)} must provide non-empty prerequisites",
hint: "declare at least one provider-neutral prerequisite for each corridor profile"
}
| errors
]
end
end
defp validate_commerce_corridor_vocab(errors, corridor, corridor_ref) do
if provider_specific_vocabulary?(corridor) do
message =
if provider_specific_semantic_vocabulary?(corridor) do
"commerce corridor #{inspect(corridor_ref)} includes provider-specific vocabulary in nested entitlement or evidence semantics"
else
"commerce corridor #{inspect(corridor_ref)} includes provider-specific vocabulary"
end
[
%{
key: :commerce_corridors,
message: message,
hint: "use provider-neutral commerce corridor vocabulary in core manifest seams"
}
| errors
]
else
errors
end
end
defp capability_errors(%Types.Capability{} = capability) do
[]
|> validate_capability_vocab(
:owner,
capability.owner,
[:bounded_bridge, :native_screen, :backend_seam, :activation, :defer]
)
|> validate_capability_vocab(
:package_class,
capability.package_class,
[:core, :companion, :example_docs_only, :defer]
)
|> validate_capability_vocab(:proof_class, capability.proof_class, [:merge_blocking, :advisory])
|> validate_capability_vocab(
:rebuild,
capability.rebuild,
[:none, :native_required, :companion_required]
)
|> validate_capability_required_string(:denial, capability.denial, capability.id)
|> validate_capability_required_string(:fallback, capability.fallback, capability.id)
|> validate_capability_required_string(:guide, capability.guide, capability.id)
|> validate_capability_prerequisites(capability)
end
defp validate_capability_vocab(errors, key, value, allowed) do
if value in allowed do
errors
else
[
%{
key: key,
message: "capability metadata uses unsupported #{key} #{inspect(value)}",
hint: "use one of #{Enum.map_join(allowed, ", ", &inspect/1)}"
}
| errors
]
end
end
defp validate_capability_required_string(errors, key, value, capability_id) do
if non_empty_string?(value) do
errors
else
[
%{
key: key,
message: "capability #{inspect(capability_id)} must provide non-empty #{inspect(key)} metadata",
hint: "populate #{inspect(key)} with explicit support truth"
}
| errors
]
end
end
defp validate_capability_prerequisites(errors, %Types.Capability{id: id, prerequisites: prerequisites}) do
if is_list(prerequisites) and prerequisites != [] and Enum.all?(prerequisites, &non_empty_string?/1) do
errors
else
[
%{
key: :prerequisites,
message: "capability #{inspect(id)} must provide non-empty prerequisite strings",
hint: "publish explicit prerequisite truth for every capability entry"
}
| errors
]
end
end
defp route_capability_declared?(capability_registry, capability) do
Map.has_key?(capability_registry, capability) or
Enum.any?(capability_registry, fn {_id, entry} -> capability in entry.legacy_ids end)
end
defp validate_route_packs(errors, route, pack_registry) do
Enum.reduce(route.packs, errors, fn pack_reference, acc ->
if Map.has_key?(pack_registry, pack_reference) do
acc
else
[
%{
key: :packs,
route_id: route.id,
path: route.path,
message:
"route #{route.id} declares pack reference #{inspect(pack_reference)} outside the manifest pack registry",
hint:
"compile #{inspect(pack_reference)} into pack_registry before validation passes"
}
| acc
]
end
end)
end
defp validate_route_transfers(errors, route) do
Enum.reduce(route.transfers, errors, fn transfer, acc ->
acc
|> validate_transfer_protocol(route, transfer)
|> validate_transfer_declaration(route, transfer)
|> validate_transfer_runtime(route, transfer)
end)
end
defp validate_transfer_protocol(errors, route, transfer) do
cond do
transfer.protocol != Contracts.protocol() ->
[
%{
key: :transfers,
route_id: route.id,
path: route.path,
message:
"transfer seam #{inspect(transfer.id)} must use protocol #{inspect(Contracts.protocol())}",
hint: "compile transfer seams from the canonical Crosswake transfer contract"
}
| errors
]
transfer.version != Contracts.version() ->
[
%{
key: :transfers,
route_id: route.id,
path: route.path,
message:
"transfer seam #{inspect(transfer.id)} must use version #{inspect(Contracts.version())}",
hint: "regenerate the manifest from current transfer seam truth"
}
| errors
]
transfer.states != Contracts.transfer_states() ->
[
%{
key: :transfers,
route_id: route.id,
path: route.path,
message:
"transfer seam #{inspect(transfer.id)} must expose the canonical route-local transfer state vocabulary",
hint: "compile states from Crosswake.Transfer.Contracts.transfer_states/0"
}
| errors
]
true ->
errors
end
end
defp validate_transfer_declaration(errors, route, transfer) do
attrs = [
id: transfer.id,
intent: transfer.intent,
source: transfer.source,
destination: transfer.destination,
verification: transfer.verification,
media_types: transfer.media_types
]
case Contracts.normalize_declaration(attrs) do
{:ok, declaration} ->
if declaration.direction == transfer.direction do
errors
else
[
%{
key: :transfers,
route_id: route.id,
path: route.path,
message:
"transfer seam #{inspect(transfer.id)} must use direction #{inspect(declaration.direction)}",
hint: "derive transfer direction from intent instead of hand-authoring it"
}
| errors
]
end
{:error, reason} ->
[
%{
key: :transfers,
route_id: route.id,
path: route.path,
message: "transfer seam #{inspect(transfer.id)} is invalid: #{reason}",
hint: "supply typed source or destination metadata that matches the transfer intent"
}
| errors
]
end
end
defp validate_transfer_runtime(errors, route, transfer) do
if transfer.source == :native_capture and route.runtime != :native_screen do
[
%{
key: :transfers,
route_id: route.id,
path: route.path,
message:
"transfer seam #{inspect(transfer.id)} native_capture source requires route runtime :native_screen",
hint: "move capture-owned transfers to a native_screen route or use a non-capture transfer source"
}
| errors
]
else
errors
end
end
defp build_error(attrs, route \\ nil) do
struct!(Error, %{
key: attrs[:key],
route_id: attrs[:route_id] || (route && route.id),
path: attrs[:path] || (route && route.path),
message: attrs[:message],
hint: attrs[:hint]
})
end
defp non_empty_string?(value) when is_binary(value), do: byte_size(String.trim(value)) > 0
defp non_empty_string?(_value), do: false
defp provider_specific_vocabulary?(value) when is_binary(value) do
normalized = value |> String.downcase() |> String.trim()
normalized in @provider_specific_commerce_terms
end
defp provider_specific_vocabulary?(value) when is_atom(value),
do: value |> Atom.to_string() |> provider_specific_vocabulary?()
defp provider_specific_vocabulary?(value) when is_list(value),
do: Enum.any?(value, &provider_specific_vocabulary?/1)
defp provider_specific_vocabulary?(value) when is_map(value),
do:
value
|> normalize_provider_vocab_map()
|> Enum.any?(fn {key, nested_value} ->
provider_specific_vocabulary?(key) or provider_specific_vocabulary?(nested_value)
end)
defp provider_specific_vocabulary?(_value), do: false
defp provider_specific_semantic_vocabulary?(value) when is_map(value) do
value
|> normalize_provider_vocab_map()
|> Enum.any?(fn {key, nested_value} ->
if commerce_semantic_key?(key) do
provider_specific_vocabulary?(nested_value)
else
provider_specific_semantic_vocabulary?(nested_value)
end
end)
end
defp provider_specific_semantic_vocabulary?(value) when is_list(value),
do: Enum.any?(value, &provider_specific_semantic_vocabulary?/1)
defp provider_specific_semantic_vocabulary?(_value), do: false
defp commerce_semantic_key?(value) when is_atom(value), do: value |> Atom.to_string() |> commerce_semantic_key?()
defp commerce_semantic_key?(value) when is_binary(value), do: String.downcase(value) in @nested_commerce_semantic_keys
defp commerce_semantic_key?(_value), do: false
defp normalize_provider_vocab_map(%_{} = struct), do: Map.from_struct(struct)
defp normalize_provider_vocab_map(map), do: map
defp commerce_field(nil, _field), do: nil
defp commerce_field(commerce, field) when is_map(commerce), do: Map.get(commerce, field)
defp present?(:pack_registry, value) when is_map(value), do: true
defp present?(:commerce_corridors, value) when is_map(value), do: true
defp present?(_key, value) when value in [nil, ""], do: false
defp present?(_key, value) when is_map(value), do: map_size(value) > 0
defp present?(_key, value) when is_list(value), do: value != []
defp present?(_key, _value), do: true
end