Decide, field by field, what a reader may see of work in progress

f6d98d2b54ab · AtlantisPleb · · parent 939208aa475c

Decide, field by field, what a reader may see of work in progress

`#70` shipped four tiers and attached them to changelog entries and releases —
records of work that finished. An attempt, a work job, and a deployment receipt
describe work someone is doing right now, and for those the question a tier
answers is not "may this reader see the record" but "which of its fields, and
why". An attempt carries a branch and a revision; a job carries a prompt, a
budget, and a report; an edge names an environment. Some of that is exactly
what public transparency is for. Some of it is the contents of a private
repository restated in a place the repository's own gate does not cover.

So the unit here is the field. `OpenAgents.Transparency.WorkDisclosure` states,
per family, the tier that first exposes each field and the columns no tier
exposes in any form, and every column of `forge_assignments`, `work_jobs`, and
`issue_evidence` is in exactly one of the two. A column in neither fails the
proof, so the schedule cannot fall behind the schema it describes.

**Attempt.** `pulse`: `id`, `target_kind`, `state`, `requester_kind`, and the
three timestamps — an attempt of a named shape ran and ended this way, naming
no machine and no ref. `ledger`: `branch` and `terminal_branch`, because a
branch is a ref in the repository's namespace and a caller may name it after
the work; `terminal_commit`, because a sha identifies repository content and
TRANSPARENCY-001 already puts shas at `:l2`; `failure_reason`, a detail of the
result rather than the bare fact of one. `glass` adds nothing: an attempt has
no field only its owner may read.

**Work job.** `pulse`: `id`, `kind`, `status`, `started_at`, `completed_at`.
`ledger`: `tool_call_count` and `continuation_count`, the same class of fact as
the module and node counts TRANSPARENCY-001 already admits; `error_code`; and
`budget`, the three bounds read out of `budget_snapshot`, because a bound on a
run is a fact about the run. `glass`: `report`, `usage`, `model_id` — the
report is model-authored prose up to eight kilobytes that may restate private
repository content verbatim, so it reaches the account the work belongs to and
an operator and nobody else. This is the one family whose `glass` rung carries
anything, and the one field on the timeline that is newly disclosed at all.

**Evidence edge.** `pulse`: `id`, `family`, `result`, `plane`, `source`,
`recorded_at` — which is precisely `#149`'s criterion that a public issue may
say restricted evidence exists. `ledger`: `commit`, `receipt_id`, and
`environment`. A receipt handle at `pulse` would be a pointer past the gate,
and an environment names the place bytes reached, which on the tenant plane is
a customer's name.

**The never list is not a fourth rung.** The prompt, the goal, the delegation,
the authority snapshot (a machine's roots, cwd, and name), the owner node (an
internal node name TRANSPARENCY-001 bans by name), the credential delivery
fields, the conversation, the surface, and an evidence edge's actor string are
withheld from every viewer including an operator. `requesting_principal` is not
in it because it is the source of `requester_kind` — the kind at `pulse`, never
the id, which is the rule TRANSPARENCY-001 already fixes for principals; the
proof asserts no projection value contains the actor id.

A reader's rung is their relationship to the repository, not to the record: an
operator is `glass`, a member is `ledger`, and every other reader who got this
far is `pulse`. That is the population a tier governs and repository authority
does not — on a public repository `readable_by/2` admits everybody, so every
rung assertion below runs on one. A private repository would have proved the
tier worked while the repository gate did the work.

`artifact_links` carries the ceiling and the consent, so
`Transparency.effective_tier/2` raises the requesting account to `glass` and
resolves a revoked link to `dark`; a revoked record leaves the projection
rather than appearing as an empty shell. An attempt an agent requested names no
account, gets no link, and has no owner to raise — an operator reaches its
report and nobody else does, which is why ADMIN-001 now accounts for this
module. Evidence edges inherit their attempt's link where the attempt produced
them, so revoking an attempt takes its receipts with it.

`#149` asked for the four members to be added "to the check constraint". There
was no such constraint: `#70` constrained the tier and left the artifact type
and the ref kind to a changeset a direct writer can skip. Both are constraints
now, along with the two new tier columns. `trace` is in the vocabulary and
nothing writes one; the enumeration asserts that rather than leaving a member
that reads as shipped.

One schedule, read from one place. `attempt_summary/2` and `Evidence.summary/2`
take the tier and project it; `issue_json.ex` renames two keys and adds none,
so a withheld field is an absent key rather than a `null`; and a proof reads
`lib/` and fails on any caller of a work projection that passes no viewer. The
same anonymous request to `/api/v3` and the same anonymous page get the same
answer because they are the same call.

Contract: INVARIANTS.md, TRANSPARENCY-001, extended rather than given an id of
its own; ADMIN-001 gains the one module. Mutation-checked, nine mutations, each
red on a named assertion: `branch` moved to `pulse`; `deadline_at` dropped from
the never list; `viewer/2` putting everybody at `ledger`; `effective_tier/2`
ignoring the link (twice, before and after the operator-raise fix the first run
exposed); `requester_kind` returning the whole principal; the evidence edge not
inheriting its attempt's link; the LiveView reading without a viewer;
`readable_by/2` admitting a private repository to anonymous traffic; and a
`dark` projection returning `%{}` instead of `nil`.

Two mutations did not bite at first and both were the test's fault. The
link-inheritance mutation passed because the helper fabricated the edge with
the attempt's link already on it; the helper now writes the edge through
`Evidence.bind_attempt/1` and a real build receipt, which is the only path that
sets `assignment_id` at all. The operator mutation exposed that a record with
no link never reached `glass` for anyone, because
`Transparency.effective_tier/2` raises only for a link; `unlinked_tier/2` now
applies the operator half, and a test covers the agent-requested attempt that
is the only record it can act on.

Stage 5 of #10, and the whole of Track E's E6. Closes #149. Unblocks the
independent-artifact-visibility criteria in #69 and #71.

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified INVARIANTS.md
  • modified docs/taxonomy.md
  • modified lib/openagents/forge/assignment.ex
  • modified lib/openagents/forge/assignments.ex
  • modified lib/openagents/issues/evidence.ex
  • modified lib/openagents/issues/evidence_entry.ex
  • modified lib/openagents/transparency/artifact_link.ex
  • added lib/openagents/transparency/work_disclosure.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/controllers/issue_json.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260824040140_add_transparency_tiers_to_work_records.exs
  • added test/openagents/transparency/work_disclosure_test.exs
  • modified test/openagents_web/controllers/issue_controller_test.exs
  • modified test/openagents_web/operator_surface_test.exs

Diff

16 files changed, +1553 -119

INVARIANTS.md modified +72 -4

@@ -2456,7 +2456,12 @@ sentence:

2456 2456
2457 2457
Reading a private forum board and raising a repository's transparency tier to
2458 2458
`glass` are operator reads that widen with the same allowlist
2459
(`OpenAgents.Forum`, `OpenAgents.Transparency`).
2459
(`OpenAgents.Forum`, `OpenAgents.Transparency`). Raising an *unlinked* work
2460
record to `glass` widens with it (`OpenAgents.Transparency.WorkDisclosure`):
2461
an attempt an agent requested carries no consent-bearing link and so has no
2462
owning account to raise, and an operator is the only reader that reaches its
2463
work job's report. Neither read touches the never list, which withholds the
2464
prompt, the authority snapshot, and the credential fields from an operator too.
2460 2465
2461 2466
None of these touches an account row, a conversation, a message, or a ban.
2462 2467
That bound is what remains of the original read-only claim, and it is the part

@@ -3147,16 +3152,79 @@ compiled import tables:

3147 3152
An anonymous route added under the repository scope fails until this contract
3148 3153
says which gate it is behind, and a handler that loses its gate fails with it.
3149 3154
3155
**Work in progress is disclosed field by field, not record by record.** The
3156
tracker surfaces above are readable exactly when the repository is, which
3157
settles who may open an issue and settles nothing about how much of an attempt,
3158
a work job, or a deployment receipt that issue then shows. An attempt carries a
3159
branch and a revision; a work job carries a prompt, a budget, and a report; an
3160
evidence edge names an environment. Some of that is what public transparency is
3161
for and some of it restates a private repository in a place the repository's own
3162
gate does not cover, so the unit of decision is the field.
3163
3164
`OpenAgents.Transparency.WorkDisclosure` is that decision, written down once:
3165
per family, the tier that first exposes each field, and the columns no tier
3166
exposes in any form. Three rules hold over it.
3167
3168
- **Every column is classified.** Each column of `forge_assignments`,
3169
  `work_jobs`, and `issue_evidence` is either the source of exactly one
3170
  scheduled field or a member of that family's never list, never both and never
3171
  neither. A new column fails the enumeration until somebody decides, which is
3172
  what keeps the schedule from falling behind the schema.
3173
- **The rungs mean the same thing everywhere.** `pulse` says work of a named
3174
  shape ran and how it came out, and names no ref, revision, receipt, or place;
3175
  `ledger` adds those, which is exactly where this contract already puts shas,
3176
  paths, counts, and timings; `glass` adds only the work job's own output — the
3177
  report, the usage, the model — and reaches only the account the work belongs
3178
  to and an operator; `dark` removes the record from the projection rather than
3179
  leaving an empty shell that still says it existed.
3180
- **The never list is not a fourth rung.** The prompt, the goal, the delegation,
3181
  the authority snapshot, the owner node, the credential delivery fields, the
3182
  conversation, and an evidence edge's actor string are withheld from every
3183
  viewer including an operator, because publishing them would restate a private
3184
  repository, an internal node name, or a principal's id — each of which the
3185
  bounds above already refuse.
3186
3187
A reader's rung is their relationship to the repository, not to the record: an
3188
operator is `glass`, a member is `ledger`, and every other reader who got this
3189
far is `pulse`. `artifact_links` carries the ceiling and the consent, so
3190
`Transparency.effective_tier/2` raises the account that requested the attempt to
3191
`glass` and resolves a revoked link to `dark`; an attempt an agent requested
3192
names no account and so has no owner to raise. Evidence edges inherit their
3193
attempt's link, so revoking an attempt takes its receipts with it.
3194
3195
**Repository authority stays stronger than every rung.**
3196
`Repositories.readable_by/2` runs first and raises, so a tier can only narrow
3197
what a reader who already passed it sees. A record whose link and column both
3198
say `glass`, in a repository that went private, is invisible to a non-member —
3199
the one case no rung of this ladder can reach, and the case the proof exercises.
3200
Every rung assertion runs on a *public* repository, where `readable_by/2` admits
3201
everybody and the tier is the only gate; a private repository would have proved
3202
the tier worked when the repository gate was doing the work.
3203
3204
`ArtifactLink.artifact_types/0` gained `attempt`, `work_job`, `deployment`, and
3205
`trace`, and PostgreSQL now holds that vocabulary and the ref-kind vocabulary as
3206
check constraints rather than leaving both to a changeset a direct writer can
3207
skip. `trace` has no producer: the vocabulary admits one, nothing writes one,
3208
and the enumeration asserts exactly that.
3209
3150 3210
(Amended 2026-08-23, issue #173: `/<owner>/<repo>/tree/:ref/*path` was already
3151 3211
served and gated when this contract listed three forge paths, and the tracker
3152 3212
surfaces were public without the contract saying what governed them. Both are
3153 3213
now stated.)
3154 3214
3215
(Amended 2026-08-24, issue #149: the tracker rule said which readers reach an
3216
issue and nothing about how much of the work on it they see. The field-by-field
3217
schedule above is that missing half.)
3218
3155 3219
Evidence: `OpenAgents.Forge.Visibility`, `OpenAgents.Forge.Browse`, `OpenAgents.Changelog`,
3156 3220
`OpenAgents.Changelog.Entry`, `OpenAgentsWeb.ChangelogLive`, `OpenAgentsWeb.CodeRepoLive`,
3157 3221
`OpenAgentsWeb.CodeCommitLive`, `OpenAgentsWeb.CodeTreeLive`, `OpenAgentsWeb.CodeBlobLive`,
3158
`OpenAgentsWeb.RepositoryAccess`, `OpenAgentsWeb.ChangelogController`, their tests, and the
3159
surface enumeration in `OpenAgentsWeb.TransparencySurfaceTest`.
3222
`OpenAgentsWeb.RepositoryAccess`, `OpenAgentsWeb.ChangelogController`, their tests, the
3223
surface enumeration in `OpenAgentsWeb.TransparencySurfaceTest`,
3224
`OpenAgents.Transparency.WorkDisclosure`, `OpenAgents.Transparency.ArtifactLink`,
3225
the `artifact_links` type and ref check constraints, the `transparency_tier` check
3226
constraints on `forge_assignments` and `issue_evidence`, and the field-by-field
3227
enumeration in `OpenAgents.Transparency.WorkDisclosureTest`.
3160 3228
3161 3229
### REPOSITORY-001 — GitHub identity names repositories; OpenAgents owns stored snapshots
3162 3230

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

