Publish verified artifact catalog

90ee9be4b45d · Devin AI · · parent ae9fad08d4aa

Publish verified artifact catalog

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.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.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added lib/openagents/artifact_catalog.ex
  • added lib/openagents/artifact_catalog/listing.ex
  • added lib/openagents/artifact_catalog/receipt.ex
  • modified lib/openagents_web/components/layouts.ex
  • added lib/openagents_web/controllers/artifact_listing_admin_controller.ex
  • added lib/openagents_web/controllers/artifact_listing_controller.ex
  • added lib/openagents_web/live/artifact_catalog_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified lib/openagents_web/user_auth.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823045906_create_verified_artifact_listings.exs
  • added test/openagents/artifact_catalog_test.exs
  • added test/openagents_web/controllers/artifact_listing_controller_test.exs
  • added test/support/fixtures/artifact_catalog_fixtures.ex

Diff

15 files changed, +1839 -0

lib/openagents/artifact_catalog.ex added +411

@@ -0,0 +1,411 @@

1
defmodule OpenAgents.ArtifactCatalog do
2
  @moduledoc """
3
  Publishes safe licensed-artifact projections and append-only transaction evidence.
4
  """
5
6
  import Ecto.Query
7
8
  alias Ecto.Multi
9
  alias OpenAgents.ArtifactCatalog.Listing
10
  alias OpenAgents.ArtifactCatalog.Receipt
11
  alias OpenAgents.Repo
12
13
  @transaction_actions ~w(offer acceptance delivery verification settlement)
14
  @predecessors %{
15
    "offer" => "publication",
16
    "acceptance" => "offer",
17
    "delivery" => "acceptance",
18
    "verification" => "delivery",
19
    "settlement" => "verification"
20
  }
21
  @statuses %{
22
    "offer" => "recorded",
23
    "acceptance" => "admitted",
24
    "delivery" => "recorded",
25
    "verification" => "verified",
26
    "settlement" => "settled"
27
  }
28
29
  def publish_listing(attributes) when is_map(attributes) do
30
    changeset = Listing.publication_changeset(%Listing{}, attributes)
31
32
    Multi.new()
33
    |> Multi.insert(:listing, changeset)
34
    |> Multi.insert(:receipt, fn %{listing: listing} ->
35
      Receipt.changeset(%Receipt{listing_id: listing.id}, %{
36
        action: "publication",
37
        status: "recorded",
38
        receipt_ref: listing.publication_receipt_ref,
39
        buyer_ref: listing.buyer_name,
40
        buyer_class: listing.buyer_class,
41
        artifact_digest: listing.artifact_digest,
42
        provenance_digest: listing.provenance_digest,
43
        license_digest: listing.license_digest,
44
        listing_digest: listing.listing_digest,
45
        metadata: %{
46
          "license_contract_ref" => listing.license_contract_ref,
47
          "verification_policy" => listing.verification_policy
48
        }
49
      })
50
    end)
51
    |> Repo.transaction()
52
    |> case do
53
      {:ok, %{listing: listing}} -> {:ok, listing}
54
      {:error, :listing, changeset, _changes} -> {:error, changeset}
55
      {:error, :receipt, changeset, _changes} -> {:error, changeset}
56
    end
57
  end
58
59
  def list_public_listings(filters \\ %{}) when is_map(filters) do
60
    now = DateTime.utc_now()
61
62
    Listing
63
    |> where([listing], listing.state == "active")
64
    |> where([listing], listing.license_effective_at <= ^now)
65
    |> where([listing], listing.license_expires_at > ^now)
66
    |> filter_type(filters)
67
    |> filter_buyer_class(filters)
68
    |> filter_query(filters)
69
    |> order_by([listing], desc: listing.evidence_fresh_at, desc: listing.inserted_at)
70
    |> limit(^limit(filters))
71
    |> Repo.all()
72
  end
73
74
  def get_public_listing(id) when is_binary(id) do
75
    with {:ok, id} <- Ecto.UUID.cast(id) do
76
      now = DateTime.utc_now()
77
78
      Listing
79
      |> where([listing], listing.id == ^id)
80
      |> where([listing], listing.state == "active")
81
      |> where([listing], listing.license_effective_at <= ^now)
82
      |> where([listing], listing.license_expires_at > ^now)
83
      |> Repo.one()
84
      |> case do
85
        nil -> {:error, :not_found}
86
        listing -> {:ok, listing}
87
      end
88
    else
89
      :error -> {:error, :not_found}
90
    end
91
  end
92
93
  def export_public_listing(id) when is_binary(id) do
94
    with {:ok, listing} <- get_public_listing(id) do
95
      {:ok,
96
       %{
97
         "catalog_version" => 1,
98
         "exported_at" => DateTime.utc_now(),
99
         "listing" => Listing.public_projection(listing)
100
       }}
101
    end
102
  end
103
104
  def record_transaction(listing_id, action, attributes)
105
      when is_binary(listing_id) and action in @transaction_actions and is_map(attributes) do
106
    Repo.transaction(fn ->
107
      with {:ok, listing} <- get_available_listing_for_update(listing_id),
108
           :ok <- verify_digest_bindings(listing, attributes),
109
           {:ok, predecessor} <- verify_predecessor(listing, action, attributes),
110
           {:ok, receipt} <- insert_transaction_receipt(listing, action, attributes, predecessor) do
111
        receipt
112
      else
113
        {:error, reason} -> Repo.rollback(reason)
114
      end
115
    end)
116
    |> unwrap_transaction()
117
  end
118
119
  def record_transaction(_listing_id, _action, _attributes), do: {:error, :invalid_action}
120
121
  def authorize_source_access(listing_id, attributes)
122
      when is_binary(listing_id) and is_map(attributes) do
123
    with purpose when purpose in ["delivery", "evaluation"] <- attribute(attributes, :purpose),
124
         buyer_ref when is_binary(buyer_ref) <- attribute(attributes, :buyer_ref),
125
         acceptance_ref when is_binary(acceptance_ref) <- attribute(attributes, :acceptance_ref),
126
         {:ok, listing} <- get_available_listing(listing_id),
127
         %Receipt{} = acceptance <-
128
           Repo.get_by(Receipt,
129
             listing_id: listing.id,
130
             receipt_ref: acceptance_ref,
131
             action: "acceptance",
132
             status: "admitted",
133
             buyer_ref: buyer_ref
134
           ),
135
         :ok <- receipt_matches_listing(acceptance, listing) do
136
      {:ok,
137
       %{
138
         source_ref: listing.source_ref,
139
         artifact_digest: listing.artifact_digest,
140
         provenance_digest: listing.provenance_digest,
141
         license_digest: listing.license_digest,
142
         purpose: purpose,
143
         acceptance_ref: acceptance.receipt_ref
144
       }}
145
    else
146
      {:error, reason} -> {:error, reason}
147
      _not_admitted -> {:error, :not_authorized}
148
    end
149
  end
150
151
  def remove_listing(listing_id, attributes)
152
      when is_binary(listing_id) and is_map(attributes) do
153
    Repo.transaction(fn ->
154
      with {:ok, listing} <- get_available_listing_for_update(listing_id),
155
           reason when is_binary(reason) <- attribute(attributes, :reason),
156
           receipt_ref when is_binary(receipt_ref) <- attribute(attributes, :receipt_ref),
157
           actor_ref when is_binary(actor_ref) <- attribute(attributes, :actor_ref),
158
           {:ok, removed} <-
159
             listing
160
             |> Listing.removal_changeset(%{
161
               state: "removed",
162
               removed_at: DateTime.utc_now(),
163
               removal_reason: reason
164
             })
165
             |> Repo.update(),
166
           {:ok, _receipt} <-
167
             insert_removal_receipt(removed, receipt_ref, actor_ref, reason) do
168
        removed
169
      else
170
        {:error, reason} -> Repo.rollback(reason)
171
        _invalid_attributes -> Repo.rollback(:invalid_removal)
172
      end
173
    end)
174
    |> unwrap_transaction()
175
  end
176
177
  def export_listing_history(listing_id) when is_binary(listing_id) do
178
    with {:ok, listing_id} <- Ecto.UUID.cast(listing_id) do
179
      case Repo.get(Listing, listing_id) do
180
        nil ->
181
          {:error, :not_found}
182
183
        listing ->
184
          receipts =
185
            Receipt
186
            |> where([receipt], receipt.listing_id == ^listing.id)
187
            |> order_by([receipt], asc: receipt.inserted_at, asc: receipt.id)
188
            |> Repo.all()
189
            |> Enum.map(&Receipt.projection/1)
190
191
          {:ok,
192
           %{
193
             "catalog_version" => 1,
194
             "exported_at" => DateTime.utc_now(),
195
             "state" => listing.state,
196
             "removed_at" => listing.removed_at,
197
             "listing" => Listing.public_projection(listing),
198
             "receipts" => receipts
199
           }}
200
      end
201
    else
202
      :error -> {:error, :not_found}
203
    end
204
  end
205
206
  defp get_available_listing(id) do
207
    with {:ok, id} <- Ecto.UUID.cast(id) do
208
      case Repo.get(Listing, id) do
209
        nil -> {:error, :not_found}
210
        listing -> validate_available_listing(listing)
211
      end
212
    else
213
      :error -> {:error, :not_found}
214
    end
215
  end
216
217
  defp get_available_listing_for_update(id) do
