Withhold a repository's name from an export that cannot read it

8e58fcf7b500 · AtlantisPleb · · parent 773ad6806030

Withhold a repository's name from an export that cannot read it

`OpenAgents.DataRights.AccountExport` stated one policy and applied two.
`repository_work_export/1` joins `OpenAgents.Repositories.readable_by/2` and
says so. Three other joins reached the repositories table with no read
predicate at all: `push_receipts_export/1` on `storage_key`, and both
`left_join`s in `deployments_export/1` on `repository_id`. Each selected the
repository's `owner` and `name` and rendered `owner/name`.

The records are the account's own, so no other account's data left. What left
was the repository's current owner and name — for a private repository the
account had been removed from, or one renamed after they went. The export told
them what it is called now.

All three now join the same `readable_by/2` subquery, and the module states one
rule for every collection that renders a path. The record keeps its own fields
and loses the name: a push receipt arrives with `"repository": null` and its
`storage_key`, `wal_seq`, and refs intact, because the receipt is the account's
own evidence against the log. A record under `repository_work` is still
withheld entirely, because a pull request apart from its repository is not a
record an account can use, and the difference is now written down rather than
left to each query. `not_included` names the rule as the family
`repository_identity`, so a recipient reading the document offline is not left
to infer why a receipt names no repository.

This was the residue class `REPOSITORY-001`'s enumeration cannot catch: the
joins named no visibility term and called no resolver, so neither the predicate
scan nor the caller sets saw them. `REPOSITORY-001` and the proof audit both
record that this instance is closed and that the class is not.

Closes #185.

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>
Closes
#185

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-23-invariant-proof-audit.md
  • modified lib/openagents/data_rights/account_export.ex
  • modified test/openagents/data_rights/account_export_test.exs

Diff

4 files changed, +208 -14

INVARIANTS.md modified +34 -6

@@ -3294,12 +3294,26 @@ role, so removing the filter reddens nothing. The vocabulary is pinned against

