Close an issue from a claim that names its evidence, and from nothing else

bd7939c6f6e3 · AtlantisPleb · · parent 6a5cf7db4583

Close an issue from a claim that names its evidence, and from nothing else

`#148` bound an issue to the receipts that evaluated its commit. That
answers "what evaluated this?" and does not answer "is this done?". A
green build on a commit whose message names an issue proves the tree
compiles. A successful deployment proves bytes reached a host. Neither
entails that the outcome the issue asked for was produced, and a rule
that closed an issue on either would file a receipt saying work was
verified when nothing verified it.

So no receipt closes an issue here. What closes an issue is a claim: an
assertion that names, for each acceptance criterion, the evidence that
satisfied it. `AcceptedOutcome.evaluate/1` was a pure function with
nothing behind it and one caller in continual learning; it now has a
durable record. `issue_completion_claims` stores one verdict per
`{issue, attempt, revision}` -- the state, the typed reasons, and the
criterion-to-evidence mapping -- and stores no prompt, report, budget, or
output, so it is not a fifth work record.

Mapping intent to evidence is the one judgment no record can make, which
is why it is claimed. Everything else is derived, which is why it cannot
be asserted. The issue's four sections come from its body; the attempt's
five binding fields from `forge_assignments` and the `budget_snapshot`
on its `work_jobs` row; the verifier from the check result the evidence
resolves to, admitted because publishing one is already authority-gated;
the falsifier from that check's own identity reporting `failed`, which
`deployment_check_results_identity_index` makes a row that can exist.
Producer-verifier separation is always required, so an attempt whose
requester also published the check is `unauthorized`. A caller may add
false-green classes against its own result, which can only make the
verdict worse.

**What can close: a qualification receipt, and only that.** An acceptance
criterion is satisfied only by an `issue_evidence` edge that is a
`qualification` receipt, for that issue, at that exact revision, with the
status `succeeded`. `deployment_check_results` is the one family whose
row is a verdict about named bytes -- identity is `{repository, name,
commit, artifact digest}` -- published by an authorized principal that is
not the attempt.

**What can only claim.** A push records that the forge received the
bytes and carries no result at all. A build records that the tree
compiles, which is necessary for anything to work and sufficient for
nothing to be done. A deployment records that an artifact reached an
environment, which is placement rather than behavior; that is true of
the forge fleet and of a named tenant environment alike, so no
environment widens it. All four still record on the evidence chain, and
a failing edge in any family withholds the close.

Closing needs two per-repository opt-ins, both false by default, with an
absent `repository_closure_policies` row meaning the same as both false.
`agents_enabled` decides whether a claim is graded at all;
`verified_closing_enabled` decides whether an accepted one may move the
issue. Grading on and closing off is the useful half on its own: the
verdict and its evidence are recorded and a person decides.

When a later receipt disagrees -- a build that fails, a deployment
reverted -- the claim is stamped `contradicted_at` and named to the edge
that disagreed. The issue is not reopened: reopening on a later signal is
a separate policy with its own failure modes, and ISSUE-001 draws the
same line for reverts. What changes is that the closure stops reading as
uncontested, and the same rule refuses any further close on that
revision.

No second closer. `ClosingReferences` remains the trailer path,
unchanged and still attributed to the person who wrote `Closes #N`. This
path closes only issues that are still open, so a trailer close that
already happened is recorded as a claim and moves nothing. Neither reads
commit prose; `Forge.CommitReferences` is still the only reader. A reader
tells the two apart by the record: a person's close leaves an
`issue_closing_references` row with a `closed_by_user_id`, and a verified
close leaves a claim whose `closed_by_actor` is `system:accepted-outcome`
and never a user id. PostgreSQL refuses any row claiming a close on a
verdict other than `accepted`.

`issue.openagents.completion_claims` serves the record on every issue
response, and one route --
`POST /repos/:owner/:repo/issues/:number/completion_claim` -- accepts
one. An agent may claim only for the attempt it requested.

Contract: INVARIANTS.md, OUTCOME-001. Mutation-checked, twenty
mutations, each red on a named assertion: the family, status, revision
and issue filters on qualifying evidence; both opt-ins; the
contradicting-evidence gate; the issue-must-be-open gate; separation;
the contradiction hook; the close attribution; the changeset and the
database close-requires-accepted guards; the false-green pass-through;
the section parse; the budget read; and four controller authority
checks. Dropping the family filter alone did not bite at first, because
a build's success word is `complete` and the status filter already
excluded it -- a tenant deployment run reaching `succeeded` is the
collision, and the test now uses one. Dropping the exact-revision filter
did not bite at first either, because the test's other-revision check
result belonged to no issue and so resolved to nothing; a second attempt
on the same issue now makes that edge real.

Stage 6 of #10, and the whole of Track E's E7. Closes #150.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#150

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified INVARIANTS.md
  • modified docs/2026-08-23-issue-work-receipt-linkage-design.md
  • modified docs/accepted-outcome-contract.md
  • modified docs/taxonomy.md
  • added lib/openagents/issues/closure_policy.ex
  • added lib/openagents/issues/completion_claim.ex
  • added lib/openagents/issues/completion_claims.ex
  • modified lib/openagents/issues/evidence.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • added lib/openagents_web/controllers/issue_completion_claim_controller.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/controllers/issue_json.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824032138_create_issue_completion_claims.exs
  • added test/openagents/issues/completion_claims_test.exs
  • added test/openagents_web/controllers/issue_completion_claim_controller_test.exs

Diff

18 files changed, +2429 -12

INVARIANTS.md modified +58 -3

@@ -1766,10 +1766,65 @@ The committed contract is `priv/api-contracts/accepted-outcome-v1.json`, and

1766 1766
sections, attempt fields, false-green classes, or result states drift from
1767 1767
the code that enforces them.
1768 1768
1769
**The claim is durable, and only the caller's judgment comes from the caller.**
1770
`issue_completion_claims` stores one graded verdict per
1771
`{issue, attempt, revision}`: the state, the typed reasons, and which evidence
1772
satisfied which acceptance criterion. A caller supplies the
1773
criterion-to-evidence mapping and, optionally, false-green classes against its
1774
own result — an assertion that can only make the verdict worse. Everything
1775
else `OpenAgents.Issues.CompletionClaims` grades is read from records: the
1776
issue's four sections from its body, the attempt's five binding fields from
1777
`forge_assignments` and the `budget_snapshot` on its `work_jobs` row, the
1778
verifier from the published check result the evidence resolves to, and the
1779
falsifier as that check's own identity reporting `failed`. Producer-verifier
1780
separation is always required on this path, so an attempt whose requester also
1781
published the check is `unauthorized`.
1782
1783
**Only a qualification receipt can close an issue, and only under an opt-in.**
1784
An acceptance criterion is satisfied only by an `issue_evidence` edge that is a
1785
`qualification` receipt, for that issue, at that exact revision, with the
1786
status `succeeded`. `deployment_check_results` is the one family whose row is a
1787
verdict about named bytes — identity is `{repository, name, commit, artifact
1788
digest}` — and whose publisher is an authorized principal that is not the
1789
attempt. A push records receipt of bytes and carries no result at all; a build
1790
records that a tree compiles, which is necessary for anything to work and
1791
sufficient for nothing to be done; a deployment records that an artifact
1792
reached an environment, which is placement rather than behavior. All four
1793
still record on the evidence chain; only one qualifies. On top of that,
1794
closing requires the repository to have opted in twice —
1795
`repository_closure_policies.agents_enabled` and `verified_closing_enabled`,
1796
both false by default, with an absent row meaning the same as both false — and
1797
requires that no evidence edge for that issue at that revision carries its
1798
family's word for failure.
1799
1800
**A later receipt contradicts; it never reopens.** A failing edge arriving for
1801
an issue and revision an accepted claim rested on stamps `contradicted_at` and
1802
names the edge that disagreed. The issue stays closed, because reopening on a
1803
later signal is a separate policy with its own failure modes (`ISSUE-001`), and
1804
rule four stops any further close on that revision.
1805
1806
**There is one automatic closer, and an automatic close is attributable.**
1807
`OpenAgents.Issues.ClosingReferences` remains the trailer path and is
1808
unchanged: a person wrote `Closes #N`, their write authority was checked, and
1809
the commit was reachable from the default branch. This path closes only issues
1810
that are still open, so a trailer close that already happened is recorded as a
1811
claim and moves nothing. Neither path reads commit prose;
1812
`OpenAgents.Forge.CommitReferences` is still the only reader of it. A reader
1813
tells the two apart by the record: a person's close leaves an
1814
`issue_closing_references` row with a `closed_by_user_id`, and a verified close
1815
leaves an `issue_completion_claims` row whose `closed_by_actor` is
1816
`system:accepted-outcome` and never a user id. PostgreSQL refuses any row that
1817
claims a close on a verdict other than `accepted`
1818
(`issue_completion_claims_close_requires_accepted`).
1819
1769 1820
Evidence: `OpenAgents.AcceptedOutcome`,
1821
`OpenAgents.Issues.CompletionClaims`, `OpenAgents.Issues.CompletionClaim`,
1822
`OpenAgents.Issues.ClosurePolicy`,
1770 1823
`priv/api-contracts/accepted-outcome-v1.json`,
1771
`docs/accepted-outcome-contract.md`, and
1772
`test/openagents/accepted_outcome_test.exs`.
1824
`docs/accepted-outcome-contract.md`,
1825
`test/openagents/accepted_outcome_test.exs`,
1826
`test/openagents/issues/completion_claims_test.exs`, and
1827
`test/openagents_web/controllers/issue_completion_claim_controller_test.exs`.
1773 1828
1774 1829
### THREAD-001 — A thread owns its own model authority, and names it exactly once
1775 1830

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

4354 4409
| SELF-EDIT-001 | `test/openagents/tools/repository_mutation_tools_test.exs`, `test/openagents/coding_job_test.exs`, `test/openagents/dependency_boundary_test.exs` |
4355 4410
| SCV-001 | `test/openagents/scv/deployments_test.exs`, `test/openagents/dependency_boundary_test.exs` |
4356 4411
| THREAD-001 | `test/openagents/threads/grant_fence_test.exs`, `test/openagents/threads/grant_token_reach_test.exs`, `test/openagents/threads_test.exs` |
4357
| OUTCOME-001 | `test/openagents/accepted_outcome_test.exs` |
4412
| OUTCOME-001 | `test/openagents/accepted_outcome_test.exs`, `test/openagents/issues/completion_claims_test.exs`, `test/openagents_web/controllers/issue_completion_claim_controller_test.exs` |
4358 4413
| DEPLOYPLANE-001 | `test/openagents/deployments_test.exs`, `test/openagents_web/controllers/deployment_controller_test.exs`, `test/openagents_web/api_route_authority_test.exs` |
4359 4414
| DEPLOYPLANE-002 | `test/openagents/deployments_test.exs` |
4360 4415
| DEPLOYPLANE-003 | `test/openagents/deployments/policy_test.exs`, `test/openagents/deployments_test.exs` |
docs/2026-08-23-issue-work-receipt-linkage-design.md modified +31

@@ -338,6 +338,37 @@ The design that follows, for stage 6:

338 338
  policy requires it; the attempt records who produced the revision, so the
339 339
  check has both halves.
340 340
341
### What stage 6 shipped, and what it deliberately did not
342
343
`issue_completion_claims` stores one graded verdict per
344
`{issue, attempt, revision}`. `OpenAgents.Issues.CompletionClaims` grades it
345
with `AcceptedOutcome.evaluate/1` and takes exactly one judgment from the
346
caller: which evidence satisfied which acceptance criterion. Mapping intent to
347
evidence is the judgment no record can make on its own, which is why it is
348
claimed; everything else is derived, which is why it cannot be asserted. The
349
issue's sections come from its body, the attempt's five fields from
350
`forge_assignments` and its work job's budget snapshot, the verifier from the
351
check result the evidence resolves to, and the falsifier from that check's own
352
identity reporting `failed`. Separation is always required on this path.
353
354
The narrowing that closes the gap between "a receipt evaluated this commit" and
355
"the outcome was produced": **only a `qualification` receipt can satisfy an
356
acceptance criterion**, and only at the exact revision with the status
357
`succeeded`. `deployment_check_results` is the one family whose row is a verdict
358
about named bytes, published by an authorized principal that is not the attempt.
359
A push proves receipt of bytes, a build proves the tree compiles, and a
360
deployment proves placement — each real, none of them the issue's intent. All
361
four still record on the evidence chain, and a failing edge in any family
362
withholds the close.
363
364
Closing also needs two per-repository opt-ins, both false by default
365
(`repository_closure_policies`), and it never reopens: a failing receipt
366
arriving after an accepted claim closed the issue stamps `contradicted_at` on
367
the claim and leaves the issue closed for a person to act on. `#130` is
368
untouched, and the two closes are told apart by their records — a
369
`closed_by_user_id` on a closing reference, or `system:accepted-outcome` on a
370
claim. See `INVARIANTS.md`, `OUTCOME-001`.
371
341 372
---
342 373
343 374
## 6. The staged plan
docs/accepted-outcome-contract.md modified +51

@@ -93,6 +93,57 @@ Contribution and review use this same definition. Reviewers grade claims, not

