Apply artifact-link redaction to Changelog.timeline/2 and Changelog.projection/2 for #70

5b7915942687 · AtlantisPleb · · parent 53cc2908226a

Apply artifact-link redaction to Changelog.timeline/2 and Changelog.projection/2 for #70

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

  • modified lib/openagents/changelog.ex
  • modified lib/openagents/transparency.ex
  • added priv/repo/migrations/20260823152752_add_transparency_tier_and_artifact_link_to_changelog_entries.exs
  • modified test/openagents/changelog_test.exs

Diff

4 files changed, +227 -17

lib/openagents/changelog.ex modified +29 -17

@@ -19,6 +19,7 @@ defmodule OpenAgents.Changelog do

19 19
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt, PushReceipt, Visibility}
20 20
  alias OpenAgents.Repo
21 21
  alias OpenAgents.Transparency
22
  alias OpenAgents.Transparency.ArtifactLink
22 23
23 24
  @schema_version "openagents.changelog.v1"
24 25
  @cache_key {__MODULE__, :cache}

@@ -66,23 +67,6 @@ defmodule OpenAgents.Changelog do

66 67
    |> Repo.insert(on_conflict: :nothing, conflict_target: [:repo, :sha, :source])
67 68
  end
68 69
69
  @doc """
70
  Returns `entry` with `trace_ref`, `trace_digest`, and `detail` redacted
71
  according to the effective transparency tier for `viewer`.
72
73
  `viewer` is a tier atom/string, a map with a `:tier` field, or `nil`.
74
  """
75
  def redact_for_viewer(%Entry{} = entry, viewer) do
76
    tier = entry.transparency_tier
77
78
    %{
79
      entry
80
      | trace_ref: if(Transparency.allows?(tier, :metadata, viewer), do: entry.trace_ref, else: nil),
81
        trace_digest: if(Transparency.allows?(tier, :metadata, viewer), do: entry.trace_digest, else: nil),
82
        detail: if(Transparency.allows?(tier, :content, viewer), do: entry.detail, else: %{})
83
    }
84
  end
85
86 70
  # ── assembly ─────────────────────────────────────────────────────────────
87 71
88 72
  defp cached(repo) do

@@ -114,6 +98,30 @@ defmodule OpenAgents.Changelog do

114 98
    (authored ++ uncovered)
115 99
    |> Enum.sort_by(& &1.entry_at, {:desc, DateTime})
116 100
    |> Enum.take(@entry_limit)
101
    |> redact_rows()
102
  end
103
104
  defp redact_rows(rows) do
105
    link_ids =
106
      rows
107
      |> Enum.map(& &1[:artifact_link_id])
108
      |> Enum.reject(&is_nil/1)
109
      |> Enum.uniq()
110
111
    by_id =
112
      if link_ids == [] do
113
        %{}
114
      else
115
        ArtifactLink
116
        |> where([l], l.id in ^link_ids)
117
        |> Repo.all()
118
        |> Map.new(&{&1.id, &1})
119
      end
120
121
    Enum.map(rows, fn row ->
122
      link = row[:artifact_link_id] && Map.get(by_id, row[:artifact_link_id])
123
      Transparency.redact_for_viewer(row, link)
124
    end)
117 125
  end
118 126
119 127
  defp authored_entries(repo) do

@@ -163,6 +171,8 @@ defmodule OpenAgents.Changelog do

163 171
      source: entry.source,
164 172
      visibility: entry.visibility,
165 173
      entry_at: entry.entry_at,
174
      artifact_link_id: entry.artifact_link_id,
175
      transparency_tier: entry.transparency_tier,
166 176
      detail: if(embargoed, do: %{}, else: entry.detail || %{}),
167 177
      trace_ref: entry.trace_ref,
168 178
      trace_digest: entry.trace_digest,

@@ -191,6 +201,8 @@ defmodule OpenAgents.Changelog do

191 201
      source: "receipt",
192 202
      visibility: "l2",
193 203
      entry_at: deploy.inserted_at,
204
      artifact_link_id: nil,
205
      transparency_tier: nil,
194 206
      detail: %{},
195 207
      trace_ref: nil,
196 208
      trace_digest: nil,
