Attest verified outcomes with signed, scoped evidence

f769743791fd · Devin AI · · parent c896ce88fb02

Attest verified outcomes with signed, scoped evidence

A reputation attestation is one Ed25519-signed canonical claim binding an issuer key, a subject, an accepted outcome receipt, a repository, an issue, a revision, an artifact digest, an admitted verifier policy version and digest, a confidence, evidence references, a timestamp, and a nonce. Completion, verification, review, payment, reversal, and revocation stay distinct facts.

Issuance requires an accepted outcome decision that already reached its terminal state, so presence, token volume, online time, and narration are not attestable. There is no score and no ranking: subject evidence is counted inside one repository and reports a null score.

A skeptical client verifies a claim without trusting the interface. Read routes under /api/v3 publish the claim verbatim next to its signature, the admitted public keys, and the policy rules, so the client recomputes the digest, checks the signature, reproduces the policy digest, resolves evidence, and reads revocation state. A claim presented for another issue, revision, verifier, or actor fails its binding. Disclosure follows repository authority, and a private attestation withholds the outcome and evidence references while staying verifiable.

See INVARIANTS.md, REPUTATION-001.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

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

Changed files

  • modified INVARIANTS.md
  • added lib/openagents/reputation.ex
  • added lib/openagents/reputation/attestation.ex
  • added lib/openagents/reputation/claim.ex
  • added lib/openagents/reputation/policy_receipt.ex
  • added lib/openagents/reputation/signing_key.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/reputation_controller.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/rest-api.md
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823052000_create_reputation_attestations.exs
  • added test/openagents/reputation_test.exs
  • added test/openagents_web/controllers/reputation_controller_test.exs
  • added test/support/fixtures/compensation_fixtures.ex

Diff

15 files changed, +2440 -1

INVARIANTS.md modified +42

@@ -793,6 +793,47 @@ constraints, `test/openagents/compensation_test.exs`, and duplicate, revocation,

793 793
allocation, adjustment, reconciliation, privacy, and no-payout cases in
794 794
`OpenAgents.CompensationTest`.
795 795
796
### REPUTATION-001 — An attestation is scoped signed evidence, never a score
797
798
Status: Current
799
800
A reputation attestation is one Ed25519-signed canonical claim binding an
801
issuer key, a subject, an accepted outcome, a repository, an issue, a revision,
802
an artifact digest, an admitted verifier policy version and digest, a
803
confidence in parts per million, evidence references, a timestamp, and a nonce.
804
The six event types — completion, verification, review, payment, reversal, and
805
revocation — stay distinct facts.
806
807
Issuance requires an accepted-outcome receipt that already reached its admitted
808
terminal state, so an invocation, a presence signal, token volume, online time,
809
or unverifiable narration can never produce an attestation. The verifier policy
810
rules carry `global_score: false`, no function returns a ranking, and subject
811
evidence is counted inside one repository with a `score` of `nil`.
812
813
Verification is independent of the interface. A client recomputes the claim
814
digest, checks the signature against the admitted public key, compares the
815
policy digest and version, resolves each evidence reference, and reads the
816
revocation state. Because the claim covers the issue, the revision, the
817
subject, the outcome, and the verifier, a valid attestation presented for
818
another issue, revision, verifier, or actor fails its binding. A reversed or
819
invalidated outcome produces a linked invalidating attestation, and the revoked
820
claim and signature stay readable. Retiring a key never invalidates the history
821
it signed, and a private key never enters the database.
822
823
Disclosure follows repository authority: an attestation is `public` only where
824
the repository is public or its transparency level admits ledger disclosure
825
(TRANSPARENCY-001), evidence must
826
stay inside the repository, and a `private` attestation withholds the outcome
827
reference and every evidence reference from the signed claim while remaining
828
verifiable.
829
830
Evidence: `OpenAgents.Reputation`, `OpenAgents.Reputation.Claim`,
831
`OpenAgents.Reputation.Attestation`, `OpenAgents.Reputation.SigningKey`,
832
`OpenAgents.Reputation.PolicyReceipt`, the append-only and uniqueness
833
constraints on `reputation_attestations`, `OpenAgentsWeb.ReputationController`,
834
`test/openagents/reputation_test.exs`, and
835
`test/openagents_web/controllers/reputation_controller_test.exs`.
836
796 837
### MODULE-001 — Every invocation pins one immutable admitted module
797 838
798 839
Status: Current

@@ -2028,6 +2069,7 @@ contract; the invariant prose above defines the assertion, not the filename.

2028 2069
| COLLECTIVE-002 | `test/openagents/collective_generalizer_test.exs` |
2029 2070
| COLLECTIVE-003 | `test/openagents/collective_publication_test.exs` |
2030 2071
| COMPENSATION-001 | `test/openagents/compensation_test.exs` |
2072
| REPUTATION-001 | `test/openagents/reputation_test.exs`, `test/openagents_web/controllers/reputation_controller_test.exs` |
2031 2073
| MODULE-001 | `test/openagents/modules/registry_test.exs`, `test/openagents/tool_step_persistence_test.exs` |
2032 2074
| MODULE-002 | `test/openagents/modules/discovery_test.exs`, `test/openagents/modules/lifecycle_test.exs` |
2033 2075
| MODULE-003 | `test/openagents/modules/router_test.exs`, `test/openagents/turn_tool_loop_test.exs` |
lib/openagents/reputation.ex added +880

@@ -0,0 +1,880 @@

1
defmodule OpenAgents.Reputation do
2
  @moduledoc """
3
  Portable, revocable reputation attestations for accepted outcomes.
4
5
  An attestation is a signed claim that one subject completed, verified,
6
  reviewed, was paid for, or lost credit for one accepted outcome, under one
7
  admitted verifier policy, in one repository, at one revision. It is scoped
8
  evidence a stranger can check, never a global social score: nothing here
9
  derives credit from presence, token volume, online time, or narration, and
10
  no function returns a universal ranking.
11
12
  The context owns four operations:
13
14
    * `admit_policy/1` and `admit_key/1` record the verifier policy and the
15
      issuer public key an attestation binds to.
16
    * `issue/3` signs a claim, and only after the accepted-outcome contract it
17
      names reached an admitted terminal state.
18
    * `verify/2` recomputes the digest, checks the signature against the
19
      admitted key, and reports policy, binding, evidence, and revocation
20
      state. It trusts no column and no caller.
21
    * `revoke/4` and `correct/4` publish a linked invalidating event.
22
23
  Reads project the stored claim verbatim, so a client can verify an
24
  attestation the forge serves without trusting the surface that displayed it.
25
  """
26
27
  import Ecto.Query
28
29
  alias OpenAgents.Compensation.OutcomeDecision
30
  alias OpenAgents.Forge.Visibility
31
  alias OpenAgents.Issues.Issue
32
  alias OpenAgents.Provenance.Canonical
33
  alias OpenAgents.Repo
34
  alias OpenAgents.Repositories.Repository
35
  alias OpenAgents.Reputation.{Attestation, Claim, PolicyReceipt, SigningKey}
36
37
  @policy_id "openagents.reputation.verifier.v1"
38
  @policy_version 1
39
  @policy_rules %{
40
    "unit" => "scoped_evidence",
41
    "signature_algorithm" => "ed25519",
42
    "event_types" => Attestation.event_types(),
43
    "accepted_outcome_kinds" => ["compensation_outcome_decision"],
44
    "accepted_terminal_state" => "accepted",
45
    "minimum_confidence_ppm" => 500_000,
46
    "evidence_max_age_seconds" => 7_776_000,
47
    "evidence_kinds" => ["outcome", "issue", "repository", "attestation"],
48
    "global_score" => false
49
  }
50
51
  @doc "The verifier policy identifier every attestation binds to."
52
  def policy_id, do: @policy_id
53
54
  @doc "The rules of the current verifier policy version."
55
  def policy_rules, do: @policy_rules
56
57
  @doc "The digest of one policy version's rules."
58
  @spec policy_digest(String.t(), pos_integer(), map()) :: String.t()
59
  def policy_digest(policy_id, version, rules) do
60
    Canonical.digest!(%{"policy_id" => policy_id, "version" => version, "rules" => rules})
61
  end
62
63
  @doc """
64
  Admits the current verifier policy version under operator authority.
65
66
  The receipt is append-only, and its digest is what a client compares a
67
  claim's `verifier.policy_digest` against.
68
  """
69
  @spec admit_policy(map()) :: {:ok, PolicyReceipt.t()} | {:error, term()}
70
  def admit_policy(operator) do
71
    with :ok <- validate_operator(operator) do
72
      %PolicyReceipt{}
73
      |> PolicyReceipt.changeset(%{
74
        policy_id: @policy_id,
75
        version: @policy_version,
76
        policy_digest: policy_digest(@policy_id, @policy_version, @policy_rules),
77
        rules: @policy_rules,
78
        actor_id: operator.actor_id,
79
        auth_method: operator.auth_method,
80
        approval_receipt_ref: operator.approval_receipt_ref
81
      })
82
      |> Repo.insert()
83
    end
84
  end
85
86
  @doc "The admitted policy receipt for one version, if any."
87
  @spec policy(String.t(), pos_integer()) :: PolicyReceipt.t() | nil
88
  def policy(policy_id \\ @policy_id, version \\ @policy_version),
89
    do: Repo.get_by(PolicyReceipt, policy_id: policy_id, version: version)
90
91
  @doc "Every admitted policy version, oldest first."
92
  @spec policies() :: [PolicyReceipt.t()]
93
  def policies,
94
    do:
95
      Repo.all(
96
        from receipt in PolicyReceipt, order_by: [asc: receipt.policy_id, asc: receipt.version]
97
      )
98
99
  @doc """
100
  The published form of one policy version: the rules a client hashes to
101
  reproduce `policy_digest` itself.
102
  """
103
  @spec policy_projection(PolicyReceipt.t()) :: map()
104
  def policy_projection(%PolicyReceipt{} = receipt) do
105
    %{
106
      "policy_id" => receipt.policy_id,
107
      "version" => receipt.version,
108
      "policy_digest" => receipt.policy_digest,
109
      "rules" => receipt.rules,
110
      "admitted_at" => receipt.inserted_at
111
    }
112
  end
113
114
  @doc """
115
  Admits an issuer public key.
116
117
  Only the public half is stored. The private key stays in runtime
118
  configuration, so the table a verifier reads can never mint a claim.
119
  """
120
  @spec admit_key(map()) :: {:ok, SigningKey.t()} | {:error, term()}
121
  def admit_key(attributes) do
122
    public_key = Map.fetch!(attributes, :public_key)
123
124
    %SigningKey{}
125
    |> SigningKey.changeset(%{
126
      key_id: Map.get(attributes, :key_id) || Claim.key_id(public_key),
127
      algorithm: Map.get(attributes, :algorithm, Claim.algorithm()),
128
      public_key: public_key,
129
      issuer: Map.fetch!(attributes, :issuer),
130
      activated_at: Map.get(attributes, :activated_at) || DateTime.utc_now(),
131
      retired_at: Map.get(attributes, :retired_at)
132
    })
133
    |> Repo.insert()
134
  end
135
136
  @doc "Retires an issuer key. Attestations it already signed keep verifying."
137
  @spec retire_key(SigningKey.t(), DateTime.t()) :: {:ok, SigningKey.t()} | {:error, term()}
138
  def retire_key(%SigningKey{} = key, retired_at \\ DateTime.utc_now()) do
139
    key |> SigningKey.retire_changeset(retired_at) |> Repo.update()
140
  end
141
142
  @doc "Every admitted issuer key, for independent verification."
143
  @spec keys() :: [SigningKey.t()]
144
  def keys, do: Repo.all(from key in SigningKey, order_by: [asc: key.activated_at])
145
146
  @doc """
147
  Issues one attestation for an accepted outcome.
148
149
  `signer` carries the admitted `key_id` and the runtime-only `private_key`.
150
  Issuance fails when the outcome is missing or not accepted, when the key is
151
  unknown, retired, or does not match the admitted public key, when the
152
  confidence falls below the policy, when the requested transparency tier
153
  exceeds the repository's authority, or when the same issuer already
154
  attested this event for this subject and outcome.
155
  """
156
  @spec issue(PolicyReceipt.t(), map(), map()) :: {:ok, Attestation.t()} | {:error, term()}
157
  def issue(%PolicyReceipt{} = policy, signer, attributes) do
158
    with :ok <- validate_policy(policy),
159
         :ok <- validate_event_type(attributes[:event_type], attributes[:revokes_id]),
160
         :ok <- validate_confidence(policy, attributes[:confidence_ppm]),
161
         {:ok, repository} <- fetch_repository(attributes[:repository]),
162
         :ok <- validate_issue_number(repository, attributes[:issue_number]),
163
         :ok <- validate_tier(repository, attributes[:transparency_tier]),
