Resolve a reputation attestation's subject to an account

73fb8de34043 · AtlantisPleb · · parent 6af97278d163

Resolve a reputation attestation's subject to an account

`reputation_attestations.subject_id` was a bare string the issuer supplied and
`OpenAgents.Reputation.issue/3` validated only for length. Nothing resolved it,
and nothing stopped it resolving to two things. `OpenAgents.DataRights.ExportInventory`
recorded `reputation` as `:partial` because of it: an account could not ask for
its own attestations, so `GET /data/export/account` named the family in
`not_included` rather than returning an empty list that reads like an answer.

What a subject is, is now decided rather than assumed. A subject may be an
account, a legacy forum actor with no account of its own, or a registered
agent, and collapsing the three is what produced a bare string.
`reputation_subject_claims` carries the typed reference — `subject_kind` plus
`subject_id` — with one `CHECK` constraint per kind: an `account` subject must
be this account's own `user:<account-id>` reference, checked in SQL without an
operator; a `forum_actor` subject must carry the `forum_actor_links` row that
established the legacy identity; an `agent` subject must carry the `agents`
row. A row inserted past every changeset is still refused.

The kind is on the claim rather than on the attestation, and that follows from
REPUTATION-001. An attestation's `subject_id` sits inside the Ed25519-signed
claim, so a `subject_kind` column on `reputation_attestations` would either
stay outside the signature — a field a verifier must not trust — or change the
claim shape and invalidate every signature already published. Issuance and
verification read the binding not at all: an attestation is exactly as valid
for a subject no account has claimed.

The binding is established, not guessed, the way `forum_actor_links` resolves a
legacy forum identity: `POST /api/v3/reputation/subject-claims` asks, and
`PATCH /api/v3/reputation/subject-claims/:id` is the operator's decision. Only
a `linked` claim resolves. The unique index is on `subject_id` alone rather
than on the kind and the string together, because an attestation names its
subject with the bare string — so one string resolves to at most one account,
and two accounts cannot both answer to it.

`reputation` moves to `:portable`. The attestations travel under
`repository_work` behind the same `Repositories.readable_by/2` join the other
three repository-keyed families use, and the `repository` and `private`
transparency tiers stay behind the membership test
`OpenAgentsWeb.ReputationController` applies, so a wider export never means
wider disclosure. The signed claim travels verbatim beside its signature, so a
recipient checks the attestation offline.

EXIT-001 records the change rather than trailing it, and its probe round-trips
one attestation through the route. EXIT-004 no longer defers reputation to a
recorded gap. ADMIN-001 accounts for the one new operator gate. STATUS-001
gains the rule its enumeration was already following: a list-element key path
is published only while the list has an element, and `independence.export.gaps`
is now empty.

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/forge-exit-rehearsals.md
  • modified docs/forge-operator-independence.md
  • modified lib/openagents/data_rights/account_export.ex
  • modified lib/openagents/data_rights/export_inventory.ex
  • modified lib/openagents/reputation.ex
  • added lib/openagents/reputation/subject_claim.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/reputation_controller.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824024146_create_reputation_subject_claims.exs
  • modified test/openagents/data_rights/account_export_test.exs
  • modified test/openagents/data_rights/export_inventory_test.exs
  • modified test/openagents/network_status_test.exs
  • added test/openagents/reputation/subject_claim_test.exs
  • added test/openagents_web/controllers/reputation_subject_claim_controller_test.exs
  • modified test/openagents_web/operator_surface_test.exs

Diff

18 files changed, +1318 -69

INVARIANTS.md modified +74 -21

@@ -1197,12 +1197,26 @@ stay inside the repository, and a `private` attestation withholds the outcome

1197 1197
reference and every evidence reference from the signed claim while remaining
1198 1198
verifiable.
1199 1199
1200
A subject is a bare string inside the signed claim, and it stays that way. What
1201
resolves one to an account is a separate binding, `reputation_subject_claims`:
1202
a claim the account makes and an operator decides, where only a `linked` claim
1203
resolves and a unique index on `subject_id` means one string never resolves to
1204
two accounts. The binding carries the kind — `account`, `forum_actor`, or
1205
`agent` — with one `CHECK` constraint per kind, because putting the kind on the
1206
attestation would either leave it outside the signature or invalidate every
1207
signature already published. Nothing in issuance or verification reads the
1208
binding: an attestation is exactly as valid, and exactly as checkable, for a
1209
subject no account has claimed.
1210
1200 1211
Evidence: `OpenAgents.Reputation`, `OpenAgents.Reputation.Claim`,
1201 1212
`OpenAgents.Reputation.Attestation`, `OpenAgents.Reputation.SigningKey`,
1202
`OpenAgents.Reputation.PolicyReceipt`, the append-only and uniqueness
1203
constraints on `reputation_attestations`, `OpenAgentsWeb.ReputationController`,
1204
`test/openagents/reputation_test.exs`, and
1205
`test/openagents_web/controllers/reputation_controller_test.exs`.
1213
`OpenAgents.Reputation.PolicyReceipt`, `OpenAgents.Reputation.SubjectClaim`,
1214
the append-only and uniqueness constraints on `reputation_attestations`, the
1215
per-kind reference constraint on `reputation_subject_claims`,
1216
`OpenAgentsWeb.ReputationController`, `test/openagents/reputation_test.exs`,
1217
`test/openagents/reputation/subject_claim_test.exs`,
1218
`test/openagents_web/controllers/reputation_subject_claim_controller_test.exs`,
1219
and `test/openagents_web/controllers/reputation_controller_test.exs`.
1206 1220
1207 1221
### SETTLEMENT-001 — A bounty pays once, against fingerprinted evidence
1208 1222

@@ -2322,6 +2336,12 @@ sentence:

2322 2336
  `PATCH /api/v3/forum/topics/:id` and `PATCH /api/v3/forum/posts/:id`; and
2323 2337
  approving or rejecting an identity claim from `/admin/forum/claims` and
2324 2338
  `PATCH /api/v3/forum/claims/:id`.
2339
- Approving or rejecting a reputation subject claim under
2340
  `GET /api/v3/reputation/subject-claims/pending` and
2341
  `PATCH /api/v3/reputation/subject-claims/:id`
2342
  (`OpenAgentsWeb.ReputationController`). The decision binds an attestation
2343
  subject to an account under `EXIT-001`; it never issues, revokes, or alters
2344
  an attestation, and `REPUTATION-001`'s verification does not read it.
2325 2345
- Suspending and reinstating an agent under `/api/operator/agents/:handle`.
2326 2346
- Creating, authorizing, recording against, and deleting artifact listings under
2327 2347
  `/api/operator/artifact-listings`.

@@ -2920,6 +2940,14 @@ anonymous caller — the posture `LEADERBOARD-001` gets from

