# Phoenix Runtime Example

This is the canonical Phoenix-hosted Scoria flow for the Keystone public runtime surface. It is derived from the existing runtime integration behavior in `test/scoria/runtime_integration_test.exs`, not from a separate sample app or a speculative architecture.

Keep the canonical adoption order boring: `identity -> start -> inspect -> resume`.

## What this guide shows

- normalize request and session context with `Scoria.identity/1`
- start a run through `Scoria.start_run/2`
- persist `run_id` as the exact durable handle for one run
- reuse `session_id` for continuity across turns
- inspect progress with `Scoria.get_run/1` and `Scoria.list_runs_for_session/1`
- link `/scoria/workflows/:run_id` as operator evidence for that same run
- resume a paused approval flow through `Scoria.resume_run/2`

## Core rule: `session_id` is not `run_id`

Use `session_id` to group related turns in your host app. Use `run_id` to inspect or resume one exact Scoria execution.

- same conversation, new turn: reuse `session_id`, create a fresh run
- paused run: resume only by its exact `run_id`

That distinction is the main contract to preserve in your Phoenix app.

## Controller-triggered start

Start from a normal controller action. Normalize the Phoenix edge state first, then call the top-level `Scoria` facade.

```elixir
defmodule MyAppWeb.AssistantController do
  use MyAppWeb, :controller

  def create(conn, %{"prompt" => prompt}) do
    identity =
      Scoria.identity(%{
        actor_id: conn.assigns.current_user.id,
        tenant_id: conn.assigns.current_account.id,
        session_id: get_session(conn, :assistant_session_id),
        metadata: %{"channel" => "web"}
      })

    {:ok, started} =
      Scoria.start_run(identity,
        root_role_id: "executor",
        initial_step: %{
          sequence: 1,
          kind: "approval",
          role_id: "executor",
          status: "queued"
        },
        runtime: [
          metadata: %{
            "payload" => %{"prompt" => prompt}
          }
        ],
        handlers: %{"approval" => {MyApp.RuntimeHandlers, :wait_for_approval}}
      )

    conn
    |> put_session(:last_scoria_run_id, started.run_id)
    |> redirect(to: ~p"/assistant/runs/#{started.run_id}")
  end
end
```

If your identity already lives in assigns or session maps, the same module exposes narrower helpers:

```elixir
identity_from_assigns = Scoria.Identity.from_conn_assigns(conn.assigns)
identity_from_session = Scoria.Identity.from_session(get_session(conn))
```

Use those helpers only when they make the edge boundary clearer. The important part is that the controller hands Scoria one normalized identity before the run starts.

## Persist the exact `run_id`

Persist `started.run_id` anywhere your host app already tracks ongoing work: session, database row, job record, or a conversation table. That `run_id` is the exact handle for:

- `Scoria.get_run/1`
- `Scoria.resume_run/2`
- `/scoria/workflows/:run_id`

Do not try to resume from `session_id` alone.

## Inspect progress from the host app

Your app can inspect one run directly or list all runs that share the same `session_id`.

```elixir
{:ok, summary} = Scoria.get_run(run_id)
same_session_runs = Scoria.list_runs_for_session(session_id)
```

Use `Scoria.get_run/1` when the host app needs the current state of one exact execution. Use `Scoria.list_runs_for_session/1` when you want to show a session timeline across multiple turns.

## Operator evidence page

The operator page for one run is:

```text
/scoria/workflows/:run_id
```

Link to it from your host app when an operator needs traceable evidence for the same durable run:

```elixir
redirect(conn, to: ~p"/scoria/workflows/#{run_id}")
```

Treat that page as operator evidence, not as the source of your product's business truth.

## Bounded handoffs branch from the same runtime lane

If the core runtime path is already working and a draft needs a bounded review, branch from the same identity and `run_id` model instead of starting a second onboarding path.
The host app owns identity, escalation policy, prompt or draft selection, and projected-context selection.
Scoria owns durable run creation, projected-context validation, queued delegated child creation, and curated readback through Scoria.get_run_detail/1.
Use Scoria.start_handoff_run/3 only for that explicit bounded delegation branch.