218
    with {:ok, id} <- Ecto.UUID.cast(id) do
219
      Listing
220
      |> where([listing], listing.id == ^id)
221
      |> lock("FOR UPDATE")
222
      |> Repo.one()
223
      |> case do
224
        nil -> {:error, :not_found}
225
        listing -> validate_available_listing(listing)
226
      end
227
    else
228
      :error -> {:error, :not_found}
229
    end
230
  end
231
232
  defp validate_available_listing(%Listing{state: "removed"}), do: {:error, :listing_removed}
233
234
  defp validate_available_listing(%Listing{} = listing) do
235
    now = DateTime.utc_now()
236
237
    cond do
238
      DateTime.compare(listing.license_effective_at, now) == :gt -> {:error, :stale_license}
239
      DateTime.compare(listing.license_expires_at, now) != :gt -> {:error, :stale_license}
240
      true -> {:ok, listing}
241
    end
242
  end
243
244
  defp verify_digest_bindings(listing, attributes) do
245
    expected = %{
246
      artifact_digest: listing.artifact_digest,
247
      provenance_digest: listing.provenance_digest,
248
      license_digest: listing.license_digest,
249
      listing_digest: listing.listing_digest
250
    }
251
252
    Enum.reduce_while(expected, :ok, fn {field, digest}, :ok ->
253
      if attribute(attributes, field) == digest do
254
        {:cont, :ok}
255
      else
256
        {:halt, {:error, {:digest_mismatch, field}}}
257
      end
258
    end)
259
  end
260
261
  defp verify_predecessor(listing, action, attributes) do
262
    predecessor_ref = attribute(attributes, :predecessor_ref)
263
    expected_action = Map.fetch!(@predecessors, action)
264
265
    case Repo.get_by(Receipt,
266
           listing_id: listing.id,
267
           receipt_ref: predecessor_ref,
268
           action: expected_action
269
         ) do
270
      nil -> {:error, {:invalid_predecessor, expected_action}}
271
      receipt -> {:ok, receipt}
272
    end
273
  end
274
275
  defp insert_transaction_receipt(listing, action, attributes, predecessor) do
276
    buyer_ref = attribute(attributes, :buyer_ref)
277
    buyer_class = attribute(attributes, :buyer_class)
278
279
    cond do
280
      not is_binary(buyer_ref) ->
281
        {:error, :invalid_buyer}
282
283
      action == "settlement" and not valid_reference?(attribute(attributes, :external_ref)) ->
284
        {:error, :missing_settlement_reference}
285
286
      buyer_class != listing.buyer_class ->
287
        {:error, :buyer_class_mismatch}
288
289
      predecessor.action != "publication" and predecessor.buyer_ref != buyer_ref ->
290
        {:error, :buyer_mismatch}
291
292
      true ->
293
        %Receipt{listing_id: listing.id}
294
        |> Receipt.changeset(%{
295
          action: action,
296
          status: Map.fetch!(@statuses, action),
297
          receipt_ref: attribute(attributes, :receipt_ref),
298
          predecessor_ref: predecessor.receipt_ref,
299
          external_ref: attribute(attributes, :external_ref),
300
          buyer_ref: buyer_ref,
301
          buyer_class: buyer_class,
302
          artifact_digest: listing.artifact_digest,
303
          provenance_digest: listing.provenance_digest,
304
          license_digest: listing.license_digest,
305
          listing_digest: listing.listing_digest,
306
          metadata: attribute(attributes, :metadata) || %{}
307
        })
308
        |> Repo.insert()
309
    end
310
  end
311
312
  defp insert_removal_receipt(listing, receipt_ref, actor_ref, reason) do
313
    predecessor_ref =
314
      Receipt
315
      |> where([receipt], receipt.listing_id == ^listing.id)
316
      |> order_by([receipt], desc: receipt.inserted_at, desc: receipt.id)
317
      |> select([receipt], receipt.receipt_ref)
318
      |> limit(1)
319
      |> Repo.one()
320
321
    %Receipt{listing_id: listing.id}
322
    |> Receipt.changeset(%{
323
      action: "removal",
324
      status: "removed",
325
      receipt_ref: receipt_ref,
326
      predecessor_ref: predecessor_ref,
327
      buyer_ref: actor_ref,
328
      buyer_class: "operator",
329
      artifact_digest: listing.artifact_digest,
330
      provenance_digest: listing.provenance_digest,
331
      license_digest: listing.license_digest,
332
      listing_digest: listing.listing_digest,
333
      metadata: %{"reason" => reason}
334
    })
335
    |> Repo.insert()
336
  end
337
338
  defp receipt_matches_listing(receipt, listing) do
339
    if receipt.artifact_digest == listing.artifact_digest and
340
         receipt.provenance_digest == listing.provenance_digest and
341
         receipt.license_digest == listing.license_digest and
342
         receipt.listing_digest == listing.listing_digest do
343
      :ok
344
    else
345
      {:error, :receipt_digest_mismatch}
346
    end
347
  end
348
349
  defp unwrap_transaction({:ok, value}), do: {:ok, value}
350
  defp unwrap_transaction({:error, reason}), do: {:error, reason}
351
352
  defp filter_type(query, filters) do
353
    case attribute(filters, :artifact_type) do
354
      type when type in ["trace", "dataset"] ->
355
        where(query, [listing], listing.artifact_type == ^type)
356
357
      _other ->
358
        query
359
    end
360
  end
361
362
  defp filter_buyer_class(query, filters) do
363
    case attribute(filters, :buyer_class) do
364
      buyer_class when is_binary(buyer_class) and buyer_class != "" ->
365
        where(query, [listing], listing.buyer_class == ^buyer_class)
366
367
      _other ->
368
        query
369
    end
370
  end
371
372
  defp filter_query(query, filters) do
373
    case attribute(filters, :q) do
374
      term when is_binary(term) and term != "" ->
375
        pattern = "%#{term}%"
376
377
        where(
378
          query,
379
          [listing],
380
          ilike(listing.owner_description, ^pattern) or
381
            ilike(listing.artifact_digest, ^pattern) or
382
            ilike(listing.buyer_name, ^pattern) or
383
            ilike(listing.buyer_class, ^pattern)
384
        )
385
386
      _other ->
387
        query
388
    end
389
  end
390
391
  defp limit(filters) do
392
    case attribute(filters, :limit) do
393
      value when is_integer(value) -> value |> max(1) |> min(100)
394
      value when is_binary(value) -> parse_limit(value)
395
      _other -> 50
396
    end
397
  end
398
399
  defp parse_limit(value) do
400
    case Integer.parse(value) do
401
      {integer, ""} -> integer |> max(1) |> min(100)
402
      _invalid -> 50
403
    end
404
  end
405
406
  defp valid_reference?(value), do: is_binary(value) and value != ""
407
408
  defp attribute(attributes, field) do
409
    Map.get(attributes, field) || Map.get(attributes, Atom.to_string(field))
410
  end
411
end
lib/openagents/artifact_catalog/listing.ex added +324

@@ -0,0 +1,324 @@

1
defmodule OpenAgents.ArtifactCatalog.Listing do
2
  @moduledoc "A safe catalog projection for a licensed trace or dataset."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  alias OpenAgents.Provenance.Canonical
8
9
  @digest_regex ~r/\A[0-9a-f]{64}\z/
10
  @private_keys ~w(
11
    email message_id private_source_ref raw_source secret source_path source_ref source_uri token
12
    user_id
13
  )
14
15
  @primary_key {:id, :binary_id, autogenerate: true}
16
  @foreign_key_type :binary_id
17
  @timestamps_opts [type: :utc_datetime_usec]
18
19
  schema "verified_artifact_listings" do
20
    field :artifact_type, :string
21
    field :state, :string, default: "active"
22
    field :owner_ref, :string
23
    field :owner_description, :string
24
    field :source_ref, :string
25
    field :artifact_digest, :string
26
    field :provenance_digest, :string
27
    field :provenance, :map, default: %{}
28
    field :schema, :map, default: %{}
29
    field :size_bytes, :integer
30
    field :record_count, :integer
31
    field :coverage, :map, default: %{}
32
    field :redaction, :map, default: %{}
33
    field :license_contract_ref, :string
34
    field :license_terms, :map, default: %{}
35
    field :license_digest, :string
36
    field :license_effective_at, :utc_datetime_usec
37
    field :license_expires_at, :utc_datetime_usec
38
    field :price, :map, default: %{}
39
    field :buyer_name, :string
40
    field :buyer_class, :string
41
    field :verification_policy, :map, default: %{}
42
    field :evidence_fresh_at, :utc_datetime_usec
43
    field :listing_digest, :string
44
    field :publication_receipt_ref, :string
45
    field :removed_at, :utc_datetime_usec
46
    field :removal_reason, :string
47
48
    has_many :receipts, OpenAgents.ArtifactCatalog.Receipt
49
    timestamps()
50
  end
51
52
  @type t :: %__MODULE__{}
53
54
  def publication_changeset(listing, attributes) do
55
    listing
56
    |> cast(attributes, [
57
      :artifact_type,
58
      :owner_ref,
59
      :owner_description,
60
      :source_ref,
61
      :artifact_digest,
62
      :provenance_digest,
63
      :provenance,
64
      :schema,
65
      :size_bytes,
66
      :record_count,
67
      :coverage,
68
      :redaction,
69
      :license_contract_ref,
70
      :license_terms,
71
      :license_effective_at,
72
      :license_expires_at,
73
      :price,
74
      :buyer_name,
75
      :buyer_class,
76
      :verification_policy,
77
      :evidence_fresh_at,
78
      :publication_receipt_ref
79
    ])
