defmodule Crosswake.Manifest.Builder do
@moduledoc """
Builds the route-first manifest root from compiled Crosswake route policy.
"""
alias Crosswake.Manifest.Types
alias Crosswake.Offline.Contracts
alias Crosswake.Offline.ContentPack
alias Crosswake.Policy.CorridorProfiles
alias Crosswake.Policy.Route
@public_route_capability_ids ~w(
app_info
file_picker
haptics
notification_token
permissions.status
share
deep_link
media_capture
scanner
document_scan
paywall_entry
purchase_intent
restore_intent
entitlement_snapshot
reconciliation_evidence
)
@compatibility_route_capability_ids ~w(
app.info.get
camera
camera.capture
files.pick
haptics.impact
push.notifications
)
@spec build([Route.t()], [map()], keyword()) :: Types.Root.t()
def build(routes, managed_routes, opts \\ [])
when is_list(routes) and is_list(managed_routes) do
capability_registry = capability_registry(routes)
host =
Keyword.get_lazy(opts, :host, fn ->
case Keyword.fetch(opts, :origin) do
{:ok, origin} -> Types.new_host(origin: origin)
:error -> Types.new_host()
end
end)
compatibility = Keyword.get(opts, :compatibility, Types.new_compatibility())
commerce_corridors = commerce_corridor_registry(routes)
support_matrix =
Keyword.get(
opts,
:support_matrix,
Crosswake.SupportMatrix.canonical(capability_registry: capability_registry)
)
Types.new_root(
crosswake_version:
Keyword.get(opts, :crosswake_version, Mix.Project.config()[:version] || "dev"),
generated_at:
Keyword.get(
opts,
:generated_at,
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
),
host: host,
compatibility: compatibility,
support_matrix: support_matrix,
capability_registry: capability_registry,
pack_registry: pack_registry(routes),
commerce_corridors: commerce_corridors,
routes: route_entries(routes, managed_routes, host.origin)
)
end
@spec capability_registry([Route.t()]) :: %{String.t() => Types.Capability.t()}
def capability_registry(routes) do
public_registry =
capability_catalog()
|> Enum.map(fn attrs ->
capability = Types.new_capability(attrs)
{capability.id, capability}
end)
|> Map.new()
compatibility_registry =
routes
|> Enum.flat_map(& &1.capabilities)
|> Enum.uniq()
|> Enum.reject(&Map.has_key?(public_registry, &1))
|> Enum.map(fn capability_id ->
family_capability = family_capability_for(capability_id)
capability =
family_capability
|> compatibility_capability_attrs(capability_id)
|> Types.new_capability()
{capability_id, capability}
end)
|> Map.new()
Map.merge(public_registry, compatibility_registry)
end
@spec public_route_capability_ids() :: [String.t()]
def public_route_capability_ids, do: @public_route_capability_ids
@spec compatibility_route_capability_ids() :: [String.t()]
def compatibility_route_capability_ids, do: @compatibility_route_capability_ids
defp route_entries(routes, managed_routes, origin) do
routes
|> Enum.zip(managed_routes)
|> Map.new(fn {%Route{} = route, managed_route} ->
path = Map.fetch!(managed_route, :path)
entry =
Types.new_route_entry(
id: route.id,
path: path,
runtime: route.runtime,
offline: route.offline,
entry: route.entry,
cache_contract: cache_contract(route),
island_contract: island_contract(route),
commerce: route_commerce(route),
capabilities: route.capabilities,
packs: route_pack_references(route.packs),
sync: route.sync,
transfers: transfer_seams(route.transfers),
security: route.security,
allowlisted_origins: [origin],
gated_by: route.gated_by,
on_unavailable: route.on_unavailable,
auth_min_level: route.auth_min_level,
requires_recent_auth: route.requires_recent_auth,
auth_posture: route.auth_posture,
auth_return: route_auth_return(route),
notification_open: route.notification_open
)
{route.id, entry}
end)
end
defp pack_registry(routes) do
routes
|> Enum.flat_map(& &1.packs)
|> Enum.sort_by(&pack_reference/1)
|> Map.new(fn %ContentPack{} = pack ->
{pack_reference(pack),
Types.new_pack_entry(
id: pack.id,
version: pack.version,
kind: pack.kind,
integrity: pack.integrity
)}
end)
end
defp commerce_corridor_registry(routes) do
canonical_profiles = CorridorProfiles.commerce_corridors()
routes
|> Enum.flat_map(fn
%Route{commerce: nil} -> []
%Route{commerce: %{corridor: corridor}} -> [corridor]
_route -> []
end)
|> Enum.uniq()
|> Enum.reduce(%{}, fn corridor_ref, acc ->
case Map.get(canonical_profiles, corridor_ref) do
nil ->
acc
profile ->
Map.put(
acc,
corridor_ref,
Types.new_commerce_corridor(
id: profile.id,
role_ownership: profile.role_ownership,
denial: profile.denial,
fallback: profile.fallback,
prerequisites: profile.prerequisites
)
)
end
end)
end
defp route_pack_references(packs), do: Enum.map(packs, &pack_reference/1)
defp transfer_seams(transfers) do
Enum.map(transfers, fn transfer ->
Types.new_transfer_seam(
id: transfer.id,
intent: transfer.intent,
direction: transfer.direction,
source: transfer.source,
destination: transfer.destination,
verification: transfer.verification,
media_types: transfer.media_types
)
end)
end
defp pack_reference(%ContentPack{id: id, version: version}), do: "#{id}@#{version}"
defp route_commerce(%Route{commerce: nil}), do: nil
defp route_commerce(%Route{commerce: %{corridor: corridor, role: role}})
when is_binary(corridor) and is_atom(role) do
Types.new_route_commerce(corridor_ref: corridor, role: role)
end
defp route_commerce(_route), do: nil
defp route_auth_return(%Route{auth_return: nil}), do: nil
defp route_auth_return(%Route{
auth_return: %{
kind: kind,
transport: transport,
return_route_id: return_route_id,
validates: validates
}
}) do
Types.new_route_auth_return(
kind: kind,
transport: transport,
return_route_id: return_route_id,
validates: validates
)
end
defp route_auth_return(_route), do: nil
defp capability_catalog do
[
[
id: "deep_link",
family: "deep_link",
owner: :activation,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :none,
prerequisites: [
"bundled or cached manifest",
"shell activation support",
"explicit route entry approval"
],
denial: "route_unavailable",
fallback:
"show route unavailable surface that distinguishes inactive routes from routes that reject external entry",
guide: "guides/native_shell.md#manifest-first-activation"
],
[
id: "app_info",
family: "app_info",
owner: :bounded_bridge,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :none,
prerequisites: ["declared route capability", "bounded bridge support"],
denial: "undeclared_capability",
fallback: "Phoenix route continues without native app metadata",
guide: "guides/bridge.md#bounded-bridge",
legacy_ids: ["app.info.get"]
],
[
id: "haptics",
family: "haptics",
owner: :bounded_bridge,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :none,
prerequisites: ["declared route capability", "bounded bridge support"],
denial: "undeclared_capability",
fallback: "Phoenix route continues without native confirmation feedback",
guide: "guides/bridge.md#bounded-bridge",
legacy_ids: ["haptics.impact"]
],
[
id: "share",
family: "share",
owner: :bounded_bridge,
package_class: :core,
proof_class: :advisory,
rebuild: :none,
prerequisites: ["truthful semantic share contract"],
denial: "undeclared_capability",
fallback: "keep content in the Phoenix-owned route until a share family is declared",
guide: "guides/capabilities.md#bounded-bridge"
],
[
id: "permissions.status",
family: "permissions.status",
owner: :bounded_bridge,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :none,
prerequisites: [
"declared route capability",
"bounded bridge support",
"notifications alias only"
],
denial: "undeclared_capability",
fallback: "route continues without native notification permission snapshot authority",
guide: "guides/capabilities.md#bounded-bridge"
],
[
id: "notification_token",
family: "notification_token",
owner: :bounded_bridge,
package_class: :companion,
proof_class: :advisory,
rebuild: :companion_required,
prerequisites: [
"declared route capability",
"bounded bridge support",
"notification authorization already resolved",
"provider token snapshot available"
],
denial: "unavailable_capability",
fallback:
"treat notification token replies as provider-tagged evidence instead of backend registration truth",
guide: "guides/capabilities.md#bounded-bridge",
legacy_ids: ["push.notifications"]
],
[
id: "file_picker",
family: "file_picker",
owner: :bounded_bridge,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :native_required,
prerequisites: [
"declared transfer_id",
"bounded bridge support",
"inbound native_picker transfer seam",
"copy-first staged handle plus transfer verification"
],
denial: "undeclared_capability",
fallback:
"keep the route on Phoenix-owned import guidance until a copy-first native_picker seam is declared and verified",
guide: "guides/capabilities.md#bounded-bridge",
legacy_ids: ["files.pick"]
],
[
id: "media_capture",
family: "media_capture",
owner: :native_screen,
package_class: :companion,
proof_class: :merge_blocking,
rebuild: :native_required,
prerequisites: ["native screen route", "capture pack availability"],
denial: "pack_incompatible",
fallback: "fail closed instead of degrading into a bounded web upload flow",
guide: "guides/native_shell.md#native-capture-escape-hatch",
legacy_ids: ["camera", "camera.capture"]
],
[
id: "scanner",
family: "scanner",
owner: :native_screen,
package_class: :defer,
proof_class: :advisory,
rebuild: :companion_required,
prerequisites: ["scanner-native runtime", "policy-heavy proof lane"],
denial: "unavailable_capability",
fallback: "defer scanner support until native and proof posture are explicit",
guide: "guides/capabilities.md#explicit-defers"
],
[
id: "document_scan",
family: "document_scan",
owner: :native_screen,
package_class: :defer,
proof_class: :advisory,
rebuild: :companion_required,
prerequisites: ["document-scan runtime", "policy-heavy proof lane"],
denial: "unavailable_capability",
fallback: "defer document scan support until native and proof posture are explicit",
guide: "guides/capabilities.md#explicit-defers"
],
[
id: "paywall_entry",
family: "paywall_entry",
owner: :backend_seam,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :companion_required,
prerequisites: ["backend entitlement contract", "storefront guidance"],
denial: "unavailable_capability",
fallback: "fall back to Phoenix-owned paywall guidance without device authority",
guide: "guides/capabilities.md#backend-seams-and-deferred-surfaces"
],
[
id: "purchase_intent",
family: "purchase_intent",
owner: :backend_seam,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :companion_required,
prerequisites: ["backend reconciliation", "provider-specific adapter"],
denial: "unavailable_capability",
fallback: "treat purchase events as reconciliation inputs, not entitlement truth",
guide: "guides/capabilities.md#backend-seams-and-deferred-surfaces"
],
[
id: "restore_intent",
family: "restore_intent",
owner: :backend_seam,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :companion_required,
prerequisites: ["backend reconciliation", "storefront-aware adapter"],
denial: "unavailable_capability",
fallback: "keep restore flow backend-owned until adapter truth is explicit",
guide: "guides/capabilities.md#backend-seams-and-deferred-surfaces"
],
[
id: "entitlement_snapshot",
family: "entitlement_snapshot",
owner: :backend_seam,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :companion_required,
prerequisites: ["backend entitlement authority", "reconciliation hook"],
denial: "unavailable_capability",
fallback: "treat device-side state as evidence instead of final entitlement truth",
guide: "guides/capabilities.md#backend-seams-and-deferred-surfaces"
],
[
id: "reconciliation_evidence",
family: "reconciliation_evidence",
owner: :backend_seam,
package_class: :core,
proof_class: :merge_blocking,
rebuild: :companion_required,
prerequisites: ["backend reconciliation", "device callback or webhook"],
denial: "unavailable_capability",
fallback: "treat evidence as asynchronous payload without blocking route entry",
guide: "guides/capabilities.md#backend-seams-and-deferred-surfaces"
]
]
end
defp family_capability_for(capability_id) do
Enum.find(capability_catalog(), fn attrs ->
capability_id in Keyword.get(attrs, :legacy_ids, [])
end)
end
defp compatibility_capability_attrs(nil, capability_id) do
[
id: capability_id,
version: capability_version(capability_id)
]
end
defp compatibility_capability_attrs(attrs, capability_id) do
attrs
|> Keyword.put(:id, capability_id)
|> Keyword.put(:version, capability_version(capability_id))
|> Keyword.put(:family, Keyword.fetch!(attrs, :family))
end
defp capability_version(_capability), do: "1.0.0"
defp cache_contract(%Route{cache_contract: nil}), do: nil
defp cache_contract(%Route{id: route_id, cache_contract: contract_id}) do
contract_id
|> Contracts.new_cache_route(route_id: route_id)
|> Contracts.cache_contract()
end
defp island_contract(%Route{island_contract: nil}), do: nil
defp island_contract(%Route{
id: route_id,
island_contract: contract_id,
sync: [sync_seam | _rest]
}) do
contract_id
|> Contracts.new_study_session_island(
route_id: route_id,
sync_seam: sync_seam,
storage_budget: {:mb, 50},
reserve_for_journal: {:mb, 5},
eviction: :manual
)
|> Contracts.island_contract()
end
defp island_contract(%Route{id: route_id, island_contract: contract_id}) do
contract_id
|> Contracts.new_study_session_island(
route_id: route_id,
sync_seam: contract_id,
storage_budget: {:mb, 50},
reserve_for_journal: {:mb, 5},
eviction: :manual
)
|> Contracts.island_contract()
end
end