93 93
prose: a report that sounds complete but fails a check above is a typed
94 94
non-accepted result, and the review states which one.
95 95
96
## Closing an issue from a claim
97
98
A claim is stored whether or not it is accepted, so a refusal is on the record
99
rather than silent. `OpenAgents.Issues.CompletionClaims` writes one
100
`issue_completion_claims` row per `{issue, attempt, revision}` and grades it
101
with `OpenAgents.AcceptedOutcome.evaluate/1`.
102
103
The caller supplies one thing: which evidence satisfied which acceptance
104
criterion. It may also name false-green classes against its own result, which
105
can only make the verdict worse. Everything else is read from records — the
106
issue's sections from its body, the attempt's five binding fields from
107
`forge_assignments` and its work job's budget snapshot, the verifier from the
108
published check result the evidence resolves to, and the falsifier as that
109
check's own identity reporting `failed`. Producer-verifier separation is always
110
required here, so an attempt whose requester also published the check is
111
`unauthorized`.
112
113
### What can close, and what can only claim
114
115
| Receipt family | Record | Close | Why |
116
| --- | --- | --- | --- |
117
| `qualification` | Yes | **Yes** | A verdict about named bytes. Identity is `{repository, name, commit, artifact digest}`, so a green result cannot be replayed onto bytes it never examined, and the publisher is an authorized principal that is not the attempt. |
118
| `build` | Yes | No | That the tree compiles at that commit. Necessary for anything to work; sufficient for nothing to be done. |
119
| `deployment` | Yes | No | That an artifact reached an environment. Placement, not behavior. A `fleet` deployment and a named tenant environment are both operational facts. |
120
| `push` | Yes | No | That the forge received the bytes. It carries no outcome at all; its `result` is null. |
121
122
A qualification receipt closes only at the exact revision the attempt reported,
123
only with the status `succeeded`, and only for the issue that already claims
124
that commit. An evidence reference to anything else resolves to no receipt, so
125
the criterion it names is unevidenced and the claim is `incomplete`.
126
127
### The rest of the rule
128
129
- **Two opt-ins.** `repository_closure_policies.agents_enabled` decides whether
130
  a claim is graded at all; `verified_closing_enabled` decides whether an
131
  accepted claim may move the issue. Both default false, and an absent row
132
  means the same as both false.
133
- **No contradiction.** If any evidence edge for that issue at that revision
134
  carries its family's word for failure — a failed or expired build, a
135
  reverted, failed, cancelled, or superseded deployment, a failed check — the
136
  verdict is recorded and the close is withheld.
137
- **Never reopen.** A failing receipt that arrives after an accepted claim
138
  closed the issue stamps `contradicted_at` on the claim and names the edge.
139
  The issue stays closed and a person decides.
140
- **One closer.** `#130`'s trailer path is unchanged and stays attributed to
141
  the person who wrote the trailer. This path closes only issues that are still
142
  open. A reader tells them apart by the record: a person's close leaves an
143
  `issue_closing_references` row with a `closed_by_user_id`; this one leaves an
144
  `issue_completion_claims` row whose `closed_by_actor` is
145
  `system:accepted-outcome`, which is never a user id.
146
96 147
## Related records
97 148
98 149
- Issue-to-job and receipt linkage: `docs/2026-08-21-issues-projects-work-system-assessment.md`, Track E.
docs/taxonomy.md modified +12

@@ -117,6 +117,18 @@ and per-criterion evidence. It returns `:accepted`, `:incomplete`,

117 117
`:unauthorized`, `:failed`, or `:not_applicable`. The issue stays the
118 118
canonical work record; the contract only grades a claim about it.
119 119
120
**Completion claim** — the durable record of one graded verdict, stored in
121
`issue_completion_claims` by `OpenAgents.Issues.CompletionClaims`, keyed on
122
`{issue, attempt, revision}`. The accepted outcome is the grading; the claim is
123
what was graded and what happened next. Say "claim" for the record and
124
"accepted outcome" for the verdict.
125
126
**Verified close** — an issue closed from an accepted claim, attributed to
127
`system:accepted-outcome`. It is not a trailer close: a trailer close is a
128
person's assertion recorded in `issue_closing_references` with a
129
`closed_by_user_id` (`ISSUE-001`, `#130`). Both close an issue; only one names
130
a person, and only a verified close can be contradicted by a later receipt.
131
120 132
### Exports
121 133
122 134
Two account exports exist and they are not the same document. Say which one.
lib/openagents/issues/closure_policy.ex added +58

@@ -0,0 +1,58 @@

1
defmodule OpenAgents.Issues.ClosurePolicy do
2
  @moduledoc """
3
  One repository's opt-in for grading and for verified closing
4
  (`repository_closure_policies`).
5
6
  Two flags, both false by default, and the absent row means the same thing as
7
  the row with both false. That equivalence is the point: a repository that has
8
  never been asked has not consented, and a policy that treats silence as
9
  consent would close issues in repositories nobody opted in.
10
11
  `agents_enabled` decides whether an agent-authored claim is graded at all.
12
  False makes every claim `not_applicable` — the accepted-outcome contract's
13
  own word for work outside the gate — which is not a refusal and not a
14
  failure.
15
16
  `verified_closing_enabled` decides whether an accepted claim may move the
17
  issue. False records the verdict and the evidence and leaves the issue open,
18
  which is the useful half of this feature on its own: a person reads what was
19
  claimed and decides.
20
  """
21
22
  use Ecto.Schema
23
  import Ecto.Changeset
24
25
  alias OpenAgents.Accounts.User
26
  alias OpenAgents.Repositories.Repository
27
28
  @type t :: %__MODULE__{}
29
30
  @primary_key {:id, :binary_id, autogenerate: true}
31
  @foreign_key_type :binary_id
32
  @timestamps_opts [type: :utc_datetime_usec]
33
34
  schema "repository_closure_policies" do
35
    field :agents_enabled, :boolean, default: false
36
    field :verified_closing_enabled, :boolean, default: false
37
38
    belongs_to :repository, Repository
39
    belongs_to :updated_by_user, User
40
41
    timestamps()
42
  end
43
44
  @doc false
45
  def changeset(policy, attrs) do
46
    policy
47
    |> cast(attrs, [
48
      :repository_id,
49
      :agents_enabled,
50
      :verified_closing_enabled,
51
      :updated_by_user_id
52
    ])
53
    |> validate_required([:repository_id, :agents_enabled, :verified_closing_enabled])
54
    |> unique_constraint(:repository_id)
55
    |> foreign_key_constraint(:repository_id)
56
    |> foreign_key_constraint(:updated_by_user_id)
57
  end
58
end
lib/openagents/issues/completion_claim.ex added +124

@@ -0,0 +1,124 @@

1
defmodule OpenAgents.Issues.CompletionClaim do
2
  @moduledoc """
3
  One graded completion claim (`issue_completion_claims`).
4
5
  The row is the durable half of `OpenAgents.AcceptedOutcome.evaluate/1`, which
6
  was a pure function with nothing behind it. It stores the verdict, the typed
7
  reasons, and the criterion-to-evidence mapping — never the prompt, the
8
  transcript, the report, or the budget those came from. The execution stays in
9
  `work_jobs`, the attempt stays in `forge_assignments`, the receipts stay in
10
  the tables their families own, and the edge between issue and receipt stays
11
  in `issue_evidence`. This row adds the one fact none of them carried: what
12
  was claimed about the issue's intent, and how that claim graded.
13
14
  `{issue_id, assignment_id, revision}` is unique. An attempt reporting one
15
  revision has one verdict, so resubmitting a claim regrades it rather than
16
  accumulating verdicts nobody can order.
17
18
  Two columns carry the close, and a database constraint —
19
  `issue_completion_claims_close_requires_accepted` — refuses a row that says
20
  it closed an issue on anything but an `accepted` verdict. `closed_by_actor`
21
  is a system principal and never a user id, which is how a reader tells this
22
  close from a person's: a person's close is an `issue_closing_references` row
23
  with a `closed_by_user_id` behind it.
24
25
  Two more carry the contradiction. A later receipt that disagrees with the
26
  evidence an accepted claim rested on stamps `contradicted_at` and names the
27
  edge that disagreed. It never reopens the issue and never rewrites the
28
  verdict, because reopening on a later signal is a separate policy with its
29
  own failure modes (`ISSUE-001`). What it does is stop the claim from reading
30
  as uncontested.
31
  """
32
33
  use Ecto.Schema
34
  import Ecto.Changeset
35
36
  alias OpenAgents.Forge.Assignment
37
  alias OpenAgents.Issues.{EvidenceEntry, Issue}
38
  alias OpenAgents.Repositories.Repository
39
40
  @type t :: %__MODULE__{}
41
42
  @states ~w(accepted incomplete unauthorized failed not_applicable)
43
44
  @primary_key {:id, :binary_id, autogenerate: true}
45
  @timestamps_opts [type: :utc_datetime_usec]
46
47
  schema "issue_completion_claims" do
48
    field :revision, :string
49
    field :state, :string
50
    field :reasons, {:array, :string}, default: []
51
    field :criteria, {:array, :map}, default: []
52
    field :verifier, :string
53
    field :falsifier, :string
54
    field :closed, :boolean, default: false
55
    field :closed_at, :utc_datetime_usec
56
    field :closed_by_actor, :string
57
    field :contradicted_at, :utc_datetime_usec
58
    field :contradiction_reason, :string
59
60
    belongs_to :repository, Repository, type: :binary_id
61
    belongs_to :issue, Issue
62
    belongs_to :assignment, Assignment, type: :binary_id
63
    belongs_to :contradicted_by_evidence, EvidenceEntry, type: :binary_id
64
65
    timestamps()
66
  end
67
68
  @doc "The verdicts a graded claim can hold."
69
  @spec states() :: [String.t()]
70
  def states, do: @states
71
72
  @doc false
73
  def changeset(claim, attrs) do
74
    claim
75
    |> cast(attrs, [
76
      :repository_id,
77
      :issue_id,
78
      :assignment_id,
79
      :revision,
80
      :state,
81
      :reasons,
82
      :criteria,
83
      :verifier,
84
      :falsifier,
85
      :closed,
86
      :closed_at,
87
      :closed_by_actor,
88
      :contradicted_at,
89
      :contradicted_by_evidence_id,
90
      :contradiction_reason
91
    ])
92
    |> update_change(:revision, &String.downcase/1)
93
    |> validate_required([:repository_id, :issue_id, :assignment_id, :revision, :state])
94
    |> validate_inclusion(:state, @states)
95
    |> validate_format(:revision, ~r/\A[0-9a-f]{7,64}\z/)
96
    |> validate_length(:verifier, max: 200)
97
    |> validate_length(:falsifier, max: 500)
98
    |> validate_length(:closed_by_actor, max: 200)
99
    |> validate_length(:contradiction_reason, max: 200)
100
    |> validate_close()
101
    |> unique_constraint([:issue_id, :assignment_id, :revision])
102
    |> check_constraint(:closed, name: :issue_completion_claims_close_requires_accepted)
103
    |> foreign_key_constraint(:repository_id)
104
    |> foreign_key_constraint(:issue_id)
105
    |> foreign_key_constraint(:assignment_id)
106
  end
107
108
  # The same rule the database constraint holds, refused earlier and by name so
109
  # a caller reads `closed_without_accepted_outcome` instead of a constraint
110
  # violation. Both exist on purpose: the constraint is what makes the rule
111
  # true of every row, and this is what makes it legible.
112
  defp validate_close(changeset) do
113
    closed? = get_field(changeset, :closed)
114
    state = get_field(changeset, :state)
115
    closed_at = get_field(changeset, :closed_at)
116
117
    cond do
118
      not closed? -> changeset
119
      state != "accepted" -> add_error(changeset, :closed, "requires an accepted outcome")
120
      is_nil(closed_at) -> add_error(changeset, :closed_at, "is required for a closed claim")
121
      true -> changeset
122
    end
123
  end
124
end
lib/openagents/issues/completion_claims.ex added +783

@@ -0,0 +1,783 @@

1
defmodule OpenAgents.Issues.CompletionClaims do
2
  @moduledoc """
3
  Store what an attempt claims about an issue, grade it, and close the issue
4
  only when a narrow rule says the claim entails the outcome.
5
6
  ## The gap this closes, and the gap it refuses to close
7
8
  `#148` bound an issue to the receipts that evaluated its commit. That answers
9
  "what evaluated this?" and it does not answer "is this done?". A green build
10
  on a commit whose message names an issue proves the tree compiles. A
11
  successful deployment proves bytes reached a host. Neither entails that the
12
  outcome the issue asked for was produced, and a rule that closed an issue on
13
  either would file a receipt saying work was verified when nothing verified
14
  it.
15
16
  So no receipt closes an issue here. What closes an issue is a *claim*: an
17
  assertion that names, for each of the issue's acceptance criteria, the
18
  evidence that satisfied it. Mapping intent to evidence is the one judgment no
19
  record can make on its own, which is why it is claimed rather than derived —
20
  and why the rest is derived rather than claimed.
21
22
  ## What the caller supplies, and what it cannot
23
24
  The caller supplies exactly one thing: the criterion-to-evidence mapping, and
25
  optionally the false-green classes it names against its own result, which can
26
  only make the verdict worse. Everything else is read from records:
27
28
    * the **issue's** four required sections, parsed from its body;
29
    * the **attempt's** five binding fields — issue number, repository,
30
      authority, budget, revision — from `forge_assignments` and the
31
      `budget_snapshot` on its `work_jobs` row, never from the request, so the
32
      attempt cannot claim to be bound to an issue it did not run against;
33
    * the **verifier**, which is the published check result the evidence
34
      resolves to. It is admitted because publishing a check result is already
35
      authority-gated (`OpenAgents.Deployments.Authority`), and it is
36
      independent of the producer unless the same user both requested the
37
      attempt and published the check;
38
    * the **falsifier**, which is the observation that would have made the same
39
      claim red: the same check name, on the same commit and the same artifact
40
      digest, reporting `failed`. `deployment_check_results` keys on exactly
41
      that identity, so the falsifying observation is a row that can exist.
42
43
  Producer-verifier separation is always required on this path. An agent
44
  grading its own work is the hazard the whole contract exists for, so it is
45
  not a knob.
46
47
  ## The closure rule
48
49
  An issue closes automatically only when every one of these holds:
50
51
    1. The repository opted in twice: `agents_enabled` and
52
       `verified_closing_enabled` (`OpenAgents.Issues.ClosurePolicy`). Both
53
       default false, and an absent policy row means the same as both false.
54
    2. `OpenAgents.AcceptedOutcome.evaluate/1` graded the claim `accepted`.
55
    3. Every acceptance criterion names an `issue_evidence` edge that is a
56
       **qualification** receipt for **this issue** at **this exact revision**
57
       whose status is `succeeded`. An edge in any other family, for any other
58
       revision, or for any other issue satisfies nothing, so the criterion is
59
       unevidenced and the claim is `incomplete`.
60
    4. No evidence edge for this issue at this revision carries a failing
61
       terminal word in any family. A revision that qualified and then failed
62
       to build is contradicted, and a person decides.
63
    5. The issue is open. This path never reopens and never changes a closed
64
       issue's state.
65
66
  ### Why only qualification
67
68
  `deployment_check_results` is the only family whose row is a *verdict about
69
  named bytes*: identity is `{repository, name, commit, artifact digest}`, so a
70
  green result cannot be replayed onto bytes it never examined, and the
71
  publisher is an authorized principal that is not the attempt. The other three
72
  record something real and something else:
73
74
    * **push** — that the forge received the bytes. It carries no outcome at
75
      all; its `result` is null.
76
    * **build** — that the tree compiles at that commit. Necessary for anything
77
      to work; sufficient for nothing to be done.
78
    * **deployment** — that an artifact reached an environment. An operational
79
      fact about placement, not an observation of behavior.
80
81
  All four still *record*: they appear on the issue's evidence chain, they are
82
  what a person reads, and a failing one in any family blocks the automatic
83
  close by rule 4. Recording is the broad half; closing is the narrow half.
84
85
  ## When a later receipt disagrees
86
87
  `note_evidence/1` runs where evidence is written. A failing edge arriving for
88
  an issue and revision an accepted claim already closed stamps
89
  `contradicted_at` and names the edge. It does **not** reopen the issue:
90
  reopening on a later signal is a separate policy with its own failure modes
91
  (`ISSUE-001` draws the same line for reverts). What it does is stop the
92
  closure reading as uncontested, and rule 4 stops any further close on that
93
  revision.
94
95
  ## One closer, not two
96
97
  `OpenAgents.Issues.ClosingReferences` stays the trailer path and is untouched:
98
  a person wrote `Closes #N`, the pusher's write authority was checked, and the
99
  commit was reachable from the default branch. That is a human assertion and it
100
  stays attributed to that person. This path closes only issues that are still
101
  open, so a trailer close that already happened is recorded here as a claim
102
  and moves nothing. Neither path reads commit prose —
103
  `OpenAgents.Forge.CommitReferences` is still the only reader of that.
104
105
  A reader tells the two apart by the record, not by the issue: a person's close
106
  leaves an `issue_closing_references` row with a `closed_by_user_id`; this one
107
  leaves an `issue_completion_claims` row whose `closed_by_actor` is the system
108
  principal `system:accepted-outcome` and never a user id.
109
  """
