Give the forge build and deploy receipts a repository foreign key

6a5cf7db4583 · AtlantisPleb · · parent 57b367f52239

Give the forge build and deploy receipts a repository foreign key

`forge_builds.repo` and `forge_deploys.repo` hold `Target.repo`, which
`OpenAgents.Forge.Targets.validate_deployable_repo/1` constrains to a member of
`:forge_repos` or to `owner/name` — a repository *name*, not a key.
`repositories` is unique on `{namespace_id, name_key}` rather than on `name`,
so `alice/demo` and `bob/demo` both produce the string `demo`. #148 needed
`{repo, sha}` to bind receipts to issues, found the ambiguity, and refused:
`OpenAgents.Issues.Evidence` recorded no evidence at all when two repositories
answered to a name. Safe, and lossy — the receipt gave its issue nothing.

Both tables now carry a nullable `repository_id`. A receipt written after this
change names its repository, and `Evidence`, `Forge.receipts_for/2`, and
`Changelog` read that key. The string survives as the fallback for the rows the
backfill could not settle, and it is read only for those rows, so a shared name
can no longer pull one repository's receipts into another's answer. The refusal
survives where it still applies: an unsettled name resolves to nothing, and
nothing resolves to no evidence rather than to a guess.

`forge_pushes` is deliberately untouched, and establishing that was the first
half of the work. Its `repo` is `Repository.storage_key`, which carries a
unique index, so a push receipt already names exactly one repository — a key
there would buy no disambiguation. It would cost something: `EXIT-003` requires
every `forge_pushes` column to be re-derivable from the WAL by
`reconcile_receipts/1`, and a key only PostgreSQL can produce would make the
database a second opinion about a record `EXIT-002` and `EXIT-005` say the WAL
alone decides. The absence is the invariant holding, not an omission. The three
columns were never one identifier space, which is why
`Pushes.receipt_repo_keys/1` had to exist.

The backfill resolves a name only when exactly one repository answers to it,
across the three forms a receipt string is written in: a storage key, a bare
allowlist name, and an `owner/name` path. A row it cannot settle keeps a null
key and stays readable by its string. A null means "not settled", never "no
repository", and no row is attached to a guess. Both local databases hold zero
build and deploy receipts, so nothing resolved and nothing was left null here;
the statement is proven by test rather than by what this machine happened to
have. It lives in `ReceiptRepository.backfill!/1` rather than inline in the
migration so its rule is tested instead of asserted, and it does not suspend
`forge_deploy_receipts_immutable` itself — the migration does that for the
length of its own transaction, so a backfill cannot quietly acquire the
authority to rewrite a receipt.

ISSUE-003 records what a receipt now names and what a null key means. EXIT-003
records why this table did not get one. The storage audit answers the question

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <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.

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 INVARIANTS.md
  • modified docs/2026-08-21-repository-storage-architecture-audit.md
  • modified docs/2026-08-23-issue-work-receipt-linkage-design.md
  • modified lib/openagents/changelog.ex
  • modified lib/openagents/forge.ex
  • modified lib/openagents/forge/build_receipt.ex
  • modified lib/openagents/forge/builder.ex
  • modified lib/openagents/forge/deploy_receipt.ex
  • modified lib/openagents/forge/hot_loader.ex
  • added lib/openagents/forge/receipt_repository.ex
  • modified lib/openagents/forge/targets.ex
  • modified lib/openagents/issues/evidence.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824031651_add_repository_key_to_forge_build_and_deploy_receipts.exs
  • modified test/openagents/changelog_test.exs
  • modified test/openagents/forge/builder_test.exs
  • modified test/openagents/forge/hot_loader_test.exs
  • added test/openagents/forge/receipt_repository_test.exs
  • modified test/openagents/forge/target_lifecycle_test.exs
  • modified test/openagents/issues/evidence_test.exs

Diff

20 files changed, +727 -57

INVARIANTS.md modified +33 -1

@@ -3609,6 +3609,14 @@ storage alone. A row written before the column existed carries no link and is

3609 3609
not repaired in place, because a link the operator writes over their own store
3610 3610
is not evidence.
3611 3611
3612
The direction is also what keeps a column *out* of this table. `ISSUE-003` gave
3613
`forge_builds` and `forge_deploys` a `repository_id` because their `repo` string
3614
is a repository name two repositories can share. `forge_pushes.repo` is a
3615
storage key, which is unique, so it already names exactly one repository — and a
3616
key only PostgreSQL could produce would not survive `reconcile_receipts/1`,
3617
which rebuilds this table from the WAL alone. The absence is the invariant
3618
holding, not an omission.
3619
3612 3620
Two operational facts bound the claim. `:forge_mirror_urls` is empty in
3613 3621
`config/config.exs` and set by no environment, so no mirror runs today and
3614 3622
GitHub holds whatever was last pushed to it directly, which is the trade

@@ -4044,6 +4052,28 @@ deployment, a cancelled attempt, and a superseded run each keep their edge with