80
    |> validate_required([
81
      :artifact_type,
82
      :owner_ref,
83
      :owner_description,
84
      :source_ref,
85
      :artifact_digest,
86
      :provenance_digest,
87
      :provenance,
88
      :schema,
89
      :size_bytes,
90
      :coverage,
91
      :redaction,
92
      :license_contract_ref,
93
      :license_terms,
94
      :license_effective_at,
95
      :license_expires_at,
96
      :buyer_name,
97
      :buyer_class,
98
      :verification_policy,
99
      :evidence_fresh_at,
100
      :publication_receipt_ref
101
    ])
102
    |> validate_inclusion(:artifact_type, ~w(trace dataset))
103
    |> validate_format(:artifact_digest, @digest_regex)
104
    |> validate_format(:provenance_digest, @digest_regex)
105
    |> validate_number(:size_bytes, greater_than: 0)
106
    |> validate_number(:record_count, greater_than: 0)
107
    |> validate_length(:owner_ref, min: 1, max: 256)
108
    |> validate_length(:owner_description, min: 1, max: 2_000)
109
    |> validate_length(:source_ref, min: 1, max: 1_000)
110
    |> validate_length(:license_contract_ref, min: 1, max: 256)
111
    |> validate_length(:buyer_name, min: 1, max: 256)
112
    |> validate_length(:buyer_class, min: 1, max: 128)
113
    |> validate_length(:publication_receipt_ref, min: 1, max: 256)
114
    |> validate_safe_map(:provenance)
115
    |> validate_safe_map(:schema)
116
    |> validate_safe_map(:coverage)
117
    |> validate_safe_map(:redaction)
118
    |> validate_safe_map(:license_terms)
119
    |> validate_safe_map(:price)
120
    |> validate_safe_map(:verification_policy)
121
    |> validate_opt_in_license()
122
    |> validate_license_window()
123
    |> validate_active_license()
124
    |> put_identity_digests(attributes)
125
    |> unique_constraint([:artifact_digest, :provenance_digest, :license_digest],
126
      name: :verified_artifact_listings_identity_index
127
    )
128
    |> unique_constraint(:listing_digest)
129
    |> unique_constraint(:publication_receipt_ref)
130
  end
131
132
  def removal_changeset(listing, attributes) do
133
    listing
134
    |> change(attributes)
135
    |> validate_required([:state, :removed_at, :removal_reason])
136
    |> validate_inclusion(:state, ["removed"])
137
    |> validate_length(:removal_reason, min: 1, max: 1_000)
138
  end
139
140
  def public_projection(%__MODULE__{} = listing) do
141
    %{
142
      "id" => listing.id,
143
      "artifact_type" => listing.artifact_type,
144
      "owner" => %{
145
        "ref" => listing.owner_ref,
146
        "description" => listing.owner_description
147
      },
148
      "artifact_digest" => listing.artifact_digest,
149
      "provenance" => Map.put(listing.provenance, "digest", listing.provenance_digest),
150
      "schema" => listing.schema,
151
      "size" => %{
152
        "bytes" => listing.size_bytes,
153
        "records" => listing.record_count
154
      },
155
      "coverage" => listing.coverage,
156
      "redaction" => listing.redaction,
157
      "license" => %{
158
        "contract_ref" => listing.license_contract_ref,
159
        "digest" => listing.license_digest,
160
        "terms" => listing.license_terms,
161
        "effective_at" => listing.license_effective_at,
162
        "expires_at" => listing.license_expires_at
163
      },
164
      "price" => listing.price,
165
      "buyer" => %{
166
        "name" => listing.buyer_name,
167
        "class" => listing.buyer_class
168
      },
169
      "verification_policy" => listing.verification_policy,
170
      "evidence_fresh_at" => listing.evidence_fresh_at,
171
      "listing_digest" => listing.listing_digest,
172
      "publication_receipt_ref" => listing.publication_receipt_ref,
173
      "published_at" => listing.inserted_at
174
    }
175
  end
176
177
  defp validate_license_window(changeset) do
178
    effective_at = get_field(changeset, :license_effective_at)
179
    expires_at = get_field(changeset, :license_expires_at)
180
    evidence_fresh_at = get_field(changeset, :evidence_fresh_at)
181
182
    changeset
183
    |> validate_after(:license_expires_at, expires_at, :license_effective_at, effective_at)
184
    |> validate_after(:license_expires_at, expires_at, :evidence_fresh_at, evidence_fresh_at)
185
  end
186
187
  defp validate_opt_in_license(changeset) do
188
    case get_field(changeset, :license_terms) do
189
      %{"opt_in" => true} -> changeset
190
      %{opt_in: true} -> changeset
191
      _terms -> add_error(changeset, :license_terms, "must record explicit opt-in")
192
    end
193
  end
194
195
  defp validate_active_license(changeset) do
196
    now = DateTime.utc_now()
197
    effective_at = get_field(changeset, :license_effective_at)
198
    expires_at = get_field(changeset, :license_expires_at)
199
200
    changeset
201
    |> validate_not_after_now(:license_effective_at, effective_at, now)
202
    |> validate_after_now(:license_expires_at, expires_at, now)
203
  end
204
205
  defp validate_not_after_now(changeset, field, %DateTime{} = at, now) do
206
    if DateTime.compare(at, now) in [:lt, :eq] do
207
      changeset
208
    else
209
      add_error(changeset, field, "must be active")
210
    end
211
  end
212
213
  defp validate_not_after_now(changeset, _field, _at, _now), do: changeset
214
215
  defp validate_after_now(changeset, field, %DateTime{} = at, now) do
216
    if DateTime.compare(at, now) == :gt do
217
      changeset
218
    else
219
      add_error(changeset, field, "must be current")
220
    end
221
  end
222
223
  defp validate_after_now(changeset, _field, _at, _now), do: changeset
224
225
  defp validate_after(changeset, field, %DateTime{} = later, earlier_field, %DateTime{} = earlier) do
226
    if DateTime.compare(later, earlier) == :gt do
227
      changeset
228
    else
229
      add_error(changeset, field, "must be after #{earlier_field}")
230
    end
231
  end
232
233
  defp validate_after(changeset, _field, _later, _earlier_field, _earlier), do: changeset
234
235
  defp put_identity_digests(%Ecto.Changeset{valid?: false} = changeset, _attributes),
236
    do: changeset
237
238
  defp put_identity_digests(changeset, attributes) do
239
    listing = apply_changes(changeset)
240
241
    license_digest =
242
      Canonical.digest!(%{
243
        "contract_ref" => listing.license_contract_ref,
244
        "effective_at" => DateTime.to_iso8601(listing.license_effective_at),
245
        "expires_at" => DateTime.to_iso8601(listing.license_expires_at),
246
        "terms" => listing.license_terms
247
      })
248
249
    listing_digest =
250
      listing
251
      |> public_identity(license_digest)
252
      |> Canonical.digest!()
253
254
    changeset
255
    |> verify_supplied_digest(attributes, :license_digest, license_digest)
256
    |> verify_supplied_digest(attributes, :listing_digest, listing_digest)
257
    |> put_change(:license_digest, license_digest)
258
    |> put_change(:listing_digest, listing_digest)
259
  end
260
261
  defp public_identity(listing, license_digest) do
262
    %{
263
      "artifact_type" => listing.artifact_type,
264
      "owner" => %{
265
        "ref" => listing.owner_ref,
266
        "description" => listing.owner_description
267
      },
268
      "artifact_digest" => listing.artifact_digest,
269
      "provenance" => Map.put(listing.provenance, "digest", listing.provenance_digest),
270
      "schema" => listing.schema,
271
      "size" => %{"bytes" => listing.size_bytes, "records" => listing.record_count},
272
      "coverage" => listing.coverage,
273
      "redaction" => listing.redaction,
274
      "license" => %{
275
        "contract_ref" => listing.license_contract_ref,
276
        "digest" => license_digest,
277
        "terms" => listing.license_terms,
278
        "effective_at" => DateTime.to_iso8601(listing.license_effective_at),
279
        "expires_at" => DateTime.to_iso8601(listing.license_expires_at)
280
      },
281
      "price" => listing.price,
282
      "buyer" => %{"name" => listing.buyer_name, "class" => listing.buyer_class},
283
      "verification_policy" => listing.verification_policy,
284
      "evidence_fresh_at" => DateTime.to_iso8601(listing.evidence_fresh_at)
285
    }
286
  end
287
288
  defp verify_supplied_digest(changeset, attributes, field, expected) do
289
    case attribute(attributes, field) do
290
      nil -> changeset
291
      ^expected -> changeset
292
      _mismatch -> add_error(changeset, field, "does not match the canonical identity")
293
    end
294
  end
295
296
  defp attribute(attributes, field) do
297
    Map.get(attributes, field) || Map.get(attributes, Atom.to_string(field))
298
  end
299
300
  defp validate_safe_map(changeset, field) do
301
    validate_change(changeset, field, fn ^field, value ->
302
      cond do
303
        not is_map(value) -> [{field, "must be a map"}]
304
        contains_private_key?(value) -> [{field, "contains private source metadata"}]
305
        true -> []
306
      end
307
    end)
