Accept ATIF trace uploads at POST /api/v3/traces

fdab08c09463 · AtlantisPleb · · parent 882b6e34af94

Accept ATIF trace uploads at POST /api/v3/traces

The CLI's trace upload (OpenAgentsInc/openagents#14) stops refusing:
the server accepts an ATIF v1.x document under the chat:account bearer
scope, stores it as received with its SHA-256 digest, deduplicates per
owner (same digest returns the existing trace, no duplicate row),
defaults visibility to dark on the shared tier ladder with a DB-level
CHECK, bounds the body with a typed 413 refusal, and returns the
trace id and URL. Redaction stays the client's job; the server records
what it was told (issue #217).

Built by a Devin child through the openagents coder's delegate tool;
contract, guard suites, and tests re-verified before landing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 333 · 2026-08-25T03:02:28.066185Z

Changed files

  • added lib/openagents/traces.ex
  • added lib/openagents/traces/trace.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/trace_controller.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825024500_create_traces.exs
  • added test/openagents_web/controllers/trace_controller_test.exs

Diff

9 files changed, +365 -3

lib/openagents/traces.ex added +88

@@ -0,0 +1,88 @@

1
defmodule OpenAgents.Traces do
2
  @moduledoc """
3
  Store and retrieve account-scoped ATIF trace documents.
4
  """
5
6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Repo
8
  alias OpenAgents.Traces.Trace
9
10
  @maximum_trace_bytes 10_485_760
11
  @atif_prefixes ["ATIF/1.", "ATIF-v1."]
12
  @default_visibility "dark"
13
14
  @doc "The largest trace body this surface accepts, in bytes."
15
  def maximum_trace_bytes, do: @maximum_trace_bytes
16
17
  @doc """
18
  Store an ATIF document for an account.
19
20
  Re-uploading the same canonical bytes for the same account returns the
21
  existing trace. A document without an admitted `schema_version` or one that
22
  exceeds the size ceiling is refused.
23
  """
24
  def store(%User{} = user, %{} = document), do: store(user, document, [])
25
26
  def store(%User{id: user_id} = _user, %{} = document, options) do
27
    canonical = Jason.encode!(document)
28
    byte_size = byte_size(canonical)
29
30
    cond do
31
      byte_size > @maximum_trace_bytes ->
32
        {:error, :body_too_large}
33
34
      not valid_atif?(document) ->
35
        {:error, :invalid_atif}
36
37
      true ->
38
        digest =
39
          "sha256:" <>
40
            (:crypto.hash(:sha256, canonical) |> Base.encode16(case: :lower))
41
42
        visibility = normalize_visibility(options, document)
43
44
        case Repo.get_by(Trace, user_id: user_id, digest: digest) do
45
          %Trace{} = existing ->
46
            {:ok, existing, :existing}
47
48
          nil ->
49
            attrs = %{
50
              user_id: user_id,
51
              digest: digest,
52
              visibility: visibility,
53
              document: document,
54
              byte_size: byte_size
55
            }
56
57
            %Trace{}
58
            |> Trace.create_changeset(attrs)
59
            |> Repo.insert()
60
            |> case do
61
              {:ok, trace} -> {:ok, trace, :created}
62
              {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset}
63
            end
64
        end
65
    end
66
  end
67
68
  def store(_user, _document, _options), do: {:error, :invalid_atif}
69
70
  defp valid_atif?(document) do
71
    version = Map.get(document, "schema_version") || Map.get(document, :schema_version)
72
73
    is_binary(version) and
74
      String.starts_with?(version, @atif_prefixes)
75
  end
76
77
  defp normalize_visibility(options, document) do
78
    from_options = Keyword.get(options, :visibility)
79
    from_document = Map.get(document, "visibility") || Map.get(document, :visibility)
80
    candidate = from_options || from_document || @default_visibility
81
82
    if is_binary(candidate) do
83
      String.trim(candidate)
84
    else
85
      @default_visibility
86
    end
87
  end
88
end
lib/openagents/traces/trace.ex added +47

@@ -0,0 +1,47 @@

1
defmodule OpenAgents.Traces.Trace do
2
  @moduledoc """
3
  An account-scoped ATIF trace upload.
4
5
  A trace is an owner-attested document with a stable digest. The server stores
6
  the document as received and deduplicates per owner, so re-uploading the same
7
  bytes returns the existing record rather than creating a duplicate.
8
  """
9
10
  use Ecto.Schema
11
  import Ecto.Changeset
12
13
  alias OpenAgents.Transparency
14
15
  @primary_key {:id, :binary_id, autogenerate: true}
16
  @foreign_key_type :binary_id
17
  @timestamps_opts [type: :utc_datetime_usec]
18
19
  @visibilities Enum.map(Transparency.tier_atoms(), &to_string/1)
20
  @default_visibility "dark"
21
22
  schema "traces" do
23
    field :user_id, :binary_id
24
    field :digest, :string
25
    field :visibility, :string, default: @default_visibility
26
    field :document, :map
27
    field :byte_size, :integer
28
    timestamps(updated_at: false)
29
  end
30
31
  @type t :: %__MODULE__{}
32
33
  def default_visibility, do: @default_visibility
34
  def visibilities, do: @visibilities
35
36
  def create_changeset(%__MODULE__{} = trace, attrs) do
37
    trace
38
    |> cast(attrs, [:digest, :visibility, :document, :byte_size])
39
    |> put_change(:user_id, attrs.user_id)
40
    |> validate_required([:user_id, :digest, :visibility, :document, :byte_size])
41
    |> validate_inclusion(:visibility, @visibilities)
42
    |> validate_number(:byte_size, greater_than_or_equal_to: 0)
43
    |> validate_format(:digest, ~r/\Asha256:[0-9a-f]{64}\z/)
44
    |> foreign_key_constraint(:user_id)
45
    |> unique_constraint([:user_id, :digest], name: :traces_user_id_digest_index)
46
  end
47
end
lib/openagents_web/api_error.ex modified +2 -1

@@ -96,7 +96,8 @@ defmodule OpenAgentsWeb.ApiError do

96 96
    # that will not answer is a temporary unreadability rather than an absence.
97 97
    # Reporting it as `not_found` would tell a pusher their push is not on
98 98
    # record, which is a different and much worse claim.
99
    "push_record_unreadable" => {503, "The push record is temporarily unreadable"}
99
    "push_record_unreadable" => {503, "The push record is temporarily unreadable"},
100
    "trace_body_too_large" => {413, "The trace body is larger than the maximum allowed size"}
100 101
  }