164
         {:ok, evidence} <- validate_evidence(policy, repository, attributes[:evidence]),
165
         {:ok, outcome} <- resolve_outcome(policy, attributes[:outcome]),
166
         {:ok, key} <- fetch_signing_key(signer, attributes[:attested_at]) do
167
      persist(policy, key, signer, repository, outcome, evidence, attributes)
168
    end
169
  end
170
171
  @doc """
172
  Publishes a linked invalidating event for `attestation`.
173
174
  `event_type` is `reversal` for an outcome that was undone and `revocation`
175
  for a claim that should no longer count. The original row keeps its claim
176
  and signature; only its revocation fields are set, and only once.
177
  """
178
  @spec revoke(Attestation.t(), PolicyReceipt.t(), map(), map()) ::
179
          {:ok, %{revocation: Attestation.t(), attestation: Attestation.t()}} | {:error, term()}
180
  def revoke(%Attestation{} = attestation, %PolicyReceipt{} = policy, signer, attributes) do
181
    event_type = Map.get(attributes, :event_type, "revocation")
182
    reason_code = Map.get(attributes, :reason_code)
183
184
    with :ok <- validate_invalidating_event(event_type),
185
         :ok <- validate_reason_code(reason_code),
186
         :ok <- require_live(attestation) do
187
      Repo.transaction(fn ->
188
        case issue_invalidation(attestation, policy, signer, attributes, event_type) do
189
          {:ok, revocation} ->
190
            %{
191
              revocation: revocation,
192
              attestation: mark_revoked!(attestation, revocation, reason_code)
193
            }
194
195
          {:error, reason} ->
196
            Repo.rollback(reason)
197
        end
198
      end)
199
    end
200
  end
201
202
  @doc """
203
  Corrects `attestation`: revokes it and issues a replacement that names the
204
  revoked claim digest in `supersedes`.
205
  """
206
  @spec correct(Attestation.t(), PolicyReceipt.t(), map(), map()) ::
207
          {:ok, %{revocation: Attestation.t(), correction: Attestation.t()}} | {:error, term()}
208
  def correct(%Attestation{} = attestation, %PolicyReceipt{} = policy, signer, attributes) do
209
    reason_code = Map.get(attributes, :reason_code, "corrected")
210
211
    Repo.transaction(fn ->
212
      with {:ok, revoked} <-
213
             revoke(attestation, policy, signer, %{
214
               event_type: "revocation",
215
               reason_code: reason_code,
216
               subject_id: attestation.subject_id
217
             }),
218
           {:ok, correction} <-
219
             issue(
220
               policy,
221
               signer,
222
               attributes
223
               |> Map.put(:supersedes_digest, attestation.claim_digest)
224
               |> Map.put_new(:evidence, evidence_for_link(attestation))
225
             ) do
226
        %{revocation: revoked.revocation, correction: correction}
227
      else
228
        {:error, reason} -> Repo.rollback(reason)
229
      end
230
    end)
231
  end
232
233
  @doc """
234
  Verifies one attestation the way a skeptical client does: recompute the
235
  claim digest, check the Ed25519 signature against the admitted public key,
236
  compare the policy and the binding, resolve the evidence, and read the
237
  revocation state.
238
239
  `expectation` is what the caller believes it is looking at — any of
240
  `:repository`, `:issue_number`, `:subject_id`, `:revision`, `:event_type`,
241
  `:outcome_ref`, or `:policy_id`. A mismatch is reported, which is what stops
242
  a valid attestation from being replayed for another issue, revision,
243
  verifier, or actor.
244
  """
245
  @spec verify(Attestation.t() | String.t(), map()) :: map()
246
  def verify(attestation, expectation \\ %{})
247
248
  def verify(claim_digest, expectation) when is_binary(claim_digest) do
249
    case Repo.get_by(Attestation, claim_digest: claim_digest) do
250
      nil ->
251
        %{"claim_digest" => claim_digest, "verified" => false, "reasons" => ["unknown_claim"]}
252
253
      attestation ->
254
        verify(attestation, expectation)
255
    end
256
  end
257
258
  def verify(%Attestation{} = attestation, expectation) do
259
    attestation = Repo.preload(attestation, :repository)
260
    key = Repo.get_by(SigningKey, key_id: attestation.issuer_key_id)
261
    digest_match? = Canonical.digest!(attestation.claim) == attestation.claim_digest
262
    signature = signature_report(attestation, key, digest_match?)
263
    policy = policy_report(attestation)
264
    binding = binding_report(attestation, expectation)
265
    evidence = evidence_report(attestation)
266
    revocation = revocation_report(attestation)
267
268
    report = %{
269
      "attestation_id" => attestation.id,
270
      "claim_digest" => attestation.claim_digest,
271
      "digest_match" => digest_match?,
272
      "signature" => signature,
273
      "policy" => policy,
274
      "binding" => binding,
275
      "evidence" => evidence,
276
      "revocation" => revocation
277
    }
278
279
    Map.put(report, "verified", verified?(report))
280
  end
281
282
  @doc """
283
  The published form of one attestation: the exact signed claim, its
284
  signature, and the state a verifier needs. The claim is the stored object,
285
  never a rendering of it.
286
  """
287
  @spec projection(Attestation.t()) :: map()
288
  def projection(%Attestation{} = attestation) do
289
    %{
290
      "id" => attestation.id,
291
      "claim" => attestation.claim,
292
      "claim_digest" => attestation.claim_digest,
293
      "signature" => attestation.signature,
294
      "signature_algorithm" => attestation.signature_algorithm,
295
      "event_type" => attestation.event_type,
296
      "subject_id" => attestation.subject_id,
297
      "issuer_key_id" => attestation.issuer_key_id,
298
      "transparency_tier" => attestation.transparency_tier,
299
      "attested_at" => attestation.attested_at,
300
      "supersedes" => attestation.supersedes_digest,
301
      "revokes" => attestation.revokes_id,
302
      "revocation" => %{
303
        "revoked" => not is_nil(attestation.revoked_at),
304
        "revoked_at" => attestation.revoked_at,
305
        "reason_code" => attestation.revocation_reason_code
306
      }
307
    }
308
  end
309
310
  @doc "The published form of one admitted key."
311
  @spec key_projection(SigningKey.t()) :: map()
312
  def key_projection(%SigningKey{} = key) do
313
    %{
314
      "key_id" => key.key_id,
315
      "algorithm" => key.algorithm,
316
      "public_key" => key.public_key,
317
      "issuer" => key.issuer,
318
      "activated_at" => key.activated_at,
319
      "retired_at" => key.retired_at,
320
      "status" => if(is_nil(key.retired_at), do: "active", else: "retired")
321
    }
322
  end
323
324
  @doc """
325
  The attestations on one issue that `tiers` may disclose.
326
327
  A `repository` tier attestation discloses evidence references to repository
328
  members, so callers pass the tiers the reader holds authority for.
329
  """
330
  @spec list_for_issue(Repository.t(), pos_integer(), [String.t()]) :: [Attestation.t()]
331
  def list_for_issue(%Repository{id: repository_id}, issue_number, tiers) do
332
    Repo.all(
333
      from attestation in Attestation,
334
        where:
335
          attestation.repository_id == ^repository_id and
336
            attestation.issue_number == ^issue_number and
337
            attestation.transparency_tier in ^tiers,
338
        order_by: [asc: attestation.attested_at, asc: attestation.id]
339
    )
340
  end
341
342
  @doc "One attestation in one repository, or `nil`."
343
  @spec get(Repository.t(), String.t(), [String.t()]) :: Attestation.t() | nil
344
  def get(%Repository{id: repository_id}, id, tiers) do
345
    Repo.one(
346
      from attestation in Attestation,
347
        where:
348
          attestation.repository_id == ^repository_id and attestation.id == ^id and
349
            attestation.transparency_tier in ^tiers
350
    )
351
  rescue
352
    Ecto.Query.CastError -> nil
353
  end
354
355
  @doc """
356
  Scoped evidence about one subject in one repository.
357
358
  The projection counts live and revoked events per policy inside one
359
  repository. `score` is always `nil`: a ranking system may weigh these
360
  counts, but nothing here publishes a universal number, and evidence from
361
  one repository never leaks into another's summary.
362
  """
363
  @spec subject_evidence(String.t(), Repository.t()) :: map()
364
  def subject_evidence(subject_id, %Repository{} = repository) do
365
    attestations =
366
      Repo.all(
367
        from attestation in Attestation,
368
          where:
369
            attestation.repository_id == ^repository.id and
370
              attestation.subject_id == ^subject_id
371
      )
372
373
    {live, revoked} = Enum.split_with(attestations, &is_nil(&1.revoked_at))
374
375
    %{
376
      "subject_id" => subject_id,
377
      "scope" => "repository",
378
      "repository" => path(repository),
379
      "policy_id" => @policy_id,
380
      "counts" => Enum.frequencies_by(live, & &1.event_type),
381
      "revoked" => length(revoked),
382
      "score" => nil
383
    }
384
  end
385
386
  defp persist(policy, key, signer, repository, outcome, evidence, attributes) do
387
    attested_at = attributes[:attested_at] || DateTime.utc_now()
388
389
    claim =
390
      Claim.build(%{
391
        event_type: attributes[:event_type],
392
        issuer_key_id: key.key_id,
393
        issuer_public_key: key.public_key,
394
        subject_id: attributes[:subject_id],
395
        outcome_kind: outcome.kind,
396
        outcome_ref: outcome.ref,
397
        outcome_digest: outcome.digest,
398
        outcome_state: outcome.state,
399
        repository: path(repository),
400
        repository_id: repository.id,
401
        issue_number: attributes[:issue_number],
402
        revision: attributes[:revision],
403
        artifact_digest: attributes[:artifact_digest],
404
        policy_id: policy.policy_id,
405
        policy_version: policy.version,
406
        policy_digest: policy.policy_digest,
407
        confidence_ppm: attributes[:confidence_ppm],
408
        transparency_tier: attributes[:transparency_tier],
409
        evidence: evidence,
410
        attested_at: attested_at,
411
        nonce: attributes[:nonce] || Claim.nonce(),
412
        supersedes_digest: attributes[:supersedes_digest]
413
      })
414
415
    with {:ok, digest} <- Claim.digest(claim),
416
         {:ok, signature} <- Claim.sign(claim, Map.fetch!(signer, :private_key)) do
417
      %Attestation{}
418
      |> Attestation.changeset(%{
419
        repository_id: repository.id,
420
        issue_number: attributes[:issue_number],
421
        event_type: attributes[:event_type],
422
        subject_id: attributes[:subject_id],
423
        issuer_key_id: key.key_id,
424
        outcome_kind: outcome.kind,
425
        outcome_ref: outcome.ref,
426
        outcome_digest: outcome.digest,
427
        revision: attributes[:revision],
428
        artifact_digest: attributes[:artifact_digest],
429
        policy_id: policy.policy_id,
430
        policy_version: policy.version,
431
        policy_digest: policy.policy_digest,
432
        confidence_ppm: attributes[:confidence_ppm],
433
        transparency_tier: attributes[:transparency_tier],
434
        attested_at: attested_at,
435
        nonce: claim["nonce"],
436
        claim: claim,
437
        claim_digest: digest,
438
        signature: signature,
439
        signature_algorithm: Claim.algorithm(),
440
        supersedes_digest: attributes[:supersedes_digest],
441
        revokes_id: attributes[:revokes_id]
442
      })
443
      |> Repo.insert()
444
    end
445
  end
446
447
  defp issue_invalidation(attestation, policy, signer, attributes, event_type) do
448
    attestation = Repo.preload(attestation, :repository)
449
450
    issue(
451
      policy,
452
      signer,
453
      %{
454
        event_type: event_type,
455
        subject_id: Map.get(attributes, :subject_id, attestation.subject_id),
456
        outcome: %{kind: attestation.outcome_kind, ref: attestation.outcome_ref},
457
        repository: attestation.repository,
458
        issue_number: attestation.issue_number,
459
        revision: attestation.revision,
460
        artifact_digest: attestation.artifact_digest,
461
        confidence_ppm: Map.get(attributes, :confidence_ppm, 1_000_000),
462
        transparency_tier: attestation.transparency_tier,
463
        evidence: Map.get(attributes, :evidence) || evidence_for_link(attestation),
464
        supersedes_digest: attestation.claim_digest,
465
        revokes_id: attestation.id
466
      }
467
    )
468
  end
469
470
  defp mark_revoked!(attestation, revocation, reason_code) do
471
    attestation
472
    |> Attestation.revocation_changeset(%{
473
      revoked_at: revocation.attested_at,
474
      revocation_reason_code: reason_code,
475
      revoked_by_id: revocation.id
476
    })
477
    |> Repo.update!()
478
  end
479
480
  defp evidence_for_link(%Attestation{} = attestation) do