110
111
  import Ecto.Query
112
113
  require Logger
114
115
  alias OpenAgents.AcceptedOutcome
116
  alias OpenAgents.Deployments.CheckResult
117
  alias OpenAgents.Forge.Assignment
118
  alias OpenAgents.Issues
119
  alias OpenAgents.Issues.{ClosurePolicy, CompletionClaim, EvidenceEntry, Issue}
120
  alias OpenAgents.Repo
121
  alias OpenAgents.Repositories.Repository
122
  alias OpenAgents.Work.Job
123
124
  # The one actor an automatic close is attributed to. It is not a user id and
125
  # cannot become one, which is the whole distinction between this close and a
126
  # person's.
127
  @closing_actor "system:accepted-outcome"
128
129
  # The one family whose receipt can satisfy an acceptance criterion. See the
130
  # moduledoc: the other three record, they do not qualify.
131
  @closing_family "qualification"
132
133
  # A qualification receipt satisfies a criterion only with this status.
134
  # `pending` is not a verdict, and `failed` is the opposite of one.
135
  @qualifying_result "succeeded"
136
137
  # Each family's own words for "this did not work", read straight from the
138
  # schemas that own them: `forge_builds.status`, `forge_deploys.result`,
139
  # `deployment_runs.state`, and `deployment_check_results.status`. A push
140
  # receipt has no result, so it can never contradict.
141
  @failing_results %{
142
    "push" => [],
143
    "build" => ~w(failed expired),
144
    "deployment" => ~w(failed reverted needs_rolling_replace cancelled superseded),
145
    "qualification" => ~w(failed)
146
  }
147
148
  # The issue-body headings the accepted-outcome contract requires. The match is
149
  # on the heading text, after the semantic question — "is this claim gradeable
150
  # at all" — has already been settled by the policy read.
151
  @section_headings %{
152
    problem: ["problem"],
153
    scope: ["scope"],
154
    acceptance_criteria: ["acceptance criteria"],
155
    success_metrics: ["success metrics"]
156
  }
157
158
  @type verdict :: {:ok, CompletionClaim.t()} | {:error, atom()}
159
160
  @doc "The system principal an automatic close is attributed to."
161
  @spec closing_actor() :: String.t()
162
  def closing_actor, do: @closing_actor
163
164
  @doc "The one receipt family whose evidence can satisfy an acceptance criterion."
165
  @spec closing_family() :: String.t()
166
  def closing_family, do: @closing_family
167
168
  @doc "The receipt families that can record evidence but never close an issue."
169
  @spec recording_only_families() :: [String.t()]
170
  def recording_only_families,
171
    do: Enum.reject(EvidenceEntry.families(), &(&1 == @closing_family))
172
173
  # ── policy ──────────────────────────────────────────────────────────────
174
175
  @doc """
176
  One repository's closure policy, defaulted rather than absent.
177
178
  An unconfigured repository reads as both flags false, which is the same
179
  answer the stored row with both flags false gives. Nothing distinguishes
180
  silence from a deliberate no, because nothing should.
181
  """
182
  @spec policy(Repository.t() | binary()) :: ClosurePolicy.t()
183
  def policy(%Repository{id: id}), do: policy(id)
184
185
  def policy(repository_id) when is_binary(repository_id) do
186
    Repo.one(from p in ClosurePolicy, where: p.repository_id == ^repository_id) ||
187
      %ClosurePolicy{
188
        repository_id: repository_id,
189
        agents_enabled: false,
190
        verified_closing_enabled: false
191
      }
192
  end
193
194
  @doc "Set one repository's closure policy, attributed to the user who set it."
195
  @spec set_policy(Repository.t(), map(), OpenAgents.Accounts.User.t() | nil) ::
196
          {:ok, ClosurePolicy.t()} | {:error, Ecto.Changeset.t()}
197
  def set_policy(%Repository{id: repository_id}, attrs, actor \\ nil) do
198
    existing =
199
      Repo.one(from p in ClosurePolicy, where: p.repository_id == ^repository_id) ||
200
        %ClosurePolicy{}
201
202
    attrs =
203
      attrs
204
      |> Map.new(fn {key, value} -> {to_string(key), value} end)
205
      |> Map.put("repository_id", repository_id)
206
      |> Map.put("updated_by_user_id", actor && actor.id)
207
208
    existing |> ClosurePolicy.changeset(attrs) |> Repo.insert_or_update()
209
  end
210
211
  # ── the claim ───────────────────────────────────────────────────────────
212
213
  @doc """
214
  Grade and store one completion claim for a finished attempt.
215
216
  `author` is `:agent` or `:human`. A human's claim is `not_applicable` —
217
  people close their own issues through the ordinary path, and the contract
218
  gates agent-authored claims only.
219
220
  `attrs` carries `:evidence`, a list of `%{criterion: binary, evidence_id:
221
  binary}`, and optionally `:false_green_classes`. Nothing else in `attrs` is
222
  read: the attempt's binding, the verifier, and the falsifier are derived from
223
  records so that a claim cannot assert its own authority.
224
225
  Returns the stored claim. A non-accepted verdict is stored too — a typed
226
  refusal on the record is the point, not silence.
227
  """
228
  @spec submit(Assignment.t(), :agent | :human, map()) :: verdict()
229
  def submit(%Assignment{} = assignment, author, attrs \\ %{})
230
      when author in [:agent, :human] do
231
    with {:ok, assignment} <- terminal_attempt(assignment),
232
         {:ok, repository} <- fetch(Repository, assignment.repository_id),
233
         {:ok, issue} <- fetch_issue(assignment.issue_id) do
234
      revision = String.downcase(assignment.terminal_commit)
235
      policy = policy(repository)
236
      references = evidence_references(issue, revision, attrs)
237
238
      claim =
239
        build_claim(
240
          author,
241
          policy,
242
          repository,
243
          issue,
244
          assignment,
245
          revision,
246
          references,
247
          attrs
248
        )
249
250
      persist(repository, issue, assignment, revision, AcceptedOutcome.evaluate(claim), policy)
251
    end
252
  end
253
254
  @doc "The claims recorded against one issue, oldest first."
255
  @spec for_issue(Issue.t() | integer()) :: [map()]
256
  def for_issue(%Issue{id: id}), do: for_issue(id)
257
258
  def for_issue(issue_id) when is_integer(issue_id) do
259
    CompletionClaim
260
    |> where([claim], claim.issue_id == ^issue_id)
261
    |> order_by([claim], asc: claim.inserted_at, asc: claim.id)
262
    |> Repo.all()
263
    |> Enum.map(&summary/1)
264
  end
265
266
  @doc """
267
  Claims for a whole page of issues, keyed by issue id.
268
269
  One query for the page, the way `OpenAgents.Issues.Evidence.for_issues/1`
270
  reads evidence, so listing issues does not cost one query per row.
271
  """
272
  @spec for_issues([Issue.t()]) :: %{integer() => [map()]}
273
  def for_issues(issues) when is_list(issues) do
274
    ids = Enum.map(issues, & &1.id)
275
    base = Map.new(ids, &{&1, []})
276
277
    CompletionClaim
278
    |> where([claim], claim.issue_id in ^ids)
279
    |> order_by([claim], asc: claim.inserted_at, asc: claim.id)
280
    |> Repo.all()
281
    |> Enum.reduce(base, fn claim, acc ->
282
      Map.update(acc, claim.issue_id, [summary(claim)], &(&1 ++ [summary(claim)]))
283
    end)
284
  end
285
286
  @doc """
287
  The bounded projection of one stored claim.
288
289
  It carries the verdict, the typed reasons, the criterion names, the public
290
  receipt references, and the close. It never carries a prompt, a log, a
291
  private repository name, or a private receipt reference, because the criteria
292
  it stores were already reduced to that shape by
293
  `OpenAgents.AcceptedOutcome.public_projection/1` before the row was written.
294
  """
295
  @spec summary(CompletionClaim.t()) :: map()
296
  def summary(%CompletionClaim{} = claim) do
297
    %{
298
      id: claim.id,
299
      revision: claim.revision,
300
      state: claim.state,
301
      reasons: claim.reasons,
302
      criteria: claim.criteria,
303
      verifier: claim.verifier,
304
      falsifier: claim.falsifier,
305
      closed: claim.closed,
306
      closed_at: claim.closed_at,
307
      closed_by_actor: claim.closed_by_actor,
308
      contradicted_at: claim.contradicted_at,
309
      contradiction_reason: claim.contradiction_reason,
310
      recorded_at: claim.inserted_at
311
    }
312
  end
313
314
  @doc """
315
  Mark the accepted claims one new evidence edge contradicts.
316
317
  Called where evidence is written. A failing edge for an issue and revision an
318
  accepted claim rested on stamps the claim; nothing else moves, and the issue
319
  is never reopened. Never raises and never fails the receipt that produced it.
320
  """
321
  @spec note_evidence(EvidenceEntry.t()) :: :ok
322
  def note_evidence(%EvidenceEntry{} = entry) do
323
    if failing?(entry) do
324
      now = DateTime.utc_now()
325
326
      {_count, _rows} =
327
        CompletionClaim
328
        |> where([claim], claim.issue_id == ^entry.issue_id)
329
        |> where([claim], claim.revision == ^String.downcase(entry.commit_sha))
330
        |> where([claim], claim.state == "accepted" and is_nil(claim.contradicted_at))
331
        |> Repo.update_all(
332
          set: [
333
            contradicted_at: now,
334
            contradicted_by_evidence_id: entry.id,
335
            contradiction_reason: "#{entry.family}:#{entry.result}",
336
            updated_at: now
337
          ]
338
        )
339
340
      :ok
341
    else
342
      :ok
343
    end
344
  end
345
346
  def note_evidence(_entry), do: :ok
347
348
  @doc "Whether one evidence edge carries its family's word for failure."
349
  @spec failing?(EvidenceEntry.t()) :: boolean()
350
  def failing?(%EvidenceEntry{family: family, result: result}) when is_binary(result) do
351
    result in Map.get(@failing_results, family, [])
352
  end
353
354
  def failing?(%EvidenceEntry{}), do: false
355
356
  # ── building the claim from records ─────────────────────────────────────
357
358
  defp build_claim(author, policy, repository, issue, assignment, revision, references, attrs) do
359
    %{
360
      actor: if(author == :agent, do: :agent, else: :human),
361
      agents_enabled: policy.agents_enabled,
362
      issue: %{
363
        number: issue.number,
364
        repository: "#{repository.owner}/#{repository.name}",
365
        sections: sections(issue.body)
366
      },
367
      attempt: %{
368
        issue_number: issue.number,
369
        repository: "#{repository.owner}/#{repository.name}",
370
        authority: authority(assignment),
371
        budget: budget(assignment),
372
        revision: revision
373
      },
374
      verification: %{
375
        verifier: verifier(references, assignment),
376
        falsifier: falsifier(references),
377
        terminal_result: terminal_result(references),
378
        separation_required: true,
379
        false_green_classes: named_false_greens(attrs)
380
      },
381
      evidence:
382
        Enum.map(references, fn reference ->
383
          %{
384
            criterion: reference.criterion,
385
            receipt: reference.receipt,
386
            visibility: reference.visibility
387
          }
388
        end)
389
    }
390
  end
391
392
  # The five attempt fields the contract binds come from records, never from
393
  # the request. Two of them are the issue and the repository, which is why
394
  # `attempt_not_bound_to_issue` cannot fire on this path: an attempt that ran
395
  # against another issue is a different `forge_assignments` row and produces a
396
  # different claim.
397
  defp authority(%Assignment{requesting_principal: %{"type" => type, "id" => id}})
398
       when is_binary(type) and is_binary(id),
399
       do: "#{type}:#{id}"
400
401
  defp authority(%Assignment{}), do: nil
402
403
  defp budget(%Assignment{work_job_id: nil}), do: nil
404
405
  defp budget(%Assignment{work_job_id: job_id}) do
406
    case Repo.one(from job in Job, where: job.id == ^job_id, select: job.budget_snapshot) do
407
      snapshot when is_map(snapshot) and map_size(snapshot) > 0 -> snapshot
