Crosswake Route Policy Guide

View Source

Crosswake's one job is to declare, enforce, and diagnose which runtime owns each route as a Phoenix app crosses into mobile.

Owner selection comes before syntax. Start with the product question from guides/user_flows.md: Who should own this route? Then write the smallest route policy that makes that owner, its prerequisites, and its failure behavior explicit.

Route owner first, capability second. Capabilities, packs, sync seams, transfers, auth posture, commerce posture, and notification-open handling all hang off the route owner. They are not a separate catalog that lets the shell take over the app.

Use this guide as the start-here owner map. The detailed contracts stay in guides/bridge.md, guides/offline.md, guides/native_shell.md, guides/capabilities.md, and guides/support_matrix.md.

Owner Decisions

Owner decisionUse it whenDo not use it for
plain :live_viewPhoenix owns data, rendering, auth, and the interaction loopdevice session control or local mutation
:live_view with one bounded bridge affordancePhoenix still owns the route, and native helps with one low-frequency semantic actionnavigation authority, high-frequency event streams, or mutation authority
cached read-onlythe route may show a stale snapshot but cannot mutate server truthlocal drafts, replay, or optimistic writes
:offline_islandone route owns local-first work with outbox or journal replayapp-wide offline claims
:native_screennative code owns a device-heavy or policy-sensitive session loopordinary SaaS forms that already work as LiveView
backend/provider seamPhoenix/backend remains authority while device or provider events become evidencedevice-owned entitlement, auth, or commerce truth
explicit deferthe route needs support Crosswake has not proven honestly yetsilent fallback into a weaker owner

Every owner choice should make four downstream truths inspectable:

  • Manifest truth: what the generated manifest says about runtime, offline, entry, capabilities, packs, sync, transfers, auth, commerce, and route availability.
  • Doctor/support posture: what mix crosswake.doctor and the support matrix can prove, warn about, or leave advisory.
  • Denial/fallback behavior: what happens when route activation, bridge authorization, packs, gates, auth, or capability prerequisites fail.
  • Rough edge: the limitation Crosswake keeps visible instead of hiding behind a broad mobile claim.

plain :live_view

Use a plain LiveView route when Phoenix owns the whole user job. Most SaaS routes belong here: dashboard, account detail, approval list, settings, billing history, admin review, and routine forms.

live("/dashboard", DashboardLive,
  crosswake: [
    id: "saas-dashboard",
    runtime: :live_view,
    offline: :unavailable,
    entry: :internal_only,
    security: :standard
  ]
)
  • Manifest truth: the route declares runtime: :live_view, offline: :unavailable, route-local entry, and security without native prerequisites.
  • Doctor/support posture: doctor can validate manifest-first activation and support posture without implying native ownership.
  • Denial/fallback behavior: activation still fails closed to route unavailable when the shell cannot honestly activate the route.
  • Rough edge: this is still Phoenix UI inside a shell. Crosswake is not turning the route into native widgets.

:live_view with one bounded bridge affordance

Use this when Phoenix owns the route but one low-frequency native action improves the experience. The native side answers a typed request/reply command; it does not drive navigation, rendering, or data authority.

live("/approvals/:id", ApprovalLive,
  crosswake: [
    id: "saas-approval",
    runtime: :live_view,
    entry: :external,
    capabilities: ["haptics.impact"],
    offline: :cached_read_only,
    cache_contract: :approval_snapshot_v1,
    security: :standard
  ]
)
  • Manifest truth: the route declares runtime, entry, capabilities, offline, cache_contract, and security. The manifest capability registry must know the capability family before the bridge can execute it.
  • Doctor/support posture: bounded bridge diagnostics report declared capabilities, supported command families, denial reasons, and advisory versus merge-blocking proof posture.
  • Denial/fallback behavior: undeclared, unavailable, origin-denied, inactive, or pack-incompatible bridge calls return typed denial replies. The Phoenix-owned approval still owns the product action.
  • Rough edge: if a flow needs continuous client authority, it is not a bounded bridge flow. Move it toward an offline island or native screen.

cached read-only

Use cached read-only when a route can degrade to a stale snapshot and remain honest because it does not mutate canonical rows.

live("/study/history", StudyHistoryLive,
  crosswake: [
    id: "local-first-study-history",
    runtime: :live_view,
    offline: :cached_read_only,
    cache_contract: :study_history_v1,
    auth_posture: :cached_read_only_ok,
    security: :standard
  ]
)
  • Manifest truth: cache_contract belongs only on offline: :cached_read_only routes. It is not local-first mutation.
  • Doctor/support posture: offline doctor output can show cached-route posture, route-local states, and typed telemetry without promoting the route to an offline island.
  • Denial/fallback behavior: if the cache contract or compatibility posture is missing, the route should stay explicit about stale or unavailable state.
  • Rough edge: cached read-only is a degraded read. It does not grant draft, journal, outbox, replay, or reconciliation authority.

:offline_island

Use an offline island for one route with true local-first work: local state, semantic mutation records, an outbox or journal, and explicit replay outcomes.