lib/openagents/transparency.ex modified +23

@@ -56,6 +56,29 @@ defmodule OpenAgents.Transparency do

56 56
    @tier_rank[effective] >= @tier_rank[required]
57 57
  end
58 58
59
  @doc """
60
  Returns `data` with `trace_ref`, `trace_digest`, and `detail` redacted
61
  according to the effective transparency tier.
62
63
  `data` must be a map with `trace_ref`, `trace_digest`, and `detail`.
64
  `artifact_tier_or_link` is an `ArtifactLink`, a tier atom/string, or `nil`.
65
  `viewer` is a tier atom/string, a map with a `:tier` field, or `nil`.
66
  """
67
  def redact_for_viewer(
68
        %{trace_ref: _, trace_digest: _, detail: _} = data,
69
        artifact_tier_or_link,
70
        viewer \\ nil
71
      ) do
72
    tier = artifact_tier_or_link || Map.get(data, :transparency_tier)
73
74
    %{
75
      data
76
      | trace_ref: if(allows?(tier, :metadata, viewer), do: data.trace_ref, else: nil),
77
        trace_digest: if(allows?(tier, :metadata, viewer), do: data.trace_digest, else: nil),
78
        detail: if(allows?(tier, :content, viewer), do: data.detail, else: %{})
79
    }
80
  end