408
      _absent -> nil
409
    end
410
  end
411
412
  # Only edges this issue already owns, at this exact revision, in the one
413
  # family that qualifies, with the one status that qualifies. A reference to
414
  # anything else resolves to no receipt, so the criterion it names is
415
  # unevidenced and the claim is `incomplete` — which is the contract's own way
416
  # of saying "a build is not a verdict".
417
  defp evidence_references(%Issue{} = issue, revision, attrs) do
418
    requested =
419
      attrs
420
      |> Map.get(:evidence, Map.get(attrs, "evidence", []))
421
      |> List.wrap()
422
      |> Enum.map(&normalize_reference/1)
423
      |> Enum.reject(&is_nil/1)
424
425
    ids = requested |> Enum.map(& &1.evidence_id) |> Enum.reject(&is_nil/1)
426
427
    qualifying =
428
      if ids == [] do
429
        %{}
430
      else
431
        EvidenceEntry
432
        |> where([entry], entry.id in ^ids)
433
        |> where([entry], entry.issue_id == ^issue.id)
434
        |> where([entry], entry.commit_sha == ^revision)
435
        |> where([entry], entry.family == ^@closing_family)
436
        |> where([entry], entry.result == ^@qualifying_result)
437
        |> Repo.all()
438
        |> Map.new(&{&1.id, &1})
439
      end
440
441
    Enum.map(requested, fn reference ->
442
      entry = Map.get(qualifying, reference.evidence_id)
443
444
      %{
445
        criterion: reference.criterion,
446
        entry: entry,
447
        receipt: entry && entry.id,
448
        visibility: visibility(issue)
449
      }
450
    end)
451
  end
452
453
  defp normalize_reference(%{} = reference) do
454
    criterion = reference[:criterion] || reference["criterion"]
455
    evidence_id = reference[:evidence_id] || reference["evidence_id"]
456
457
    case {present(criterion), cast_id(evidence_id)} do
458
      {nil, _id} -> nil
459
      {criterion, id} -> %{criterion: criterion, evidence_id: id}
460
    end
461
  end
462
463
  defp normalize_reference(_reference), do: nil
464
465
  defp cast_id(value) do
466
    case Ecto.UUID.cast(value) do
467
      {:ok, id} -> id
468
      :error -> nil
469
    end
470
  end
471
472
  defp present(value) when is_binary(value) do
473
    case String.trim(value) do
474
      "" -> nil
475
      trimmed -> trimmed
476
    end
477
  end
478
479
  defp present(_value), do: nil
480
481
  # A public repository's receipt references are publishable; a private
482
  # repository's are not, and the projection renders them as `:private` without
483
  # the reference. The visibility is read from the repository rather than from
484
  # the caller for the same reason everything else here is.
485
  defp visibility(%Issue{repository_id: repository_id}) do
486
    case Repo.one(from r in Repository, where: r.id == ^repository_id, select: r.visibility) do
487
      "public" -> :public
488
      _private_or_absent -> :private
489
    end
490
  end
491
492
  # The verifier is the check result the evidence resolves to, never a name the
493
  # caller supplied. It is admitted because publishing a check result is
494
  # already authority-gated; it is independent unless the same user both
495
  # requested the attempt and published the check.
496
  defp verifier(references, %Assignment{} = assignment) do
497
    case check_results(references) do
498
      [] ->
499
        %{}
500
501
      results ->
502
        %{
503
          id: results |> Enum.map(& &1.name) |> Enum.uniq() |> Enum.sort() |> Enum.join(","),
504
          admitted: true,
505
          independent_of_producer: Enum.all?(results, &independent?(&1, assignment))
506
        }
507
    end
508
  end
509
510
  defp independent?(%CheckResult{published_by_user_id: nil}, %Assignment{}), do: true
511
512
  defp independent?(%CheckResult{published_by_user_id: publisher}, %Assignment{} = assignment) do
513
    authority(assignment) != "user:#{publisher}"
514
  end
515
516
  # What observation would have made this red: the same check name, on the same
517
  # commit and the same artifact digest, reporting `failed`.
518
  # `deployment_check_results_identity_index` keys on exactly that tuple, so the
519
  # falsifying observation is a row that can exist rather than a sentence.
520
  defp falsifier(references) do
521
    case check_results(references) do
522
      [] ->
523
        nil
524
525
      results ->
526
        results
527
        |> Enum.map(&"deployment_check_results:#{&1.name}@#{&1.artifact_digest}=failed")
528
        |> Enum.uniq()
529
        |> Enum.sort()
530
        |> Enum.join(" ")
531
        |> String.slice(0, 500)
532
    end
533
  end
534
535
  defp terminal_result(references) do
536
    entries = references |> Enum.map(& &1.entry) |> Enum.reject(&is_nil/1)
537
538
    if entries != [] and Enum.all?(entries, &(&1.result == @qualifying_result)),
539
      do: :passed,
540
      else: :failed
541
  end
542
543
  defp check_results(references) do
544
    ids =
545
      references
546
      |> Enum.map(& &1.entry)
547
      |> Enum.reject(&is_nil/1)
548
      |> Enum.map(& &1.receipt_id)
549
      |> Enum.uniq()
550
551
    if ids == [], do: [], else: Repo.all(from r in CheckResult, where: r.id in ^ids)
552
  end
553
554
  # Self-reported, and deliberately the one thing a caller may add: naming a
555
  # false-green class against your own result can only make the verdict worse.
556
  defp named_false_greens(attrs) do
557
    attrs
558
    |> Map.get(:false_green_classes, Map.get(attrs, "false_green_classes", []))
559
    |> List.wrap()
560
    |> Enum.map(&to_string/1)
561
    |> Enum.filter(&(&1 in AcceptedOutcome.false_green_classes()))
562
  end
563
564
  # ── issue sections ──────────────────────────────────────────────────────
565
566
  # Deterministic parsing of a bounded field, after the question of whether to
567
  # grade at all has been answered by the stored policy. It reads Markdown ATX
568
  # headings and nothing else: a section is present when its heading exists and
569
  # some non-blank line follows it before the next heading.
570
  defp sections(body) when is_binary(body) do
571
    lines = String.split(body, ~r/\r?\n/)
572
573
    @section_headings
574
    |> Enum.reduce(%{}, fn {section, headings}, acc ->
575
      Map.put(acc, section, section_body(lines, headings))
576
    end)
577
  end
578
579
  defp sections(_body), do: %{}
580
581
  defp section_body(lines, headings) do
582
    lines
583
    |> Enum.drop_while(&(not heading_matching?(&1, headings)))
584
    |> Enum.drop(1)
585
    |> Enum.take_while(&(not heading?(&1)))
586
    |> Enum.map(&strip_marker/1)
587
    |> Enum.reject(&(&1 == ""))
588
    |> case do
589
      [] -> nil
590
      content -> content
591
    end
592
  end
593
594
  # A criterion is the sentence, not the bullet that carries it. An acceptance
595
  # criterion written `- [ ] the thing` and the same criterion named in a claim
596
  # have to be the same string, or every criterion would read as unevidenced
597
  # for a reason that is about Markdown rather than about the work.
598
  defp strip_marker(line) do
599
    line
600
    |> String.trim()
601
    |> String.replace(~r/\A(?:[-*+]|\d+\.)\s+/, "")
602
    |> String.replace(~r/\A\[[ xX]\]\s+/, "")
603
    |> String.trim()
604
  end
605
606
  defp heading?(line), do: Regex.match?(~r/\A\s{0,3}\#{1,6}\s+/, line)
607
608
  defp heading_matching?(line, headings) do
609
    case Regex.run(~r/\A\s{0,3}\#{1,6}\s+(.+?)\s*\#*\s*\z/, line) do
610
      [_line, text] -> normalize_heading(text) in headings
611
      nil -> false
612
    end
613
  end
614
615
  defp normalize_heading(text) do
616
    text
617
    |> String.downcase()
618
    |> String.replace(~r/[^a-z0-9]+/, " ")
619
    |> String.trim()
620
  end
621
622
  # ── storing the verdict, and the close ──────────────────────────────────
623
624
  defp persist(repository, issue, assignment, revision, evaluation, policy) do
625
    projection = AcceptedOutcome.public_projection(evaluation)
626
    {state, reasons} = verdict(evaluation)
627
    withheld = if state == "accepted", do: closure_block(issue, revision, policy), else: nil
628
    close? = state == "accepted" and is_nil(withheld) and issue.state == "open"
629
    now = DateTime.utc_now()
630
631
    attrs = %{
632
      "repository_id" => repository.id,
633
      "issue_id" => issue.id,
634
      "assignment_id" => assignment.id,
635
      "revision" => revision,
636
      "state" => state,
637
      "reasons" => reasons ++ List.wrap(withheld),
638
      "criteria" => criteria(projection),
639
      "verifier" => verifier_id(evaluation),
640
      "falsifier" => falsifier_of(evaluation),
641
      "closed" => close?,
642
      "closed_at" => if(close?, do: now),
643
      "closed_by_actor" => if(close?, do: @closing_actor)
644
    }
645
646
    Repo.transaction(fn ->
647
      claim =
648
        case existing(issue, assignment, revision) do
649
          nil -> %CompletionClaim{}
650
          %CompletionClaim{} = found -> found
651
        end
652
        |> CompletionClaim.changeset(attrs)
653
        |> Repo.insert_or_update()
654
655
      case claim do
656
        {:ok, stored} -> if close?, do: close(issue, stored), else: stored
657
        {:error, changeset} -> Repo.rollback(changeset)
658
      end
659
    end)
660
    |> case do
661
      {:ok, %CompletionClaim{} = stored} ->
662
        {:ok, stored}
663
664
      {:error, reason} ->
665
        Logger.warning(
666
          "issue_completion_claim_failed issue=#{issue.number} code=#{OpenAgents.OperationalLog.code(reason)}"
667
        )
668
669
        {:error, :claim_not_recorded}
670
    end
671
  end
672
673
  # The close and the record land together. A failure anywhere rolls both back,
674
  # so a claim can never say it closed an issue that stayed open, which is the
675
  # same discipline `ClosingReferences` holds for the trailer path.
676
  defp close(%Issue{} = issue, %CompletionClaim{} = claim) do
677
    case Issues.update_issue(issue, %{"state" => "closed", "state_reason" => "completed"}, nil) do
678
      {:ok, _closed} -> claim
679
      {:error, reason} -> Repo.rollback(reason)
680
    end
681
  end
682
683
  # Rule 4. An accepted claim on a revision that also carries a failing receipt
684
  # in any family records the verdict and withholds the close, because two
685
  # receipts about the same bytes disagree and no rule here is entitled to pick
686
  # one.
687
  defp closure_block(%Issue{} = issue, revision, %ClosurePolicy{} = policy) do
688
    cond do
689
      not policy.verified_closing_enabled ->
690
        "closure_withheld:repository_has_not_opted_in"
691
692
      contradicted?(issue, revision) ->
693
        "closure_withheld:contradicting_evidence"
694
695
      issue.state != "open" ->
696
        "closure_withheld:issue_already_closed"
697
698
      true ->
699
        nil
700
    end
701
  end
702
703
  defp contradicted?(%Issue{id: issue_id}, revision) do
704
    EvidenceEntry
705
    |> where([entry], entry.issue_id == ^issue_id and entry.commit_sha == ^revision)
706
    |> Repo.all()
707
    |> Enum.any?(&failing?/1)
708
  end
709
710
  defp existing(%Issue{id: issue_id}, %Assignment{id: assignment_id}, revision) do
711
    Repo.one(
712
      from claim in CompletionClaim,
713
        where:
714
          claim.issue_id == ^issue_id and claim.assignment_id == ^assignment_id and
715
            claim.revision == ^revision
716
    )
717
  end
718
719
  defp verdict({:accepted, _outcome}), do: {"accepted", []}
720
  defp verdict({:not_applicable, exemption}), do: {"not_applicable", ["#{exemption}"]}
721
722
  defp verdict({:not_accepted, type, reasons}),
723
    do: {"#{type}", Enum.map(reasons, &render_reason/1)}
724
725
  defp render_reason({key, detail}) when is_list(detail),
726
    do: "#{key}:#{Enum.join(Enum.map(detail, &to_string/1), ",")}"
727
728
  defp render_reason({key, detail}), do: "#{key}:#{detail}"
729
  defp render_reason(reason), do: to_string(reason)
730
731
  defp criteria(%{criteria: criteria}) when is_list(criteria) do
732
    Enum.map(criteria, fn item ->
733
      %{
734
        "criterion" => to_string(item.criterion),
735
        "evidence" => if(item.evidence == :private, do: nil, else: to_string(item.evidence)),
736
        "visibility" => if(item.evidence == :private, do: "private", else: "public")
737
      }
738
    end)
739
  end
740
741
  defp criteria(_projection), do: []
742
743
  defp verifier_id({:accepted, outcome}),
744
    do: outcome.verifier && String.slice(outcome.verifier, 0, 200)
745
746
  defp verifier_id(_evaluation), do: nil
747
748
  defp falsifier_of({:accepted, outcome}), do: outcome.falsifier
749
  defp falsifier_of(_evaluation), do: nil
750
751
  # ── loading ─────────────────────────────────────────────────────────────
752
753
  defp terminal_attempt(%Assignment{id: id}) do
754
    case Repo.get(Assignment, id) do
755
      %Assignment{terminal_commit: commit} = assignment when is_binary(commit) ->
756
        if Assignment.terminal?(assignment),
757
          do: {:ok, assignment},
758
          else: {:error, :claim_attempt_not_terminal}
759
760
      %Assignment{} ->
761
        {:error, :claim_attempt_has_no_revision}
762
763
      nil ->
764
        {:error, :claim_attempt_not_found}
765
    end
766
  end
767
768
  defp fetch(schema, id) do
769
    case id && Repo.get(schema, id) do
770
      nil -> {:error, :claim_attempt_not_found}
771
      record -> {:ok, record}
772
    end
773
  end
774
775
  defp fetch_issue(nil), do: {:error, :claim_attempt_not_found}
776
777
  defp fetch_issue(issue_id) do
778
    case Repo.get(Issue, issue_id) do
779
      nil -> {:error, :claim_attempt_not_found}
780
      %Issue{} = issue -> {:ok, issue}
781
    end
782
  end
783
end
lib/openagents/issues/evidence.ex modified +21 -3

@@ -69,7 +69,7 @@ defmodule OpenAgents.Issues.Evidence do

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

@@ -497,12 +497,30 @@ defmodule OpenAgents.Issues.Evidence do

497 497
        conflict_target: [:issue_id, :commit_sha, :family, :receipt_id]
498 498
      )
499 499
      |> case do
500
        {:ok, %EvidenceEntry{} = entry} -> [entry]
501
        {:error, _changeset} -> []
500
        {:ok, %EvidenceEntry{} = entry} ->
501
          # A receipt that disagrees with an accepted claim's evidence marks
502
          # that claim contradicted. It never reopens the issue and never
503
          # fails the receipt that produced it; #150 owns that rule.
504
          _ = note_contradiction(entry)
505
          [entry]
506
507
        {:error, _changeset} ->
508
          []
502 509
      end
503 510
    end
504 511
  end
505 512
513
  defp note_contradiction(%EvidenceEntry{} = entry) do
514
    CompletionClaims.note_evidence(entry)
515
  rescue
516
    error ->
517
      Logger.warning(
518
        "issue_evidence_contradiction_failed code=#{OpenAgents.OperationalLog.code(error)}"
519
      )
520
521
      :ok
522
  end
523
506 524
  defp recorded?(attrs) do
507 525
    Repo.exists?(
508 526
      from entry in EvidenceEntry,
lib/openagents_web/api_route_authority.ex modified +2

@@ -327,6 +327,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

327 327
      "post /api/v3/repos/:owner/:repo/issues" => {:required_bearer, :issue, :envelope},
328 328
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" =>
329 329
        {:required_bearer, :comment, :envelope},
330
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/completion_claim" =>
331
        {:required_bearer, :issue, :envelope},
330 332
      "get /api/v3/agent" => {:required_bearer, :agent, :legacy},
331 333
      "post /api/v3/agents/:handle/box-control" => {:required_bearer, :agent, :legacy},
332 334
      "delete /api/v3/agents/:handle/box-control" => {:required_bearer, :agent, :legacy},
lib/openagents_web/controllers/api_extension_controller.ex modified +85

@@ -112,6 +112,82 @@ defmodule OpenAgentsWeb.ApiExtensionController do

112 112
    }
113 113
  }