```elixir
def create(conn, %{"draft_answer" => draft_answer}) do
  identity =
    Scoria.identity(%{
      actor_id: conn.assigns.current_user.id,
      tenant_id: conn.assigns.current_account.id,
      session_id: get_session(conn, :assistant_session_id),
      metadata: %{"channel" => "web"}
    })

  {:ok, started} = Scoria.start_run(identity, root_role_id: "executor")

  conn = put_session(conn, :last_scoria_run_id, started.run_id)

  if needs_bounded_review?(draft_answer) do
    {:ok, handoff_run} =
      Scoria.start_handoff_run(identity, "critic",
        root_role_id: "planner",
        delegated_kind: "review",
        handoff_input: %{"brief" => "Review the draft answer for policy and accuracy"},
        projected_context: %{
          "task" => "policy-and-accuracy review",
          "draft_answer" => draft_answer
        },
        handlers: %{"review" => {MyApp.RuntimeHandlers, :review}}
      )

    conn = put_session(conn, :last_scoria_handoff_run_id, handoff_run.run_id)

    {:ok, detail} = Scoria.get_run_detail(handoff_run.run_id)
    delegated = detail.delegated_handoffs

    started.run_id != handoff_run.run_id

    redirect(conn, to: ~p"/assistant/runs/#{handoff_run.run_id}")
  else
    redirect(conn, to: ~p"/assistant/runs/#{started.run_id}")
  end
end

defp needs_bounded_review?(draft_answer) do
  String.contains?(draft_answer, "policy")
end
```

Use `Scoria.get_run_detail/1` when the host app or support path needs the curated delegated evidence surface, and use `/scoria/workflows/:run_id` when an operator needs the same run's `Delegated Evidence` section.

session_id groups related host turns; run_id names one exact Scoria execution.

## Runtime-to-handoff verifier

When this bounded delegation branch is wired, verify it with:

```bash
mix test.runtime_to_handoff
```

Keep `mix test.adoption` as the default-lane verifier; this lane is only the bounded runtime-to-handoff escalation proof.

This verifier follows the same run-detail path shown above: `Scoria.get_run_detail/1` returns `delegated_handoffs` for the run that appears on `/scoria/workflows/:run_id`.

## Resume after approval

When a run pauses for approval, resume that exact run by the stored `run_id`.

```elixir
{:ok, resumed} =
  Scoria.resume_run(run_id,
    handlers: %{"approval" => {MyApp.RuntimeHandlers, :succeed}}
  )
```

The resumed run keeps the same `run_id`. Resuming does not create a new run.

## Same session, fresh run

When the user comes back for another turn in the same conversation, reuse the same `session_id` and start a new run:

```elixir
identity =
  Scoria.identity(%{
    actor_id: conn.assigns.current_user.id,
    tenant_id: conn.assigns.current_account.id,
    session_id: get_session(conn, :assistant_session_id)
  })

{:ok, next_run} = Scoria.start_run(identity, root_role_id: "executor")

next_run.session_id == session_id
next_run.run_id != run_id
```

This is the intended continuity model:

- `session_id` groups the conversation
- `run_id` names one exact execution

## Verification checklist

After wiring the flow:

1. Start a run through your controller with `Scoria.start_run/2`.
2. Store the returned `run_id`.
3. Read it back with `Scoria.get_run/1`.
4. Open `/scoria/workflows/:run_id` and confirm the same run is visible there.
5. If the run pauses for approval, call `Scoria.resume_run/2` with the stored `run_id`.
6. Start another turn with the same `session_id` and confirm it produces a different `run_id`.

## What this guide does not require

- LiveView-first orchestration
- background-job-first orchestration
- direct workflow internals as the normal app entrypoint
- pgvector or the knowledge lane just to prove the runtime path

Use the public `Scoria` facade first. Expand into advanced runtime or knowledge features only after this core lane is working.