4602 4670
| RELEASE-009 | `test/openagents/forge/deployment_lane_test.exs`, `test/openagents/forge/hot_loader_test.exs` |
4603 4671
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
4604 4672
| CAPACITY-001 | `test/openagents/capacity_test.exs` |
4605
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs`, `test/openagents_web/transparency_surface_test.exs` |
4673
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs`, `test/openagents_web/transparency_surface_test.exs`, `test/openagents/transparency/work_disclosure_test.exs` |
4606 4674
| REPOSITORY-001 | `test/openagents/repositories/visibility_join_test.exs`, `test/openagents/data_rights/account_export_test.exs`, `test/openagents_web/live/computers_repository_access_test.exs`, `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
4607 4675
| API-001 | `test/openagents_web/controllers/api_extension_governance_test.exs`, `test/openagents/issue_progress_test.exs` |
4608 4676
| CONTRIBUTION-001 | `test/openagents_web/contribution_contract_test.exs` |
docs/taxonomy.md modified +14 -2

@@ -191,8 +191,20 @@ application's either: repository-side disclosure uses the four levels in

191 191
A trace is not authority: PostgreSQL turns, tool steps, and receipts remain
192 192
the source. A conversation is not a trace until it is exported. Changelog
193 193
rows may carry `trace_ref` and `trace_digest` pointers. Trace visibility
194
tiers shipped with issue #70; binding a trace to an issue timeline
195
(work-system E6, issue #10) has not.
194
tiers shipped with issue #70. `ArtifactLink.artifact_types/0` gained a
195
`trace` member with issue #149, and nothing writes one: binding a trace to an
196
issue timeline (work-system E6, issue #10) is still unbuilt, and the
197
vocabulary admitting the word is not the same as a surface producing it.
198
199
**Disclosure schedule** — the field-by-field decision in
200
`OpenAgents.Transparency.WorkDisclosure` about which tier first exposes each
201
field of an attempt, a work job, or an evidence edge, and which columns no
202
tier exposes at all. It is not the *disclosure level*: a level
203
(`OpenAgents.Forge.Visibility`, `:l0`-`:l3`) is operator-owned per-repository
204
configuration that governs source and history, while a schedule is a
205
per-field rule about work in progress that the issue tracker's own
206
repository-readability gate does not settle. Say "level" for the dial and
207
"schedule" for the field rules. Both use the same four tier words.
196 208
197 209
**Trajectory** — the ATIF schema name for the document (`AtifTrajectory`,
198 210
`trajectory.json`, `trajectory_id`). Use it in schema and code. Product copy
lib/openagents/forge/assignment.ex modified +6 -1

@@ -32,6 +32,8 @@ defmodule OpenAgents.Forge.Assignment do

32 32
    field :admitted_at, :utc_datetime_usec
33 33
    field :started_at, :utc_datetime_usec
34 34
    field :finished_at, :utc_datetime_usec
35
    field :transparency_tier, :string, default: "ledger"
36
    belongs_to :artifact_link, OpenAgents.Transparency.ArtifactLink
35 37
    timestamps()
36 38
  end
37 39

@@ -59,7 +61,9 @@ defmodule OpenAgents.Forge.Assignment do

59 61
      :conversation_id,
60 62
      :target_kind,
61 63
      :credential_delivery_status,
62
      :credential_delivery_reason
64
      :credential_delivery_reason,
65
      :transparency_tier,
66
      :artifact_link_id
63 67
    ])
64 68
    |> put_programmatic(attrs, :conversation_box_id)
65 69
    |> put_programmatic(attrs, :machine_id)

@@ -75,6 +79,7 @@ defmodule OpenAgents.Forge.Assignment do

75 79
      :admitted_at
76 80
    ])
77 81
    |> validate_inclusion(:state, @states)
82
    |> validate_inclusion(:transparency_tier, OpenAgents.Transparency.ArtifactLink.tiers())
78 83
    |> validate_format(
79 84
      :branch,
80 85
      ~r/\A(?![.-])(?!.*(?:\.\.|@\{|[ ~^:?*\[\\]))[^\s:]+(?<!\.)(?<!\/)(?<!\.lock)\z/
lib/openagents/forge/assignments.ex modified +83 -30

@@ -19,10 +19,18 @@ defmodule OpenAgents.Forge.Assignments do

19 19
  alias OpenAgents.Repo
20 20
  alias OpenAgents.Conversations
21 21
  alias OpenAgents.Repositories.Repository
22
  alias OpenAgents.Transparency.WorkDisclosure
23
  alias OpenAgents.Work.Job
22 24
23 25
  @prefix "oa_assignment_"
24 26
  @terminal_states ~w(completed failed cancelled)
25 27
28
  # A caller that names no viewer is an internal caller: the executor, the
29
  # janitor, the reconciler. They read the record, not a projection of it, so
30
  # nothing is clamped. Every surface a reader reaches passes a real viewer
31
  # from `WorkDisclosure.viewer/2`, and the surface enumeration proves it.
32
  @unclamped %{account_id: nil, tier: :glass, admin: true}
33
26 34
  @doc "Creates an assignment, claims its issue, mints its credential, and starts its run."
27 35
  @spec create(map()) :: {:ok, Assignment.t(), String.t()} | {:error, term()}
28 36
  def create(attrs) when is_map(attrs) do

@@ -139,15 +147,19 @@ defmodule OpenAgents.Forge.Assignments do

139 147
  reads the attempts that already exist in `forge_assignments`, so an issue
140 148
  with no agent work returns an empty list rather than an absent fact.
141 149
  """
142
  @spec attempts_for_issue(Issue.t() | integer()) :: [map()]
143
  def attempts_for_issue(%Issue{id: id}), do: attempts_for_issue(id)
150
  @spec attempts_for_issue(Issue.t() | integer(), map()) :: [map()]
151
  def attempts_for_issue(issue_id, viewer \\ @unclamped)
152
153
  def attempts_for_issue(%Issue{id: id}, viewer), do: attempts_for_issue(id, viewer)
144 154
145
  def attempts_for_issue(issue_id) when is_integer(issue_id) do
155
  def attempts_for_issue(issue_id, viewer) when is_integer(issue_id) do
146 156
    Assignment
147 157
    |> where([assignment], assignment.issue_id == ^issue_id)
148 158
    |> order_by([assignment], asc: assignment.admitted_at, asc: assignment.id)
159
    |> preload([:artifact_link, :work_job])
149 160
    |> Repo.all()
150
    |> Enum.map(&attempt_summary/1)
161
    |> Enum.map(&attempt_summary(&1, viewer))
162
    |> Enum.reject(&is_nil/1)
151 163
  end
152 164
153 165
  @doc """

@@ -157,47 +169,79 @@ defmodule OpenAgents.Forge.Assignments do

157 169
  prerequisites, so listing issues does not cost one query per row. Every
158 170
  issue in `issues` appears in the result, with `[]` when it has no attempt.
159 171
  """
160
  @spec attempts_for_issues([Issue.t()]) :: %{integer() => [map()]}
161
  def attempts_for_issues(issues) when is_list(issues) do
172
  @spec attempts_for_issues([Issue.t()], map()) :: %{integer() => [map()]}
173
  def attempts_for_issues(issues, viewer \\ @unclamped) when is_list(issues) do
162 174
    ids = Enum.map(issues, & &1.id)
163 175
    base = Map.new(ids, &{&1, []})
164 176
165 177
    Assignment
166 178
    |> where([assignment], assignment.issue_id in ^ids)
167 179
    |> order_by([assignment], asc: assignment.admitted_at, asc: assignment.id)
180
    |> preload([:artifact_link, :work_job])
168 181
    |> Repo.all()
169 182
    |> Enum.reduce(base, fn assignment, acc ->
170
      Map.update(acc, assignment.issue_id, [attempt_summary(assignment)], fn existing ->
171
        existing ++ [attempt_summary(assignment)]
172
      end)
183
      case attempt_summary(assignment, viewer) do
184
        nil -> acc
185
        summary -> Map.update(acc, assignment.issue_id, [summary], &(&1 ++ [summary]))
186
      end
173 187
    end)
174 188
  end
175 189
176 190
  @doc """
177
  The bounded projection of one attempt.
191
  The bounded projection of one attempt, at the tier `viewer` is admitted to.
192
193
  Which field each rung first exposes is decided once, in
194
  `OpenAgents.Transparency.WorkDisclosure`, and this function only reads that
195
  schedule. It cannot publish a column the schedule has not classified, and it
196
  returns `nil` at `dark`, so a revoked link removes the attempt from the
197
  timeline rather than leaving an empty shell that still says it existed.
178 198
179
  It carries only what the claim and result comments already publish on the
180
  issue: the target kind, the branch, the exact commit, the terminal state,
181
  and the timestamps. The conversation, the prompt, the credential, and the
182
  work job's report stay out, because they belong to the requesting account
183
  rather than to everyone who can read the issue.
199
  The attempt's own job is projected beside it as its own family, so the
200
  report reaches the account the work belongs to and nobody else, rather than
201
  travelling on the attempt's tier.
184 202
  """
185
  @spec attempt_summary(Assignment.t()) :: map()
186
  def attempt_summary(%Assignment{} = assignment) do
187
    %{
188
      id: assignment.id,
189
      target_kind: assignment.target_kind,
190
      state: assignment.state,
191
      branch: assignment.branch,
192
      terminal_branch: assignment.terminal_branch,
193
      terminal_commit: assignment.terminal_commit,
194
      failure_reason: assignment.failure_reason,
195
      admitted_at: assignment.admitted_at,
196
      started_at: assignment.started_at,
197
      finished_at: assignment.finished_at
198
    }
203
  @spec attempt_summary(Assignment.t(), map()) :: map() | nil
204
  def attempt_summary(assignment, viewer \\ @unclamped)
205
206
  def attempt_summary(%Assignment{} = assignment, viewer) do
207
    tier = WorkDisclosure.effective_tier(assignment, viewer)
208
209
    case WorkDisclosure.project(:attempt, attempt_source(assignment), tier) do
210
      nil -> nil
211
      projection -> Map.put(projection, :work_job, work_job_summary(assignment, tier))
212
    end
199 213
  end
200 214
215
  defp attempt_source(%Assignment{} = assignment) do
216
    assignment
217
    |> Map.from_struct()
218
    |> Map.put(:requester_kind, requester_kind(assignment.requesting_principal))
219
  end
220
221
  # TRANSPARENCY-001 publishes a principal's kind and never its id. The
222
  # narration comment `#147` retires published the agent itself; this publishes
223
  # that an agent asked, which is the half that contract admits.
224
  defp requester_kind(%{"type" => type}) when type in ["user", "agent"], do: type
225
  defp requester_kind(_), do: nil
226
227
  defp work_job_summary(%Assignment{work_job: %Job{} = job}, tier) do
228
    job
229
    |> Map.from_struct()
230
    |> Map.put(:budget, budget_bounds(job.budget_snapshot))
231
    |> then(&WorkDisclosure.project(:work_job, &1, tier))
232
  end
233
234
  defp work_job_summary(%Assignment{}, _tier), do: nil
235
236
  # The bounds, never the snapshot. `maximum_prompt_bytes` is a ceiling on a