114 114
115
  @issue_completion_claim %{
116
    "type" => "object",
117
    "description" =>
118
      "One graded completion claim: what an attempt asserted about the " <>
119
        "issue's intent at one exact revision, and how the accepted-outcome " <>
120
        "contract graded it. A receipt says what evaluated a commit; this " <>
121
        "says what was claimed about the outcome, which is a different " <>
122
        "question and the only one that can close an issue.",
123
    "properties" => %{
124
      "id" => %{"type" => "string"},
125
      "revision" => %{"type" => "string"},
126
      "state" => %{
127
        "type" => "string",
128
        "enum" => OpenAgents.Issues.CompletionClaim.states(),
129
        "description" =>
130
          "The graded verdict. `not_applicable` is human-authored work or a " <>
131
            "repository that has not enabled agents, which the contract does " <>
132
            "not gate at all."
133
      },
134
      "reasons" => %{
135
        "type" => "array",
136
        "items" => %{"type" => "string"},
137
        "description" =>
138
          "The typed reasons a non-accepted verdict carries, and the reason " <>
139
            "an accepted claim did not close the issue when it did not."
140
      },
141
      "criteria" => %{
142
        "type" => "array",
143
        "items" => %{
144
          "type" => "object",
145
          "properties" => %{
146
            "criterion" => %{"type" => "string"},
147
            "evidence" => %{"type" => ["string", "null"]},
148
            "visibility" => %{"type" => "string", "enum" => ["public", "private"]}
149
          }
150
        },
151
        "description" =>
152
          "Which evidence satisfied which acceptance criterion. A criterion " <>
153
            "satisfied by a private repository's evidence appears as " <>
154
            "satisfied without the reference."
155
      },
156
      "verifier" => %{"type" => ["string", "null"]},
157
      "falsifier" => %{
158
        "type" => ["string", "null"],
159
        "description" =>
160
          "The observation that would have made this claim red: the same " <>
161
            "check name, on the same commit and artifact digest, reporting " <>
162
            "failed."
163
      },
164
      "closed" => %{
165
        "type" => "boolean",
166
        "description" =>
167
          "Whether this claim closed the issue. Only a qualification receipt " <>
168
            "for this exact revision can, only on a repository that opted " <>
169
            "in, and only when no receipt for that revision failed."
170
      },
171
      "closed_at" => %{"type" => ["string", "null"]},
172
      "closed_by_actor" => %{
173
        "type" => ["string", "null"],
174
        "description" =>
175
          "The system principal an automatic close is attributed to. It is " <>
176
            "never a user id: a person's close leaves a closing reference " <>
177
            "with a user behind it instead."
178
      },
179
      "contradicted_at" => %{
180
        "type" => ["string", "null"],
181
        "description" =>
182
          "When a later receipt disagreed with the evidence this claim " <>
183
            "rested on. The issue is never reopened; the claim stops reading " <>
184
            "as uncontested."
185
      },
186
      "contradiction_reason" => %{"type" => ["string", "null"]},
187
      "recorded_at" => %{"type" => "string"}
188
    }
189
  }
190
115 191
  @promise_record %{
116 192
    "type" => "object",
117 193
    "description" => "A state-gated promise record stored in a project item.",

@@ -227,6 +303,15 @@ defmodule OpenAgentsWeb.ApiExtensionController do

227 303
              "first, empty when nothing has evaluated one. A failed build, " <>
228 304
              "a reverted deployment, and a superseded run all stay: the " <>
229 305
              "chain is what happened, not what worked."
306
        },
307
        "completion_claims" => %{
308
          "type" => "array",
309
          "items" => @issue_completion_claim,
310
          "description" =>
311
            "Every completion claim graded against this issue, oldest " <>
312
              "first. A claim is stored whether or not it was accepted and " <>
313
              "whether or not it closed anything, so a refusal is on the " <>
314
              "record rather than silent."
230 315
        }
231 316
      },
232 317
      "filters" => %{
lib/openagents_web/controllers/issue_completion_claim_controller.ex added +136

@@ -0,0 +1,136 @@

1
defmodule OpenAgentsWeb.IssueCompletionClaimController do
2
  @moduledoc """
3
  Submit one completion claim for a finished attempt on an issue.
4
5
  The request carries exactly one judgment: which evidence satisfied which
6
  acceptance criterion. Everything else `OpenAgents.Issues.CompletionClaims`
7
  grades — the issue's sections, the attempt's five binding fields, the
8
  verifier, and the falsifier — is read from records, so a caller cannot assert
9
  its own authority or its own verification.
10
11
  Two principals may submit. An agent may submit only for the attempt it
12
  requested, which is the authority the attempt already recorded. A user may
13
  submit for any attempt in a repository it can write. A user's claim is
14
  `not_applicable`: the contract gates agent-authored claims, and a person who
15
  wants an issue closed closes it.
16
17
  The response is the same claim object the issue extension carries, so a
18
  client that reads `issue.openagents.completion_claims` and a client that
19
  reads this endpoint agree without translating between two shapes.
20
  """
21
22
  use OpenAgentsWeb, :controller
23
24
  alias OpenAgents.Agents.Agent
25
  alias OpenAgents.Forge.Assignment
26
  alias OpenAgents.Issues
27
  alias OpenAgents.Issues.CompletionClaims
28
  alias OpenAgents.Repo
29
  alias OpenAgents.Repositories
30
  alias OpenAgents.Repositories.Repository
31
  alias OpenAgentsWeb.ApiError
32
  alias OpenAgentsWeb.ControllerHelpers
33
34
  def create(conn, %{"owner" => owner, "repo" => repo, "issue_number" => number} = params) do
35
    actor = conn.assigns[:current_agent] || conn.assigns[:current_user]
36
37
    with {:ok, assignment} <- load_assignment(params["assignment_id"]),
38
         {:ok, repository} <- load_repository(assignment, owner, repo),
39
         {:ok, issue} <- load_issue(repository, assignment, number),
40
         :ok <- authorize(repository, assignment, actor) do
41
      case CompletionClaims.submit(assignment, author(actor), claim_attrs(params)) do
42
        {:ok, claim} ->
43
          conn
44
          |> put_status(:created)
45
          |> json(%{
46
            "claim" => CompletionClaims.summary(claim),
47
            "issue" => %{
48
              "number" => issue.number,
49
              "state" => Issues.get_issue!(repository, issue.id).state
50
            }
51
          })
52
53
        {:error, reason} ->
54
          ApiError.refuse(conn, "validation_failed",
55
            message: "The claim could not be graded: #{reason}"
56
          )
57
      end
58
    else
59
      {:error, :forbidden} ->
60
        ApiError.refuse(conn, "forbidden",
61
          message: "This principal may not claim completion for that attempt"
62
        )
63
64
      {:error, :not_found} ->
65
        ApiError.not_found(conn, message: "No such attempt on that issue")
66
    end
67
  end
68
69
  # The attempt is the anchor, because it is what carries the authority, the
70
  # budget, and the revision. A caller that cannot name one has no claim to
71
  # make.
72
  defp load_assignment(id) when is_binary(id) do
73
    case Ecto.UUID.cast(id) do
74
      {:ok, uuid} ->
75
        case Repo.get(Assignment, uuid) do
76
          %Assignment{} = assignment -> {:ok, assignment}
77
          nil -> {:error, :not_found}
78
        end
79
80
      :error ->
81
        {:error, :not_found}
82
    end
83
  end
84
85
  defp load_assignment(_id), do: {:error, :not_found}
86
87
  # The path must name the attempt's own repository. Resolving the repository
88
  # from the attempt rather than from the path is what keeps a caller from
89
  # pointing a real attempt at another repository's issue.
90
  defp load_repository(%Assignment{repository_id: repository_id}, owner, repo) do
91
    case Repo.get(Repository, repository_id) do
92
      %Repository{} = repository ->
93
        if String.downcase(owner) == repository.owner_key and
94
             String.downcase(repo) == repository.name_key,
95
           do: {:ok, repository},
96
           else: {:error, :not_found}
97
98
      nil ->
99
        {:error, :not_found}
100
    end
101
  end
102
103
  defp load_issue(repository, %Assignment{issue_id: issue_id}, number) do
104
    issue = Issues.get_issue_by_number!(repository, ControllerHelpers.integer_param!(number))
105
    if issue.id == issue_id, do: {:ok, issue}, else: {:error, :not_found}
106
  rescue
107
    Ecto.NoResultsError -> {:error, :not_found}
108
  end
109
110
  # An agent may claim only for the attempt it requested. That is not a
111
  # courtesy: the attempt's `requesting_principal` is the authority the claim
112
  # is graded under, so an agent claiming for someone else's attempt would be
113
  # claiming under an authority it never held.
114
  defp authorize(_repository, %Assignment{} = assignment, %Agent{id: agent_id}) do
115
    case assignment.requesting_principal do
116
      %{"type" => "agent", "id" => ^agent_id} -> :ok
117
      _other -> {:error, :forbidden}
118
    end
119
  end
120
121
  defp authorize(repository, %Assignment{}, %OpenAgents.Accounts.User{} = user) do
122
    if Repositories.writable?(repository, user), do: :ok, else: {:error, :forbidden}
123
  end
124
125
  defp authorize(_repository, _assignment, _actor), do: {:error, :forbidden}
126
127
  defp author(%Agent{}), do: :agent
128
  defp author(_actor), do: :human
129
130
  defp claim_attrs(params) do
131
    %{
132
      evidence: List.wrap(params["evidence"]),
133
      false_green_classes: List.wrap(params["false_green_classes"])
134
    }
135
  end
136
end
lib/openagents_web/controllers/issue_controller.ex modified +13 -3

@@ -3,6 +3,7 @@ defmodule OpenAgentsWeb.IssueController do

3 3
4 4
  alias OpenAgents.Forge.Assignments
5 5
  alias OpenAgents.Issues
6
  alias OpenAgents.Issues.CompletionClaims
6 7
  alias OpenAgents.Issues.Evidence
7 8
  alias OpenAgents.Issues.Issue
8 9
  alias OpenAgents.Agents.Agent

@@ -28,6 +29,7 @@ defmodule OpenAgentsWeb.IssueController do

28 29
        pull_requests: PullRequests.markers_by_issue_id(issues),
29 30
        work: Assignments.attempts_for_issues(issues),
30 31
        evidence: Evidence.for_issues(issues),
32
        completion_claims: CompletionClaims.for_issues(issues),
31 33
        pagination: %{
32 34
          page: Issues.parse_page(params["page"]),
33 35
          per_page: Issues.per_page(),

@@ -134,7 +136,8 @@ defmodule OpenAgentsWeb.IssueController do

134 136
            dependencies: dependencies(issue),
135 137
            progress: progress(issue, actor),
136 138
            work: work(issue),
137
            evidence: evidence(issue)
139
            evidence: evidence(issue),
140
            completion_claims: completion_claims(issue)
138 141
          )
139 142
140 143
        {:error, %Ecto.Changeset{} = changeset} ->

@@ -181,7 +184,8 @@ defmodule OpenAgentsWeb.IssueController do

181 184
      progress: progress(issue, conn.assigns[:current_user]),
182 185
      pull_requests: PullRequests.markers_by_issue_id([issue]),
183 186
      work: work(issue),
184
      evidence: evidence(issue)
187
      evidence: evidence(issue),
188
      completion_claims: completion_claims(issue)
185 189
    )
186 190
  rescue
187 191
    Ecto.NoResultsError -> not_found(conn)

@@ -214,7 +218,8 @@ defmodule OpenAgentsWeb.IssueController do

214 218
          dependencies: dependencies(issue),
215 219
          progress: progress(issue, conn.assigns.current_user),
216 220
          work: work(issue),
217
          evidence: evidence(issue)
221
          evidence: evidence(issue),
222
          completion_claims: completion_claims(issue)
218 223
        )
219 224
220 225
      {:error, %Ecto.Changeset{} = changeset} ->