4044 4052
the receipt's own terminal word in `result`. An issue's history is what
4045 4053
happened, not what worked.
4046 4054
4055
**A receipt names its repository, and a name it cannot settle stays unsettled.**
4056
`forge_builds.repo` and `forge_deploys.repo` hold `Target.repo`, a repository
4057
*name* or an `owner/name` path, and `repositories` is unique on
4058
`{namespace_id, name_key}` rather than on `name` — so one string can answer for
4059
two repositories, and #148 recorded no evidence at all when it did. Both tables
4060
carry `repository_id` now (#181): every receipt written since names its
4061
repository, and the evidence chain reads that key rather than a name. The
4062
string survives as the fallback for the rows the backfill could not settle, and
4063
it is read only for those rows, so a shared name can no longer pull one
4064
repository's receipts into another's answer. The refusal survives where it
4065
still applies: an unsettled name resolves to nothing, and nothing resolves to
4066
no evidence rather than to a guess. A null key means "not settled", never "no
4067
repository".
4068
4069
`forge_pushes` deliberately has no such key. Its `repo` is
4070
`Repository.storage_key`, which carries a unique index, so a push receipt
4071
already names exactly one repository — and `EXIT-003` requires every column
4072
there to be re-derivable from the WAL by
4073
`OpenAgents.Forge.Pushes.reconcile_receipts/1`. A key only PostgreSQL can
4074
produce would make the database a second opinion about a record the WAL alone
4075
decides, which is the thing `EXIT-002` and `EXIT-005` exist to prevent.
4076
4047 4077
**The two deployment planes stay distinct.** An issue in this repository is
4048 4078
evidenced by `forge_deploys` on the `forge` plane, whose one environment is the
4049 4079
fleet. An issue in a tenant repository is evidenced by `deployment_runs` on the

@@ -4063,7 +4093,9 @@ that produced it. Missing evidence is a smaller failure than a lost receipt.

4063 4093
4064 4094
Evidence: `OpenAgents.Issues.Evidence`, `OpenAgents.Issues.EvidenceEntry`,
4065 4095
`OpenAgents.Issues.ClosingReferences`, `OpenAgents.Forge.Assignments`,
4066
`test/openagents/issues/evidence_test.exs`, and
4096
`OpenAgents.Forge.ReceiptRepository`,
4097
`test/openagents/issues/evidence_test.exs`,
4098
`test/openagents/forge/receipt_repository_test.exs`, and
4067 4099
`test/openagents_web/controllers/issue_controller_test.exs`.
4068 4100
4069 4101
### ISSUE-004 — Agent work on an issue starts through one admission
docs/2026-08-21-repository-storage-architecture-audit.md modified +4

@@ -201,6 +201,10 @@ None of that is a storage difference, and none of it should grow into one.

201 201
202 202
**The mirror stays separate.** It is an export with no read path, and folding it into the store would only couple the WAL to a third party's availability. Separate does not mean unguarded: because it force-pushes and cannot detect a mirror that is ahead (section 3.1), the boundary needs a lease, not just a direction.
203 203
204
**Two repositories can produce the same receipt `repo` string, and which string decides depends on the table.** `#181` asked this question of this audit, so here is the answer. `forge_pushes.repo` is written from `Repository.storage_key` on both paths that reach it — `GitHTTP.receive_pack/3` (`lib/openagents/forge/git_http.ex:109`) and `Pushes.reconcile_receipts/1` (`lib/openagents/forge/janitor.ex:122`) — and `storage_key` carries a unique index, so a push receipt names exactly one repository and always will. `forge_builds.repo` and `forge_deploys.repo` are written from `Target.repo`, which `Targets.validate_deployable_repo/1` (`lib/openagents/forge/targets.ex:945`) constrains to a member of `:forge_repos` *or* to `"#{owner}/#{allowed}"` — a repository **name**, or an `owner/name` path. Repository names are unique only within a namespace (`unique_index(:repositories, [:namespace_id, :name_key])`), so `alice/demo` and `bob/demo` are both legal and both produce the string `demo`. That collision is real today for every repository except the grandfathered one, whose name and storage key happen to be the same literal.
205
206
The consequence is that the three `repo` columns are not one identifier space, and `Pushes.receipt_repo_keys/1` exists to bridge them. `#181` settled it by giving `forge_builds` and `forge_deploys` a nullable `repository_id`, backfilled where a name resolved to exactly one repository, and by leaving `forge_pushes` alone for the reason `EXIT-003` gives: every column there must be re-derivable from the WAL, and a key only PostgreSQL can produce would make the database a second opinion about a push.
207
204 208
---
205 209
206 210
## 3. Failure modes, ranked
docs/2026-08-23-issue-work-receipt-linkage-design.md modified +14 -10

@@ -653,16 +653,20 @@ to a reader who can already read the issue, and to no one else.

653 653
  `forge_builds` and `forge_deploys`. A commit's age therefore never changes
654 654
  the answer. Evidence written before this change does not exist, so there is
655 655
  no backfill to size; the earliest edge is the first receipt after deployment.
656
- **Should the receipt tables gain a repository foreign key? Still open, and
657
  stage 4 refuses rather than guesses.** `forge_pushes`, `forge_builds`, and
658
  `forge_deploys` all key on `repo` as a string. `Evidence` resolves that
659
  string through `Pushes.receipt_repo_keys/1`
660
  (`lib/openagents/forge/pushes.ex:295`) and records an edge only when exactly
661
  one repository answers to the name; two candidates record nothing rather than
662
  attaching a receipt to the wrong issue. That is safe but lossy, and a
663
  repository foreign key on the three receipt tables would remove the case
664
  entirely. `#181` carries that work; settle the name question first with
665
  `docs/2026-08-21-repository-storage-architecture-audit.md`.
656
- **Should the receipt tables gain a repository foreign key? Settled by `#181`,
657
  and the answer is two tables, not three.** The three `repo` columns do not
658
  hold the same kind of value. `forge_pushes.repo` is `Repository.storage_key`,
659
  which carries a unique index, so a push receipt already names exactly one
660
  repository; it gained no key, because `EXIT-003` requires every column there
661
  to be re-derivable from the WAL and a key only PostgreSQL can produce would
662
  make the database a second opinion. `forge_builds.repo` and
663
  `forge_deploys.repo` hold `Target.repo` — a repository name, or an
664
  `owner/name` path — and `repositories` is unique on `{namespace_id,
665
  name_key}` rather than on `name`, so one string can answer for two
666
  repositories. Both gained a nullable `repository_id`, backfilled where the
667
  name settled to exactly one repository. Stage 4's refusal survives where it
668
  still applies: an unsettled name resolves to nothing, and nothing resolves to
669
  no evidence rather than to a guess.
666 670
- **Does the tenant deployment plane or the forge deployment plane own an
667 671
  issue's deployment evidence? Settled: both, and the row says which.** Stage 4
668 672
  records `plane` on every edge — `forge` for a `forge_deploys` receipt, whose
lib/openagents/changelog.ex modified +9 -3

@@ -16,7 +16,7 @@ defmodule OpenAgents.Changelog do

16 16
  import Ecto.Query
17 17
18 18
  alias OpenAgents.Changelog.Entry
19
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt, PushReceipt, Visibility}
19
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt, PushReceipt, ReceiptRepository, Visibility}
20 20
  alias OpenAgents.Repo
21 21
  alias OpenAgents.Transparency
22 22
  alias OpenAgents.Transparency.ArtifactLink

@@ -212,7 +212,13 @@ defmodule OpenAgents.Changelog do

212 212
    |> Repo.all()
213 213
  end
214 214
215
  # `forge_pushes.repo` is `Repository.storage_key`, which is unique, so a push
216
  # receipt is already keyed. Build and deploy receipts hold a repository name,
217
  # so they match on `repository_id` where they have one and on the name only
218
  # where the #181 backfill could not settle them.
215 219
  defp receipt_index(repo) do
220
    repository = ReceiptRepository.resolve(repo)