237
  # prompt no tier publishes, which is why the ceiling is safe and the prompt
238
  # is not.
239
  defp budget_bounds(snapshot) when is_map(snapshot) do
240
    Map.take(snapshot, ["wall_clock_ms", "maximum_report_bytes", "maximum_prompt_bytes"])
241
  end
242
243
  defp budget_bounds(_snapshot), do: %{}
244
201 245
  defp target_kind(attrs) do
202 246
    case attrs[:target_kind] || attrs["target_kind"] do
203 247
      nil ->

@@ -417,7 +461,16 @@ defmodule OpenAgents.Forge.Assignments do

417 461
          admitted_at: now,
418 462
          target_kind: target_kind,
419 463
          credential_delivery_status: credential_delivery_status,
420
          credential_delivery_reason: credential_delivery_reason
464
          credential_delivery_reason: credential_delivery_reason,
465
          artifact_link_id:
466
            case WorkDisclosure.link_for_attempt(repository, principal, %{
467
                   "branch" => branch,
468
                   "target_kind" => target_kind
469
                 }) do
470
              {:ok, link} -> link.id
471
              :none -> nil
472
              {:error, changeset} -> Repo.rollback(changeset)
473
            end
421 474
        })
422 475
        |> Repo.insert()
423 476
        |> case do
lib/openagents/issues/evidence.ex modified +73 -27

@@ -72,6 +72,11 @@ defmodule OpenAgents.Issues.Evidence do

72 72
  alias OpenAgents.Issues.{ClosingReference, CompletionClaims, EvidenceEntry, Issue}
73 73
  alias OpenAgents.Repo
74 74
  alias OpenAgents.Repositories.Repository
75
  alias OpenAgents.Transparency.WorkDisclosure
76
77
  # See `OpenAgents.Forge.Assignments`: an internal caller reads the record,
78
  # not a projection of it. Every reader-facing surface passes a real viewer.
79
  @unclamped %{account_id: nil, tier: :glass, admin: true}
75 80
76 81
  # The forge release plane converges one fleet. Naming it rather than leaving
77 82
  # it blank is what lets the environment refusal mean something on both planes.

@@ -140,15 +145,19 @@ defmodule OpenAgents.Issues.Evidence do

140 145
  reads edges, so an issue with no evidence returns an empty list rather than
141 146
  an absent fact.
142 147
  """
143
  @spec for_issue(Issue.t() | integer()) :: [map()]
144
  def for_issue(%Issue{id: id}), do: for_issue(id)
148
  @spec for_issue(Issue.t() | integer(), map()) :: [map()]
149
  def for_issue(issue_id, viewer \\ @unclamped)
150
151
  def for_issue(%Issue{id: id}, viewer), do: for_issue(id, viewer)
145 152
146
  def for_issue(issue_id) when is_integer(issue_id) do
153
  def for_issue(issue_id, viewer) when is_integer(issue_id) do
147 154
    EvidenceEntry
148 155
    |> where([entry], entry.issue_id == ^issue_id)
149 156
    |> order_by([entry], asc: entry.inserted_at, asc: entry.id)
157
    |> preload(:artifact_link)
150 158
    |> Repo.all()
151
    |> Enum.map(&summary/1)
159
    |> Enum.map(&summary(&1, viewer))
160
    |> Enum.reject(&is_nil/1)
152 161
  end
153 162
154 163
  @doc """

@@ -158,41 +167,58 @@ defmodule OpenAgents.Issues.Evidence do

158 167
  reads attempts, so listing issues does not cost one query per row. Every issue
159 168
  in `issues` appears in the result, with `[]` when it has no evidence.
160 169
  """
161
  @spec for_issues([Issue.t()]) :: %{integer() => [map()]}
162
  def for_issues(issues) when is_list(issues) do
170
  @spec for_issues([Issue.t()], map()) :: %{integer() => [map()]}
171
  def for_issues(issues, viewer \\ @unclamped) when is_list(issues) do
163 172
    ids = Enum.map(issues, & &1.id)
164 173
    base = Map.new(ids, &{&1, []})
165 174
166 175
    EvidenceEntry
167 176
    |> where([entry], entry.issue_id in ^ids)
168 177
    |> order_by([entry], asc: entry.inserted_at, asc: entry.id)
178
    |> preload(:artifact_link)
169 179
    |> Repo.all()
170 180
    |> Enum.reduce(base, fn entry, acc ->
171
      Map.update(acc, entry.issue_id, [summary(entry)], &(&1 ++ [summary(entry)]))
181
      case summary(entry, viewer) do
182
        nil -> acc
183
        projection -> Map.update(acc, entry.issue_id, [projection], &(&1 ++ [projection]))
184
      end
172 185
    end)
173 186
  end
174 187
175 188
  @doc """
176
  The bounded projection of one evidence edge.
177
178
  It carries the receipt's identity and its outcome, and nothing about the
179
  execution that produced it. The work job's report, the attempt's prompt, and
180
  the credential stay out, because they belong to the requesting account rather
181
  than to everyone who can read the issue.
189
  The bounded projection of one evidence edge, at the tier `viewer` is
190
  admitted to.
191
192
  `pulse` is the acceptance criterion "a public issue can say that restricted
193
  evidence exists": the family and the receipt's own verdict, without the
194
  revision, the receipt handle, or the environment those bytes reached.
195
  `ledger` adds those three. Which field sits on which rung is decided in
196
  `OpenAgents.Transparency.WorkDisclosure` and read from there, never restated
197
  here.
198
199
  Nothing about the execution appears at any rung. The work job's report, the
200
  attempt's prompt, and the credential are in that schedule's never list, not
201
  on a rung nobody has reached yet.
182 202
  """
183
  @spec summary(EvidenceEntry.t()) :: map()
184
  def summary(%EvidenceEntry{} = entry) do
185
    %{
186
      id: entry.id,
187
      commit: entry.commit_sha,
188
      family: entry.family,
189
      receipt_id: entry.receipt_id,
190
      plane: entry.plane,
191
      environment: entry.environment,
192
      result: entry.result,
193
      source: entry.source,
194
      recorded_at: entry.inserted_at
195
    }
203
  @spec summary(EvidenceEntry.t(), map()) :: map() | nil
204
  def summary(entry, viewer \\ @unclamped)
205
206
  def summary(%EvidenceEntry{} = entry, viewer) do
207
    WorkDisclosure.project(
208
      :evidence,
209
      %{
210
        id: entry.id,
211
        commit: entry.commit_sha,
212
        family: entry.family,
213
        receipt_id: entry.receipt_id,
214
        plane: entry.plane,
215
        environment: entry.environment,
216
        result: entry.result,
217
        source: entry.source,
218
        recorded_at: entry.inserted_at
219
      },
220
      WorkDisclosure.effective_tier(entry, viewer)
221
    )
196 222
  end
197 223
198 224
  # ── receipt-side entry points ───────────────────────────────────────────

@@ -491,7 +517,7 @@ defmodule OpenAgents.Issues.Evidence do

491 517
      []
492 518
    else
493 519
      %EvidenceEntry{}
494
      |> EvidenceEntry.changeset(attrs)
520
      |> EvidenceEntry.changeset(inherit_link(attrs))
495 521
      |> Repo.insert(
496 522
        on_conflict: :nothing,
497 523
        conflict_target: [:issue_id, :commit_sha, :family, :receipt_id]

@@ -510,6 +536,26 @@ defmodule OpenAgents.Issues.Evidence do

510 536
    end
511 537
  end
512 538
539
  # An edge an attempt produced consents on the attempt's link, not on one of
540
  # its own. The attempt is where a person chose to start work and where a
541
  # revocation has to bite: revoking the attempt's link must take its receipts
542
  # with it, and a second link per edge would leave them behind.
543
  defp inherit_link(%{assignment_id: id} = attrs) when is_binary(id) do
544
    case Repo.one(
545
           from a in Assignment,
546
             where: a.id == ^id,
547
             select: {a.artifact_link_id, a.transparency_tier}
548
         ) do
549
      {link_id, tier} when is_binary(tier) ->
550
        attrs |> Map.put(:artifact_link_id, link_id) |> Map.put(:transparency_tier, tier)
551
552
      _absent ->
553
        attrs
554
    end
555
  end
556
557
  defp inherit_link(attrs), do: attrs
558
513 559
  defp note_contradiction(%EvidenceEntry{} = entry) do
514 560
    CompletionClaims.note_evidence(entry)
515 561
  rescue
lib/openagents/issues/evidence_entry.ex modified +10 -1

@@ -49,6 +49,8 @@ defmodule OpenAgents.Issues.EvidenceEntry do

49 49
    belongs_to :repository, Repository, type: :binary_id
50 50
    belongs_to :issue, Issue
51 51
    belongs_to :assignment, Assignment, type: :binary_id
52
    field :transparency_tier, :string, default: "ledger"
53
    belongs_to :artifact_link, OpenAgents.Transparency.ArtifactLink, type: :binary_id
52 54
53 55
    timestamps(updated_at: false)
54 56
  end

@@ -79,7 +81,9 @@ defmodule OpenAgents.Issues.EvidenceEntry do

79 81
      :environment,
80 82
      :result,
81 83
      :actor,
82
      :source
84
      :source,
85
      :transparency_tier,
86
      :artifact_link_id
83 87
    ])
84 88
    |> update_change(:commit_sha, &String.downcase/1)
85 89
    |> validate_required([

@@ -96,6 +100,10 @@ defmodule OpenAgents.Issues.EvidenceEntry do

96 100
    |> validate_inclusion(:family, @families)
97 101
    |> validate_inclusion(:plane, @planes)
98 102
    |> validate_inclusion(:source, @sources)
103
    |> validate_inclusion(
104
      :transparency_tier,
105
      OpenAgents.Transparency.ArtifactLink.tiers()
106
    )
99 107
    |> validate_length(:environment, max: 120)
100 108
    |> validate_length(:result, max: 64)
101 109
    |> validate_length(:actor, max: 200)

@@ -103,5 +111,6 @@ defmodule OpenAgents.Issues.EvidenceEntry do

103 111
    |> foreign_key_constraint(:repository_id)
104 112
    |> foreign_key_constraint(:issue_id)
105 113
    |> foreign_key_constraint(:assignment_id)
114
    |> foreign_key_constraint(:artifact_link_id)
106 115
  end
107 116
end
lib/openagents/transparency/work_disclosure.ex added +413

@@ -0,0 +1,413 @@

1
defmodule OpenAgents.Transparency.WorkDisclosure do
2
  @moduledoc """
3
  The field-by-field disclosure schedule for work in progress.
4
5
  `OpenAgents.Transparency` fixed four tiers — `dark`, `pulse`, `ledger`,
6
  `glass` — and `#70` attached them to changelog entries and releases. Those
7
  are records of work that finished. An attempt, a work job, and a deployment
8
  receipt describe work someone is doing right now, and the question a tier
9
  answers for them is not "may this reader see the record" but "which of its
10
  fields, and why".
11
12
  So the unit here is the field, not the record. Each family below names the
13
  tier that first exposes each field, and a second list names the fields no
14
  tier exposes at all. A column in neither list is a test failure, which is
15
  what keeps this a schedule rather than an intention: a new column on
16
  `forge_assignments`, `work_jobs`, or `issue_evidence` fails
17
  `OpenAgents.Transparency.WorkDisclosureTest` until somebody decides.
18
19
  ## What each rung means here
20
21
    * `pulse` — that the work exists, what shape it is, and how it came out.
22
      A reader learns an attempt ran and finished, or that a deployment
23
      receipt evaluated something and what its verdict was. Nothing a reader
24
      learns at `pulse` names a ref, a revision, a receipt, or a place.
25
    * `ledger` — the content of the result: the branch, the revision, the
26
      receipt handle, the environment, the counts, the budget. These identify
27
      or address repository content, so they sit exactly where
28
      `TRANSPARENCY-001` puts shas, paths, counts, and timings.
29
    * `glass` — the work job's own output. The report is model-authored prose
30
      bounded at eight kilobytes that may restate private repository content
31
      verbatim, so it reaches only the account the work belongs to and an
