Let an issue bound its work, and say what the agent did without saying it

eb48e65a444c · AtlantisPleb · · parent c4562df44726

Let an issue bound its work, and say what the agent did without saying it

Four things were left on #10 after the release link landed, and three of
them were policy decisions nobody had made yet.

**An issue now bounds the work started from it.** Starting agent work *from*
an issue differs from starting it *beside* an issue only if the issue is what
bounds it, and it was not: the objective came from the issue on the page and
from the caller in the API, and the wall clock came from the caller on both.
`Issues.WorkScope` answers both halves and `Assignments.create/1` applies it,
which is the one admission point the page and the API share.

The limit is where the issue's own text decides what the work is worth.
`OUTCOME-001` says a claim against an issue that does not state its problem,
scope, acceptance criteria, and success metrics is `incomplete` and can never
be accepted — so an unscoped issue cannot buy delivery, because no amount of
agent time on it can produce a graded outcome. It buys a short exploratory
window; a scoped issue buys the hour. A caller may narrow that and may not
widen it, which is the difference between a bound and a suggestion. The bound
outlives the request: it becomes `wall_clock_ms` in the job's
`budget_snapshot`, which is the `budget` field `OUTCOME-001` later grades the
attempt's binding against. The four sections are read by the grader's own
parser, because a second one could disagree about whether an issue is scoped
and then an attempt could buy a budget for work its own grader would refuse.

**An issue says an agent trajectory exists and never publishes one.** An ATIF
document is the whole run: every prompt, every tool call's raw arguments,
every result. `WorkDisclosure` already refuses `work_jobs.goal` and
`delegation` because publishing them restates repository contents where the
repository's gate does not reach, and a trajectory restates more than both.
So the new `trace` family's ladder stops at the digest: `pulse` says a
trajectory of this shape exists, `ledger` adds the digest a holder of the
bytes can verify against, and `glass` adds nothing — not for an operator, and
not for the uploader, who reads their own document through their own account.
This is deliberately not the readback `EXIT-001` publishes as missing, and it
must not be mistaken for one.

Two gates, both required. Consent is `traces.visibility`, uploader-set and
`dark` by default, and no viewer's rung raises it. Repository access runs
first, as it already does for receipts: consenting to publish your own
trajectory is not consenting to publish which attempts ran in somebody else's
private repository. The tier is the lower of the two and is clamped again by
the viewer, so adding a gate can only remove fields.

A trace names the attempt, not the issue. The attempt already records which
issue and which repository it was admitted against, so the issue gains its
traces without holding a second work record — the shape `work_job_id` already
uses. Only the account that requested the attempt may bind, and anyone else
is refused rather than having the field dropped: a caller that believed it
filed evidence should not be told it succeeded.

**The verdict is on the page.** `OUTCOME-001` closes an issue from a graded
claim and has since `#150`, and the page showed none of it. The issue rail
now renders what `Issues.Activity` returns for that same reader — the release
that carried it, the receipt families, the trace line, and the completion
claim's verdict — plus the bound an attempt would inherit, said before anyone
starts one rather than at grading time.

The schedule is exhaustive over `traces` in both directions, so a column
added there fails until somebody decides which rung it belongs on. Proved on
a public repository, where repository authority admits everybody and the two
gates are the only thing between an anonymous reader and a digest, and again
on a private one, where authority is still the stronger gate.

Closes #10

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

Deploy story

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

pushed
by user · WAL seq 397 · 2026-08-25T15:24:56.351803Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/forge/assignments.ex
  • modified lib/openagents/issues/activity.ex
  • modified lib/openagents/issues/completion_claims.ex
  • added lib/openagents/issues/trace_disclosure.ex
  • added lib/openagents/issues/work_scope.ex
  • modified lib/openagents/traces.ex
  • modified lib/openagents/traces/trace.ex
  • modified lib/openagents/transparency/work_disclosure.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/controllers/issue_json.ex
  • modified lib/openagents_web/controllers/trace_controller.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825160000_link_traces_to_forge_assignments.exs
  • added test/openagents/issues/trace_disclosure_test.exs
  • added test/openagents/issues/work_scope_test.exs
  • modified test/openagents/transparency/work_disclosure_test.exs
  • modified test/openagents_web/live/issue_show_live_test.exs

Diff

19 files changed, +1605 -90

INVARIANTS.md modified +87 -3

@@ -5330,6 +5330,32 @@ number, title, and body rather than typed beside it, so what the agent was

5330 5330
asked to do and what the issue asked for cannot drift. The body is clamped well
5331 5331
inside the 8,000-byte prompt bound rather than refused for being long.
5332 5332
5333
**The scope and the limits are the issue's, at the admission rather than at a
5334
surface.** The objective moved out of `IssueShowLive` into
5335
`OpenAgents.Issues.WorkScope`, which `Assignments.create/1` applies, so the API
5336
route gets what the page always had: a caller that supplies no prompt gets the
5337
issue's, and the prompt names the branch the attempt was actually admitted on
5338
rather than the one that was suggested.
5339
5340
The wall clock is read from the issue too. `OUTCOME-001` says a claim against
5341
an issue that does not state its problem, scope, acceptance criteria, and
5342
success metrics is `incomplete` and can never be accepted — so an unscoped
5343
issue cannot buy delivery, because no amount of agent time on it can produce a
5344
graded outcome. It buys a short exploratory window instead; an issue that
5345
states all four buys the full hour. The deadline is the earliest of that
5346
window, the deployment's TTL, and whatever the caller asked for: narrowing is
5347
admitted, widening is not, because a bound the requester can raise is a
5348
suggestion.
5349
5350
The bound outlives the request. It becomes `timeout_ms` on the delegation, then
5351
`wall_clock_ms` in `work_jobs.budget_snapshot`, which is the `budget` field
5352
`OUTCOME-001` grades the attempt's binding against — so the issue's own scope
5353
is what the attempt is later held to, through records rather than through a
5354
parameter that was true once. The four sections are read by
5355
`OpenAgents.Issues.CompletionClaims.sections/1`, the grader's own parser: a
5356
second one could disagree about whether an issue is scoped, and then an attempt
5357
could buy a budget for work its own grader would refuse.
5358
5333 5359
**The target's own declarations decide, never the request.** The working
5334 5360
directory is chosen from the computer's `roots` and the agent from its probed
5335 5361
`acp_agents`; a value outside either is replaced by one the computer declared

@@ -5397,14 +5423,71 @@ and a crafted event cancels nothing. An attempt that started and never finished

5397 5423
renders as started and nothing more: the timeline invents no terminal event.
5398 5424
5399 5425
Evidence: `OpenAgentsWeb.IssueShowLive`, `OpenAgents.Forge.Assignments`,
5400
`OpenAgents.ComputerAgentJobs`,
5426
`OpenAgents.Issues.WorkScope`, `OpenAgents.ComputerAgentJobs`,
5401 5427
`test/openagents_web/live/issue_start_work_live_test.exs`,
5402
`test/openagents_web/live/issue_live_work_test.exs`, and
5428
`test/openagents_web/live/issue_live_work_test.exs`,
5429
`test/openagents/issues/work_scope_test.exs`, and
5403 5430
`test/openagents/forge/assignment_test.exs`.
5404 5431
5405 5432
(Amended 2026-08-24, issue #147: an attempt was narrated in prose and shown
5406 5433
without moving. It is now shown, live, and narrated nowhere.)
5407 5434
5435
(Amended 2026-08-25, issue #10: the objective was read from the issue on the
5436
page and from the caller in the API, and the wall clock came from the caller on
5437
both. Both now come from the issue at the admission, and a caller may narrow
5438
the bound but not widen it.)
5439
5440
### ISSUE-005 — An issue says an agent trajectory exists and never publishes one
5441
5442
Status: Current
5443
5444
An ATIF trace is the whole run: every prompt, every assistant message, every
5445
tool call's raw arguments, every tool result.
5446
`OpenAgents.Transparency.WorkDisclosure` already refuses `work_jobs.goal`,
5447
`context_hint`, and `delegation` because publishing them would restate the
5448
contents of a repository in a place the repository's own gate does not cover,
5449
and a trajectory restates more of it than all three. So the `trace` family's
5450
schedule stops at the digest.
5451
5452
**No rung returns the document.** `pulse` publishes that a trajectory exists,
5453
its schema version, its step count, and when it was recorded. `ledger` adds the
5454
digest — by which a holder of the bytes can prove they hold *these* bytes — and
5455
the size. `glass` adds nothing. Not for an operator, and not for the account
5456
that uploaded it: an owner reads their own document through their own account,
5457
and this schedule governs what an *issue* discloses. `document` is the source
5458
column of two counted facts and of no third, which
5459
`OpenAgents.Transparency.WorkDisclosureTest` enforces against the live schema
5460
in both directions.
5461
5462
This is therefore not a readback, and must not be mistaken for one. `EXIT-001`
5463
publishes to anonymous callers that `POST /api/v1/traces` accepts an upload and
5464
no route reads one back. That gap stays open and stays disclosed.
5465
5466
**Two gates, and both must pass.** *Consent* is `traces.visibility`, which the
5467
uploader sets and which defaults to `dark`; a `dark` trace is invisible on an
5468
issue to everyone, an operator included, and no viewer's own rung raises it.
5469
*Repository access* is applied first by `OpenAgents.Issues.Activity`, exactly
5470
as it is to receipts: a reader who cannot read the repository sees no traces
5471
however widely the uploader consented, because consenting to publish your own
5472
trajectory is not consenting to publish which attempts ran in somebody else's
5473
private repository. The effective tier is the lower of the two and is then
5474
clamped again by the viewer, so adding a gate can only remove fields.
5475
5476
**The binding is checked, not believed.** A trace names the attempt it is a
5477
trajectory of, never the issue: the attempt already records which issue and
5478
which repository it was admitted against, so the issue gains its traces and the
5479
repository gate gains something to act on without the issue holding a second
5480
work record. Only the account named as the attempt's requesting principal may
5481
bind, and any other is refused with `trace_assignment_forbidden` rather than
5482
having the field dropped — a caller that believed it was filing evidence should
5483
not be told it succeeded.
5484
5485
Evidence: `OpenAgents.Issues.TraceDisclosure`, `OpenAgents.Traces`,
5486
`OpenAgents.Transparency.WorkDisclosure`,
5487
`test/openagents/issues/trace_disclosure_test.exs`,
5488
`test/openagents/transparency/work_disclosure_test.exs`, and
5489
`test/openagents_web/live/issue_show_live_test.exs`.
5490
5408 5491
### CAPACITY-001 — Capacity is a bounded, owner-safe quantity projection
5409 5492
5410 5493
Status: Current

@@ -5783,4 +5866,5 @@ contract; the invariant prose above defines the assertion, not the filename.

5783 5866
| FORUM-001 | `test/openagents/forum/legacy_surface_test.exs`, `test/openagents_web/live/forum_live_test.exs`, `test/openagents_web/route_authority_test.exs`, `test/openagents_web/sidebar_state_test.exs` |
5784 5867
| ISSUE-002 | `test/openagents/issues/task_list_test.exs`, `test/openagents/issues/task_references_test.exs`, `test/openagents_web/live/issue_show_live_test.exs` |
5785 5868
| ISSUE-003 | `test/openagents/issues/evidence_test.exs`, `test/openagents_web/controllers/issue_controller_test.exs` |
5786
| ISSUE-004 | `test/openagents_web/live/issue_live_work_test.exs`, `test/openagents_web/live/issue_start_work_live_test.exs` |
5869
| ISSUE-004 | `test/openagents_web/live/issue_live_work_test.exs`, `test/openagents_web/live/issue_start_work_live_test.exs`, `test/openagents/issues/work_scope_test.exs` |
5870
| ISSUE-005 | `test/openagents/issues/trace_disclosure_test.exs`, `test/openagents/transparency/work_disclosure_test.exs`, `test/openagents_web/live/issue_show_live_test.exs` |
lib/openagents/forge/assignments.ex modified +71 -7