2920 2940
`OpenAgents.Leaderboard.Entry`'s field set. Degrading may not introduce a key
2921 2941
either: a failed read is absent, never an error string.
2922 2942
2943
A list-element key path is published only while that list has an element, so
2944
the enumeration follows the data in both directions. `independence.export.gaps`
2945
is empty today because `EXIT-001` records no `partial` or `blocked` family, and
2946
its `family`, `status`, and `issue` paths are therefore not declared. A family
2947
that becomes a gap republishes all three, and the enumeration fails until this
2948
contract readmits them — which is the decision being asked for, not an
2949
accident.
2950
2923 2951
What the projection carries beside counts is the bounded public SCV activity
2924 2952
band (`scvs`: a derived public id, a label, a status, a weight, and one
2925 2953
bounded activity line), the forge deploy lane (short shas, statuses, timings,

@@ -3371,8 +3399,9 @@ this ledger records the change rather than trailing it.

3371 3399
trip it rather than reading its source: one `forum` post and topic, one
3372 3400
`thread` with a transcript entry, one `push_receipt`, one `box` lease and run,
3373 3401
one `computer`, one `agent` link, one `deployment` request, one
3374
`pull_request`, one `stack` with its entry, and one `issue_dependency` are
3375
seeded and read back through the route in an authenticated session. A family
3402
`pull_request`, one `stack` with its entry, one `issue_dependency`, and one
3403
`reputation` attestation whose subject the account established are seeded and
3404
read back through the route in an authenticated session. A family
3376 3405
whose record stops coming back turns this red, and so does a receipt returned
3377 3406
under a principal that is not the requesting account. `push_receipt` is probed
3378 3407
there rather than against the route inventory: what the account gets back is

@@ -3406,21 +3435,44 @@ means two accounts cannot both resolve one legacy identity. The claims

3406 3435
themselves travel in the document at every status, so an account can see what
3407 3436
it asked for and what the operator decided.
3408 3437
3409
No family is `blocked` today, which is a result rather than a default: #142
3410
opened the private-repository metadata reads, #143 exported the forge-owned and
3411
forum-owned families, and #165 added the cross-repository read. One family
3412
stays `partial`, and it is not an enumeration problem. A `reputation`
3413
attestation names a `subject_id` the issuer supplies and an `issuer_key_id`
3414
that is the operator's; no column, and no table on this surface, resolves
3415
either to an account, and no route creates an attestation. There is no filter
3416
that would find an account's own attestations, so the ledger records the gap
3417
and issue #171 carries the subject binding. The export names that omission in
3418
its own `not_included` section rather than leaving a recipient to infer it.
3438
No family is `blocked` or `partial` today, which is a result rather than a
3439
default: #142 opened the private-repository metadata reads, #143 exported the
3440
forge-owned and forum-owned families, #165 added the cross-repository read, and
3441
#171 closed the last gap. `reputation` was that gap, and it was never an
3442
enumeration problem. An attestation names its subject with a bare string the
3443
issuer supplies, so what was missing was a binding rather than a query. The
3444
binding is `reputation_subject_claims`, and it resolves a subject the way
3445
`forum_actor_links` resolves a legacy forum identity: a claim an account makes,
3446
an operator decides, and only a `linked` claim resolves. Its unique index is on
3447
`subject_id` alone rather than on the kind and the string together, because an
3448
attestation names its subject with the bare string — so one string resolves to
3449
at most one account, and two accounts cannot both answer to it. The
3450
attestations travel under `repository_work` behind the same `readable_by/2`
3451
join the other three repository-keyed families use, and the `repository` and
3452
`private` transparency tiers stay behind the membership test
3453
`OpenAgentsWeb.ReputationController` applies, so widening the export never
3454
widens disclosure. What the export still omits it names in its own
3455
`not_included` section rather than leaving a recipient to infer it.
3456
3457
The subject kind lives on the claim rather than on the attestation, and that
3458
follows from `REPUTATION-001` rather than from convenience. An attestation's
3459
`subject_id` sits inside the Ed25519-signed claim, so a `subject_kind` column
3460
on `reputation_attestations` would either stay outside the signature — a field
3461
a verifier must not trust — or change the claim shape and invalidate every
3462
signature already published. Three kinds stay distinct, because collapsing them
3463
is what produced a bare string: `account`, whose subject is this forge's own
3464
`user:<account-id>` reference and which the table checks without an operator;
3465
`forum_actor`, which carries the `forum_actor_links` row that established a
3466
legacy identity; and `agent`, which carries the `agents` row. One `CHECK`
3467
constraint states all three rules in SQL, so the shape is the database's
3468
decision rather than a changeset's, and a row inserted past every changeset is
3469
still refused.
3419 3470
3420 3471
Evidence: `OpenAgents.DataRights.ExportInventory`,
3421 3472
`OpenAgents.DataRights.AccountExport`, `OpenAgentsWeb.ApiRouteAuthority`,
3422 3473
`test/openagents/data_rights/export_inventory_test.exs`,
3423
`test/openagents/data_rights/account_export_test.exs`, and
3474
`test/openagents/data_rights/account_export_test.exs`,
3475
`test/openagents/reputation/subject_claim_test.exs`, and
3424 3476
`docs/forge-operator-independence.md`.
3425 3477
3426 3478
### EXIT-002 — Served state is checkable against the WAL with no database

@@ -3544,10 +3596,11 @@ links, pull requests, stacks, and issue dependencies leave through

3544 3596
metadata reads answer their own members now. A stack's boundary object ids
3545 3597
travel in that document precisely because a clone cannot fetch the refs holding
3546 3598
them, so the withheld namespace costs the account the refs and not the shape of
3547
its own work. Reputation attestations remain `EXIT-001`'s recorded gap rather
3548
than a claim here, and issue #171 carries them. A single complete invariant
3549
plus a recorded gap is worth more than four that assert less than they appear
3550
to.
3599
its own work. Reputation attestations leave through that document too, now that
3600
#171 bound an attestation subject to an account, and they carry their signed
3601
claim verbatim rather than depending on this invariant to be checkable. One
3602
complete invariant about the Git plane is worth more than four that assert less
3603
than they appear to.
3551 3604
3552 3605
Amended 2026-08-23 (issue #179): the advertised ref set was the whole
3553 3606
population, and it is not what a clone walks. A clone follows every advertised
docs/forge-exit-rehearsals.md modified +3 -2

@@ -238,8 +238,9 @@ returning a shorter document that reads as complete.

238 238
   collection publishes its own cap; a truncated collection is flagged, and Box
239 239
   run output reports the full byte size alongside the capped bytes.
240 240
2. Read `"not_included"`. It names the families the document does not carry and
241
   why, including the one repository-keyed family that has no account-scoped
242
   read (#171).
241
   why. Reputation attestations left that list when #171 bound an attestation
242
   subject to an account; they now travel under `"repository_work"`, and only a
243
   subject the account holds a `linked` claim on reaches the document.
243 244
3. Compare against `OpenAgents.DataRights.ExportInventory`, which `EXIT-001`
244 245
   enforces against the surface in both directions, and against the same
245 246
   counts published at `GET /api/status` under `independence.export`.
docs/forge-operator-independence.md modified +15 -13

@@ -158,15 +158,18 @@ its own owner and push receipts left through no route at all. The ledger keeps

158 158
the `blocked` status and its shape checks, because the honest move when a
159 159
family becomes unreadable is to record it.
160 160
161
What is reachable and still has no account-scoped read: reputation
162
attestations. This is the one repository-keyed family issue #165 could not
163
move, and the reason is not enumeration. An attestation names a `subject_id`
164
that the issuer supplies and an `issuer_key_id` that is the operator's; no
165
column, and no table on this surface, resolves either to an account, and no
166
route creates an attestation. There is no filter that would find an account's
167
own attestations, so the export names the gap in `not_included` rather than
168
returning an empty list that reads like an answer. Issue #171 carries the
169
subject binding that would make the read possible.
161
Nothing is `partial` today either, and reputation attestations were the last
162
family to move. Issue #165 could not move them, and the reason was never
163
enumeration: an attestation names its subject with a bare string the issuer
164
supplies, so what was missing was a binding rather than a query. Issue #171
165
added it. `reputation_subject_claims` resolves a subject the way
166
`forum_actor_links` resolves a legacy forum identity — a claim you make, an
167
operator decides, and only a `linked` claim resolves — and its unique index on
168
`subject_id` means one subject never resolves to two accounts. The attestations
169
travel under `repository_work` behind the same `readable_by/2` join the other
170
repository-keyed families use, and the `repository` and `private` transparency
171
tiers stay behind the same membership test the API applies, so a wider export
172
never means wider disclosure.
170 173
171 174
Who owns a migrated forum post is the subtle question in the account export,
172 175
and it is answered rather than assumed. Two identities resolve to an account:

@@ -254,9 +257,9 @@ The one omission is the `refs/internal/` namespace, where stack boundary

254 257
commits are retained without being advertised; the proof asserts that this is
255 258
the *only* omission, so withholding a branch would turn it red.
256 259
257
That is exit for source. It is not yet exit for everything: an account cannot
258
identify its own reputation attestations, private repository exports are not
259
encrypted, and no commitment to the WAL is published outside operator storage.
260
That is exit for source. It is not yet exit for everything: private repository
261
exports are not encrypted, and no commitment to the WAL is published outside
262
operator storage.
260 263
Those are covered by the gaps above rather than by a claim. Saying so is the
261 264
point. Five green invariants that assert less than they appear to would be
262 265
worse than five plus a recorded gap.

@@ -312,7 +315,6 @@ the one people clone from.

312 315
| Gap | Issue |
313 316
| --- | --- |
314 317
| The live forge cannot serve a full clone of its own repository | #179 |
315
| No binding from a reputation attestation's subject to an account, so an account cannot identify its own attestations | #171 |
316 318
| No commitment to the WAL is published outside operator storage, so a consistent rewrite still verifies clean | #151 |
317 319
| No export is encrypted to a key the recipient holds, and no column is encrypted at rest | #178 |
318 320
| Five of six exit rehearsals have never been performed | #180 |
lib/openagents/data_rights/account_export.ex modified +77 -12

@@ -77,7 +77,10 @@ defmodule OpenAgents.DataRights.AccountExport do

77 77
  alias OpenAgents.PullRequests.PullRequest
78 78
  alias OpenAgents.Repo
79 79
  alias OpenAgents.Repositories
80
  alias OpenAgents.Repositories.Membership
80 81
  alias OpenAgents.Repositories.Repository
82
  alias OpenAgents.Reputation
83
  alias OpenAgents.Reputation.Attestation
81 84
  alias OpenAgents.Stacks.{Stack, StackEntry}
82 85
  alias OpenAgents.Threads.{Event, Thread}
83 86

@@ -96,6 +99,7 @@ defmodule OpenAgents.DataRights.AccountExport do

96 99
  @maximum_stacks 2_000
97 100
  @maximum_stack_entries 10_000
98 101
  @maximum_issue_dependencies 5_000
102
  @maximum_attestations 5_000
99 103
100 104
  @doc """
101 105
  Builds the account's export document.

@@ -157,6 +161,10 @@ defmodule OpenAgents.DataRights.AccountExport do

157 161
      "account_actor_ref" => account_actor_ref(user),
158 162
      "authored_actor_refs" => refs,
159 163
      "claims" => Enum.map(actor_links, &actor_link_export/1),
164
      "reputation_subject_claims" =>
165
        user
166
        |> Reputation.list_subject_claims()
167
        |> Enum.map(&Reputation.subject_claim_projection/1),
160 168
      "resolution_rule" =>
161 169
        "A legacy forum identity resolves to this account only through a claim with " <>
162 170
          "status \"linked\". Posts under a pending, rejected, or unclaimed actor_ref " <>

@@ -607,7 +615,73 @@ defmodule OpenAgents.DataRights.AccountExport do

607 615
          "not a member of reaches this document.",
608 616
      "pull_requests" => pull_requests_export(user, readable),
609 617
      "stacks" => stacks_export(user, readable),
610
      "issue_dependencies" => issue_dependencies_export(user, readable)
618
      "issue_dependencies" => issue_dependencies_export(user, readable),
619
      "attestations" => attestations_export(user, readable)
620
    }
621
  end
622
623
  # A reputation attestation names its subject with a bare string, so the
624
  # filter is the subject binding rather than an authoring column: the strings
625
  # this account has a `linked` claim on in `reputation_subject_claims`. An
626
  # account with no linked claim gets nothing, and a subject another account
627
  # holds is unreachable, because the table's unique index on `subject_id`
628
  # means one string resolves to at most one account.
629
  #
630
  # Disclosure does not widen here. `readable_by/2` admits a public repository
631
  # to a non-member, and `OpenAgentsWeb.ReputationController` shows such a
632
  # reader `public` attestations only, so the same membership test gates the
633
  # `repository` and `private` tiers in this document. The account is active —
634
  # `build/1` refuses an inactive one — so a membership row is the whole of
635
  # `Repositories.member?/2` here.
636
  defp attestations_export(user, readable) do
637
    subject_ids = Reputation.linked_subject_ids(user)
638
639
    rows =
640
      Repo.all(
641
        from attestation in Attestation,
642
          join: repository in subquery(readable),
643
          on: repository.id == attestation.repository_id,
644
          left_join: membership in Membership,
645
          on: membership.repository_id == repository.id and membership.user_id == ^user.id,
646
          where:
647
            attestation.subject_id in ^subject_ids and
648
              (attestation.transparency_tier == "public" or not is_nil(membership.user_id)),
649
          order_by: [asc: attestation.attested_at, asc: attestation.id],
650
          limit: ^(@maximum_attestations + 1),
651
          select: {attestation, repository.owner, repository.name}
652
      )
653
654
    %{
655
      "subject_resolution_rule" =>
656
        "An attestation reaches this document only through a reputation subject claim with " <>
657
          "status \"linked\". The issuer supplies a bare subject_id, so nothing but an " <>
658
          "established claim says the subject is this account's, and one subject string " <>
659
          "resolves to at most one account.",
660
      "established_subjects" => subject_ids,
661
      "records" => rows |> Enum.take(@maximum_attestations) |> Enum.map(&attestation_export/1),
662
      "records_truncated" => length(rows) > @maximum_attestations
663
    }
664
  end
665
666
  # The signed claim travels verbatim beside its signature and the issuer key,
667
  # so a recipient checks the attestation offline the way
668
  # `OpenAgentsWeb.ReputationController` lets a stranger check it.
669
  defp attestation_export({attestation, owner_login, name}) do
670
    %{
671
      "id" => attestation.id,
672
      "repository" => repository_path(owner_login, name),
673
      "issue_number" => attestation.issue_number,
674
      "event_type" => attestation.event_type,
675
      "subject_id" => attestation.subject_id,
676
      "issuer_key_id" => attestation.issuer_key_id,
677
      "transparency_tier" => attestation.transparency_tier,
678
      "claim" => attestation.claim,
679
      "claim_digest" => attestation.claim_digest,
680
      "signature" => attestation.signature,
681
      "signature_algorithm" => attestation.signature_algorithm,
682
      "revoked" => not is_nil(attestation.revoked_at),
683
      "revocation_reason_code" => attestation.revocation_reason_code,
684
      "attested_at" => iso8601(attestation.attested_at)
611 685
    }
612 686
  end
613 687

@@ -827,7 +901,8 @@ defmodule OpenAgents.DataRights.AccountExport do

827 901
      "pull_requests" => @maximum_pull_requests,
828 902
      "stacks" => @maximum_stacks,
829 903
      "stack_entries" => @maximum_stack_entries,
830
      "issue_dependencies" => @maximum_issue_dependencies
904
      "issue_dependencies" => @maximum_issue_dependencies,
905
      "attestations" => @maximum_attestations
831 906
    }
832 907
  end
833 908

@@ -848,16 +923,6 @@ defmodule OpenAgents.DataRights.AccountExport do

848 923
        "mechanism" => "git clone with an oa_pat_ token",
849 924
        "issue" => nil
850 925
      },
851
      %{
852
        "family" => "reputation",
853
        "reason" =>
854
          "A reputation attestation names a subject_id — a solver identity string the issuer " <>
855
            "supplies — and nothing on this surface resolves one to an account. The issuer is " <>
856
            "the operator's admitted signing key, and no route creates an attestation, so " <>
857
            "there is no record here this account authored and no filter that would find one.",
858
        "mechanism" => "GET /api/v3/repos/{owner}/{repo}/issues/{issue_number}/attestations",
859
        "issue" => 171
860
      },
861 926
      %{
862 927
        "family" => "forum",
863 928
        "reason" =>
lib/openagents/data_rights/export_inventory.ex modified +12 -14

@@ -52,9 +52,6 @@ defmodule OpenAgents.DataRights.ExportInventory do

52 52
          note: String.t()
53 53
        }
54 54
55
  # No binding from a reputation subject to an account, #171.
56
  @reputation_subject_issue 171
57
58 55
  # The account-scoped export of forge-owned and forum-owned records, #143.
59 56
  @account_export "GET /data/export/account"
60 57
  @account_export_proof {:test, "test/openagents/data_rights/account_export_test.exs"}

@@ -325,21 +322,22 @@ defmodule OpenAgents.DataRights.ExportInventory do

325 322
        "The prerequisite edges the account recorded, with both issue numbers and " <>
326 323
          "the prerequisite's own state, behind the same readable_by gate."
327 324
    },
328
329
    # ── reachable, but nothing here names an account ──────────────────────
330 325
    %{
331 326
      family: :reputation,
332 327
      api?: true,
333
      status: :partial,
334
      mechanism: "GET /api/v3/repos/{owner}/{repo}/issues/{issue_number}/attestations",
335
      proof: nil,
336
      issue: @reputation_subject_issue,
328
      status: :portable,
329
      mechanism: @account_export,
330
      proof: @account_export_proof,
331
      issue: nil,
337 332
      note:
338
        "The only family #165 could not move. An attestation names a subject_id " <>
339
          "the issuer supplies and an issuer_key_id that is the operator's; no " <>
340
          "column, and no table on this surface, resolves either to an account. " <>
341
          "There is no filter that would find an account's own attestations, so " <>
342
          "the export names the gap instead of returning an empty list."
333
        "An attestation names its subject with a bare string the issuer supplies, " <>
334
          "so the filter is a binding rather than an authoring column: the " <>
335
          "subject strings the account holds a linked claim on in " <>
336
          "reputation_subject_claims, #171. The table's unique index on " <>
337
          "subject_id means one string resolves to at most one account. The " <>
338
          "records travel under repository_work behind the same readable_by " <>
339
          "gate, and the repository and private tiers stay behind the membership " <>
340
          "test OpenAgentsWeb.ReputationController applies."
343 341
    },
344 342
345 343
    # ── not a record a user authors and takes with them ───────────────────
lib/openagents/reputation.ex modified +184 -1

@@ -20,6 +20,12 @@ defmodule OpenAgents.Reputation do

20 20
      state. It trusts no column and no caller.
21 21
    * `revoke/4` and `correct/4` publish a linked invalidating event.
22 22
23
  A subject is a bare string inside the signed claim, so the context also
24
  owns the binding that resolves one to an account: `claim_subject/2`,
25
  `approve_subject_claim/1`, and `reject_subject_claim/1`. Only a `linked`
26
  claim resolves a subject, and `linked_subject_ids/1` is the one filter an
27
  account-scoped read may use.
28
23 29
  Reads project the stored claim verbatim, so a client can verify an
24 30
  attestation the forge serves without trusting the surface that displayed it.
25 31
  """

@@ -32,7 +38,9 @@ defmodule OpenAgents.Reputation do

32 38
  alias OpenAgents.Provenance.Canonical
33 39
  alias OpenAgents.Repo
34 40
  alias OpenAgents.Repositories.Repository
35
  alias OpenAgents.Reputation.{Attestation, Claim, PolicyReceipt, SigningKey}
41
  alias OpenAgents.Accounts.User
42
  alias OpenAgents.Forum.ActorLink
43
  alias OpenAgents.Reputation.{Attestation, Claim, PolicyReceipt, SigningKey, SubjectClaim}
36 44
37 45
  @policy_id "openagents.reputation.verifier.v1"
38 46
  @policy_version 1

@@ -383,6 +391,181 @@ defmodule OpenAgents.Reputation do

383 391
    }
384 392
  end
385 393
394
  ## ── the subject binding ────────────────────────────────────────────────
395
396
  @doc """
397
  Records an account's claim on an attestation subject.
398
399
  The claim starts `pending`: nothing has established that the subject is this
400
  account's, so nothing resolves yet. `attributes` carries `:subject_kind`,
401
  `:subject_id`, and for the two kinds that name another namespace, the row
402
  that already established the identity — `:forum_actor_link_id` for a legacy
403
  forum actor, `:agent_id` for an agent.
404
405
  The cross-namespace checks live here because a `CHECK` constraint cannot read
406
  another table: a `forum_actor` claim must name a `linked` `forum_actor_links`
407
  row belonging to this account whose `actor_ref` is the subject, and an
408
  `agent` claim must name a `linked` `agent_user_links` row for this account.
409
  The shape checks — which kind admits which reference, and that an `account`
410
  subject is this account's own actor reference — are constraints on the table.
411
  """
412
  @spec claim_subject(User.t(), map()) :: {:ok, SubjectClaim.t()} | {:error, term()}
413
  def claim_subject(%User{} = user, attributes) do
414
    attributes = Map.new(attributes, fn {key, value} -> {to_string(key), value} end)
415
416
    with :ok <- validate_subject_reference(user, attributes) do
417
      %SubjectClaim{}
418
      |> SubjectClaim.changeset(
419
        Map.merge(attributes, %{
420
          "user_id" => user.id,
421
          "status" => "pending",
422
          "proof_evidence" => %{"started_at" => DateTime.to_iso8601(DateTime.utc_now())}
423
        })
424
      )
425
      |> Repo.insert()
426
    end
427
  end
428
429
  @doc "Approves a pending subject claim after its proof has been checked."
430
  @spec approve_subject_claim(SubjectClaim.t()) :: {:ok, SubjectClaim.t()} | {:error, term()}
431
  def approve_subject_claim(%SubjectClaim{status: "pending"} = claim) do
432
    now = DateTime.utc_now()
433
434
    claim
435
    |> SubjectClaim.changeset(%{
436
      status: "linked",
437
      linked_at: now,
438
      proof_evidence:
439
        Map.put(claim.proof_evidence || %{}, "approved_at", DateTime.to_iso8601(now))
440
    })
441
    |> Repo.update()
442
  end
443
444
  def approve_subject_claim(%SubjectClaim{}), do: {:error, :not_pending}
445
446
  @doc "Rejects a pending subject claim."
447
  @spec reject_subject_claim(SubjectClaim.t()) :: {:ok, SubjectClaim.t()} | {:error, term()}
448
  def reject_subject_claim(%SubjectClaim{status: "pending"} = claim) do
449
    claim
450
    |> SubjectClaim.changeset(%{status: "rejected", rejected_at: DateTime.utc_now()})
451
    |> Repo.update()
452
  end
453
454
  def reject_subject_claim(%SubjectClaim{}), do: {:error, :not_pending}
455
456
  @doc "Every subject claim this account made, newest first."
457
  @spec list_subject_claims(User.t()) :: [SubjectClaim.t()]
458
  def list_subject_claims(%User{id: user_id}) do
459
    Repo.all(
460
      from claim in SubjectClaim,
461
        where: claim.user_id == ^user_id,
462
        order_by: [desc: claim.inserted_at, desc: claim.id]
463
    )
464
  end
465
466
  @doc "Every subject claim still waiting on an operator, oldest first."
467
  @spec list_pending_subject_claims() :: [SubjectClaim.t()]
468
  def list_pending_subject_claims do
469
    Repo.all(
470
      from claim in SubjectClaim,
471
        where: claim.status == "pending",
472
        order_by: [asc: claim.inserted_at, asc: claim.id]
473
    )
474
  end
475
476
  @doc "The subject claim behind `id`, or `{:error, :not_found}`."
477
  @spec fetch_subject_claim(String.t()) :: {:ok, SubjectClaim.t()} | {:error, :not_found}
478
  def fetch_subject_claim(id) do
479
    case Ecto.UUID.cast(id) do
480
      {:ok, uuid} ->
481
        case Repo.get(SubjectClaim, uuid) do
482
          %SubjectClaim{} = claim -> {:ok, claim}
483
          nil -> {:error, :not_found}
484
        end
485
486
      :error ->
487
        {:error, :not_found}
488
    end
489
  end
490
491
  @doc """
492
  The subject strings this account has established.
493
494
  Only a `linked` claim resolves a subject, so a pending or rejected claim
495
  widens nothing. An account with no linked claim gets `[]`, and a read
496
  filtered on `[]` returns nothing rather than everything.
497
  """
498
  @spec linked_subject_ids(User.t()) :: [String.t()]
499
  def linked_subject_ids(%User{id: user_id}) do
500
    Repo.all(
501
      from claim in SubjectClaim,
502
        where: claim.user_id == ^user_id and claim.status == "linked",
503
        select: claim.subject_id,
504
        order_by: [asc: claim.subject_id]
505
    )
506
  end
507
508
  @doc "The public projection of one subject claim."
509
  @spec subject_claim_projection(SubjectClaim.t()) :: map()
510
  def subject_claim_projection(%SubjectClaim{} = claim) do
511
    %{
512
      "id" => claim.id,
513
      "subject_kind" => claim.subject_kind,
514
      "subject_id" => claim.subject_id,
515
      "status" => claim.status,
516
      "proof_method" => claim.proof_method,
517
      "forum_actor_link_id" => claim.forum_actor_link_id,
518
      "agent_id" => claim.agent_id,
519
      "claimed_at" => iso8601(claim.inserted_at),
520
      "linked_at" => iso8601(claim.linked_at),
521
      "rejected_at" => iso8601(claim.rejected_at)
522
    }
523
  end
524
525
  defp iso8601(nil), do: nil
526
  defp iso8601(%DateTime{} = at), do: DateTime.to_iso8601(at)
527
528
  # An `account` subject is checked by the table: the string has to be this
529
  # account's own actor reference. The other two kinds name a row in another
530
  # namespace, and only a link that namespace already established counts.
531
  defp validate_subject_reference(_user, %{"subject_kind" => "account"}), do: :ok
532
533
  defp validate_subject_reference(user, %{"subject_kind" => "forum_actor"} = attributes) do
534
    link =
535
      Repo.get_by(ActorLink,
536
        id: cast_uuid(attributes["forum_actor_link_id"]),
537
        user_id: user.id,
538
        status: "linked"
539
      )
540
541
    cond do
542
      is_nil(link) -> {:error, :forum_actor_not_linked}
543
      link.actor_ref != attributes["subject_id"] -> {:error, :subject_is_not_the_actor_ref}
544
      true -> :ok
545
    end
546
  end
547
548
  defp validate_subject_reference(user, %{"subject_kind" => "agent"} = attributes) do
549
    linked? =
550
      Repo.exists?(
551
        from link in OpenAgents.Agents.AgentUserLink,
552
          where:
553
            link.agent_id == ^cast_uuid(attributes["agent_id"]) and
554
              link.user_id == ^user.id and link.status == "linked"
555
      )
556
557
    if linked?, do: :ok, else: {:error, :agent_not_linked}
558
  end
559
560
  defp validate_subject_reference(_user, _attributes), do: {:error, :unsupported_subject_kind}
561
562
  defp cast_uuid(value) do
563
    case Ecto.UUID.cast(value) do
564
      {:ok, uuid} -> uuid
565
      :error -> Ecto.UUID.generate()
566
    end
567
  end
568
386 569
  defp persist(policy, key, signer, repository, outcome, evidence, attributes) do
387 570
    attested_at = attributes[:attested_at] || DateTime.utc_now()
388 571
lib/openagents/reputation/subject_claim.ex added +78

@@ -0,0 +1,78 @@

1
defmodule OpenAgents.Reputation.SubjectClaim do
2
  @moduledoc """
3
  One account's claim on one attestation subject.
4
5
  `OpenAgents.Reputation.Attestation` names its subject with a bare string the
6
  issuer supplies. This row says what that string names and which account holds
7
  it, and only a `linked` status resolves it — the rule
8
  `OpenAgents.Forum.ActorLink` already applies to a legacy forum identity.
9
10
  A subject is one of three things, and collapsing them is what produced a bare
11
  string in the first place:
12
13
    * `account` — this forge's own actor reference, `user:<account-id>`. The
14
      string names the account, so the database checks it rather than an
15
      operator.
16
    * `forum_actor` — a legacy forum identity that may have no account of its
17
      own. The claim carries the `forum_actor_links` row that established it.
18
    * `agent` — a registered agent. The claim carries the `agents` row.
19
20
  The kind is not on the attestation. An attestation's `subject_id` is inside
21
  the signed claim, so a kind column there would either go unsigned or
22
  invalidate every published signature.
23
  """
24
25
  use Ecto.Schema
26
  import Ecto.Changeset
27
28
  alias OpenAgents.Accounts.User
29
  alias OpenAgents.Agents.Agent
30
  alias OpenAgents.Forum.ActorLink
31
32
  @primary_key {:id, :binary_id, autogenerate: true}
33
  @foreign_key_type :binary_id
34
35
  @subject_kinds ~w(account forum_actor agent)
36
  @statuses ~w(pending linked rejected)
37
38
  schema "reputation_subject_claims" do
39
    field :subject_kind, :string
40
    field :subject_id, :string
41
    field :status, :string, default: "pending"
42
    field :proof_method, :string
43
    field :proof_evidence, :map
44
    field :linked_at, :utc_datetime_usec
45
    field :rejected_at, :utc_datetime_usec
46
47
    belongs_to :user, User
48
    belongs_to :forum_actor_link, ActorLink
49
    belongs_to :agent, Agent
50
51
    timestamps(type: :utc_datetime_usec)
52
  end
53
54
  @type t :: %__MODULE__{}
55
56
  def subject_kinds, do: @subject_kinds
57
  def statuses, do: @statuses
58
59
  def changeset(claim, attributes) do
60
    claim
61
    |> cast(
62
      attributes,
63
      ~w(subject_kind subject_id user_id forum_actor_link_id agent_id status proof_method
64
         proof_evidence linked_at rejected_at)a
65
    )
66
    |> validate_required(~w(subject_kind subject_id user_id status)a)
67
    |> validate_inclusion(:subject_kind, @subject_kinds)
68
    |> validate_inclusion(:status, @statuses)
69
    |> validate_length(:subject_id, min: 1, max: 256)
70
    |> unique_constraint(:subject_id)
71
    |> foreign_key_constraint(:user_id)
72
    |> foreign_key_constraint(:forum_actor_link_id)
73
    |> foreign_key_constraint(:agent_id)
74
    |> check_constraint(:subject_kind, name: :reputation_subject_claims_kind_check)
75
    |> check_constraint(:status, name: :reputation_subject_claims_status_check)
76
    |> check_constraint(:subject_id, name: :reputation_subject_claims_reference_check)
77
  end
78
end
lib/openagents_web/api_route_authority.ex modified +4

@@ -340,6 +340,10 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

340 340
      "delete /api/v3/agents/links/:id" => {:required_bearer, :agent, :legacy},
341 341
      "post /api/v3/forum/claims" => {:required_bearer, :forum, :legacy},
342 342
      "get /api/v3/forum/claims" => {:required_bearer, :forum, :legacy},
343
      "post /api/v3/reputation/subject-claims" => {:required_bearer, :reputation, :legacy},
344
      "get /api/v3/reputation/subject-claims" => {:required_bearer, :reputation, :legacy},
345
      "get /api/v3/reputation/subject-claims/pending" => {:required_bearer, :reputation, :legacy},
346
      "patch /api/v3/reputation/subject-claims/:id" => {:required_bearer, :reputation, :legacy},
343 347
      # Moderation and claim review: a bearer the controller then checks for
344 348
      # operator authority.
345 349
      "patch /api/v3/forum/topics/:id" => {:required_bearer, :forum, :legacy},
lib/openagents_web/controllers/reputation_controller.ex modified +96

@@ -8,10 +8,16 @@ defmodule OpenAgentsWeb.ReputationController do

8 8
  read the revocation state without trusting this API or the web interface.
9 9
  Disclosure follows the repository: a reader outside the repository sees
10 10
  `public` attestations only.
11
12
  An attestation names its subject with a bare string. The subject-claim
13
  actions here are how an account establishes which strings are its own — it
14
  asks, and an operator decides — so that an account-scoped read such as
15
  `GET /data/export/account` has a filter that resolves rather than guesses.
11 16
  """
12 17
13 18
  use OpenAgentsWeb, :controller
14 19
20
  alias OpenAgents.Accounts
15 21
  alias OpenAgents.Reputation
16 22
  alias OpenAgents.Repositories
17 23
  alias OpenAgentsWeb.ControllerHelpers

@@ -72,6 +78,96 @@ defmodule OpenAgentsWeb.ReputationController do

72 78
    Ecto.NoResultsError -> not_found(conn)
73 79
  end
74 80
81
  ## Subject claims
82
83
  @doc "Records this account's claim on an attestation subject."
84
  def create_subject_claim(conn, %{"subject_kind" => kind, "subject_id" => subject_id} = params)
85
      when is_binary(kind) and is_binary(subject_id) do
86
    attributes = %{
87
      "subject_kind" => kind,
88
      "subject_id" => String.trim(subject_id),
89
      "forum_actor_link_id" => params["forum_actor_link_id"],
90
      "agent_id" => params["agent_id"],
91
      "proof_method" => params["proof_method"] || "api_token"
92
    }
93
94
    case Reputation.claim_subject(conn.assigns.current_user, attributes) do
95
      {:ok, claim} ->
96
        conn
97
        |> put_status(:created)
98
        |> json(Reputation.subject_claim_projection(claim))
99
100
      {:error, %Ecto.Changeset{} = changeset} ->
101
        unprocessable(conn, changeset_message(changeset))
102
103
      {:error, reason} ->
104
        unprocessable(conn, Atom.to_string(reason))
105
    end
106
  end
107
108
  def create_subject_claim(conn, _params),
109
    do: unprocessable(conn, "subject_kind and subject_id are required")
110
111
  @doc "Every subject claim this account made, at every status."
112
  def list_subject_claims(conn, _params) do
113
    claims = Reputation.list_subject_claims(conn.assigns.current_user)
114
    json(conn, %{"claims" => Enum.map(claims, &Reputation.subject_claim_projection/1)})
115
  end
116
117
  @doc "Every subject claim waiting on review. Operators only."
118
  def pending_subject_claims(conn, _params) do
119
    case ensure_operator(conn) do
120
      :ok ->
121
        claims = Reputation.list_pending_subject_claims()
122
        json(conn, %{"claims" => Enum.map(claims, &Reputation.subject_claim_projection/1)})
123
124
      {:error, :forbidden} ->
125
        forbidden(conn)
126
    end
127
  end
128
129
  @doc "Approves or rejects a pending subject claim. Operators only."
130
  def update_subject_claim(conn, %{"id" => id, "status" => status}) do
131
    with :ok <- ensure_operator(conn),
132
         {:ok, claim} <- Reputation.fetch_subject_claim(id),
133
         {:ok, claim} <- review_subject_claim(claim, status) do
134
      json(conn, Reputation.subject_claim_projection(claim))
135
    else
136
      {:error, :forbidden} -> forbidden(conn)
137
      {:error, :not_found} -> not_found(conn)
138
      {:error, :invalid_status} -> unprocessable(conn, "status must be linked or rejected")
139
      {:error, :not_pending} -> conflict(conn, "claim_not_pending")
140
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_message(changeset))
141
    end
142
  end
143
144
  def update_subject_claim(conn, _params),
145
    do: unprocessable(conn, "status must be linked or rejected")
146
147
  defp ensure_operator(conn) do
148
    if Accounts.admin?(conn.assigns[:current_user]), do: :ok, else: {:error, :forbidden}
149
  end
150
151
  defp review_subject_claim(claim, "linked"), do: Reputation.approve_subject_claim(claim)
152
  defp review_subject_claim(claim, "rejected"), do: Reputation.reject_subject_claim(claim)
153
  defp review_subject_claim(_claim, _status), do: {:error, :invalid_status}
154
155
  defp changeset_message(changeset) do
156
    changeset
157
    |> Ecto.Changeset.traverse_errors(fn {message, _opts} -> message end)
158
    |> Enum.map_join("; ", fn {field, messages} ->
159
      "#{field} #{Enum.join(messages, ", ")}"
160
    end)
161
  end
162
163
  defp unprocessable(conn, message),
164
    do: conn |> put_status(:unprocessable_entity) |> json(%{"message" => message})
165
166
  defp conflict(conn, message),
167
    do: conn |> put_status(:conflict) |> json(%{"message" => message})
168
169
  defp forbidden(conn), do: conn |> put_status(:forbidden) |> json(%{"message" => "Forbidden"})
170
75 171
  # What the caller believes it is looking at. A mismatch is reported rather
76 172
  # than corrected, which is how a replayed attestation fails for a client
77 173
  # that names the issue, revision, subject, or verifier it expects.
lib/openagents_web/router.ex modified +10

@@ -593,6 +593,16 @@ defmodule OpenAgentsWeb.Router do

593 593
    post "/forum/claims", ForumApiController, :create_claim
594 594
    get "/forum/claims", ForumApiController, :list_claims
595 595
596
    # Reputation subject claims. An attestation names its subject with a bare
597
    # string, so an account establishes which strings are its own the way it
598
    # claims a legacy forum identity: it asks, and an operator decides.
599
    post "/reputation/subject-claims", ReputationController, :create_subject_claim
600
    get "/reputation/subject-claims", ReputationController, :list_subject_claims
601
602
    # Claim review. The controller refuses a non-operator token.
603
    get "/reputation/subject-claims/pending", ReputationController, :pending_subject_claims
604
    patch "/reputation/subject-claims/:id", ReputationController, :update_subject_claim
605
596 606
    # Moderation and claim review. The controller refuses a non-operator token.
597 607
    get "/forum/claims/pending", ForumApiController, :pending_claims
598 608
    patch "/forum/claims/:id", ForumApiController, :update_claim
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -281,7 +281,8 @@

281 281
    20260823221416,
282 282
    20260824010337,
283 283
    20260824010826,
284
    20260824011303
284
    20260824011303,
285
    20260824024146
285 286
  ],
286 287
  "required_tables": [
287 288
    "users",
priv/repo/migrations/20260824024146_create_reputation_subject_claims.exs added +85

@@ -0,0 +1,85 @@

1
defmodule OpenAgents.Repo.Migrations.CreateReputationSubjectClaims do
2
  use Ecto.Migration
3
4
  @moduledoc """
5
  Binds an attestation subject to an account.
6
7
  `reputation_attestations.subject_id` is a bare string the issuer supplies.
8
  Nothing resolved it, and nothing stopped it from resolving to two things.
9
  This table is the binding: a claim an account makes and an operator decides,
10
  the way `forum_actor_links` resolves a legacy forum identity.
11
12
  The kind lives here rather than on the attestation. The attestation's
13
  `subject_id` sits inside the Ed25519-signed claim, so a `subject_kind`
14
  column on `reputation_attestations` would either stay outside the signature
15
  — a field a verifier must not trust — or change the claim shape and
16
  invalidate every signature already published. REPUTATION-001 keeps
17
  verification independent of the interface, so the typing goes where a
18
  verifier never has to read it.
19
  """
20
21
  def change do
22
    create table(:reputation_subject_claims, primary_key: false) do
23
      add :id, :uuid, primary_key: true, default: fragment("gen_random_uuid()")
24
25
      # What the string names: an account on this forge, a legacy forum actor
26
      # that may have no account of its own, or a registered agent. Collapsing
27
      # the three is how a bare string got here.
28
      add :subject_kind, :string, null: false
29
30
      # The exact string `reputation_attestations.subject_id` carries.
31
      add :subject_id, :string, null: false
32
33
      add :user_id, references(:users, type: :uuid, on_delete: :restrict), null: false
34
35
      # The row in the namespace that already established this identity. One
36
      # per kind, and null for the kinds it does not describe.
37
      add :forum_actor_link_id, references(:forum_actor_links, type: :uuid, on_delete: :restrict)
38
      add :agent_id, references(:agents, type: :binary_id, on_delete: :restrict)
39
40
      add :status, :string, null: false, default: "pending"
41
      add :proof_method, :string
42
      add :proof_evidence, :map
43
      add :linked_at, :utc_datetime_usec
44
      add :rejected_at, :utc_datetime_usec
45
46
      timestamps(type: :utc_datetime_usec)
47
    end
48
49
    # A subject already claimed by one account cannot be claimed by another.
50
    # An attestation names its subject with the bare string, so the string
51
    # alone — not the kind and the string together — is what has to resolve to
52
    # at most one account. Two kinds carrying one string would put the
53
    # ambiguity straight back.
54
    create unique_index(:reputation_subject_claims, [:subject_id])
55
    create index(:reputation_subject_claims, [:user_id, :status])
56
57
    create constraint(:reputation_subject_claims, :reputation_subject_claims_kind_check,
58
             check: "subject_kind IN ('account', 'forum_actor', 'agent')"
59
           )
60
61
    create constraint(:reputation_subject_claims, :reputation_subject_claims_status_check,
62
             check: "status IN ('pending', 'linked', 'rejected')"
63
           )
64
65
    # One constraint per kind, in the database rather than in a changeset.
66
    # An `account` subject names the account itself, so the string is checkable
67
    # here and the operator has nothing to decide beyond that it is well
68
    # formed. A `forum_actor` or `agent` subject names a row in another
69
    # namespace, so the claim has to carry that row.
70
    create constraint(:reputation_subject_claims, :reputation_subject_claims_reference_check,
71
             check: """
72
             CASE subject_kind
73
               WHEN 'account' THEN
74
                 forum_actor_link_id IS NULL AND agent_id IS NULL
75
                 AND subject_id = 'user:' || user_id::text
76
               WHEN 'forum_actor' THEN
77
                 forum_actor_link_id IS NOT NULL AND agent_id IS NULL
78
               WHEN 'agent' THEN
79
                 agent_id IS NOT NULL AND forum_actor_link_id IS NULL
80
               ELSE false
81
             END
82
             """
83
           )
84
  end
85
end
test/openagents/data_rights/account_export_test.exs modified +211 -1

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

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

@@ -355,7 +356,8 @@ defmodule OpenAgents.DataRights.AccountExportTest do

355 356
      end
356 357
357 358
      families = Enum.map(export["not_included"], & &1["family"])
358
      assert "reputation" in families
359
      # `reputation` left this list when #171 gave the subject a binding.
360
      refute "reputation" in families
359 361
      assert "repository_content" in families
360 362
      assert "conversation" in families
361 363

@@ -520,6 +522,153 @@ defmodule OpenAgents.DataRights.AccountExportTest do

520 522
  # something a person can read — a repository path, a topic and board slug, an
521 523
  # issue number, an agent handle — rather than only by a UUID this forge would
522 524
  # have to resolve. Replacing any of those with an id alone turns this red.
525
  describe "a reputation attestation's subject" do
526
    test "an attestation naming a subject this account established comes back" do
527
      user = github_user("account-export-attest", "export-attest")
528
      repository = private_repository_with_member("export-attest-private", user)
529
      issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "Attested"})
530
531
      link_subject!(user, "user:" <> user.id)
532
      attestation = attest!(repository, issue, "user:" <> user.id, "repository")
533
534
      assert {:ok, export} = AccountExport.build(user)
535
      work = export["repository_work"]
536
537
      assert [record] = work["attestations"]["records"]
538
      assert record["id"] == attestation.id
539
      assert record["subject_id"] == "user:" <> user.id
540
      assert record["repository"] == repository.owner <> "/" <> repository.name
541
      assert record["issue_number"] == issue.number
542
      assert record["transparency_tier"] == "repository"
543
      refute work["attestations"]["records_truncated"]
544
545
      # The signed claim travels verbatim, so the recipient checks it offline.
546
      assert record["claim"]["subject"]["actor_id"] == "user:" <> user.id
547
      assert record["claim_digest"] == attestation.claim_digest
548
549
      assert OpenAgents.Reputation.Claim.valid_signature?(
550
               record["claim"],
551
               record["signature"],
552
               issuer_public_key(attestation)
553
             )
554
555
      assert work["attestations"]["established_subjects"] == ["user:" <> user.id]
556
      assert work["attestations"]["subject_resolution_rule"] =~ "linked"
557
    end
558
559
    # Without the linked-claim filter this attestation would come back to
560
    # whoever asked. Dropping `Reputation.linked_subject_ids/1` from
561
    # `attestations_export/2` turns this red.
562
    test "an attestation whose subject the account has not established stays behind" do
563
      user = github_user("account-export-attest-none", "export-attest-none")
564
      repository = private_repository_with_member("export-attest-none-private", user)
565
      issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "Not mine"})
566
567
      _attestation = attest!(repository, issue, "actor:someone-else", "repository")
568
569
      assert {:ok, export} = AccountExport.build(user)
570
      assert export["repository_work"]["attestations"]["records"] == []
571
      assert export["repository_work"]["attestations"]["established_subjects"] == []
572
    end
573
574
    test "a pending claim resolves nothing, and only the operator's decision does" do
575
      user = github_user("account-export-attest-pending", "export-attest-pending")
576
      repository = private_repository_with_member("export-attest-pending-private", user)
577
      issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "Pending"})
578
579
      {:ok, claim} =
580
        Reputation.claim_subject(user, %{
581
          subject_kind: "account",
582
          subject_id: "user:" <> user.id
583
        })
584
585
      _attestation = attest!(repository, issue, "user:" <> user.id, "repository")
586
587
      assert {:ok, pending_export} = AccountExport.build(user)
588
      assert pending_export["repository_work"]["attestations"]["records"] == []
589
590
      {:ok, _linked} = Reputation.approve_subject_claim(claim)
591
592
      assert {:ok, export} = AccountExport.build(user)
593
      assert [_record] = export["repository_work"]["attestations"]["records"]
594
595
      assert [projected] = export["identities"]["reputation_subject_claims"]
596
      assert projected["status"] == "linked"
597
      assert projected["subject_kind"] == "account"
598
    end
599
600
    # Disclosure does not widen. `readable_by/2` admits a public repository to
601
    # a non-member, and the controller shows such a reader `public` only.
602
    # Dropping the membership test from `attestations_export/2` turns this red.
603
    test "a repository or private tier attestation stays behind for a non-member" do
604
      user = github_user("account-export-attest-tier", "export-attest-tier")
605
      public_repository = OpenAgents.AccountsFixtures.repository_fixture()
606
      issue = OpenAgents.IssuesFixtures.issue_fixture(public_repository, %{title: "Tiered"})
607
      other = OpenAgents.IssuesFixtures.issue_fixture(public_repository, %{title: "Open"})
608
609
      link_subject!(user, "user:" <> user.id)
610
      refute Repositories.member?(public_repository, user)
611
612
      withheld = attest!(public_repository, issue, "user:" <> user.id, "repository")
613
      disclosed = attest!(public_repository, other, "user:" <> user.id, "public")
614
615
      assert {:ok, export} = AccountExport.build(user)
616
      ids = Enum.map(export["repository_work"]["attestations"]["records"], & &1["id"])
617
618
      assert disclosed.id in ids
619
      refute withheld.id in ids
620
    end
621
622
    # A public-tier attestation in a repository that went private afterwards is
623
    # the one case the tier test admits and `readable_by/2` does not, so this is
624
    # what isolates the join: replacing `subquery(readable)` with `Repository`
625
    # in `attestations_export/2` turns this red and nothing else in this file.
626
    test "a public tier attestation in a repository the account cannot read stays behind" do
627
      user = github_user("account-export-attest-shut", "export-attest-shut")
628
      other = github_user("account-export-attest-shut-owner", "export-attest-shut-owner")
629
      repository = OpenAgents.AccountsFixtures.repository_fixture()
630
      issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "Was public"})
631
632
      link_subject!(user, "user:" <> user.id)
633
      attestation = attest!(repository, issue, "user:" <> user.id, "public")
634
635
      assert {:ok, before} = AccountExport.build(user)
636
      assert [%{"id" => id}] = before["repository_work"]["attestations"]["records"]
637
      assert id == attestation.id
638
639
      # The repository closes. The attestation's tier still says `public`, so
640
      # only the repository visibility predicate withholds it now.
641
      repository
642
      |> Ecto.Changeset.change(visibility: "private")
643
      |> Repo.update!()
644
645
      {:ok, _membership} = Repositories.add_member(repository, other, "owner")
646
      refute Repositories.member?(repository, user)
647
648
      assert {:ok, export} = AccountExport.build(user)
649
      assert export["repository_work"]["attestations"]["records"] == []
650
    end
651
652
    # Both gates withhold this one, and that is the honest description: for a
653
    # private repository at the `repository` tier the membership test and
654
    # `readable_by/2` coincide, so this asserts the outcome rather than
655
    # isolating one join. The test above isolates it.
656
    test "an attestation in a private repository the account cannot read stays behind" do
657
      user = github_user("account-export-attest-closed", "export-attest-closed")
658
      other = github_user("account-export-attest-owner", "export-attest-owner")
659
      repository = private_repository_with_member("export-attest-closed-private", other)
660
      issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "Closed"})
661
662
      link_subject!(user, "user:" <> user.id)
663
      _attestation = attest!(repository, issue, "user:" <> user.id, "repository")
664
665
      refute Repositories.member?(repository, user)
666
667
      assert {:ok, export} = AccountExport.build(user)
668
      assert export["repository_work"]["attestations"]["records"] == []
669
    end
670
  end
671
523 672
  describe "the document resolves without the forge" do
524 673
    test "every record names its context in readable terms", %{board: board} do
525 674
      user = github_user("account-export-standalone", "export-standalone")

@@ -607,6 +756,67 @@ defmodule OpenAgents.DataRights.AccountExportTest do

607 756
    })
608 757
  end
609 758
759
  defp link_subject!(user, subject_id) do
760
    {:ok, claim} =
761
      Reputation.claim_subject(user, %{subject_kind: "account", subject_id: subject_id})
762
763
    {:ok, linked} = Reputation.approve_subject_claim(claim)
764
    linked
765
  end
766
767
  defp attest!(repository, issue, subject_id, tier) do
768
    policy =
769
      case Reputation.admit_policy(reputation_operator()) do
770
        {:ok, policy} -> policy
771
        {:error, _already_admitted} -> List.last(Reputation.policies())
772
      end
773
774
    keypair = OpenAgents.Reputation.Claim.generate_keypair()
775
776
    {:ok, key} =
777
      Reputation.admit_key(%{public_key: keypair.public_key, issuer: "account-export"})
778
779
    decision = OpenAgents.CompensationFixtures.outcome_decision_fixture()
780
781
    {:ok, attestation} =
782
      Reputation.issue(policy, %{key_id: key.key_id, private_key: keypair.private_key}, %{
783
        event_type: "completion",
784
        subject_id: subject_id,
785
        outcome: %{kind: "compensation_outcome_decision", ref: decision.decision_receipt_ref},
786
        repository: repository,
787
        issue_number: issue.number,
788
        revision: String.duplicate("a", 40),
789
        artifact_digest: String.duplicate("1", 64),
790
        confidence_ppm: 900_000,
791
        transparency_tier: tier,
792
        evidence: [
793
          %{
794
            kind: "outcome",
795
            ref: decision.decision_receipt_ref,
796
            digest: decision.outcome_digest,
797
            observed_at: DateTime.to_iso8601(DateTime.utc_now())
798
          }
799
        ]
800
      })
801
802
    attestation
803
  end
804
805
  defp issuer_public_key(attestation) do
806
    Reputation.keys()
807
    |> Enum.find(&(&1.key_id == attestation.issuer_key_id))
808
    |> Map.fetch!(:public_key)
809
  end
810
811
  defp reputation_operator do
812
    %{
813
      authenticated: true,
814
      actor_id: "operator:account-export",
815
      auth_method: "test_session",
816
      approval_receipt_ref: "account-export:#{System.unique_integer([:positive])}"
817
    }
818
  end
819
610 820
  defp private_repository_with_member(name, user) do
611 821
    repository =
612 822
      OpenAgents.AccountsFixtures.repository_fixture(%{name: name, visibility: "private"})
test/openagents/data_rights/export_inventory_test.exs modified +73 -1

@@ -16,6 +16,7 @@ defmodule OpenAgents.DataRights.ExportInventoryTest do

16 16
  alias OpenAgents.Issues
17 17
  alias OpenAgents.Repo
18 18
  alias OpenAgents.Repositories
19
  alias OpenAgents.Reputation
19 20
  alias OpenAgentsWeb.ApiRouteAuthority
20 21
21 22
  # Families whose recorded status this test observes rather than trusts.

@@ -38,7 +39,8 @@ defmodule OpenAgents.DataRights.ExportInventoryTest do

38 39
    :deployment,
39 40
    :pull_request,
40 41
    :stack,
41
    :issue_dependency
42
    :issue_dependency,
43
    :reputation
42 44
  ]
43 45
44 46
  describe "coverage" do

@@ -301,11 +303,81 @@ defmodule OpenAgents.DataRights.ExportInventoryTest do

301 303
    blocker = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "exit blocker"})
302 304
    :ok = Issues.add_dependencies(blocked, [blocker.number], owner)
303 305
306
    seed_attestation(owner, repository)
307
308
    :ok
309
  end
310
311
  # A reputation attestation names its subject with a bare string, so the seed
312
  # has to establish the binding as well as the record: a claim the account
313
  # makes and the operator decides. Without the linked claim the export
314
  # returns nothing here, which is what this family recorded before #171.
315
  defp seed_attestation(owner, repository) do
316
    {:ok, claim} =
317
      Reputation.claim_subject(owner, %{
318
        subject_kind: "account",
319
        subject_id: "user:" <> owner.id
320
      })
321
322
    {:ok, _linked} = Reputation.approve_subject_claim(claim)
323
324
    issue = OpenAgents.IssuesFixtures.issue_fixture(repository, %{title: "exit attested"})
325
326
    policy =
327
      case Reputation.admit_policy(%{
328
             authenticated: true,
329
             actor_id: "operator:export-inventory",
330
             auth_method: "test_session",
331
             approval_receipt_ref: "export-inventory:#{System.unique_integer([:positive])}"
332
           }) do
333
        {:ok, policy} -> policy
334
        {:error, _already_admitted} -> List.last(Reputation.policies())
335
      end
336
337
    keypair = OpenAgents.Reputation.Claim.generate_keypair()
338
339
    {:ok, key} =
340
      Reputation.admit_key(%{public_key: keypair.public_key, issuer: "export-inventory"})
341
342
    decision = OpenAgents.CompensationFixtures.outcome_decision_fixture()
343
344
    {:ok, _attestation} =
345
      Reputation.issue(policy, %{key_id: key.key_id, private_key: keypair.private_key}, %{
346
        event_type: "completion",
347
        subject_id: "user:" <> owner.id,
348
        outcome: %{kind: "compensation_outcome_decision", ref: decision.decision_receipt_ref},
349
        repository: repository,
350
        issue_number: issue.number,
351
        revision: String.duplicate("a", 40),
352
        artifact_digest: String.duplicate("1", 64),
353
        confidence_ppm: 900_000,
354
        transparency_tier: "repository",
355
        evidence: [
356
          %{
357
            kind: "outcome",
358
            ref: decision.decision_receipt_ref,
359
            digest: decision.outcome_digest,
360
            observed_at: DateTime.to_iso8601(DateTime.utc_now())
361
          }
362
        ]
363
      })
364
304 365
    :ok
305 366
  end
306 367
307 368
  ## ── probes ─────────────────────────────────────────────────────────────
308 369
370
  defp probe(:reputation, %{account_export: export}) do
371
    attestations = export["repository_work"]["attestations"]
372
373
    if Enum.any?(
374
         attestations["records"],
375
         &(&1["subject_id"] in attestations["established_subjects"])
376
       ),
377
       do: :portable,
378
       else: :partial
379
  end
380
309 381
  defp probe(:pull_request, %{account_export: export}) do
310 382
    if Enum.any?(
311 383
         export["repository_work"]["pull_requests"]["records"],
test/openagents/network_status_test.exs modified +5 -3

@@ -95,11 +95,13 @@ defmodule OpenAgents.NetworkStatusTest do

95 95
    "independence.document",
96 96
    "independence.export",
97 97
    "independence.export.blocked",
98
    # `independence.export.gaps` is a list, and STATUS-001 declares list-element
99
    # paths only while the list has an element. It is empty because EXIT-001
100
    # records no `partial` or `blocked` family; a family that becomes a gap
101
    # republishes `family`, `status`, and `issue`, and this set must readmit
102
    # them.
98 103
    "independence.export.families",
99 104
    "independence.export.gaps",
100
    "independence.export.gaps[].family",
101
    "independence.export.gaps[].issue",
102
    "independence.export.gaps[].status",
103 105
    "independence.export.not_user_data",
104 106
    "independence.export.partial",
105 107
    "independence.export.portable",
test/openagents/reputation/subject_claim_test.exs added +246

@@ -0,0 +1,246 @@

1
defmodule OpenAgents.Reputation.SubjectClaimTest do
2
  @moduledoc """
3
  The binding that resolves an attestation subject to an account, #171.
4
5
  A changeset is the application's opinion. What decides here is the table, so
6
  every rule this file cares about is asserted against the constraint itself —
7
  by reading `pg_get_constraintdef` and by inserting through raw SQL, which no
8
  changeset sees. A rule that only a changeset enforced would leave the bare
9
  string exactly as ambiguous as it was.
10
  """
11
12
  use OpenAgents.DataCase, async: false
13
14
  alias OpenAgents.Agents.{Agent, AgentUserLink}
15
  alias OpenAgents.Forum.ActorLink
16
  alias OpenAgents.Repo
17
  alias OpenAgents.Reputation
18
  alias OpenAgents.Reputation.SubjectClaim
19
20
  describe "what the table decides" do
21
    test "the per-kind reference constraint is the database's rule, stated in SQL" do
22
      %{rows: [[definition]]} =
23
        Repo.query!(
24
          "SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = $1",
25
          ["reputation_subject_claims_reference_check"]
26
        )
27
28
      # An account subject names the account itself, and the string is
29
      # checkable without an operator. The other two kinds must carry the row
30
      # in the namespace that established them.
31
      assert definition =~ "'user:'"
32
      assert definition =~ "forum_actor_link_id IS NOT NULL"
33
      assert definition =~ "agent_id IS NOT NULL"
34
    end
35
36
    test "an account subject that is not this account's actor reference is refused in SQL" do
37
      user = user_fixture()
38
39
      assert_raise Postgrex.Error, ~r/reputation_subject_claims_reference_check/, fn ->
40
        insert_claim!(%{
41
          subject_kind: "account",
42
          subject_id: "user:" <> Ecto.UUID.generate(),
43
          user_id: user.id
44
        })
45
      end
46
    end
47
48
    test "a forum_actor subject with no link row is refused in SQL" do
49
      user = user_fixture()
50
51
      assert_raise Postgrex.Error, ~r/reputation_subject_claims_reference_check/, fn ->
52
        insert_claim!(%{
53
          subject_kind: "forum_actor",
54
          subject_id: "agent:user_legacy",
55
          user_id: user.id
56
        })
57
      end
58
    end
59
60
    test "an agent subject carrying a forum link instead of an agent is refused in SQL" do
61
      user = user_fixture()
62
      link = linked_actor_link(user, "agent:user_mixed")
63
64
      assert_raise Postgrex.Error, ~r/reputation_subject_claims_reference_check/, fn ->
65
        insert_claim!(%{
66
          subject_kind: "agent",
67
          subject_id: "agent:user_mixed",
68
          user_id: user.id,
69
          forum_actor_link_id: link.id
70
        })
71
      end
72
    end
73
74
    test "a subject one account holds cannot be claimed by another, in SQL" do
75
      first = user_fixture()
76
      second = user_fixture()
77
78
      {:ok, _claim} =
79
        Reputation.claim_subject(first, %{
80
          subject_kind: "account",
81
          subject_id: "user:" <> first.id
82
        })
83
84
      # The unique index is on `subject_id` alone rather than on the kind and
85
      # the string together: an attestation names its subject with the bare
86
      # string, so two kinds carrying one string would put the ambiguity back.
87
      assert_raise Postgrex.Error, ~r/reputation_subject_claims_subject_id_index/, fn ->
88
        insert_claim!(%{
89
          subject_kind: "forum_actor",
90
          subject_id: "user:" <> first.id,
91
          user_id: second.id,
92
          forum_actor_link_id: linked_actor_link(second, "user:" <> first.id).id
93
        })
94
      end
95
    end
96
  end
97
98
  describe "what the context decides, because a constraint cannot read another table" do
99
    test "a forum_actor claim needs a linked forum_actor_links row of this account's" do
100
      user = user_fixture()
101
      other = user_fixture()
102
103
      assert {:error, :forum_actor_not_linked} =
104
               Reputation.claim_subject(user, %{
105
                 subject_kind: "forum_actor",
106
                 subject_id: "agent:user_absent",
107
                 forum_actor_link_id: Ecto.UUID.generate()
108
               })
109
110
      theirs = linked_actor_link(other, "agent:user_theirs")
111
112
      assert {:error, :forum_actor_not_linked} =
113
               Reputation.claim_subject(user, %{
114
                 subject_kind: "forum_actor",
115
                 subject_id: "agent:user_theirs",
116
                 forum_actor_link_id: theirs.id
117
               })
118
119
      pending =
120
        Repo.insert!(%ActorLink{
121
          user_id: user.id,
122
          actor_ref: "agent:user_pending",
123
          status: "pending"
124
        })
125
126
      assert {:error, :forum_actor_not_linked} =
127
               Reputation.claim_subject(user, %{
128
                 subject_kind: "forum_actor",
129
                 subject_id: "agent:user_pending",
130
                 forum_actor_link_id: pending.id
131
               })
132
133
      mine = linked_actor_link(user, "agent:user_mine")
134
135
      assert {:error, :subject_is_not_the_actor_ref} =
136
               Reputation.claim_subject(user, %{
137
                 subject_kind: "forum_actor",
138
                 subject_id: "agent:user_something_else",
139
                 forum_actor_link_id: mine.id
140
               })
141
142
      assert {:ok, claim} =
143
               Reputation.claim_subject(user, %{
144
                 subject_kind: "forum_actor",
145
                 subject_id: "agent:user_mine",
146
                 forum_actor_link_id: mine.id
147
               })
148
149
      assert claim.status == "pending"
150
    end
151
152
    test "an agent claim needs a linked agent_user_links row of this account's" do
153
      user = user_fixture()
154
      agent = agent_fixture("subject-claim-agent")
155
156
      assert {:error, :agent_not_linked} =
157
               Reputation.claim_subject(user, %{
158
                 subject_kind: "agent",
159
                 subject_id: "agent:" <> agent.handle,
160
                 agent_id: agent.id
161
               })
162
163
      Repo.insert!(%AgentUserLink{agent_id: agent.id, user_id: user.id, status: "linked"})
164
165
      assert {:ok, claim} =
166
               Reputation.claim_subject(user, %{
167
                 subject_kind: "agent",
168
                 subject_id: "agent:" <> agent.handle,
169
                 agent_id: agent.id
170
               })
171
172
      assert claim.subject_kind == "agent"
173
    end
174
  end
175
176
  describe "only a decided claim resolves" do
177
    test "a pending or rejected claim resolves nothing, and a linked one resolves one string" do
178
      user = user_fixture()
179
180
      {:ok, claim} =
181
        Reputation.claim_subject(user, %{
182
          subject_kind: "account",
183
          subject_id: "user:" <> user.id
184
        })
185
186
      assert Reputation.linked_subject_ids(user) == []
187
188
      {:ok, linked} = Reputation.approve_subject_claim(claim)
189
      assert linked.status == "linked"
190
      assert Reputation.linked_subject_ids(user) == ["user:" <> user.id]
191
192
      assert {:error, :not_pending} = Reputation.approve_subject_claim(linked)
193
      assert {:error, :not_pending} = Reputation.reject_subject_claim(linked)
194
    end
195
196
    test "a rejected claim never resolves" do
197
      user = user_fixture()
198
199
      {:ok, claim} =
200
        Reputation.claim_subject(user, %{
201
          subject_kind: "account",
202
          subject_id: "user:" <> user.id
203
        })
204
205
      {:ok, rejected} = Reputation.reject_subject_claim(claim)
206
      assert rejected.status == "rejected"
207
      assert Reputation.linked_subject_ids(user) == []
208
    end
209
  end
210
211
  ## helpers
212
213
  defp user_fixture do
214
    OpenAgents.AccountsFixtures.repository_user_fixture(
215
      "subject-claim-#{System.unique_integer([:positive])}"
216
    )
217
  end
218
219
  defp agent_fixture(prefix) do
220
    Repo.insert!(%Agent{
221
      handle: "#{prefix}-#{System.unique_integer([:positive])}",
222
      display_name: "Subject claim agent",
223
      registration_ip_digest: :crypto.hash(:sha256, prefix)
224
    })
225
  end
226
227
  defp linked_actor_link(user, actor_ref) do
228
    Repo.insert!(%ActorLink{
229
      user_id: user.id,
230
      actor_ref: actor_ref,
231
      status: "linked",
232
      linked_at: DateTime.utc_now()
233
    })
234
  end
235
236
  # Straight past every changeset, so what refuses the row is the table.
237
  defp insert_claim!(attributes) do
238
    now = DateTime.utc_now()
239
240
    Repo.insert_all(SubjectClaim, [
241
      attributes
242
      |> Map.put_new(:status, "pending")
243
      |> Map.merge(%{id: Ecto.UUID.generate(), inserted_at: now, updated_at: now})
244
    ])
245
  end
246
end
test/openagents_web/controllers/reputation_subject_claim_controller_test.exs added +142

@@ -0,0 +1,142 @@

1
defmodule OpenAgentsWeb.ReputationSubjectClaimControllerTest do
2
  @moduledoc """
3
  The route half of #171: an account asks, and an operator decides.
4
5
  Before this, a reputation subject was whatever string an issuer typed and
6
  nothing on the surface let an account say a string was its own. These are the
7
  four routes that turn the binding into something a person can establish.
8
  """
9
10
  use OpenAgentsWeb.ConnCase
11
12
  alias OpenAgents.Reputation
13
14
  test "an account claims its own actor reference and reads the claim back", %{conn: conn} do
15
    user = github_user("api-token-reputation-claim")
16
    authed = put_forge_api_token(conn, "reputation-claim")
17
18
    created =
19
      authed
20
      |> post(~p"/api/v3/reputation/subject-claims", %{
21
        "subject_kind" => "account",
22
        "subject_id" => "user:" <> user.id
23
      })
24
      |> json_response(201)
25
26
    assert created["status"] == "pending"
27
    assert created["subject_kind"] == "account"
28
    assert created["subject_id"] == "user:" <> user.id
29
30
    listed =
31
      build_conn()
32
      |> put_forge_api_token("reputation-claim")
33
      |> get(~p"/api/v3/reputation/subject-claims")
34
35
    assert [claim] = json_response(listed, 200)["claims"]
36
    assert claim["id"] == created["id"]
37
  end
38
39
  test "a subject that is not this account's actor reference is refused", %{conn: conn} do
40
    body =
41
      conn
42
      |> put_forge_api_token("reputation-claim-wrong")
43
      |> post(~p"/api/v3/reputation/subject-claims", %{
44
        "subject_kind" => "account",
45
        "subject_id" => "user:" <> Ecto.UUID.generate()
46
      })
47
      |> json_response(422)
48
49
    assert body["message"] =~ "subject_id"
50
  end
51
52
  test "an unsupported kind is refused rather than stored", %{conn: conn} do
53
    body =
54
      conn
55
      |> put_forge_api_token("reputation-claim-kind")
56
      |> post(~p"/api/v3/reputation/subject-claims", %{
57
        "subject_kind" => "solver",
58
        "subject_id" => "actor:whoever"
59
      })
60
      |> json_response(422)
61
62
    assert body["message"] == "unsupported_subject_kind"
63
  end
64
65
  test "review is the operator's, and only a linked claim resolves", %{conn: conn} do
66
    user = github_user("api-token-reputation-claim-review")
67
68
    created =
69
      conn
70
      |> put_forge_api_token("reputation-claim-review")
71
      |> post(~p"/api/v3/reputation/subject-claims", %{
72
        "subject_kind" => "account",
73
        "subject_id" => "user:" <> user.id
74
      })
75
      |> json_response(201)
76
77
    assert Reputation.linked_subject_ids(user) == []
78
79
    assert build_conn()
80
           |> put_forge_api_token("reputation-claim-review")
81
           |> get(~p"/api/v3/reputation/subject-claims/pending")
82
           |> json_response(403)
83
84
    assert build_conn()
85
           |> put_forge_api_token("reputation-claim-review")
86
           |> patch(~p"/api/v3/reputation/subject-claims/#{created["id"]}", %{
87
             "status" => "linked"
88
           })
89
           |> json_response(403)
90
91
    assert Reputation.linked_subject_ids(user) == []
92
93
    grant_operator(github_user("api-token-reputation-claim-operator"))
94
95
    pending =
96
      build_conn()
97
      |> put_forge_api_token("reputation-claim-operator")
98
      |> get(~p"/api/v3/reputation/subject-claims/pending")
99
      |> json_response(200)
100
101
    assert Enum.any?(pending["claims"], &(&1["id"] == created["id"]))
102
103
    linked =
104
      build_conn()
105
      |> put_forge_api_token("reputation-claim-operator")
106
      |> patch(~p"/api/v3/reputation/subject-claims/#{created["id"]}", %{"status" => "linked"})
107
      |> json_response(200)
108
109
    assert linked["status"] == "linked"
110
    assert Reputation.linked_subject_ids(user) == ["user:" <> user.id]
111
112
    assert build_conn()
113
           |> put_forge_api_token("reputation-claim-operator")
114
           |> patch(~p"/api/v3/reputation/subject-claims/#{created["id"]}", %{
115
             "status" => "linked"
116
           })
117
           |> json_response(409)
118
  end
119
120
  test "a subject another account already claimed cannot be claimed again", %{conn: conn} do
121
    first = github_user("api-token-reputation-claim-first")
122
123
    conn
124
    |> put_forge_api_token("reputation-claim-first")
125
    |> post(~p"/api/v3/reputation/subject-claims", %{
126
      "subject_kind" => "account",
127
      "subject_id" => "user:" <> first.id
128
    })
129
    |> json_response(201)
130
131
    body =
132
      build_conn()
133
      |> put_forge_api_token("reputation-claim-second")
134
      |> post(~p"/api/v3/reputation/subject-claims", %{
135
        "subject_kind" => "account",
136
        "subject_id" => "user:" <> first.id
137
      })
138
      |> json_response(422)
139
140
    assert body["message"] =~ "subject_id"
141
  end
142
end
test/openagents_web/operator_surface_test.exs modified +1

@@ -97,6 +97,7 @@ defmodule OpenAgentsWeb.OperatorSurfaceTest do

97 97
    OpenAgentsWeb.HomeLive => "marks the session operator for the home surface",
98 98
    OpenAgentsWeb.Layouts => "shows the operator entries in the sidebar",
99 99
    OpenAgentsWeb.Plugs.OperatorApiTokenAuth => "rechecks the operator behind /api/operator",
100
    OpenAgentsWeb.ReputationController => "gates reputation subject-claim review over the API",
100 101
    OpenAgentsWeb.UserAuth => "gates the /admin scope as a plug and as an on_mount hook"
101 102
  }
102 103

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