32
      operator. `Transparency.effective_tier/2` raises the owner of an
33
      `ArtifactLink` to `glass`; an attempt with no link has no owner to raise.
34
    * `dark` — withheld entirely. A revoked link resolves here, and the row
35
      leaves the projection rather than appearing as an empty shell.
36
37
  ## What no tier exposes
38
39
  The never list is not a tier-four field waiting for a viewer. It is the set
40
  of columns whose disclosure would restate, in a place the repository's own
41
  gate does not cover, something that gate exists to withhold: the prompt and
42
  the goal (the contents of a repository, retyped), the authority snapshot
43
  (a machine's roots, working directory, and name), the owner node (an
44
  internal node name `TRANSPARENCY-001` bans by name), the credential delivery
45
  fields, and the conversation the work was requested from.
46
47
  Repository authority is stronger than every rung of this ladder.
48
  `OpenAgents.Repositories.readable_by/2` runs first and raises; a tier can
49
  only narrow what a reader who already passed it sees. A record whose tier is
50
  `glass` in a repository that went private is invisible, and that is the case
51
  the proof exercises.
52
  """
53
54
  alias OpenAgents.Accounts
55
  alias OpenAgents.Repositories
56
  alias OpenAgents.Transparency
57
  alias OpenAgents.Transparency.ArtifactLink
58
59
  @families ~w(attempt work_job evidence)a
60
61
  # ── attempt (forge_assignments) ─────────────────────────────────────────
62
  #
63
  # The bounded projection of one attempt. `pulse` says an attempt of a named
64
  # shape ran and how it ended; `ledger` adds the refs and the revision it
65
  # produced.
66
  @attempt %{
67
    # An opaque handle. It correlates this attempt's own events and names
68
    # nothing outside the repository the reader already reached.
69
    id: :pulse,
70
    # `box` or `computer`. Two structural values that say which execution
71
    # shape ran, never which box or which computer.
72
    target_kind: :pulse,
73
    # The attempt's own lifecycle word. "Work is happening, and it ended this
74
    # way" is the whole of what `pulse` is for.
75
    state: :pulse,
76
    # `user` or `agent`, derived from `requesting_principal`. TRANSPARENCY-001
77
    # publishes a principal's kind and never its id; the raw map is in the
78
    # never list below because it carries the id.
79
    requester_kind: :pulse,
80
    admitted_at: :pulse,
81
    started_at: :pulse,
82
    finished_at: :pulse,
83
    # A branch is a ref in the repository's namespace, and a caller may name it
84
    # after the work rather than after the issue. That is repository content.
85
    branch: :ledger,
86
    terminal_branch: :ledger,
87
    # A revision identifies repository content. TRANSPARENCY-001 already puts
88
    # shas at `:l2`, and putting one here at `pulse` would contradict it.
89
    terminal_commit: :ledger,
90
    # The executor's own word about how the run ended — a detail of the result
91
    # rather than the bare fact that there was one.
92
    failure_reason: :ledger
93
  }
94
95
  @attempt_never [
96
    # The prompt's home. A conversation is the requesting account's, not the
97
    # issue's readers'.
98
    :conversation_id,
99
    :conversation_box_id,
100
    # Names a machine and a run on it. TRANSPARENCY-001 bans internal node
101
    # names, and a box or computer id is one.
102
    :machine_id,
103
    :run_id,
104
    # The job is projected as its own family, gated on its own fields, rather
105
    # than handed over as an id that fetches all of them.
106
    :work_job_id,
107
    # A bound on the run, published once from the job's budget snapshot rather
108
    # than twice from two records.
109
    :deadline_at,
110
    # Credential metadata. TRANSPARENCY-001 admits no credential at any level.
111
    :credential_delivery_status,
112
    :credential_delivery_reason,
113
    # The reader named both in the URL that got them here. Re-publishing them
114
    # turns two internal identifiers into public ones for no gain.
115
    :repository_id,
116
    :issue_id,
117
    # The tier is the gate, not a field the gate discloses.
118
    :transparency_tier,
119
    :artifact_link_id,
120
    :inserted_at,
121
    :updated_at
122
  ]
123
124
  # ── work_job (work_jobs) ────────────────────────────────────────────────
125
  #
126
  # The job is the execution behind an attempt. It is the one family with a
127
  # `glass` rung that carries anything, because it is the one family that
128
  # stores output.
129
  @work_job %{
130
    id: :pulse,
131
    # Which of five delegation shapes ran.
132
    kind: :pulse,
133
    status: :pulse,
134
    started_at: :pulse,
135
    completed_at: :pulse,
136
    # How much work happened. TRANSPARENCY-001 already admits module and node
137
    # counts at `:l2`, and these are the same class of fact.
138
    tool_call_count: :ledger,
139
    continuation_count: :ledger,
140
    # A bounded typed word about how the run ended.
141
    error_code: :ledger,
142
    # Derived from `budget_snapshot`: the wall clock, the report ceiling, and
143
    # the prompt ceiling. A bound on the run is a fact about the run. The
144
    # prompt the ceiling applies to is never published.
145
    budget: :ledger,
146
    # Model-authored prose, up to eight kilobytes, which may restate private
147
    # repository content verbatim. Only the account the work belongs to, and
148
    # an operator.
149
    report: :glass,
150
    # Token and cost accounting, which is billing about that account.
151
    usage: :glass,
152
    # Which model did that account's work.
153
    model_id: :glass
154
  }
155
156
  @work_job_never [
157
    # The issue's own sentence: the contents of a private repository restated
158
    # in a place the repository's gate does not cover.
159
    :goal,
160
    :context_hint,
161
    :delegation,
162
    # Carries `roots`, `cwd`, `machine_name`, and `agent_id` — the machine's
163
    # filesystem shape and the operator's node name.
164
    :authority_snapshot,
165
    :machine_id,
166
    :conversation_id,
167
    :owner_visitor_id,
168
    :requesting_tool_step_ref,
169
    # Handles into the operator's own substrate.
170
    :instruction_digest,
171
    :tool_catalog_digest,
172
    :memory_snapshot_ref,
173
    :report_message_id,
174
    # An internal node name, banned by TRANSPARENCY-001 by name.
175
    :owner_node,
176
    :generation,
177
    # `text` or `voice` says how the requester was talking to us, which is
178
    # conversation metadata about that account.
179
    :surface,
180
    :inserted_at,
181
    :updated_at
182
  ]
183
184
  # ── evidence (issue_evidence) ───────────────────────────────────────────
185
  #
186
  # The edge `#148` recorded. `pulse` is exactly the acceptance criterion "a
187
  # public issue can say that restricted evidence exists": the family and the
188
  # verdict, without the artifact, the revision, or the place.
189
  @evidence %{
190
    id: :pulse,
191
    # Which of four receipt families evaluated the commit. This is the
192
    # existence disclosure.
193
    family: :pulse,
194
    # The receipt's own terminal word — the outcome, without the artifact.
195
    result: :pulse,
196
    # `forge` or `tenant`. Two structural values.
197
    plane: :pulse,
198
    # `closing_reference` or `assignment`: how the commit resolved to the
199
    # issue.
200
    source: :pulse,
201
    recorded_at: :pulse,
202
    # A revision identifies repository content, as on the attempt.
203
    commit: :ledger,
204
    # The handle that fetches the receipt. At `pulse` it would be a pointer
205
    # past the gate.
206
    receipt_id: :ledger,
207
    # Names the place bytes reached. On the tenant plane that is a
208
    # customer-named environment, so a reader learns at `pulse` that a
209
    # deployment happened and how it came out, and at `ledger` where.
210
    environment: :ledger
211
  }
212
213
  @evidence_never [
214
    # Carries a principal's id. TRANSPARENCY-001 publishes kinds, not ids.
215
    :actor,
216
    # The attempt publishes its own revision at `ledger` and the edge
217
    # publishes the commit at `ledger`, so the two join on the revision
218
    # without a second identifier crossing the gate.
219
    :assignment_id,
220
    :repository_id,
221
    :issue_id,
222
    :transparency_tier,
223
    :artifact_link_id
224
  ]
225
226
  # The schema column each projection field is read from. Three fields are not
227
  # 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.
233
  @attempt_columns %{requester_kind: :requesting_principal}
234
  @work_job_columns %{budget: :budget_snapshot}
235
  @evidence_columns %{commit: :commit_sha, recorded_at: :inserted_at}
236
237
  @schedule %{attempt: @attempt, work_job: @work_job, evidence: @evidence}
238
  @columns %{
239
    attempt: @attempt_columns,
240
    work_job: @work_job_columns,
241
    evidence: @evidence_columns
242
  }
243
  @never %{
244
    attempt: @attempt_never,
245
    work_job: @work_job_never,
246
    evidence: @evidence_never
247
  }
248
249
  @doc "The families this schedule covers."
250
  @spec families() :: [atom()]
251
  def families, do: @families
252
253
  @doc "The whole schedule, as `%{family => %{field => tier}}`."
254
  @spec schedule() :: %{atom() => %{atom() => atom()}}
255
  def schedule, do: @schedule
256
257
  @doc "The columns of `family` that no tier discloses, in any form."
258
  @spec never(atom()) :: [atom()]
259
  def never(family) when family in @families, do: Map.fetch!(@never, family)
260
261
  @doc """
262
  The schema column each scheduled field of `family` is read from.
263
264
  A field with no entry is read from the column of the same name.
265
  """
266
  @spec source_columns(atom()) :: [atom()]
267
  def source_columns(family) when family in @families do
268
    renames = Map.fetch!(@columns, family)
269
270
    @schedule
271
    |> Map.fetch!(family)
272
    |> Map.keys()
273
    |> Enum.map(&Map.get(renames, &1, &1))
274
    |> Enum.uniq()
275
    |> Enum.sort()
276
  end
277
278
  @doc "The tier that first exposes `field` of `family`, or `nil` if none does."
279
  @spec tier_for(atom(), atom()) :: atom() | nil
280
  def tier_for(family, field) when family in @families,
281
    do: @schedule |> Map.fetch!(family) |> Map.get(field)
282
283
  @doc """
284
  The fields of `family` that `tier` admits, in schedule order.
285
286
  `dark` admits nothing, which is why a `dark` projection is `nil` rather than
287
  an empty map: an empty shell would still say the record exists.
288
  """
289
  @spec fields_at(atom(), atom()) :: [atom()]
290
  def fields_at(family, tier) when family in @families do
291
    @schedule
292
    |> Map.fetch!(family)
293
    |> Enum.filter(fn {_field, at} -> Transparency.allows?(at, capability(at), tier) end)
294
    |> Enum.map(&elem(&1, 0))
295
    |> Enum.sort()
296
  end
297
298
  # `allows?/3` asks whether a tier admits a capability, and the schedule is
299
  # written in tiers. Mapping each tier back to the capability it is the
300
  # minimum for keeps one comparison function rather than a second ladder.
301
  defp capability(:pulse), do: :metadata
302
  defp capability(:ledger), do: :content
303
  defp capability(:glass), do: :full
304
  defp capability(_), do: :full
305
306
  @doc """
307
  The viewer descriptor for `repository` and `user`.
308
309
  It carries both halves `OpenAgents.Transparency` needs: `account_id`, which
310
  `effective_tier/2` compares against an `ArtifactLink`'s owner, and `tier`,
311
  which clamps everything else down.
312
313
  The three rungs are the reader's relationship to the repository, not to the
314
  record:
315
316
    * an operator is `glass`, as `Transparency.viewer_tier/1` already says;
317
    * a member of the repository is `ledger`, because membership is what
318
      `Repositories.readable_by/2` admits a private repository's reader on;
319
    * every other reader who got this far is `pulse`, which on a public
320
      repository is anonymous traffic and is the only population a tier
321
      governs that repository authority does not.
322
  """
323
  @spec viewer(Repositories.Repository.t(), Accounts.User.t() | nil) :: map()
324
  def viewer(repository, user) do
325
    cond do
326
      is_struct(user, Accounts.User) and Accounts.admin?(user) ->
327
        %{account_id: user.id, tier: :glass, admin: true}
328
329
      is_struct(user, Accounts.User) and Repositories.member?(repository, user) ->
330
        %{account_id: user.id, tier: :ledger}
331
332
      is_struct(user, Accounts.User) ->
333
        %{account_id: user.id, tier: :pulse}
334
335
      true ->
336
        %{account_id: nil, tier: :pulse}
337
    end
338
  end
339
340
  @doc """