@@ -5,6 +5,16 @@ defmodule OpenAgents.Forge.Assignments do

5 5
  Assignment credentials are digest-only and authenticate as an `:assignment`
6 6
  forge principal. Their repository and branch scope is read from the durable
7 7
  assignment snapshot, never from request metadata.
8
9
  ## The issue is the bound
10
11
  `create/1` is the one admission point for agent work on an issue, reached by
12
  the issue page and by `POST /api/v1/.../assignments` alike, so it is where
13
  `OpenAgents.Issues.WorkScope` applies. The objective and the wall clock come
14
  from the issue: a caller that supplies neither gets the issue's, and a caller
15
  that supplies a deadline may narrow the issue's bound but never widen it.
16
  Bounded work means bounded by the requested outcome, not by whoever asked for
17
  it.
8 18
  """
9 19
10 20
  import Ecto.Query

@@ -14,7 +24,7 @@ defmodule OpenAgents.Forge.Assignments do

14 24
  alias OpenAgents.Box.ConversationBox
15 25
  alias OpenAgents.BoxRuns
16 26
  alias OpenAgents.Forge.{Assignment, AssignmentCredential, AssignmentCredentialVault}
17
  alias OpenAgents.Issues.{Evidence, Issue}
27
  alias OpenAgents.Issues.{Evidence, Issue, WorkScope}
18 28
  alias OpenAgents.Repo
19 29
  alias OpenAgents.Conversations
20 30
  alias OpenAgents.Repositories.Repository

@@ -51,7 +61,32 @@ defmodule OpenAgents.Forge.Assignments do

51 61
         {:ok, assignment, plaintext} <-
52 62
           persist_assignment(target_kind, target, repository, issue, branch, principal, attrs) do
53 63
      _ = announce(assignment)
54
      start_target(assignment, target, target_kind, plaintext, attrs, owner, conversation)
64
65
      start_target(
66
        assignment,
67
        target,
68
        target_kind,
69
        plaintext,
70
        scoped(attrs, issue, assignment.branch),
71
        owner,
72
        conversation
73
      )
74
    end
75
  end
76
77
  # The issue writes the objective when the caller does not. A caller that
78
  # supplies one is still bounded — by the branch its credential is scoped to,
79
  # by the repository the issue lives in, and by the wall clock `deadline/3`
80
  # already took from the issue — but the common case is that nobody should be
81
  # composing a second version of what the issue already says.
82
  #
83
  # The branch comes from the persisted attempt rather than from the request,
84
  # because that is the ref the credential was minted for. A prompt that named
85
  # any other would be telling an agent to push where it cannot.
86
  defp scoped(attrs, issue, branch) do
87
    case attrs[:prompt] || attrs["prompt"] do
88
      prompt when is_binary(prompt) and prompt != "" -> attrs
89
      _absent -> Map.put(attrs, "prompt", WorkScope.objective(issue, branch))
55 90
    end
56 91
  end
57 92

@@ -149,13 +184,29 @@ defmodule OpenAgents.Forge.Assignments do

149 184
  def attempts_for_issue(%Issue{id: id}, viewer), do: attempts_for_issue(id, viewer)
150 185
151 186
  def attempts_for_issue(issue_id, viewer) when is_integer(issue_id) do
187
    issue_id
188
    |> attempt_records_for_issue()
189
    |> Enum.map(&attempt_summary(&1, viewer))
190
    |> Enum.reject(&is_nil/1)
191
  end
192
193
  @doc """
194
  The attempt rows for `issue`, oldest first, with their consent links loaded.
195
196
  Unprojected, so a caller that has to clamp a *different* record against an
197
  attempt's real tier can do so. Every caller that shows an attempt to a reader
198
  still goes through `attempt_summary/2`; this exists for records that hang off
199
  an attempt and carry a second gate of their own, such as an ATIF trace.
200
  """
201
  @spec attempt_records_for_issue(Issue.t() | integer()) :: [Assignment.t()]
202
  def attempt_records_for_issue(%Issue{id: id}), do: attempt_records_for_issue(id)
203
204
  def attempt_records_for_issue(issue_id) when is_integer(issue_id) do
152 205
    Assignment
153 206
    |> where([assignment], assignment.issue_id == ^issue_id)
154 207
    |> order_by([assignment], asc: assignment.admitted_at, asc: assignment.id)
155 208
    |> preload([:artifact_link, :work_job])
156 209
    |> Repo.all()
157
    |> Enum.map(&attempt_summary(&1, viewer))
158
    |> Enum.reject(&is_nil/1)
159 210
  end
160 211
161 212
  @doc """

@@ -459,7 +510,7 @@ defmodule OpenAgents.Forge.Assignments do