101 102
102 103
  @doc """
lib/openagents_web/api_route_authority.ex modified +2 -1

@@ -408,7 +408,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

408 408
        {:required_bearer, :comment, :envelope},
409 409
      "put /api/v3/repos/:owner/:repo/labels/:name" => {:required_bearer, :label, :envelope},
410 410
      "put /api/v3/repos/:owner/:repo/milestones/:milestone_number" =>
411
        {:required_bearer, :milestone, :envelope}
411
        {:required_bearer, :milestone, :envelope},
412
      "post /api/v3/traces" => {:required_bearer, :trace, :envelope}
412 413
    }
413 414
  end
414 415
end
lib/openagents_web/controllers/trace_controller.ex added +56

@@ -0,0 +1,56 @@

1
defmodule OpenAgentsWeb.TraceController do
2
  @moduledoc """
3
  Accept ATIF v1 trace uploads at `POST /api/v3/traces`.
4
  """
5
6
  use OpenAgentsWeb, :controller
7
8
  alias OpenAgents.Traces
9
  alias OpenAgentsWeb.ApiError
10
11
  def create(conn, params) do
12
    document = conn.body_params
13
    visibility = parse_visibility(params)
14
15
    case Traces.store(conn.assigns.current_user, document, visibility: visibility) do
16
      {:ok, trace, :created} ->
17
        conn
18
        |> put_status(:created)
19
        |> json(trace_view(trace))
20
21
      {:ok, trace, :existing} ->
22
        conn
23
        |> put_status(:ok)
24
        |> json(trace_view(trace))
25
26
      {:error, :body_too_large} ->
27
        ApiError.refuse(conn, "trace_body_too_large")
28
29
      {:error, :invalid_atif} ->
30
        ApiError.validation_failed(conn, %{
31
          "document" => ["The document is not a valid ATIF v1 object."]
32
        })
33
34
      {:error, %Ecto.Changeset{} = changeset} ->
35
        ApiError.changeset(conn, changeset)
36
    end
37
  end
38
39
  defp parse_visibility(params) do
40
    case Map.get(params, "visibility") do
41
      value when is_binary(value) -> String.trim(value)
42
      _ -> nil
43
    end
44
  end
45
46
  defp trace_view(trace) do
47
    %{
48
      "id" => trace.id,
49
      "url" => OpenAgentsWeb.Endpoint.url() <> "/api/v3/traces/" <> trace.id,
50
      "digest" => trace.digest,
51
      "byte_size" => trace.byte_size,
52
      "visibility" => trace.visibility,
53
      "inserted_at" => DateTime.to_iso8601(trace.inserted_at)
54
    }
55
  end
56
end
lib/openagents_web/router.ex modified +1

@@ -583,6 +583,7 @@ defmodule OpenAgentsWeb.Router do

583 583
    get "/models", ModelCatalogController, :index
584 584
585 585
    post "/threads", ThreadController, :create
586
    post "/traces", TraceController, :create
586 587
    get "/threads", ThreadController, :index
587 588
    get "/threads/:thread_id", ThreadController, :show
588 589
    delete "/threads/:thread_id", ThreadController, :delete
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -296,7 +296,8 @@

296 296
    20260824210500,
297 297
    20260824230007,
298 298
    20260824230730,
299
    20260824231951
299
    20260824231951,
300
    20260825024500
300 301
  ],
301 302
  "required_tables": [
302 303
    "users",
priv/repo/migrations/20260825024500_create_traces.exs added +35

@@ -0,0 +1,35 @@

1
defmodule OpenAgents.Repo.Migrations.CreateTraces do
2
  @moduledoc """