308
  end
309
310
  defp contains_private_key?(map) when is_map(map) do
311
    Enum.any?(map, fn {key, value} ->
312
      normalize_key(key) in @private_keys or contains_private_key?(value)
313
    end)
314
  end
315
316
  defp contains_private_key?(values) when is_list(values),
317
    do: Enum.any?(values, &contains_private_key?/1)
318
319
  defp contains_private_key?(_value), do: false
320
321
  defp normalize_key(key) when is_atom(key), do: Atom.to_string(key)
322
  defp normalize_key(key) when is_binary(key), do: key
323
  defp normalize_key(_key), do: ""
324
end
lib/openagents/artifact_catalog/receipt.ex added +126

@@ -0,0 +1,126 @@

1
defmodule OpenAgents.ArtifactCatalog.Receipt do
2
  @moduledoc "Append-only evidence for a verified artifact transaction."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @digest_regex ~r/\A[0-9a-f]{64}\z/
8
  @private_keys ~w(
9
    email message_id private_source_ref raw_source secret source_path source_ref source_uri token
10
    user_id
11
  )
12
13
  @primary_key {:id, :binary_id, autogenerate: true}
14
  @foreign_key_type :binary_id
15
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
16
17
  schema "verified_artifact_receipts" do
18
    belongs_to :listing, OpenAgents.ArtifactCatalog.Listing
19
    field :action, :string
20
    field :status, :string
21
    field :receipt_ref, :string
22
    field :predecessor_ref, :string
23
    field :external_ref, :string
24
    field :buyer_ref, :string
25
    field :buyer_class, :string
26
    field :artifact_digest, :string
27
    field :provenance_digest, :string
28
    field :license_digest, :string
29
    field :listing_digest, :string
30
    field :metadata, :map, default: %{}
31
    timestamps()
32
  end
33
34
  @type t :: %__MODULE__{}
35
36
  def changeset(receipt, attributes) do
37
    receipt
38
    |> cast(attributes, [
39
      :action,
40
      :status,
41
      :receipt_ref,
42
      :predecessor_ref,
43
      :external_ref,
44
      :buyer_ref,
45
      :buyer_class,
46
      :artifact_digest,
47
      :provenance_digest,
48
      :license_digest,
49
      :listing_digest,
50
      :metadata
51
    ])
52
    |> validate_required([
53
      :listing_id,
54
      :action,
55
      :status,
56
      :receipt_ref,
57
      :buyer_ref,
58
      :buyer_class,
59
      :artifact_digest,
60
      :provenance_digest,
61
      :license_digest,
62
      :listing_digest,
63
      :metadata
64
    ])
65
    |> validate_inclusion(
66
      :action,
67
      ~w(publication offer acceptance delivery verification settlement removal)
68
    )
69
    |> validate_inclusion(:status, ~w(recorded admitted verified settled removed))
70
    |> validate_format(:artifact_digest, @digest_regex)
71
    |> validate_format(:provenance_digest, @digest_regex)
72
    |> validate_format(:license_digest, @digest_regex)
73
    |> validate_format(:listing_digest, @digest_regex)
74
    |> validate_length(:receipt_ref, min: 1, max: 256)
75
    |> validate_length(:predecessor_ref, min: 1, max: 256)
76
    |> validate_length(:external_ref, min: 1, max: 256)
77
    |> validate_length(:buyer_ref, min: 1, max: 256)
78
    |> validate_length(:buyer_class, min: 1, max: 128)
79
    |> validate_safe_metadata()
80
    |> foreign_key_constraint(:listing_id)
81
    |> unique_constraint(:receipt_ref)
82
  end
83
84
  def projection(%__MODULE__{} = receipt) do
85
    %{
86
      "action" => receipt.action,
87
      "status" => receipt.status,
88
      "receipt_ref" => receipt.receipt_ref,
89
      "predecessor_ref" => receipt.predecessor_ref,
90
      "external_ref" => receipt.external_ref,
91
      "buyer_ref" => receipt.buyer_ref,
92
      "buyer_class" => receipt.buyer_class,
93
      "artifact_digest" => receipt.artifact_digest,
94
      "provenance_digest" => receipt.provenance_digest,
95
      "license_digest" => receipt.license_digest,
96
      "listing_digest" => receipt.listing_digest,
97
      "metadata" => receipt.metadata,
98
      "recorded_at" => receipt.inserted_at
99
    }
100
  end
101
102
  defp validate_safe_metadata(changeset) do
103
    validate_change(changeset, :metadata, fn :metadata, value ->
104
      cond do
105
        not is_map(value) -> [metadata: "must be a map"]
106
        contains_private_key?(value) -> [metadata: "contains private source metadata"]
107
        true -> []
108
      end
109
    end)
110
  end
111
112
  defp contains_private_key?(map) when is_map(map) do
113
    Enum.any?(map, fn {key, value} ->
114
      normalize_key(key) in @private_keys or contains_private_key?(value)
115
    end)
116
  end
117
118
  defp contains_private_key?(values) when is_list(values),
119
    do: Enum.any?(values, &contains_private_key?/1)
120
121
  defp contains_private_key?(_value), do: false
122
123
  defp normalize_key(key) when is_atom(key), do: Atom.to_string(key)
124
  defp normalize_key(key) when is_binary(key), do: key
125
  defp normalize_key(_key), do: ""
126
end
lib/openagents_web/components/layouts.ex modified +6

@@ -780,6 +780,12 @@ defmodule OpenAgentsWeb.Layouts do

780 780
          icon="folder"
781 781
          patchable={false}
782 782
        />
783
        <Layouts.sidebar_link
784
          path={~p"/artifact-catalog"}
785
          label="Artifact catalog"
786
          icon="archive"
787
          patchable={false}
788
        />
783 789
      </nav>
784 790
785 791
      <%!-- The agent's own surfaces, grouped under her name. Chat, computers
lib/openagents_web/controllers/artifact_listing_admin_controller.ex added +112

@@ -0,0 +1,112 @@

1
defmodule OpenAgentsWeb.ArtifactListingAdminController do
2
  use OpenAgentsWeb, :controller
3
4
  alias OpenAgents.ArtifactCatalog
5
  alias OpenAgents.ArtifactCatalog.Listing
6
  alias OpenAgents.ArtifactCatalog.Receipt
7
8
  def create(conn, params) do
9
    case ArtifactCatalog.publish_listing(params) do
10
      {:ok, listing} ->
11
        conn
12
        |> put_status(:created)
13
        |> json(%{"listing" => Listing.public_projection(listing)})
14
15
      {:error, %Ecto.Changeset{} = changeset} ->
16
        validation_error(conn, changeset)
17
    end
18
  end
19
20
  def delete(conn, %{"id" => id} = params) do
21
    attributes =
22
      params
23
      |> Map.put_new("actor_ref", conn.assigns.current_user.github_login)
24
      |> Map.put_new("receipt_ref", "artifact-removal:#{Ecto.UUID.generate()}")
25
26
    case ArtifactCatalog.remove_listing(id, attributes) do
27
      {:ok, listing} ->
28
        json(conn, %{
29
          "listing_id" => listing.id,
30
          "listing_digest" => listing.listing_digest,
31
          "state" => listing.state,
32
          "removed_at" => listing.removed_at
33
        })
34
35
      {:error, reason} ->
36
        operation_error(conn, reason)
37
    end
38
  end
39
40
  def export(conn, %{"id" => id}) do
41
    case ArtifactCatalog.export_listing_history(id) do
42
      {:ok, export} ->
43
        conn
44
        |> put_resp_header(
45
          "content-disposition",
46
          ~s(attachment; filename="artifact-listing-history-#{id}.json")
47
        )
48
        |> json(export)
49
50
      {:error, reason} ->
51
        operation_error(conn, reason)
52
    end
53
  end
54
55
  def record(conn, %{"id" => id, "action" => action} = params) do
56
    case ArtifactCatalog.record_transaction(id, action, params) do
57
      {:ok, %Receipt{} = receipt} ->
58
        conn
59
        |> put_status(:created)
60
        |> json(%{"receipt" => Receipt.projection(receipt)})
61
62
      {:error, reason} ->
63
        operation_error(conn, reason)
64
    end
65
  end
66
67
  def authorize(conn, %{"id" => id} = params) do
68
    case ArtifactCatalog.authorize_source_access(id, params) do
69
      {:ok, authorization} ->
70
        json(conn, %{"authorization" => authorization})
71
72
      {:error, reason} ->
73
        operation_error(conn, reason)
74
    end
75
  end
76
77
  defp validation_error(conn, changeset) do
78
    errors =
79
      Ecto.Changeset.traverse_errors(changeset, fn {message, options} ->
80
        Enum.reduce(options, message, fn {key, value}, text ->
81
          String.replace(text, "%{#{key}}", to_string(value))
82
        end)
83
      end)
84
85
    conn
86
    |> put_status(:unprocessable_entity)
87
    |> json(%{"error" => "invalid_listing", "fields" => errors})
88
  end
89
90
  defp operation_error(conn, reason) do
91
    status =
92
      case reason do
93
        :not_found -> :not_found
94
        :not_authorized -> :forbidden
95
        :listing_removed -> :conflict
96
        :stale_license -> :conflict
97
        {:digest_mismatch, _field} -> :unprocessable_entity
98
        {:invalid_predecessor, _action} -> :unprocessable_entity