459 510
    Repo.transaction(fn ->
460 511
      now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
461 512
      id = Ecto.UUID.generate()
462
      deadline = deadline(attrs, now)
513
      deadline = deadline(attrs, issue, now)
463 514
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
464 515
      plaintext = @prefix <> id <> "." <> secret
465 516

@@ -735,16 +786,29 @@ defmodule OpenAgents.Forge.Assignments do

735 786
  defp idempotency_key(attrs),
736 787
    do: attrs[:idempotency_key] || attrs["idempotency_key"] || Ecto.UUID.generate()
737 788
738
  defp deadline(attrs, now) do
789
  # Three ceilings, and the deadline is the earliest of them: the deployment's
790
  # own TTL, the wall clock the issue's scope buys, and whatever the caller
791
  # asked for. A caller may narrow — a short run on a well-understood issue is
792
  # a reasonable thing to ask for — and a caller may not widen, because then
793
  # the bound would come from whoever pressed the button rather than from the
794
  # outcome that was requested.
795
  defp deadline(attrs, issue, now) do
739 796
    configured = attrs[:deadline_at] || attrs["deadline_at"]
740 797
    ttl = Application.get_env(:openagents, :box_api, [])[:ttl_seconds] || 3_600
741
    maximum = DateTime.add(now, ttl, :second)
798
799
    maximum =
800
      earlier(
801
        DateTime.add(now, ttl, :second),
802
        DateTime.add(now, WorkScope.wall_clock_ms(issue), :millisecond)
803
      )
742 804
743 805
    if match?(%DateTime{}, configured) and DateTime.compare(configured, maximum) == :lt,
744 806
      do: configured,
745 807
      else: maximum
746 808
  end
747 809
810
  defp earlier(left, right), do: if(DateTime.compare(left, right) == :lt, do: left, else: right)
811
748 812
  defp claim_error(changeset) do
749 813
    if Enum.any?(changeset.errors, fn {field, _} -> field == :issue_id end),
750 814
      do: :assignment_issue_claimed,
lib/openagents/issues/activity.ex modified +35 -12

@@ -19,28 +19,40 @@ defmodule OpenAgents.Issues.Activity do

19 19
  release's revision contains the issue's commits. It reads the same closing
20 20
  references this module already reads, so the two halves of the answer can
21 21
  never disagree about which commits the issue claims.
22
23
  `traces` answers the fourth question — what the agent actually did — and
24
  answers it deliberately narrowly. It is the ATIF trajectories uploaded
25
  against this issue's attempts, projected through
26
  `OpenAgents.Issues.TraceDisclosure`, which publishes that a trajectory exists
27
  and never publishes one. Both gates apply: the uploader's consent, which
28
  defaults to withholding, and the repository authority already checked here.
22 29
  """
23 30
24 31
  alias OpenAgents.Accounts.User
25 32
  alias OpenAgents.Forge
26
  alias OpenAgents.Issues.{ClosingReferences, Issue, Releases}
33
  alias OpenAgents.Forge.Assignments
34
  alias OpenAgents.Issues.{ClosingReferences, Issue, Releases, TraceDisclosure}
27 35
  alias OpenAgents.Repositories
28 36
  alias OpenAgents.Repositories.Repository
29 37
  alias OpenAgents.Threads
38
  alias OpenAgents.Transparency.WorkDisclosure
30 39
31 40
  @doc """
32
  The threads, receipts, and releases that name `issue` and that `reader` may
33
  read.
41
  The threads, receipts, releases, and traces that name `issue` and that
42
  `reader` may read.
34 43
35
  Returns `%{threads: [...], receipts: [...], releases: %{...}}`. An issue with
36
  no closing references or no matching receipts returns an empty `:receipts`
37
  list, an issue with no readable threads returns an empty `:threads` list, and
38
  an issue no release carried returns `OpenAgents.Issues.Releases.empty/0`.
44
  Returns `%{threads: [...], receipts: [...], releases: %{...}, traces:
45
  [...]}`. An issue with no closing references or no matching receipts returns
46
  an empty `:receipts` list, an issue with no readable threads returns an empty
47
  `:threads` list, an issue no release carried returns
48
  `OpenAgents.Issues.Releases.empty/0`, and an issue whose attempts recorded no
49
  trace a reader may see returns an empty `:traces` list.
39 50
  """
40 51
  @spec for_issue(Issue.t(), User.t() | nil) :: %{
41 52
          threads: [Threads.Thread.t()],
42 53
          receipts: [map()],
43
          releases: Releases.t()
54
          releases: Releases.t(),
55
          traces: [TraceDisclosure.projection()]
44 56
        }
45 57
  def for_issue(%Issue{} = issue, %User{} = reader), do: do_for_issue(issue, reader)
46 58
  def for_issue(%Issue{} = issue, _reader), do: do_for_issue(issue, nil)

@@ -50,15 +62,26 @@ defmodule OpenAgents.Issues.Activity do

50 62
    repository = visible_repository(issue, reader)
51 63
    threads = if reader, do: Threads.list_for_issue(issue, reader), else: []
52 64
53
    {receipts, releases} =
65
    {receipts, releases, traces} =
54 66
      if repository do
55 67
        {issue |> ClosingReferences.for_issue() |> receipts_for_references(repository),
56
         Releases.for_issue(repository, issue)}
68
         Releases.for_issue(repository, issue), traces_for_issue(issue, repository, reader)}
57 69
      else
58
        {[], Releases.empty()}
70
        {[], Releases.empty(), []}
59 71
      end
60 72
61
    %{threads: threads, receipts: receipts, releases: releases}
73
    %{threads: threads, receipts: receipts, releases: releases, traces: traces}
74
  end
75
76
  # The attempts are read unclamped and then thrown away: what a reader gets is
77
  # the trace projection, and the attempt itself is published by
78
  # `Assignments.attempt_summary/2` on its own family. Reading them whole here
79
  # is what lets `TraceDisclosure` clamp against the attempt's real tier rather
80
  # than against an already-clamped shadow of it.
81
  defp traces_for_issue(%Issue{} = issue, %Repository{} = repository, reader) do
82
    issue
83
    |> Assignments.attempt_records_for_issue()
84
    |> TraceDisclosure.for_attempts(WorkDisclosure.viewer(repository, reader))
62 85
  end
63 86
64 87
  defp visible_repository(%Issue{repository_id: repository_id}, reader) do
lib/openagents/issues/completion_claims.ex modified +15 -2

@@ -567,7 +567,20 @@ defmodule OpenAgents.Issues.CompletionClaims do

567 567
  # grade at all has been answered by the stored policy. It reads Markdown ATX
568 568
  # headings and nothing else: a section is present when its heading exists and
569 569
  # some non-blank line follows it before the next heading.
570
  defp sections(body) when is_binary(body) do
570
  @doc """
571
  The accepted-outcome sections `body` states, as `%{section => lines | nil}`.
572
573
  This is the one reader of an issue's four sections. `OUTCOME-001` grades a
574
  claim against them and `OpenAgents.Issues.WorkScope` bounds an attempt by
575
  them, and both call here rather than parsing headings again: a second parser
576
  could disagree about whether an issue is scoped, and then an attempt could
577
  buy a budget for work that can never be accepted.
578
579
  A section the body does not state is `nil`, never an empty list, so "absent"
580
  and "present but empty" stay distinguishable.
581
  """
582
  @spec sections(String.t() | nil) :: %{atom() => [String.t()] | nil}
583
  def sections(body) when is_binary(body) do
571 584
    lines = String.split(body, ~r/\r?\n/)
572 585
573 586
    @section_headings

@@ -576,7 +589,7 @@ defmodule OpenAgents.Issues.CompletionClaims do

576 589
    end)
577 590
  end
578 591
579
  defp sections(_body), do: %{}
592
  def sections(_body), do: %{}
580 593
581 594
  defp section_body(lines, headings) do
582 595
    lines
lib/openagents/issues/trace_disclosure.ex added +184

@@ -0,0 +1,184 @@

1
defmodule OpenAgents.Issues.TraceDisclosure do
2
  @moduledoc """
3
  What an issue's readers learn about the ATIF trajectories of its attempts.
4
5
  ## The decision
6
7
  **An issue publishes that a trace exists and what shape it has. It never
8
  publishes the trace.**
9
10
  An ATIF document is the whole run — every prompt, every assistant message,
11
  every tool call's raw arguments, every tool result. Handing that to an issue's
12
  readers would restate the contents of a repository, and the reasoning the
13
  agent did about it, in a place the repository's own gate does not cover.
14
  `OpenAgents.Transparency.WorkDisclosure` already refuses `work_jobs.goal` and
15
  `work_jobs.delegation` for exactly that reason, and a trajectory carries more
16
  of it than either. So the `trace` family's schedule stops at the digest, and
17
  no rung of it returns a step.
18
19
  That is also why this is not a readback. `EXIT-001` publishes, to anonymous
20
  callers, that `POST /api/v1/traces` accepts an upload and no route reads one
21
  back. This projection does not close that gap and must not be mistaken for
22
  closing it: nobody gets a document here, including the account that uploaded
23
  one.
24
25
  ## Two gates, and both must pass
26
27
  Consent and repository access are independent, and neither substitutes for
28
  the other.
29
30
    * **Consent** is `traces.visibility`, which the uploader sets and which
31
      defaults to `dark`. A `dark` trace is invisible on an issue no matter who
32
      is reading — an operator included. Consent is a ceiling this module
33
      never raises, only lowers.
34
35
    * **Repository access** is `OpenAgents.Repositories`, applied by the caller
36
      before this module is reached, exactly as `OpenAgents.Issues.Activity`
37
      applies it to receipts. A reader who cannot read the repository sees no
38
      traces, however widely the uploader consented, because the uploader
39
      consented to publishing their own trajectory and not to publishing which
40
      attempts ran in somebody else's private repository.
41
42
  The effective tier is the lower of the two, then clamped again by the
43
  viewer's own relationship to the repository through
44
  `WorkDisclosure.effective_tier/2`. Lowering twice and raising never is what
45
  makes the composition safe to reason about: adding a gate can only remove
46
  fields.
47
48
  ## Why the trace names the attempt
49
50
  A trace carries `assignment_id`, not `issue_id`. The attempt already records
51
  which issue and which repository it was admitted against, so binding to the
52
  attempt gives the issue its traces and gives the repository gate something to
53
  act on, without the issue gaining a second work record — the same reason
54
  `forge_assignments.work_job_id` points at the execution rather than the issue
55
  pointing at both.
56
  """
57
58
  alias OpenAgents.Forge.Assignment
59
  alias OpenAgents.Traces
60
  alias OpenAgents.Traces.Trace
61
  alias OpenAgents.Transparency
62
  alias OpenAgents.Transparency.WorkDisclosure
63
64
  @family :trace
65
66
  @typedoc "One trace, projected at the tier both gates admit."
67
  @type projection :: %{
68
          required(:assignment_id) => binary(),
69
          required(:tier) => atom(),
70
          optional(atom()) => term()
71
        }
72
73
  @doc "The disclosure family this module projects."
74
  @spec family() :: atom()
75
  def family, do: @family
76
77
  @doc """
78
  The traces of `attempts`, projected for `viewer`.
79
80
  `attempts` are `forge_assignments` rows for one issue, and `viewer` is a
81
  `WorkDisclosure.viewer/2` descriptor — the caller has already applied
82
  repository authority to produce it. Returns one entry per disclosable trace,
83
  oldest first, each naming the attempt it belongs to.
84
85
  A trace whose two gates leave it at `dark` is absent rather than empty: an
86
  empty shell would still say the trajectory exists, which is the disclosure
87
  `dark` is refusing.
88
  """
89
  @spec for_attempts([Assignment.t()], map()) :: [projection()]
90
  def for_attempts([], _viewer), do: []
91
92
  def for_attempts(attempts, viewer) when is_list(attempts) do
93
    by_attempt = Traces.for_assignments(Enum.map(attempts, & &1.id))
94
95
    attempts
96
    |> Enum.flat_map(fn attempt ->
97
      by_attempt
98
      |> Map.get(attempt.id, [])
99
      |> Enum.map(&project(&1, attempt, viewer))
100
    end)
101
    |> Enum.reject(&is_nil/1)
102
  end
103
104
  @doc """
105
  Projects one trace of one attempt for `viewer`, or `nil` at `dark`.
106
107
  The tier is the lower of the uploader's consent and the attempt's own
108
  effective tier, and the projection carries the tier it was taken at so a
109
  reader can tell a withheld field from an absent one.
110
  """
111
  @spec project(Trace.t(), Assignment.t(), map()) :: projection() | nil
112
  def project(%Trace{} = trace, %Assignment{} = attempt, viewer) do
113
    tier = effective_tier(trace, attempt, viewer)
114
115
    case WorkDisclosure.project(@family, source(trace), tier) do
116
      nil ->
117
        nil
118
119
      fields ->
120
        fields
121
        |> Map.put(:assignment_id, attempt.id)
122
        |> Map.put(:tier, tier)
123
    end
124
  end
125
126
  @doc """
127
  The tier both gates admit for `trace` on `attempt`, for `viewer`.
128
129
  Consent is a ceiling: `Transparency.effective_tier/2` clamps the uploader's
130
  own tier by the viewer's, and the attempt's tier clamps it again. The result
131
  is never higher than either input, so a reader admitted to a wide attempt
132
  still gets nothing from a `dark` trace, and a reader of a widely consented
133
  trace still gets nothing from an attempt they may not read.
134
  """
135
  @spec effective_tier(Trace.t(), Assignment.t(), map()) :: atom()
136
  def effective_tier(%Trace{visibility: visibility}, %Assignment{} = attempt, viewer) do
137
    lower(
138
      Transparency.effective_tier(visibility, viewer),
139
      WorkDisclosure.effective_tier(attempt, viewer)
140
    )
141
  end
142
143
  defp lower(left, right) do
144
    if rank(left) <= rank(right), do: left, else: right
145
  end
146
147
  defp rank(:dark), do: 0
148
  defp rank(:pulse), do: 1
149
  defp rank(:ledger), do: 2
150
  defp rank(:glass), do: 3
151
  defp rank(_unknown), do: 0
152
153
  # The two derived fields, read out of the document that is never returned.
154
  # `step_count` counts what ATIF calls steps; a document that carries none, or
155
  # carries something other than a list where steps go, reports zero rather
156
  # than raising, because an uploader's malformed document must not break an
157
  # issue page.
158
  defp source(%Trace{} = trace) do
159
    document = trace.document || %{}
160
161
    %{
162
      id: trace.id,
163
      schema_version: schema_version(document),
164
      step_count: step_count(document),
165
      recorded_at: trace.inserted_at,
166
      digest: trace.digest,
167
      byte_size: trace.byte_size
168
    }
169
  end
170
171
  defp schema_version(document) do
172
    case Map.get(document, "schema_version") do
173
      version when is_binary(version) -> version
174
      _absent -> nil
175
    end
176
  end
177
178
  defp step_count(document) do
179
    case Map.get(document, "steps") do
180
      steps when is_list(steps) -> length(steps)
181
      _absent -> 0
182
    end
183
  end
184
end
lib/openagents/issues/work_scope.ex added +174

@@ -0,0 +1,174 @@

1
defmodule OpenAgents.Issues.WorkScope do
2
  @moduledoc """
3
  The bound an issue sets on an attempt to do it.
4
5
  Starting agent work from an issue is only meaningfully different from
6
  starting agent work beside an issue if the issue is what bounds the work.
7
  Before this module the objective came from the issue on one surface and from
8
  the caller on the other, and the wall clock came from the caller on both —
9
  so two attempts on the same issue could have been asked different questions
10
  and given different amounts of time to answer them.
11
12
  Every attempt now takes its scope and its limits from here, at
13
  `OpenAgents.Forge.Assignments.create/1`, which is the single admission point
14
  both the issue page and the API pass through.
15
16
  ## The scope
17
18
  `objective/1` is the prompt, written from the issue's own title and body. A
19
  caller may not widen it, because an agent asked to do something the issue
20
  does not say is producing work no reader of that issue asked for.
21
22
  `branch/1` is `agent/issue-<number>`. The attempt's credential is scoped to
23
  that one ref, so the branch is part of the bound rather than a convention.
24
25
  ## The limits
26
27
  `wall_clock_ms/1` is where the issue's own text decides what the work is
28
  worth. `OUTCOME-001` says a claim against an issue that does not state its
29
  problem, scope, acceptance criteria, and success metrics is `incomplete` and
30
  can never be accepted. An unscoped issue therefore cannot buy delivery: no
31
  amount of agent time on it can produce a graded outcome. It buys exploration
32
  instead — long enough to read the repository and say what the issue is
33
  missing, and not long enough to spend an hour of a machine on work nothing
34
  can accept.
35
36
  A scoped issue buys the full hour. That number is a ceiling, not a
37
  reservation: `deadline_at` may still be narrowed by the caller, and by the
38
  `:box_api` TTL. What a caller may never do is widen it, which is the whole
39
  difference between a bound and a suggestion.
40
41
  The bound outlives the request. It becomes `timeout_ms` on the delegation,
42
  which becomes `wall_clock_ms` in `work_jobs.budget_snapshot`, which is the
43
  `budget` field `OUTCOME-001` grades the attempt's binding against. So the
44
  issue's own scope is what the attempt is later held to, through records
45
  rather than through a parameter that was true once.
46
47
  The scope is read with `OpenAgents.Issues.CompletionClaims.sections/1` — the
48
  grader's own parser, not a second one. If the two ever disagreed about
49
  whether an issue is scoped, an attempt could buy a budget for work its own
50
  grader would refuse.
51
  """
52
53
  alias OpenAgents.Issues.CompletionClaims
54
  alias OpenAgents.Issues.Issue
55
  alias OpenAgents.AcceptedOutcome
56
57
  # `ComputerAgentJobs` refuses a prompt over 8,000 bytes. The body is clamped
58
  # well inside that so a long issue is trimmed rather than refused.
59
  @maximum_body_bytes 6_000
60
61
  # The ceiling `ComputerAgentJobs` already enforces for any delegation.
62
  @scoped_ms 3_600_000
63
64
  # Long enough to read a repository and report what the issue does not say.
65
  @unscoped_ms 900_000
66
67
  @typedoc "What an issue bounds an attempt on it to."
68
  @type t :: %{
69
          branch: String.t(),
70
          objective: String.t(),
71
          wall_clock_ms: pos_integer(),
72
          scoped?: boolean(),
73
          missing_sections: [atom()]
74
        }
75
76
  @doc "The whole bound, for one issue."
77
  @spec for_issue(Issue.t()) :: t()
78
  def for_issue(%Issue{} = issue) do
79
    missing = missing_sections(issue)
80
81
    %{
82
      branch: branch(issue),
83
      objective: objective(issue),
84
      wall_clock_ms: if(missing == [], do: @scoped_ms, else: @unscoped_ms),
85
      scoped?: missing == [],
86
      missing_sections: missing
87
    }
88
  end
89
90
  @doc "The one branch an attempt on `issue` may write."
91
  @spec branch(Issue.t()) :: String.t()
92
  def branch(%Issue{number: number}), do: "agent/issue-#{number}"
93
94
  @doc """
95
  The prompt for an attempt on `issue`, written from the issue.
96
97
  It names the branch the credential is scoped to, so an agent is not left to
98
  discover its one writable ref by being refused. `branch` is the branch the
99
  attempt was actually admitted on, which is `branch/1` unless the requester
100
  named another; passing it is what keeps the prompt from telling an agent to
101
  push somewhere its credential cannot reach.
102
  """
103
  @spec objective(Issue.t(), String.t() | nil) :: String.t()
104
  def objective(issue, branch \\ nil)
105
106
  def objective(%Issue{} = issue, requested) do
107
    body = issue.body || ""
108
    body = if byte_size(body) > @maximum_body_bytes, do: clamp(body), else: body
109
    branch = if is_binary(requested) and requested != "", do: requested, else: branch(issue)
110
111
    """
112
    Do the work issue ##{issue.number} describes, and nothing beyond it.
113
114
    Title: #{issue.title}
115
116
    #{body}
117
118
    Commit your work on the branch `#{branch}`, which is the only branch you \
119
    are authorized to write. Push it when the work is done.
120
    """
121
    |> String.trim()
122
  end
123
124
  @doc "The wall clock `issue` buys, in milliseconds."
125
  @spec wall_clock_ms(Issue.t()) :: pos_integer()
126
  def wall_clock_ms(%Issue{} = issue),
127
    do: if(scoped?(issue), do: @scoped_ms, else: @unscoped_ms)
128
129
  @doc "The wall clock a fully scoped issue buys."
130
  @spec scoped_wall_clock_ms() :: pos_integer()
131
  def scoped_wall_clock_ms, do: @scoped_ms
132
133
  @doc "The wall clock an issue missing any accepted-outcome section buys."
134
  @spec unscoped_wall_clock_ms() :: pos_integer()
135
  def unscoped_wall_clock_ms, do: @unscoped_ms
136
137
  @doc "Whether `issue` states every section `OUTCOME-001` requires."
138
  @spec scoped?(Issue.t()) :: boolean()
139
  def scoped?(%Issue{} = issue), do: missing_sections(issue) == []
140
141
  @doc """
142
  The accepted-outcome sections `issue` does not state, in contract order.
143
144
  An empty list is the scoped issue. Anything else is what a person has to
145
  write before an attempt on this issue can end in an accepted outcome, which
146
  is worth naming on the page rather than discovering at grading time.
147
  """
148
  @spec missing_sections(Issue.t()) :: [atom()]
149
  def missing_sections(%Issue{} = issue) do
150
    stated = CompletionClaims.sections(issue.body)
151
152
    Enum.reject(AcceptedOutcome.required_issue_sections(), fn section ->
153
      case Map.get(stated, section) do
154
        nil -> false
155
        [] -> false
156
        _stated -> true
157
      end
158
    end)
159
  end
160
161
  # Clamp on a character boundary: `binary_part/3` can cut a multi-byte
162
  # grapheme in half, and an invalid UTF-8 prompt is refused by
163
  # `ComputerAgentJobs.validate_prompt/1` for a reason that has nothing to do
164
  # with the issue being long.
165
  defp clamp(body) do
166
    body
167
    |> binary_part(0, @maximum_body_bytes)
168
    |> String.chunk(:valid)
169
    |> case do
170
      [valid | _rest] -> valid
171
      [] -> ""
172
    end
173
  end
174
end
lib/openagents/traces.ex modified +101 -27

@@ -1,9 +1,30 @@

1 1
defmodule OpenAgents.Traces do
2 2
  @moduledoc """
3 3
  Store and retrieve account-scoped ATIF trace documents.
4
5
  ## Binding a trace to an attempt
6
7
  A trace may name the `forge_assignments` attempt it is a trajectory of. The
8
  attempt already records the issue and the repository it was admitted against,
9
  so naming it is what lets an issue say a trajectory exists without the issue
10
  holding one.
11
12
  The binding is an authority claim, so it is checked rather than trusted: only
13
  the account that requested the attempt may bind a trace to it. Anybody else
14
  is refused with `:trace_assignment_forbidden` rather than having the field
15
  quietly dropped, because a caller that believed it was filing evidence
16
  against an attempt should not be told it succeeded.
17
18
  Binding does not disclose. `traces.visibility` is still the uploader's
19
  consent and still defaults to `dark`; what an issue's readers may learn from
20
  a bound trace is decided by `OpenAgents.Issues.TraceDisclosure`, and no rung
21
  of that ladder returns the document.
4 22
  """
5 23
24
  import Ecto.Query
25
6 26
  alias OpenAgents.Accounts.User
27
  alias OpenAgents.Forge.Assignment
7 28
  alias OpenAgents.Repo
8 29
  alias OpenAgents.Traces.Trace
9 30

@@ -20,10 +41,15 @@ defmodule OpenAgents.Traces do

20 41
  Re-uploading the same canonical bytes for the same account returns the
21 42
  existing trace. A document without an admitted `schema_version` or one that
22 43
  exceeds the size ceiling is refused.
44
45
  `options` may carry `:visibility`, the tier the uploader consents to, and
46
  `:assignment_id`, the attempt this trajectory was produced under. The
47
  assignment must be one this account requested; any other is refused with
48
  `:trace_assignment_forbidden`.
23 49
  """
24 50
  def store(%User{} = user, %{} = document), do: store(user, document, [])
25 51
26
  def store(%User{id: user_id} = _user, %{} = document, options) do
52
  def store(%User{id: user_id} = user, %{} = document, options) do
27 53
    canonical = Jason.encode!(document)
28 54
    byte_size = byte_size(canonical)
29 55

@@ -35,38 +61,86 @@ defmodule OpenAgents.Traces do

35 61
        {:error, :invalid_atif}
36 62
37 63
      true ->
38
        digest =
39
          "sha256:" <>
40
            (:crypto.hash(:sha256, canonical) |> Base.encode16(case: :lower))
41
42
        visibility = normalize_visibility(options, document)
43
44
        case Repo.get_by(Trace, user_id: user_id, digest: digest) do
45
          %Trace{} = existing ->
46
            {:ok, existing, :existing}
47
48
          nil ->
49
            attrs = %{
50
              user_id: user_id,
51
              digest: digest,
52
              visibility: visibility,
53
              document: document,
54
              byte_size: byte_size
55
            }
56
57
            %Trace{}
58
            |> Trace.create_changeset(attrs)
59
            |> Repo.insert()
60
            |> case do
61
              {:ok, trace} -> {:ok, trace, :created}
62
              {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset}
63
            end
64
        with {:ok, assignment_id} <- requested_assignment(user, options) do
65
          digest =
66
            "sha256:" <>
67
              (:crypto.hash(:sha256, canonical) |> Base.encode16(case: :lower))
68
69
          visibility = normalize_visibility(options, document)
70
71
          case Repo.get_by(Trace, user_id: user_id, digest: digest) do
72
            %Trace{} = existing ->
73
              {:ok, existing, :existing}
74
75
            nil ->
76
              attrs = %{
77
                user_id: user_id,
78
                digest: digest,
79
                visibility: visibility,
80
                document: document,
81
                byte_size: byte_size,
82
                assignment_id: assignment_id
83
              }
84
85
              %Trace{}
86
              |> Trace.create_changeset(attrs)
87
              |> Repo.insert()
88
              |> case do
89
                {:ok, trace} -> {:ok, trace, :created}
90
                {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset}
91
              end
92
          end
64 93
        end
65 94
    end
66 95
  end
67 96
68 97
  def store(_user, _document, _options), do: {:error, :invalid_atif}
69 98
99
  @doc """
100
  The traces bound to `assignment_ids`, oldest first, grouped by attempt.
101
102
  This is a join, not a disclosure: it returns whole rows, and every caller
103
  that shows one to a reader goes through
104
  `OpenAgents.Issues.TraceDisclosure`, which decides which fields that reader
105
  may have. Keeping the two apart is what lets the owner's own surfaces read
106
  the row while an issue's readers get the schedule.
107
  """
108
  @spec for_assignments([binary()]) :: %{binary() => [Trace.t()]}
109
  def for_assignments([]), do: %{}
110
111
  def for_assignments(assignment_ids) when is_list(assignment_ids) do
112
    Trace
113
    |> where([trace], trace.assignment_id in ^assignment_ids)
114
    |> order_by([trace], asc: trace.inserted_at, asc: trace.id)
115
    |> Repo.all()
116
    |> Enum.group_by(& &1.assignment_id)
117
  end
118
119
  # Binding a trace to an attempt is a claim about authority, so it is read
120
  # from the attempt rather than believed from the request. Only the account
121
  # named as the attempt's requesting principal may bind, which is the same
122
  # account `WorkDisclosure.link_for_attempt/3` would have raised to `glass`
123
  # for that attempt — one notion of "whose work this was", not two.
124
  defp requested_assignment(user, options) do
125
    case Keyword.get(options, :assignment_id) do
126
      nil ->
127
        {:ok, nil}
128
129
      id when is_binary(id) ->
130
        with {:ok, uuid} <- Ecto.UUID.cast(id),
131
             %Assignment{requesting_principal: %{"type" => "user", "id" => account_id}} <-
132
               Repo.get(Assignment, uuid),
133
             true <- account_id == user.id do
134
          {:ok, uuid}
135
        else
136
          _otherwise -> {:error, :trace_assignment_forbidden}
137
        end
138
139
      _invalid ->
140
        {:error, :trace_assignment_forbidden}
141
    end
142
  end
143
70 144
  defp valid_atif?(document) do
71 145
    version = Map.get(document, "schema_version") || Map.get(document, :schema_version)
72 146
lib/openagents/traces/trace.ex modified +8 -1

@@ -5,6 +5,11 @@ defmodule OpenAgents.Traces.Trace do

5 5
  A trace is an owner-attested document with a stable digest. The server stores
6 6
  the document as received and deduplicates per owner, so re-uploading the same
7 7
  bytes returns the existing record rather than creating a duplicate.
8
9
  `visibility` is the uploader's consent, not a description of the document. It
10
  defaults to `dark`, and nothing raises it but the uploader. `assignment_id`,
11
  when present, is the attempt this trajectory was produced under, which is how
12
  an issue comes to know a trace exists without holding one.
8 13
  """
9 14
10 15
  use Ecto.Schema

@@ -25,6 +30,7 @@ defmodule OpenAgents.Traces.Trace do

25 30
    field :visibility, :string, default: @default_visibility
26 31
    field :document, :map
27 32
    field :byte_size, :integer
33
    field :assignment_id, :binary_id
28 34
    timestamps(updated_at: false)
29 35
  end
30 36

@@ -35,13 +41,14 @@ defmodule OpenAgents.Traces.Trace do

35 41
36 42
  def create_changeset(%__MODULE__{} = trace, attrs) do
37 43
    trace
38
    |> cast(attrs, [:digest, :visibility, :document, :byte_size])
44
    |> cast(attrs, [:digest, :visibility, :document, :byte_size, :assignment_id])
39 45
    |> put_change(:user_id, attrs.user_id)
40 46
    |> validate_required([:user_id, :digest, :visibility, :document, :byte_size])
41 47
    |> validate_inclusion(:visibility, @visibilities)
42 48
    |> validate_number(:byte_size, greater_than_or_equal_to: 0)
43 49
    |> validate_format(:digest, ~r/\Asha256:[0-9a-f]{64}\z/)
44 50
    |> foreign_key_constraint(:user_id)
51
    |> foreign_key_constraint(:assignment_id)
45 52
    |> unique_constraint([:user_id, :digest], name: :traces_user_id_digest_index)
46 53
  end
47 54
end
lib/openagents/transparency/work_disclosure.ex modified +76 -10

@@ -56,7 +56,7 @@ defmodule OpenAgents.Transparency.WorkDisclosure do

56 56
  alias OpenAgents.Transparency
57 57
  alias OpenAgents.Transparency.ArtifactLink
58 58
59
  @families ~w(attempt work_job evidence)a
59
  @families ~w(attempt work_job evidence trace)a
60 60
61 61
  # ── attempt (forge_assignments) ─────────────────────────────────────────
62 62
  #

@@ -223,27 +223,93 @@ defmodule OpenAgents.Transparency.WorkDisclosure do

223 223
    :artifact_link_id
224 224
  ]
225 225
226
  # The schema column each projection field is read from. Three fields are not
226
  # ── trace (traces) ──────────────────────────────────────────────────────
227
  #
228
  # An uploaded ATIF trajectory of one attempt. This is the family with the
229
  # emptiest ladder, and deliberately so.
230
  #
231
  # An ATIF document is the whole run: every prompt, every assistant message,
232
  # every tool call's raw arguments, and every tool result. That is precisely
233
  # the material the `work_job` never list withholds — `goal`, `context_hint`,
234
  # and `delegation` are withheld because they restate the contents of a
235
  # repository in a place the repository's own gate does not cover, and a
236
  # trajectory restates far more of it than any of the three. So **no rung
237
  # publishes the document**, not even to the account that uploaded it. `glass`
238
  # here is not "the owner may read the steps"; the owner reads their own
239
  # document through their own account, and this schedule governs what an issue
240
  # discloses about it.
241
  #
242
  # What the ladder does publish is the existence and the shape of a
243
  # trajectory, which is the disclosure that has value on an issue: a reader
244
  # learns that the work was recorded, how long it ran, and — at `ledger` — the
245
  # digest by which a holder of the bytes can prove they hold these bytes.
246
  @trace %{
247
    id: :pulse,
248
    # `ATIF-v1.7` or the like. A format name, and the existence disclosure.
249
    schema_version: :pulse,
250
    # How many steps the trajectory has. A count of work, the same class of
251
    # fact as `work_job.tool_call_count`, and the reason it sits a rung lower
252
    # is that it says nothing about a repository at all.
253
    step_count: :pulse,
254
    recorded_at: :pulse,
255
    # A digest addresses content. TRANSPARENCY-001 puts shas at `:l2`, and this
256
    # is a sha of a document. It is also the only field that does anything
257
    # outside this page: a reader who obtained the bytes through the owner's
258
    # own export can prove they are the bytes this attempt recorded.
259
    digest: :ledger,
260
    byte_size: :ledger
261
  }
262
263
  @trace_never [
264
    # The uploader. TRANSPARENCY-001 publishes a principal's kind, never its
265
    # id, and here even the kind is already known: a trace is bound only by the
266
    # account that requested the attempt.
267
    :user_id,
268
    # The consent that gates this projection. The tier is the gate, not a field
269
    # the gate discloses — the same reason `transparency_tier` is never on the
270
    # attempt.
271
    :visibility,
272
    # The attempt is projected as its own family, gated on its own fields.
273
    :assignment_id
274
  ]
275
276
  # `document` is the source column of two scheduled fields and of no third,
277
  # exactly as `requesting_principal` is the source of `requester_kind` and of
278
  # nothing else. The column is read; the document is not published. Every rung
279
  # of this family takes a bounded fact *about* the trajectory and none takes a
280
  # step out of it, which is what `project/3` enforces by name rather than by
281
  # intention.
282
283
  # The schema column each projection field is read from. Five fields are not
227 284
  # columns: `requester_kind` is the kind half of `requesting_principal`,
228
  # `budget` is the bounds half of `budget_snapshot`, and the evidence edge
229
  # renames two columns. Naming the source column is what lets the enumeration
230
  # be exact — every column of the three tables is either the source of one
231
  # scheduled field or a member of the never list, never both and never
232
  # neither.
285
  # `budget` is the bounds half of `budget_snapshot`, the evidence edge renames
286
  # two columns, and the trace's `schema_version` and `step_count` are read
287
  # out of the document it never publishes. Naming the source column is what
288
  # lets the enumeration be exact — every column of the four tables is either
289
  # the source of one scheduled field or a member of the never list, never both
290
  # and never neither.
233 291
  @attempt_columns %{requester_kind: :requesting_principal}
234 292
  @work_job_columns %{budget: :budget_snapshot}
235 293
  @evidence_columns %{commit: :commit_sha, recorded_at: :inserted_at}
294
  @trace_columns %{schema_version: :document, step_count: :document, recorded_at: :inserted_at}
236 295
237
  @schedule %{attempt: @attempt, work_job: @work_job, evidence: @evidence}
296
  @schedule %{
297
    attempt: @attempt,
298
    work_job: @work_job,
299
    evidence: @evidence,
300
    trace: @trace
301
  }
238 302
  @columns %{
239 303
    attempt: @attempt_columns,
240 304
    work_job: @work_job_columns,
241
    evidence: @evidence_columns
305
    evidence: @evidence_columns,
306
    trace: @trace_columns
242 307
  }
243 308
  @never %{
244 309
    attempt: @attempt_never,
245 310
    work_job: @work_job_never,
246
    evidence: @evidence_never
311
    evidence: @evidence_never,
312
    trace: @trace_never
247 313
  }
248 314
249 315
  @doc "The families this schedule covers."
lib/openagents_web/api_error.ex modified +7 -1

@@ -110,7 +110,13 @@ defmodule OpenAgentsWeb.ApiError do

110 110
    # what it lost to rather than fetching again.
111 111
    "run_already_graded" => {409, "This run is already graded"},
112 112
    "recipe_digest_conflict" => {409, "That recipe digest already names another run"},
113
    "trace_body_too_large" => {413, "The trace body is larger than the maximum allowed size"}
113
    "trace_body_too_large" => {413, "The trace body is larger than the maximum allowed size"},
114
    # Binding a trace to an attempt is an authority claim, so a caller that
115
    # names somebody else's attempt is refused rather than having the binding
116
    # dropped: it should not be told the trajectory was filed as evidence when
117
    # it was not.
118
    "trace_assignment_forbidden" =>
119
      {403, "Only the account that requested an attempt may bind a trace to it"}
114 120
  }
115 121
116 122
  @doc """
lib/openagents_web/controllers/issue_json.ex modified +20 -1

@@ -48,10 +48,29 @@ defmodule OpenAgentsWeb.IssueJSON do

48 48
    %{
49 49
      threads: Enum.map(activity.threads, &thread_json(&1, url_base)),
50 50
      receipts: Enum.map(activity.receipts, &receipt_json/1),
51
      releases: releases_json(Map.get(activity, :releases))
51
      releases: releases_json(Map.get(activity, :releases)),
52
      traces: Enum.map(Map.get(activity, :traces) || [], &trace_json/1)
52 53
    }
53 54
  end
54 55
56
  # The trace half of the activity answer, and the narrowest of the four.
57
  # `OpenAgents.Issues.TraceDisclosure` has already decided which fields this
58
  # reader may have, so the renderer takes what it was given and adds nothing:
59
  # a field absent here is a field the schedule withheld, and no rung of that
60
  # schedule carries a step of the trajectory.
61
  defp trace_json(trace) do
62
    Map.take(trace, [
63
      :id,
64
      :assignment_id,
65
      :tier,
66
      :schema_version,
67
      :step_count,
68
      :recorded_at,
69
      :digest,
70
      :byte_size
71
    ])
72
  end
73
55 74
  # The release half of the activity answer. `receipts` matches a receipt to
56 75
  # the exact commit; this says which release revision contains that commit,
57 76
  # which is the question "did this ship" actually asks.
lib/openagents_web/controllers/trace_controller.ex modified +25 -1

@@ -1,6 +1,13 @@

1 1
defmodule OpenAgentsWeb.TraceController do
2 2
  @moduledoc """
3 3
  Accept ATIF v1 trace uploads at `POST /api/v1/traces`.
4
5
  A document may name the attempt it is a trajectory of, as
6
  `assignment_id`, which is what lets the issue that attempt was admitted
7
  against say a trajectory exists. Only the account that requested the attempt
8
  may bind to it; anybody else is refused rather than having the binding
9
  dropped. What an issue's readers then learn is decided by
10
  `OpenAgents.Issues.TraceDisclosure`, and it is never the document.
4 11
  """
5 12
6 13
  use OpenAgentsWeb, :controller

@@ -11,8 +18,11 @@ defmodule OpenAgentsWeb.TraceController do

11 18
  def create(conn, params) do
12 19
    document = conn.body_params
13 20
    visibility = parse_visibility(params)
21
    assignment_id = parse_assignment_id(params, document)
22
23
    options = [visibility: visibility, assignment_id: assignment_id]
14 24
15
    case Traces.store(conn.assigns.current_user, document, visibility: visibility) do
25
    case Traces.store(conn.assigns.current_user, document, options) do
16 26
      {:ok, trace, :created} ->
17 27
        conn
18 28
        |> put_status(:created)

@@ -31,6 +41,9 @@ defmodule OpenAgentsWeb.TraceController do

31 41
          "document" => ["The document is not a valid ATIF v1 object."]
32 42
        })