3
  ATIF trace documents uploaded by an account.
4
5
  A trace is an owner-attested document with a stable SHA-256 digest. The
6
  server stores the document as received, deduplicates per owner, and records
7
  the transparency tier the owner consented to. The vocabulary for the
8
  `visibility` tier is the shared `dark/pulse/ledger/glass` ladder.
9
  """
10
11
  use Ecto.Migration
12
13
  def change do
14
    create table(:traces, primary_key: false) do
15
      add :id, :binary_id, primary_key: true
16
17
      add :user_id,
18
          references(:users, type: :binary_id, on_delete: :delete_all),
19
          null: false
20
21
      add :digest, :string, null: false
22
      add :visibility, :string, null: false, default: "dark"
23
      add :document, :map, null: false
24
      add :byte_size, :integer, null: false
25
26
      timestamps(type: :utc_datetime_usec, updated_at: false)
27
    end
28
29
    create unique_index(:traces, [:user_id, :digest], name: :traces_user_id_digest_index)
30
31
    create constraint(:traces, :traces_visibility_check,
32
             check: "visibility IN ('dark','pulse','ledger','glass')"
33
           )
34
  end
35
end
test/openagents_web/controllers/trace_controller_test.exs added +132

@@ -0,0 +1,132 @@

1
defmodule OpenAgentsWeb.TraceControllerTest do
2
  @moduledoc """
3
  Accept ATIF v1 trace uploads at `POST /api/v3/traces`.