81
59 82
  @doc """
60 83
  Marks `artifact_link` as revoked by `revoked_by_id` for `reason`.
61 84
test/openagents/changelog_test.exs modified +165

@@ -12,6 +12,7 @@ defmodule OpenAgents.ChangelogTest do

12 12
  alias OpenAgents.Changelog
13 13
  alias OpenAgents.Changelog.{Backfill, Entry}
14 14
  alias OpenAgents.Forge.{DeployReceipt, PushReceipt}
15
  alias OpenAgents.Transparency.ArtifactLink
15 16
16 17
  setup do
17 18
    :persistent_term.erase({OpenAgents.Changelog, :cache})

@@ -67,6 +68,29 @@ defmodule OpenAgents.ChangelogTest do

67 68
    deploy
68 69
  end
69 70
71
  defp insert_artifact_link!(attrs) do
72
    user = repository_user_fixture("test-user")
73
    repository = repository_fixture(%{})
74
75
    {:ok, link} =
76
      %ArtifactLink{}
77
      |> ArtifactLink.changeset(
78
        Map.merge(
79
          %{
80
            account_id: user.id,
81
            repository_id: repository.id,
82
            artifact_type: "changelog",
83
            artifact_ref: "sha",
84
            tier: "dark"
85
          },
86
          attrs
87
        )
88
      )
89
      |> Repo.insert()
90
91
    link
92
  end
93
70 94
  describe "record/1" do
71 95
    test "validates category, source, and sha shape" do
72 96
      assert {:error, changeset} = Changelog.record(entry_attrs(%{category: "nonsense"}))

@@ -224,6 +248,147 @@ defmodule OpenAgents.ChangelogTest do

224 248
    end
225 249
  end
226 250
251
  describe "timeline/2 redaction" do
252
    test "a ledger tier exposes trace_ref, trace_digest, and detail" do
253
      override_visibility(%{"openagents.com" => :l2})
254
255
      {:ok, _} =
256
        Changelog.record(
257
          entry_attrs(%{
258
            sha: full_sha("feed0101"),
259
            summary: "Ledger trace",
260
            transparency_tier: "ledger",
261
            trace_ref: "trace:v1:ledger",
262
            trace_digest: "sha256:ledger",
263
            detail: %{"note" => "visible"}
264
          })
265
        )
266
267
      assert {:ok, [row]} = Changelog.timeline("openagents.com", refresh: true)
268
      assert row.trace_ref == "trace:v1:ledger"
269
      assert row.trace_digest == "sha256:ledger"
270
      assert row.detail == %{"note" => "visible"}
271
    end
272
273
    test "a pulse tier exposes metadata but hides detail" do
274
      override_visibility(%{"openagents.com" => :l2})
275
276
      {:ok, _} =
277
        Changelog.record(
278
          entry_attrs(%{
279
            sha: full_sha("feed0102"),
280
            summary: "Pulse trace",
281
            transparency_tier: "pulse",
282
            trace_ref: "trace:v1:pulse",
283
            trace_digest: "sha256:pulse",
284
            detail: %{"note" => "hidden"}
285
          })
286
        )
287
288
      assert {:ok, [row]} = Changelog.timeline("openagents.com", refresh: true)
289
      assert row.trace_ref == "trace:v1:pulse"
290
      assert row.trace_digest == "sha256:pulse"
291
      assert row.detail == %{}
292
    end
293
294
    test "a dark tier hides trace_ref, trace_digest, and detail" do
295
      override_visibility(%{"openagents.com" => :l2})
296
297
      {:ok, _} =
298
        Changelog.record(
299
          entry_attrs(%{
300
            sha: full_sha("feed0103"),
301
            summary: "Dark trace",
302
            transparency_tier: "dark",
303
            trace_ref: "trace:v1:dark",
304
            trace_digest: "sha256:dark",
305
            detail: %{"note" => "hidden"}
306
          })
307
        )
308
309
      assert {:ok, [row]} = Changelog.timeline("openagents.com", refresh: true)
310
      assert row.trace_ref == nil
311
      assert row.trace_digest == nil
312
      assert row.detail == %{}
313
    end
314
315
    test "an artifact_link overrides the entry transparency_tier" do
316
      override_visibility(%{"openagents.com" => :l2})
317
318
      link = insert_artifact_link!(%{tier: "pulse"})
319
320
      {:ok, _} =
321
        Changelog.record(
322
          entry_attrs(%{
323
            sha: full_sha("feed0104"),
324
            summary: "Link-tier trace",
325
            transparency_tier: "ledger",
326
            artifact_link_id: link.id,
327
            trace_ref: "trace:v1:link",
328
            trace_digest: "sha256:link",
329
            detail: %{"note" => "hidden"}
330
          })
331
        )
332
333
      assert {:ok, [row]} = Changelog.timeline("openagents.com", refresh: true)
334
      assert row.trace_ref == "trace:v1:link"
335
      assert row.trace_digest == "sha256:link"
336
      assert row.detail == %{}
337
    end
338
339
    test "a revoked artifact_link hides all trace and detail" do
340
      override_visibility(%{"openagents.com" => :l2})
341
342
      link = insert_artifact_link!(%{tier: "glass"})
343
344
      link
345
      |> OpenAgents.Transparency.revoke("test", Ecto.UUID.generate())
346
      |> Repo.update!()
347
348
      {:ok, _} =
349
        Changelog.record(
350
          entry_attrs(%{
351
            sha: full_sha("feed0105"),
352
            summary: "Revoked trace",
353
            artifact_link_id: link.id,
354
            trace_ref: "trace:v1:revoked",
355
            trace_digest: "sha256:revoked",
356
            detail: %{"note" => "hidden"}
357
          })
358
        )
359
360
      assert {:ok, [row]} = Changelog.timeline("openagents.com", refresh: true)
361
      assert row.trace_ref == nil
362
      assert row.trace_digest == nil
363
      assert row.detail == %{}
364
    end
365
  end
366
367
  describe "projection/2 redaction" do
368
    test "the API payload reflects redacted trace and detail" do
369
      override_visibility(%{"openagents.com" => :l2})
370
371
      {:ok, _} =
372
        Changelog.record(
373
          entry_attrs(%{
374
            sha: full_sha("feed0106"),
375
            summary: "Projected redaction",
376
            transparency_tier: "pulse",
377
            trace_ref: "trace:v1:projected",
378
            trace_digest: "sha256:projected",
379
            detail: %{"note" => "hidden"}
380
          })
381
        )
382
383
      assert {:ok, payload} = Changelog.projection("openagents.com", refresh: true)
384
385
      assert [entry] = payload["entries"]
386
      assert entry["trace_ref"] == "trace:v1:projected"
387
      assert entry["trace_digest"] == "sha256:projected"
388
      assert entry["detail"] == %{}
389
    end
390
  end
391
227 392
  describe "Backfill.run/0" do
228 393
    test "seeds what it can prove and is idempotent on re-run" do
229 394
      Backfill.run()

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