@@ -235,6 +240,11 @@ defmodule OpenAgentsWeb.IssueController do

235 240
  # never disagree about what shipped an issue.
236 241
  defp evidence(%Issue{} = issue), do: Evidence.for_issues([issue])
237 242
243
  # The claims read through the same page-shaped function as the index, for the
244
  # same reason: a detail response and a row in the list can never disagree
245
  # about what was claimed for an issue.
246
  defp completion_claims(%Issue{} = issue), do: CompletionClaims.for_issues([issue])
247
238 248
  # Progress is derived from the boards this reader can open, so an agent
239 249
  # authenticating as itself never inherits a private board's column.
240 250
  defp progress(%Issue{} = issue, %OpenAgents.Accounts.User{} = reader),
lib/openagents_web/controllers/issue_json.ex modified +29

@@ -103,6 +103,7 @@ defmodule OpenAgentsWeb.IssueJSON do

103 103
      |> put_progress(Map.get(assigns, :progress), issue)
104 104
      |> put_work(Map.get(assigns, :work), issue)
105 105
      |> put_evidence(Map.get(assigns, :evidence), issue)
106
      |> put_completion_claims(Map.get(assigns, :completion_claims), issue)
106 107
107 108
    if extension == %{}, do: json, else: Map.put(json, :openagents, extension)
108 109
  end

@@ -136,6 +137,34 @@ defmodule OpenAgentsWeb.IssueJSON do

136 137
    Map.put(extension, :evidence, evidence |> Map.get(issue.id, []) |> Enum.map(&evidence_json/1))
137 138
  end
138 139
140
  defp put_completion_claims(extension, nil, _issue), do: extension
141
142
  defp put_completion_claims(extension, claims, issue) when is_map(claims) do
143
    Map.put(
144
      extension,
145
      :completion_claims,
146
      claims |> Map.get(issue.id, []) |> Enum.map(&completion_claim_json/1)
147
    )
148
  end
149
150
  defp completion_claim_json(claim) do
151
    %{
152
      id: claim.id,
153
      revision: claim.revision,
154
      state: claim.state,
155
      reasons: claim.reasons,
156
      criteria: claim.criteria,
157
      verifier: claim.verifier,
158
      falsifier: claim.falsifier,
159
      closed: claim.closed,
160
      closed_at: claim.closed_at,
161
      closed_by_actor: claim.closed_by_actor,
162
      contradicted_at: claim.contradicted_at,
163
      contradiction_reason: claim.contradiction_reason,
164
      recorded_at: claim.recorded_at
165
    }
166
  end
167
139 168
  defp evidence_json(entry) do
140 169
    %{
141 170
      id: entry.id,
lib/openagents_web/router.ex modified +4

@@ -474,6 +474,10 @@ defmodule OpenAgentsWeb.Router do

474 474
    post "/forum/topics/:topic_id/posts", ForumApiController, :create_post
475 475
    post "/repos/:owner/:repo/issues", IssueController, :create
476 476
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
477
478
    post "/repos/:owner/:repo/issues/:issue_number/completion_claim",
479
         IssueCompletionClaimController,
480
         :create
477 481
  end
478 482
479 483
  scope "/api/v3", OpenAgentsWeb do
priv/migration_lineages/prior-2026-08-19.json modified +9 -3

@@ -284,6 +284,7 @@

284 284
    20260824011303,
285 285
    20260824024146,
286 286
    20260824031651,
287
    20260824032138,
287 288
    20260824032226
288 289
  ],
289 290
  "required_tables": [

@@ -298,7 +299,9 @@

298 299
    "github_oauth_attempts",
299 300
    "forge_fleet_targets",
300 301
    "forge_builds",
301
    "forge_deploys"
302
    "forge_deploys",
303
    "repository_closure_policies",
304
    "issue_completion_claims"
302 305
  ],