4
  """
5
6
  use OpenAgentsWeb.ConnCase, async: false
7
8
  alias OpenAgents.Repo
9
  alias OpenAgents.Traces.Trace
10
11
  describe "POST /api/v3/traces" do
12
    test "returns the trace id and url for a valid ATIF v1.7 document", %{conn: conn} do
13
      body =
14
        conn
15
        |> put_chat_api_token("trace-valid")
16
        |> post(~p"/api/v3/traces", %{
17
          "schema_version" => "ATIF/1.7",
18
          "trace" => %{"events" => [%{"type" => "step"}]}
19
        })
20
        |> json_response(201)
21
22
      assert %{"id" => id, "url" => url} = body
23
      assert is_binary(id)
24
      assert url =~ "/api/v3/traces/#{id}"
25
      assert body["visibility"] == "dark"
26
      assert is_integer(body["byte_size"])
27
      assert is_binary(body["digest"])
28
      assert String.starts_with?(body["digest"], "sha256:")
29
    end
30
31
    test "returns the existing trace when the same owner uploads the same document again", %{
32
      conn: conn
33
    } do
34
      document = %{
35
        "schema_version" => "ATIF/1.7",
36
        "trace" => %{"events" => [%{"type" => "step"}]}
37
      }
38
39
      first =
40
        conn
41
        |> put_chat_api_token("trace-dedup")
42
        |> post(~p"/api/v3/traces", document)
43
        |> json_response(201)
44
45
      second =
46
        conn
47
        |> put_chat_api_token("trace-dedup")
48
        |> post(~p"/api/v3/traces", document)
49
        |> json_response(200)
50
51
      assert second["id"] == first["id"]
52
      assert second["digest"] == first["digest"]
53
      assert second["byte_size"] == first["byte_size"]
54
      assert Repo.aggregate(Trace, :count) == 1
55
    end
56
57
    test "refuses a body that exceeds the size ceiling", %{conn: conn} do
58
      big = String.duplicate("x", 10_485_761)
59
60
      body =
61
        conn
62
        |> put_chat_api_token("trace-oversize")
63
        |> post(~p"/api/v3/traces", %{
64
          "schema_version" => "ATIF/1.7",
65
          "data" => big
66
        })
67
        |> json_response(413)
68
69
      assert body["code"] == "trace_body_too_large"
70
    end
71
72
    test "rejects an unauthenticated call", %{conn: conn} do
73
      conn
74
      |> post(~p"/api/v3/traces", %{"schema_version" => "ATIF/1.7"})
75
      |> assert_api_error(401, "unauthenticated")
76
    end
77
78
    test "rejects an invalid ATIF schema_version", %{conn: conn} do
79
      conn =
80
        conn
81
        |> put_chat_api_token("trace-invalid")
82
        |> post(~p"/api/v3/traces", %{"schema_version" => "ATIF/2.0"})
83
84
      assert_api_error(conn, 422, "validation_failed",
85
        errors: %{"document" => ["The document is not a valid ATIF v1 object."]}
86
      )
87
    end
88
89
    test "rejects a document with no schema_version", %{conn: conn} do
90
      conn =
91
        conn
92
        |> put_chat_api_token("trace-no-version")
93
        |> post(~p"/api/v3/traces", %{"trace" => %{}})
94
95
      assert_api_error(conn, 422, "validation_failed")
96
    end
97
98
    test "uses dark visibility by default and allows an explicit tier", %{conn: conn} do
99
      body =
100
        conn
101
        |> put_chat_api_token("trace-visibility")
102
        |> post("/api/v3/traces?visibility=ledger", %{
103
          "schema_version" => "ATIF/1.7",
104
          "trace" => %{}
105
        })
106
        |> json_response(201)
107
108
      assert body["visibility"] == "ledger"
109
    end
110
111
    test "different owners uploading the same document get separate traces", %{conn: conn} do
112
      document = %{
113
        "schema_version" => "ATIF/1.7",
114
        "trace" => %{"events" => [%{"type" => "step"}]}
115
      }
116
117
      first =
118
        conn
119
        |> put_chat_api_token("trace-owner-one")
120
        |> post(~p"/api/v3/traces", document)
121
        |> json_response(201)
122
123
      second =
124
        conn
125
        |> put_chat_api_token("trace-owner-two")
126
        |> post(~p"/api/v3/traces", document)
127
        |> json_response(201)
128
129
      refute first["id"] == second["id"]
130
    end
131
  end
132
end

This page updates live while a promote is in flight · changelog