341
  The effective tier for one record and one viewer.
342
343
  A record that carries a loaded `ArtifactLink` resolves through it, so a
344
  revoked link is `dark` and the link's owning account is `glass`. A record
345
  with no link resolves through its own tier column and has no owner to raise:
346
  an attempt requested by an agent names no account, so nothing about it can
347
  reach `glass` for anyone but an operator.
348
  """
349
  @spec effective_tier(map(), map()) :: atom()
350
  def effective_tier(record, viewer) do
351
    case Map.get(record, :artifact_link) do
352
      %ArtifactLink{} = link -> Transparency.effective_tier(link, viewer)
353
      _absent -> unlinked_tier(Map.get(record, :transparency_tier), viewer)
354
    end
355
  end
356
357
  # `Transparency.effective_tier/2` raises an owner *or* an operator to `glass`
358
  # for a link, and only clamps for a bare tier. A record with no link has no
359
  # owner, so the owner half has nothing to act on — but an operator is not the
360
  # record's owner and reaches `glass` either way. Applying that half here is
361
  # what keeps an agent-requested attempt readable by an operator and by nobody
362
  # else raised.
363
  defp unlinked_tier(_tier, %{admin: true}), do: :glass
364
  defp unlinked_tier(tier, viewer), do: Transparency.effective_tier(tier, viewer)
365
366
  @doc """
367
  Mints the consent-bearing link for one attempt, or `:none`.
368
369
  The link exists so `Transparency.effective_tier/2` can do two things a tier
370
  column alone cannot: raise the account the work belongs to to `glass`, and
371
  resolve to `dark` the moment `Transparency.revoke/3` stamps it. Its tier is
372
  `ledger` — the ceiling a repository member already had — and the viewer's own
373
  relationship to the repository clamps every other reader down from there.
374
375
  An attempt an agent requested names no account, so it gets `:none`. That is
376
  the honest answer rather than a link owned by nobody: with no owner there is
377
  nothing for `glass` to mean, and the tier column still clamps every reader.
378
  """
379
  @spec link_for_attempt(Repositories.Repository.t(), map(), map()) ::
380
          {:ok, ArtifactLink.t()} | :none | {:error, Ecto.Changeset.t()}
381
  def link_for_attempt(repository, principal, authority \\ %{})
382
383
  def link_for_attempt(%{id: repository_id}, %{"type" => "user", "id" => account_id}, authority)
384
      when is_binary(account_id) do
385
    %ArtifactLink{}
386
    |> ArtifactLink.changeset(%{
387
      account_id: account_id,
388
      repository_id: repository_id,
389
      artifact_type: "attempt",
390
      artifact_ref: "id",
391
      tier: "ledger",
392
      consent: %{"granted_by" => "requesting_principal"},
393
      authority_snapshot: authority
394
    })
395
    |> OpenAgents.Repo.insert()
396
  end
397
398
  def link_for_attempt(_repository, _principal, _authority), do: :none
399
400
  @doc """
401
  Projects `source` for `family` at `tier`, taking each admitted field from
402
  `source` by its projection name.
403
404
  Returns `nil` at `dark`.
405
  """
406
  @spec project(atom(), map(), atom()) :: map() | nil
407
  def project(family, source, tier) when family in @families do
408
    case fields_at(family, tier) do
409
      [] -> nil
410
      fields -> Map.new(fields, fn field -> {field, Map.get(source, field)} end)
411
    end
412
  end
413
end
lib/openagents_web/controllers/issue_controller.ex modified +19 -10

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

10 10
  alias OpenAgents.Agents.Agent
11 11
  alias OpenAgents.PullRequests
12 12
  alias OpenAgents.Repositories
13
  alias OpenAgents.Transparency.WorkDisclosure
13 14
  alias OpenAgentsWeb.ApiError
14 15
15 16
  import OpenAgentsWeb.ControllerHelpers, only: [integer_param!: 1, lookup: 1]

@@ -31,8 +32,8 @@ defmodule OpenAgentsWeb.IssueController do

31 32
        dependencies: Issues.dependency_graph(issues),
32 33
        progress: Issues.progress_map(issues, reader),
33 34
        pull_requests: PullRequests.markers_by_issue_id(issues),
34
        work: Assignments.attempts_for_issues(issues),
35
        evidence: Evidence.for_issues(issues),
35
        work: Assignments.attempts_for_issues(issues, viewer(repository, reader)),
36
        evidence: Evidence.for_issues(issues, viewer(repository, reader)),
36 37
        completion_claims: CompletionClaims.for_issues(issues),
37 38
        pagination: %{
38 39
          page: Issues.parse_page(params["page"]),

@@ -156,8 +157,8 @@ defmodule OpenAgentsWeb.IssueController do

156 157
          repo: repo,
157 158
          dependencies: dependencies(issue),
158 159
          progress: progress(issue, actor),
159
          work: work(issue),
160
          evidence: evidence(issue),
160
          work: work(issue, repository, actor),
161
          evidence: evidence(issue, repository, actor),
161 162
          completion_claims: completion_claims(issue)
162 163
        )
163 164

@@ -218,8 +219,8 @@ defmodule OpenAgentsWeb.IssueController do

218 219
        dependencies: dependencies(issue),
219 220
        progress: progress(issue, reader),
220 221
        pull_requests: PullRequests.markers_by_issue_id([issue]),
221
        work: work(issue),
222
        evidence: evidence(issue),
222
        work: work(issue, repository, reader),
223
        evidence: evidence(issue, repository, reader),
223 224
        completion_claims: completion_claims(issue)
224 225
      )
225 226
    else

@@ -253,8 +254,8 @@ defmodule OpenAgentsWeb.IssueController do

253 254
            repo: repo,
254 255
            dependencies: dependencies(issue),
255 256
            progress: progress(issue, user),
256
            work: work(issue),
257
            evidence: evidence(issue),
257
            work: work(issue, repository, user),
258
            evidence: evidence(issue, repository, user),
258 259
            completion_claims: completion_claims(issue)
259 260
          )
260 261

@@ -273,12 +274,20 @@ defmodule OpenAgentsWeb.IssueController do

273 274
274 275
  # One issue reads through the same page-shaped function the index uses, so
275 276
  # the detail response and a row in the list can never disagree.
276
  defp work(%Issue{} = issue), do: Assignments.attempts_for_issues([issue])
277
  defp work(%Issue{} = issue, repository, reader),
278
    do: Assignments.attempts_for_issues([issue], viewer(repository, reader))
277 279
278 280
  # The evidence chain reads through the same page-shaped function as the
279 281
  # index, for the same reason: a detail response and a row in the list can
280 282
  # never disagree about what shipped an issue.
281
  defp evidence(%Issue{} = issue), do: Evidence.for_issues([issue])
283
  defp evidence(%Issue{} = issue, repository, reader),
284
    do: Evidence.for_issues([issue], viewer(repository, reader))
285
286
  # One viewer descriptor, built the same way for every action, so the API and
287
  # the issue page cannot disagree about which rung a reader is on. An API
288
  # caller authenticating as an agent is not a repository member and lands on
289
  # `pulse`, which is the same answer anonymous web traffic gets.
290
  defp viewer(repository, reader), do: WorkDisclosure.viewer(repository, reader)
282 291
283 292
  # The claims read through the same page-shaped function as the index, for the
284 293
  # same reason: a detail response and a row in the list can never disagree
lib/openagents_web/controllers/issue_json.ex modified +20 -23

@@ -165,33 +165,30 @@ defmodule OpenAgentsWeb.IssueJSON do

165 165
    }
166 166
  end
167 167
168
  defp evidence_json(entry) do
169
    %{
170
      id: entry.id,
171
      commit: entry.commit,
172
      family: entry.family,
173
      receipt_id: entry.receipt_id,
174
      plane: entry.plane,
175
      environment: entry.environment,
176
      result: entry.result,
177
      source: entry.source,
178
      recorded_at: entry.recorded_at
179
    }
180
  end
168
  # The API renames two keys of the attempt projection and none of the
169
  # evidence projection, and adds nothing to either. Rendering exactly the keys
170
  # the projection returned is what keeps this from becoming a second
171
  # disclosure schedule: a tier that withheld `terminal_commit` produces a
172
  # response with no `commit` key at all rather than one carrying `null`, and a
173
  # field `OpenAgents.Transparency.WorkDisclosure` adds appears here without an
174
  # edit. `OpenAgentsWeb.IssueWorkDisclosureTest` reads both key sets from that
175
  # schedule, so a rename that loses a field fails.
176
  @attempt_key_names %{target_kind: :target, terminal_commit: :commit}
177
178
  defp evidence_json(entry), do: entry
181 179
182 180
  defp attempt_json(attempt) do
183
    %{
184
      id: attempt.id,
185
      target: attempt.target_kind,
186
      state: attempt.state,
187
      branch: attempt.branch,
188
      commit: attempt.terminal_commit,
189
      failure_reason: attempt.failure_reason,
190
      started_at: attempt.started_at || attempt.admitted_at,
191
      finished_at: attempt.finished_at
192
    }
181
    attempt
182
    |> Map.drop([:work_job])
183
    |> Map.new(fn {key, value} -> {Map.get(@attempt_key_names, key, key), value} end)
184
    |> put_attempt_work_job(attempt)
193 185
  end
194 186
187
  defp put_attempt_work_job(json, %{work_job: job}) when is_map(job),
188
    do: Map.put(json, :work_job, job)
189
190
  defp put_attempt_work_job(json, _attempt), do: json
191
195 192
  defp total_pages(0, _per_page), do: 1
196 193
197 194
  defp total_pages(total, per_page), do: ceil(total / per_page)
lib/openagents_web/live/issue_show_live.ex modified +34 -7

@@ -52,6 +52,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

52 52
  alias OpenAgents.Notifications
53 53
  alias OpenAgents.PullRequests
54 54
  alias OpenAgents.Repositories
55
  alias OpenAgents.Transparency.WorkDisclosure
55 56
  alias OpenAgentsWeb.LiveRefresh
56 57
  alias OpenAgentsWeb.OG
57 58
  alias OpenAgentsWeb.RelativeTime

@@ -530,7 +531,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

530 531
  # failure here would hide the one fact that tells someone what to do next.
531 532
  defp refusal(:assignment_issue_claimed, socket) do
532 533
    case live_attempt(socket.assigns[:attempts] || []) do
533
      %{branch: branch} ->
534
      %{branch: branch} when is_binary(branch) ->
534 535
        "Work is already running on this issue, on branch #{branch}. " <>
535 536
          "One attempt may be live at a time."
536 537

@@ -583,7 +584,16 @@ defmodule OpenAgentsWeb.IssueShowLive do

583 584
    # An issue can become a pull request while the page is open, and the badge
584 585
    # that says so was read at mount only.
585 586
586
    attempts = Assignments.attempts_for_issue(issue)
587
    # The page reads attempts at the rung this reader is on, from the same
588
    # schedule and the same viewer descriptor the API uses. That is what makes
589
    # "the same viewer gets the same answer on the page and in the API" a
590
    # property of one function rather than of two lists kept in step by hand.
591
    attempts =
592
      Assignments.attempts_for_issue(
593
        issue,
594
        WorkDisclosure.viewer(socket.assigns.repository, socket.assigns.current_user)
595
      )
596
587 597
    references = ClosingReferences.for_issue(issue)
588 598
    syncs = TaskReferences.for_issue(issue)
589 599
    base = "/#{socket.assigns.owner}/#{socket.assigns.repo}"

@@ -820,7 +830,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

820 830
            <h3 class="properties-panel__heading">Agent work</h3>
821 831
822 832
            <p :if={@live_attempt} class="properties-panel__none" id="issue-work-live">
823
              Work is running on branch <code>{@live_attempt.branch}</code>. One attempt may be live
833
              Work is running{live_attempt_branch(@live_attempt)}. One attempt may be live
824 834
              on an issue at a time.
825 835
            </p>
826 836

@@ -1083,7 +1093,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

1083 1093
  # started and nothing more rather than as a silent gap.
1084 1094
  defp attempt_events(attempts) do
1085 1095
    Enum.flat_map(attempts, fn attempt ->
1086
      started_at = attempt.started_at || attempt.admitted_at
1096
      started_at = attempt[:started_at] || attempt[:admitted_at]
1087 1097
1088 1098
      start =
1089 1099
        if started_at do

@@ -1103,7 +1113,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

1103 1113
        end
1104 1114
1105 1115
      finish =
1106
        if attempt.finished_at do
1116
        if attempt[:finished_at] do
1107 1117
          [
1108 1118
            %{
1109 1119
              kind: :event,

@@ -1111,8 +1121,8 @@ defmodule OpenAgentsWeb.IssueShowLive do

1111 1121
              text: attempt_finish_text(attempt),
1112 1122
              icon: attempt_icon(attempt),
1113 1123
              tone: attempt_tone(attempt),
1114
              at: stamp(attempt.finished_at),
1115
              sort: attempt.finished_at
1124
              at: stamp(attempt[:finished_at]),
1125
              sort: attempt[:finished_at]
1116 1126
            }
1117 1127
          ]
1118 1128
        else

@@ -1123,11 +1133,20 @@ defmodule OpenAgentsWeb.IssueShowLive do

1123 1133
    end)
1124 1134
  end
1125 1135
1136
  # A projection at `pulse` carries no branch and no revision, so every clause
1137
  # that names one matches on a key that may be absent rather than on a struct
1138
  # field that is always there. The sentence a reader gets is shorter, never
1139
  # wrong: an anonymous reader of a public repository is told that work started
1140
  # and how it ended, and is not told the ref it ran on.
1126 1141
  defp attempt_start_text(%{target_kind: "computer", branch: branch}),
1127 1142
    do: "started work on a computer, on branch #{branch}"
1128 1143
1129 1144
  defp attempt_start_text(%{branch: branch}), do: "started work on a box, on branch #{branch}"
1130 1145
1146
  defp attempt_start_text(%{target_kind: "computer"}), do: "started work on a computer"
1147
1148
  defp attempt_start_text(_attempt), do: "started work on a box"
1149
1131 1150
  defp attempt_finish_text(%{state: "completed", terminal_commit: commit})
1132 1151
       when is_binary(commit),
1133 1152
       do: "finished this work at #{String.slice(commit, 0, 7)}"

@@ -1141,6 +1160,14 @@ defmodule OpenAgentsWeb.IssueShowLive do

1141 1160
1142 1161
  defp attempt_finish_text(_attempt), do: "stopped this work"
1143 1162
1163
  defp live_attempt_branch(%{branch: branch}) when is_binary(branch),
1164
    do:
1165
      Phoenix.HTML.raw(
1166
        " on branch <code>#{Phoenix.HTML.html_escape(branch) |> Phoenix.HTML.safe_to_string()}</code>"
1167
      )
1168
1169
  defp live_attempt_branch(_attempt), do: ""
1170
1144 1171
  defp attempt_icon(%{state: "completed"}), do: "check-circle"
1145 1172
  defp attempt_icon(_attempt), do: "x-circle-filled"
1146 1173
priv/migration_lineages/prior-2026-08-19.json modified +7 -2

@@ -286,7 +286,8 @@

286 286
    20260824031651,
287 287
    20260824032138,
288 288
    20260824032226,
289
    20260824035934
289
    20260824035934,
290
    20260824040140
290 291
  ],
291 292
  "required_tables": [
292 293
    "users",

@@ -499,7 +500,11 @@

499 500
    "forge_fleet_target_status",
500 501
    "forge_deploys_result",
501 502
    "issue_completion_claims_close_requires_accepted",
502
    "issue_completion_claims_state"
503
    "issue_completion_claims_state",
504
    "artifact_links_artifact_type_check",
505
    "artifact_links_artifact_ref_check",
506
    "forge_assignments_transparency_tier_check",
507
    "issue_evidence_transparency_tier_check"
503 508
  ],
504 509
  "required_indexes": [
505 510
    "users_github_id_index",
priv/repo/migrations/20260824040140_add_transparency_tiers_to_work_records.exs added +46

@@ -0,0 +1,46 @@

1
defmodule OpenAgents.Repo.Migrations.AddTransparencyTiersToWorkRecords do
2
  use Ecto.Migration
3
4
  # `#70` gave `artifact_links` a check constraint on the tier and none on the
5
  # artifact type or the ref kind, so the vocabulary `ArtifactLink` validated
6
  # in Elixir was not a vocabulary the database held. Adding the work members
7
  # is the moment to make both real: a type nobody declared, and a ref kind
8
  # nobody declared, are now refused by PostgreSQL rather than by a changeset
9
  # a direct writer can skip.
10
  def change do
11
    create constraint(:artifact_links, :artifact_links_artifact_type_check,
12
             check:
13
               "artifact_type IN ('changelog','release','issue','build','attempt','work_job','deployment','trace')"
14
           )
15
16
    create constraint(:artifact_links, :artifact_links_artifact_ref_check,
17
             check: "artifact_ref IN ('sha','tag','digest','path','id')"
18
           )
19
20
    # The tier is the ceiling the record consents to, and the viewer's own
21
    # relationship to the repository clamps it down from there. `ledger` is the
22
    # default because it is what a repository member already saw: the column
23
    # narrows anonymous traffic on a public repository, and narrows nobody who
24
    # was already inside.
25
    alter table(:forge_assignments) do
26
      add :transparency_tier, :string, null: false, default: "ledger"
27
      add :artifact_link_id, references(:artifact_links, type: :binary_id, on_delete: :nothing)
28
    end
29
30
    alter table(:issue_evidence) do
31
      add :transparency_tier, :string, null: false, default: "ledger"
32
      add :artifact_link_id, references(:artifact_links, type: :binary_id, on_delete: :nothing)
33
    end
34
35
    create constraint(:forge_assignments, :forge_assignments_transparency_tier_check,
36
             check: "transparency_tier IN ('dark','pulse','ledger','glass')"
37
           )
38
39
    create constraint(:issue_evidence, :issue_evidence_transparency_tier_check,
40
             check: "transparency_tier IN ('dark','pulse','ledger','glass')"
41
           )
42
43
    create index(:forge_assignments, [:artifact_link_id])
44
    create index(:issue_evidence, [:artifact_link_id])
45
  end
46
end
test/openagents/transparency/work_disclosure_test.exs added +695

@@ -0,0 +1,695 @@

1
defmodule OpenAgents.Transparency.WorkDisclosureTest do
2
  @moduledoc """