481
    [
482
      %{
483
        "kind" => "attestation",
484
        "ref" => attestation.claim_digest,
485
        "digest" => attestation.claim_digest,
486
        "observed_at" => DateTime.to_iso8601(attestation.attested_at)
487
      }
488
    ]
489
  end
490
491
  defp signature_report(attestation, nil, _digest_match?) do
492
    %{"valid" => false, "key_id" => attestation.issuer_key_id, "key_status" => "unknown"}
493
  end
494
495
  defp signature_report(attestation, %SigningKey{} = key, digest_match?) do
496
    valid? =
497
      digest_match? and
498
        attestation.signature_algorithm == key.algorithm and
499
        Claim.valid_signature?(attestation.claim, attestation.signature, key.public_key)
500
501
    %{
502
      "valid" => valid?,
503
      "key_id" => key.key_id,
504
      "key_status" => if(is_nil(key.retired_at), do: "active", else: "retired"),
505
      "key_active_at_attestation" => SigningKey.active_at?(key, attestation.attested_at)
506
    }
507
  end
508
509
  defp policy_report(attestation) do
510
    admitted =
511
      Repo.get_by(PolicyReceipt,
512
        policy_id: attestation.policy_id,
513
        version: attestation.policy_version
514
      )
515
516
    current = current_policy_version(attestation.policy_id)
517
518
    %{
519
      "policy_id" => attestation.policy_id,
520
      "version" => attestation.policy_version,
521
      "current_version" => current,
522
      "admitted" => not is_nil(admitted),
523
      "digest_match" =>
524
        not is_nil(admitted) and admitted.policy_digest == attestation.policy_digest,
525
      "superseded" => not is_nil(current) and current > attestation.policy_version
526
    }
527
  end
528
529
  defp current_policy_version(policy_id) do
530
    Repo.one(
531
      from receipt in PolicyReceipt,
532
        where: receipt.policy_id == ^policy_id,
533
        select: max(receipt.version)
534
    )
535
  end
536
537
  defp binding_report(attestation, expectation) do
538
    claimed = %{
539
      repository: attestation.claim["scope"]["repository"],
540
      issue_number: attestation.claim["scope"]["issue_number"],
541
      revision: attestation.claim["scope"]["revision"],
542
      subject_id: attestation.claim["subject"]["actor_id"],
543
      event_type: attestation.claim["event_type"],
544
      outcome_ref: attestation.claim["outcome"]["ref"],
545
      policy_id: attestation.claim["verifier"]["policy_id"]
546
    }
547
548
    mismatches =
549
      expectation
550
      |> Enum.filter(fn {field, expected} -> Map.get(claimed, field) != expected end)
551
      |> Enum.map(fn {field, expected} ->
552
        %{
553
          "field" => to_string(field),
554
          "expected" => expected,
555
          "claimed" => Map.get(claimed, field)
556
        }
557
      end)
558
559
    columns_match? =
560
      claimed.repository == path(attestation.repository) and
561
        claimed.issue_number == attestation.issue_number and
562
        claimed.subject_id == attestation.subject_id and
563
        claimed.event_type == attestation.event_type and
564
        claimed.revision == attestation.revision
565
566
    %{
567
      "matches" => mismatches == [] and columns_match?,
568
      "claim_matches_columns" => columns_match?,
569
      "mismatches" => mismatches
570
    }
571
  end
572
573
  defp evidence_report(attestation) do
574
    max_age = policy_rule(attestation, "evidence_max_age_seconds")
575
576
    entries =
577
      Enum.map(attestation.claim["evidence"] || [], fn entry ->
578
        age = evidence_age(entry, attestation.attested_at)
579
580
        Map.merge(entry, %{
581
          "available" => evidence_available?(entry, attestation),
582
          "age_seconds" => age,
583
          "stale" => is_integer(age) and is_integer(max_age) and age > max_age
584
        })
585
      end)
586
587
    %{
588
      "entries" => entries,
589
      "available" => entries != [] and Enum.all?(entries, & &1["available"]),
590
      "stale" => Enum.any?(entries, & &1["stale"])
591
    }
592
  end
593
594
  defp policy_rule(attestation, rule) do
595
    case Repo.get_by(PolicyReceipt,
596
           policy_id: attestation.policy_id,
597
           version: attestation.policy_version
598
         ) do
599
      nil -> Map.get(@policy_rules, rule)
600
      receipt -> Map.get(receipt.rules, rule)
601
    end
602
  end
603
604
  defp evidence_age(entry, attested_at) do
605
    with observed when is_binary(observed) <- entry["observed_at"],
606
         {:ok, observed_at, _offset} <- DateTime.from_iso8601(observed) do
607
      DateTime.diff(attested_at, observed_at)
608
    else
609
      _other -> nil
610
    end
611
  end
612
613
  defp evidence_available?(%{"disclosed" => false}, _attestation), do: false
614
615
  defp evidence_available?(entry, attestation) do
616
    case entry["kind"] do
617
      "outcome" ->
618
        resolvable_outcome?(attestation.outcome_kind, entry["ref"])
619
620
      "issue" ->
621
        issue_exists?(attestation.repository_id, entry["ref"])
622
623
      "repository" ->
624
        entry["ref"] == path(attestation.repository)
625
626
      "attestation" ->
627
        digest = entry["ref"]
628
        Repo.exists?(from other in Attestation, where: other.claim_digest == ^digest)
629
630
      _other ->
631
        false
632
    end
633
  end
634
635
  defp resolvable_outcome?("compensation_outcome_decision", ref) when is_binary(ref),
636
    do:
637
      Repo.exists?(from decision in OutcomeDecision, where: decision.decision_receipt_ref == ^ref)
638
639
  defp resolvable_outcome?(_kind, _ref), do: false
640
641
  defp issue_exists?(repository_id, ref) when is_binary(ref) do
642
    case Integer.parse(ref |> String.split("#") |> List.last() || "") do
643
      {number, ""} ->
644
        Repo.exists?(
645
          from issue in Issue,
646
            where: issue.repository_id == ^repository_id and issue.number == ^number
647
        )
648
649
      _other ->
650
        false
651
    end
652
  end
653
654
  defp issue_exists?(_repository_id, _ref), do: false
655
656
  defp revocation_report(attestation) do
657
    %{
658
      "revoked" => not is_nil(attestation.revoked_at),
659
      "revoked_at" => attestation.revoked_at,
660
      "reason_code" => attestation.revocation_reason_code,
661
      "revoked_by" => attestation.revoked_by_id,
662
      "supersedes" => attestation.supersedes_digest
663
    }
664
  end
665
666
  defp verified?(report) do
667
    private? = report["evidence"]["entries"] |> Enum.any?(&(&1["disclosed"] == false))
668
669
    report["digest_match"] and report["signature"]["valid"] and
670
      report["signature"]["key_active_at_attestation"] == true and
671
      report["policy"]["digest_match"] and report["binding"]["matches"] and
672
      not report["revocation"]["revoked"] and not report["evidence"]["stale"] and
673
      (report["evidence"]["available"] or private?)
674
  end
675
676
  defp validate_policy(%PolicyReceipt{} = policy) do
677
    expected = policy_digest(policy.policy_id, policy.version, policy.rules)
678
679
    if expected == policy.policy_digest, do: :ok, else: {:error, :policy_digest_mismatch}
680
  end
681
682
  # An invalidating event exists only as the linked successor of the claim it
683
  # invalidates, so it carries the attestation it revokes.
684
  defp validate_event_type(event_type, revokes_id) do
685
    invalidating? = event_type in Attestation.invalidating_event_types()
686
687
    cond do
688
      event_type not in Attestation.event_types() -> {:error, :event_type_unsupported}
689
      invalidating? and is_nil(revokes_id) -> {:error, :invalidation_requires_prior_attestation}
690
      not invalidating? and not is_nil(revokes_id) -> {:error, :event_type_not_invalidating}
691
      true -> :ok
692
    end
693
  end
694
695
  defp validate_invalidating_event(event_type) do
696
    if event_type in Attestation.invalidating_event_types(),
697
      do: :ok,
698
      else: {:error, :event_type_not_invalidating}
699
  end
700
701
  defp validate_reason_code(code) when is_binary(code) and byte_size(code) > 0, do: :ok
702
  defp validate_reason_code(_code), do: {:error, :reason_code_required}
703
704
  defp validate_confidence(policy, confidence) when is_integer(confidence) do
705
    minimum = Map.get(policy.rules, "minimum_confidence_ppm", 0)
706
707
    cond do
708
      confidence < 0 or confidence > 1_000_000 -> {:error, :confidence_out_of_range}
709
      confidence < minimum -> {:error, :confidence_below_policy}
710
      true -> :ok
711
    end
712
  end
713
714
  defp validate_confidence(_policy, _confidence), do: {:error, :confidence_required}
715
716
  defp fetch_repository(%Repository{} = repository), do: {:ok, repository}
717
  defp fetch_repository(_other), do: {:error, :repository_required}
718
719
  defp validate_issue_number(repository, number) when is_integer(number) and number > 0 do
720
    if Repo.exists?(
721
         from issue in Issue,
722
           where: issue.repository_id == ^repository.id and issue.number == ^number
723
       ),
724
       do: :ok,
725
       else: {:error, :issue_not_found}
726
  end
727
728
  defp validate_issue_number(_repository, _number), do: {:error, :issue_number_required}
729
730
  defp validate_tier(repository, tier) do
731
    cond do
732
      tier not in Attestation.transparency_tiers() ->
733
        {:error, :transparency_tier_unsupported}
734
735
      tier == "public" and not public_disclosure?(repository) ->
736
        {:error, :transparency_tier_exceeds_repository_authority}
737
738
      true ->
739
        :ok
740
    end
741
  end
742
743
  defp public_disclosure?(repository) do
744
    repository.visibility == "public" or Visibility.allows?(repository.name, :ledger)
745
  end
746
747
  defp validate_evidence(policy, repository, entries) when is_list(entries) and entries != [] do
748
    kinds = Map.get(policy.rules, "evidence_kinds", [])
749
750
    entries
751
    |> Enum.reduce_while({:ok, []}, fn entry, {:ok, validated} ->
752
      normalized = Map.new(entry, fn {key, value} -> {to_string(key), value} end)
753
754
      case validate_evidence_entry(normalized, kinds, repository) do
755
        :ok -> {:cont, {:ok, [normalized | validated]}}
756
        {:error, reason} -> {:halt, {:error, reason}}
757
      end
758
    end)
759
    |> case do
760
      {:ok, validated} -> {:ok, Enum.reverse(validated)}
761
      error -> error
762
    end
763
  end
764
765
  defp validate_evidence(_policy, _repository, _entries), do: {:error, :evidence_required}
766
767
  defp validate_evidence_entry(entry, kinds, repository) do
768
    cond do
769
      entry["kind"] not in kinds ->
770
        {:error, :evidence_kind_unsupported}
771
772
      not is_binary(entry["ref"]) or entry["ref"] == "" ->
773
        {:error, :evidence_ref_required}
774
775
      not valid_digest?(entry["digest"]) ->
776
        {:error, :evidence_digest_invalid}
777
778
      not valid_timestamp?(entry["observed_at"]) ->
779
        {:error, :evidence_observed_at_invalid}
780
781
      entry["kind"] in ~w(issue repository) and not repository_scoped?(entry["ref"], repository) ->
782
        {:error, :evidence_outside_repository_authority}
783
784
      true ->
785
        :ok
786
    end
787
  end
788
789
  defp repository_scoped?(ref, repository) do
790
    path = path(repository)
791
792
    ref == path or String.starts_with?(ref, path <> "#")
793
  end
794
795
  defp valid_digest?(digest) when is_binary(digest),
796
    do: Regex.match?(~r/\A[0-9a-f]{64}\z/, digest)
797
798
  defp valid_digest?(_digest), do: false
799
800
  defp valid_timestamp?(value) when is_binary(value) do
801
    match?({:ok, _instant, _offset}, DateTime.from_iso8601(value))
802
  end
803
804
  defp valid_timestamp?(_value), do: false
805
806
  # The accepted-outcome contract. `compensation_outcome_decision` is the
807
  # accepted-outcome receipt the application records today; verified bounty
808
  # settlement adds one clause here and no new attestation semantics.
809
  defp resolve_outcome(policy, %{kind: kind, ref: ref}) when is_binary(kind) and is_binary(ref) do
810
    if kind in Map.get(policy.rules, "accepted_outcome_kinds", []) do
811
      resolve_outcome_state(policy, kind, ref)
812
    else
813
      {:error, :outcome_kind_unsupported}
814
    end
815
  end
816
817
  defp resolve_outcome(_policy, _outcome), do: {:error, :outcome_required}
818
819
  defp resolve_outcome_state(policy, "compensation_outcome_decision" = kind, ref) do