221
216 222
    %{
217 223
      pushes:
218 224
        PushReceipt

@@ -222,13 +228,13 @@ defmodule OpenAgents.Changelog do

222 228
        |> Repo.all(),
223 229
      builds:
224 230
        BuildReceipt
225
        |> where([r], r.repo == ^repo)
231
        |> ReceiptRepository.scope(repository, [repo])
226 232
        |> order_by([r], desc: r.inserted_at)
227 233
        |> limit(@receipt_scan)
228 234
        |> Repo.all(),
229 235
      deploys:
230 236
        DeployReceipt
231
        |> where([r], r.repo == ^repo)
237
        |> ReceiptRepository.scope(repository, [repo])
232 238
        |> order_by([r], desc: r.inserted_at)
233 239
        |> limit(@receipt_scan)
234 240
        |> Repo.all()
lib/openagents/forge.ex modified +14 -5

@@ -15,7 +15,7 @@ defmodule OpenAgents.Forge do

15 15
16 16
  import Ecto.Query
17 17
18
  alias OpenAgents.Forge.{Pushes, PushReceipt}
18
  alias OpenAgents.Forge.{Pushes, PushReceipt, ReceiptRepository}
19 19
  alias OpenAgents.Repo
20 20
21 21
  @doc "Whether the forge endpoint is enabled (default true; env-gated in prod)."

@@ -34,19 +34,28 @@ defmodule OpenAgents.Forge do

34 34
    |> Repo.all()
35 35
  end
36 36
37
  @doc "Recent deploy receipts for one repo, newest first, bounded."
37
  @doc """
38
  Recent deploy receipts for one repo, newest first, bounded.
39
40
  A receipt that names its repository is matched by that key; `repo` is read
41
  only for a row the #181 backfill could not settle.
42
  """
38 43
  def recent_deploys(repo, limit \\ 20) do
39 44
    OpenAgents.Forge.DeployReceipt
40
    |> where([d], d.repo == ^repo)
45
    |> ReceiptRepository.scope(ReceiptRepository.resolve(repo), [repo])
41 46
    |> order_by([d], desc: d.inserted_at)
42 47
    |> limit(^limit)
43 48
    |> Repo.all()
44 49
  end
45 50
46
  @doc "Recent build receipts for one repo, newest first, bounded."
51
  @doc """
52
  Recent build receipts for one repo, newest first, bounded.
53
54
  Keyed the same way `recent_deploys/2` is.
55
  """
47 56
  def recent_builds(repo, limit \\ 20) do
48 57
    OpenAgents.Forge.BuildReceipt
49
    |> where([b], b.repo == ^repo)
58
    |> ReceiptRepository.scope(ReceiptRepository.resolve(repo), [repo])
50 59
    |> order_by([b], desc: b.inserted_at)
51 60
    |> limit(^limit)
52 61
    |> Repo.all()
lib/openagents/forge/build_receipt.ex modified +4 -1

@@ -15,6 +15,7 @@ defmodule OpenAgents.Forge.BuildReceipt do

15 15
16 16
  schema "forge_builds" do
17 17
    field :repo, :string
18
    field :repository_id, :binary_id
18 19
    field :sha, :string
19 20
    field :target_id, :binary_id
20 21
    field :status, :string, default: "running"

@@ -36,10 +37,11 @@ defmodule OpenAgents.Forge.BuildReceipt do

36 37
  @doc "Create the durable `running` row before handing work to the sidecar."
37 38
  def start_changeset(receipt, attrs) do
38 39
    receipt
39
    |> cast(attrs, [:repo, :sha, :target_id, :baseline_manifest])
40
    |> cast(attrs, [:repo, :repository_id, :sha, :target_id, :baseline_manifest])
40 41
    |> put_change(:status, "running")
41 42
    |> validate_required([:repo, :sha, :target_id, :status])
42 43
    |> validate_format(:sha, ~r/^[0-9a-f]{40}$/)
44
    |> foreign_key_constraint(:repository_id)
43 45
    |> unique_constraint(:target_id, name: :forge_builds_one_running_attempt_per_target)
44 46
  end
45 47

@@ -83,6 +85,7 @@ defmodule OpenAgents.Forge.BuildReceipt do

83 85
    receipt
84 86
    |> cast(attrs, [
85 87
      :repo,
88
      :repository_id,
86 89
      :sha,
87 90
      :target_id,
88 91
      :status,
lib/openagents/forge/builder.ex modified +4

@@ -18,6 +18,7 @@ defmodule OpenAgents.Forge.Builder do

18 18
  alias OpenAgents.Forge.BuildArtifact
19 19
  alias OpenAgents.Forge.BuildExecutor
20 20
  alias OpenAgents.Forge.BuildProtocol
21
  alias OpenAgents.Forge.ReceiptRepository
21 22
  alias OpenAgents.Forge.BuildReceipt
22 23
  alias OpenAgents.Forge.Repos
23 24
  alias OpenAgents.Forge.Target

@@ -95,6 +96,9 @@ defmodule OpenAgents.Forge.Builder do

95 96
      %BuildReceipt{id: build_id}
96 97
      |> BuildReceipt.start_changeset(%{
97 98
        repo: target.repo,
99
        # Resolved once, here, where an unsettled name records nothing rather
100
        # than a guess. Every read afterwards uses the key.
101
        repository_id: ReceiptRepository.resolve_id(target.repo),
98 102
        sha: target.sha,
99 103
        target_id: target.id,
100 104
        baseline_manifest: baseline_manifest
lib/openagents/forge/deploy_receipt.ex modified +3

@@ -17,6 +17,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

17 17
18 18
  schema "forge_deploys" do
19 19
    field :repo, :string
20
    field :repository_id, :binary_id
20 21
    field :sha, :string
21 22
    field :target_id, :binary_id
22 23
    field :deployment_id, :binary_id

@@ -55,6 +56,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

55 56
    receipt
56 57
    |> cast(attrs, [
57 58
      :repo,
59
      :repository_id,
58 60
      :sha,
59 61
      :target_id,
60 62
      :deployment_id,

@@ -93,6 +95,7 @@ defmodule OpenAgents.Forge.DeployReceipt do

93 95
    |> validate_length(:canary, max: 255)
94 96
    |> validate_length(:error_code, max: 128)
95 97
    |> validate_node_results()
98
    |> foreign_key_constraint(:repository_id)
96 99
    |> unique_constraint(:deployment_id)
97 100
    |> check_constraint(:result, name: :forge_deploys_result)
98 101
    |> check_constraint(:deployment_type, name: :forge_deploys_deployment_type)
lib/openagents/forge/hot_loader.ex modified +2

@@ -15,6 +15,7 @@ defmodule OpenAgents.Forge.HotLoader do

15 15
  alias OpenAgents.Forge.BuildArtifact
16 16
  alias OpenAgents.Forge.Deployment
17 17
  alias OpenAgents.Forge.DeploymentLane
18
  alias OpenAgents.Forge.ReceiptRepository
18 19
  alias OpenAgents.Forge.DeployReceipt
19 20
  alias OpenAgents.Forge.{Pushes, PushReceipt}
20 21
  alias OpenAgents.Forge.Targets

@@ -342,6 +343,7 @@ defmodule OpenAgents.Forge.HotLoader do

342 343
    %DeployReceipt{}
343 344
    |> DeployReceipt.changeset(%{
344 345
      repo: repo,
346
      repository_id: ReceiptRepository.resolve_id(repo),
345 347
      sha: sha,
346 348
      target_id: target_id,
347 349
      modules: modules,
lib/openagents/forge/receipt_repository.ex added +125

@@ -0,0 +1,125 @@

1
defmodule OpenAgents.Forge.ReceiptRepository do
2
  @moduledoc """
3
  Which repository a forge receipt belongs to.
4
5
  `forge_builds.repo` and `forge_deploys.repo` hold `Target.repo`, which
6
  `OpenAgents.Forge.Targets` constrains to a member of `:forge_repos` — a
7
  repository *name*, or `owner/name`. `repositories` is unique on
8
  `{namespace_id, name_key}` rather than on `name`, so a name can answer for
9
  two repositories and a receipt keyed only by that name names neither.
10
11
  `forge_pushes.repo` is a different value with a different property: it is
12
  `Repository.storage_key`, which carries a unique index, so a push receipt
13
  already names exactly one repository. It has no `repository_id` and does not
14
  need one — see `EXIT-003`, which requires every `forge_pushes` column to be
15
  re-derivable from the WAL.
16
17
  Two operations, and they are deliberately asymmetric:
18
19
    * `resolve/1` runs once, at receipt time, and turns a string into a
20
      repository or into nothing. A name two repositories answer to resolves to
21
      nothing, so the receipt records a null key rather than a guess.
22
    * `scope/3` runs at read time and does not consult a string at all for a
23
      receipt that carries a key. The string stays as the fallback for the rows
24
      the backfill could not settle, and it is read only for those rows, so a
25
      shared name can no longer pull one repository's receipts into another's
26
      answer.
27
  """
28
29
  import Ecto.Query
30
31
  alias OpenAgents.Repo
32
  alias OpenAgents.Repositories.Repository
33
34
  @doc """
35
  The one repository `repo` names, or `nil`.
36
37
  Zero candidates and two candidates are the same answer on purpose: attaching
38
  a receipt to the wrong repository is worse than attaching it to none.
39
  """
40
  @spec resolve(term()) :: Repository.t() | nil
41
  def resolve(repo) when is_binary(repo) do
42
    Repository
43
    |> where(
44
      [repository],
45
      repository.storage_key == ^repo or repository.name == ^repo or
46
        fragment("? || '/' || ?", repository.owner, repository.name) == ^repo
47
    )
48
    |> limit(2)
49
    |> Repo.all()
50
    |> case do
51
      [%Repository{} = repository] -> repository
52
      _ambiguous_or_absent -> nil
53
    end
54
  end
55
56
  def resolve(_repo), do: nil
57
58
  @doc "The id of the one repository `repo` names, or `nil`."
59
  @spec resolve_id(term()) :: Ecto.UUID.t() | nil
60
  def resolve_id(repo) do
61
    case resolve(repo) do
62
      %Repository{id: id} -> id
63
      nil -> nil
64
    end
65
  end
66
67
  @doc """
68
  Narrows a `forge_builds` or `forge_deploys` query to one repository.
69
70
  With a repository in hand, a receipt matches on its key, and the string is
71
  consulted only for a receipt that has no key. Without one — a name that
72
  settles to nothing — the string is all there is, which is the same answer
73
  this surface gave before the key existed.
74
  """
75
  @spec scope(Ecto.Queryable.t(), Repository.t() | nil, [String.t()]) :: Ecto.Query.t()
76
  def scope(query, nil, repo_keys) do
77
    from receipt in query, where: receipt.repo in ^repo_keys
78
  end
79
80
  def scope(query, %Repository{id: repository_id}, repo_keys) do
81
    from receipt in query,
82
      where:
83
        receipt.repository_id == ^repository_id or
84
          (is_nil(receipt.repository_id) and receipt.repo in ^repo_keys)
85
  end
86
87
  @doc """
88
  Fills `repository_id` for the rows of `table` whose `repo` string settles.
89
90
  The migration that added the column runs exactly this statement, and it lives
91
  here rather than inside the migration so the rule is proven by a test instead
92
  of asserted by a comment. It is idempotent: a row that already carries a key
93
  is left alone, and a row whose name answers for two repositories, or for
94
  none, is left null. A null means "not settled", never "no repository".
95
96
  `forge_deploys` carries the `forge_deploy_receipts_immutable` trigger, which
97
  refuses every `UPDATE`. The caller suspends it; this function does not, so a
98
  backfill cannot quietly acquire the authority to rewrite a receipt.
99
  """
100
  @spec backfill!(String.t()) :: non_neg_integer()
101
  def backfill!(table) when table in ~w(forge_builds forge_deploys) do
102
    %Postgrex.Result{num_rows: filled} =
103
      Repo.query!("""
104
      WITH candidate AS (
105
        SELECT
106
          receipt.id AS receipt_id,
107
          repository.id AS repository_id,
108
          count(*) OVER (PARTITION BY receipt.id) AS matches
109
        FROM #{table} AS receipt
110
        JOIN repositories AS repository
111
          ON repository.storage_key = receipt.repo
112
          OR repository.name = receipt.repo
113
          OR repository.owner || '/' || repository.name = receipt.repo
114
      )
115
      UPDATE #{table} AS receipt
116
      SET repository_id = candidate.repository_id
117
      FROM candidate
118
      WHERE candidate.receipt_id = receipt.id
119
        AND candidate.matches = 1
120
        AND receipt.repository_id IS NULL
121
      """)
122
123
    filled
124
  end
125
end
lib/openagents/forge/targets.ex modified +4

@@ -18,6 +18,7 @@ defmodule OpenAgents.Forge.Targets do

18 18
  alias OpenAgents.Analytics
19 19
  alias OpenAgents.Forge.BuildReceipt
20 20
  alias OpenAgents.Forge.DeployReceipt
21
  alias OpenAgents.Forge.ReceiptRepository
21 22
  alias OpenAgents.Forge.{Pushes, Target}
22 23
  alias OpenAgents.Repo
23 24

@@ -234,6 +235,7 @@ defmodule OpenAgents.Forge.Targets do

234 235
            receipt_attrs
235 236
            |> Map.put(:target_id, target.id)
236 237
            |> Map.put(:repo, target.repo)
238
            |> Map.put(:repository_id, ReceiptRepository.resolve_id(target.repo))
237 239
            |> Map.put(:sha, target.sha)
238 240
            |> Map.put(:result, status)
239 241

@@ -340,6 +342,7 @@ defmodule OpenAgents.Forge.Targets do

340 342
              end),
341 343
            push_to_live_ms: relup.duration_ms,
342 344
            repo: target.repo,
345
            repository_id: build.repository_id,
343 346
            result: relup.status,
344 347
            rollback_verified:
345 348
              relup.status == "failed" and

@@ -561,6 +564,7 @@ defmodule OpenAgents.Forge.Targets do

561 564
            node_results: rolling.node_results,
562 565
            nodes: Enum.map(rolling.expected_nodes, &"#{&1}=#{rolling.node_results[&1]}"),
563 566
            repo: target.repo,
567
            repository_id: build.repository_id,
564 568
            result: rolling.status,
565 569
            rollback_verified:
566 570
              rolling.status == "failed" and rolling.recovery == "last_known_good_restored",
lib/openagents/issues/evidence.ex modified +32 -32

@@ -68,6 +68,7 @@ defmodule OpenAgents.Issues.Evidence do

68 68
  alias OpenAgents.Deployments.Request, as: DeploymentRequest
69 69
  alias OpenAgents.Deployments.Run, as: DeploymentRun
70 70
  alias OpenAgents.Forge.{Assignment, BuildReceipt, DeployReceipt, PushReceipt}
71
  alias OpenAgents.Forge.ReceiptRepository
71 72
  alias OpenAgents.Issues.{ClosingReference, EvidenceEntry, Issue}
72 73
  alias OpenAgents.Repo
73 74
  alias OpenAgents.Repositories.Repository

@@ -309,7 +310,7 @@ defmodule OpenAgents.Issues.Evidence do

309 310
  defp receipt_facts("build", id) do
310 311
    case Repo.get(BuildReceipt, id) do
311 312
      %BuildReceipt{} = receipt ->
312
        with %Repository{} = repository <- repository_for(receipt.repo) do
313
        with %Repository{} = repository <- repository_for(receipt) do
313 314
          {:ok,
314 315
           %{
315 316
             repository: repository,

@@ -362,7 +363,7 @@ defmodule OpenAgents.Issues.Evidence do

362 363
  end
363 364
364 365
  defp forge_deployment_facts(%DeployReceipt{} = receipt) do
365
    case repository_for(receipt.repo) do
366
    case repository_for(receipt) do
366 367
      %Repository{} = repository ->
367 368
        {:ok,
368 369
         %{

@@ -513,47 +514,46 @@ defmodule OpenAgents.Issues.Evidence do

513 514
    )
514 515
  end
515 516
516
  # `forge_builds` and `forge_deploys` key on `repo` as a string rather than a
517
  # repository foreign key, so this join inherits that ambiguity. It is resolved
518
  # only when exactly one repository answers to the name; two candidates record
519
  # nothing rather than guessing which issue the receipt belongs to.
520
  defp repository_for(repo) when is_binary(repo) do
521
    keys = OpenAgents.Forge.Pushes.receipt_repo_keys(repo)
517
  # A receipt written since #181 carries `repository_id`, so the evidence chain
518
  # no longer depends on a name resolving to exactly one repository. The string
519
  # is read only for a row the backfill could not settle, and there it keeps the
520
  # old refusal: two candidates record nothing rather than guessing which issue
521
  # the receipt belongs to.
522
  defp repository_for(%{repository_id: repository_id}) when is_binary(repository_id) do
523
    Repo.get(Repository, repository_id)
524
  end
522 525
523
    Repository
524
    |> where([repository], repository.storage_key in ^keys or repository.name in ^keys)
525
    |> limit(2)
526
    |> Repo.all()
527
    |> case do
528
      [%Repository{} = repository] -> repository
529
      _ambiguous_or_absent -> nil
530
    end
526
  defp repository_for(%{repo: repo}), do: repository_by_name(repo)
527
528
  defp repository_by_name(repo) when is_binary(repo) do
529
    ReceiptRepository.resolve(repo)
531 530
  end
532 531
533
  defp repository_for(_repo), do: nil
532
  defp repository_by_name(_repo), do: nil
534 533
535 534
  defp sweep(%Repository{} = repository, commit_sha, %Assignment{} = assignment) do
536
    # The receipt tables key on `repo` as a string. Restricting the sweep to
537
    # this repository's own keys keeps a receipt for another repository's
538
    # identical sha out of the answer before the repository resolution runs.
535
    # A receipt that names this repository is matched by its key. The string is
536
    # the fallback for a row the backfill could not settle, and restricting it
537
    # to this repository's own keys keeps a receipt for another repository's
538
    # identical sha out of the answer.
539 539
    keys = OpenAgents.Forge.Pushes.receipt_repo_keys(repository.storage_key)
540 540
541 541
    builds =
542
      Repo.all(
543
        from build in BuildReceipt,
544
          where: build.sha == ^commit_sha and build.repo in ^keys,
545
          select: build.id,
546
          limit: @claimant_limit
547
      )
542
      BuildReceipt
543
      |> ReceiptRepository.scope(repository, keys)
544
      |> where([build], build.sha == ^commit_sha)
545
      |> select([build], build.id)
546
      |> limit(@claimant_limit)
547
      |> Repo.all()
548 548
      |> Enum.map(&%{family: "build", receipt_id: &1})
549 549
550 550
    deploys =
551
      Repo.all(
552
        from deploy in DeployReceipt,
553
          where: deploy.sha == ^commit_sha and deploy.repo in ^keys,
554
          select: deploy.id,
555
          limit: @claimant_limit
556
      )
551
      DeployReceipt
552
      |> ReceiptRepository.scope(repository, keys)
553
      |> where([deploy], deploy.sha == ^commit_sha)
554
      |> select([deploy], deploy.id)
555
      |> limit(@claimant_limit)
556
      |> Repo.all()
557 557
      |> Enum.map(&%{family: "deployment", receipt_id: &1})
558 558
559 559
    checks =
priv/migration_lineages/prior-2026-08-19.json modified +1

@@ -283,6 +283,7 @@

283 283
    20260824010826,
284 284
    20260824011303,
285 285
    20260824024146,
286
    20260824031651,
286 287
    20260824032226
287 288
  ],
288 289
  "required_tables": [
priv/repo/migrations/20260824031651_add_repository_key_to_forge_build_and_deploy_receipts.exs added +71

@@ -0,0 +1,71 @@

1
defmodule OpenAgents.Repo.Migrations.AddRepositoryKeyToForgeBuildAndDeployReceipts do
2
  use Ecto.Migration
3
4
  @moduledoc """
5
  Names the repository a build or deploy receipt belongs to.
6
7
  `forge_builds.repo` and `forge_deploys.repo` hold `Target.repo`, which
8
  `OpenAgents.Forge.Targets` constrains to a member of `:forge_repos` — a
9
  repository *name*, or `owner/name`. `repositories` is unique on
10
  `{namespace_id, name_key}` rather than on `name`, so two repositories can
11
  answer to one receipt string, and `OpenAgents.Issues.Evidence` records no
12
  evidence at all when they do.
13
14
  `forge_pushes.repo` is deliberately left alone. It holds
15
  `Repository.storage_key`, which carries a unique index, so it already names
16
  exactly one repository. Adding a key there would buy no disambiguation and
17
  would cost something: `EXIT-003` requires every `forge_pushes` column to be
18
  re-derivable from the WAL by `Pushes.reconcile_receipts/1`, and a column only
19
  PostgreSQL can produce would make the database a second opinion about a push
20
  record the WAL alone decides.
21
22
  The backfill statement itself lives in
23
  `OpenAgents.Forge.ReceiptRepository.backfill!/1` rather than inline here, so
24
  the rule it applies is proven by a test rather than asserted by a comment. It
25
  resolves a name only when exactly one repository answers to it.
26
  A row it cannot settle keeps a null key and stays readable by its string. A
27
  null here means "not settled", never "no repository", and no row is attached
28
  to a guess.
29
30
  `forge_deploys` carries the `forge_deploy_receipts_immutable` trigger, which
31
  refuses every `UPDATE`. The backfill suspends it for the length of this
32
  migration's transaction and restores it in the same transaction. What the
33
  backfill writes is a derived key beside the receipt, not a change to anything
34
  the receipt claims: `repo`, `sha`, `result`, the digests, and the node results
35
  are untouched.
36
  """
37
38
  def up do
39
    alter table(:forge_builds) do
40
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :nothing)
41
    end
42
43
    alter table(:forge_deploys) do
44
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :nothing)
45
    end
46
47
    create index(:forge_builds, [:repository_id, :sha])
48
    create index(:forge_deploys, [:repository_id, :sha])
49
50
    flush()
51
52
    OpenAgents.Forge.ReceiptRepository.backfill!("forge_builds")
53
54
    repo().query!("ALTER TABLE forge_deploys DISABLE TRIGGER forge_deploy_receipts_immutable")
55
    OpenAgents.Forge.ReceiptRepository.backfill!("forge_deploys")
56
    repo().query!("ALTER TABLE forge_deploys ENABLE TRIGGER forge_deploy_receipts_immutable")
57
  end
58
59
  def down do
60
    drop index(:forge_builds, [:repository_id, :sha])
61
    drop index(:forge_deploys, [:repository_id, :sha])
62
63
    alter table(:forge_builds) do
64
      remove :repository_id
65
    end
66
67
    alter table(:forge_deploys) do
68
      remove :repository_id
69
    end
70
  end
71
end
test/openagents/changelog_test.exs modified +47 -1

@@ -11,7 +11,8 @@ defmodule OpenAgents.ChangelogTest do

11 11
  use OpenAgents.DataCase, async: false
12 12
  alias OpenAgents.Changelog
13 13
  alias OpenAgents.Changelog.{Backfill, Entry}
14
  alias OpenAgents.Forge.{DeployReceipt, PushReceipt}
14
  alias OpenAgents.Forge
15
  alias OpenAgents.Forge.{DeployReceipt, PushReceipt, ReceiptRepository}
15 16
  alias OpenAgents.Transparency.ArtifactLink
16 17
17 18
  setup do

@@ -213,6 +214,51 @@ defmodule OpenAgents.ChangelogTest do

213 214
    end
214 215
  end
215 216
217
  describe "which repository a receipt belongs to" do
218
    # `forge_builds.repo` and `forge_deploys.repo` hold a repository *name*, and
219
    # `Targets.promote/4` admits both the bare name and the `owner/name` path.
220
    # Before #181 the changelog matched the string exactly, so a receipt written
221
    # under one form was invisible to a projection asking for the other, even
222
    # though both settle to the same repository. The key settles it.
223
    test "a receipt written under the owner/name path reaches the bare-name projection" do
224
      sha = full_sha("feed0181")
225
      {:ok, _entry} = Changelog.record(entry_attrs(%{sha: sha, summary: "Keyed receipt"}))
226
227
      repository = ReceiptRepository.resolve("openagents.com")
228
      assert repository.id == "00000000-0000-4000-8000-000000000001"
229
230
      deploy =
231
        insert_deploy!(%{
232
          repo: "OpenAgentsInc/openagents.com",
233
          repository_id: repository.id,
234
          sha: sha
235
        })
236
237
      assert {:ok, payload} = Changelog.projection("openagents.com", refresh: true)
238
      assert [entry] = payload["entries"]
239
      assert entry["receipt_ids"]["deploy"] == deploy.id
240
241
      # `OpenAgents.Forge.receipts_for/2` reads the same way.
242
      assert Enum.any?(Forge.receipts_for("openagents.com", sha), fn
243
               {:deploys, deploys} -> Enum.any?(deploys, &(&1.id == deploy.id))
244
               _other -> false
245
             end)
246
    end
247
248
    # A receipt whose name the backfill could not settle still reads. It keeps
249
    # a null key, and the string is what finds it.
250
    test "a receipt with no key still reaches the projection by its string" do
251
      sha = full_sha("feed0182")
252
      {:ok, _entry} = Changelog.record(entry_attrs(%{sha: sha, summary: "Unkeyed receipt"}))
253
254
      deploy = insert_deploy!(%{repo: "openagents.com", repository_id: nil, sha: sha})
255
256
      assert {:ok, payload} = Changelog.projection("openagents.com", refresh: true)
257
      assert [entry] = payload["entries"]
258
      assert entry["receipt_ids"]["deploy"] == deploy.id
259
    end
260
  end
261
216 262
  describe "projection/2" do
217 263
    test "publishes the schema version, receipt ids, commit_url — and only the push role" do
218 264
      sha = full_sha("feed0007")
test/openagents/forge/builder_test.exs modified +7

@@ -5,6 +5,7 @@ defmodule OpenAgents.Forge.BuilderTest do

5 5
  alias OpenAgents.Forge.BuildExecutor.Sidecar
6 6
  alias OpenAgents.Forge.BuildReceipt
7 7
  alias OpenAgents.Forge.Builder
8
  alias OpenAgents.Forge.ReceiptRepository
8 9
  alias OpenAgents.Forge.FakeBuildExecutor
9 10
  alias OpenAgents.Forge.Repos
10 11
  alias OpenAgents.Forge.Target

@@ -116,6 +117,12 @@ defmodule OpenAgents.Forge.BuilderTest do

116 117
      # Receipt row.
117 118
      receipt = Repo.get_by!(BuildReceipt, repo: "openagents.com", sha: sha)
118 119
      assert receipt.target_id == target.id
120
121
      # #181: a receipt written after the key exists names its repository, so
122
      # no reader has to resolve `repo` back to one. Removing `repository_id`
123
      # from `Builder`'s start changeset turns this red.
124
      assert receipt.repository_id == ReceiptRepository.resolve_id("openagents.com")
125
      assert receipt.repository_id == "00000000-0000-4000-8000-000000000001"
119 126
      assert receipt.modules == [module]
120 127
      assert receipt.warnings == "warn: something minor"
121 128
      assert receipt.tests == nil
test/openagents/forge/hot_loader_test.exs modified +10

@@ -157,6 +157,11 @@ defmodule OpenAgents.Forge.HotLoaderTest do

157 157
158 158
    assert receipt.result == "live"
159 159
    assert receipt.repo == "openagents.com"
160
161
    # #181: a deploy receipt written after the key exists names its repository,
162
    # so `Changelog` and `Evidence` read a key rather than resolve a name.
163
    assert receipt.repository_id == "00000000-0000-4000-8000-000000000001"
164
160 165
    assert receipt.target_id == target.id
161 166
    assert receipt.modules == [name]
162 167
    assert receipt.canary == "ok"

@@ -277,6 +282,11 @@ defmodule OpenAgents.Forge.HotLoaderTest do

277 282
    assert Repo.get!(Target, target.id).status == "failed"
278 283
    assert deploy_receipt(sha).result == "failed"
279 284
285
    # #181: the failed-load path writes its own receipt, and it names the
286
    # repository too. Dropping the key from `HotLoader.insert_receipt/9` turns
287
    # this red.
288
    assert deploy_receipt(sha).repository_id == "00000000-0000-4000-8000-000000000001"
289
280 290
    assert_receive {:forge_deploy, %{repo: "openagents.com", sha: ^sha, result: "failed"}}
281 291
  end
282 292
test/openagents/forge/receipt_repository_test.exs added +204

@@ -0,0 +1,204 @@

1
defmodule OpenAgents.Forge.ReceiptRepositoryTest do
2
  @moduledoc """
3
  Which repository a build or deploy receipt belongs to, #181.
4
5
  `forge_builds.repo` and `forge_deploys.repo` hold a repository *name*, and
6
  `repositories` is unique on `{namespace_id, name_key}` rather than on `name`,
7
  so a name can answer for two repositories. This file pins what the string
8
  could not decide and what the key now does.
9
  """
10
11
  use OpenAgents.DataCase, async: false
12
13
  import Ecto.Query
14
15
  alias OpenAgents.AccountsFixtures
16
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt, ReceiptRepository}
17
  alias OpenAgents.Repo
18
  alias OpenAgents.Repositories.Repository
19
20
  describe "what each receipt column actually holds" do
21
    test "a push receipt's repo is a storage key, which is already unambiguous" do
22
      # `forge_pushes` deliberately has no `repository_id`. This is the reason:
23
      # its `repo` is `Repository.storage_key`, which carries a unique index,
24
      # so it names exactly one repository already. EXIT-003 keeps every column
25
      # there re-derivable from the WAL, and a key only PostgreSQL can produce
26
      # would make the database a second opinion about a push.
27
      %{rows: [[unique?]]} =
28
        Repo.query!("""
29
        SELECT count(*) = 1
30
        FROM pg_indexes
31
        WHERE tablename = 'repositories'
32
          AND indexdef LIKE 'CREATE UNIQUE INDEX%(storage_key)'
33
        """)
34
35
      assert unique?
36
37
      refute :repository_id in OpenAgents.Forge.PushReceipt.__schema__(:fields)
38
39
      %{rows: [[present]]} =
40
        Repo.query!("""
41
        SELECT count(*) FROM information_schema.columns
42
        WHERE table_name = 'forge_pushes' AND column_name = 'repository_id'
43
        """)
44
45
      assert present == 0
46
    end
47
48
    test "a build or deploy receipt's repo is a name, which two repositories can share" do
49
      first = AccountsFixtures.repository_fixture(%{owner: "FirstOrg", name: "shared-name"})
50
      second = AccountsFixtures.repository_fixture(%{owner: "SecondOrg", name: "shared-name"})
51
52
      assert first.name == second.name
53
      refute first.storage_key == second.storage_key
54
55
      assert Repo.aggregate(from(r in Repository, where: r.name == "shared-name"), :count) == 2
56
    end
57
  end
58
59
  describe "resolve/1" do
60
    test "answers for a storage key, a bare name, and an owner/name path" do
61
      repository = AccountsFixtures.repository_fixture(%{owner: "ResolveOrg", name: "resolvable"})
62
63
      assert ReceiptRepository.resolve(repository.storage_key).id == repository.id
64
      assert ReceiptRepository.resolve("resolvable").id == repository.id
65
      assert ReceiptRepository.resolve("ResolveOrg/resolvable").id == repository.id
66
    end
67
68
    test "refuses a name two repositories answer to, and a name none answers to" do
69
      AccountsFixtures.repository_fixture(%{owner: "AmbiguousA", name: "two-answers"})
70
      AccountsFixtures.repository_fixture(%{owner: "AmbiguousB", name: "two-answers"})
71
72
      assert ReceiptRepository.resolve("two-answers") == nil
73
      assert ReceiptRepository.resolve("no-such-repository") == nil
74
      assert ReceiptRepository.resolve(nil) == nil
75
    end
76
  end
77
78
  describe "scope/3" do
79
    test "a keyed receipt is matched by its key and never by another repository's name" do
80
      mine = AccountsFixtures.repository_fixture(%{owner: "ScopeMine", name: "collide"})
81
      theirs = AccountsFixtures.repository_fixture(%{owner: "ScopeTheirs", name: "collide"})
82
83
      ours = build_receipt!("collide", mine.id)
84
      not_ours = build_receipt!("collide", theirs.id)
85
86
      found =
87
        BuildReceipt
88
        |> ReceiptRepository.scope(mine, ["collide"])
89
        |> select([b], b.id)
90
        |> Repo.all()
91
92
      assert ours.id in found
93
      refute not_ours.id in found
94
    end
95
96
    test "a receipt with no key still reads by its string" do
97
      repository = AccountsFixtures.repository_fixture(%{owner: "ScopeOld", name: "historical"})
98
      unkeyed = build_receipt!("historical", nil)
99
100
      found =
101
        BuildReceipt
102
        |> ReceiptRepository.scope(repository, ["historical"])
103
        |> select([b], b.id)
104
        |> Repo.all()
105
106
      assert unkeyed.id in found
107
    end
108
109
    test "with no repository to name, the string is all there is" do
110
      unkeyed = build_receipt!("unsettled-name", nil)
111
112
      found =
113
        BuildReceipt
114
        |> ReceiptRepository.scope(nil, ["unsettled-name"])
115
        |> select([b], b.id)
116
        |> Repo.all()
117
118
      assert found == [unkeyed.id]
119
    end
120
  end
121
122
  describe "backfill!/1" do
123
    test "fills a name exactly one repository answers to and leaves the rest null" do
124
      repository = AccountsFixtures.repository_fixture(%{owner: "FillOrg", name: "fillable"})
125
      AccountsFixtures.repository_fixture(%{owner: "ClashA", name: "clashing"})
126
      AccountsFixtures.repository_fixture(%{owner: "ClashB", name: "clashing"})
127
128
      by_name = build_receipt!("fillable", nil)
129
      by_path = build_receipt!("FillOrg/fillable", nil)
130
      by_storage_key = build_receipt!(repository.storage_key, nil)
131
      ambiguous = build_receipt!("clashing", nil)
132
      absent = build_receipt!("gone-from-the-forge", nil)
133
134
      assert ReceiptRepository.backfill!("forge_builds") >= 3
135
136
      assert reload(by_name).repository_id == repository.id
137
      assert reload(by_path).repository_id == repository.id
138
      assert reload(by_storage_key).repository_id == repository.id
139
140
      # A backfill that guesses is worse than a null. Both of these stay null,
141
      # and a null means "not settled", never "no repository".
142
      assert reload(ambiguous).repository_id == nil
143
      assert reload(absent).repository_id == nil
144
    end
145
146
    test "it is idempotent and never re-points a receipt that already names one" do
147
      first = AccountsFixtures.repository_fixture(%{owner: "IdemA", name: "idempotent"})
148
      second = AccountsFixtures.repository_fixture(%{owner: "IdemB", name: "elsewhere"})
149
150
      # Deliberately pointed at the repository its name does not name.
151
      receipt = build_receipt!("idempotent", second.id)
152
153
      assert ReceiptRepository.backfill!("forge_builds") >= 0
154
      assert reload(receipt).repository_id == second.id
155
      refute reload(receipt).repository_id == first.id
156
    end
157
158
    test "it does not carry the authority to rewrite a deploy receipt" do
159
      repository = AccountsFixtures.repository_fixture(%{owner: "TriggerOrg", name: "triggered"})
160
      receipt = deploy_receipt!("triggered", nil)
161
162
      # `forge_deploys` refuses every UPDATE. The migration suspends the trigger
163
      # for the length of its own transaction; `backfill!/1` does not, so a
164
      # caller cannot quietly acquire that authority.
165
      assert_raise Postgrex.Error, ~r/forge deployment receipts are immutable/, fn ->
166
        ReceiptRepository.backfill!("forge_deploys")
167
      end
168
169
      Repo.query!("ALTER TABLE forge_deploys DISABLE TRIGGER forge_deploy_receipts_immutable")
170
      assert ReceiptRepository.backfill!("forge_deploys") >= 1
171
      Repo.query!("ALTER TABLE forge_deploys ENABLE TRIGGER forge_deploy_receipts_immutable")
172
173
      assert Repo.get(DeployReceipt, receipt.id).repository_id == repository.id
174
    end
175
  end
176
177
  ## helpers
178
179
  defp build_receipt!(repo, repository_id) do
180
    %BuildReceipt{}
181
    |> BuildReceipt.changeset(%{
182
      repo: repo,
183
      repository_id: repository_id,
184
      sha: String.duplicate("a", 40),
185
      target_id: Ecto.UUID.generate(),
186
      status: "complete"
187
    })
188
    |> Repo.insert!()
189
  end
190
191
  defp deploy_receipt!(repo, repository_id) do
192
    %DeployReceipt{}
193
    |> DeployReceipt.changeset(%{
194
      repo: repo,
195
      repository_id: repository_id,
196
      sha: String.duplicate("b", 40),
197
      target_id: Ecto.UUID.generate(),
198
      result: "live"
199
    })
200
    |> Repo.insert!()
201
  end
202
203
  defp reload(%BuildReceipt{id: id}), do: Repo.get!(BuildReceipt, id)
204
end
test/openagents/forge/target_lifecycle_test.exs modified +58 -4

@@ -2,7 +2,7 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

2 2
  use OpenAgents.DataCase, async: false
3 3
  import OpenAgents.AccountsFixtures
4 4
5
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt, Repos, Targets}
5
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt, ReceiptRepository, Repos, Targets}
6 6
7 7
  @rolling_nodes ["openagents@10.42.0.11", "openagents@10.42.0.12"]
8 8
  @rolling_digest "sha256:" <> String.duplicate("c", 64)

@@ -163,6 +163,32 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

163 163
    assert Repo.get!(OpenAgents.Forge.Target, target.id).status == "deploying"
164 164
  end
165 165
166
  test "a fleet commit names the repository its target's name settles to", %{sha: sha} do
167
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
168
    {:ok, _building} = Targets.advance(target.id, "building")
169
    {:ok, _built} = Targets.advance(target.id, "built")
170
    {:ok, _deploying} = Targets.begin_deployment(target.id)
171
172
    # Created after promotion: a repository answering to `demo` also changes
173
    # what `Pushes.mirror_storage_key/1` returns, and promotion resolves the
174
    # bare cache through it. The receipt key is resolved at settlement.
175
    demo = repository_fixture(%{owner: "FleetDemoOrg", name: "demo"})
176
    assert ReceiptRepository.resolve("demo").id == demo.id
177
178
    deployment_id = Ecto.UUID.generate()
179
180
    assert {:ok, %{receipt: receipt}} =
181
             Targets.finish_deployment(
182
               target.id,
183
               "live",
184
               %{"deployment_id" => deployment_id},
185
               %{deployment_id: deployment_id, expected_nodes: [], nodes: [], node_results: %{}}
186
             )
187
188
    assert receipt.repo == "demo"
189
    assert receipt.repository_id == demo.id
190
  end
191
166 192
  test "fleet commit writes the live target and terminal receipt atomically", %{sha: sha} do
167 193
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
168 194
    {:ok, _building} = Targets.advance(target.id, "building")

@@ -195,6 +221,14 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

195 221
196 222
    assert live.status == "live"
197 223
    assert receipt.result == "live"
224
225
    # #181: `demo` is an allowlist name no repository on this forge answers to,
226
    # so the receipt records a null key rather than a guess. A null here means
227
    # "not settled", never "no repository".
228
    assert receipt.repo == "demo"
229
    assert receipt.repository_id == nil
230
    assert OpenAgents.Forge.ReceiptRepository.resolve("demo") == nil
231
198 232
    assert receipt.deployment_id == deployment_id
199 233
    assert receipt.artifact_digest == digest
200 234
    assert receipt.manifest_digest == manifest_digest

@@ -206,10 +240,18 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

206 240
    {:ok, _built} = Targets.advance(target.id, "built")
207 241
    {:ok, _rolling} = Targets.advance(target.id, "needs_rolling_replace")
208 242
243
    repository = repository_fixture()
209 244
    artifact_digest = String.duplicate("a", 64)
210 245
    manifest = %{"classification" => "needs_rolling_replace", "source_sha" => sha}
211 246
212
    insert_build_receipt!(target, manifest, artifact_digest)
247
    insert_build_receipt!(
248
      target,
249
      manifest,
250
      artifact_digest,
251
      ["Elixir.OpenAgents.BuildInfo"],
252
      repository.id
253
    )
254
213 255
    authorize_rolling!(target, sha)
214 256
    observe_rolling!(target, sha)
215 257

@@ -228,6 +270,10 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

228 270
             "openagents@10.42.0.12" => "ready"
229 271
           }
230 272
273
    # #181: a rolling-replacement receipt inherits the build's repository key
274
    # rather than resolving the name a second time.
275
    assert receipt.repository_id == repository.id
276
231 277
    assert Targets.live("demo").id == target.id
232 278
233 279
    assert {:error, {:invalid_transition, "live", "live"}} =

@@ -240,6 +286,7 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

240 286
    {:ok, _built} = Targets.advance(target.id, "built")
241 287
    {:ok, _relup} = Targets.advance(target.id, "needs_rolling_replace")
242 288
289
    repository = repository_fixture()
243 290
    build_digest = String.duplicate("a", 64)
244 291
    package_digest = String.duplicate("c", 64)
245 292
    package_manifest_digest = String.duplicate("d", 64)

@@ -247,7 +294,9 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

247 294
    insert_build_receipt!(
248 295
      target,
249 296
      %{"classification" => "needs_rolling_replace", "source_sha" => sha},
250
      build_digest
297
      build_digest,
298
      ["Elixir.OpenAgents.BuildInfo"],
299
      repository.id
251 300
    )
252 301
253 302
    result =

@@ -265,6 +314,9 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

265 314
    assert receipt.result == "live"
266 315
    assert receipt.artifact_digest == package_digest
267 316
    assert receipt.manifest_digest == package_manifest_digest
317
318
    # #181: a relup receipt inherits the build's repository key too.
319
    assert receipt.repository_id == repository.id
268 320
    assert receipt.push_to_live_ms == 53_876
269 321
270 322
    assert receipt.node_results == %{

@@ -577,11 +629,13 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

577 629
         target,
578 630
         manifest,
579 631
         artifact_digest,
580
         modules \\ ["Elixir.OpenAgents.BuildInfo"]
632
         modules \\ ["Elixir.OpenAgents.BuildInfo"],
633
         repository_id \\ nil
581 634
       ) do
582 635
    %BuildReceipt{}
583 636
    |> BuildReceipt.changeset(%{
584 637
      repo: target.repo,
638
      repository_id: repository_id,
585 639
      sha: target.sha,
586 640
      target_id: target.id,
587 641
      status: "complete",
test/openagents/issues/evidence_test.exs modified +81

@@ -15,6 +15,7 @@ defmodule OpenAgents.Issues.EvidenceTest do

15 15
16 16
  alias OpenAgents.Deployments.CheckResult
17 17
  alias OpenAgents.Forge.{Assignment, BuildReceipt, DeployReceipt, PushReceipt}
18
  alias OpenAgents.Forge.ReceiptRepository
18 19
  alias OpenAgents.Issues
19 20
  alias OpenAgents.Issues.{ClosingReference, ClosingReferences, Evidence, EvidenceEntry}
20 21
  alias OpenAgents.Machines

@@ -327,6 +328,74 @@ defmodule OpenAgents.Issues.EvidenceTest do

327 328
    end
328 329
  end
329 330
331
  describe "which repository a receipt names" do
332
    # `forge_builds.repo` holds a repository *name*, and `repositories` is
333
    # unique on `{namespace_id, name_key}` rather than on `name`. Before #181
334
    # this receipt recorded no evidence at all: two repositories answered to
335
    # the name, and attaching it to the wrong issue is worse than attaching it
336
    # to none. The key settles it without a name lookup.
337
    test "a receipt whose name two repositories answer to is still evidence", context do
338
      %{repository: repository, issue: issue} = context
339
      closing_reference(context, @sha)
340
341
      _decoy =
342
        repository_fixture(%{owner: "EvidenceDecoy", name: repository.name, visibility: "public"})
343
344
      assert ReceiptRepository.resolve(repository.name) == nil
345
346
      receipt = named_build_receipt(repository, repository.name, repository.id, @sha)
347
348
      assert [entry] = Evidence.record_build(receipt)
349
      assert entry.issue_id == issue.id
350
      assert entry.receipt_id == receipt.id
351
    end
352
353
    # The refusal survives for a row the backfill could not settle. This is the
354
    # honest half: a null key is "not settled", and the name behind it still
355
    # answers for two repositories, so nothing is recorded.
356
    test "a receipt with no key whose name is ambiguous records nothing", context do
357
      %{repository: repository} = context
358
      closing_reference(context, @sha)
359
360
      _decoy =
361
        repository_fixture(%{
362
          owner: "EvidenceDecoy2",
363
          name: repository.name,
364
          visibility: "public"
365
        })
366
367
      receipt = named_build_receipt(repository, repository.name, nil, @sha)
368
369
      assert Evidence.record_build(receipt) == []
370
    end
371
372
    test "a receipt naming a repository the forge does not track records nothing", context do
373
      closing_reference(context, @sha)
374
      receipt = named_build_receipt(context.repository, "gone-from-the-forge", nil, @sha)
375
376
      assert Evidence.record_build(receipt) == []
377
    end
378
379
    # A repository whose name and storage key differ is the ordinary case:
380
    # every repository created since storage keys became UUIDs is one. The
381
    # sweep restricted itself to `receipt_repo_keys(storage_key)`, which does
382
    # not contain the name a build receipt is actually written with, so the
383
    # key is what finds it.
384
    test "the sweep finds a receipt written under a name, not a storage key", context do
385
      %{repository: repository, issue: issue, user: user} = context
386
      refute repository.storage_key == repository.name
387
388
      receipt = named_build_receipt(repository, repository.name, repository.id, @sha)
389
      assignment = attempt(context, @sha, "completed")
390
391
      assert Evidence.bind_attempt(assignment) != []
392
393
      entries = Evidence.for_issue(issue)
394
      assert Enum.any?(entries, &(&1.receipt_id == receipt.id and &1.family == "build"))
395
      assert user
396
    end
397
  end
398
330 399
  describe "the edge is never a work record" do
331 400
    test "an edge stores no steps, no report, and no budget", _context do
332 401
      fields = EvidenceEntry.__schema__(:fields)

@@ -364,6 +433,18 @@ defmodule OpenAgents.Issues.EvidenceTest do

364 433
    |> Repo.insert!()
365 434
  end
366 435
436
  defp named_build_receipt(_repository, repo, repository_id, sha) do
437
    %BuildReceipt{}
438
    |> BuildReceipt.start_changeset(%{
439
      repo: repo,
440
      repository_id: repository_id,
441
      sha: sha,
442
      target_id: Ecto.UUID.generate()
443
    })
444
    |> Ecto.Changeset.put_change(:status, "complete")
445
    |> Repo.insert!()
446
  end
447
367 448
  defp build_receipt(%{repository: repository}, sha, status) do
368 449
    %BuildReceipt{}
369 450
    |> BuildReceipt.start_changeset(%{

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