3
  Stage 5 of `#10`: transparency tiers over work in progress.
4
5
  Three properties, and the first is the one that makes the other two mean
6
  anything.
7
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
12
  fall behind the schema it describes.
13
14
  **The rungs discriminate on a repository nothing else gates.** Every tier
15
  assertion below runs on a *public* repository, where
16
  `Repositories.readable_by/2` admits everybody and the tier is the only thing
17
  between an anonymous reader and a branch, a revision, a receipt handle, an
18
  environment, and a report. A private repository would have proved the tier
19
  worked when the repository gate was doing the work.
20
21
  **Repository authority is still stronger.** A record whose tier is `glass`
22
  in a repository that went private is invisible to a non-member. That case is
23
  the one the disclosure dial does not cover and the tier cannot override.
24
  """
25
  use OpenAgents.DataCase, async: true
26
27
  import Ecto.Query
28
  import OpenAgents.AccountsFixtures
29
30
  alias OpenAgents.Forge.Assignment
31
  alias OpenAgents.Issues
32
  alias OpenAgents.Issues.{Evidence, EvidenceEntry}
33
  alias OpenAgents.Forge.Assignments
34
  alias OpenAgents.Repo
35
  alias OpenAgents.Repositories
36
  alias OpenAgents.Transparency
37
  alias OpenAgents.Transparency.{ArtifactLink, WorkDisclosure}
38
  alias OpenAgents.Work.Job
39
40
  @sha String.duplicate("ab", 20)
41
42
  setup do
43
    owner = repository_user_fixture("work-owner")
44
    member = repository_user_fixture("work-member")
45
    stranger = repository_user_fixture("work-stranger")
46
47
    repository = repository_with_member_fixture(owner, %{visibility: "public"}, "owner")
48
    {:ok, _} = Repositories.add_member(repository, member, "maintainer")
49
50
    {:ok, issue} = Issues.create_issue(repository, %{title: "Do the work"})
51
52
    %{
53
      owner: owner,
54
      member: member,
55
      stranger: stranger,
56
      repository: repository,
57
      issue: issue
58
    }
59
  end
60
61
  # ── the schedule is exhaustive ──────────────────────────────────────────
62
63
  describe "the schedule covers every column of every record it describes" do
64
    @schema_for %{
65
      attempt: Assignment,
66
      work_job: Job,
67
      evidence: EvidenceEntry
68
    }
69
70
    for {family, schema} <- @schema_for do
71
      test "#{family}: every column is scheduled or in the never list" do
72
        family = unquote(family)
73
        schema = unquote(schema)
74
75
        columns = MapSet.new(schema.__schema__(:fields))
76
        scheduled = MapSet.new(WorkDisclosure.source_columns(family))
77
        never = MapSet.new(WorkDisclosure.never(family))
78
79
        unclassified = columns |> MapSet.difference(scheduled) |> MapSet.difference(never)
80
81
        assert MapSet.to_list(unclassified) == [],
82
               "#{family} columns in neither the schedule nor the never list: " <>
83
                 inspect(MapSet.to_list(unclassified))
84
85
        both = MapSet.intersection(scheduled, never)
86
87
        assert MapSet.to_list(both) == [],
88
               "#{family} columns both scheduled and never: " <> inspect(MapSet.to_list(both))
89
      end
90
    end
91
92
    test "every scheduled field names a real tier, and the ladder is monotone" do
93
      for family <- WorkDisclosure.families() do
94
        for {field, tier} <- WorkDisclosure.schedule()[family] do
95
          assert tier in [:pulse, :ledger, :glass],
96
                 "#{family}.#{field} is on no rung this ladder has"
97
        end
98
99
        assert WorkDisclosure.fields_at(family, :dark) == []
100
101
        pulse = MapSet.new(WorkDisclosure.fields_at(family, :pulse))
102
        ledger = MapSet.new(WorkDisclosure.fields_at(family, :ledger))
103
        glass = MapSet.new(WorkDisclosure.fields_at(family, :glass))
104
105
        assert MapSet.subset?(pulse, ledger)
106
        assert MapSet.subset?(ledger, glass)
107
      end
108
    end
109
110
    test "the work vocabulary is in the artifact type list and `trace` has no producer" do
111
      types = ArtifactLink.artifact_types()
112
113
      for member <- ~w(attempt work_job deployment trace) do
114
        assert member in types
115
      end
116
117
      # `#149` asks for a `trace` member and nothing in this repository
118
      # produces a trace artifact. Naming that here keeps the vocabulary from
119
      # reading as a shipped surface.
120
      producing =
121
        "lib/**/*.ex"
122
        |> Path.wildcard()
123
        |> Enum.flat_map(&Regex.scan(~r/artifact_type:\s*"(\w+)"/, File.read!(&1)))
124
        |> Enum.map(&Enum.at(&1, 1))
125
        |> Enum.uniq()
126
127
      refute "trace" in producing
128
      assert "attempt" in producing
129
    end
130
  end
131
132
  # ── the rungs, on a public repository ───────────────────────────────────
133
134
  describe "an attempt on a public repository" do
135
    test "tells an anonymous reader that work ran, and no ref, revision, or reason",
136
         context do
137
      attempt(context, %{state: "completed", terminal_commit: @sha})
138
139
      assert [projection] = read_attempts(context, nil)
140
141
      assert Enum.sort(Map.keys(projection)) ==
142
               Enum.sort([:work_job | WorkDisclosure.fields_at(:attempt, :pulse)])
143
144
      assert projection.state == "completed"
145
      assert projection.target_kind == "box"
146
      assert projection.requester_kind == "user"
147
148
      refute Map.has_key?(projection, :branch)
149
      refute Map.has_key?(projection, :terminal_branch)
150
      refute Map.has_key?(projection, :terminal_commit)
151
      refute Map.has_key?(projection, :failure_reason)
152
    end
153
154
    test "gives a signed-in stranger exactly what it gives anonymous traffic", context do
155
      attempt(context, %{state: "completed", terminal_commit: @sha})
156
157
      assert read_attempts(context, nil) == read_attempts(context, context.stranger)
158
    end
159
160
    test "gives a repository member the branch, the revision, and the reason", context do
161
      attempt(context, %{state: "failed", failure_reason: "assignment_expired"})
162
163
      assert [projection] = read_attempts(context, context.member)
164
165
      assert Enum.sort(Map.keys(projection)) ==
166
               Enum.sort([:work_job | WorkDisclosure.fields_at(:attempt, :ledger)])
167
168
      assert projection.branch == "agent/issue-1"
169
      assert projection.failure_reason == "assignment_expired"
170
    end
171
172
    test "never publishes the requesting principal, only its kind", context do
173
      %{assignment: assignment} = attempt(context, %{})
174
175
      for reader <- [nil, context.stranger, context.member, context.owner] do
176
        assert [projection] = read_attempts(context, reader)
177
        assert projection.requester_kind in ["user", "agent"]
178
179
        values = projection |> Map.values() |> Enum.map(&inspect/1) |> Enum.join(" ")
180
        refute values =~ assignment.requesting_principal["id"]
181
        refute values =~ "actor_id"
182
      end
183
    end
184
  end
185
186
  describe "the work job behind an attempt" do
187
    test "reaches a member as counts and bounds, and never as a report", context do
188
      attempt(context, %{}, job: true)
189
190
      assert [%{work_job: job}] = read_attempts(context, context.member)
191
192
      assert Enum.sort(Map.keys(job)) == Enum.sort(WorkDisclosure.fields_at(:work_job, :ledger))
193
194
      assert job.tool_call_count == 7
195
      assert job.budget == %{"wall_clock_ms" => 60_000, "maximum_report_bytes" => 8_000}
196
197
      refute Map.has_key?(job, :report)
198
      refute Map.has_key?(job, :usage)
199
      refute Map.has_key?(job, :model_id)
200
    end
201
202
    test "reaches the account the work belongs to as its report", context do
203
      attempt(context, %{}, job: true)
204
205
      assert [%{work_job: job}] = read_attempts(context, context.owner)
206
207
      assert Enum.sort(Map.keys(job)) == Enum.sort(WorkDisclosure.fields_at(:work_job, :glass))
208
      assert job.report == "Renamed the billing column and pushed."
209
      assert job.model_id == "test-model"
210
    end
211
212
    test "never publishes the goal, the prompt, or the authority snapshot", context do
213
      attempt(context, %{}, job: true)
214
215
      for reader <- [nil, context.stranger, context.member, context.owner] do
216
        assert [%{work_job: job}] = read_attempts(context, reader)
217
218
        rendered = inspect(job)
219
        refute rendered =~ "SECRET-GOAL"
220
        refute rendered =~ "SECRET-PROMPT"
221
        refute rendered =~ "/private/checkout"
222
        refute Map.has_key?(job, :goal)
223
        refute Map.has_key?(job, :delegation)
224
        refute Map.has_key?(job, :authority_snapshot)
225
        refute Map.has_key?(job, :budget_snapshot)
226
      end
227
    end
228
229
    test "an attempt with no job carries no job", context do
230
      attempt(context, %{})
231
232
      assert [%{work_job: nil}] = read_attempts(context, context.owner)
233
    end
234
  end
235
236
  describe "an evidence edge on a public repository" do
237
    test "says restricted evidence exists without naming the revision, receipt, or place",
238
         context do
239
      evidence(context, %{
240
        family: "deployment",
241
        plane: "tenant",
242
        environment: "acme-production",
243
        result: "succeeded"
244
      })
245
246
      assert [projection] = read_evidence(context, nil)
247
248
      assert Enum.sort(Map.keys(projection)) ==
249
               Enum.sort(WorkDisclosure.fields_at(:evidence, :pulse))
250
251
      assert projection.family == "deployment"
252
      assert projection.result == "succeeded"
253
      assert projection.plane == "tenant"
254
255
      refute Map.has_key?(projection, :commit)
256
      refute Map.has_key?(projection, :receipt_id)
257
      refute Map.has_key?(projection, :environment)
258
    end
259
260
    test "gives a member the revision, the receipt handle, and the environment", context do
261
      evidence(context, %{
262
        family: "deployment",
263
        plane: "tenant",
264
        environment: "acme-production",
265
        result: "succeeded"
266
      })
267
268
      assert [projection] = read_evidence(context, context.member)
269
270
      assert Enum.sort(Map.keys(projection)) ==
271
               Enum.sort(WorkDisclosure.fields_at(:evidence, :ledger))
272
273
      assert projection.commit == @sha
274
      assert projection.environment == "acme-production"
275
    end
276
277
    test "never publishes the actor at any rung", context do
278
      evidence(context, %{actor: "user:0e1c1a1e-secret"})
279
280
      for reader <- [nil, context.stranger, context.member, context.owner] do
281
        assert [projection] = read_evidence(context, reader)
282
        refute Map.has_key?(projection, :actor)
283
        refute inspect(projection) =~ "0e1c1a1e-secret"
284
      end
285
    end
286
287
    test "inherits the attempt's link, so revoking the attempt takes its receipts",
288
         context do
289
      %{assignment: assignment} = attempt(context, %{terminal_commit: @sha})
290
291
      # The edge is written by the production path, not fabricated here.
292
      # `bind_attempt/1` sweeps the receipt chain for the revision the attempt
293
      # reported and records what it finds, which is the only route by which an
294
      # edge carries an `assignment_id` at all.
295
      build_receipt(context, @sha, "complete")
296
297
      assert [edge] = Evidence.bind_attempt(assignment)
298
      assert edge.assignment_id == assignment.id
299
      assert edge.artifact_link_id == assignment.artifact_link_id
300
301
      assert [_projection] = read_evidence(context, context.member)
302
303
      revoke(assignment.artifact_link_id)
304
305
      assert read_evidence(context, context.member) == []
306
      assert read_attempts(context, context.member) == []
307
    end
308
  end
309
310
  # ── revocation ──────────────────────────────────────────────────────────
311
312
  describe "revoking an attempt's link" do
313
    test "removes the attempt from every reader, including its own account", context do
314
      %{assignment: assignment} = attempt(context, %{}, job: true)
315
316
      assert [_] = read_attempts(context, context.owner)
317
318
      revoke(assignment.artifact_link_id)
319
320
      for reader <- [nil, context.stranger, context.member, context.owner] do
321
        assert read_attempts(context, reader) == []
322
      end
323
    end
324
325
    test "leaves an auditable tombstone on the link", context do
326
      %{assignment: assignment} = attempt(context, %{})
327
328
      revoke(assignment.artifact_link_id)
329
330
      link = Repo.get!(ArtifactLink, assignment.artifact_link_id)
331
332
      assert link.revoked_at
333
      assert link.revocation_tombstone["reason"] == "owner_withdrew_consent"
334
      assert link.revocation_tombstone["revoked_at"]
335
      assert Transparency.effective_tier(link, %{tier: :glass, admin: true}) == :dark
336
    end
337
  end
338
339
  # ── repository authority is stronger than any tier ──────────────────────
340
341
  describe "repository authority" do
342
    test "a glass record in a repository that went private is invisible to a non-member",
343
         context do
344
      %{assignment: assignment} = attempt(context, %{terminal_commit: @sha}, job: true)
345
346
      # The strongest possible tier: the link and the row both say `glass`, so
347
      # nothing in the disclosure ladder is withholding anything.
348
      Repo.update_all(from(l in ArtifactLink, where: l.id == ^assignment.artifact_link_id),
349
        set: [tier: "glass"]
350
      )
351
352
      Repo.update_all(from(a in Assignment, where: a.id == ^assignment.id),
353
        set: [transparency_tier: "glass"]
354
      )
355
356
      assert [_] = read_attempts(context, nil)
357
358
      {1, _} =
359
        Repo.update_all(
360
          from(r in OpenAgents.Repositories.Repository, where: r.id == ^context.repository.id),
361
          set: [visibility: "private"]
362
        )
363
364
      assert_raise Ecto.NoResultsError, fn ->
365
        Repositories.get_visible_by_path!(
366
          context.repository.owner,
367
          context.repository.name,
368
          nil
369
        )
370
      end
371
372
      assert_raise Ecto.NoResultsError, fn ->
373
        Repositories.get_visible_by_path!(
374
          context.repository.owner,
375
          context.repository.name,
376
          context.stranger
377
        )
378
      end
379
    end
380
  end
381
382
  # ── the viewer ladder ───────────────────────────────────────────────────
383
384
  describe "the viewer descriptor" do
385
    test "puts an operator at glass, a member at ledger, and everyone else at pulse",
386
         context do
387
      operator = admin_user()
388
389
      assert WorkDisclosure.viewer(context.repository, operator).tier == :glass
390
      assert WorkDisclosure.viewer(context.repository, context.member).tier == :ledger
391
      assert WorkDisclosure.viewer(context.repository, context.owner).tier == :ledger
392
      assert WorkDisclosure.viewer(context.repository, context.stranger).tier == :pulse
393
      assert WorkDisclosure.viewer(context.repository, nil).tier == :pulse
394
      assert WorkDisclosure.viewer(context.repository, nil).account_id == nil
395
    end
396
397
    test "an agent-requested attempt has no link, and only an operator reaches glass",
398
         context do
399
      attempt(context, %{}, job: true, link: false)
400
401
      assert [%{work_job: operator_job}] = read_attempts(context, admin_user())
402
      assert Map.has_key?(operator_job, :report)
403
404
      # Nobody owns an attempt an agent requested, so nobody but an operator is
405
      # raised. The account that would have owned a user-requested attempt
406
      # reads it at `ledger` like any other member.
407
      assert [%{work_job: owner_job}] = read_attempts(context, context.owner)
408
      refute Map.has_key?(owner_job, :report)
409
410
      assert [projection] = read_attempts(context, nil)
411
      assert projection.requester_kind == "agent"
412
      refute Map.has_key?(projection, :branch)
413
    end
414
415
    test "an operator reads an attempt at glass without owning it", context do
416
      attempt(context, %{}, job: true)
417
418
      assert [%{work_job: job}] = read_attempts(context, admin_user())
419
      assert Map.has_key?(job, :report)
420
    end
421
  end
422
423
  # ── the attempt link is minted where the attempt is ─────────────────────
424
425
  describe "the attempt link" do
426
    test "is minted for a user-requested attempt", context do
427
      assert {:ok, link} =
428
               WorkDisclosure.link_for_attempt(
429
                 context.repository,
430
                 %{"type" => "user", "id" => context.owner.id},
431
                 %{"branch" => "agent/issue-1"}
432
               )
433
434
      assert link.artifact_type == "attempt"
435
      assert link.artifact_ref == "id"
436
      assert link.tier == "ledger"
437
      assert link.account_id == context.owner.id
438
    end
439
440
    test "is not minted for an agent-requested attempt", context do
441
      assert :none ==
442
               WorkDisclosure.link_for_attempt(
443
                 context.repository,
444
                 %{"type" => "agent", "id" => Ecto.UUID.generate()},
445
                 %{}
446
               )
447
    end
448
449
    test "is what `Assignments.create/1` stores on the attempt it persists", context do
450
      {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.owner)
451
452
      {:ok, box} =
453
        %OpenAgents.Box.ConversationBox{}
454
        |> OpenAgents.Box.ConversationBox.changeset(%{
455
          conversation_id: conversation.id,
456
          box_id: "bx_work_disclosure",
457
          state: "ready",
458
          setup_status: "done"
459
        })
460
        |> Repo.insert()
461
462
      # The run never starts in a test, and it does not need to: the assignment
463
      # and its link are committed by `persist_assignment/7` before
464
      # `start_target/7` is reached, so the row this reads is the row the
465
      # production path writes.
466
      result =
467
        try do
468
          Assignments.create(%{
469
            "target_kind" => "box",
470
            "box_id" => box.box_id,
471
            "conversation_id" => conversation.id,
472
            "repository_id" => context.repository.id,
473
            "issue_number" => context.issue.number,
474
            "branch" => "agent/created",
475
            "requesting_user" => context.owner,
476
            "requesting_principal" => context.owner
477
          })
478
        rescue
479
          # The box never starts here, and `start_target/7` records that as a
480
          # terminal failure. Whatever it returns, the assignment and its link
481
          # were committed by `persist_assignment/7` first, which is the row
482
          # this test is about.
483
          error -> {:error, error}
484
        end
485
486
      assignment =
487
        case Repo.one(from a in Assignment, where: a.issue_id == ^context.issue.id, limit: 1) do
488
          nil -> flunk("no assignment persisted: #{inspect(result)}")
489
          row -> row
490
        end
491
492
      assert assignment.artifact_link_id
493
      assert assignment.transparency_tier == "ledger"
494
495
      link = Repo.get!(ArtifactLink, assignment.artifact_link_id)
496
      assert link.account_id == context.owner.id
497
      assert link.artifact_type == "attempt"
498
    end
499
  end
500
501
  # ── the same viewer, the same answer, on every surface ──────────────────
502
503
  describe "one schedule, read from one place" do
504
    test "every caller of a work projection outside its own context passes a viewer" do
505
      offenders =
506
        "lib/**/*.ex"
507
        |> Path.wildcard()
508
        |> Enum.reject(
509
          &(&1 in [
510
              "lib/openagents/forge/assignments.ex",
511
              "lib/openagents/issues/evidence.ex"
512
            ])
513
        )
514
        |> Enum.flat_map(fn path ->
515
          path
516
          |> File.read!()
517
          |> then(
518
            &Regex.scan(
519
              ~r/(?:Assignments\.attempts_for_issues?|Evidence\.for_issues?|attempt_summary|Evidence\.summary)\(([\s\S]{0,260})/,
520
              &1
521
            )
522
          )
523
          |> Enum.reject(fn [_whole, args] -> String.contains?(args, "viewer") end)
524
          |> Enum.map(fn [whole, _args] -> "#{path}: #{String.slice(whole, 0, 60)}" end)
525
        end)
526
527
      assert offenders == [],
528
             "a work projection read without a viewer runs at the unclamped default: " <>
529
               Enum.join(offenders, "\n")
530
    end
531
  end
532
533
  # ── helpers ─────────────────────────────────────────────────────────────
534
535
  defp read_attempts(context, reader),
536
    do: Assignments.attempts_for_issue(context.issue, viewer(context, reader))
537
538
  defp read_evidence(context, reader),
539
    do: Evidence.for_issue(context.issue, viewer(context, reader))
540
541
  defp viewer(context, reader), do: WorkDisclosure.viewer(context.repository, reader)
542
543
  defp attempt(context, attrs, opts \\ []) do
544
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
545
546
    principal =
547
      if Keyword.get(opts, :link, true) do
548
        %{
549
          "type" => "user",
550
          "id" => context.owner.id,
551
          "actor_type" => "user",
552
          "actor_id" => context.owner.id
553
        }
554
      else
555
        agent = Ecto.UUID.generate()
556
        %{"type" => "agent", "id" => agent, "actor_type" => "agent", "actor_id" => agent}
557
      end
558
559
    link =
560
      case WorkDisclosure.link_for_attempt(context.repository, principal, %{
561
             "branch" => "agent/issue-1"
562
           }) do
563
        {:ok, link} -> link
564
        :none -> nil
565
      end
566
567
    job = if opts[:job], do: work_job(context), else: nil
568
569
    assignment =
570
      %Assignment{}
571
      |> Assignment.changeset(
572
        Map.merge(
573
          %{
574
            conversation_box_id: box(context).id,
575
            target_kind: "box",
576
            repository_id: context.repository.id,
577
            issue_id: context.issue.id,
578
            requesting_principal: principal,
579
            branch: "agent/issue-1",
580
            deadline_at: DateTime.add(now, 600, :second),
581
            admitted_at: now,
582
            started_at: now,
583
            finished_at: now,
584
            work_job_id: job && job.id,
585
            artifact_link_id: link && link.id
586
          },
587
          attrs
588
        )
589
      )
590
      |> Repo.insert!()
591
592
    %{assignment: assignment, link: link, job: job}
593
  end
594
595
  defp box(context) do
596
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.owner)
597
598
    {:ok, box} =
599
      %OpenAgents.Box.ConversationBox{}
600
      |> OpenAgents.Box.ConversationBox.changeset(%{
601
        conversation_id: conversation.id,
602
        box_id: "bx_wd_#{System.unique_integer([:positive])}",
603
        state: "ready",
604
        setup_status: "done"
605
      })
606
      |> Repo.insert()
607
608
    box
609
  end
610
611
  defp work_job(context) do
612
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(context.owner)
613
    visitor = Repo.get_by!(OpenAgents.Conversations.Visitor, user_id: context.owner.id)
614
615
    %Job{}
616
    |> Job.create_changeset(%{
617
      conversation_id: conversation.id,
618
      owner_visitor_id: visitor.id,
619
      surface: "text",
620
      kind: "coding",
621
      goal: "SECRET-GOAL rename the billing column",
622
      context_hint: "SECRET-PROMPT the private schema",
623
      authority_snapshot: %{"roots" => ["/private/checkout"], "cwd" => "/private/checkout"},
624
      budget_snapshot: %{"wall_clock_ms" => 60_000, "maximum_report_bytes" => 8_000}
625
    })
626
    |> Repo.insert!()
627
    |> Job.lifecycle_changeset(%{status: "running", started_at: DateTime.utc_now()})
628
    |> Repo.update!()
629
    |> Job.lifecycle_changeset(%{
630
      status: "completed",
631
      report: "Renamed the billing column and pushed.",
632
      model_id: "test-model",
633
      tool_call_count: 7,
634
      continuation_count: 1,
635
      usage: %{"input_tokens" => 100},
636
      started_at: DateTime.utc_now(),
637
      completed_at: DateTime.utc_now()
638
    })
639
    |> Repo.update!()
640
  end
641
642
  defp evidence(context, attrs) do
643
    defaults = %{
644
      repository_id: context.repository.id,
645
      issue_id: context.issue.id,
646
      commit_sha: @sha,
647
      family: "build",
648
      receipt_id: Ecto.UUID.generate(),
649
      plane: "forge",
650
      result: "complete",
651
      actor: "user:someone",
652
      source: "closing_reference"
653
    }
654
655
    attrs = Map.merge(defaults, attrs)
656
657
    %EvidenceEntry{}
658
    |> EvidenceEntry.changeset(attrs)
659
    |> Repo.insert!()
660
  end
661
662
  defp build_receipt(context, sha, status) do
663
    %OpenAgents.Forge.BuildReceipt{}
664
    |> OpenAgents.Forge.BuildReceipt.start_changeset(%{
665
      repo: context.repository.storage_key,
666
      repository_id: context.repository.id,
667
      sha: sha,
668
      target_id: Ecto.UUID.generate()
669
    })
670
    |> Ecto.Changeset.put_change(:status, status)
671
    |> Repo.insert!()
672
  end
673
674
  defp revoke(link_id) do
675
    ArtifactLink
676
    |> Repo.get!(link_id)
677
    |> Transparency.revoke("owner_withdrew_consent", Ecto.UUID.generate())
678
    |> Repo.update!()
679
  end
680
681
  # The owner's GitHub id is unioned into the operator list in
682
  # `OpenAgents.Accounts` itself rather than read from configuration, so this
683
  # needs no global state and stays safe to run concurrently.
684
  defp admin_user do
685
    {:ok, user} =
686
      OpenAgents.Accounts.upsert_github_user(%{
687
        github_id: 14_167_547,
688
        github_login: "work-operator",
689
        github_avatar_url: "https://avatars.githubusercontent.com/u/14167547?v=4"
690
      })
691
692
    true = OpenAgents.Accounts.admin?(user)
693
    user
694
  end
695
end
test/openagents_web/controllers/issue_controller_test.exs modified +48 -10

@@ -706,16 +706,54 @@ defmodule OpenAgentsWeb.IssueControllerTest do

706 706
707 707
      assert %{"openagents" => %{"work" => [attempt]}} = json_response(conn, 200)
708 708
709
      assert Enum.sort(Map.keys(attempt)) == [
710
               "branch",
711
               "commit",
712
               "failure_reason",
713
               "finished_at",
714
               "id",
715
               "started_at",
716
               "state",
717
               "target"
718
             ]
709
      # The key set comes from the disclosure schedule, renamed the two ways
710
      # the API renames, so a column added to `forge_assignments` and put on a
711
      # rung shows up here without an edit and a column put nowhere fails
712
      # `OpenAgents.Transparency.WorkDisclosureTest` first.
713
      renames = %{target_kind: :target, terminal_commit: :commit}
714
715
      expected =
716
        :attempt
717
        |> OpenAgents.Transparency.WorkDisclosure.fields_at(:ledger)
718
        |> Enum.map(&to_string(Map.get(renames, &1, &1)))
719
        |> Enum.sort()
720
721
      assert Enum.sort(Map.keys(attempt)) == expected
722
723
      refute Map.has_key?(attempt, "prompt")
724
      refute Map.has_key?(attempt, "conversation_id")
725
      refute Map.has_key?(attempt, "requesting_principal")
726
      refute Map.has_key?(attempt, "credential_delivery_status")
727
      refute Map.has_key?(attempt, "machine_id")
728
    end
729
730
    test "an anonymous reader is told an attempt ran and not what it ran on", %{conn: conn} do
731
      {:ok, issue} = Issues.create_issue(repository(), %{title: "Public work"})
732
      record_attempt(issue, "agent/secret-rename", -30, %{state: "completed"})
733
734
      # The same request, twice, differing only in who makes it. The member's
735
      # token reaches `ledger`; anonymous traffic on the same public repository
736
      # reaches `pulse`, and the tier is the only thing between them —
737
      # `Repositories.readable_by/2` admits both.
738
      member =
739
        conn
740
        |> get(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")
741
        |> json_response(200)
742
743
      anonymous =
744
        Phoenix.ConnTest.build_conn()
745
        |> get(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}")
746
        |> json_response(200)
747
748
      assert [%{"branch" => "agent/secret-rename"}] = member["openagents"]["work"]
749
750
      assert [attempt] = anonymous["openagents"]["work"]
751
      assert attempt["state"] == "completed"
752
      assert attempt["requester_kind"] == "user"
753
      refute Map.has_key?(attempt, "branch")
754
      refute Map.has_key?(attempt, "commit")
755
      refute Map.has_key?(attempt, "terminal_branch")
756
      refute Map.has_key?(attempt, "failure_reason")
719 757
    end
720 758
721 759
    test "index carries the attempts for every issue on the page", %{conn: conn} do
test/openagents_web/operator_surface_test.exs modified +2

@@ -85,6 +85,8 @@ defmodule OpenAgentsWeb.OperatorSurfaceTest do

85 85
    OpenAgents.Tools.ConversationExecutionContext => "selects the operator routing policy",
86 86
    OpenAgents.Tools.Reach => "satisfies the :operator reach requirement for a tool",
87 87
    OpenAgents.Transparency => "widens a repository's visible tier to :glass",
88
    OpenAgents.Transparency.WorkDisclosure =>
89
      "widens an unlinked work record to :glass, which no owner can be raised to",
88 90
    OpenAgentsWeb.AdminAnalyticsLive => "rechecks the operator on mount and on every event",
89 91
    OpenAgentsWeb.AdminForgeLive => "rechecks the operator before promoting",
90 92
    OpenAgentsWeb.AdminLive => "rechecks the operator on mount and on every event",

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