820
    terminal = Map.get(policy.rules, "accepted_terminal_state")
821
822
    case Repo.get_by(OutcomeDecision, decision_receipt_ref: ref) do
823
      nil ->
824
        {:error, :outcome_not_found}
825
826
      %OutcomeDecision{decision: ^terminal} = decision ->
827
        {:ok, %{kind: kind, ref: ref, digest: decision.outcome_digest, state: decision.decision}}
828
829
      %OutcomeDecision{} ->
830
        {:error, :outcome_not_accepted}
831
    end
832
  end
833
834
  defp fetch_signing_key(signer, attested_at) do
835
    instant = attested_at || DateTime.utc_now()
836
    private_key = Map.get(signer, :private_key)
837
838
    with {:ok, key} <- lookup_key(Map.get(signer, :key_id)),
839
         :ok <- require_active_key(key, instant),
840
         :ok <- require_matching_key(key, private_key) do
841
      {:ok, key}
842
    end
843
  end
844
845
  defp lookup_key(key_id) when is_binary(key_id) do
846
    case Repo.get_by(SigningKey, key_id: key_id) do
847
      nil -> {:error, :signing_key_unknown}
848
      key -> {:ok, key}
849
    end
850
  end
851
852
  defp lookup_key(_key_id), do: {:error, :signing_key_required}
853
854
  defp require_active_key(key, instant) do
855
    if SigningKey.active_at?(key, instant), do: :ok, else: {:error, :signing_key_retired}
856
  end
857
858
  defp require_matching_key(key, private_key) when is_binary(private_key) do
859
    if Claim.public_key_for(private_key) == key.public_key,
860
      do: :ok,
861
      else: {:error, :signing_key_mismatch}
862
  end
863
864
  defp require_matching_key(_key, _private_key), do: {:error, :private_key_required}
865
866
  defp require_live(%Attestation{revoked_at: nil}), do: :ok
867
  defp require_live(%Attestation{}), do: {:error, :already_revoked}
868
869
  defp validate_operator(%{authenticated: true} = operator) do
870
    required = [:actor_id, :auth_method, :approval_receipt_ref]
871
872
    if Enum.all?(required, &is_binary(Map.get(operator, &1))),
873
      do: :ok,
874
      else: {:error, :operator_receipt_incomplete}
875
  end
876
877
  defp validate_operator(_operator), do: {:error, :operator_unauthenticated}
878
879
  defp path(%Repository{owner: owner, name: name}), do: "#{owner}/#{name}"
880
end
lib/openagents/reputation/attestation.ex added +101

@@ -0,0 +1,101 @@

1
defmodule OpenAgents.Reputation.Attestation do
2
  @moduledoc """
3
  One signed reputation event about one accepted outcome.
4
5
  The `claim` map is the exact object the signature covers. Every column
6
  beside it is a projection of that claim kept for querying, so a client can
7
  ignore the columns, canonicalize the claim, and check the signature itself.
8
  """
9
  use Ecto.Schema
10
  import Ecto.Changeset
11
12
  alias OpenAgents.Repositories.Repository
13
14
  @primary_key {:id, :binary_id, autogenerate: true}
15
  @foreign_key_type :binary_id
16
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
17
18
  @event_types ~w(completion verification review payment reversal revocation)
19
  @invalidating_event_types ~w(reversal revocation)
20
  @transparency_tiers ~w(public repository private)
21
22
  schema "reputation_attestations" do
23
    belongs_to :repository, Repository
24
    field :issue_number, :integer
25
    field :event_type, :string
26
    field :subject_id, :string
27
    field :issuer_key_id, :string
28
    field :outcome_kind, :string
29
    field :outcome_ref, :string
30
    field :outcome_digest, :string
31
    field :revision, :string
32
    field :artifact_digest, :string
33
    field :policy_id, :string
34
    field :policy_version, :integer
35
    field :policy_digest, :string
36
    field :confidence_ppm, :integer
37
    field :transparency_tier, :string
38
    field :attested_at, :utc_datetime_usec
39
    field :nonce, :string
40
    field :claim, :map
41
    field :claim_digest, :string
42
    field :signature, :string
43
    field :signature_algorithm, :string
44
    field :supersedes_digest, :string
45
    field :revoked_at, :utc_datetime_usec
46
    field :revocation_reason_code, :string
47
    field :revoked_by_id, Ecto.UUID
48
    belongs_to :revokes, __MODULE__
49
    timestamps()
50
  end
51
52
  @type t :: %__MODULE__{}
53
54
  def event_types, do: @event_types
55
  def invalidating_event_types, do: @invalidating_event_types
56
  def transparency_tiers, do: @transparency_tiers
57
58
  def changeset(record, attributes) do
59
    required =
60
      ~w(repository_id issue_number event_type subject_id issuer_key_id outcome_kind outcome_ref
61
         outcome_digest revision artifact_digest policy_id policy_version policy_digest
62
         confidence_ppm transparency_tier attested_at nonce claim claim_digest signature
63
         signature_algorithm)a
64
65
    record
66
    |> cast(attributes, required ++ ~w(supersedes_digest revokes_id)a)
67
    |> validate_required(required)
68
    |> validate_inclusion(:event_type, @event_types)
69
    |> validate_inclusion(:transparency_tier, @transparency_tiers)
70
    |> validate_number(:issue_number, greater_than: 0)
71
    |> validate_number(:policy_version, greater_than: 0)
72
    |> validate_number(:confidence_ppm,
73
      greater_than_or_equal_to: 0,
74
      less_than_or_equal_to: 1_000_000
75
    )
76
    |> validate_format(:outcome_digest, ~r/\A[0-9a-f]{64}\z/)
77
    |> validate_format(:artifact_digest, ~r/\A[0-9a-f]{64}\z/)
78
    |> validate_format(:policy_digest, ~r/\A[0-9a-f]{64}\z/)
79
    |> validate_format(:claim_digest, ~r/\A[0-9a-f]{64}\z/)
80
    |> validate_format(:issuer_key_id, ~r/\A[0-9a-f]{64}\z/)
81
    |> validate_format(:nonce, ~r/\A[0-9a-f]{32}\z/)
82
    |> validate_length(:subject_id, min: 1, max: 256)
83
    |> validate_length(:outcome_kind, min: 1, max: 64)
84
    |> validate_length(:outcome_ref, min: 1, max: 256)
85
    |> validate_length(:revision, min: 1, max: 128)
86
    |> foreign_key_constraint(:repository_id)
87
    |> foreign_key_constraint(:revokes_id)
88
    |> unique_constraint(:claim_digest)
89
    |> unique_constraint(:revokes_id)
90
    |> unique_constraint([:issuer_key_id, :subject_id, :outcome_kind, :outcome_ref, :event_type],
91
      name: :reputation_attestations_outcome_event_index
92
    )
93
  end
94
95
  def revocation_changeset(record, attributes) do
96
    record
97
    |> cast(attributes, ~w(revoked_at revocation_reason_code revoked_by_id)a)
98
    |> validate_required(~w(revoked_at revocation_reason_code)a)
99
    |> validate_length(:revocation_reason_code, min: 1, max: 64)
100
  end
101
end
lib/openagents/reputation/claim.ex added +155

@@ -0,0 +1,155 @@

1
defmodule OpenAgents.Reputation.Claim do
2
  @moduledoc """
3
  The canonical attestation claim: the exact object a signature covers.
4
5
  A claim binds the issuer key, the subject, the accepted outcome, the
6
  repository scope, the revision, the artifact digest, the verifier policy,
7
  the confidence, the evidence, and a nonce. Changing any of those changes the
8
  claim digest, so one attestation cannot be presented for another issue,
9
  revision, verifier, or actor.
10
11
  Signing and verification use Ed25519 through `:crypto`, over the canonical
12
  JSON encoding from `OpenAgents.Provenance.Canonical`.
13
  """
14
15
  alias OpenAgents.Provenance.Canonical
16
17
  @schema "openagents.reputation.attestation.v1"
18
  @algorithm "ed25519"
19
20
  @type claim :: %{optional(String.t()) => term()}
21
22
  def schema, do: @schema
23
  def algorithm, do: @algorithm
24
25
  @doc "A fresh Ed25519 keypair. The private key is never persisted."
26
  @spec generate_keypair() :: %{key_id: String.t(), public_key: String.t(), private_key: binary()}
27
  def generate_keypair do
28
    {public_key, private_key} = :crypto.generate_key(:eddsa, :ed25519)
29
30
    %{
31
      key_id: key_id(public_key),
32
      public_key: Base.encode16(public_key, case: :lower),
33
      private_key: private_key
34
    }
35
  end
36
37
  @doc "The public key that `private_key` signs for, hex encoded."
38
  @spec public_key_for(binary()) :: String.t()
39
  def public_key_for(private_key) when is_binary(private_key) do
40
    {public_key, _private_key} = :crypto.generate_key(:eddsa, :ed25519, private_key)
41
    Base.encode16(public_key, case: :lower)
42
  end
43
44
  @doc "The key identifier for a raw or hex encoded public key."
45
  @spec key_id(binary()) :: String.t()
46
  def key_id(public_key) when is_binary(public_key) do
47
    public_key |> decode_key() |> Canonical.sha256()
48
  end
49
50
  @doc """
51
  The canonical claim for `attributes`.
52
53
  A `private` transparency tier keeps references out of the signed object:
54
  the claim carries each reference's kind, digest, and observation time, and
55
  the outcome's digest without its receipt reference. The published claim
56
  stays complete and verifiable and discloses nothing about the work.
57
  """
58
  @spec build(map()) :: claim()
59
  def build(attributes) do
60
    %{
61
      "schema" => @schema,
62
      "event_type" => attributes.event_type,
63
      "issuer" => %{
64
        "key_id" => attributes.issuer_key_id,
65
        "algorithm" => @algorithm,
66
        "public_key" => attributes.issuer_public_key
67
      },
68
      "subject" => %{"actor_id" => attributes.subject_id},
69
      "outcome" => %{
70
        "kind" => attributes.outcome_kind,
71
        "ref" => if(disclosed?(attributes.transparency_tier), do: attributes.outcome_ref),
72
        "digest" => attributes.outcome_digest,
73
        "state" => attributes.outcome_state
74
      },
75
      "scope" => %{
76
        "repository" => attributes.repository,
77
        "repository_id" => attributes.repository_id,
78
        "issue_number" => attributes.issue_number,
79
        "revision" => attributes.revision,
80
        "artifact_digest" => attributes.artifact_digest
81
      },
82
      "verifier" => %{
83
        "policy_id" => attributes.policy_id,
84
        "policy_version" => attributes.policy_version,
85
        "policy_digest" => attributes.policy_digest
86
      },
87
      "confidence_ppm" => attributes.confidence_ppm,
88
      "transparency_tier" => attributes.transparency_tier,
89
      "evidence" => evidence(attributes.evidence, attributes.transparency_tier),
90
      "attested_at" => DateTime.to_iso8601(attributes.attested_at),
91
      "nonce" => attributes.nonce,
92
      "supersedes" => attributes[:supersedes_digest]
93
    }
94
  end
95
96
  @doc "The canonical digest of `claim`."
97
  @spec digest(claim()) :: {:ok, String.t()} | {:error, term()}
98
  def digest(claim), do: Canonical.digest(claim)
99
100
  @doc "Signs the canonical encoding of `claim` with an Ed25519 private key."
101
  @spec sign(claim(), binary()) :: {:ok, String.t()} | {:error, term()}
102
  def sign(claim, private_key) when is_binary(private_key) do
103
    with {:ok, encoded} <- Canonical.encode(claim) do
104
      signature = :crypto.sign(:eddsa, :none, encoded, [private_key, :ed25519])
105
      {:ok, Base.encode16(signature, case: :lower)}
106
    end
107
  end
108
109
  @doc "Whether `signature` covers `claim` under `public_key`."
110
  @spec valid_signature?(claim(), String.t(), String.t()) :: boolean()
111
  def valid_signature?(claim, signature, public_key) do
112
    with {:ok, encoded} <- Canonical.encode(claim),
113
         {:ok, raw_signature} <- Base.decode16(signature, case: :mixed) do
114
      :crypto.verify(:eddsa, :none, encoded, raw_signature, [decode_key(public_key), :ed25519])
115
    else
116
      _other -> false
117
    end
118
  rescue
119
    ErlangError -> false
120
  end
121
122
  @doc "A fresh claim nonce."
123
  @spec nonce() :: String.t()
124
  def nonce, do: 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
125
126
  defp evidence(entries, tier) do
127
    entries
128
    |> Enum.map(&entry(&1, tier))
129
    |> Enum.sort_by(& &1["digest"])
130
  end
131
132
  defp entry(entry, tier) do
133
    entry = Map.new(entry, fn {key, value} -> {to_string(key), value} end)
134
    disclosed? = disclosed?(tier)