live("/study/session", StudySessionLive,
  crosswake: [
    id: "local-first-study-session",
    runtime: :offline_island,
    offline: :local_first,
    island_contract: :study_session_v1,
    packs: [[id: :daily_study, version: "1.0.0", kind: :content]],
    sync: [:study_reviews],
    security: :standard
  ]
)
  • Manifest truth: island_contract requires runtime: :offline_island with offline: :local_first. packs and sync stay route-local.
  • Doctor/support posture: doctor can distinguish cached neighbors from the island and report saved-locally, queued-for-replay, replay-failed, accepted, rejected, and conflict outcomes.
  • Denial/fallback behavior: replay can be accepted, rejected, or conflict. A conflict requires attention rather than silent overwrite.
  • Rough edge: Crosswake proves one honest offline owner. It does not claim broad background sync or app-wide local-first behavior.

:native_screen

Use a native screen when native code owns the session loop: capture, scan, policy-sensitive native UI, or permission choreography that should not be forced through a bounded web container.

live("/claims/:id/capture", ClaimCaptureLive,
  crosswake: [
    id: "selective-native-claim-capture",
    runtime: :native_screen,
    capabilities: [:camera],
    packs: [[id: :camera_capture_assets, version: "1.0.0", kind: :media]],
    transfers: [
      [
        id: :capture_upload,
        intent: :upload,
        source: :native_capture,
        verification: :required,
        media_types: ["image/*"]
      ]
    ],
    offline: :cached_read_only,
    security: :sensitive
  ]
)
  • Manifest truth: a transfers seam with source: :native_capture requires runtime: :native_screen. Route-local packs and sensitivity are part of the same manifest entry.
  • Doctor/support posture: native shell and support-matrix labels must say what is merge-blocking proof, advisory evidence, or verification-required.
  • Denial/fallback behavior: missing capture packs or native prerequisites fail closed with explicit route-unavailable or pack_incompatible posture.
  • Rough edge: Crosswake does not silently degrade a native capture route into a web upload flow and pretend that the owner is unchanged.

backend/provider seam

Use a backend or provider seam when the device or provider can provide evidence, but Phoenix/backend remains authority. Commerce, entitlement, auth-return, and notification-open flows belong here unless a later milestone proves a stronger native owner.

live("/account/security/return", AuthReturnLive,
  crosswake: [
    id: "auth-return",
    runtime: :live_view,
    offline: :unavailable,
    entry: :external,
    security: :sensitive,
    auth_min_level: :mfa,
    requires_recent_auth: 300,
    auth_posture: :strict_recent,
    auth_return: [
      kind: :oauth,
      transport: :verified_https_link,
      return_route_id: "auth-return",
      validates: [:state, :pkce, :redirect_uri, :expiry, :replay]
    ],
    notification_open: [actions: [:resume_step_up]]
  ]
)
post("/purchase", CorridorController, :purchase,
  crosswake: [
    id: "commerce-purchase-intent",
    runtime: :native_screen,
    commerce: [corridor: :subscription_default, role: :purchase_intent],
    security: :sensitive
  ]
)
  • Manifest truth: provider-neutral auth_return, notification_open, and commerce fields describe return, open, and corridor posture without embedding a provider SDK as route-policy vocabulary.
  • Doctor/support posture: doctor reports session-authority, notification, and commerce posture as backend-owned contracts with explicit deferred/provider evidence boundaries.
  • Denial/fallback behavior: stale entitlement snapshots, missing corridor prerequisites, unknown auth-return routes, or unsupported notification actions fail closed and return to Phoenix-owned guidance.
  • Rough edge: device, storefront, webhook, notification, or provider evidence is not backend/session authority by itself.

explicit defer

Use an explicit defer when the route would force Crosswake to widen support claims before proof exists. A defer is better than a weak fallback that teaches adopters the wrong owner.

live("/scan/document", DocumentScanLive,
  crosswake: [
    id: "document-scan",
    runtime: :native_screen,
    capabilities: ["document_scan"],
    gated_by: :document_scan_rollout,
    on_unavailable: :deny,
    offline: :unavailable,
    security: :sensitive
  ]
)
  • Manifest truth: gated_by and on_unavailable make the route unavailable posture explicit.
  • Doctor/support posture: doctor can surface the gated route and support matrix can keep the family advisory or deferred until promotion criteria pass.
  • Denial/fallback behavior: fail closed with an explicit unavailable surface or a declared {:fallback_phoenix, route_id} when a real fallback exists.
  • Rough edge: a deferred owner is not hidden support. It is a visible boundary.

Field Checklist

Route policies should use the current semantic DSL fields instead of a simplified shadow vocabulary:

  • runtime, offline, entry, security
  • capabilities
  • cache_contract, island_contract
  • packs, sync, transfers
  • gated_by, on_unavailable
  • auth_min_level, requires_recent_auth, auth_posture, auth_return
  • commerce
  • notification_open

The schema and route validator enforce important owner boundaries:

  • cache_contract requires offline: :cached_read_only.
  • island_contract requires runtime: :offline_island and offline: :local_first.
  • native-capture transfers require runtime: :native_screen.
  • on_unavailable requires gated_by.
  • sensitive or recent-auth routes require strict auth posture.
  • provider-specific auth and commerce vocabulary stays out of core route policy.