33 43
44
      {:error, :trace_assignment_forbidden} ->
45
        ApiError.refuse(conn, "trace_assignment_forbidden")
46
34 47
      {:error, %Ecto.Changeset{} = changeset} ->
35 48
        ApiError.changeset(conn, changeset)
36 49
    end

@@ -43,6 +56,17 @@ defmodule OpenAgentsWeb.TraceController do

43 56
    end
44 57
  end
45 58
59
  # Read from the query string or from the document, in that order. The
60
  # document is the natural home for a client that produced the trajectory
61
  # under an attempt it already knows the id of, and either way the claim is
62
  # checked against the attempt rather than believed.
63
  defp parse_assignment_id(params, document) do
64
    case Map.get(params, "assignment_id") || Map.get(document, "assignment_id") do
65
      value when is_binary(value) -> String.trim(value)
66
      _absent -> nil
67
    end
68
  end
69
46 70
  defp trace_view(trace) do
47 71
    %{
48 72
      "id" => trace.id,
lib/openagents_web/live/issue_show_live.ex modified +138 -20

@@ -45,6 +45,9 @@ defmodule OpenAgentsWeb.IssueShowLive do

45 45
  alias OpenAgents.Issues.Comment
46 46
  alias OpenAgents.Issues.TaskReferences
47 47
  alias OpenAgents.Issues.Issue
48
  alias OpenAgents.Issues.Activity
49
  alias OpenAgents.Issues.CompletionClaims
50
  alias OpenAgents.Issues.WorkScope
48 51
  alias OpenAgents.Labels
49 52
  alias OpenAgents.Machines
50 53
  alias OpenAgents.Markdown

@@ -479,7 +482,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

479 482
  # The branch is never the default or a protected one — `Assignments` refuses
480 483
  # both — so the suggestion names the issue it is for.
481 484
  defp branch_or_default(branch, _issue) when is_binary(branch) and branch != "", do: branch
482
  defp branch_or_default(_branch, issue), do: "agent/issue-#{issue.number}"
485
  defp branch_or_default(_branch, issue), do: WorkScope.branch(issue)
483 486
484 487
  # The one attempt that may be live per issue, per
485 488
  # `forge_assignments_one_active_issue_index`. Naming it is what turns a

@@ -503,7 +506,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

503 506
        "branch" => params["branch"],
504 507
        "agent_id" => params["agent_id"],
505 508
        "cwd" => params["cwd"],
506
        "prompt" => objective(issue, params["branch"]),
509
        "prompt" => WorkScope.objective(issue, params["branch"]),
507 510
        "requesting_user" => user,
508 511
        "requesting_principal" => user
509 512
      }