3294 3294
that constraint instead, and a fifth role fails until someone says whether it
3295 3295
reads.
3296 3296
3297
Amended 2026-08-23 (issue #185): the one instance of that residue is closed.
3298
`OpenAgents.DataRights.AccountExport`'s push-receipt and deployment joins used
3299
to select a repository's `owner` and `name` with no predicate at all, so an
3300
export named the current owner and name of a private repository the account had
3301
been removed from, or of one renamed after they left. All three now join the
3302
same `readable_by/2` subquery the module's `repository_work_export/1` composes,
3303
and the module states one rule for all four collections that render a path: a
3304
repository's current owner and name travel only where the predicate admits the
3305
repository to that account. What the rule does to the record differs by family
3306
and is stated rather than left to each query — an account-keyed record survives
3307
with a null repository, because a push receipt is the account's own evidence
3308
under `EXIT-005`, and a repository-keyed record under `repository_work` is
3309
withheld entirely, because it has no meaning apart from its repository.
3310
`OpenAgents.DataRights.AccountExportTest` proves both halves for each
3311
collection.
3312
3297 3313
What is still not enumerated: a listing that applies no predicate at all names
3298
no term and calls no resolver, so it passes every test above.
3299
`OpenAgents.DataRights.AccountExport`'s push-receipt and deployment joins are
3300
that shape — each is scoped to the acting account's own rows, and each selects
3301
a repository's `owner` and `name` without the predicate, which the same
3302
module's `repository_work_export/1` explicitly applies.
3314
no term and calls no resolver, so it passes every test above. Closing the one
3315
instance does not close the class — a predicate-free listing added tomorrow
3316
would still pass every test here, and only a reader would catch it.
3303 3317
`docs/2026-08-23-invariant-proof-audit.md` records the residue.
3304 3318
3305 3319
Reading across repositories obeys the same rule as reading one: the

@@ -3580,6 +3594,20 @@ is not a member of, is withheld, and another account's records in a repository

3580 3594
this account can read never appear. Dropping the `readable_by` join turns the
3581 3595
first red while every other assertion still passes.
3582 3596
3597
Amended 2026-08-23 (issue #185): a repository's current owner and name are the
3598
repository's rather than the account's, and the export applies one rule to all
3599
four collections that render a path. `push_receipt`, `deployment` request, and
3600
`deployment` approval are keyed on the account, so the record comes back
3601
whatever the repository says now — with `"repository": null` where
3602
`readable_by/2` no longer admits it, keeping the `storage_key`, `wal_seq`, and
3603
refs an `EXIT-005` chain link needs. `repository_work` withholds the record
3604
instead, because a pull request separated from its repository is not a record
3605
an account can use. Nothing moves off `portable`: an account still gets every
3606
record it authored, and what it stops getting is a name that was never its
3607
own. The document says so in its own `not_included` section under the family
3608
`repository_identity`, so a recipient reading the file offline is not left to
3609
infer why a receipt names no repository.
3610
3583 3611
Ownership of a migrated forum post is decided, not guessed. Two identities
3584 3612
resolve to an account and no third: `user:<account-id>`, which every topic and
3585 3613
post written on this surface carries, and any `actor_ref` the account holds a

@@ -4496,7 +4524,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4496 4524
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
4497 4525
| CAPACITY-001 | `test/openagents/capacity_test.exs` |
4498 4526
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs`, `test/openagents_web/transparency_surface_test.exs` |
4499
| REPOSITORY-001 | `test/openagents/repositories/visibility_join_test.exs`, `test/openagents_web/live/computers_repository_access_test.exs`, `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
4527
| REPOSITORY-001 | `test/openagents/repositories/visibility_join_test.exs`, `test/openagents/data_rights/account_export_test.exs`, `test/openagents_web/live/computers_repository_access_test.exs`, `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
4500 4528
| API-001 | `test/openagents_web/controllers/api_extension_governance_test.exs`, `test/openagents/issue_progress_test.exs` |
4501 4529
| CONTRIBUTION-001 | `test/openagents_web/contribution_contract_test.exs` |
4502 4530
| REPOSITORY-002 | `ops/ci/push-remote-check.sh`, `ops/dev/install-push-guard.sh`, `test/openagents/push_remote_contract_test.exs` |
docs/2026-08-23-invariant-proof-audit.md modified +9 -3

@@ -222,9 +222,15 @@ constraint instead, so a fifth role fails until someone says whether it reads.

222 222
223 223
**What is left.** A listing that applies no predicate at all names no term and
224 224
calls no resolver. `OpenAgents.DataRights.AccountExport`'s push-receipt and
225
deployment joins are that shape; each is scoped to the acting account's own
226
rows and selects a repository's `owner` and `name` without the predicate the
227
same module applies elsewhere. `REPOSITORY-001` records it.
225
deployment joins were that shape; each was scoped to the acting account's own
226
rows and selected a repository's `owner` and `name` without the predicate the
227
same module applies elsewhere. Issue #185 closed that instance: all three joins
228
now compose `readable_by/2`, the module states one disclosure rule for every
229
collection that renders a path, and
230
`test/openagents/data_rights/account_export_test.exs` reddens on each of them
231
when the predicate is dropped. The class stays open — a predicate-free listing
232
added tomorrow would still name no term and call no resolver — and
233
`REPOSITORY-001` records it.
228 234
229 235
### `RELEASE-004` — no hosted CI
230 236
lib/openagents/data_rights/account_export.ex modified +59 -5

@@ -45,6 +45,26 @@ defmodule OpenAgents.DataRights.AccountExport do

45 45
  inside the document — a post to its topic, a tip to its post, an event to its
46 46
  thread — resolves inside the document itself.
47 47
48
  ## Repository names
49
50
  A repository's current owner and name are the repository's, not the
51
  account's. They travel in this document only where
52
  `OpenAgents.Repositories.readable_by/2` admits the repository to this
53
  account, and every collection that renders `owner/name` applies that one
54
  rule: push receipts, deployment requests, deployment approvals, and the
55
  four families under `"repository_work"`. A private repository the account
56
  was removed from, or one renamed after they left, does not tell them what it
57
  is called now.
58
59
  What the rule does to the record differs by family, and the difference is
60
  stated rather than left to each query. An account-keyed record survives with
61
  `"repository": null`: a push receipt is the account's own evidence — the WAL
62
  sequence and ref map a clone does not carry — and withholding it would cost
63
  the account its own history to protect a name it no longer has. A
64
  repository-keyed record under `"repository_work"` is withheld entirely,
65
  because a pull request separated from its repository is not a record the
66
  account can use.
67
48 68
  ## Repository-keyed work
49 69
50 70
  Pull requests, stacks, and issue dependencies key on a repository rather than

@@ -420,13 +440,20 @@ defmodule OpenAgents.DataRights.AccountExport do

420 440
  # `forge_pushes.principal` is `user:<account-id>` for a person's push and an
421 441
  # operator or assignment string otherwise, so the account's own pushes are an
422 442
  # exact match rather than a guess.
443
  #
444
  # The receipt is the account's own and comes back whatever the repository
445
  # says now. The repository's current owner and name are not, so the join
446
  # goes through `readable_repositories/1` rather than straight at the table:
447
  # a receipt for a repository this account can no longer read arrives with
448
  # `"repository": null` and keeps its `storage_key`, `wal_seq`, and refs.
423 449
  defp push_receipts_export(user) do
424 450
    principal = account_actor_ref(user)
451
    readable = readable_repositories(user)
425 452
426 453
    receipts =
427 454
      Repo.all(
428 455
        from receipt in PushReceipt,
429
          left_join: repository in Repository,
456
          left_join: repository in subquery(readable),
430 457
          on: repository.storage_key == receipt.repo,
431 458
          where: receipt.principal == ^principal,
432 459
          order_by: [asc: receipt.inserted_at, asc: receipt.id],

@@ -460,11 +487,17 @@ defmodule OpenAgents.DataRights.AccountExport do

460 487
461 488
  ## ── deployments ────────────────────────────────────────────────────────
462 489
490
  # Both reads are keyed on the account — the requester of a deployment and the
491
  # approver of one — so both records survive a lost membership. Both join
492
  # `readable_repositories/1` for the same reason the push receipts do: the
493
  # request is the account's, the repository's current name is not.
463 494
  defp deployments_export(user) do
495
    readable = readable_repositories(user)
496
464 497
    requests =
465 498
      Repo.all(
466 499
        from request in Request,
467
          left_join: repository in Repository,
500
          left_join: repository in subquery(readable),
468 501
          on: repository.id == request.repository_id,
469 502
          where: request.requested_by_user_id == ^user.id,
470 503
          order_by: [asc: request.requested_at, asc: request.id],

@@ -475,7 +508,7 @@ defmodule OpenAgents.DataRights.AccountExport do

475 508
    approvals =
476 509
      Repo.all(
477 510
        from approval in Approval,
478
          left_join: repository in Repository,
511
          left_join: repository in subquery(readable),
479 512
          on: repository.id == approval.repository_id,
480 513
          where: approval.approver_user_id == ^user.id,
481 514
          order_by: [asc: approval.decided_at, asc: approval.id],

@@ -686,10 +719,20 @@ defmodule OpenAgents.DataRights.AccountExport do

686 719
  end
687 720
688 721
  # A repository's identity comes back with the predicate rather than beside
689
  # it, so a caller cannot name a repository this query did not admit.
722
  # it, so a caller cannot name a repository this query did not admit. Every
723
  # collection that renders `owner/name` joins this one query — the four
724
  # repository-keyed families here, and the account-keyed push receipts and
725
  # deployments above — so the module cannot state one disclosure rule and
726
  # apply two. `storage_key` rides along because a push receipt names its
727
  # repository by that key rather than by `repository_id`.
690 728
  defp readable_repositories(user) do
691 729
    from repository in Repositories.readable_by(Repository, user),
692
      select: %{id: repository.id, owner: repository.owner, name: repository.name}
730
      select: %{
731
        id: repository.id,
732
        owner: repository.owner,
733
        name: repository.name,
734
        storage_key: repository.storage_key
735
      }
693 736
  end
694 737
695 738
  defp pull_requests_export(user, readable) do

@@ -923,6 +966,17 @@ defmodule OpenAgents.DataRights.AccountExport do

923 966
        "mechanism" => "git clone with an oa_pat_ token",
924 967
        "issue" => nil
925 968
      },
969
      %{
970
        "family" => "repository_identity",
971
        "reason" =>
972
          "A repository's current owner and name appear only where the account can still read " <>
973
            "the repository. A push receipt, deployment request, or approval for a repository " <>
974
            "the account was removed from arrives with a null repository and keeps every field " <>
975
            "that is the account's own; a record under repository_work is withheld instead, " <>
976
            "because it has no meaning apart from its repository.",
977
        "mechanism" => "OpenAgents.Repositories.readable_by/2",
978
        "issue" => nil
979
      },
926 980
      %{
927 981
        "family" => "forum",
928 982
        "reason" =>
test/openagents/data_rights/account_export_test.exs modified +106

@@ -13,6 +13,9 @@ defmodule OpenAgents.DataRights.AccountExportTest do

13 13
  import Ecto.Query
14 14
15 15
  alias OpenAgents.DataRights.AccountExport
16
  alias OpenAgents.Deployments.Approval
17
  alias OpenAgents.Deployments.Request
18
  alias OpenAgents.DeploymentsFixtures
16 19
  alias OpenAgents.Forum
17 20
  alias OpenAgents.Forum.{Post, TipDestination, Topic}
18 21
  alias OpenAgents.Forum.Forum, as: Board

@@ -22,6 +25,7 @@ defmodule OpenAgents.DataRights.AccountExportTest do

22 25
  alias OpenAgents.PullRequests.PullRequest
23 26
  alias OpenAgents.Repo
24 27
  alias OpenAgents.Repositories
28
  alias OpenAgents.Repositories.Membership
25 29
  alias OpenAgents.Reputation
26 30
  alias OpenAgents.Stacks
27 31
  alias OpenAgents.Threads

@@ -180,6 +184,95 @@ defmodule OpenAgents.DataRights.AccountExportTest do

180 184
    end
181 185
  end
182 186
187
  # The repository's current owner and name are the repository's, not the
188
  # account's, and `readable_by/2` is the only thing withholding them here:
189
  # each account gets these records back through a column naming it, so no
190
  # other gate stands between the export and the path. Replacing the
191
  # `readable_repositories/1` subquery with the repositories table turns every
192
  # assertion in this block red and nothing else in this file.
193
  describe "a repository the account can no longer read" do
194
    test "a push receipt keeps every field that is the account's own and loses the path" do
195
      user = github_user("account-export-push-gone", "export-push-gone")
196
      owner = github_user("account-export-push-owner", "export-push-owner")
197
      repository = private_repository_with_member("export-push-private", owner)
198
      {:ok, _membership} = Repositories.add_member(repository, user, "contributor")
199
200
      refs = %{"refs/heads/main" => String.duplicate("b", 40)}
201
202
      Repo.insert!(%PushReceipt{
203
        repo: repository.storage_key,
204
        wal_seq: 9,
205
        principal: "user:" <> user.id,
206
        refs: refs,
207
        duration_ms: 31
208
      })
209
210
      # While the account is still a member, the document names the repository.
211
      assert {:ok, before_removal} = AccountExport.build(user)
212
      assert [named] = before_removal["push_receipts"]["records"]
213
      assert named["repository"] == repository.owner <> "/" <> repository.name
214
215
      drop_membership!(repository, user)
216
      refute Repositories.member?(repository, user)
217
218
      assert {:ok, export} = AccountExport.build(user)
219
      assert [receipt] = export["push_receipts"]["records"]
220
221
      assert receipt["repository"] == nil
222
      assert receipt["storage_key"] == repository.storage_key
223
      assert receipt["wal_seq"] == 9
224
      assert receipt["refs"] == refs
225
      assert receipt["duration_ms"] == 31
226
    end
227
228
    test "a deployment request and an approval keep the record and lose the path" do
229
      user = github_user("account-export-deploy-gone", "export-deploy-gone")
230
      owner = github_user("account-export-deploy-owner", "export-deploy-owner")
231
      repository = private_repository_with_member("export-deploy-private", owner)
232
      {:ok, _membership} = Repositories.add_member(repository, user, "maintainer")
233
234
      _environment = DeploymentsFixtures.environment_fixture(repository, user)
235
      run = DeploymentsFixtures.run_fixture(repository, user)
236
      request = Repo.get!(Request, run.deployment_request_id)
237
238
      Repo.insert!(%Approval{
239
        repository_id: repository.id,
240
        deployment_run_id: run.id,
241
        approver_user_id: user.id,
242
        decision: "approved",
243
        rule: "manual",
244
        request_digest: request.request_digest,
245
        decided_at: DateTime.utc_now()
246
      })
247
248
      drop_membership!(repository, user)
249
      refute Repositories.member?(repository, user)
250
251
      assert {:ok, export} = AccountExport.build(user)
252
253
      assert [exported_request] = export["deployments"]["requests"]
254
      assert exported_request["repository"] == nil
255
      assert exported_request["commit_sha"] == DeploymentsFixtures.commit_sha()
256
      assert exported_request["request_digest"] == request.request_digest
257
258
      assert [exported_approval] = export["deployments"]["approvals"]
259
      assert exported_approval["repository"] == nil
260
      assert exported_approval["decision"] == "approved"
261
      assert exported_approval["request_digest"] == request.request_digest
262
    end
263
264
    test "the document names the rule it applies to a repository it will not name" do
265
      user = github_user("account-export-disclosure", "export-disclosure")
266
      assert {:ok, export} = AccountExport.build(user)
267
268
      assert gap =
269
               Enum.find(export["not_included"], &(&1["family"] == "repository_identity"))
270
271
      assert gap["mechanism"] == "OpenAgents.Repositories.readable_by/2"
272
      assert gap["reason"] =~ "null repository"
273
    end
274
  end
275
183 276
  describe "who a legacy forum post belongs to" do
184 277
    test "an unclaimed legacy identity does not export", %{board: board} do
185 278
      user = github_user("account-export-unclaimed", "export-unclaimed")

@@ -817,6 +910,19 @@ defmodule OpenAgents.DataRights.AccountExportTest do

817 910
    }
818 911
  end
819 912
913
  # Membership is what `readable_by/2` reads for a private repository, so the
914
  # row goes rather than the repository: the account really did the work and
915
  # really cannot read where it landed.
916
  defp drop_membership!(repository, user) do
917
    {1, nil} =
918
      Repo.delete_all(
919
        from membership in Membership,
920
          where: membership.repository_id == ^repository.id and membership.user_id == ^user.id
921
      )
922
923
    :ok
924
  end
925
820 926
  defp private_repository_with_member(name, user) do
821 927
    repository =
822 928
      OpenAgents.AccountsFixtures.repository_fixture(%{name: name, visibility: "private"})

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