135
136
    %{
137
      "kind" => entry["kind"],
138
      "ref" => if(disclosed?, do: entry["ref"]),
139
      "digest" => entry["digest"],
140
      "observed_at" => entry["observed_at"],
141
      "disclosed" => disclosed?
142
    }
143
  end
144
145
  defp disclosed?(tier), do: tier != "private"
146
147
  defp decode_key(key) when byte_size(key) == 64 do
148
    case Base.decode16(key, case: :mixed) do
149
      {:ok, raw} -> raw
150
      :error -> key
151
    end
152
  end
153
154
  defp decode_key(key), do: key
155
end
lib/openagents/reputation/policy_receipt.ex added +38

@@ -0,0 +1,38 @@

1
defmodule OpenAgents.Reputation.PolicyReceipt do
2
  @moduledoc "The admitted verifier policy one attestation was issued under."
3
  use Ecto.Schema
4
  import Ecto.Changeset
5
6
  @primary_key {:id, :binary_id, autogenerate: true}
7
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
8
9
  schema "reputation_verifier_policies" do
10
    field :policy_id, :string
11
    field :version, :integer
12
    field :policy_digest, :string
13
    field :rules, :map
14
    field :actor_id, :string
15
    field :auth_method, :string
16
    field :approval_receipt_ref, :string
17
    timestamps()
18
  end
19
20
  @type t :: %__MODULE__{}
21
22
  def changeset(record, attributes) do
23
    fields =
24
      ~w(policy_id version policy_digest rules actor_id auth_method approval_receipt_ref)a
25
26
    record
27
    |> cast(attributes, fields)
28
    |> validate_required(fields)
29
    |> validate_number(:version, greater_than: 0)
30
    |> validate_format(:policy_digest, ~r/\A[0-9a-f]{64}\z/)
31
    |> validate_length(:policy_id, min: 1, max: 128)
32
    |> validate_length(:actor_id, min: 1, max: 256)
33
    |> validate_length(:auth_method, min: 1, max: 128)
34
    |> validate_length(:approval_receipt_ref, min: 1, max: 256)
35
    |> unique_constraint([:policy_id, :version])
36
    |> unique_constraint(:approval_receipt_ref)
37
  end
38
end
lib/openagents/reputation/signing_key.ex added +55

@@ -0,0 +1,55 @@

1
defmodule OpenAgents.Reputation.SigningKey do
2
  @moduledoc """
3
  One admitted attestation issuer key.
4
5
  The row carries the public key only. A private key stays in runtime
6
  configuration (RELEASE-002), so reading every key a verifier needs never
7
  grants the authority to mint an attestation.
8
  """
9
  use Ecto.Schema
10
  import Ecto.Changeset
11
12
  @primary_key {:id, :binary_id, autogenerate: true}
13
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
14
15
  @algorithms ~w(ed25519)
16
17
  schema "reputation_signing_keys" do
18
    field :key_id, :string
19
    field :algorithm, :string
20
    field :public_key, :string
21
    field :issuer, :string
22
    field :activated_at, :utc_datetime_usec
23
    field :retired_at, :utc_datetime_usec
24
    timestamps()
25
  end
26
27
  @type t :: %__MODULE__{}
28
29
  def algorithms, do: @algorithms
30
31
  def changeset(record, attributes) do
32
    record
33
    |> cast(attributes, ~w(key_id algorithm public_key issuer activated_at retired_at)a)
34
    |> validate_required(~w(key_id algorithm public_key issuer activated_at)a)
35
    |> validate_inclusion(:algorithm, @algorithms)
36
    |> validate_format(:key_id, ~r/\A[0-9a-f]{64}\z/)
37
    |> validate_format(:public_key, ~r/\A[0-9a-f]{64}\z/)
38
    |> validate_length(:issuer, min: 1, max: 128)
39
    |> unique_constraint(:key_id)
40
    |> unique_constraint(:public_key)
41
  end
42
43
  def retire_changeset(record, retired_at) do
44
    record
45
    |> cast(%{retired_at: retired_at}, ~w(retired_at)a)
46
    |> validate_required(~w(retired_at)a)
47
  end
48
49
  @doc "Whether the key was admitted and not yet retired at `instant`."
50
  @spec active_at?(t(), DateTime.t()) :: boolean()
51
  def active_at?(%__MODULE__{} = key, %DateTime{} = instant) do
52
    DateTime.compare(instant, key.activated_at) != :lt and
53
      (is_nil(key.retired_at) or DateTime.compare(instant, key.retired_at) == :lt)
54
  end
55
end
lib/openagents_web/api_route_authority.ex modified +6

@@ -69,6 +69,12 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

69 69
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/items" => :optional_bearer,
70 70
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/fields" => :optional_bearer,
71 71
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes" => :optional_bearer,
72
      "get /api/v3/reputation/policy" => :optional_bearer,
73
      "get /api/v3/reputation/keys" => :optional_bearer,
74
      "get /api/v3/repos/:owner/:repo/issues/:issue_number/attestations" => :optional_bearer,
75
      "get /api/v3/repos/:owner/:repo/attestations/:id" => :optional_bearer,
76
      "get /api/v3/repos/:owner/:repo/attestations/:id/verification" => :optional_bearer,
77
      "get /api/v3/repos/:owner/:repo/reputation/subjects/:subject_id" => :optional_bearer,
72 78
      # Scoped bearer pipelines require the route-specific token authority.
73 79
      "get /api/v3/chat/events" => :required_bearer,
74 80
      "post /api/v3/chat/turns" => :required_bearer,
lib/openagents_web/controllers/reputation_controller.ex added +98

@@ -0,0 +1,98 @@

1
defmodule OpenAgentsWeb.ReputationController do
2
  @moduledoc """
3
  Reputation attestations for accepted outcomes, published forge first.
4
5
  Every response carries the signed claim verbatim next to its signature and
6
  the admitted issuer key, so a client can canonicalize the claim, check the
7
  Ed25519 signature, compare the verifier policy, resolve the evidence, and
8
  read the revocation state without trusting this API or the web interface.
9
  Disclosure follows the repository: a reader outside the repository sees
10
  `public` attestations only.
11
  """
12
13
  use OpenAgentsWeb, :controller
14
15
  alias OpenAgents.Reputation
16
  alias OpenAgents.Repositories
17
  alias OpenAgentsWeb.ControllerHelpers
18
19
  def policy(conn, _params) do
20
    json(conn, %{
21
      "policy_id" => Reputation.policy_id(),
22
      "versions" => Enum.map(Reputation.policies(), &Reputation.policy_projection/1),
23
      "score" => nil
24
    })
25
  end
26
27
  def keys(conn, _params) do
28
    json(conn, %{"keys" => Enum.map(Reputation.keys(), &Reputation.key_projection/1)})
29
  end
30
31
  def index(conn, %{"owner" => owner, "repo" => repo, "issue_number" => issue_number}) do
32
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
33
    number = ControllerHelpers.integer_param!(issue_number)
34
    attestations = Reputation.list_for_issue(repository, number, tiers(conn, repository))
35
36
    json(conn, %{
37
      "repository" => "#{repository.owner}/#{repository.name}",
38
      "issue_number" => number,
39
      "attestations" => Enum.map(attestations, &Reputation.projection/1)
40
    })
41
  rescue
42
    Ecto.NoResultsError -> not_found(conn)
43
  end