99
        %Ecto.Changeset{} -> :unprocessable_entity
100
        _other -> :unprocessable_entity
101
      end
102
103
    conn
104
    |> put_status(status)
105
    |> json(%{"error" => error_code(reason)})
106
  end
107
108
  defp error_code({:digest_mismatch, field}), do: "digest_mismatch:#{field}"
109
  defp error_code({:invalid_predecessor, action}), do: "invalid_predecessor:#{action}"
110
  defp error_code(%Ecto.Changeset{}), do: "invalid_receipt"
111
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
112
end
lib/openagents_web/controllers/artifact_listing_controller.ex added +46

@@ -0,0 +1,46 @@

1
defmodule OpenAgentsWeb.ArtifactListingController do
2
  use OpenAgentsWeb, :controller
3
4
  alias OpenAgents.ArtifactCatalog
5
  alias OpenAgents.ArtifactCatalog.Listing
6
7
  def index(conn, params) do
8
    listings =
9
      params
10
      |> ArtifactCatalog.list_public_listings()
11
      |> Enum.map(&Listing.public_projection/1)
12
13
    json(conn, %{"listings" => listings})
14
  end
15
16
  def show(conn, %{"id" => id}) do
17
    case ArtifactCatalog.get_public_listing(id) do
18
      {:ok, listing} ->
19
        json(conn, %{"listing" => Listing.public_projection(listing)})
20
21
      {:error, :not_found} ->
22
        not_found(conn)
23
    end
24
  end
25
26
  def export(conn, %{"id" => id}) do
27
    case ArtifactCatalog.export_public_listing(id) do
28
      {:ok, export} ->
29
        conn
30
        |> put_resp_header(
31
          "content-disposition",
32
          ~s(attachment; filename="artifact-listing-#{id}.json")
33
        )
34
        |> json(export)
35
36
      {:error, :not_found} ->
37
        not_found(conn)
38
    end
39
  end
40
41
  defp not_found(conn) do
42
    conn
43
    |> put_status(:not_found)
44
    |> json(%{"error" => "listing_not_found"})
45
  end
46
end
lib/openagents_web/live/artifact_catalog_live.ex added +192

@@ -0,0 +1,192 @@

1
defmodule OpenAgentsWeb.ArtifactCatalogLive do
2
  @moduledoc "Authenticated read-only catalog for verified traces and datasets."
3
4
  use OpenAgentsWeb, :live_view
5
6
  alias OpenAgents.ArtifactCatalog
7
8
  @impl true
9
  def mount(_params, _session, socket) do
10
    {:ok,
11
     socket
12
     |> assign(:page_title, "Artifact catalog · OpenAgents")
13
     |> assign(:form, to_form(%{"q" => "", "artifact_type" => ""}, as: :filters))
14
     |> assign(:listings, [])}
15
  end
16
17
  @impl true
18
  def handle_params(params, _uri, socket) do
19
    filters = %{
20
      "q" => Map.get(params, "q", ""),
21
      "artifact_type" => Map.get(params, "artifact_type", "")
22
    }
23
24
    {:noreply,
25
     socket
26
     |> assign(:form, to_form(filters, as: :filters))
27
     |> assign(:listings, ArtifactCatalog.list_public_listings(filters))}
28
  end
29
30
  @impl true
31
  def handle_event("search", %{"filters" => filters}, socket) do
32
    query =
33
      filters
34
      |> Map.take(["q", "artifact_type"])
35
      |> Enum.reject(fn {_key, value} -> value == "" end)
36
      |> Map.new()
37
38
    {:noreply, push_patch(socket, to: ~p"/artifact-catalog?#{query}")}
39
  end