@@ -537,24 +540,6 @@ defmodule OpenAgentsWeb.IssueShowLive do

537 540
538 541
  # The objective comes from the issue, not from free text typed beside it, so
539 542
  # what the agent was asked to do and what the issue asked for cannot drift.
540
  # `ComputerAgentJobs` bounds a prompt at 8,000 bytes, so the body is clamped
541
  # well inside that rather than refused for being long.
542
  defp objective(issue, branch) do
543
    body = issue.body || ""
544
    body = if byte_size(body) > 6_000, do: binary_part(body, 0, 6_000), else: body
545
546
    """
547
    Do the work issue ##{issue.number} describes, and nothing beyond it.
548
549
    Title: #{issue.title}
550
551
    #{body}
552
553
    Commit your work on the branch `#{branch}`, which is the only branch you     are authorized to write. Push it when the work is done.
554
    """
555
    |> String.trim()
556
  end
557
558 543
  # Every refusal the admission already returns, said as itself. A generic
559 544
  # failure here would hide the one fact that tells someone what to do next.
560 545
  defp refusal(:assignment_issue_claimed, socket) do

@@ -688,10 +673,66 @@ defmodule OpenAgentsWeb.IssueShowLive do

688 673
    |> assign(:form, to_form(Issues.change_issue(issue)))