303 306
  "required_columns": {
304 307
    "users": [

@@ -493,13 +496,16 @@

493 496
    "users_ban_state_check",
494 497
    "visitors_identity_source_check",
495 498
    "forge_fleet_target_status",
496
    "forge_deploys_result"
499
    "forge_deploys_result",
500
    "issue_completion_claims_close_requires_accepted",
501
    "issue_completion_claims_state"
497 502
  ],
498 503
  "required_indexes": [
499 504
    "users_github_id_index",
500 505
    "visitors_browser_key_hash_index",
501 506
    "visitors_user_id_index",
502
    "github_oauth_attempts_state_digest_index"
507
    "github_oauth_attempts_state_digest_index",
508
    "issue_completion_claims_issue_id_assignment_id_revision_index"
503 509
  ],
504 510
  "pre_baseline_required_indexes": [
505 511
    "forge_builds_repo_sha_target_id_index"
priv/repo/migrations/20260824032138_create_issue_completion_claims.exs added +108

@@ -0,0 +1,108 @@

1
defmodule OpenAgents.Repo.Migrations.CreateIssueCompletionClaims do
2
  use Ecto.Migration
3
4
  def change do
5
    # The opt-in. Both flags default to false, so a repository that has said
6
    # nothing is a repository where nothing closes itself and no claim is even
7
    # graded. Silence is never consent here: the absent row and the row with
8
    # both flags false must mean the same thing.
9
    create table(:repository_closure_policies, primary_key: false) do
10
      add :id, :binary_id, primary_key: true
11
12
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
13
        null: false
14
15
      # Whether agent-authored claims are graded at all. False makes every
16
      # claim `not_applicable`, which is the accepted-outcome contract's own
17
      # word for work outside the gate.
18
      add :agents_enabled, :boolean, null: false, default: false
19
20
      # Whether an accepted claim may close the issue. False records the
21
      # verdict and leaves the issue open for a person to act on.
22
      add :verified_closing_enabled, :boolean, null: false, default: false
23
24
      add :updated_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
25
26
      timestamps(type: :utc_datetime_usec)
27
    end
28
29
    create unique_index(:repository_closure_policies, [:repository_id])
30
31
    create table(:issue_completion_claims, primary_key: false) do
32
      add :id, :binary_id, primary_key: true
33
34
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
35
        null: false
36
37
      add :issue_id, references(:issues, on_delete: :delete_all), null: false
38
39
      # The attempt. Not nullable: three of the five attempt fields the
40
      # contract binds — the authority, the budget, and the revision — are
41
      # only reachable through an assignment, so a claim without one could
42
      # never be graded as anything but incomplete.
43
      add :assignment_id,
44
          references(:forge_assignments, type: :binary_id, on_delete: :delete_all), null: false
45
46
      # The exact revision the claim is about, copied from the attempt's own
47
      # terminal report rather than from the caller.
48
      add :revision, :string, null: false, size: 64
49
50
      # The graded verdict: the accepted-outcome contract's own vocabulary.
51
      add :state, :string, null: false, size: 32
52
53
      # The typed reasons a non-accepted verdict carries, rendered as strings.
54
      add :reasons, {:array, :string}, null: false, default: []
55
56
      # Which evidence satisfied which acceptance criterion, and nothing else.
57
      add :criteria, {:array, :map}, null: false, default: []
58
59
      # The verifier's identity and the falsifier it recorded, so a reader can
60
      # ask what observation would have made this red.
61
      add :verifier, :string, size: 200
62
      add :falsifier, :string, size: 500
63
64
      # The closure half. `closed_by_actor` is a system principal and never a
65
      # user: a person's close is a `issue_closing_references` row with a
66
      # `closed_by_user_id`, and the two records cannot be confused.
67
      add :closed, :boolean, null: false, default: false
68
      add :closed_at, :utc_datetime_usec
69
      add :closed_by_actor, :string, size: 200
70
71
      # A later receipt that disagreed with the evidence this claim rested on.
72
      # It never reopens the issue; it records that the ground moved.
73
      add :contradicted_at, :utc_datetime_usec
74
75
      add :contradicted_by_evidence_id,
76
          references(:issue_evidence, type: :binary_id, on_delete: :nilify_all)
77
78
      add :contradiction_reason, :string, size: 200
79
80
      timestamps(type: :utc_datetime_usec)
81
    end
82
83
    # `{issue, attempt, revision}` is the key #150 names. One attempt reporting
84
    # one revision produces one graded claim however many times it is
85
    # submitted, so a resubmission updates a verdict rather than accumulating
86
    # verdicts nobody can order.
87
    create unique_index(:issue_completion_claims, [:issue_id, :assignment_id, :revision])
88
89
    # The contradiction lookup: a failing receipt lands for a commit and asks
90
    # which claims rested on that exact revision of that exact issue.
91
    create index(:issue_completion_claims, [:issue_id, :revision])
92
93
    create constraint(:issue_completion_claims, :issue_completion_claims_state,
94
             check: "state IN ('accepted','incomplete','unauthorized','failed','not_applicable')"
95
           )
96
97
    create constraint(:issue_completion_claims, :issue_completion_claims_revision,
98
             check: "revision ~ '^[0-9a-f]{7,64}$'"
99
           )
100
101
    # Only an accepted claim may carry a close. A row that says it closed an
102
    # issue on an `incomplete` verdict is the exact failure this issue exists
103
    # to prevent, so PostgreSQL refuses it rather than trusting the caller.
104
    create constraint(:issue_completion_claims, :issue_completion_claims_close_requires_accepted,
105
             check: "closed = false OR (state = 'accepted' AND closed_at IS NOT NULL)"
106
           )
107
  end
108
end
test/openagents/issues/completion_claims_test.exs added +656

@@ -0,0 +1,656 @@

1
defmodule OpenAgents.Issues.CompletionClaimsTest do
2
  @moduledoc """
3
  Stage 6 of `#10`: store the completion claim, and close an issue from it only
4
  when a rule that entails the outcome permits.
5
6
  The properties under test are the ones that make the rule worth having. A
7
  claim is graded and stored whether or not it is accepted. Only a
8
  qualification receipt for the exact revision can satisfy an acceptance
9
  criterion; a push, a build, and a deployment record and never qualify. A
10
  repository closes nothing until it opts in twice. A later receipt that
11
  disagrees contradicts the claim and never reopens the issue. An automatic
12
  close is attributable to a system principal and is not a person's close.
13
  """
14
  use OpenAgents.DataCase, async: true
15
16
  import OpenAgents.AccountsFixtures
17
18
  alias OpenAgents.Conversations
19
  alias OpenAgents.Deployments.CheckResult
20
  alias OpenAgents.Forge.{Assignment, BuildReceipt, DeployReceipt}
21
  alias OpenAgents.Issues
22
  alias OpenAgents.Issues.{ClosingReference, ClosingReferences, CompletionClaim}
23
  alias OpenAgents.Issues.{CompletionClaims, Evidence}
24
  alias OpenAgents.Machines
25
  alias OpenAgents.Repo
26
  alias OpenAgents.Work
27
28
  @sha String.duplicate("ab", 20)
29
  @other_sha String.duplicate("cd", 20)
30
  @digest "sha256:" <> String.duplicate("9f", 32)
31
32
  @scoped_body """
33
  ## Problem
34
35
  Nothing stores a completion claim, so no policy can key on one.
36
37
  ## Scope
38
39
  The claim record, its grading, and the closure rule.
40
41
  ## Acceptance criteria
42
43
  - The claim is stored and graded.
44
45
  ## Success metrics
46
47
  An issue can name which receipt satisfied which criterion.
48
  """
49
50
  @criterion "The claim is stored and graded."
51
52
  setup do
53
    user = repository_user_fixture("claim-author")
54
    repository = repository_with_member_fixture(user, %{}, "owner")
55
56
    {:ok, issue} =
57
      Issues.create_issue(repository, %{
58
        title: "Close from a verified outcome",
59
        body: @scoped_body
60
      })
61
62
    %{user: user, repository: repository, issue: issue}
63
  end
64
65
  describe "the repository has to opt in, twice" do
66
    test "an unconfigured repository grades not_applicable and closes nothing", context do
67
      context = with_qualified_attempt(context)
68
69
      assert {:ok, claim} = submit(context)
70
      assert claim.state == "not_applicable"
71
      assert claim.reasons == ["agents_disabled_repository"]
72
      refute claim.closed
73
      assert reload(context.issue).state == "open"
74
    end
75
76
    test "grading on, closing off records an accepted claim and leaves the issue open",
77
         context do
78
      context = with_qualified_attempt(context)
79
      opt_in(context, agents_enabled: true, verified_closing_enabled: false)
80
81
      assert {:ok, claim} = submit(context)
82
      assert claim.state == "accepted"
83
      refute claim.closed
84
      assert "closure_withheld:repository_has_not_opted_in" in claim.reasons
85
      assert reload(context.issue).state == "open"
86
    end
87
88
    test "an absent policy row reads the same as one with both flags false", context do
89
      assert %{agents_enabled: false, verified_closing_enabled: false} =
90
               CompletionClaims.policy(context.repository)
91
    end
92
  end
93
94
  describe "a human's claim is not gated" do
95
    test "human-authored work grades not_applicable even with both flags on", context do
96
      context = with_qualified_attempt(context)
97
      opt_in(context)
98
99
      assert {:ok, claim} = CompletionClaims.submit(context.assignment, :human, evidence(context))
100
      assert claim.state == "not_applicable"
101
      assert claim.reasons == ["human_only_work"]
102
      refute claim.closed
103
      assert reload(context.issue).state == "open"
104
    end
105
  end
106
107
  describe "an accepted claim closes the issue" do
108
    setup context do
109
      context = with_qualified_attempt(context)
110
      opt_in(context)
111
      context
112
    end
113
114
    test "the issue closes and names which evidence satisfied which criterion", context do
115
      assert {:ok, claim} = submit(context)
116
117
      assert claim.state == "accepted"
118
      assert claim.closed
119
      assert claim.closed_at
120
      assert [%{"criterion" => @criterion, "evidence" => evidence_id}] = claim.criteria
121
      assert evidence_id == context.evidence.id
122
      assert reload(context.issue).state == "closed"
123
      assert reload(context.issue).state_reason == "completed"
124
    end
125
126
    test "the close is attributed to a system principal and to no person", context do
127
      assert {:ok, claim} = submit(context)
128
129
      assert claim.closed_by_actor == CompletionClaims.closing_actor()
130
      assert claim.closed_by_actor == "system:accepted-outcome"
131
132
      # The one way a reader tells this close from a person's: a person's close
133
      # leaves a closing reference carrying the user who made it. This one
134
      # leaves none, because no person asserted anything.
135
      assert ClosingReferences.for_issue(context.issue) == []
136
      assert Repo.aggregate(ClosingReference, :count) == 0
137
    end
138
139
    test "the claim records the falsifier the check result could have produced", context do
140
      assert {:ok, claim} = submit(context)
141
142
      assert claim.verifier == "coverage"
143
      assert claim.falsifier =~ "deployment_check_results:coverage@#{@digest}=failed"
144
    end
145
146
    test "closing this way clears the derived blocked flag on a dependent", context do
147
      {:ok, dependent} = Issues.create_issue(context.repository, %{title: "Waits on the claim"})
148
      :ok = Issues.add_dependencies(dependent, [context.issue.number])
149
150
      assert Issues.dependencies(reload(dependent)).blocked
151
152
      assert {:ok, claim} = submit(context)
153
      assert claim.closed
154
155
      refute Issues.dependencies(reload(dependent)).blocked
156
    end
157
158
    test "resubmitting the same claim regrades in place", context do
159
      assert {:ok, first} = submit(context)
160
      assert {:ok, second} = submit(context)
161
162
      assert first.id == second.id
163
      assert Repo.aggregate(CompletionClaim, :count) == 1
164
    end
165
  end
166
167
  describe "only a qualification receipt can satisfy a criterion" do
168
    setup context do
169
      context = with_qualified_attempt(context)
170
      opt_in(context)
171
      context
172
    end
173
174
    test "a build receipt for the same commit qualifies nothing", context do
175
      [build] = Evidence.record_build(build_receipt(context, @sha, "complete"))
176
      assert build.family == "build"
177
178
      assert {:ok, claim} = submit(context, build.id)
179
180
      assert claim.state == "incomplete"
181
      assert "unevidenced_criterion:#{@criterion}" in claim.reasons
182
      refute claim.closed
183
      assert reload(context.issue).state == "open"
184
    end
185
186
    test "a deployment receipt for the same commit qualifies nothing", context do
187
      [deploy] = Evidence.record_deploy(deploy_receipt(context, @sha, "live"))
188
      assert deploy.family == "deployment"
189
190
      assert {:ok, claim} = submit(context, deploy.id)
191
192
      assert claim.state == "incomplete"
193
      assert "unevidenced_criterion:#{@criterion}" in claim.reasons
194
      refute claim.closed
195
    end
196
197
    test "a succeeded tenant deployment run qualifies nothing", context do
198
      # The one family word that collides with qualification's: a tenant
199
      # deployment run reaches the state `succeeded` too. Without the family
200
      # narrowing, a successful deployment would satisfy an acceptance
201
      # criterion, which is the exact confusion #150 exists to refuse.
202
      run = deployment_run(context, "succeeded")
203
      [deployed] = Evidence.record_deployment_run(run)
204
205
      assert deployed.family == "deployment"
206
      assert deployed.result == "succeeded"
207
208
      assert {:ok, claim} = submit(context, deployed.id)
209
210
      assert claim.state == "incomplete"
211
      assert "unevidenced_criterion:#{@criterion}" in claim.reasons
212
      refute claim.closed
213
      assert reload(context.issue).state == "open"
214
    end
215
216
    test "another issue's qualification receipt qualifies nothing here", context do
217
      {:ok, other} =
218
        Issues.create_issue(context.repository, %{title: "Elsewhere", body: @scoped_body})
219
220
      _other_attempt = attempt(%{context | issue: other}, @sha, "completed", budget_snapshot())
221
222
      # One check result on the same commit, which both issues claim, so both
223
      # get an edge. The other issue's edge is a qualification receipt for this
224
      # exact revision with the qualifying status — identical in every way
225
      # except the issue it belongs to.
226
      entries = Evidence.record_check_result(check_result(context, @sha, "succeeded", "shared"))
227
      elsewhere = Enum.find(entries, &(&1.issue_id == other.id))
228
229
      assert elsewhere.family == "qualification"
230
      assert elsewhere.result == "succeeded"
231
      assert elsewhere.commit_sha == @sha
232
233
      assert {:ok, claim} = submit(context, elsewhere.id)
234
235
      assert claim.state == "incomplete"
236
      assert "unevidenced_criterion:#{@criterion}" in claim.reasons
237
      refute claim.closed
238
      assert reload(context.issue).state == "open"
239
    end
240
241
    test "a qualification receipt for another revision qualifies nothing", context do
242
      # A second attempt on this same issue reported a different revision, and a
243
      # check passed on that one. The edge is real, it belongs to this issue,
244
      # it is a qualification receipt and it succeeded — it is simply about
245
      # bytes this claim is not about.
246
      _older = attempt(context, @other_sha, "completed", budget_snapshot())
247
248
      [other] =
249
        Evidence.record_check_result(check_result(context, @other_sha, "succeeded", "older"))
250
251
      assert other.issue_id == context.issue.id
252
      assert other.family == "qualification"
253
      assert other.result == "succeeded"
254
      assert other.commit_sha == @other_sha
255
256
      assert {:ok, claim} = submit(context, other.id)
257
258
      assert claim.state == "incomplete"
259
      assert "unevidenced_criterion:#{@criterion}" in claim.reasons
260
      refute claim.closed
261
    end
262
263
    test "a failed qualification receipt qualifies nothing", context do
264
      failed = qualification(context, @sha, "failed", "coverage-red")
265
266
      assert {:ok, claim} = submit(context, failed.id)
267
268
      assert claim.state == "incomplete"
269
      refute claim.closed
270
    end
271
272
    test "the two halves are named rather than implied", _context do
273
      assert CompletionClaims.closing_family() == "qualification"
274
275
      assert Enum.sort(CompletionClaims.recording_only_families()) ==
276
               ~w(build deployment push)
277
    end
278
  end
279
280
  describe "typed non-accepted results" do
281
    setup context do
282
      context = with_qualified_attempt(context)
283
      opt_in(context)
284
      context
285
    end
286
287
    test "an issue missing a required section is incomplete by that section's name",
288
         context do
289
      {:ok, thin} =
290
        Issues.update_issue(context.issue, %{"body" => "## Problem\n\nNo other sections.\n"})
291
292
      assert {:ok, claim} = submit(%{context | issue: thin})
293
294
      assert claim.state == "incomplete"
295
      assert "missing_issue_section:scope" in claim.reasons
296
      assert "missing_issue_section:acceptance_criteria" in claim.reasons
297
      assert "missing_issue_section:success_metrics" in claim.reasons
298
      refute claim.closed
299
    end
300
301
    test "an attempt with no budget snapshot is incomplete by that field's name", context do
302
      unbudgeted = attempt(context, @sha, "completed", nil)
303
304
      assert {:ok, claim} = CompletionClaims.submit(unbudgeted, :agent, evidence(context))
305
306
      assert claim.state == "incomplete"
307
      assert "missing_attempt_field:budget" in claim.reasons
308
      refute claim.closed
309
    end
310
311
    test "a criterion naming nothing is incomplete", context do
312
      assert {:ok, claim} = CompletionClaims.submit(context.assignment, :agent, %{evidence: []})
313
314
      assert claim.state == "incomplete"
315
      assert "unevidenced_criterion:#{@criterion}" in claim.reasons
316
    end
317
318
    test "a self-named false-green class fails a claim its verifier called green",
319
         context do
320
      assert {:ok, claim} =
321
               CompletionClaims.submit(
322
                 context.assignment,
323
                 :agent,
324
                 Map.put(evidence(context), :false_green_classes, ["false_green_mocked_seam"])
325
               )
326
327
      assert claim.state == "failed"
328
      assert "false_green:false_green_mocked_seam" in claim.reasons
329
      refute claim.closed
330
      assert reload(context.issue).state == "open"
331
    end
332
333
    test "a verifier who is also the producer is unauthorized", context do
334
      # The same user both requested the attempt and published the check. The
335
      # producer-verifier separation this path always requires is exactly the
336
      # case where an agent's work grades itself.
337
      Repo.update_all(
338
        from(result in CheckResult, where: result.id == ^context.check_result.id),
339
        set: [published_by_user_id: context.user.id]
340
      )
341
342
      assert {:ok, claim} = submit(context)
343
344
      assert claim.state == "unauthorized"
345
      assert "verifier_not_independent" in claim.reasons
346
      refute claim.closed
347
      assert reload(context.issue).state == "open"
348
    end
349
  end
350
351
  describe "a later receipt that disagrees" do
352
    setup context do
353
      context = with_qualified_attempt(context)
354
      opt_in(context)
355
      context
356
    end
357
358
    test "a failing build after the close contradicts the claim without reopening",
359
         context do
360
      assert {:ok, claim} = submit(context)
361
      assert claim.closed
362
      assert is_nil(claim.contradicted_at)
363
364
      [failed] = Evidence.record_build(build_receipt(context, @sha, "failed"))
365
366
      contradicted = Repo.get!(CompletionClaim, claim.id)
367
      assert contradicted.contradicted_at
368
      assert contradicted.contradicted_by_evidence_id == failed.id
369
      assert contradicted.contradiction_reason == "build:failed"
370
371
      # The issue stays closed. Reopening on a later signal is a separate
372
      # policy with its own failure modes, and this path never has it.
373
      assert reload(context.issue).state == "closed"
374
      assert contradicted.closed
375
    end
376
377
    test "a reverted deployment contradicts the claim", context do
378
      assert {:ok, claim} = submit(context)
379
380
      [reverted] = Evidence.record_deploy(deploy_receipt(context, @sha, "reverted"))
381
382
      contradicted = Repo.get!(CompletionClaim, claim.id)
383
      assert contradicted.contradicted_by_evidence_id == reverted.id
384
      assert contradicted.contradiction_reason == "deployment:reverted"
385
    end
386
387
    test "a successful receipt landing later contradicts nothing", context do
388
      assert {:ok, claim} = submit(context)
389
390
      Evidence.record_build(build_receipt(context, @sha, "complete"))
391
392
      assert is_nil(Repo.get!(CompletionClaim, claim.id).contradicted_at)
393
    end
394
395
    test "a failing receipt already on the revision withholds the close", context do
396
      Evidence.record_build(build_receipt(context, @sha, "failed"))
397
398
      assert {:ok, claim} = submit(context)
399
400
      assert claim.state == "accepted"
401
      refute claim.closed
402
      assert "closure_withheld:contradicting_evidence" in claim.reasons
403
      assert reload(context.issue).state == "open"
404
    end
405
  end
406
407
  describe "one closer, not two" do
408
    setup context do
409
      context = with_qualified_attempt(context)
410
      opt_in(context)
411
      context
412
    end
413
414
    test "an issue #130 already closed records the claim and moves nothing", context do
415
      reference = closing_reference(context, @other_sha)
416
      {:ok, closed} = Issues.update_issue(context.issue, %{"state" => "closed"})
417
418
      assert {:ok, claim} = CompletionClaims.submit(context.assignment, :agent, evidence(context))
419
420
      assert claim.state == "accepted"
421
      refute claim.closed
422
      assert "closure_withheld:issue_already_closed" in claim.reasons
423
424
      # The person's attribution survives untouched: the trailer close is still
425
      # the trailer close.
426
      assert [%ClosingReference{id: id, closed_by_user_id: user_id}] =
427
               ClosingReferences.for_issue(closed)
428
429
      assert id == reference.id
430
      assert user_id == context.user.id
431
    end
432
433
    test "the database refuses a close on a non-accepted verdict, changeset or not",
434
         context do
435
      # The changeset refusal below is what makes the rule legible; this is
436
      # what makes it true of every row. A writer that skips the changeset
437
      # entirely still cannot record a close it did not earn.
438
      assert_raise Postgrex.Error, ~r/issue_completion_claims_close_requires_accepted/, fn ->
439
        Repo.query!(
440
          """
441
          INSERT INTO issue_completion_claims
442
            (id, repository_id, issue_id, assignment_id, revision, state, reasons,
443
             criteria, closed, closed_at, inserted_at, updated_at)
444
          VALUES ($1, $2, $3, $4, $5, 'incomplete', ARRAY[]::varchar[], ARRAY[]::jsonb[],
445
                  true, now(), now(), now())
446
          """,
447
          [
448
            Ecto.UUID.dump!(Ecto.UUID.generate()),
449
            Ecto.UUID.dump!(context.repository.id),
450
            context.issue.id,
451
            Ecto.UUID.dump!(context.assignment.id),
452
            @sha
453
          ]
454
        )
455
      end
456
    end
457
458
    test "a claim cannot record a close it did not make", context do
459
      changeset =
460
        CompletionClaim.changeset(%CompletionClaim{}, %{
461
          "repository_id" => context.repository.id,
462
          "issue_id" => context.issue.id,
463
          "assignment_id" => context.assignment.id,
464
          "revision" => @sha,
465
          "state" => "incomplete",
466
          "closed" => true,
467
          "closed_at" => DateTime.utc_now()
468
        })
469
470
      refute changeset.valid?
471
      assert {"requires an accepted outcome", _meta} = changeset.errors[:closed]
472
    end
473
  end
474
475
  describe "the claim is not a work record" do
476
    test "it stores no prompt, no report, no budget, and no output", _context do
477
      fields = CompletionClaim.__schema__(:fields)
478
479
      for forbidden <- [:prompt, :report, :budget, :budget_snapshot, :output, :steps, :objective] do
480
        refute forbidden in fields, "#{forbidden} would make this a second work record"
481
      end
482
    end
483
484
    test "a private repository's evidence reference stays out of the projection", _context do
485
      user = repository_user_fixture("private-claimant")
486
      repository = repository_with_member_fixture(user, %{visibility: "private"}, "owner")
487
488
      {:ok, issue} =
489
        Issues.create_issue(repository, %{title: "Private work", body: @scoped_body})
490
491
      context =
492
        with_qualified_attempt(%{user: user, repository: repository, issue: issue})
493
494
      opt_in(context)
495
496
      assert {:ok, claim} = submit(context)
497
498
      assert claim.state == "accepted"
499
500
      assert [%{"criterion" => @criterion, "evidence" => nil, "visibility" => "private"}] =
501
               claim.criteria
502
    end
503
  end
504
505
  # ── fixtures ────────────────────────────────────────────────────────────
506
507
  defp submit(context, evidence_id \\ nil) do
508
    CompletionClaims.submit(context.assignment, :agent, evidence(context, evidence_id))
509
  end
510
511
  defp evidence(context, evidence_id \\ nil) do
512
    %{evidence: [%{criterion: @criterion, evidence_id: evidence_id || context.evidence.id}]}
513
  end
514
515
  defp opt_in(context, flags \\ [agents_enabled: true, verified_closing_enabled: true]) do
516
    {:ok, policy} = CompletionClaims.set_policy(context.repository, Map.new(flags), context.user)
517
    policy
518
  end
519
520
  # An attempt that finished on @sha, a check result published for those exact
521
  # bytes by somebody other than the requester, and the evidence edge #148
522
  # writes between them.
523
  defp with_qualified_attempt(context) do
524
    assignment = attempt(context, @sha, "completed", budget_snapshot())
525
    context = Map.put(context, :assignment, assignment)
526
    result = check_result(context, @sha, "succeeded", "coverage")
527
    [entry] = Evidence.record_check_result(result)
528
529
    context
530
    |> Map.put(:check_result, result)
531
    |> Map.put(:evidence, entry)
532
  end
533
534
  defp qualification(context, sha, status, name) do
535
    [entry] = Evidence.record_check_result(check_result(context, sha, status, name))
536
    entry
537
  end
538
539
  defp check_result(%{repository: repository}, sha, status, name) do
540
    %CheckResult{repository_id: repository.id}
541
    |> CheckResult.changeset(%{
542
      name: name,
543
      commit_sha: sha,
544
      artifact_digest: @digest,
545
      status: status
546
    })
547
    |> Repo.insert!()
548
  end
549
550
  defp build_receipt(%{repository: repository}, sha, status) do
551
    %BuildReceipt{}
552
    |> BuildReceipt.start_changeset(%{
553
      repo: repository.storage_key,
554
      sha: sha,
555
      target_id: Ecto.UUID.generate()
556
    })
557
    |> Ecto.Changeset.put_change(:status, status)
558
    |> Repo.insert!()
559
  end
560
561
  defp deployment_run(%{repository: repository, user: user}, state) do
562
    _environment = OpenAgents.DeploymentsFixtures.environment_fixture(repository, user)
563
    run = OpenAgents.DeploymentsFixtures.run_fixture(repository, user, %{"commit_sha" => @sha})
564
565
    {1, _rows} =
566
      Repo.update_all(
567
        from(r in OpenAgents.Deployments.Run, where: r.id == ^run.id),
568
        set: [state: state]
569
      )
570
571
    Repo.get!(OpenAgents.Deployments.Run, run.id)
572
  end
573
574
  defp deploy_receipt(%{repository: repository}, sha, result) do
575
    %DeployReceipt{}
576
    |> DeployReceipt.changeset(%{
577
      repo: repository.storage_key,
578
      sha: sha,
579
      target_id: Ecto.UUID.generate(),
580
      result: result,
581
      deployment_type: "direct_load"
582
    })
583
    |> Repo.insert!()
584
  end
585
586
  defp closing_reference(%{repository: repository, issue: issue, user: user}, sha) do
587
    %ClosingReference{}
588
    |> ClosingReference.changeset(%{
589
      repository_id: repository.id,
590
      issue_id: issue.id,
591
      commit_sha: sha,
592
      principal: "user:#{user.id}",
593
      verb: "closes",
594
      closed: true,
595
      closed_by_user_id: user.id
596
    })
597
    |> Repo.insert!()
598
  end
599
600
  defp attempt(%{repository: repository, issue: issue, user: user}, commit, state, budget) do
601
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
602
603
    %Assignment{}
604
    |> Assignment.changeset(%{
605
      target_kind: "computer",
606
      machine_id: paired_machine(user, "agent-#{System.unique_integer([:positive])}").id,
607
      repository_id: repository.id,
608
      issue_id: issue.id,
609
      requesting_principal: %{"type" => "user", "id" => user.id},
610
      branch: "agent/issue-#{issue.number}",
611
      state: state,
612
      terminal_commit: commit,
613
      work_job_id: budget && work_job(budget).id,
614
      admitted_at: now,
615
      started_at: now,
616
      finished_at: now,
617
      deadline_at: DateTime.add(now, 3600, :second)
618
    })
619
    |> Repo.insert!()
620
  end
621
622
  defp budget_snapshot, do: %{"tokens" => 100_000, "seconds" => 900}
623
624
  defp work_job(budget) do
625
    key = "claim-job-#{System.unique_integer([:positive])}"
626
    {:ok, conversation} = Conversations.ensure_conversation(key)
627
    owner = Conversations.get_conversation_owner!(conversation)
628
629
    {:ok, job} =
630
      Work.create_job(%{
631
        conversation_id: conversation.id,
632
        owner_visitor_id: owner.id,
633
        surface: "text",
634
        goal: "close the issue from a verified outcome",
635
        budget_snapshot: budget
636
      })
637
638
    job
639
  end
640
641
  defp paired_machine(user, name) do
642
    {:ok, %{code: code}} =
643
      Machines.start_pairing(%{
644
        "name" => name,
645
        "tier" => "curated",
646
        "platform" => "linux-x64",
647
        "agent_version" => "0.1.0",
648
        "roots" => []
649
      })
650
651
    {:ok, machine} = Machines.approve_pairing(user, code)
652
    machine
653
  end
654
655
  defp reload(issue), do: Repo.get!(Issues.Issue, issue.id)
656
end
test/openagents_web/controllers/issue_completion_claim_controller_test.exs added +249

@@ -0,0 +1,249 @@

1
defmodule OpenAgentsWeb.IssueCompletionClaimControllerTest do
2
  @moduledoc """
3
  The one route a completion claim arrives on.
4
5
  The properties under test are about authority and about what the request may
6
  say. An agent may claim only for the attempt it requested. A user may claim
7
  for any attempt in a repository it can write, and its claim is
8
  `not_applicable` because the contract gates agent-authored claims. A path
9
  that names another repository resolves to nothing.
10
  """
11
  use OpenAgentsWeb.ConnCase, async: true
12
13
  import OpenAgents.AccountsFixtures
14
15
  alias OpenAgents.Agents
16
  alias OpenAgents.Conversations
17
  alias OpenAgents.Deployments.CheckResult
18
  alias OpenAgents.Forge.Assignment
19
  alias OpenAgents.Issues
20
  alias OpenAgents.Issues.{CompletionClaims, Evidence}
21
  alias OpenAgents.Machines
22
  alias OpenAgents.Repo
23
  alias OpenAgents.Work
24
25
  @sha String.duplicate("ab", 20)
26
  @digest "sha256:" <> String.duplicate("7c", 32)
27
28
  @scoped_body """
29
  ## Problem
30
31
  A claim has no route to arrive on.
32
33
  ## Scope
34
35
  One endpoint on the issue.
36
37
  ## Acceptance criteria
38
39
  - The route grades and stores the claim.
40
41
  ## Success metrics
42
43
  A client can read back what it claimed.
44
  """
45
46
  @criterion "The route grades and stores the claim."
47
48
  setup do
49
    owner = repository_user_fixture("claim-route-owner")
50
    repository = repository_with_member_fixture(owner, %{}, "owner")
51
52
    {:ok, issue} =
53
      Issues.create_issue(repository, %{title: "Claim over HTTP", body: @scoped_body})
54
55
    {:ok, agent, credential} =
56
      Agents.register(%{
57
        handle: "claim-agent-#{System.unique_integer([:positive])}",
58
        display_name: "Claim agent",
59
        registration_ip: "192.0.2.52"
60
      })
61
62
    assignment = attempt(repository, issue, owner, %{"type" => "agent", "id" => agent.id})
63
    result = check_result(repository, @sha, "succeeded")
64
    [entry] = Evidence.record_check_result(result)
65
66
    %{
67
      owner: owner,
68
      repository: repository,
69
      issue: issue,
70
      agent: agent,
71
      credential: credential,
72
      assignment: assignment,
73
      evidence: entry
74
    }
75
  end
76
77
  test "the agent that requested the attempt closes an opted-in issue", context do
78
    {:ok, _policy} =
79
      CompletionClaims.set_policy(
80
        context.repository,
81
        %{agents_enabled: true, verified_closing_enabled: true},
82
        context.owner
83
      )
84
85
    conn = post_claim(build_conn(), context, "Bearer #{context.credential}")
86
87
    assert %{"claim" => claim, "issue" => %{"state" => "closed"}} = json_response(conn, 201)
88
    assert claim["state"] == "accepted"
89
    assert claim["closed"] == true
90
    assert claim["closed_by_actor"] == "system:accepted-outcome"
91
    assert [%{"criterion" => @criterion}] = claim["criteria"]
92
  end
93
94
  test "an agent that did not request the attempt is refused", context do
95
    {:ok, _other, other_credential} =
96
      Agents.register(%{
97
        handle: "other-claim-agent-#{System.unique_integer([:positive])}",
98
        display_name: "Other agent",
99
        registration_ip: "192.0.2.53"
100
      })
101
102
    conn = post_claim(build_conn(), context, "Bearer #{other_credential}")
103
104
    assert json_response(conn, 403)["code"] == "forbidden"
105
    assert Repo.get!(Issues.Issue, context.issue.id).state == "open"
106
  end
107
108
  test "a user who can write the repository records a not_applicable claim", context do
109
    conn =
110
      build_conn()
111
      |> put_forge_api_token("claim-writer", context.repository)
112
      |> post_claim_conn(context)
113
114
    assert %{"claim" => claim, "issue" => %{"state" => "open"}} = json_response(conn, 201)
115
    assert claim["state"] == "not_applicable"
116
    assert claim["reasons"] == ["human_only_work"]
117
  end
118
119
  test "a user with no write authority is refused", context do
120
    conn =
121
      build_conn()
122
      |> put_forge_api_token("claim-outsider")
123
      |> post_claim_conn(context)
124
125
    assert json_response(conn, 403)["code"] == "forbidden"
126
  end
127
128
  test "a path naming another repository resolves to nothing", context do
129
    elsewhere = repository_with_member_fixture(context.owner, %{}, "owner")
130
131
    conn =
132
      build_conn()
133
      |> put_req_header("authorization", "Bearer #{context.credential}")
134
      |> post(
135
        ~p"/api/v3/repos/#{elsewhere.owner}/#{elsewhere.name}/issues/#{context.issue.number}/completion_claim",
136
        %{
137
          "assignment_id" => context.assignment.id,
138
          "evidence" => [%{"criterion" => @criterion, "evidence_id" => context.evidence.id}]
139
        }
140
      )
141
142
    assert json_response(conn, 404)["code"] == "not_found"
143
  end
144
145
  test "the issue response carries the claim it recorded", context do
146
    {:ok, _policy} =
147
      CompletionClaims.set_policy(
148
        context.repository,
149
        %{agents_enabled: true, verified_closing_enabled: true},
150
        context.owner
151
      )
152
153
    conn = post_claim(build_conn(), context, "Bearer #{context.credential}")
154
    assert json_response(conn, 201)
155
156
    conn =
157
      get(
158
        build_conn(),
159
        ~p"/api/v3/repos/#{context.repository.owner}/#{context.repository.name}/issues/#{context.issue.number}"
160
      )
161
162
    assert %{"openagents" => %{"completion_claims" => [claim]}} = json_response(conn, 200)
163
    assert claim["state"] == "accepted"
164
    assert claim["revision"] == @sha
165
  end
166
167
  # ── fixtures ────────────────────────────────────────────────────────────
168
169
  defp post_claim(conn, context, authorization) do
170
    conn
171
    |> put_req_header("authorization", authorization)
172
    |> post_claim_conn(context)
173
  end
174
175
  defp post_claim_conn(conn, context) do
176
    post(
177
      conn,
178
      ~p"/api/v3/repos/#{context.repository.owner}/#{context.repository.name}/issues/#{context.issue.number}/completion_claim",
179
      %{
180
        "assignment_id" => context.assignment.id,
181
        "evidence" => [%{"criterion" => @criterion, "evidence_id" => context.evidence.id}]
182
      }
183
    )
184
  end
185
186
  defp check_result(repository, sha, status) do
187
    %CheckResult{repository_id: repository.id}
188
    |> CheckResult.changeset(%{
189
      name: "route-coverage",
190
      commit_sha: sha,
191
      artifact_digest: @digest,
192
      status: status
193
    })
194
    |> Repo.insert!()
195
  end
196
197
  defp attempt(repository, issue, user, principal) do
198
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
199
200
    %Assignment{}
201
    |> Assignment.changeset(%{
202
      target_kind: "computer",
203
      machine_id: paired_machine(user).id,
204
      repository_id: repository.id,
205
      issue_id: issue.id,
206
      requesting_principal: principal,
207
      branch: "agent/issue-#{issue.number}",
208
      state: "completed",
209
      terminal_commit: @sha,
210
      work_job_id: work_job().id,
211
      admitted_at: now,
212
      started_at: now,
213
      finished_at: now,
214
      deadline_at: DateTime.add(now, 3600, :second)
215
    })
216
    |> Repo.insert!()
217
  end
218
219
  defp work_job do
220
    key = "claim-route-job-#{System.unique_integer([:positive])}"
221
    {:ok, conversation} = Conversations.ensure_conversation(key)
222
    owner = Conversations.get_conversation_owner!(conversation)
223
224
    {:ok, job} =
225
      Work.create_job(%{
226
        conversation_id: conversation.id,
227
        owner_visitor_id: owner.id,
228
        surface: "text",
229
        goal: "claim completion over the route",
230
        budget_snapshot: %{"tokens" => 50_000}
231
      })
232
233
    job
234
  end
235
236
  defp paired_machine(user) do
237
    {:ok, %{code: code}} =
238
      Machines.start_pairing(%{
239
        "name" => "claim-route-machine-#{System.unique_integer([:positive])}",
240
        "tier" => "curated",
241
        "platform" => "linux-x64",
242
        "agent_version" => "0.1.0",
243
        "roots" => []
244
      })
245
246
    {:ok, machine} = Machines.approve_pairing(user, code)
247
    machine
248
  end
249
end

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