40
41
  defp stamp(%DateTime{} = at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")
42
43
  defp size_text(bytes) when bytes < 1_000, do: "#{bytes} B"
44
  defp size_text(bytes) when bytes < 1_000_000, do: "#{Float.round(bytes / 1_000, 1)} kB"
45
  defp size_text(bytes), do: "#{Float.round(bytes / 1_000_000, 1)} MB"
46
47
  defp digest(digest), do: "#{binary_part(digest, 0, 12)}…"
48
49
  defp price_text(price) when map_size(price) == 0, do: "Not priced"
50
51
  defp price_text(price) do
52
    amount = Map.get(price, "amount")
53
    currency = Map.get(price, "currency")
54
    unit = Map.get(price, "unit")
55
56
    [amount, currency, unit]
57
    |> Enum.reject(&is_nil/1)
58
    |> Enum.map_join(" ", &to_string/1)
59
  end
60
61
  @impl true
62
  def render(assigns) do
63
    ~H"""
64
    <Layouts.app
65
      flash={@flash}
66
      sidebar_sections={assigns[:sidebar_sections]}
67
      current_scope={@current_scope}
68
      title="Artifact catalog"
69
      subtitle="Verified traces and datasets"
70
    >
71
      <section id="artifact-catalog" class="mx-auto w-full max-w-7xl space-y-6">
72
        <.header>
73
          Licensed artifacts
74
          <:subtitle>
75
            Discover compatible artifacts without exposing private source metadata.
76
          </:subtitle>
77
        </.header>
78
79
        <.card id="artifact-catalog-search" class="p-4">
80
          <.form
81
            for={@form}
82
            id="artifact-catalog-filter-form"
83
            phx-submit="search"
84
            class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem_auto]"
85
          >
86
            <.input
87
              field={@form[:q]}
88
              type="search"
89
              label="Search"
90
              placeholder="Description, digest, or buyer"
91
            />
92
            <.input
93
              field={@form[:artifact_type]}
94
              type="select"
95
              label="Artifact type"
96
              prompt="All artifacts"
97
              options={[{"Dataset", "dataset"}, {"Trace", "trace"}]}
98
            />
99
            <.button id="artifact-catalog-search-button" type="submit" class="self-end">
100
              Search catalog
101
            </.button>
102
          </.form>
103
        </.card>
104
105
        <.empty
106
          :if={@listings == []}
107
          id="artifact-catalog-empty"
108
          title="No compatible artifacts"
109
        >
110
          Change the search terms or artifact type.
111
        </.empty>
112
113
        <div id="artifact-catalog-listings" class="grid gap-4 lg:grid-cols-2">
114
          <.card
115
            :for={listing <- @listings}
116
            id={"artifact-listing-#{listing.id}"}
117
            class="group flex h-full flex-col gap-5 p-5 transition hover:-translate-y-0.5 hover:shadow-lg"
118
          >
119
            <div class="flex flex-wrap items-start justify-between gap-3">
120
              <div class="space-y-2">
121
                <div class="flex flex-wrap items-center gap-2">
122
                  <.badge variant={:info}>{listing.artifact_type}</.badge>
123
                  <.badge variant={:success}>licensed</.badge>
124
                </div>
125
                <h2 class="text-lg font-semibold text-foreground">
126
                  {listing.owner_description}
127
                </h2>
128
                <p class="text-sm text-muted-foreground">
129
                  Offered by {listing.owner_ref} for {listing.buyer_name}
130
                </p>
131
              </div>
132
              <p class="text-sm font-medium text-foreground">{price_text(listing.price)}</p>
133
            </div>
134
135
            <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
136
              <div>
137
                <dt class="text-muted-foreground">Artifact digest</dt>
138
                <dd class="font-mono text-foreground" title={listing.artifact_digest}>
139
                  {digest(listing.artifact_digest)}
140
                </dd>
141
              </div>
142
              <div>
143
                <dt class="text-muted-foreground">Provenance digest</dt>
144
                <dd class="font-mono text-foreground" title={listing.provenance_digest}>
145
                  {digest(listing.provenance_digest)}
146
                </dd>
147
              </div>
148
              <div>
149
                <dt class="text-muted-foreground">Size</dt>
150
                <dd class="text-foreground">{size_text(listing.size_bytes)}</dd>
151
              </div>
152
              <div>
153
                <dt class="text-muted-foreground">Buyer class</dt>
154
                <dd class="text-foreground">{listing.buyer_class}</dd>
155
              </div>
156
              <div>
157
                <dt class="text-muted-foreground">Schema</dt>
158
                <dd class="text-foreground">
159
                  {Map.get(listing.schema, "name", "Documented")} {Map.get(
160
                    listing.schema,
161
                    "version",
162
                    ""
163
                  )}
164
                </dd>
165
              </div>
166
              <div>
167
                <dt class="text-muted-foreground">Evidence fresh at</dt>
168
                <dd class="text-foreground">{stamp(listing.evidence_fresh_at)}</dd>
169
              </div>
170
            </dl>
171
172
            <div class="mt-auto flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
173
              <p class="text-xs text-muted-foreground">
174
                Verification: {Map.get(listing.verification_policy, "method", "policy bound")}
175
              </p>
176
              <.button
177
                id={"artifact-listing-export-#{listing.id}"}
178
                variant={:outline}
179
                size={:sm}
180
                href={~p"/api/artifact-listings/#{listing.id}/export"}
181
                download
182
              >
183
                Export listing
184
              </.button>
185
            </div>
186
          </.card>
187
        </div>
188
      </section>
189
    </Layouts.app>
190
    """
191
  end
192
end
lib/openagents_web/route_authority.ex modified +21

@@ -36,6 +36,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

36 36
    "/computers",
37 37
    "/voice/",
38 38
    "/data",
39
    "/artifact-catalog",
39 40
    "/machines",
40 41
    # No trailing slash: the memory page is "/memory" itself, and its export
41 42
    # lives under it.

@@ -213,6 +214,25 @@ defmodule OpenAgentsWeb.RouteAuthority do

213 214
  defp policy(%{path: "/api/inference/proxy"}),
214 215
    do: declaration(:internal_service, "scoped inference grant", "inference:invoke", true)
215 216
217
  defp policy(%{path: "/api/artifact-listings" <> _path, verb: verb})
218
       when verb in [:get, :head],
219
       do:
220
         declaration(
221
           :authenticated_api,
222
           "active encrypted browser session",
223
           "artifact-catalog:read",
224
           false
225
         )
226
227
  defp policy(%{path: "/api/operator/artifact-listings" <> _path, verb: verb}),
228
    do:
229
      declaration(
230
        :operator,
231
        "configured operator GitHub ID",
232
        "artifact-catalog:operate",
233
        verb not in [:get, :head]
234
      )
235
216 236
  defp policy(%{path: "/api/v3/device/authorizations" <> _path, verb: :post}),
217 237
    do:
218 238
      declaration(

@@ -335,6 +355,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

335 355
  defp browser_scope("/api/computer-agent-jobs/" <> _path), do: "computer-job:self"
336 356
  defp browser_scope("/voice/" <> _path), do: "voice:self"
337 357
  defp browser_scope("/data" <> _path), do: "data:self"
358
  defp browser_scope("/artifact-catalog"), do: "artifact-catalog:read"
338 359
  defp browser_scope("/memory/" <> _path), do: "memory:self"
339 360
  defp browser_scope("/github/connection"), do: "github-tools:self"
340 361
  defp browser_scope("/settings/api-tokens"), do: "api-token:self"
lib/openagents_web/router.ex modified +22

@@ -71,6 +71,10 @@ defmodule OpenAgentsWeb.Router do

71 71
    plug :require_admin_user
72 72
  end
73 73
74
  pipeline :operator_api do
75
    plug :require_operator_api_user
76
  end
77
74 78
  scope "/", OpenAgentsWeb do
75 79
    pipe_through [:status_probe_compat, :browser]
76 80

@@ -112,6 +116,7 @@ defmodule OpenAgentsWeb.Router do

112 116
      live "/sarah", ChatLive, :index
113 117
      live "/memory", MemoryLive, :index
114 118
      live "/computers", ComputersLive, :index
119
      live "/artifact-catalog", ArtifactCatalogLive, :index
115 120
      live "/settings/api-tokens", ApiTokensLive, :index
116 121
      live "/device", DeviceAuthorizationLive, :show
117 122
      live "/repositories", RepositoryIndexLive, :index

@@ -205,6 +210,23 @@ defmodule OpenAgentsWeb.Router do

205 210
    post "/computers/:machine_id/agent-jobs", ComputerAgentJobsController, :create
206 211
    get "/computer-agent-jobs/:id", ComputerAgentJobsController, :show
207 212
    delete "/computer-agent-jobs/:id", ComputerAgentJobsController, :delete
213
214
    get "/artifact-listings", ArtifactListingController, :index
215
    get "/artifact-listings/:id", ArtifactListingController, :show
216
    get "/artifact-listings/:id/export", ArtifactListingController, :export
217
  end
218
219
  scope "/api/operator", OpenAgentsWeb do
220
    pipe_through [:authenticated_api, :operator_api]
221
222
    post "/artifact-listings", ArtifactListingAdminController, :create
223
    delete "/artifact-listings/:id", ArtifactListingAdminController, :delete
224
    get "/artifact-listings/:id/export", ArtifactListingAdminController, :export
225
    post "/artifact-listings/:id/transactions/:action", ArtifactListingAdminController, :record
226
227
    post "/artifact-listings/:id/source-authorizations",
228
         ArtifactListingAdminController,
229
         :authorize
208 230
  end
209 231
210 232
  scope "/admin", OpenAgentsWeb do
lib/openagents_web/user_auth.ex modified +12

@@ -159,4 +159,16 @@ defmodule OpenAgentsWeb.UserAuth do

159 159
    |> Phoenix.Controller.json(%{error: "authentication_required"})
160 160
    |> halt()
161 161
  end
162
163
  def require_operator_api_user(conn, _options) do
164
    if Accounts.admin?(conn.assigns[:current_user]) do
165
      conn
166
    else
167
      conn
168
      |> put_status(:forbidden)
169
      |> put_resp_header("cache-control", "no-store")
170
      |> Phoenix.Controller.json(%{error: "operator_required"})
171
      |> halt()
172
    end
173
  end
162 174
end
priv/migration_lineages/prior-2026-08-19.json modified +1

@@ -239,6 +239,7 @@

239 239
    20260823040635,
240 240
    20260823042207,
241 241
    20260823043000,
242
    20260823045906,
242 243
    20260823050000,
243 244
    20260823051500,
244 245
    20260823052000,
priv/repo/migrations/20260823045906_create_verified_artifact_listings.exs added +133

@@ -0,0 +1,133 @@

1
defmodule OpenAgents.Repo.Migrations.CreateVerifiedArtifactListings do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:verified_artifact_listings, primary_key: false) do
6
      add :id, :uuid, primary_key: true
7
      add :artifact_type, :text, null: false
8
      add :state, :text, null: false, default: "active"
9
      add :owner_ref, :text, null: false
10
      add :owner_description, :text, null: false
11
      add :source_ref, :text, null: false
12
      add :artifact_digest, :text, null: false
13
      add :provenance_digest, :text, null: false
14
      add :provenance, :map, null: false, default: fragment("'{}'::jsonb")
15
      add :schema, :map, null: false, default: fragment("'{}'::jsonb")
16
      add :size_bytes, :bigint, null: false
17
      add :record_count, :bigint
18
      add :coverage, :map, null: false, default: fragment("'{}'::jsonb")
19
      add :redaction, :map, null: false, default: fragment("'{}'::jsonb")
20
      add :license_contract_ref, :text, null: false
21
      add :license_terms, :map, null: false, default: fragment("'{}'::jsonb")
22
      add :license_digest, :text, null: false
23
      add :license_effective_at, :utc_datetime_usec, null: false
24
      add :license_expires_at, :utc_datetime_usec, null: false
25
      add :price, :map, null: false, default: fragment("'{}'::jsonb")
26
      add :buyer_name, :text, null: false
27
      add :buyer_class, :text, null: false
28
      add :verification_policy, :map, null: false, default: fragment("'{}'::jsonb")
29
      add :evidence_fresh_at, :utc_datetime_usec, null: false
30
      add :listing_digest, :text, null: false
31
      add :publication_receipt_ref, :text, null: false
32
      add :removed_at, :utc_datetime_usec
33
      add :removal_reason, :text
34
35
      timestamps(type: :utc_datetime_usec)
36
    end
37
38
    create constraint(:verified_artifact_listings, :verified_artifact_listings_type,
39
             check: "artifact_type IN ('trace', 'dataset')"
40
           )
41
42
    create constraint(:verified_artifact_listings, :verified_artifact_listings_state,
43
             check: "state IN ('active', 'removed')"
44
           )
45
46
    create constraint(:verified_artifact_listings, :verified_artifact_listings_size,
47
             check: "size_bytes > 0 AND (record_count IS NULL OR record_count > 0)"
48
           )
49
50
    create constraint(:verified_artifact_listings, :verified_artifact_listings_artifact_digest,
51
             check: "artifact_digest ~ '^[0-9a-f]{64}$'"
52
           )
53
54
    create constraint(:verified_artifact_listings, :verified_artifact_listings_provenance_digest,
55
             check: "provenance_digest ~ '^[0-9a-f]{64}$'"
56
           )
57
58
    create constraint(:verified_artifact_listings, :verified_artifact_listings_license_digest,
59
             check: "license_digest ~ '^[0-9a-f]{64}$'"
60
           )
61
62
    create constraint(:verified_artifact_listings, :verified_artifact_listings_listing_digest,
63
             check: "listing_digest ~ '^[0-9a-f]{64}$'"
64
           )
65
66
    create constraint(:verified_artifact_listings, :verified_artifact_listings_license_window,
67
             check: "license_expires_at > license_effective_at"
68
           )
69
70
    create unique_index(
71
             :verified_artifact_listings,
72
             [:artifact_digest, :provenance_digest, :license_digest],
73
             name: :verified_artifact_listings_identity_index
74
           )
75
76
    create unique_index(:verified_artifact_listings, [:listing_digest])
77
    create unique_index(:verified_artifact_listings, [:publication_receipt_ref])
78
    create index(:verified_artifact_listings, [:state, :license_expires_at])
79
    create index(:verified_artifact_listings, [:artifact_type, :buyer_class])
80
81
    create table(:verified_artifact_receipts, primary_key: false) do
82
      add :id, :uuid, primary_key: true
83
84
      add :listing_id,
85
          references(:verified_artifact_listings, type: :uuid, on_delete: :restrict),
86
          null: false
87
88
      add :action, :text, null: false
89
      add :status, :text, null: false
90
      add :receipt_ref, :text, null: false
91
      add :predecessor_ref, :text
92
      add :external_ref, :text
93
      add :buyer_ref, :text, null: false
94
      add :buyer_class, :text, null: false
95
      add :artifact_digest, :text, null: false
96
      add :provenance_digest, :text, null: false
97
      add :license_digest, :text, null: false
98
      add :listing_digest, :text, null: false
99
      add :metadata, :map, null: false, default: fragment("'{}'::jsonb")
100
101
      timestamps(type: :utc_datetime_usec, updated_at: false)
102
    end
103
104
    create constraint(:verified_artifact_receipts, :verified_artifact_receipts_action,
105
             check:
106
               "action IN ('publication', 'offer', 'acceptance', 'delivery', 'verification', 'settlement', 'removal')"
107
           )
108
109
    create constraint(:verified_artifact_receipts, :verified_artifact_receipts_status,
110
             check: "status IN ('recorded', 'admitted', 'verified', 'settled', 'removed')"
111
           )
112
113
    create constraint(:verified_artifact_receipts, :verified_artifact_receipts_artifact_digest,
114
             check: "artifact_digest ~ '^[0-9a-f]{64}$'"
115
           )
116
117
    create constraint(:verified_artifact_receipts, :verified_artifact_receipts_provenance_digest,
118
             check: "provenance_digest ~ '^[0-9a-f]{64}$'"
119
           )
120
121
    create constraint(:verified_artifact_receipts, :verified_artifact_receipts_license_digest,
122
             check: "license_digest ~ '^[0-9a-f]{64}$'"
123
           )
124
125
    create constraint(:verified_artifact_receipts, :verified_artifact_receipts_listing_digest,
126
             check: "listing_digest ~ '^[0-9a-f]{64}$'"
127
           )
128
129
    create unique_index(:verified_artifact_receipts, [:receipt_ref])
130
    create index(:verified_artifact_receipts, [:listing_id, :inserted_at])
131
    create index(:verified_artifact_receipts, [:listing_id, :action])
132
  end
133
end
test/openagents/artifact_catalog_test.exs added +257

@@ -0,0 +1,257 @@

1
defmodule OpenAgents.ArtifactCatalogTest do
2
  use OpenAgents.DataCase, async: true
3
4
  import OpenAgents.ArtifactCatalogFixtures
5
6
  alias OpenAgents.ArtifactCatalog
7
  alias OpenAgents.ArtifactCatalog.Listing
8
  alias OpenAgents.ArtifactCatalog.Receipt
9
  alias OpenAgents.Repo
10
11
  test "publishes and discovers an exact safe projection" do
12
    attributes = listing_attributes()
13
    assert {:ok, listing} = ArtifactCatalog.publish_listing(attributes)
14
15
    assert [^listing] = ArtifactCatalog.list_public_listings()
16
    assert byte_size(listing.license_digest) == 64
17
    assert byte_size(listing.listing_digest) == 64
18
19
    projection = Listing.public_projection(listing)
20
    refute Map.has_key?(projection, "source_ref")
21
    refute inspect(projection) =~ attributes.source_ref
22
    assert projection["artifact_digest"] == attributes.artifact_digest
23
    assert projection["provenance"]["digest"] == attributes.provenance_digest
24
    assert projection["buyer"]["name"] == "OpenAgents continual-learning program"
25
    assert projection["verification_policy"]["policy_ref"] == "verification:artifact-v1"
26
  end
27
28
  test "searches compatible listings by text, type, buyer class, and digest" do
29
    matching =
30
      publish_listing!(%{
31
        artifact_type: "trace",
32
        owner_description: "Browser navigation trace",
33
        buyer_class: "openagents_evaluation"
34
      })
35
36
    _other = publish_listing!(%{owner_description: "Database query dataset"})
37
38
    assert [^matching] = ArtifactCatalog.list_public_listings(%{"q" => "navigation"})
39
    assert [^matching] = ArtifactCatalog.list_public_listings(%{"artifact_type" => "trace"})
40
41
    assert [^matching] =
42
             ArtifactCatalog.list_public_listings(%{"buyer_class" => "openagents_evaluation"})
43
44
    assert [^matching] =
45
             ArtifactCatalog.list_public_listings(%{
46
               "q" => binary_part(matching.artifact_digest, 0, 20)
47
             })
48
  end
49
50
  test "requires explicit opt-in and rejects private source metadata" do
51
    no_opt_in =
52
      listing_attributes(%{
53
        license_terms: %{"opt_in" => false, "allowed_uses" => ["evaluation"]}
54
      })
55
56
    assert {:error, changeset} = ArtifactCatalog.publish_listing(no_opt_in)
57
    assert "must record explicit opt-in" in errors_on(changeset).license_terms
58
59
    private_projection =
60
      listing_attributes(%{
61
        coverage: %{"domains" => ["tool selection"], "source_uri" => "s3://private/source"}
62
      })
63
64
    assert {:error, changeset} = ArtifactCatalog.publish_listing(private_projection)
65
    assert "contains private source metadata" in errors_on(changeset).coverage
66
  end
67
68
  test "binds a supplied listing identity to canonical license and listing digests" do
69
    assert {:error, changeset} =
70
             listing_attributes(%{license_digest: String.duplicate("f", 64)})
71
             |> ArtifactCatalog.publish_listing()
72
73
    assert "does not match the canonical identity" in errors_on(changeset).license_digest
74
75
    assert {:error, changeset} =
76
             listing_attributes(%{listing_digest: String.duplicate("e", 64)})
77
             |> ArtifactCatalog.publish_listing()
78
79
    assert "does not match the canonical identity" in errors_on(changeset).listing_digest
80
  end
81
82
  test "admits source access only after an accepted delivery or evaluation flow" do
83
    listing = publish_listing!()
84
85
    assert {:error, :not_authorized} =
86
             ArtifactCatalog.authorize_source_access(listing.id, %{
87
               purpose: "evaluation",
88
               buyer_ref: "buyer:openagents",
89
               acceptance_ref: "missing"
90
             })
91
92
    assert {:ok, offer} =
93
             ArtifactCatalog.record_transaction(
94
               listing.id,
95
               "offer",
96
               transaction_attributes(listing, listing.publication_receipt_ref)
97
             )
98
99
    assert {:ok, acceptance} =
100
             ArtifactCatalog.record_transaction(
101
               listing.id,
102
               "acceptance",
103
               transaction_attributes(listing, offer.receipt_ref)
104
             )
105
106
    assert {:error, :not_authorized} =
107
             ArtifactCatalog.authorize_source_access(listing.id, %{
108
               purpose: "evaluation",
109
               buyer_ref: "buyer:other",
110
               acceptance_ref: acceptance.receipt_ref
111
             })
112
113
    assert {:ok, authorization} =
114
             ArtifactCatalog.authorize_source_access(listing.id, %{
115
               purpose: "evaluation",
116
               buyer_ref: "buyer:openagents",
117
               acceptance_ref: acceptance.receipt_ref
118
             })
119
120
    assert authorization.source_ref == listing.source_ref
121
    assert authorization.artifact_digest == listing.artifact_digest
122
  end
123
124
  test "rejects delivery when any accepted digest changes" do
125
    listing = publish_listing!()
126
127
    assert {:ok, offer} =
128
             ArtifactCatalog.record_transaction(
129
               listing.id,
130
               "offer",
131
               transaction_attributes(listing, listing.publication_receipt_ref)
132
             )
133
134
    assert {:ok, acceptance} =
135
             ArtifactCatalog.record_transaction(
136
               listing.id,
137
               "acceptance",
138
               transaction_attributes(listing, offer.receipt_ref)
139
             )
140
141
    mismatched =
142
      transaction_attributes(listing, acceptance.receipt_ref, %{
143
        artifact_digest: String.duplicate("0", 64)
144
      })
145
146
    assert {:error, {:digest_mismatch, :artifact_digest}} =
147
             ArtifactCatalog.record_transaction(listing.id, "delivery", mismatched)
148
149
    refute Repo.get_by(Receipt, listing_id: listing.id, action: "delivery")
150
  end
151
152
  test "records the bounded transaction chain without settling funds" do
153
    listing = publish_listing!()
154
155
    receipts =
156
      Enum.reduce(
157
        ~w(offer acceptance delivery verification settlement),
158
        {listing.publication_receipt_ref, []},
159
        fn action, {predecessor_ref, receipts} ->
160
          assert {:ok, receipt} =
161
                   ArtifactCatalog.record_transaction(
162
                     listing.id,
163
                     action,
164
                     transaction_attributes(listing, predecessor_ref)
165
                   )
166
167
          {receipt.receipt_ref, [receipt | receipts]}
168
        end
169
      )
170
      |> elem(1)
171
      |> Enum.reverse()
172
173
    assert Enum.map(receipts, & &1.action) ==
174
             ~w(offer acceptance delivery verification settlement)
175
176
    settlement = List.last(receipts)
177
    assert settlement.status == "settled"
178
    refute Map.has_key?(settlement.metadata, "amount")
179
  end
180
181
  test "requires an external settlement receipt reference" do
182
    listing = publish_listing!()
183
184
    predecessor_ref =
185
      Enum.reduce(~w(offer acceptance delivery verification), listing.publication_receipt_ref, fn
186
        action, predecessor_ref ->
187
          assert {:ok, receipt} =
188
                   ArtifactCatalog.record_transaction(
189
                     listing.id,
190
                     action,
191
                     transaction_attributes(listing, predecessor_ref)
192
                   )
193
194
          receipt.receipt_ref
195
      end)
196
197
    assert {:error, :missing_settlement_reference} =
198
             ArtifactCatalog.record_transaction(
199
               listing.id,
200
               "settlement",
201
               transaction_attributes(listing, predecessor_ref, %{external_ref: nil})
202
             )
203
  end
204
205
  test "removal blocks discovery and new transactions while preserving receipts" do
206
    listing = publish_listing!()
207
208
    assert {:ok, offer} =
209
             ArtifactCatalog.record_transaction(
210
               listing.id,
211
               "offer",
212
               transaction_attributes(listing, listing.publication_receipt_ref)
213
             )
214
215
    assert {:ok, removed} =
216
             ArtifactCatalog.remove_listing(listing.id, %{
217
               reason: "Owner withdrew this artifact",
218
               receipt_ref: "artifact-removal:test",
219
               actor_ref: "operator:test"
220
             })
221
222
    assert removed.state == "removed"
223
    assert {:error, :not_found} = ArtifactCatalog.get_public_listing(listing.id)
224
    assert ArtifactCatalog.list_public_listings() == []
225
226
    assert {:error, :listing_removed} =
227
             ArtifactCatalog.record_transaction(
228
               listing.id,
229
               "acceptance",
230
               transaction_attributes(listing, offer.receipt_ref)
231
             )
232
233
    assert {:ok, history} = ArtifactCatalog.export_listing_history(listing.id)
234
    assert history["state"] == "removed"
235
236
    assert Enum.map(history["receipts"], & &1["action"]) ==
237
             ~w(publication offer removal)
238
  end
239
240
  test "a stale license blocks discovery and new transactions" do
241
    listing = publish_listing!()
242
243
    listing
244
    |> Ecto.Changeset.change(license_expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
245
    |> Repo.update!()
246
247
    assert {:error, :not_found} = ArtifactCatalog.get_public_listing(listing.id)
248
    assert ArtifactCatalog.list_public_listings() == []
249
250
    assert {:error, :stale_license} =
251
             ArtifactCatalog.record_transaction(
252
               listing.id,
253
               "offer",
254
               transaction_attributes(listing, listing.publication_receipt_ref)
255
             )
256
  end
257
end
test/openagents_web/controllers/artifact_listing_controller_test.exs added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgentsWeb.ArtifactListingControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  import OpenAgents.ArtifactCatalogFixtures
5
  import Phoenix.LiveViewTest
6
7
  alias OpenAgents.ArtifactCatalog
8
9
  test "catalog API requires an authenticated OpenAgents session", %{conn: conn} do
10
    conn = get(conn, ~p"/api/artifact-listings")
11
12
    assert json_response(conn, 401) == %{"error" => "authentication_required"}
13
  end
14
15
  test "catalog API lists, searches, shows, and exports redacted metadata", %{conn: conn} do
16
    listing = publish_listing!(%{owner_description: "Low-risk navigation traces"})
17
    _other = publish_listing!(%{owner_description: "SQL evaluation dataset"})
18
    conn = log_in_github_user(conn, "artifact-catalog-api")
19
20
    searched = get(conn, ~p"/api/artifact-listings?q=navigation")
21
    assert %{"listings" => [projection]} = json_response(searched, 200)
22
    assert projection["id"] == listing.id
23
    refute inspect(projection) =~ listing.source_ref
24
25
    shown = searched |> recycle() |> get(~p"/api/artifact-listings/#{listing.id}")
26
    assert json_response(shown, 200)["listing"]["listing_digest"] == listing.listing_digest
27
28
    exported =
29
      shown
30
      |> recycle()
31
      |> get(~p"/api/artifact-listings/#{listing.id}/export")
32
33
    assert json_response(exported, 200)["listing"]["artifact_digest"] == listing.artifact_digest
34
    assert [disposition] = get_resp_header(exported, "content-disposition")
35
    assert disposition =~ "attachment"
36
    refute inspect(json_response(exported, 200)) =~ listing.source_ref
37
  end
38
39
  test "catalog LiveView searches safe listings without rendering source references", %{
40
    conn: conn
41
  } do
42
    matching = publish_listing!(%{owner_description: "Browser navigation trace"})
43
    other = publish_listing!(%{owner_description: "Database query dataset"})
44
    conn = log_in_github_user(conn, "artifact-catalog-live")
45
46
    {:ok, view, html} = live(conn, ~p"/artifact-catalog")
47
    assert html =~ "Licensed artifacts"
48
    assert has_element?(view, "#artifact-listing-#{matching.id}")
49
    assert has_element?(view, "#artifact-listing-#{other.id}")
50
    refute html =~ matching.source_ref
51
52
    view
53
    |> form("#artifact-catalog-filter-form", filters: %{q: "Browser", artifact_type: ""})
54
    |> render_submit()
55
56
    assert_patch(view, ~p"/artifact-catalog?q=Browser")
57
    assert has_element?(view, "#artifact-listing-#{matching.id}")
58
    refute has_element?(view, "#artifact-listing-#{other.id}")
59
  end
60
61
  test "operator API refuses regular users and records operator publications", %{conn: conn} do
62
    user = github_user("artifact-catalog-regular-user")
63
    browser = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
64
65
    forbidden = post(browser, ~p"/api/operator/artifact-listings", listing_attributes())
66
    assert json_response(forbidden, 403) == %{"error" => "operator_required"}
67
68
    grant_operator(user)
69
70
    created =
71
      build_conn()
72
      |> Plug.Test.init_test_session(%{"user_id" => user.id})
73
      |> post(~p"/api/operator/artifact-listings", listing_attributes())
74
75
    assert %{"listing" => %{"id" => id, "publication_receipt_ref" => receipt_ref}} =
76
             json_response(created, 201)
77
78
    assert {:ok, listing} = ArtifactCatalog.get_public_listing(id)
79
    assert listing.publication_receipt_ref == receipt_ref
80
  end
81
end
test/support/fixtures/artifact_catalog_fixtures.ex added +95

@@ -0,0 +1,95 @@

1
defmodule OpenAgents.ArtifactCatalogFixtures do
2
  @moduledoc false
3
4
  alias OpenAgents.ArtifactCatalog
5
  alias OpenAgents.Provenance.Canonical
6
7
  def listing_attributes(overrides \\ %{}) do
8
    suffix = System.unique_integer([:positive, :monotonic])
9
    now = DateTime.utc_now()
10
11
    Map.merge(
12
      %{
13
        artifact_type: "dataset",
14
        owner_ref: "owner:openagents",
15
        owner_description: "Redacted support traces for tool-selection evaluation #{suffix}",
16
        source_ref: "vault://artifact-catalog/#{suffix}",
17
        artifact_digest: Canonical.sha256("artifact-#{suffix}"),
18
        provenance_digest: Canonical.sha256("provenance-#{suffix}"),
19
        provenance: %{
20
          "origin_class" => "consented support trace",
21
          "transformation_receipts" => ["redaction:#{suffix}"]
22
        },
23
        schema: %{
24
          "name" => "openagents.trace",
25
          "version" => "1",
26
          "format" => "jsonl"
27
        },
28
        size_bytes: 24_000,
29
        record_count: 120,
30
        coverage: %{
31
          "domains" => ["tool selection"],
32
          "languages" => ["en"],
33
          "record_types" => ["trace"]
34
        },
35
        redaction: %{
36
          "policy" => "support-trace-v1",
37
          "removed_fields" => ["account identifiers", "message source references"],
38
          "irreversible" => true
39
        },
40
        license_contract_ref: "license:opt-in:#{suffix}",
41
        license_terms: %{
42
          "opt_in" => true,
43
          "allowed_uses" => ["evaluation", "training"],
44
          "redistribution" => "prohibited"
45
        },
46
        license_effective_at: DateTime.add(now, -60, :second),
47
        license_expires_at: DateTime.add(now, 86_400, :second),
48
        price: %{
49
          "amount" => 25,
50
          "currency" => "USD",
51
          "unit" => "evaluation"
52
        },
53
        buyer_name: "OpenAgents continual-learning program",
54
        buyer_class: "openagents_training",
55
        verification_policy: %{
56
          "method" => "exact digest plus schema checks",
57
          "required_checks" => ["artifact_digest", "provenance_digest", "schema"],
58
          "policy_ref" => "verification:artifact-v1"
59
        },
60
        evidence_fresh_at: DateTime.add(now, -30, :second),
61
        publication_receipt_ref: "artifact-publication:#{suffix}"
62
      },
63
      overrides
64
    )
65
  end
66
67
  def publish_listing!(overrides \\ %{}) do
68
    {:ok, listing} =
69
      overrides
70
      |> listing_attributes()
71
      |> ArtifactCatalog.publish_listing()
72
73
    listing
74
  end
75
76
  def transaction_attributes(listing, predecessor_ref, overrides \\ %{}) do
77
    suffix = System.unique_integer([:positive, :monotonic])
78
79
    Map.merge(
80
      %{
81
        receipt_ref: "artifact-transaction:#{suffix}",
82
        predecessor_ref: predecessor_ref,
83
        external_ref: "external-evidence:#{suffix}",
84
        buyer_ref: "buyer:openagents",
85
        buyer_class: listing.buyer_class,
86
        artifact_digest: listing.artifact_digest,
87
        provenance_digest: listing.provenance_digest,
88
        license_digest: listing.license_digest,
89
        listing_digest: listing.listing_digest,
90
        metadata: %{"operator_receipt_ref" => "operator:#{suffix}"}
91
      },
92
      overrides
93
    )
94
  end
95
end

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