689 674
    |> assign(:events, timeline(issue, comments, attempts, references, syncs, base))
690 675
    |> assign(:subscribed?, subscribed?(issue, socket.assigns.current_user))
676
    |> assign(:activity, Activity.for_issue(issue, socket.assigns.current_user))
677
    |> assign(:claims, CompletionClaims.for_issue(issue))
678
    |> assign(:scope, WorkScope.for_issue(issue))
691 679
    |> assign(:now, DateTime.utc_now())
692 680
    |> arm_tick()
693 681
  end
694 682
683
  # ── the evidence a reader can act on ────────────────────────────────────
684
  #
685
  # The page shows the same assembly the activity endpoint returns, read
686
  # through the same functions with the same viewer, so the two cannot come
687
  # apart. Nothing here is a second store: releases are computed from the
688
  # commit graph, receipts are reached from the issue's own closing
689
  # references, traces are projected through their own two gates, and the
690
  # verdict is the row `OUTCOME-001` already wrote.
691
692
  defp released_in(%{releases: %{released_in: %{} = release}}), do: release
693
  defp released_in(_activity), do: nil
694
695
  defp receipt_families(%{receipts: receipts}) do
696
    receipts
697
    |> Enum.frequencies_by(& &1.family)
698
    |> Enum.sort_by(&elem(&1, 0))
699
  end
700
701
  defp receipt_families(_activity), do: []
702
703
  defp visible_traces(%{traces: traces}) when is_list(traces), do: traces
704
  defp visible_traces(_activity), do: []
705
706
  # The verdict a reader needs is the most recent one. Earlier verdicts stay on
707
  # the record and are read through the API; repeating every attempt's grade in
708
  # the rail would bury the one that decided whether the issue closed.
709
  defp latest_claim([]), do: nil
710
  defp latest_claim(claims), do: List.last(claims)
711
712
  defp claim_tone(%{state: "accepted"}), do: "success"
713
  defp claim_tone(%{state: "not_applicable"}), do: "muted"
714
  defp claim_tone(_claim), do: "warning"
715
716
  defp claim_sentence(%{state: "accepted", closed: true}),
717
    do: "An accepted outcome closed this issue."
718
719
  defp claim_sentence(%{state: "accepted"}),
720
    do:
721
      "An outcome was accepted. This repository has not opted in to verified closing, " <>
722
        "so the issue stays open for a person to decide."
723
724
  defp claim_sentence(%{state: "not_applicable"}),
725
    do: "Graded as outside the gate: human work, or a repository with agents disabled."
726
727
  defp claim_sentence(%{state: state}), do: "The last claim graded #{state}."
728
729
  defp short_sha(sha) when is_binary(sha), do: binary_part(sha, 0, min(byte_size(sha), 7))
730
  defp short_sha(_sha), do: ""
731
732
  defp section_word(:acceptance_criteria), do: "acceptance criteria"
733
  defp section_word(:success_metrics), do: "success metrics"
734
  defp section_word(section), do: to_string(section)
735
695 736
  # The clock is armed only while an attempt is live, so an issue nobody is
696 737
  # working on costs no timer at all, and only on the transition into a live
697 738
  # attempt, so a re-read cannot stack timers.

@@ -934,6 +975,27 @@ defmodule OpenAgentsWeb.IssueShowLive do

934 975
          <section :if={@can_write or @live_attempt} id="issue-work" class="properties-panel__group">
935 976
            <h3 class="properties-panel__heading">Agent work</h3>
936 977
978
            <%!-- The bound an attempt inherits, said before anyone starts one.
979
            An unscoped issue is worth naming here rather than at grading time:
980
            `OUTCOME-001` refuses a claim against it, so the sentence is what
981
            turns a later `incomplete` into something a person could have
982
            fixed first. See `OpenAgents.Issues.WorkScope`. --%>
983
            <p :if={@can_write} class="properties-panel__none" id="issue-work-bound">
984
              <%= if @scope.scoped? do %>
985
                This issue states every section an accepted outcome needs, so an attempt on it
986
                runs for up to an hour.
987
              <% else %>
988
                This issue does not state its {Enum.map_join(
989
                  @scope.missing_sections,
990
                  ", ",
991
                  &section_word/1
992
                )}, so no claim against it can be accepted and an attempt runs for {div(
993
                  @scope.wall_clock_ms,
994
                  60_000
995
                )} minutes.
996
              <% end %>
997
            </p>
998
937 999
            <div :if={@live_attempt} id="issue-work-live">
938 1000
              <p class="properties-panel__none">