44
45
  def show(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
46
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
47
48
    case Reputation.get(repository, id, tiers(conn, repository)) do
49
      nil -> not_found(conn)
50
      attestation -> json(conn, Reputation.projection(attestation))
51
    end
52
  rescue
53
    Ecto.NoResultsError -> not_found(conn)
54
  end
55
56
  def verification(conn, %{"owner" => owner, "repo" => repo, "id" => id} = params) do
57
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
58
59
    case Reputation.get(repository, id, tiers(conn, repository)) do
60
      nil -> not_found(conn)
61
      attestation -> json(conn, Reputation.verify(attestation, expectation(params)))
62
    end
63
  rescue
64
    Ecto.NoResultsError -> not_found(conn)
65
  end
66
67
  def subject(conn, %{"owner" => owner, "repo" => repo, "subject_id" => subject_id}) do
68
    repository = Repositories.get_visible_by_path!(owner, repo, conn.assigns[:current_user])
69
70
    json(conn, Reputation.subject_evidence(subject_id, repository))
71
  rescue
72
    Ecto.NoResultsError -> not_found(conn)
73
  end
74
75
  # What the caller believes it is looking at. A mismatch is reported rather
76
  # than corrected, which is how a replayed attestation fails for a client
77
  # that names the issue, revision, subject, or verifier it expects.
78
  defp expectation(params) do
79
    %{
80
      subject_id: params["subject_id"],
81
      revision: params["revision"],
82
      event_type: params["event_type"],
83
      policy_id: params["policy_id"]
84
    }
85
    |> Enum.reject(fn {_field, value} -> is_nil(value) end)
86
    |> Map.new()
87
  end
88
89
  defp tiers(conn, repository) do
90
    if Repositories.member?(repository, conn.assigns[:current_user]) do
91
      ~w(public repository private)
92
    else
93
      ~w(public)
94
    end
95
  end
96
97
  defp not_found(conn), do: conn |> put_status(:not_found) |> json(%{message: "Not Found"})
98
end
lib/openagents_web/router.ex modified +17

@@ -318,6 +318,23 @@ defmodule OpenAgentsWeb.Router do

318 318
    get "/repos/:owner/:repo/projectsV2/:project_number/fields", ProjectController, :fields
319 319
    get "/repos/:owner/:repo/projectsV2/:project_number/notes", ProjectController, :notes
320 320
321
    # Reputation attestations. Reads only: issuance and revocation stay behind
322
    # verifier authority inside the application.
323
    get "/reputation/policy", ReputationController, :policy
324
    get "/reputation/keys", ReputationController, :keys
325
326
    get "/repos/:owner/:repo/issues/:issue_number/attestations",
327
        ReputationController,
328
        :index
329
330
    get "/repos/:owner/:repo/attestations/:id", ReputationController, :show
331
332
    get "/repos/:owner/:repo/attestations/:id/verification",
333
        ReputationController,
334
        :verification
335
336
    get "/repos/:owner/:repo/reputation/subjects/:subject_id", ReputationController, :subject
337
321 338
    # The forum reads. Posting and claiming live behind the write scope.
322 339
    get "/forum", ForumApiController, :boards
323 340
    get "/forum/topics", ForumApiController, :topics
priv/docs/rest-api.md modified +71

@@ -105,6 +105,77 @@ and an edge that would close a cycle each return `422 Unprocessable Entity`,

105 105
and none of the batch is recorded. Reading the graph needs the same access as
106 106
reading the issue. Recording or removing an edge needs repository write access.
107 107
108
## Reputation attestations
109
110
A reputation attestation is a signed claim that one subject completed,
111
verified, reviewed, was paid for, or lost credit for one accepted outcome, in
112
one repository, at one revision, under one verifier policy. Reads are public
113
for public repositories; issuing and revoking stay behind verifier authority.
114
115
```text
116
GET /api/v3/reputation/policy
117
GET /api/v3/reputation/keys
118
GET /api/v3/repos/:owner/:repo/issues/:issue_number/attestations
119
GET /api/v3/repos/:owner/:repo/attestations/:id
120
GET /api/v3/repos/:owner/:repo/attestations/:id/verification
121
GET /api/v3/repos/:owner/:repo/reputation/subjects/:subject_id
122
```
123
124
Every attestation response carries the signed claim verbatim next to its
125
signature, so you can verify it without trusting this API:
126
127
```json
128
{
129
  "claim": {
130
    "schema": "openagents.reputation.attestation.v1",
131
    "event_type": "completion",
132
    "issuer": { "key_id": "…", "algorithm": "ed25519", "public_key": "…" },
133
    "subject": { "actor_id": "…" },
134
    "outcome": { "kind": "compensation_outcome_decision", "state": "accepted" },
135
    "scope": { "repository": "…", "issue_number": 88, "revision": "…" },
136
    "verifier": { "policy_id": "…", "policy_version": 1, "policy_digest": "…" },
137
    "confidence_ppm": 900000,
138
    "evidence": [{ "kind": "outcome", "digest": "…", "disclosed": true }]
139
  },
140
  "claim_digest": "…",
141
  "signature": "…",
142
  "signature_algorithm": "ed25519"
143
}
144
```
145
146
To check one yourself, canonicalize the `claim` with sorted object keys and no
147
insignificant whitespace, confirm its
148
SHA-256 digest equals `claim_digest`, verify the Ed25519 `signature` against
149
the `public_key` that `/api/v3/reputation/keys` publishes for
150
`issuer_key_id`, and hash the policy rules from `/api/v3/reputation/policy` to
151
reproduce `policy_digest`.
152
153
The verification endpoint reports the same checks, plus evidence availability,
154
staleness, and revocation state. Pass what you expect — `subject_id`,
155
`revision`, `event_type`, or `policy_id` — and a claim that binds to something
156
else answers with `verified: false` and the mismatch:
157
158
```sh
159
curl "https://openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/attestations/$ID/verification?subject_id=actor:builder"
160
```
161
162
An attestation is issued only after its accepted-outcome receipt reaches an
163
admitted terminal state. Presence, token volume, online time, and narration
164
are not attestable. A reversed or invalidated outcome gets a linked reversal or
165
revocation attestation, and the original claim stays readable so a past
166
decision remains auditable.
167
168
Subject evidence is scoped to one repository and reports counts per event type.
169
It never returns a score or a ranking:
170
171
```json
172
{ "subject_id": "…", "scope": "repository", "counts": { "completion": 3 }, "score": null }
173
```
174
175
An attestation on a private repository is disclosed to repository members only,
176
and a `private` attestation withholds the outcome reference and every evidence
177
reference from the signed claim while staying verifiable.
178
108 179
## Labels
109 180
110 181
```text
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -240,7 +240,8 @@

240 240
    20260823042207,
241 241
    20260823043000,
242 242
    20260823050000,
243
    20260823051500
243
    20260823051500,
244
    20260823052000
244 245
  ],
245 246
  "required_tables": [
246 247
    "users",
priv/repo/migrations/20260823052000_create_reputation_attestations.exs added +138

@@ -0,0 +1,138 @@

1
defmodule OpenAgents.Repo.Migrations.CreateReputationAttestations do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:reputation_verifier_policies, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
      add :policy_id, :string, null: false
8
      add :version, :integer, null: false
9
      add :policy_digest, :string, null: false
10
      add :rules, :map, null: false
11
      add :actor_id, :string, null: false
12
      add :auth_method, :string, null: false
13
      add :approval_receipt_ref, :string, null: false
14
      timestamps(type: :utc_datetime_usec, updated_at: false)
15
    end
16
17
    create unique_index(:reputation_verifier_policies, [:policy_id, :version])
18
    create unique_index(:reputation_verifier_policies, [:approval_receipt_ref])
19
20
    # Only public keys live here. A private key stays runtime-only (RELEASE-002),
21
    # so the table a skeptical client reads carries verification material and
22
    # nothing that can mint an attestation.
23
    create table(:reputation_signing_keys, primary_key: false) do
24
      add :id, :binary_id, primary_key: true
25
      add :key_id, :string, null: false
26
      add :algorithm, :string, null: false
27
      add :public_key, :string, null: false
28
      add :issuer, :string, null: false
29
      add :activated_at, :utc_datetime_usec, null: false
30
      add :retired_at, :utc_datetime_usec
31
      timestamps(type: :utc_datetime_usec, updated_at: false)
32
    end
33
34
    create unique_index(:reputation_signing_keys, [:key_id])
35
    create unique_index(:reputation_signing_keys, [:public_key])
36
37
    create table(:reputation_attestations, primary_key: false) do
38
      add :id, :binary_id, primary_key: true
39
40
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :restrict),
41
        null: false
42
43
      add :issue_number, :integer, null: false
44
      add :event_type, :string, null: false
45
      add :subject_id, :string, null: false
46
      add :issuer_key_id, :string, null: false
47
      add :outcome_kind, :string, null: false
48
      add :outcome_ref, :string, null: false
49
      add :outcome_digest, :string, null: false
50
      add :revision, :string, null: false
51
      add :artifact_digest, :string, null: false
52
      add :policy_id, :string, null: false
53
      add :policy_version, :integer, null: false
54
      add :policy_digest, :string, null: false
55
      add :confidence_ppm, :integer, null: false
56
      add :transparency_tier, :string, null: false
57
      add :attested_at, :utc_datetime_usec, null: false
58
      add :nonce, :string, null: false
59
      add :claim, :map, null: false
60
      add :claim_digest, :string, null: false
61
      add :signature, :text, null: false
62
      add :signature_algorithm, :string, null: false
63
      add :supersedes_digest, :string
64
65
      add :revokes_id,
66
          references(:reputation_attestations, type: :binary_id, on_delete: :restrict)
67
68
      add :revoked_at, :utc_datetime_usec
69
      add :revocation_reason_code, :string
70
      add :revoked_by_id, :binary_id
71
      timestamps(type: :utc_datetime_usec, updated_at: false)
72
    end
73
74
    # The claim digest is the attestation's identity. A replayed claim collides
75
    # here instead of becoming a second attestation.
76
    create unique_index(:reputation_attestations, [:claim_digest])
77
78
    # One outcome-bound event per issuer, subject, and outcome. A second
79
    # completion claim for the same accepted outcome is a duplicate, not
80
    # additional reputation. Invalidating events are excluded: each one is
81
    # already unique through `revokes_id`.
82
    create unique_index(
83
             :reputation_attestations,
84
             [:issuer_key_id, :subject_id, :outcome_kind, :outcome_ref, :event_type],
85
             name: :reputation_attestations_outcome_event_index,
86
             where: "event_type not in ('reversal','revocation')"
87
           )
88
89
    create index(:reputation_attestations, [:repository_id, :issue_number])
90
    create index(:reputation_attestations, [:subject_id])
91
    create unique_index(:reputation_attestations, [:revokes_id])
92
93
    create constraint(:reputation_attestations, :reputation_attestations_confidence_range,
94
             check: "confidence_ppm >= 0 and confidence_ppm <= 1000000"
95
           )
96
97
    create constraint(:reputation_attestations, :reputation_attestations_event_type,
98
             check:
99
               "event_type in ('completion','verification','review','payment','reversal','revocation')"
100
           )
101
102
    create constraint(:reputation_attestations, :reputation_attestations_transparency_tier,
103
             check: "transparency_tier in ('public','repository','private')"
104
           )
105
106
    # Append-only. Revocation is the one field a later event may set, and it
107
    # may only be set once, from null.
108
    execute(
109
      """
110
      CREATE OR REPLACE FUNCTION reputation_attestations_append_only()
111
      RETURNS trigger AS $$
112
      BEGIN
113
        IF row_to_json(NEW)::text <> row_to_json(OLD)::text THEN
114
          IF (OLD.revoked_at IS NOT NULL) OR
115
             (NEW.id <> OLD.id) OR
116
             (NEW.claim_digest <> OLD.claim_digest) OR
117
             (NEW.claim::text <> OLD.claim::text) OR
118
             (NEW.signature <> OLD.signature) THEN
119
            RAISE EXCEPTION 'reputation attestations are append-only';
120
          END IF;
121
        END IF;
122
        RETURN NEW;
123
      END;
124
      $$ LANGUAGE plpgsql;
125
      """,
126
      "DROP FUNCTION IF EXISTS reputation_attestations_append_only();"
127
    )
128
129
    execute(
130
      """
131
      CREATE TRIGGER reputation_attestations_append_only
132
      BEFORE UPDATE ON reputation_attestations
133
      FOR EACH ROW EXECUTE FUNCTION reputation_attestations_append_only();
134
      """,
135
      "DROP TRIGGER IF EXISTS reputation_attestations_append_only ON reputation_attestations;"
136
    )
137
  end
138
end
test/openagents/reputation_test.exs added +505

@@ -0,0 +1,505 @@

1
defmodule OpenAgents.ReputationTest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgents.CompensationFixtures
5
  import OpenAgents.IssuesFixtures
6
7
  alias OpenAgents.Provenance.Canonical
8
  alias OpenAgents.Reputation
9
  alias OpenAgents.Reputation.{Attestation, Claim, PolicyReceipt}
10
11
  setup do
12
    repository = repository_fixture()
13
    issue = issue_fixture(repository)
14
    assert {:ok, policy} = Reputation.admit_policy(operator("policy"))
15
    keypair = Claim.generate_keypair()
16
17
    assert {:ok, key} =
18
             Reputation.admit_key(%{public_key: keypair.public_key, issuer: "verifier"})
19
20
    %{
21
      repository: repository,
22
      issue: issue,
23
      policy: policy,
24
      key: key,
25
      signer: %{key_id: key.key_id, private_key: keypair.private_key}
26
    }
27
  end
28
29
  test "an accepted outcome yields a signed claim a stranger can verify", context do
30
    decision = outcome_decision_fixture()
31
32
    assert {:ok, attestation} = issue!(context, decision, subject_id: "actor:builder")
33
34
    assert attestation.event_type == "completion"
35
    assert attestation.claim["schema"] == Claim.schema()
36
    assert attestation.claim["outcome"]["state"] == "accepted"
37
    assert attestation.claim["verifier"]["policy_digest"] == context.policy.policy_digest
38
    assert attestation.claim_digest == Canonical.digest!(attestation.claim)
39
40
    # The signature check uses only the published claim, the published
41
    # signature, and the published public key.
42
    published = Reputation.projection(attestation)
43
    key = Enum.find(Reputation.keys(), &(&1.key_id == attestation.issuer_key_id))
44
45
    assert Claim.valid_signature?(
46
             published["claim"],
47
             published["signature"],
48
             Reputation.key_projection(key)["public_key"]
49
           )
50
51
    report = Reputation.verify(attestation, %{subject_id: "actor:builder"})
52
    assert report["verified"]
53
    assert report["signature"]["valid"]
54
    assert report["policy"]["digest_match"]
55
    assert report["binding"]["matches"]
56
    assert report["evidence"]["available"]
57
    refute report["revocation"]["revoked"]
58
  end
59
60
  test "a rejected or unknown outcome never earns an attestation", context do
61
    rejected = outcome_decision_fixture("rejected", "utility_failed")
62
63
    assert {:error, :outcome_not_accepted} = issue!(context, rejected)
64
65
    assert {:error, :outcome_not_found} =
66
             Reputation.issue(
67
               context.policy,
68
               context.signer,
69
               attributes(context, "outcome-decision:absent", digest())
70
             )
71
  end
72
73
  test "presence, token volume, and narration are not attestable", context do
74
    decision = outcome_decision_fixture()
75
76
    for kind <- ~w(presence online_time token_volume narration) do
77
      attributes =
78
        context
79
        |> attributes(decision.decision_receipt_ref, decision.outcome_digest)
80
        |> Map.put(:outcome, %{kind: kind, ref: decision.decision_receipt_ref})
81
82
      assert {:error, :outcome_kind_unsupported} =
83
               Reputation.issue(context.policy, context.signer, attributes)
84
    end
85
86
    assert {:ok, attestation} = issue!(context, decision, subject_id: "actor:builder")
87
    evidence = Reputation.subject_evidence("actor:builder", context.repository)
88
89
    assert evidence["score"] == nil
90
    assert evidence["scope"] == "repository"
91
    assert evidence["counts"] == %{"completion" => 1}
92
    refute Reputation.policy_rules()["global_score"]
93
    refute function_exported?(Reputation, :rank, 1)
94
    assert attestation.transparency_tier == "public"
95
  end
96
97
  test "one attestation covers one issue, revision, verifier, and actor", context do
98
    decision = outcome_decision_fixture()
99
    assert {:ok, attestation} = issue!(context, decision, subject_id: "actor:builder")
100
101
    for {field, wrong} <- [
102
          {:subject_id, "actor:impostor"},
103
          {:revision, "0000000000000000000000000000000000000000"},
104
          {:event_type, "payment"},
105
          {:policy_id, "other.verifier.v1"}
106
        ] do
107
      report = Reputation.verify(attestation, %{field => wrong})
108
      refute report["verified"]
109
      assert %{"field" => _, "expected" => ^wrong} = hd(report["binding"]["mismatches"])
110
    end
111
112
    # Re-signing the same event for the same outcome is a duplicate, and a
113
    # claim moved to another subject no longer matches its own digest.
114
    assert {:error, %Ecto.Changeset{}} = issue!(context, decision, subject_id: "actor:builder")
115
116
    tampered = %{
117
      attestation
118
      | claim: put_in(attestation.claim, ["subject", "actor_id"], "actor:x")
119
    }
120
121
    report = Reputation.verify(tampered)
122
    refute report["digest_match"]
123
    refute report["signature"]["valid"]
124
    refute report["verified"]
125
  end
126
127
  test "a reversed outcome yields a linked revocation", context do
128
    decision = outcome_decision_fixture()
129
    assert {:ok, attestation} = issue!(context, decision, subject_id: "actor:builder")
130
131
    assert {:ok, %{revocation: revocation, attestation: revoked}} =
132
             Reputation.revoke(attestation, context.policy, context.signer, %{
133
               event_type: "reversal",
134
               reason_code: "outcome_reversed"
135
             })
136
137
    assert revocation.event_type == "reversal"
138
    assert revocation.revokes_id == attestation.id
139
    assert revocation.supersedes_digest == attestation.claim_digest
140
    assert revoked.revocation_reason_code == "outcome_reversed"
141
142
    report = Reputation.verify(revoked)
143
    refute report["verified"]
144
    assert report["signature"]["valid"]
145
    assert report["revocation"]["revoked"]
146
147
    # The revocation itself verifies, and the revoked claim stays readable.
148
    assert Reputation.verify(revocation)["verified"]
149
    assert Repo.get!(Attestation, attestation.id).claim == attestation.claim
150
    assert {:error, :already_revoked} = revoke(context, revoked)
151
152
    evidence = Reputation.subject_evidence("actor:builder", context.repository)
153
    assert evidence["counts"] == %{"reversal" => 1}
154
    assert evidence["revoked"] == 1
155
  end
156
157
  test "a correction supersedes the claim it replaces", context do
158
    decision = outcome_decision_fixture()
159
    assert {:ok, attestation} = issue!(context, decision, subject_id: "actor:builder")
160
161
    corrected =
162
      context
163
      |> attributes(decision.decision_receipt_ref, decision.outcome_digest)
164
      |> Map.merge(%{subject_id: "actor:pair", confidence_ppm: 900_000})
165
166
    assert {:ok, %{revocation: revocation, correction: correction}} =
167
             Reputation.correct(attestation, context.policy, context.signer, corrected)
168
169
    assert revocation.revokes_id == attestation.id
170
    assert correction.supersedes_digest == attestation.claim_digest
171
    assert correction.subject_id == "actor:pair"
172
    assert Reputation.verify(correction)["verified"]
173
    refute Reputation.verify(Repo.get!(Attestation, attestation.id))["verified"]
174
  end
175
176
  test "an attestation names the policy version it was issued under", context do
177
    decision = outcome_decision_fixture()
178
    assert {:ok, attestation} = issue!(context, decision)
179
180
    report = Reputation.verify(attestation)
181
    assert report["policy"]["version"] == 1
182
    refute report["policy"]["superseded"]
183
184
    # A policy whose rules no longer hash to its digest cannot issue.
185
    forged = %{context.policy | rules: Map.put(context.policy.rules, "minimum_confidence_ppm", 0)}
186
187
    assert {:error, :policy_digest_mismatch} =
188
             Reputation.issue(
189
               forged,
190
               context.signer,
191
               attributes(context, decision.decision_receipt_ref, decision.outcome_digest)
192
             )
193
194
    # A later version supersedes without invalidating what it verified.
195
    assert {:ok, _receipt} =
196
             Repo.insert(
197
               PolicyReceipt.changeset(
198
                 %PolicyReceipt{},
199
                 %{
200
                   policy_id: Reputation.policy_id(),
201
                   version: 2,
202
                   policy_digest:
203
                     Reputation.policy_digest(
204
                       Reputation.policy_id(),
205
                       2,
206
                       Reputation.policy_rules()
207
                     ),
208
                   rules: Reputation.policy_rules(),
209
                   actor_id: "operator:test",
210
                   auth_method: "test_session",
211
                   approval_receipt_ref: "reputation-policy:v2"
212
                 }
213
               )
214
             )
215
216
    superseded = Reputation.verify(attestation)
217
    assert superseded["policy"]["superseded"]
218
    assert superseded["verified"]
219
  end
220
221
  test "confidence below the policy is not attestable", context do
222
    decision = outcome_decision_fixture()
223
224
    assert {:error, :confidence_below_policy} = issue!(context, decision, confidence_ppm: 10_000)
225
226
    assert {:error, :confidence_out_of_range} =
227
             issue!(context, decision, confidence_ppm: 2_000_000)
228
229
    assert {:error, :confidence_required} = issue!(context, decision, confidence_ppm: nil)
230
  end
231
232
  test "key rotation keeps signed history verifiable", context do
233
    decision = outcome_decision_fixture()
234
    assert {:ok, attestation} = issue!(context, decision)
235
236
    assert {:ok, retired} = Reputation.retire_key(context.key, DateTime.utc_now())
237
238
    report = Reputation.verify(attestation)
239
    assert report["signature"]["valid"]
240
    assert report["signature"]["key_status"] == "retired"
241
    assert report["signature"]["key_active_at_attestation"]
242
    assert report["verified"]
243
244
    assert {:error, :signing_key_retired} = issue!(context, outcome_decision_fixture())
245
    assert retired.retired_at
246
247
    rotated = Claim.generate_keypair()
248
249
    assert {:ok, key} =
250
             Reputation.admit_key(%{public_key: rotated.public_key, issuer: "verifier"})
251
252
    signer = %{key_id: key.key_id, private_key: rotated.private_key}
253
254
    assert {:ok, next} =
255
             Reputation.issue(
256
               context.policy,
257
               signer,
258
               attributes(context, outcome(), digest())
259
             )
260
261
    assert Reputation.verify(next)["verified"]
262
263
    # A private key that does not match the admitted public key cannot sign.
264
    assert {:error, :signing_key_mismatch} =
265
             Reputation.issue(
266
               context.policy,
267
               %{key_id: key.key_id, private_key: rotated.private_key |> flip()},
268
               attributes(context, outcome(), digest())
269
             )
270
271
    assert {:error, :signing_key_unknown} =
272
             Reputation.issue(
273
               context.policy,
274
               %{key_id: String.duplicate("a", 64), private_key: rotated.private_key},
275
               attributes(context, outcome(), digest())
276
             )
277
  end
278
279
  test "stale evidence is reported instead of quietly trusted", context do
280
    decision = outcome_decision_fixture()
281
    stale = DateTime.add(DateTime.utc_now(), -400 * 24 * 3600, :second)
282
283
    assert {:ok, attestation} =
284
             issue!(context, decision,
285
               evidence: [
286
                 %{
287
                   kind: "outcome",
288
                   ref: decision.decision_receipt_ref,
289
                   digest: decision.outcome_digest,
290
                   observed_at: DateTime.to_iso8601(stale)
291
                 }
292
               ]
293
             )
294
295
    report = Reputation.verify(attestation)
296
    assert report["evidence"]["stale"]
297
    refute report["verified"]
298
  end
299
300
  test "evidence must resolve and stay inside the repository", context do
301
    decision = outcome_decision_fixture()
302
303
    assert {:error, :evidence_required} = issue!(context, decision, evidence: [])
304
305
    assert {:error, :evidence_outside_repository_authority} =
306
             issue!(context, decision,
307
               evidence: [
308
                 %{
309
                   kind: "issue",
310
                   ref: "OtherOrg/other-repository#1",
311
                   digest: digest(),
312
                   observed_at: now()
313
                 }
314
               ]
315
             )
316
317
    assert {:error, :evidence_kind_unsupported} =
318
             issue!(context, decision,
319
               evidence: [
320
                 %{kind: "vibes", ref: "anything", digest: digest(), observed_at: now()}
321
               ]
322
             )
323
324
    assert {:ok, unresolvable} =
325
             issue!(context, decision,
326
               evidence: [
327
                 %{
328
                   kind: "outcome",
329
                   ref: "outcome-decision:not-recorded",
330
                   digest: decision.outcome_digest,
331
                   observed_at: now()
332
                 }
333
               ]
334
             )
335
336
    report = Reputation.verify(unresolvable)
337
    refute report["evidence"]["available"]
338
    refute report["verified"]
339
  end
340
341
  test "a private attestation verifies without disclosing the work", context do
342
    decision = outcome_decision_fixture()
343
    private = repository_fixture(%{visibility: "private"})
344
    issue = issue_fixture(private)
345
346
    assert {:error, :transparency_tier_exceeds_repository_authority} =
347
             Reputation.issue(
348
               context.policy,
349
               context.signer,
350
               %{
351
                 attributes(context, decision.decision_receipt_ref, decision.outcome_digest)
352
                 | repository: private,
353
                   issue_number: issue.number
354
               }
355
               |> Map.put(:evidence, [
356
                 %{
357
                   kind: "outcome",
358
                   ref: decision.decision_receipt_ref,
359
                   digest: decision.outcome_digest,
360
                   observed_at: now()
361
                 }
362
               ])
363
             )
364
365
    assert {:ok, attestation} =
366
             Reputation.issue(context.policy, context.signer, %{
367
               event_type: "completion",
368
               subject_id: "actor:builder",
369
               outcome: %{
370
                 kind: "compensation_outcome_decision",
371
                 ref: decision.decision_receipt_ref
372
               },
373
               repository: private,
374
               issue_number: issue.number,
375
               revision: String.duplicate("c", 40),
376
               artifact_digest: digest(),
377
               confidence_ppm: 900_000,
378
               transparency_tier: "private",
379
               evidence: [
380
                 %{
381
                   kind: "outcome",
382
                   ref: decision.decision_receipt_ref,
383
                   digest: decision.outcome_digest,
384
                   observed_at: now()
385
                 }
386
               ]
387
             })
388
389
    published = Jason.encode!(Reputation.projection(attestation))
390
    refute published =~ decision.decision_receipt_ref
391
    assert Reputation.verify(attestation)["verified"]
392
    assert Reputation.list_for_issue(private, issue.number, ~w(public)) == []
393
394
    assert [attestation.id] ==
395
             private
396
             |> Reputation.list_for_issue(issue.number, ~w(public private))
397
             |> Enum.map(& &1.id)
398
  end
399
400
  test "an attestation only binds to an issue of its own repository", context do
401
    decision = outcome_decision_fixture()
402
403
    assert {:error, :issue_not_found} = issue!(context, decision, issue_number: 9_999)
404
405
    assert {:error, :repository_required} =
406
             issue!(context, decision, repository: "TestOrg/test-repository")
407
408
    assert {:error, :invalidation_requires_prior_attestation} =
409
             issue!(context, decision, event_type: "revocation")
410
411
    assert {:error, :event_type_unsupported} =
412
             issue!(context, decision, event_type: "vibe_check")
413
  end
414
415
  test "issuance and verification report the distinct outcome events", context do
416
    assert Attestation.event_types() ==
417
             ~w(completion verification review payment reversal revocation)
418
419
    for event_type <- ~w(completion verification review payment) do
420
      decision = outcome_decision_fixture()
421
422
      assert {:ok, attestation} =
423
               issue!(context, decision, event_type: event_type, subject_id: "actor:multi")
424
425
      assert attestation.event_type == event_type
426
      assert Reputation.verify(attestation, %{event_type: event_type})["verified"]
427
    end
428
429
    evidence = Reputation.subject_evidence("actor:multi", context.repository)
430
431
    assert evidence["counts"] == %{
432
             "completion" => 1,
433
             "verification" => 1,
434
             "review" => 1,
435
             "payment" => 1
436
           }
437
  end
438
439
  test "only an authenticated operator admits a verifier policy" do
440
    assert {:error, :operator_unauthenticated} = Reputation.admit_policy(%{})
441
442
    assert {:error, :operator_receipt_incomplete} =
443
             Reputation.admit_policy(%{authenticated: true, actor_id: "operator:test"})
444
  end
445
446
  defp issue!(context, decision, overrides \\ []) do
447
    attributes =
448
      context
449
      |> attributes(decision.decision_receipt_ref, decision.outcome_digest)
450
      |> Map.merge(Map.new(overrides))
451
452
    Reputation.issue(context.policy, context.signer, attributes)
453
  end
454
455
  defp revoke(context, attestation) do
456
    Reputation.revoke(attestation, context.policy, context.signer, %{
457
      event_type: "revocation",
458
      reason_code: "duplicate"
459
    })
460
  end
461
462
  defp attributes(context, outcome_ref, outcome_digest) do
463
    %{
464
      event_type: "completion",
465
      subject_id: "actor:builder",
466
      outcome: %{kind: "compensation_outcome_decision", ref: outcome_ref},
467
      repository: context.repository,
468
      issue_number: context.issue.number,
469
      revision: String.duplicate("a", 40),
470
      artifact_digest: digest(),
471
      confidence_ppm: 900_000,
472
      transparency_tier: "public",
473
      evidence: [
474
        %{
475
          kind: "outcome",
476
          ref: outcome_ref,
477
          digest: outcome_digest,
478
          observed_at: now()
479
        },
480
        %{
481
          kind: "issue",
482
          ref: "#{context.repository.owner}/#{context.repository.name}##{context.issue.number}",
483
          digest: digest(),
484
          observed_at: now()
485
        }
486
      ]
487
    }
488
  end
489
490
  defp outcome, do: outcome_decision_fixture().decision_receipt_ref
491
492
  defp digest, do: Canonical.digest!(%{"nonce" => Claim.nonce()})
493
494
  defp now, do: DateTime.to_iso8601(DateTime.utc_now())
495
496
  defp flip(<<first, rest::binary>>), do: <<Bitwise.bxor(first, 1), rest::binary>>
497
498
  defp operator(suffix),
499
    do: %{
500
      authenticated: true,
501
      actor_id: "operator:test",
502
      auth_method: "test_session",
503
      approval_receipt_ref: "reputation-#{suffix}:#{System.unique_integer([:positive])}"
504
    }
505
end
test/openagents_web/controllers/reputation_controller_test.exs added +182

@@ -0,0 +1,182 @@

1
defmodule OpenAgentsWeb.ReputationControllerTest do
2
  use OpenAgentsWeb.ConnCase
3
4
  import OpenAgents.CompensationFixtures
5
  import OpenAgents.IssuesFixtures
6
7
  alias OpenAgents.Reputation
8
  alias OpenAgents.Reputation.Claim
9
10
  setup do
11
    repository = repository_fixture()
12
    issue = issue_fixture(repository)
13
    {:ok, policy} = Reputation.admit_policy(operator())
14
    keypair = Claim.generate_keypair()
15
    {:ok, key} = Reputation.admit_key(%{public_key: keypair.public_key, issuer: "verifier"})
16
    signer = %{key_id: key.key_id, private_key: keypair.private_key}
17
18
    %{repository: repository, issue: issue, policy: policy, key: key, signer: signer}
19
  end
20
21
  test "a stranger verifies the published claim without trusting the API", context do
22
    {:ok, attestation} = attest(context)
23
24
    published =
25
      build_conn()
26
      |> get(issue_path(context))
27
      |> json_response(200)
28
      |> Map.fetch!("attestations")
29
      |> hd()
30
31
    keys = build_conn() |> get("/api/v3/reputation/keys") |> json_response(200)
32
    key = Enum.find(keys["keys"], &(&1["key_id"] == published["issuer_key_id"]))
33
34
    assert published["claim_digest"] == attestation.claim_digest
35
    assert Claim.valid_signature?(published["claim"], published["signature"], key["public_key"])
36
    assert published["revocation"]["revoked"] == false
37
38
    policy = build_conn() |> get("/api/v3/reputation/policy") |> json_response(200)
39
    version = hd(policy["versions"])
40
41
    assert policy["score"] == nil
42
    assert version["policy_digest"] == published["claim"]["verifier"]["policy_digest"]
43
44
    assert version["policy_digest"] ==
45
             Reputation.policy_digest(version["policy_id"], version["version"], version["rules"])
46
  end
47
48
  test "the verification endpoint reports a binding the caller did not expect", context do
49
    {:ok, attestation} = attest(context)
50
    path = attestation_path(context, attestation) <> "/verification"
51
52
    assert %{"verified" => true} = build_conn() |> get(path) |> json_response(200)
53
54
    report =
55
      build_conn()
56
      |> get(path, %{"subject_id" => "actor:impostor"})
57
      |> json_response(200)
58
59
    refute report["verified"]
60
61
    assert [%{"field" => "subject_id", "claimed" => "actor:builder"}] =
62
             report["binding"]["mismatches"]
63
  end
64
65
  test "a revoked attestation stays readable and fails verification", context do
66
    {:ok, attestation} = attest(context)
67
68
    {:ok, _result} =
69
      Reputation.revoke(attestation, context.policy, context.signer, %{
70
        event_type: "reversal",
71
        reason_code: "outcome_reversed"
72
      })
73
74
    body = build_conn() |> get(attestation_path(context, attestation)) |> json_response(200)
75
76
    assert body["revocation"]["revoked"]
77
    assert body["revocation"]["reason_code"] == "outcome_reversed"
78
79
    refute build_conn()
80
           |> get(attestation_path(context, attestation) <> "/verification")
81
           |> json_response(200)
82
           |> Map.fetch!("verified")
83
  end
84
85
  test "scoped evidence never publishes a score", context do
86
    {:ok, _attestation} = attest(context)
87
    path = "/api/v3/repos/#{path(context)}/reputation/subjects/actor:builder"
88
89
    body = build_conn() |> get(path) |> json_response(200)
90
91
    assert body["score"] == nil
92
    assert body["scope"] == "repository"
93
    assert body["counts"] == %{"completion" => 1}
94
  end
95
96
  test "a private attestation is disclosed to the repository, not the public", context do
97
    repository = repository_fixture(%{visibility: "private"})
98
    issue = issue_fixture(repository)
99
    decision = outcome_decision_fixture()
100
101
    {:ok, attestation} =
102
      Reputation.issue(context.policy, context.signer, %{
103
        event_type: "completion",
104
        subject_id: "actor:builder",
105
        outcome: %{kind: "compensation_outcome_decision", ref: decision.decision_receipt_ref},
106
        repository: repository,
107
        issue_number: issue.number,
108
        revision: String.duplicate("b", 40),
109
        artifact_digest: String.duplicate("2", 64),
110
        confidence_ppm: 900_000,
111
        transparency_tier: "private",
112
        evidence: [
113
          %{
114
            kind: "outcome",
115
            ref: decision.decision_receipt_ref,
116
            digest: decision.outcome_digest,
117
            observed_at: DateTime.to_iso8601(DateTime.utc_now())
118
          }
119
        ]
120
      })
121
122
    path = "/api/v3/repos/#{repository.owner}/#{repository.name}/attestations/#{attestation.id}"
123
124
    assert build_conn() |> get(path) |> json_response(404)
125
126
    member = put_forge_api_token(build_conn(), "private-attestation", repository)
127
    body = member |> get(path) |> json_response(200)
128
129
    assert body["claim"]["outcome"]["ref"] == nil
130
    refute Jason.encode!(body) =~ decision.decision_receipt_ref
131
  end
132
133
  test "an unknown attestation is not found", context do
134
    assert build_conn()
135
           |> get("/api/v3/repos/#{path(context)}/attestations/#{Ecto.UUID.generate()}")
136
           |> json_response(404)
137
138
    assert build_conn()
139
           |> get("/api/v3/repos/#{path(context)}/attestations/not-a-uuid")
140
           |> json_response(404)
141
  end
142
143
  defp attest(context) do
144
    decision = outcome_decision_fixture()
145
146
    Reputation.issue(context.policy, context.signer, %{
147
      event_type: "completion",
148
      subject_id: "actor:builder",
149
      outcome: %{kind: "compensation_outcome_decision", ref: decision.decision_receipt_ref},
150
      repository: context.repository,
151
      issue_number: context.issue.number,
152
      revision: String.duplicate("a", 40),
153
      artifact_digest: String.duplicate("1", 64),
154
      confidence_ppm: 900_000,
155
      transparency_tier: "public",
156
      evidence: [
157
        %{
158
          kind: "outcome",
159
          ref: decision.decision_receipt_ref,
160
          digest: decision.outcome_digest,
161
          observed_at: DateTime.to_iso8601(DateTime.utc_now())
162
        }
163
      ]
164
    })
165
  end
166
167
  defp issue_path(context),
168
    do: "/api/v3/repos/#{path(context)}/issues/#{context.issue.number}/attestations"
169
170
  defp attestation_path(context, attestation),
171
    do: "/api/v3/repos/#{path(context)}/attestations/#{attestation.id}"
172
173
  defp path(context), do: "#{context.repository.owner}/#{context.repository.name}"
174
175
  defp operator,
176
    do: %{
177
      authenticated: true,
178
      actor_id: "operator:test",
179
      auth_method: "test_session",
180
      approval_receipt_ref: "reputation-api:#{System.unique_integer([:positive])}"
181
    }
182
end
test/support/fixtures/compensation_fixtures.ex added +150

@@ -0,0 +1,150 @@

1
defmodule OpenAgents.CompensationFixtures do
2
  @moduledoc """
3
  Test helpers for accepted-outcome decisions.
4
5
  An outcome decision is the accepted-outcome receipt other contexts bind to,
6
  and it exists only at the end of the real path: a routed tool step, an
7
  immutable outcome, and an independent review. The helper walks that path so
8
  callers get a decision no shortcut could have produced.
9
  """
10
11
  import ExUnit.Assertions
12
13
  alias OpenAgents.{Compensation, Context.Composer, Conversations}
14
  alias OpenAgents.Providers.Request
15
16
  @doc "An independently reviewed outcome decision, accepted or rejected."
17
  def outcome_decision_fixture(decision \\ "accepted", reason_code \\ "verified_outcome") do
18
    suffix = System.unique_integer([:positive, :monotonic])
19
    step = completed_step("reputation-#{suffix}")
20
21
    assert {:ok, record} =
22
             Compensation.decide_outcome(
23
               step.id,
24
               reviewer("reputation-#{suffix}"),
25
               decision,
26
               reason_code
27
             )
28
29
    record
30
  end
31
32
  defp completed_step(suffix) do
33
    %{turn: turn, receipt: receipt} = begin_turn(suffix)
34
    artifact = artifact()
35
    route = route!(receipt, "call-#{suffix}", artifact)
36
    policy = artifact.attribution_policy
37
38
    assert {:ok, step, :created} =
39
             Conversations.request_tool_step(turn, receipt, %{
40
               provider_call_id: "call-#{suffix}",
41
               provider_item_id: "item-#{suffix}",
42
               provider_response_id: "response-#{suffix}",
43
               tool_name: "recall_messages",
44
               tool_version: artifact.version,
45
               module_id: artifact.module_id,
46
               module_artifact_digest: artifact.artifact_digest,
47
               executor_implementation_digest: artifact.implementation_digest,
48
               routing_receipt_id: route.id,
49
               side_effect_class: artifact.side_effect_class,
50
               attribution_policy_id: policy["id"],
51
               attribution_policy_version: policy["version"],
52
               attribution_policy_digest: policy["digest"],
53
               cost_units: 100,
54
               raw_arguments: "{}"
55
             })
56
57
    outcome = %{
58
      "schema" => "sarah.tool_outcome.v1",
59
      "call_id" => step.provider_call_id,
60
      "module_ref" => %{
61
        "module_id" => step.module_id,
62
        "tool_name" => step.tool_name,
63
        "version" => step.tool_version,
64
        "artifact_digest" => step.module_artifact_digest
65
      },
66
      "executor_ref" => %{
67
        "id" => "sarah.local",
68
        "disclosure" => "Sarah local recall",
69
        "implementation_digest" => step.executor_implementation_digest
70
      },
71
      "status" => "succeeded",
72
      "result" => %{"private" => "private customer result"},
73
      "error" => nil,
74
      "target_receipt_refs" => ["message:opaque"],
75
      "attribution_refs" => ["OpenAgentsInc/openagents.com"],
76
      "started_at" => "2026-08-16T20:00:00Z",
77
      "completed_at" => "2026-08-16T20:00:01Z"
78
    }
79
80
    assert {:ok, completed} = Conversations.complete_tool_step(step, outcome)
81
    completed
82
  end
83
84
  defp begin_turn(browser_key) do
85
    assert {:ok, conversation} = Conversations.ensure_conversation(browser_key)
86
    assert {:ok, records} = Conversations.create_turn(conversation, "Use a module.")
87
    context = Composer.compose!()
88
89
    request = %Request{
90
      model_id: "reputation-test-model",
91
      instructions: context.instructions,
92
      input: Conversations.provider_messages(conversation.id)
93
    }
94
95
    assert {:ok, inference} =
96
             Conversations.begin_inference(records.turn, context, request, "test.provider",
97
               tool_catalog_digest: OpenAgents.Tools.Registry.current!().digest
98
             )
99
100
    inference
101
  end
102
103
  defp artifact,
104
    do:
105
      Map.fetch!(
106
        OpenAgents.Tools.Registry.current!().modules,
107
        {"sarah.tool.recall_messages", 1}
108
      )
109
110
  defp route!(receipt, call_id, artifact) do
111
    snapshot = OpenAgents.Tools.Registry.current!()
112
113
    proposal = %{
114
      "module_id" => artifact.module_id,
115
      "version" => artifact.version,
116
      "artifact_digest" => artifact.artifact_digest,
117
      "registry_digest" => snapshot.digest
118
    }
119
120
    assert {:ok, decision} =
121
             OpenAgents.Modules.Router.route(
122
               snapshot,
123
               OpenAgents.Modules.RoutingPolicy.default(),
124
               %{
125
                 intent_digest: receipt.input_digest,
126
                 required_capability: "conversation.read",
127
                 required_side_effect: "read_only",
128
                 surface: "text",
129
                 data_scope: "browser_conversation",
130
                 authorities: MapSet.new(["conversation.read"]),
131
                 proposal: proposal,
132
                 exact_proposal: true
133
               }
134
             )
135
136
    assert {:ok, route} =
137
             OpenAgents.Modules.RoutingReceipts.persist(receipt.id, call_id, decision)
138
139
    route
140
  end
141
142
  defp reviewer(suffix),
143
    do: %{
144
      authenticated: true,
145
      role: "outcome_reviewer",
146
      actor_id: "outcome-reviewer:test",
147
      auth_method: "test_session",
148
      decision_receipt_ref: "outcome-decision:#{suffix}"
149
    }
150
end

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