939 1001
                Work is <span id="issue-work-state">{@live_attempt.state}</span>{live_attempt_branch(

@@ -998,6 +1060,62 @@ defmodule OpenAgentsWeb.IssueShowLive do

998 1060
            </.form>
999 1061
          </section>
1000 1062
1063
          <%!-- What happened to this issue, as records rather than as prose.
1064
          Every line is reachable through
1065
          `GET /api/v1/repos/:owner/:repo/issues/:n/activity` for the same
1066
          reader, because both read `OpenAgents.Issues.Activity` with the same
1067
          viewer. The section is absent when the issue has no evidence at all,
1068
          rather than present and empty. --%>
1069
          <section
1070
            :if={
1071
              released_in(@activity) || receipt_families(@activity) != [] ||
1072
                visible_traces(@activity) != [] || latest_claim(@claims)
1073
            }
1074
            id="issue-evidence"
1075
            class="properties-panel__group"
1076
          >
1077
            <h3 class="properties-panel__heading">Evidence</h3>
1078
1079
            <p :if={claim = latest_claim(@claims)} id="issue-evidence-claim">
1080
              <.badge data-tone={claim_tone(claim)}>{claim.state}</.badge>
1081
              <span class="properties-panel__none">{claim_sentence(claim)}</span>
1082
            </p>
1083
1084
            <p :if={release = released_in(@activity)} id="issue-evidence-release">
1085
              <span class="properties-panel__none">
1086
                Shipped in the release at <code>{short_sha(release.sha)}</code>, promoted {Calendar.strftime(
1087
                  release.promoted_at,
1088
                  "%Y-%m-%d"
1089
                )}.
1090
              </span>
1091
            </p>
1092
1093
            <p
1094
              :for={{family, count} <- receipt_families(@activity)}
1095
              id={"issue-evidence-receipt-#{family}"}
1096
              class="properties-panel__none"
1097
            >
1098
              {count} {family} {if count == 1, do: "receipt", else: "receipts"}
1099
            </p>
1100
1101
            <%!-- An issue says a trajectory exists. It never shows one: the
1102
            steps carry prompts, tool arguments, and tool results, which the
1103
            repository's own gate exists to withhold. See
1104
            `OpenAgents.Issues.TraceDisclosure`. --%>
1105
            <p
1106
              :for={trace <- visible_traces(@activity)}
1107
              id={"issue-evidence-trace-#{trace.id}"}
1108
              class="properties-panel__none"
1109
            >
1110
              An agent trajectory of {trace.step_count} {if trace.step_count == 1,
1111
                do: "step",
1112
                else: "steps"} was recorded<%= if trace[:digest] do %>
1113
                , as <code>{short_sha(String.replace_prefix(trace.digest, "sha256:", ""))}</code>
1114
              <% end %>. Its
1115
              contents are not published here.
1116
            </p>
1117
          </section>
1118
1001 1119
          <%!-- Outside the properties panel on purpose. Everything in that
1002 1120
          panel changes the issue and needs a writable membership; following an
1003 1121
          issue changes only what reaches your own inbox, so it is offered to
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -305,7 +305,8 @@

305 305
    20260825113459,
306 306
    20260825120000,
307 307
    20260825140544,
308
    20260825140545
308
    20260825140545,
309
    20260825160000
309 310
  ],
310 311
  "required_tables": [
311 312
    "users",
test/openagents/issues/trace_disclosure_test.exs added +310

@@ -0,0 +1,310 @@

1
defmodule OpenAgents.Issues.TraceDisclosureTest do
2
  @moduledoc """
3
  `#10`: the deliberate ATIF visibility policy for an issue.
4
5
  The decision under test is a refusal as much as a disclosure: **an issue
6
  publishes that a trajectory exists and never publishes one.** So the first
7
  property here is that no rung returns a step, and the rest is the two gates
8
  that decide whether even the existence is disclosed.
9
10
  Every tier assertion runs on a **public** repository, where
11
  `Repositories.readable_by/2` admits everybody and the two gates are the only
12
  things between an anonymous reader and a digest. A private repository would
13
  have proved the gates worked while the repository gate did the work — so the
14
  last test does exactly that case separately, and asserts that repository
15
  authority is still stronger than any consent.
16
  """
17
  use OpenAgents.DataCase, async: true
18
19
  import OpenAgents.AccountsFixtures
20
21
  alias OpenAgents.Box.ConversationBox
22
  alias OpenAgents.Conversations
23
  alias OpenAgents.Forge.{Assignment, Assignments}
24
  alias OpenAgents.Issues
25
  alias OpenAgents.Issues.{Activity, TraceDisclosure}
26
  alias OpenAgents.Repo
27
  alias OpenAgents.Repositories
28
  alias OpenAgents.Traces
29
  alias OpenAgents.Transparency.WorkDisclosure
30
31
  @document %{
32
    "schema_version" => "ATIF-v1.7",
33
    "session_id" => "s1",
34
    "steps" => [
35
      %{"step_id" => 1, "role" => "user", "message" => "the private prompt"},
36
      %{"step_id" => 2, "role" => "agent", "message" => "the private answer"},
37
      %{"step_id" => 3, "role" => "agent", "message" => "and a third"}
38
    ]
39
  }
40
41
  setup do
42
    owner = repository_user_fixture("trace-owner")
43
    member = repository_user_fixture("trace-member")
44
    stranger = repository_user_fixture("trace-stranger")
45
46
    repository = repository_with_member_fixture(owner, %{visibility: "public"}, "owner")
47
    {:ok, _} = Repositories.add_member(repository, member, "maintainer")
48
49
    {:ok, issue} = Issues.create_issue(repository, %{title: "Record the trajectory"})
50
    attempt = admit(owner, repository, issue)
51
52
    %{
53
      owner: owner,
54
      member: member,
55
      stranger: stranger,
56
      repository: repository,
57
      issue: issue,
58
      attempt: attempt
59
    }
60
  end
61
62
  describe "no rung publishes the trajectory" do
63
    test "the document is absent at every tier, for every reader", context do
64
      trace = upload(context.owner, context.attempt, "glass")
65
66
      for reader <- [nil, context.stranger, context.member, context.owner] do
67
        viewer = WorkDisclosure.viewer(context.repository, reader)
68
        projection = TraceDisclosure.project(trace, context.attempt, viewer)
69
70
        refute is_nil(projection),
71
               "expected a projection for #{inspect(reader && reader.github_login)}"
72
73
        refute Map.has_key?(projection, :document)
74
        refute Map.has_key?(projection, :steps)
75
76
        # Nothing anywhere in the projection restates a step, however nested.
77
        refute inspect(projection) =~ "the private prompt"
78
        refute inspect(projection) =~ "the private answer"
79
      end
80
    end
81
82
    test "the schedule itself refuses the document, not only this projection" do
83
      refute :document in WorkDisclosure.fields_at(:trace, :glass)
84
      assert WorkDisclosure.tier_for(:trace, :document) == nil
85
    end
86
87
    test "an operator gets the shape and not the steps", context do
88
      operator = admin_user()
89
      trace = upload(context.owner, context.attempt, "glass")
90
      viewer = WorkDisclosure.viewer(context.repository, operator)
91
92
      projection = TraceDisclosure.project(trace, context.attempt, viewer)
93
94
      assert projection.step_count == 3
95
      refute Map.has_key?(projection, :document)
96
    end
97
  end
98
99
  describe "consent is a gate, and it defaults to withholding" do
100
    test "a trace stored with no visibility is dark and invisible", context do
101
      trace = upload(context.owner, context.attempt, nil)
102
103
      assert trace.visibility == "dark"
104
105
      for reader <- [nil, context.stranger, context.member, context.owner] do
106
        viewer = WorkDisclosure.viewer(context.repository, reader)
107
        assert TraceDisclosure.project(trace, context.attempt, viewer) == nil
108
      end
109
    end
110
111
    test "a dark trace is absent rather than an empty shell", context do
112
      trace = upload(context.owner, context.attempt, "dark")
113
      viewer = WorkDisclosure.viewer(context.repository, context.owner)
114
115
      assert TraceDisclosure.project(trace, context.attempt, viewer) == nil
116
      assert TraceDisclosure.for_attempts([context.attempt], viewer) == []
117
    end
118
119
    test "consent at pulse discloses the shape and withholds the digest", context do
120
      trace = upload(context.owner, context.attempt, "pulse")
121
      viewer = WorkDisclosure.viewer(context.repository, context.member)
122
123
      projection = TraceDisclosure.project(trace, context.attempt, viewer)
124
125
      assert projection.tier == :pulse
126
      assert projection.schema_version == "ATIF-v1.7"
127
      assert projection.step_count == 3
128
      assert projection.recorded_at
129
      refute Map.has_key?(projection, :digest)
130
      refute Map.has_key?(projection, :byte_size)
131
    end
132
133
    test "consent at ledger adds the digest, which is the only field that travels",
134
         context do
135
      trace = upload(context.owner, context.attempt, "ledger")
136
      viewer = WorkDisclosure.viewer(context.repository, context.member)
137
138
      projection = TraceDisclosure.project(trace, context.attempt, viewer)
139
140
      assert projection.tier == :ledger
141
      assert projection.digest == trace.digest
142
      assert projection.byte_size == trace.byte_size
143
    end
144
145
    test "consent is a ceiling the viewer's own rung cannot raise", context do
146
      trace = upload(context.owner, context.attempt, "pulse")
147
148
      # An operator reaches `glass` on everything else about this attempt.
149
      viewer = WorkDisclosure.viewer(context.repository, admin_user())
150
151
      assert TraceDisclosure.effective_tier(trace, context.attempt, viewer) == :pulse
152
    end
153
  end
154
155
  describe "repository access is the other gate, and it is the stronger one" do
156
    test "a reader who cannot read the repository sees no trace, however wide the consent",
157
         context do
158
      trace = upload(context.owner, context.attempt, "glass")
159
      go_private(context.repository)
160
161
      # The activity read is where repository authority is applied, so the
162
      # assertion belongs at that seam rather than at the projection.
163
      activity = Activity.for_issue(context.issue, context.stranger)
164
165
      assert activity.traces == []
166
      assert trace.visibility == "glass"
167
    end
168
169
    test "a member of that private repository still sees the shape", context do
170
      _trace = upload(context.owner, context.attempt, "ledger")
171
      go_private(context.repository)
172
173
      activity = Activity.for_issue(context.issue, context.member)
174
175
      assert [projection] = activity.traces
176
      assert projection.assignment_id == context.attempt.id
177
      assert projection.step_count == 3
178
      refute Map.has_key?(projection, :document)
179
    end
180
181
    test "an anonymous reader of a public repository gets pulse and no digest", context do
182
      _trace = upload(context.owner, context.attempt, "ledger")
183
184
      activity = Activity.for_issue(context.issue, nil)
185
186
      assert [projection] = activity.traces
187
      assert projection.tier == :pulse
188
      refute Map.has_key?(projection, :digest)
189
    end
190
  end
191
192
  describe "binding a trace to an attempt is checked, not believed" do
193
    test "the requesting account may bind", context do
194
      assert {:ok, trace, :created} =
195
               Traces.store(context.owner, @document, assignment_id: context.attempt.id)
196
197
      assert trace.assignment_id == context.attempt.id
198
    end
199
200
    test "another account may not, and is refused rather than silently unbound", context do
201
      assert {:error, :trace_assignment_forbidden} =
202
               Traces.store(context.stranger, @document, assignment_id: context.attempt.id)
203
    end
204
205
    test "an attempt that does not exist is refused", context do
206
      assert {:error, :trace_assignment_forbidden} =
207
               Traces.store(context.owner, @document, assignment_id: Ecto.UUID.generate())
208
    end
209
210
    test "a malformed identifier is refused rather than raising", context do
211
      assert {:error, :trace_assignment_forbidden} =
212
               Traces.store(context.owner, @document, assignment_id: "not-a-uuid")
213
    end
214
215
    test "an unbound upload is still the ordinary case", context do
216
      assert {:ok, trace, :created} = Traces.store(context.owner, @document)
217
      assert is_nil(trace.assignment_id)
218
      assert Activity.for_issue(context.issue, context.owner).traces == []
219
    end
220
  end
221
222
  describe "a malformed document does not break the page" do
223
    test "a document with no steps reports zero rather than raising", context do
224
      {:ok, trace, :created} =
225
        Traces.store(context.owner, %{"schema_version" => "ATIF-v1.7"},
226
          assignment_id: context.attempt.id,
227
          visibility: "pulse"
228
        )
229
230
      viewer = WorkDisclosure.viewer(context.repository, context.member)
231
      projection = TraceDisclosure.project(trace, context.attempt, viewer)
232
233
      assert projection.step_count == 0
234
    end
235
  end
236
237
  defp go_private(repository) do
238
    {1, _} =
239
      Repo.update_all(
240
        Ecto.Query.from(r in OpenAgents.Repositories.Repository, where: r.id == ^repository.id),
241
        set: [visibility: "private"]
242
      )
243
244
    :ok
245
  end
246
247
  defp upload(user, attempt, visibility) do
248
    options =
249
      [assignment_id: attempt.id] ++
250
        if visibility, do: [visibility: visibility], else: []
251
252
    # A distinct document per upload, because `store/3` deduplicates on the
253
    # canonical bytes per account.
254
    document = Map.put(@document, "session_id", "s#{System.unique_integer([:positive])}")
255
256
    {:ok, trace, _} = Traces.store(user, document, options)
257
    trace
258
  end
259
260
  # The run never starts in a test. The assignment is committed by
261
  # `persist_assignment/7` before `start_target/7` is reached, so the row this
262
  # returns is the row the production path writes.
263
  defp admit(owner, repository, issue) do
264
    {:ok, conversation} = Conversations.ensure_conversation(owner)
265
266
    {:ok, box} =
267
      %ConversationBox{}
268
      |> ConversationBox.changeset(%{
269
        conversation_id: conversation.id,
270
        box_id: "bx_trace_#{System.unique_integer([:positive])}",
271
        state: "ready",
272
        setup_status: "done"
273
      })
274
      |> Repo.insert()
275
276
    _ =
277
      try do
278
        Assignments.create(%{
279
          "target_kind" => "box",
280
          "box_id" => box.box_id,
281
          "conversation_id" => conversation.id,
282
          "repository_id" => repository.id,
283
          "issue_number" => issue.number,
284
          "branch" => "agent/issue-#{issue.number}",
285
          "requesting_user" => owner,
286
          "requesting_principal" => owner
287
        })
288
      rescue
289
        error -> {:error, error}
290
      end
291
292
    Assignment
293
    |> Repo.get_by!(issue_id: issue.id)
294
    |> Repo.preload([:artifact_link, :work_job])
295
  end
296
297
  # The operator identity is the one `OpenAgents.Accounts.admin?/1` admits, so
298
  # this needs no global state and stays safe to run concurrently.
299
  defp admin_user do
300
    {:ok, user} =
301
      OpenAgents.Accounts.upsert_github_user(%{
302
        github_id: 14_167_547,
303
        github_login: "trace-operator",
304
        github_avatar_url: "https://avatars.githubusercontent.com/u/14167547?v=4"
305
      })
306
307
    true = OpenAgents.Accounts.admin?(user)
308
    user
309
  end
310
end
test/openagents/issues/work_scope_test.exs added +218

@@ -0,0 +1,218 @@

1
defmodule OpenAgents.Issues.WorkScopeTest do
2
  @moduledoc """
3
  `#10`: starting bounded agent work *from* an issue.
4
5
  The property under test is that the bound is the issue's, not the caller's.
6
  Two halves:
7
8
  **The scope is read from the issue.** The objective is written from the
9
  issue's own title and body, and the branch from its number, so two attempts
10
  on the same issue are asked the same question.
11
12
  **The limits are read from the issue, and a caller may only narrow them.** An
13
  issue that states every section `OUTCOME-001` requires buys the full hour; an
14
  issue that does not buys a short exploratory window, because a claim against
15
  it can never be accepted. `Assignments.create/1` is where that applies, so it
16
  applies to the issue page and to the API alike rather than to whichever
17
  surface remembered.
18
  """
19
  use OpenAgents.DataCase, async: true
20
21
  import Ecto.Query
22
  import OpenAgents.AccountsFixtures
23
24
  alias OpenAgents.Box.ConversationBox
25
  alias OpenAgents.Conversations
26
  alias OpenAgents.Forge.{Assignment, Assignments}
27
  alias OpenAgents.Issues
28
  alias OpenAgents.Issues.WorkScope
29
  alias OpenAgents.Repo
30
31
  @scoped_body """
32
  ## Problem
33
34
  Nothing bounds an attempt.
35
36
  ## Scope
37
38
  One module and its admission point.
39
40
  ## Acceptance criteria
41
42
  - The wall clock comes from the issue.
43
44
  ## Success metrics
45
46
  An unscoped issue cannot buy an hour.
47
  """
48
49
  setup do
50
    owner = repository_user_fixture("scope-owner")
51
    repository = repository_with_member_fixture(owner, %{visibility: "public"}, "owner")
52
53
    {:ok, scoped} =
54
      Issues.create_issue(repository, %{title: "Bound the work", body: @scoped_body})
55
56
    {:ok, unscoped} =
57
      Issues.create_issue(repository, %{title: "Do something", body: "Make it better."})
58
59
    %{owner: owner, repository: repository, scoped: scoped, unscoped: unscoped}
60
  end
61
62
  describe "the scope comes from the issue" do
63
    test "the objective names the issue, its title, and its one writable branch", context do
64
      objective = WorkScope.objective(context.scoped)
65
66
      assert objective =~ "issue ##{context.scoped.number}"
67
      assert objective =~ "Bound the work"
68
      assert objective =~ "Nothing bounds an attempt."
69
      assert objective =~ "agent/issue-#{context.scoped.number}"
70
    end
71
72
    test "the branch is derived from the issue number", context do
73
      assert WorkScope.branch(context.scoped) == "agent/issue-#{context.scoped.number}"
74
    end
75
76
    test "a long body is clamped on a character boundary, not refused", context do
77
      body = String.duplicate("é", 8_000)
78
      {:ok, long} = Issues.create_issue(context.repository, %{title: "Long", body: body})
79
80
      objective = WorkScope.objective(long)
81
82
      assert String.valid?(objective)
83
      assert byte_size(objective) < 8_000
84
    end
85
  end
86
87
  describe "the limits come from the issue's own scope" do
88
    test "an issue stating every required section buys the full hour", context do
89
      scope = WorkScope.for_issue(context.scoped)
90
91
      assert scope.scoped?
92
      assert scope.missing_sections == []
93
      assert scope.wall_clock_ms == WorkScope.scoped_wall_clock_ms()
94
    end
95
96
    test "an issue missing a section buys the exploratory window", context do
97
      scope = WorkScope.for_issue(context.unscoped)
98
99
      refute scope.scoped?
100
      assert scope.wall_clock_ms == WorkScope.unscoped_wall_clock_ms()
101
      assert scope.wall_clock_ms < WorkScope.scoped_wall_clock_ms()
102
    end
103
104
    test "the missing sections are named, in contract order", context do
105
      assert WorkScope.missing_sections(context.unscoped) ==
106
               [:problem, :scope, :acceptance_criteria, :success_metrics]
107
    end
108
109
    test "a body absent entirely states no section", context do
110
      {:ok, bodiless} = Issues.create_issue(context.repository, %{title: "No body"})
111
112
      refute WorkScope.scoped?(bodiless)
113
    end
114
115
    test "a heading with no content under it does not count as stated", context do
116
      {:ok, empty} =
117
        Issues.create_issue(context.repository, %{
118
          title: "Headings only",
119
          body: "## Problem\n\n## Scope\n\n## Acceptance criteria\n\n## Success metrics\n"
120
        })
121
122
      assert WorkScope.missing_sections(empty) ==
123
               [:problem, :scope, :acceptance_criteria, :success_metrics]
124
    end
125
126
    test "the reader is the grader's own parser, so the two cannot disagree", context do
127
      stated = OpenAgents.Issues.CompletionClaims.sections(context.scoped.body)
128
129
      for section <- OpenAgents.AcceptedOutcome.required_issue_sections() do
130
        assert Map.get(stated, section), "the grader does not see #{section}"
131
        refute section in WorkScope.missing_sections(context.scoped)
132
      end
133
    end
134
  end
135
136
  describe "the admission point applies the bound" do
137
    test "an unscoped issue's attempt gets the short deadline", context do
138
      assignment = admit(context, context.unscoped)
139
140
      assert within_ms?(assignment.deadline_at, WorkScope.unscoped_wall_clock_ms())
141
    end
142
143
    test "a scoped issue's attempt gets the full deadline", context do
144
      assignment = admit(context, context.scoped)
145
146
      assert within_ms?(assignment.deadline_at, WorkScope.scoped_wall_clock_ms())
147
    end
148
149
    test "a caller may narrow the bound", context do
150
      requested = DateTime.add(DateTime.utc_now(), 120, :second)
151
      assignment = admit(context, context.scoped, %{"deadline_at" => requested})
152
153
      assert DateTime.compare(assignment.deadline_at, requested) == :eq
154
    end
155
156
    test "a caller may not widen the bound", context do
157
      # Two hours, past both the issue's window and the deployment TTL.
158
      requested = DateTime.add(DateTime.utc_now(), 7_200, :second)
159
      assignment = admit(context, context.unscoped, %{"deadline_at" => requested})
160
161
      assert DateTime.compare(assignment.deadline_at, requested) == :lt
162
      assert within_ms?(assignment.deadline_at, WorkScope.unscoped_wall_clock_ms())
163
    end
164
  end
165
166
  # The run never starts in a test, and it does not need to: the assignment is
167
  # committed by `persist_assignment/7` before `start_target/7` is reached, so
168
  # the row this reads is the row the production path writes.
169
  defp admit(context, issue, extra \\ %{}) do
170
    {:ok, conversation} = Conversations.ensure_conversation(context.owner)
171
172
    {:ok, box} =
173
      %ConversationBox{}
174
      |> ConversationBox.changeset(%{
175
        conversation_id: conversation.id,
176
        box_id: "bx_scope_#{System.unique_integer([:positive])}",
177
        state: "ready",
178
        setup_status: "done"
179
      })
180
      |> Repo.insert()
181
182
    attrs =
183
      Map.merge(
184
        %{
185
          "target_kind" => "box",
186
          "box_id" => box.box_id,
187
          "conversation_id" => conversation.id,
188
          "repository_id" => context.repository.id,
189
          "issue_number" => issue.number,
190
          "branch" => WorkScope.branch(issue),
191
          "requesting_user" => context.owner,
192
          "requesting_principal" => context.owner
193
        },
194
        extra
195
      )
196
197
    result = try_create(attrs)
198
199
    case Repo.one(from a in Assignment, where: a.issue_id == ^issue.id, limit: 1) do
200
      nil -> flunk("no assignment persisted: #{inspect(result)}")
201
      row -> row
202
    end
203
  end
204
205
  defp try_create(attrs) do
206
    Assignments.create(attrs)
207
  rescue
208
    error -> {:error, error}
209
  end
210
211
  # The deadline is stamped at admission, so it lands within a second of
212
  # `now + bound`. Asserting a window rather than an instant keeps the test
213
  # about the bound rather than about the clock.
214
  defp within_ms?(deadline, bound) do
215
    actual = DateTime.diff(deadline, DateTime.utc_now(), :millisecond)
216
    actual > bound - 5_000 and actual <= bound
217
  end
218
end
test/openagents/transparency/work_disclosure_test.exs modified +6 -4

@@ -6,9 +6,9 @@ defmodule OpenAgents.Transparency.WorkDisclosureTest do

6 6
  anything.
7 7
8 8
  **The schedule is exhaustive.** Every column of `forge_assignments`,
9
  `work_jobs`, and `issue_evidence` is either the source of exactly one
10
  scheduled field or a member of that family's never list. A new column is a
11
  failure here until somebody decides which, so the schedule cannot quietly
9
  `work_jobs`, `issue_evidence`, and `traces` is either the source of exactly
10
  one scheduled field or a member of that family's never list. A new column is
11
  a failure here until somebody decides which, so the schedule cannot quietly
12 12
  fall behind the schema it describes.
13 13
14 14
  **The rungs discriminate on a repository nothing else gates.** Every tier

@@ -34,6 +34,7 @@ defmodule OpenAgents.Transparency.WorkDisclosureTest do

34 34
  alias OpenAgents.Repo
35 35
  alias OpenAgents.Repositories
36 36
  alias OpenAgents.Transparency
37
  alias OpenAgents.Traces.Trace
37 38
  alias OpenAgents.Transparency.{ArtifactLink, WorkDisclosure}
38 39
  alias OpenAgents.Work.Job
39 40

@@ -64,7 +65,8 @@ defmodule OpenAgents.Transparency.WorkDisclosureTest do

64 65
    @schema_for %{
65 66
      attempt: Assignment,
66 67
      work_job: Job,
67
      evidence: EvidenceEntry
68
      evidence: EvidenceEntry,
69
      trace: Trace
68 70
    }
69 71
70 72
    for {family, schema} <- @schema_for do
test/openagents_web/live/issue_show_live_test.exs modified +97

@@ -463,6 +463,103 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

463 463
    assert html =~ "system"
464 464
  end
465 465
466
  # ── #10: the issue page shows the evidence, not only the work ───────────
467
  #
468
  # `OUTCOME-001` says an accepted outcome "explains which receipt satisfied
469
  # each acceptance criterion, so the issue page can show the mapping". The
470
  # verdict was durable and graded before this, and nowhere on the page. These
471
  # cover the rail that renders it, the release link, and the ATIF trace line
472
  # — which says a trajectory exists and never shows one.
473
474
  test "an issue with no evidence has no evidence section at all", %{conn: conn} do
475
    issue = issue!(%{"title" => "Nothing has happened"})
476
477
    {:ok, view, _html} = live(conn, path(issue))
478
479
    refute has_element?(view, "#issue-evidence")
480
  end
481
482
  test "the work form says what bound the attempt, and why", %{conn: conn} do
483
    issue = issue!(%{"title" => "Unscoped", "body" => "Make it better."})
484
485
    {:ok, _view, html} = live(conn, path(issue))
486
487
    assert html =~ "does not state its"
488
    assert html =~ "acceptance criteria"
489
    assert html =~ "no claim against it can be accepted"
490
  end
491
492
  test "a scoped issue says it buys the full hour", %{conn: conn} do
493
    issue =
494
      issue!(%{
495
        "title" => "Scoped",
496
        "body" => """
497
        ## Problem
498
499
        A bound that comes from the caller is not a bound.
500
501
        ## Scope
502
503
        One admission point.
504
505
        ## Acceptance criteria
506
507
        - The wall clock comes from the issue.
508
509
        ## Success metrics
510
511
        An unscoped issue cannot buy an hour.
512
        """
513
      })
514
515
    {:ok, _view, html} = live(conn, path(issue))
516
517
    assert html =~ "states every section an accepted outcome needs"
518
  end
519
520
  test "the evidence rail says a trajectory exists and never shows one", %{conn: conn} do
521
    issue = issue!(%{"title" => "Recorded"})
522
    attempt = record_attempt(issue, "agent/trace", -60, %{state: "completed"})
523
524
    owner = OpenAgents.Repo.get!(Accounts.User, attempt.requesting_principal["id"])
525
526
    {:ok, _trace, :created} =
527
      OpenAgents.Traces.store(
528
        owner,
529
        %{
530
          "schema_version" => "ATIF-v1.7",
531
          "steps" => [%{"step_id" => 1, "message" => "SECRET-TRAJECTORY-CONTENT"}]
532
        },
533
        assignment_id: attempt.id,
534
        visibility: "ledger"
535
      )
536
537
    {:ok, view, html} = live(conn, path(issue))
538
539
    assert has_element?(view, "#issue-evidence")
540
    assert html =~ "An agent trajectory of 1 step was recorded"
541
    assert html =~ "contents are not published here"
542
543
    refute html =~ "SECRET-TRAJECTORY-CONTENT"
544
  end
545
546
  test "a trace the uploader did not consent to publishing stays off the page", %{conn: conn} do
547
    issue = issue!(%{"title" => "Withheld"})
548
    attempt = record_attempt(issue, "agent/dark", -60, %{state: "completed"})
549
    owner = OpenAgents.Repo.get!(Accounts.User, attempt.requesting_principal["id"])
550
551
    {:ok, _trace, :created} =
552
      OpenAgents.Traces.store(
553
        owner,
554
        %{"schema_version" => "ATIF-v1.7", "steps" => []},
555
        assignment_id: attempt.id
556
      )
557
558
    {:ok, view, _html} = live(conn, path(issue))
559
560
    refute has_element?(view, "#issue-evidence")
561
  end
562
466 563
  defp repository do
467 564
    OpenAgents.Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
468 565
  end

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