Revive the product promises registry as forge project items

c0a0451384dd · Devin AI · · parent 0470bf0dd035

Revive the product promises registry as forge project items

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified .agents/skills/openagents-work-management/SKILL.md
  • modified INVARIANTS.md
  • modified docs/github-api-issues-projects-assessment.md
  • added docs/product-promises-registry.md
  • added lib/mix/tasks/openagents.promises.backfill.ex
  • modified lib/openagents/projects.ex
  • added lib/openagents/projects/project_item_event.ex
  • added lib/openagents/projects/promise_registry.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/api_extension_controller.ex
  • modified lib/openagents_web/controllers/project_controller.ex
  • modified lib/openagents_web/controllers/project_json.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/rest-api.md
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/promises/curated.json
  • added priv/repo/migrations/20260823072000_create_project_item_events.exs
  • added test/mix/tasks/openagents_promises_backfill_test.exs
  • added test/openagents/promise_registry_test.exs
  • modified test/openagents_web/controllers/project_controller_test.exs
  • modified test/openagents_web/live/project_show_live_test.exs

Diff

23 files changed, +2223 -55

.agents/skills/openagents-work-management/SKILL.md modified +14

@@ -127,6 +127,20 @@ There are no named `project` commands. Construct every Projects V2 route manuall

127 127
| List fields | `GET` | `repos/OWNER/REPO/projectsV2/PROJECT_NUMBER/fields` |
128 128
| Create field | `POST` | `repos/OWNER/REPO/projectsV2/PROJECT_NUMBER/fields` |
129 129
130
### Product promises
131
132
Projects V2 can act as a product promises registry when the project has one
133
`promise_state` field with `LIVE`, `GATED`, and `WITHDRAWN` options. Store each
134
promise in the item's `values["promise"]` map and keep one canonical issue per
135
promise. Use readable `accepted_outcome` evidence that names an accepted
136
`OpenAgents.Compensation.OutcomeDecision` for `LIVE`. Issue, changelog, and
137
forge receipt evidence remains supporting evidence; links cannot satisfy that
138
gate.
139
140
Use `promise_state` and `bounty_candidate` filters when listing items. Read
141
actor-attributed append-only history from the item's `/events` endpoint.
142
Evidence is redacted when the reader cannot read its repository or issue.
143
130 144
List projects:
131 145
132 146
```sh
INVARIANTS.md modified +22

@@ -2225,6 +2225,26 @@ Evidence: `OpenAgents.Capacity`, `OpenAgents.Capacity.Math`,

2225 2225
`OpenAgents.Capacity.Broker`, `OpenAgents.Capacity.Connected`, and
2226 2226
`test/openagents/capacity_test.exs`.
2227 2227
2228
### PROMISE-001 — LIVE promises require accepted-outcome evidence
2229
2230
Status: Current
2231
2232
A promise can enter `LIVE` only when at least one readable
2233
`accepted_outcome` evidence entry names an
2234
`OpenAgents.Compensation.OutcomeDecision` whose `decision` is `accepted`.
2235
Issue, changelog, and forge receipt entries remain supporting evidence, but
2236
they cannot certify `LIVE`; links and issue comments do not satisfy this gate.
2237
Evidence is redacted separately from certification when its referenced
2238
repository or issue is outside the reader's visibility boundary.
2239
2240
### PROMISE-002 — Project item history is append-only
2241
2242
Status: Proposed
2243
2244
Every promise item create, update, and state change records an actor-attributed
2245
event. PostgreSQL rejects updates and deletes of project item events, and the
2246
API exposes only paginated reads.
2247
2228 2248
## Executable proof index
2229 2249
2230 2250
This index is part of the ledger. Every `Current` invariant has at least one

@@ -2246,6 +2266,8 @@ contract; the invariant prose above defines the assertion, not the filename.

2246 2266
| IDENTITY-001 | `test/openagents/github_oauth_test.exs`, `test/openagents_web/auth_controller_test.exs` |
2247 2267
| IDENTITY-002 | `test/openagents_web/auth_gate_test.exs` |
2248 2268
| IDENTITY-003 | `test/openagents/memory_portability_test.exs` |
2269
| PROMISE-001 | `test/openagents/promise_registry_test.exs`, `test/openagents_web/controllers/project_controller_test.exs` |
2270
| PROMISE-002 | `test/openagents/promise_registry_test.exs` |
2249 2271
| DATA-001 | `test/openagents/conversations_test.exs` |
2250 2272
| DATA-002 | `test/openagents/accounts_test.exs`, `test/openagents/conversations_test.exs` |
2251 2273
| DATA-003 | `test/openagents/conversations_test.exs` |
docs/github-api-issues-projects-assessment.md modified +5 -1

@@ -39,6 +39,7 @@ locks, dependencies, sub-issues, and suggestion APIs are not implemented.

39 39
| `GET, POST` | `/repos/{owner}/{repo}/projectsV2/{project_number}/items` |
40 40
| `PATCH` | `/repos/{owner}/{repo}/projectsV2/{project_number}/items/{item_id}` |
41 41
| `GET, POST` | `/repos/{owner}/{repo}/projectsV2/{project_number}/fields` |
42
| `GET` | `/repos/{owner}/{repo}/projectsV2/{project_number}/items/{item_id}/events` |
42 43
43 44
The project-creation endpoint is an OpenAgents extension because the comparable
44 45
GitHub Projects V2 creation workflow is not supplied by the assessed REST

@@ -125,7 +126,10 @@ These are current measured behaviors:

125 126
  an `issue` object with `owner`, `repo`, and `number`. Cross-repository adds
126 127
  require write access to the project repository and read access to the source
127 128
  repository. Item lists omit source issues that the current viewer cannot
128
  read.
129
  read. Promise registry items are an OpenAgents-specific extension. They use
130
  a stored `promise_state` field, state gates, resolvable evidence, and an
131
  append-only event history. This deliberately differs from GitHub Projects
132
  V2, which does not define these product-claim semantics.
129 133
- Assignee reads return active repository members with writable roles, and
130 134
  issue assignment accepts only those members.
131 135
docs/product-promises-registry.md added +100

@@ -0,0 +1,100 @@

1
# Product promises registry
2
3
The product promises registry records what OpenAgents claims, what has shipped,
4
and what still needs a gate. The registry is a Projects V2 project with exactly
5
one `promise_state` field whose values are `LIVE`, `GATED`, and `WITHDRAWN`.
6
7
## States and gates
8
9
- `LIVE` requires a readable `accepted_outcome` evidence entry that names an
10
  existing `OpenAgents.Compensation.OutcomeDecision` whose `decision` is
11
  `accepted`. Issue, changelog, and forge receipt entries remain supporting
12
  evidence, but they cannot satisfy the gate, and neither can a link.
13
- `GATED` requires `gate.missing`, `gate.owner`, and `gate.next_review`.
14
  A gated promise with a missing gate is a bounty candidate.
15
- `WITHDRAWN` requires `withdrawal.reason`, `withdrawal.replacement`, and
16
  `withdrawal.date`. Withdrawn promises remain visible.
17
18
The server sets `verified_at` when a promise enters or is reverified as
19
`LIVE`. The client cannot set this value.
20
21
See [the accepted-outcome contract](accepted-outcome-contract.md) for how an
22
agent-authored completion claim earns an accepted outcome receipt.
23
24
## Record shape
25
26
Store the record in `values["promise"]` and store its state under the name of
27
the `promise_state` field:
28
29
```json
30
{
31
  "Promise state": "GATED",
32
  "promise": {
33
    "id": "forge_bounty_clearing_loop_v1",
34
    "problem": "Verified work does not have a complete settlement loop.",
35
    "claim": "A forge issue can move from claim to settlement.",
36
    "scope": "One public bounty issue and its receipts.",
37
    "acceptance_criteria": ["A claim and settlement are linked."],
38
    "success_metrics": ["One bounty completes end to end."],
39
    "owner": "OpenAgents",
40
    "target": "2026-10-31",
41
    "evidence": [
42
      {
43
        "kind": "accepted_outcome",
44
        "decision_receipt_ref": "outcome-decision:example"
45
      }
46
    ],
47
    "gate": {
48
      "missing": "A verified settlement receipt.",
49
      "owner": "OpenAgents",
50
      "next_review": "2026-09-30"
51
    }
52
  }
53
}
54
```
55
56
Promise IDs use lowercase letters, digits, and underscores. They are unique
57
within a project. Every promise item has one canonical issue.
58
59
## API
60
61
List promises by state:
62
63
```text
64
GET /api/v3/repos/{owner}/{repo}/projectsV2/{project_number}/items?promise_state=LIVE
65
```
66
67
List bounty candidates:
68
69
```text
70
GET /api/v3/repos/{owner}/{repo}/projectsV2/{project_number}/items?bounty_candidate=true
71
```
72
73
Read the append-only history for an item:
74
75
```text
76
GET /api/v3/repos/{owner}/{repo}/projectsV2/{project_number}/items/{item_id}/events?page=1
77
```
78
79
Promise data appears under `openagents.promise`. Evidence that points to a
80
repository or issue you cannot read is omitted from both the record and its
81
evidence count.
82
83
## Backfill the registry
84
85
1. Run `mix ecto.migrate`.
86
2. Review `priv/promises/curated.json`. The file contains only promises
87
   derived from published material in `docs/episode-triage.md`.
88
3. Run the task:
89
90
   ```sh
91
   mix openagents.promises.backfill \
92
     --owner OpenAgentsInc \
93
     --repo openagents.com \
94
     --project-number 1
95
   ```
96
97
The task creates the state field when it is missing, creates one canonical
98
issue per promise, and skips existing promise IDs. If evidence cannot satisfy a
99
promise's state gate, the task reports the promise and its error, skips it, and
100
continues. You can rerun it safely.
lib/mix/tasks/openagents.promises.backfill.ex added +153

@@ -0,0 +1,153 @@

1
defmodule Mix.Tasks.Openagents.Promises.Backfill do
2
  @moduledoc """
3
  Backfills the reviewed product promise set into a forge project.
4
5
  Run this task with `--owner`, `--repo`, and `--project-number`. The curated
6
  input lives in `priv/promises/curated.json`, and rerunning the task skips
7
  promises that already have a project item.
8
  """
9
10
  use Mix.Task
11
12
  alias OpenAgents.Issues
13
  alias OpenAgents.Projects
14
  alias OpenAgents.Projects.PromiseRegistry
15
  alias OpenAgents.Repositories
16
  alias OpenAgents.Issues.Issue
17
18
  @shortdoc "Backfill the curated product promise registry"
19
20
  @impl true
21
  def run(args) do
22
    Application.ensure_all_started(:openagents)
23
24
    {options, []} =
25
      OptionParser.parse!(args, strict: [owner: :string, repo: :string, project_number: :integer])
26
27
    owner = Keyword.fetch!(options, :owner)
28
    repo_name = Keyword.fetch!(options, :repo)
29
    project_number = Keyword.fetch!(options, :project_number)
30
    repository = Repositories.get_visible_by_path!(owner, repo_name, nil)
31
    project = Projects.get_project_by_number!(repository, project_number)
32
33
    promise_field_name = ensure_promise_field(project)
34
    promises = load_promises()
35
36
    {created, skipped} =
37
      Enum.reduce(promises, {0, 0}, fn promise, {created, skipped} ->
38
        case backfill_promise(repository, project, promise, promise_field_name) do
39
          :created -> {created + 1, skipped}
40
          :skipped -> {created, skipped + 1}
41
        end
42
      end)
43
44
    Mix.shell().info("Backfilled #{created} promise(s); skipped #{skipped}.")
45
  rescue
46
    KeyError -> Mix.raise("Pass --owner, --repo, and --project-number.")
47
    Ecto.NoResultsError -> Mix.raise("The repository or project does not exist.")
48
  end
49
50
  defp ensure_promise_field(project) do
51
    unless PromiseRegistry.registry?(project) do
52
      case Projects.create_project_field(%{
53
             project_id: project.id,
54
             name: "Promise state",
55
             data_type: "promise_state",
56
             options: %{"values" => PromiseRegistry.states()}
57
           }) do
58
        {:ok, _field} ->
59
          "Promise state"
60
61
        {:error, changeset} ->
62
          Mix.raise("Could not create promise state field: #{inspect(changeset.errors)}")
63
      end
64
    else
65
      PromiseRegistry.field(project).name
66
    end
67
  end
68
69
  defp backfill_promise(repository, project, promise, promise_field_name) do
70
    if existing_promise?(project, promise["id"]) do
71
      :skipped
72
    else
73
      issue = canonical_issue(repository, promise)
74
      values = promise_values(promise, promise_field_name)
75
76
      case Projects.create_project_item(
77
             %{"issue_number" => issue.number, "values" => values},
78
             project
79
           ) do
80
        {:ok, _item} ->
81
          :created
82
83
        {:error, changeset} ->
84
          if gate_failure?(changeset) do
85
            Mix.shell().info(
86
              "Skipped #{promise["id"]}: LIVE gate failed: #{format_errors(changeset.errors)}"
87
            )
88
89
            :skipped
90
          else
91
            Mix.raise("Could not create #{promise["id"]}: #{inspect(changeset.errors)}")
92
          end
93
      end
94
    end
95
  end
96
97
  defp existing_promise?(project, id) do
98
    Projects.list_project_items(project)
99
    |> Enum.any?(&(get_in(&1.values, ["promise", "id"]) == id))
100
  end
101
102
  defp canonical_issue(repository, promise) do
103
    marker = "Promise ID: #{promise["id"]}"
104
105
    case Issues.list_issues(repository, state: "all")
106
         |> Enum.find(&String.contains?(&1.body || "", marker)) do
107
      %Issue{} = issue ->
108
        issue
109
110
      nil ->
111
        attrs = %{
112
          title: "Promise: #{promise["id"]}",
113
          body: "#{marker}\n\n#{promise["claim"]}"
114
        }
115
116
        case Issues.create_issue(repository, attrs) do
117
          {:ok, issue} -> issue
118
          {:error, changeset} -> Mix.raise("Could not create issue: #{inspect(changeset.errors)}")
119
        end
120
    end
121
  end
122
123
  defp promise_values(promise, promise_field_name) do
124
    record =
125
      promise
126
      |> Map.drop(["state", "evidence"])
127
      |> Map.put("evidence", promise["evidence"])
128
129
    %{promise_field_name => promise["state"], "promise" => record}
130
  end
131
132
  defp gate_failure?(changeset) do
133
    Enum.any?(changeset.errors, fn
134
      {:values, {message, _opts}} -> String.contains?(message, "accepted-outcome evidence")
135
      _error -> false
136
    end)
137
  end
138
139
  defp format_errors(errors) do
140
    errors
141
    |> Enum.map_join(", ", fn {field, {message, _opts}} -> "#{field} #{message}" end)
142
  end
143
144
  defp load_promises do
145
    Application.get_env(
146
      :openagents,
147
      :promises_curated_path,
148
      Application.app_dir(:openagents, "priv/promises/curated.json")
149
    )
150
    |> File.read!()
151
    |> Jason.decode!()
152
  end
153
end
lib/openagents/projects.ex modified +262 -28

@@ -10,6 +10,8 @@ defmodule OpenAgents.Projects do

10 10
  alias OpenAgents.ProjectItems.ProjectItem
11 11
  alias OpenAgents.Projects.Project
12 12
  alias OpenAgents.Projects.ProjectNote
13
  alias OpenAgents.Projects.ProjectItemEvent
14
  alias OpenAgents.Projects.PromiseRegistry
13 15
  alias OpenAgents.Repo
14 16
  alias OpenAgents.Repositories
15 17
  alias OpenAgents.Repositories.Repository

@@ -247,7 +249,11 @@ defmodule OpenAgents.Projects do

247 249
  defp describe_description_change(_value), do: "Updated the description."
248 250
249 251
  def delete_project(%Project{} = project) do
250
    Repo.delete(project)
252
    Repo.transaction(fn ->
253
      Repo.delete_all(from field in ProjectField, where: field.project_id == ^project.id)
254
      Repo.delete_all(from item in ProjectItem, where: item.project_id == ^project.id)
255
      Repo.delete!(project)
256
    end)
251 257
    |> case do
252 258
      {:ok, project} ->
253 259
        Repositories.broadcast_projects(project.repository_id)

@@ -441,17 +447,78 @@ defmodule OpenAgents.Projects do

441 447
  end
442 448
443 449
  def list_visible_project_items(
444
        %Project{id: project_id, repository_id: repository_id},
445
        user
450
        %Project{id: project_id, repository_id: repository_id} = project,
451
        user,
452
        opts \\ []
446 453
      ) do
454
    promise_context =
455
      Keyword.get(opts, :promise_context, PromiseRegistry.context(project))
456
447 457
    readable =
448 458
      from(repository in Repositories.readable_by(Repository, user), select: repository.id)
449 459
450
    project_items_query(project_id, repository_id)
451
    |> where([item], item.issue_repository_id in subquery(readable))
452
    |> order_by(asc: :id)
453
    |> preload(issue: :repository)
454
    |> Repo.all()
460
    items =
461
      project_items_query(project_id, repository_id)
462
      |> where([item], item.issue_repository_id in subquery(readable))
463
      |> order_by(asc: :id)
464
      |> preload(issue: :repository)
465
      |> Repo.all()
466
467
    Enum.filter(items, fn item ->
468
      state_matches? =
469
        case opts[:promise_state] do
470
          nil -> true
471
          state -> PromiseRegistry.state(promise_context, item.values) == state
472
        end
473
474
      bounty_matches? =
475
        case opts[:bounty_candidate] do
476
          nil -> true
477
          value -> PromiseRegistry.bounty_candidate?(promise_context, item.values) == value
478
        end
479
480
      state_matches? and bounty_matches?
481
    end)
482
  end
483
484
  def list_visible_project_items_with_promises(%Project{} = project, user, opts \\ []) do
485
    promise_context = PromiseRegistry.context(project)
486
487
    items =
488
      list_visible_project_items(
489
        project,
490
        user,
491
        Keyword.put(opts, :promise_context, promise_context)
492
      )
493
494
    {items, project_item_projections(items, promise_context, user)}
495
  end
496
497
  def project_item_projections(items, promise_context, reader) do
498
    Map.new(items, fn item ->
499
      values = PromiseRegistry.redact_values(item.values, reader)
500
501
      {item.id,
502
       %{
503
         values: values,
504
         promise: PromiseRegistry.projection_from_redacted(promise_context, values)
505
       }}
506
    end)
507
  end
508
509
  def project_item_events(events, reader) do
510
    Enum.map(events, fn event ->
511
      changes =
512
        case event.changes do
513
          %{"values" => values} ->
514
            %{"values" => PromiseRegistry.redact_values(values, reader)}
515
516
          changes ->
517
            changes
518
        end
519
520
      %{event | changes: changes}
521
    end)
455 522
  end
456 523
457 524
  def get_project_item!(%Project{id: project_id, repository_id: repository_id}, id) do

@@ -489,14 +556,23 @@ defmodule OpenAgents.Projects do

489 556
    result =
490 557
      case Map.get(attrs, "issue_number") do
491 558
        nil ->
492
          %ProjectItem{}
493
          |> ProjectItem.changeset(%{
494
            "project_id" => project.id,
495
            "repository_id" => project.repository_id,
496
            "issue_repository_id" => issue_repository_id,
497
            "values" => values
498
          })
499
          |> Ecto.Changeset.apply_action(:insert)
559
          changeset =
560
            ProjectItem.changeset(%ProjectItem{}, %{
561
              "project_id" => project.id,
562
              "repository_id" => project.repository_id,
563
              "issue_repository_id" => issue_repository_id,
564
              "values" => values
565
            })
566
567
          if PromiseRegistry.registry?(project) do
568
            Ecto.Changeset.add_error(
569
              changeset,
570
              :issue_id,
571
              "is required for promise registry items"
572
            )
573
          else
574
            Ecto.Changeset.apply_action(changeset, :insert)
575
          end
500 576
501 577
        issue_number ->
502 578
          issue =

@@ -505,15 +581,24 @@ defmodule OpenAgents.Projects do

505 581
              number: issue_number
506 582
            )
507 583
508
          %ProjectItem{}
509
          |> ProjectItem.changeset(%{
584
          item_attrs = %{
510 585
            "project_id" => project.id,
511 586
            "issue_id" => issue.id,
512 587
            "repository_id" => project.repository_id,
513 588
            "issue_repository_id" => issue_repository_id,
514 589
            "values" => values
515
          })
516
          |> Repo.insert()
590
          }
591
592
          case prepare_promise_values(project, values, actor) do
593
            {:ok, values} ->
594
              insert_project_item(Map.put(item_attrs, "values", values), project, actor)
595
596
            {:error, errors} ->
597
              item_attrs
598
              |> then(&ProjectItem.changeset(%ProjectItem{}, &1))
599
              |> add_errors(errors)
600
              |> then(&{:error, &1})
601
          end
517 602
      end
518 603
519 604
    case result do

@@ -530,13 +615,34 @@ defmodule OpenAgents.Projects do

530 615
    end
531 616
  end
532 617
533
  def update_project_item(%ProjectItem{} = item, attrs) do
618
  def update_project_item(%ProjectItem{} = item, attrs, actor \\ nil) do
534 619
    attrs = to_string_map(attrs)
535
    values = Map.merge(item.values || %{}, Map.get(attrs, "values", %{}))
536 620
537
    item
538
    |> ProjectItem.changeset(%{"values" => values})
539
    |> Repo.update()
621
    values =
622
      case Map.get(attrs, "values", %{}) do
623
        incoming when is_map(incoming) -> Map.merge(item.values || %{}, incoming)
624
        incoming -> incoming
625
      end
626
627
    project = Repo.get!(Project, item.project_id)
628
629
    case prepare_promise_values(project, values, actor, item.id) do
630
      {:ok, values} ->
631
        changeset = ProjectItem.changeset(item, %{"values" => values})
632
633
        Repo.transaction(fn ->
634
          updated = Repo.update!(changeset)
635
          record_promise_event(updated, project, actor, item.values || %{}, values, "update")
636
          updated
637
        end)
638
        |> case do
639
          {:ok, updated} -> {:ok, Repo.preload(updated, issue: :repository)}
640
          result -> result
641
        end
642
643
      {:error, errors} ->
644
        {:error, add_errors(ProjectItem.changeset(item, %{"values" => values}), errors)}
645
    end
540 646
  end
541 647
542 648
  def list_project_fields(%Project{id: project_id}) do

@@ -547,9 +653,32 @@ defmodule OpenAgents.Projects do

547 653
  end
548 654
549 655
  def create_project_field(attrs) do
550
    %ProjectField{}
551
    |> ProjectField.changeset(attrs)
552
    |> Repo.insert()
656
    changeset =
657
      %ProjectField{}
658
      |> ProjectField.changeset(attrs)
659
      |> PromiseRegistry.validate_field()
660
661
    if changeset.valid? and promise_field_available?(changeset) do
662
      Repo.insert(changeset)
663
    else
664
      {:error, changeset}
665
    end
666
  end
667
668
  def list_project_item_events(%ProjectItem{id: item_id}, opts \\ []) do
669
    page = max(parse_page(opts[:page]), 1)
670
    per_page = 25
671
    query = from event in ProjectItemEvent, where: event.project_item_id == ^item_id
672
    total = Repo.aggregate(query, :count)
673
674
    events =
675
      query
676
      |> order_by([event], desc: event.occurred_at, desc: event.id)
677
      |> limit(^per_page)
678
      |> offset(^((page - 1) * per_page))
679
      |> Repo.all()
680
681
    {events, total, page, per_page}
553 682
  end
554 683
555 684
  defp next_project_number(repository_id) do

@@ -569,6 +698,111 @@ defmodule OpenAgents.Projects do

569 698
    )
570 699
  end
571 700
701
  defp prepare_promise_values(project, values, actor, exclude_id \\ nil)
702
703
  defp prepare_promise_values(_project, values, _actor, _exclude_id) when not is_map(values) do
704
    {:error, %{values: ["must be a map"]}}
705
  end
706
707
  defp prepare_promise_values(project, values, actor, exclude_id) do
708
    case PromiseRegistry.validate_values(project, values, actor) do
709
      {:ok, values} ->
710
        if PromiseRegistry.registry?(project) do
711
          promise_id = get_in(values, ["promise", "id"])
712
713
          if promise_id && promise_id_taken?(project.id, promise_id, exclude_id) do
714
            {:error, %{values: ["promise.id must be unique within this project"]}}
715
          else
716
            {:ok, values}
717
          end
718
        else
719
          {:ok, values}
720
        end
721
722
      {:error, errors} ->
723
        {:error, errors}
724
    end
725
  end
726
727
  defp insert_project_item(attrs, project, actor) do
728
    Repo.transaction(fn ->
729
      case Repo.insert(ProjectItem.changeset(%ProjectItem{}, attrs)) do
730
        {:ok, item} ->
731
          if PromiseRegistry.registry?(project) do
732
            record_promise_event(item, project, actor, %{}, item.values || %{}, "create")
733
          end
734
735
          item
736
737
        {:error, changeset} ->
738
          Repo.rollback(changeset)
739
      end
740
    end)
741
    |> case do
742
      {:ok, item} -> {:ok, Repo.preload(item, issue: :repository)}
743
      {:error, reason} -> {:error, reason}
744
    end
745
  end
746
747
  defp record_promise_event(item, project, actor, previous, values, kind) do
748
    from_state = PromiseRegistry.state(project, previous)
749
    to_state = PromiseRegistry.state(project, values)
750
    kind = if from_state && from_state != to_state, do: "state_change", else: kind
751
752
    %ProjectItemEvent{}
753
    |> ProjectItemEvent.changeset(%{
754
      project_item_id: item.id,
755
      project_id: project.id,
756
      repository_id: project.repository_id,
757
      actor_user_id: actor && actor.id,
758
      actor_login: actor_login(actor),
759
      kind: kind,
760
      from_state: from_state,
761
      to_state: to_state,
762
      changes: %{"values" => values},
763
      occurred_at: DateTime.utc_now()
764
    })
765
    |> Repo.insert!()
766
  end
767
768
  defp actor_login(nil), do: "system"
769
  defp actor_login(%User{github_login: login}) when is_binary(login), do: login
770
  defp actor_login(_actor), do: "system"
771
772
  defp promise_field_available?(changeset) do
773
    project_id = Ecto.Changeset.get_field(changeset, :project_id)
774
    data_type = Ecto.Changeset.get_field(changeset, :data_type)
775
776
    data_type != "promise_state" or
777
      not Repo.exists?(
778
        from field in ProjectField,
779
          where: field.project_id == ^project_id and field.data_type == "promise_state"
780
      )
781
  end
782
783
  defp promise_id_taken?(project_id, promise_id, exclude_id) do
784
    query =
785
      from item in ProjectItem,
786
        where:
787
          item.project_id == ^project_id and
788
            fragment("?->'promise'->>'id' = ?", item.values, ^promise_id)
789
790
    query =
791
      if exclude_id do
792
        where(query, [item], item.id != ^exclude_id)
793
      else
794
        query
795
      end
796
797
    Repo.exists?(query)
798
  end
799
800
  defp add_errors(changeset, errors) do
801
    Enum.reduce(errors, changeset, fn {field, messages}, changeset ->
802
      Enum.reduce(messages, changeset, &Ecto.Changeset.add_error(&2, field, &1))
803
    end)
804
  end
805
572 806
  defp number_conflict?(changeset) do
573 807
    Enum.any?(changeset.errors, fn {_field, {_message, options}} ->
574 808
      options[:constraint_name] == "projects_repository_id_number_index"
lib/openagents/projects/project_item_event.ex added +55

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

1
defmodule OpenAgents.Projects.ProjectItemEvent do
2
  use Ecto.Schema
3
  import Ecto.Changeset
4
5
  alias OpenAgents.Accounts.User
6
  alias OpenAgents.ProjectItems.ProjectItem
7
  alias OpenAgents.Projects.Project
8
  alias OpenAgents.Repositories.Repository
9
10
  @primary_key {:id, :binary_id, autogenerate: true}
11
  @timestamps_opts [type: :utc_datetime_usec]
12
13
  schema "project_item_events" do
14
    belongs_to :project_item, ProjectItem, type: :id
15
    belongs_to :project, Project, type: :id
16
    belongs_to :repository, Repository, type: :binary_id
17
    belongs_to :actor_user, User, type: :binary_id
18
    field :actor_login, :string
19
    field :kind, :string
20
    field :from_state, :string
21
    field :to_state, :string
22
    field :changes, :map, default: %{}
23
    field :occurred_at, :utc_datetime_usec
24
25
    timestamps(updated_at: false)
26
  end
27
28
  def changeset(event, attrs) do
29
    event
30
    |> cast(attrs, [
31
      :project_item_id,
32
      :project_id,
33
      :repository_id,
34
      :actor_user_id,
35
      :actor_login,
36
      :kind,
37
      :from_state,
38
      :to_state,
39
      :changes,
40
      :occurred_at
41
    ])
42
    |> validate_required([
43
      :project_item_id,
44
      :project_id,
45
      :repository_id,
46
      :actor_login,
47
      :kind,
48
      :occurred_at
49
    ])
50
    |> foreign_key_constraint(:project_item_id)
51
    |> foreign_key_constraint(:project_id)
52
    |> foreign_key_constraint(:repository_id)
53
    |> foreign_key_constraint(:actor_user_id)
54
  end
55
end
lib/openagents/projects/promise_registry.ex added +354

@@ -0,0 +1,354 @@

1
defmodule OpenAgents.Projects.PromiseRegistry do
2
  @moduledoc "Validation and privacy projections for promise registry items."
3
4
  import Ecto.Query
5
6
  alias OpenAgents.Changelog.Entry
7
  alias OpenAgents.Compensation.OutcomeDecision
8
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt}
9
  alias OpenAgents.ProjectFields.ProjectField
10
  alias OpenAgents.Projects.Project
11
  alias OpenAgents.Repo
12
  alias OpenAgents.Repositories
13
  alias OpenAgents.Repositories.Repository
14
15
  @states ~w(LIVE GATED WITHDRAWN)
16
  @promise_id ~r/^[a-z0-9][a-z0-9_]{2,63}$/
17
  @required_fields ~w(id problem claim scope acceptance_criteria success_metrics owner target evidence)
18
  @required_text_fields ~w(problem claim scope owner target)
19
20
  def states, do: @states
21
22
  def context(%Project{id: project_id}) do
23
    fields =
24
      Repo.all(
25
        from field in ProjectField,
26
          where: field.project_id == ^project_id and field.data_type == "promise_state"
27
      )
28
29
    case fields do
30
      [field] -> %{field: field, registry?: valid_registry_field?(field)}
31
      _other -> %{field: nil, registry?: false}
32
    end
33
  end
34
35
  def registry?(%Project{} = project), do: context(project).registry?
36
37
  def registry?(%{registry?: registry?}), do: registry?
38
39
  def registry?(_project), do: false
40
41
  def field(%Project{id: project_id}) do
42
    Repo.one(
43
      from field in ProjectField,
44
        where: field.project_id == ^project_id and field.data_type == "promise_state"
45
    )
46
  end
47
48
  def field(%{field: field}), do: field
49
50
  def state(%Project{} = project, values) when is_map(values) do
51
    state(context(project), values)
52
  end
53
54
  def state(%{field: %ProjectField{name: name}}, values) when is_map(values) do
55
    Map.get(values, name)
56
  end
57
58
  def state(_context, _values), do: nil
59
60
  def projection(context, values, reader) when is_map(values) do
61
    values
62
    |> redact_values(reader)
63
    |> projection_from_redacted(context)
64
  end
65
66
  def projection_from_redacted(context, values) when is_map(values) do
67
    if context.registry? do
68
      %{
69
        record: Map.get(values, "promise"),
70
        state: state(context, values),
71
        verified_at: get_in(values, ["promise", "verified_at"]),
72
        bounty_candidate: bounty_candidate?(context, values)
73
      }
74
    end
75
  end
76
77
  def projection_from_redacted(_context, _values), do: nil
78
79
  def bounty_candidate?(%{registry?: true} = context, values) do
80
    state(context, values) == "GATED" and
81
      values
82
      |> get_in(["promise", "gate", "missing"])
83
      |> present?()
84
  end
85
86
  def bounty_candidate?(_context, _values), do: false
87
88
  def validate_field(changeset) do
89
    data_type = Ecto.Changeset.get_field(changeset, :data_type)
90
    options = Ecto.Changeset.get_field(changeset, :options)
91
92
    cond do
93
      data_type != "promise_state" ->
94
        changeset
95
96
      options != %{"values" => @states} ->
97
        Ecto.Changeset.add_error(
98
          changeset,
99
          :options,
100
          "must contain exactly LIVE, GATED, and WITHDRAWN"
101
        )
102
103
      true ->
104
        changeset
105
    end
106
  end
107
108
  def validate_values(project, values, actor \\ nil)
109
110
  def validate_values(%Project{} = project, values, actor) when is_map(values) do
111
    promise_context = context(project)
112
113
    if promise_context.registry? do
114
      with {:ok, state} <- fetch_state(promise_context, values),
115
           {:ok, promise} <- validate_promise(Map.get(values, "promise")) do
116
        values = Map.put(values, "promise", promise)
117
118
        with :ok <- validate_state(state, promise, project, actor) do
119
          {:ok, put_verified_at(values, project, state, promise)}
120
        else
121
          {:error, field, message} -> {:error, %{field => [message]}}
122
        end
123
      else
124
        {:error, field, message} -> {:error, %{field => [message]}}
125
      end
126
    else
127
      {:ok, values}
128
    end
129
  end
130
131
  def validate_values(_project, values, _actor), do: {:ok, values}
132
133
  def redact_values(values, reader) when is_map(values) do
134
    case Map.get(values, "promise") do
135
      promise when is_map(promise) ->
136
        evidence =
137
          case Map.get(promise, "evidence") do
138
            evidence when is_list(evidence) ->
139
              Enum.filter(evidence, &readable_evidence?(&1, reader))
140
141
            _other ->
142
              []
143
          end
144
145
        put_in(values, ["promise", "evidence"], evidence)
146
147
      _other ->
148
        values
149
    end
150
  end
151
152
  def redact_values(values, _reader), do: values
153
154
  def resolvable_evidence?(
155
        %{"kind" => "accepted_outcome", "decision_receipt_ref" => ref} = evidence,
156
        reader
157
      ) do
158
    readable_evidence?(evidence, reader) and accepted_outcome?(ref)
159
  end
160
161
  def resolvable_evidence?(_evidence, _reader), do: false
162
163
  def readable_evidence?(%{"kind" => "issue"} = evidence, reader) do
164
    with owner when is_binary(owner) <- evidence["owner"],
165
         repo when is_binary(repo) <- evidence["repo"],
166
         {:ok, number} <- to_number(evidence["number"]),
167
         %Repository{} = repository <- visible_repository(owner, repo, reader) do
168
      issue_exists?(repository.id, number)
169
    else
170
      _ -> false
171
    end
172
  end
173
174
  def readable_evidence?(%{"kind" => "changelog", "slug" => slug}, _reader) do
175
    Repo.exists?(
176
      from entry in Entry,
177
        where:
178
          fragment("?->>'slug' = ?", entry.detail, ^slug) and entry.visibility in ["l2", "l3"]
179
    )
180
  end
181
182
  def readable_evidence?(%{"kind" => "receipt", "id" => id}, _reader) do
183
    case Ecto.UUID.cast(id) do
184
      {:ok, uuid} ->
185
        Repo.exists?(from receipt in BuildReceipt, where: receipt.id == ^uuid) or
186
          Repo.exists?(from receipt in DeployReceipt, where: receipt.id == ^uuid)
187
188
      :error ->
189
        false
190
    end
191
  end
192
193
  def readable_evidence?(
194
        %{"kind" => "accepted_outcome", "decision_receipt_ref" => ref},
195
        _reader
196
      )
197
      when is_binary(ref),
198
      do:
199
        Repo.exists?(
200
          from decision in OutcomeDecision, where: decision.decision_receipt_ref == ^ref
201
        )
202
203
  def readable_evidence?(%{"kind" => "link", "url" => url}, _reader), do: valid_link?(url)
204
  def readable_evidence?(_evidence, _reader), do: false
205
206
  defp fetch_state(promise_context, values) do
207
    case state(promise_context, values) do
208
      state when state in @states -> {:ok, state}
209
      _ -> {:error, :values, "must include a valid promise state"}
210
    end
211
  end
212
213
  defp validate_promise(promise) when is_map(promise) do
214
    cond do
215
      Enum.any?(@required_fields, &(not Map.has_key?(promise, &1))) ->
216
        {:error, :values,
217
         "promise must include id, problem, claim, scope, acceptance_criteria, success_metrics, owner, target, and evidence"}
218
219
      not is_binary(promise["id"]) or not Regex.match?(@promise_id, promise["id"]) ->
220
        {:error, :values, "promise.id must match ^[a-z0-9][a-z0-9_]{2,63}$"}
221
222
      not Enum.all?(@required_text_fields, &present?(Map.get(promise, &1))) ->
223
        {:error, :values, "promise text fields must be nonempty strings"}
224
225
      not nonempty_list?(promise["acceptance_criteria"]) ->
226
        {:error, :values, "promise.acceptance_criteria must be a nonempty list"}
227
228
      not nonempty_list?(promise["success_metrics"]) ->
229
        {:error, :values, "promise.success_metrics must be a nonempty list"}
230
231
      not nonempty_text_list?(promise["acceptance_criteria"]) ->
232
        {:error, :values, "promise.acceptance_criteria must contain nonempty strings"}
233
234
      not nonempty_text_list?(promise["success_metrics"]) ->
235
        {:error, :values, "promise.success_metrics must contain nonempty strings"}
236
237
      not is_list(promise["evidence"]) ->
238
        {:error, :values, "promise.evidence must be a list"}
239
240
      not Enum.all?(promise["evidence"], &valid_evidence_shape?/1) ->
241
        {:error, :values, "promise.evidence contains an invalid entry"}
242
243
      true ->
244
        {:ok, Map.delete(promise, "verified_at")}
245
    end
246
  end
247
248
  defp validate_promise(_promise), do: {:error, :values, "values.promise is required"}
249
250
  defp validate_state("LIVE", promise, _project, actor) do
251
    if Enum.any?(promise["evidence"], &resolvable_evidence?(&1, actor)) do
252
      :ok
253
    else
254
      {:error, :values, "LIVE requires at least one accepted-outcome evidence entry"}
255
    end
256
  end
257
258
  defp validate_state("GATED", promise, _project, _actor) do
259
    if complete_map?(promise["gate"], ~w(missing owner next_review)) do
260
      :ok
261
    else
262
      {:error, :values, "GATED requires gate.missing, gate.owner, and gate.next_review"}
263
    end
264
  end
265
266
  defp validate_state("WITHDRAWN", promise, _project, _actor) do
267
    if complete_map?(promise["withdrawal"], ~w(reason replacement date)) do
268
      :ok
269
    else
270
      {:error, :values,
271
       "WITHDRAWN requires withdrawal.reason, withdrawal.replacement, and withdrawal.date"}
272
    end
273
  end
274
275
  defp put_verified_at(values, _project, "LIVE", promise) do
276
    Map.put(values, "promise", Map.put(promise, "verified_at", DateTime.utc_now()))
277
  end
278
279
  defp put_verified_at(values, _project, _state, _promise), do: values
280
281
  defp valid_evidence_shape?(%{
282
         "kind" => "issue",
283
         "owner" => owner,
284
         "repo" => repo,
285
         "number" => number
286
       }),
287
       do: present?(owner) and present?(repo) and valid_number?(number)
288
289
  defp valid_evidence_shape?(%{"kind" => "changelog", "slug" => slug}), do: present?(slug)
290
  defp valid_evidence_shape?(%{"kind" => "receipt", "id" => id}), do: present?(id)
291
292
  defp valid_evidence_shape?(%{"kind" => "accepted_outcome", "decision_receipt_ref" => ref}),
293
    do: present?(ref)
294
295
  defp valid_evidence_shape?(%{"kind" => "link", "url" => url}), do: valid_link?(url)
296
  defp valid_evidence_shape?(_evidence), do: false
297
298
  defp visible_repository(owner, repo, reader) do
299
    Repositories.get_visible_by_path!(owner, repo, reader)
300
  rescue
301
    Ecto.NoResultsError -> nil
302
  end
303
304
  defp issue_exists?(repository_id, number) do
305
    Repo.exists?(
306
      from issue in OpenAgents.Issues.Issue,
307
        where: issue.repository_id == ^repository_id and issue.number == ^number
308
    )
309
  end
310
311
  defp accepted_outcome?(ref) when is_binary(ref) do
312
    Repo.exists?(
313
      from decision in OutcomeDecision,
314
        where: decision.decision_receipt_ref == ^ref and decision.decision == "accepted"
315
    )
316
  end
317
318
  defp accepted_outcome?(_ref), do: false
319
320
  defp valid_registry_field?(%ProjectField{options: %{"values" => values}})
321
       when values == @states,
322
       do: true
323
324
  defp valid_registry_field?(_field), do: false
325
326
  defp complete_map?(map, keys) when is_map(map), do: Enum.all?(keys, &present?(map[&1]))
327
  defp complete_map?(_map, _keys), do: false
328
  defp nonempty_list?(value), do: is_list(value) and value != []
329
  defp nonempty_text_list?(value), do: Enum.all?(value, &present?/1)
330
  defp present?(value), do: is_binary(value) and String.trim(value) != ""
331
  defp valid_link?(url), do: present?(url) and valid_url?(url)
332
  defp valid_number?(value), do: match?({:ok, _}, to_number(value))
333
334
  defp valid_url?(url) do
335
    case URI.parse(url) do
336
      %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) ->
337
        true
338
339
      _ ->
340
        false
341
    end
342
  end
343
344
  defp to_number(value) when is_integer(value) and value > 0, do: {:ok, value}
345
346
  defp to_number(value) when is_binary(value) do
347
    case Integer.parse(value) do
348
      {number, ""} when number > 0 -> {:ok, number}
349
      _ -> :error
350
    end
351
  end
352
353
  defp to_number(_value), do: :error
354
end
lib/openagents_web/api_route_authority.ex modified +2

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

69 69
      "get /api/v3/repos/:owner/:repo/projectsV2" => :optional_bearer,
70 70
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number" => :optional_bearer,
71 71
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/items" => :optional_bearer,
72
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/items/:item_id/events" =>
73
        :optional_bearer,
72 74
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/fields" => :optional_bearer,
73 75
      "get /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes" => :optional_bearer,
74 76
      "get /api/v3/reputation/policy" => :optional_bearer,
lib/openagents_web/controllers/api_extension_controller.ex modified +64

@@ -19,6 +19,41 @@ defmodule OpenAgentsWeb.ApiExtensionController do

19 19
    }
20 20
  }
21 21
22
  @promise_record %{
23
    "type" => "object",
24
    "description" => "A state-gated promise record stored in a project item.",
25
    "required" => [
26
      "id",
27
      "problem",
28
      "claim",
29
      "scope",
30
      "acceptance_criteria",
31
      "success_metrics",
32
      "owner",
33
      "target",
34
      "evidence"
35
    ],
36
    "properties" => %{
37
      "verified_at" => %{"type" => ["string", "null"]},
38
      "gate" => %{
39
        "type" => "object",
40
        "properties" => %{
41
          "missing" => %{"type" => "string"},
42
          "owner" => %{"type" => "string"},
43
          "next_review" => %{"type" => "string"}
44
        }
45
      },
46
      "withdrawal" => %{
47
        "type" => "object",
48
        "properties" => %{
49
          "reason" => %{"type" => "string"},
50
          "replacement" => %{"type" => "string"},
51
          "date" => %{"type" => "string"}
52
        }
53
      }
54
    }
55
  }
56
22 57
  @extensions %{
23 58
    "capacity.openagents" => %{
24 59
      "version" => "2026-08-23",

@@ -65,6 +100,35 @@ defmodule OpenAgentsWeb.ApiExtensionController do

65 100
        "POST /api/v3/repos/{owner}/{repo}/issues/{issue_number}/dependencies",
66 101
        "DELETE /api/v3/repos/{owner}/{repo}/issues/{issue_number}/dependencies/{blocked_by_number}"
67 102
      ]
103
    },
104
    "project_item.openagents" => %{
105
      "version" => "2026-08-23",
106
      "description" => "Promise registry fields and append-only item history.",
107
      "fields" => %{
108
        "promise" => %{
109
          "type" => "object",
110
          "properties" => %{
111
            "record" => @promise_record,
112
            "state" => %{"type" => "string", "enum" => ["LIVE", "GATED", "WITHDRAWN"]},
113
            "verified_at" => %{"type" => ["string", "null"]},
114
            "bounty_candidate" => %{"type" => "boolean"}
115
          }
116
        }
117
      },
118
      "filters" => %{
119
        "promise_state" => %{
120
          "endpoint" => "GET /api/v3/repos/{owner}/{repo}/projectsV2/{project_number}/items",
121
          "type" => "string",
122
          "enum" => ["LIVE", "GATED", "WITHDRAWN"]
123
        },
124
        "bounty_candidate" => %{
125
          "endpoint" => "GET /api/v3/repos/{owner}/{repo}/projectsV2/{project_number}/items",
126
          "type" => "boolean"
127
        }
128
      },
129
      "endpoints" => [
130
        "GET /api/v3/repos/{owner}/{repo}/projectsV2/{project_number}/items/{item_id}/events"
131
      ]
68 132
    }
69 133
  }
70 134
lib/openagents_web/controllers/project_controller.ex modified +92 -11

@@ -201,17 +201,33 @@ defmodule OpenAgentsWeb.ProjectController do

201 201
  defp valid_state?(%{"state" => state}), do: state in ["open", "closed"]
202 202
  defp valid_state?(_attrs), do: true
203 203
204
  def items(conn, %{
205
        "owner" => owner,
206
        "repo" => repo,
207
        "project_number" => project_number
208
      }) do
204
  def items(
205
        conn,
206
        %{
207
          "owner" => owner,
208
          "repo" => repo,
209
          "project_number" => project_number
210
        } = params
211
      ) do
209 212
    repository = visible_repository!(conn, owner, repo)
210 213
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
211 214
212
    render(conn, :items,
213
      items: Projects.list_visible_project_items(project, conn.assigns[:current_user])
214
    )
215
    with {:ok, opts} <- item_filters(params) do
216
      {items, item_projections} =
217
        Projects.list_visible_project_items_with_promises(
218
          project,
219
          conn.assigns[:current_user],
220
          opts
221
        )
222
223
      render(conn, :items,
224
        items: items,
225
        viewer: conn.assigns[:current_user],
226
        item_projections: item_projections
227
      )
228
    else
229
      {:error, field, message} -> unprocessable(conn, %{field => [message]})
230
    end
215 231
  rescue
216 232
    Ecto.NoResultsError -> not_found(conn)
217 233
  end

@@ -241,7 +257,7 @@ defmodule OpenAgentsWeb.ProjectController do

241 257
          {:ok, item} ->
242 258
            conn
243 259
            |> put_status(:created)
244
            |> render(:items, items: [item])
260
            |> render_items([item], project)
245 261
246 262
          {:error, %Ecto.Changeset{} = changeset} ->
247 263
            conn

@@ -273,9 +289,9 @@ defmodule OpenAgentsWeb.ProjectController do

273 289
      )
274 290
275 291
    if is_map(Map.get(params, "values", %{})) do
276
      case Projects.update_project_item(item, params) do
292
      case Projects.update_project_item(item, params, conn.assigns.current_user) do
277 293
        {:ok, item} ->
278
          render(conn, :items, items: [item])
294
          render_items(conn, [item], project)
279 295
280 296
        {:error, %Ecto.Changeset{} = changeset} ->
281 297
          conn

@@ -289,6 +305,37 @@ defmodule OpenAgentsWeb.ProjectController do

289 305
    Ecto.NoResultsError -> not_found(conn)
290 306
  end
291 307
308
  def events(
309
        conn,
310
        %{
311
          "owner" => owner,
312
          "repo" => repo,
313
          "project_number" => project_number,
314
          "item_id" => item_id
315
        } = params
316
      ) do
317
    repository = visible_repository!(conn, owner, repo)
318
    project = Projects.get_project_by_number!(repository, parse_id!(project_number))
319
320
    item =
321
      Projects.get_visible_project_item!(
322
        project,
323
        parse_id!(item_id),
324
        conn.assigns[:current_user]
325
      )
326
327
    {events, total, page, per_page} =
328
      Projects.list_project_item_events(item, page: params["page"])
329
330
    render(conn, :events,
331
      events: Projects.project_item_events(events, conn.assigns[:current_user]),
332
      pagination: %{page: page, per_page: per_page, total: total},
333
      viewer: conn.assigns[:current_user]
334
    )
335
  rescue
336
    Ecto.NoResultsError -> not_found(conn)
337
  end
338
292 339
  def fields(conn, %{
293 340
        "owner" => owner,
294 341
        "repo" => repo,

@@ -391,6 +438,40 @@ defmodule OpenAgentsWeb.ProjectController do

391 438
    conn |> put_status(:forbidden) |> json(%{message: "Forbidden"})
392 439
  end
393 440
441
  defp item_filters(params) do
442
    state = params["promise_state"]
443
    bounty = params["bounty_candidate"]
444
445
    cond do
446
      state && state not in ~w(LIVE GATED WITHDRAWN) ->
447
        {:error, :promise_state, "must be one of: LIVE, GATED, WITHDRAWN"}
448
449
      bounty && bounty not in ~w(true false) ->
450
        {:error, :bounty_candidate, "must be true or false"}
451
452
      true ->
453
        {:ok,
454
         [
455
           promise_state: state,
456
           bounty_candidate: if(bounty, do: bounty == "true")
457
         ]
458
         |> Enum.reject(fn {_key, value} -> is_nil(value) end)}
459
    end
460
  end
461
462
  defp render_items(conn, items, project) do
463
    render(conn, :items,
464
      items: items,
465
      viewer: conn.assigns[:current_user],
466
      item_projections:
467
        Projects.project_item_projections(
468
          items,
469
          OpenAgents.Projects.PromiseRegistry.context(project),
470
          conn.assigns[:current_user]
471
        )
472
    )
473
  end
474
394 475
  defp not_found(conn) do
395 476
    conn |> put_status(:not_found) |> json(%{message: "Not Found"})
396 477
  end
lib/openagents_web/controllers/project_json.ex modified +45 -4

@@ -11,8 +11,24 @@ defmodule OpenAgentsWeb.ProjectJSON do

11 11
    project_json(project)
12 12
  end
13 13
14
  def render("items.json", %{items: items}) do
15
    %{items: Enum.map(items, &item_json/1)}
14
  def render("items.json", %{items: items} = assigns) do
15
    item_projections = Map.get(assigns, :item_projections, %{})
16
17
    %{
18
      items: Enum.map(items, &item_json(&1, Map.get(item_projections, &1.id)))
19
    }
20
  end
21
22
  def render("events.json", %{events: events, pagination: pagination}) do
23
    %{
24
      events: Enum.map(events, &event_json/1),
25
      pagination: %{
26
        page: pagination.page,
27
        per_page: pagination.per_page,
28
        total: pagination.total,
29
        total_pages: total_pages(pagination.total, pagination.per_page)
30
      }
31
    }
16 32
  end
17 33
18 34
  def render("fields.json", %{fields: fields}) do

@@ -63,10 +79,12 @@ defmodule OpenAgentsWeb.ProjectJSON do

63 79
    }
64 80
  end
65 81
66
  defp item_json(item) do
82
  defp item_json(item, item_projection) do
67 83
    issue = item.issue
68 84
    repository = issue.repository
69 85
    base_url = OpenAgentsWeb.Endpoint.url()
86
    values = get_in(item_projection || %{}, [:values]) || item.values
87
    promise = get_in(item_projection || %{}, [:promise])
70 88
71 89
    %{
72 90
      id: item.id,

@@ -79,10 +97,33 @@ defmodule OpenAgentsWeb.ProjectJSON do

79 97
          "#{base_url}/api/v3/repos/#{repository.owner}/#{repository.name}/issues/#{issue.number}",
80 98
        html_url: "#{base_url}/#{repository.owner}/#{repository.name}/issues/#{issue.number}"
81 99
      },
82
      values: item.values
100
      values: values
83 101
    }
102
    |> maybe_put_promise(promise)
84 103
  end
85 104
105
  defp maybe_put_promise(json, nil), do: json
106
  defp maybe_put_promise(json, promise), do: Map.put(json, :openagents, %{promise: promise})
107
108
  defp event_json(event) do
109
    %{
110
      id: event.id,
111
      project_item_id: event.project_item_id,
112
      project_id: event.project_id,
113
      actor_login: event.actor_login,
114
      actor_user_id: event.actor_user_id,
115
      kind: event.kind,
116
      from_state: event.from_state,
117
      to_state: event.to_state,
118
      changes: event.changes,
119
      occurred_at: event.occurred_at,
120
      inserted_at: event.inserted_at
121
    }
122
  end
123
124
  defp total_pages(0, _per_page), do: 1
125
  defp total_pages(total, per_page), do: ceil(total / per_page)
126
86 127
  defp field_json(field) do
87 128
    %{
88 129
      id: field.id,
lib/openagents_web/live/project_show_live.ex modified +74 -10

@@ -28,6 +28,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

28 28
  alias OpenAgents.Issues
29 29
  alias OpenAgents.Markdown
30 30
  alias OpenAgents.Projects
31
  alias OpenAgents.Projects.PromiseRegistry
31 32
  alias OpenAgents.ProjectItems.ProjectItem
32 33
  alias OpenAgents.Repositories
33 34
  alias OpenAgentsWeb.UI.Circle

@@ -41,6 +42,10 @@ defmodule OpenAgentsWeb.ProjectShowLive do

41 42
    can_write = Repositories.writable?(repository, user)
42 43
43 44
    if connected?(socket), do: Repositories.subscribe_projects(repository.id)
45
    promise_context = PromiseRegistry.context(project)
46
    items = project_items(project, socket.assigns.current_user, promise_context)
47
    promise_registry? = promise_context.registry?
48
    issue_options = issue_options(repository)
44 49
45 50
    {:ok,
46 51
     socket

@@ -50,8 +55,10 @@ defmodule OpenAgentsWeb.ProjectShowLive do

50 55
     |> assign(:repository, repository)
51 56
     |> assign(:project, project)
52 57
     |> assign(:can_write, can_write)
53
     |> assign(:items, project_items(project, user))
54
     |> assign(:issue_options, issue_options(repository))
58
     |> assign(:items, items)
59
     |> assign(:promise_context, promise_context)
60
     |> assign(:promise_registry?, promise_registry?)
61
     |> assign(:issue_options, issue_options)
55 62
     # The board columns are read as `@statuses` inside ~H, where `@` means
56 63
     # `assigns.statuses`, not the module attribute. Without this assign every
57 64
     # render raised KeyError and the route was unreachable.

@@ -81,7 +88,14 @@ defmodule OpenAgentsWeb.ProjectShowLive do

81 88
        {:ok, _item} ->
82 89
          {:noreply,
83 90
           socket
84
           |> assign(:items, project_items(project, socket.assigns.current_user))
91
           |> assign(
92
             :items,
93
             project_items(
94
               project,
95
               socket.assigns.current_user,
96
               socket.assigns.promise_context
97
             )
98
           )
85 99
           |> assign(:form, to_form(ProjectItem.changeset(%ProjectItem{}, %{}), as: "item"))
86 100
           |> put_flash(:info, "Issue added to project")}
87 101

@@ -188,10 +202,14 @@ defmodule OpenAgentsWeb.ProjectShowLive do

188 202
          {:noreply, put_flash(socket, :error, "This project no longer exists.")}
189 203
190 204
        project ->
205
          promise_context = PromiseRegistry.context(project)
206
191 207
          {:noreply,
192 208
           socket
193 209
           |> assign(:project, project)
194
           |> assign(:items, project_items(project, user))
210
           |> assign(:promise_context, promise_context)
211
           |> assign(:promise_registry?, promise_context.registry?)
212
           |> assign(:items, project_items(project, user, promise_context))
195 213
           |> reload_notes()}
196 214
      end
197 215
    else

@@ -239,15 +257,45 @@ defmodule OpenAgentsWeb.ProjectShowLive do

239 257
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
240 258
  end
241 259
242
  defp project_items(project, user) do
243
    Projects.list_visible_project_items(project, user)
260
  defp project_items(project, user, promise_context) do
261
    Projects.list_visible_project_items(project, user, promise_context: promise_context)
244 262
    |> Enum.map(fn item ->
245 263
      issue = item.issue
246
      status = get_in(item.values, ["Status"]) || "To Do"
247
      Map.merge(item, %{issue: issue, status: status})
264
      values = PromiseRegistry.redact_values(item.values, user)
265
266
      if promise_context.registry? do
267
        promise = Map.get(values, "promise", %{})
268
        state = PromiseRegistry.state(promise_context, values)
269
270
        Map.merge(item, %{
271
          issue: issue,
272
          values: values,
273
          status: state,
274
          promise_id: promise["id"],
275
          verified_at: promise["verified_at"],
276
          evidence_count: length(promise["evidence"] || []),
277
          gate_missing: get_in(promise, ["gate", "missing"]),
278
          next_review: get_in(promise, ["gate", "next_review"])
279
        })
280
      else
281
        status = get_in(values, ["Status"]) || "To Do"
282
        Map.merge(item, %{issue: issue, values: values, status: status})
283
      end
248 284
    end)
249 285
  end
250 286
287
  defp verification_time(nil), do: "Not verified"
288
  defp verification_time(%DateTime{} = value), do: Calendar.strftime(value, "%Y-%m-%d %H:%M UTC")
289
290
  defp verification_time(value) when is_binary(value) do
291
    case DateTime.from_iso8601(value) do
292
      {:ok, value, _offset} -> verification_time(value)
293
      _ -> "Not verified"
294
    end
295
  end
296
297
  defp verification_time(_value), do: "Not verified"
298
251 299
  defp issue_options(repository) do
252 300
    Issues.list_issues(repository, state: "all")
253 301
    |> Enum.map(&{"##{&1.number} #{&1.title}", &1.number})

@@ -336,7 +384,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

336 384
      </section>
337 385
338 386
      <.form
339
        :if={@can_write}
387
        :if={@can_write and not @promise_registry?}
340 388
        for={@form}
341 389
        id="new-project-item-form"
342 390
        phx-submit="add_item"

@@ -365,7 +413,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

365 413
      </.form>
366 414
367 415
      <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
368
        <%= for status <- @statuses do %>
416
        <%= for status <- if(@promise_registry?, do: PromiseRegistry.states(), else: @statuses) do %>
369 417
          <section class="card !m-0 !p-3">
370 418
            <header class="mb-2">
371 419
              <h3 class="card-title !text-sm">{status}</h3>

@@ -373,6 +421,22 @@ defmodule OpenAgentsWeb.ProjectShowLive do

373 421
            <div class="space-y-2 min-h-24">
374 422
              <%= for item <- @items, item.status == status do %>
375 423
                <article class="card !m-0 !p-3">
424
                  <%= if @promise_registry? do %>
425
                    <div class="flex items-center justify-between gap-2 mb-2">
426
                      <span class="badge" data-variant="secondary">{item.promise_id}</span>
427
                      <span class="text-xs text-muted-foreground">
428
                        {item.evidence_count} evidence
429
                      </span>
430
                    </div>
431
                    <div class="text-xs text-muted-foreground mb-2">
432
                      Verified: {verification_time(item.verified_at)}
433
                    </div>
434
                    <%= if status == "GATED" do %>
435
                      <div class="text-xs text-muted-foreground mb-2">
436
                        Missing: {item.gate_missing} · Next review: {item.next_review}
437
                      </div>
438
                    <% end %>
439
                  <% end %>
376 440
                  <.link
377 441
                    navigate={
378 442
                      ~p"/#{item.issue.repository.owner}/#{item.issue.repository.name}/issues/#{item.issue.number}"
lib/openagents_web/route_authority.ex modified +1

@@ -63,6 +63,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

63 63
    "/api/v3/repos/:owner/:repo/projectsV2",
64 64
    "/api/v3/repos/:owner/:repo/projectsV2/:project_number",
65 65
    "/api/v3/repos/:owner/:repo/projectsV2/:project_number/items",
66
    "/api/v3/repos/:owner/:repo/projectsV2/:project_number/items/:item_id/events",
66 67
    "/api/v3/repos/:owner/:repo/projectsV2/:project_number/fields"
67 68
  ]
68 69
lib/openagents_web/router.ex modified +5

@@ -388,6 +388,11 @@ defmodule OpenAgentsWeb.Router do

388 388
    get "/repos/:owner/:repo/projectsV2", ProjectController, :index
389 389
    get "/repos/:owner/:repo/projectsV2/:project_number", ProjectController, :show
390 390
    get "/repos/:owner/:repo/projectsV2/:project_number/items", ProjectController, :items
391
392
    get "/repos/:owner/:repo/projectsV2/:project_number/items/:item_id/events",
393
        ProjectController,
394
        :events
395
391 396
    get "/repos/:owner/:repo/projectsV2/:project_number/fields", ProjectController, :fields
392 397
    get "/repos/:owner/:repo/projectsV2/:project_number/notes", ProjectController, :notes
393 398
priv/docs/rest-api.md modified +19

@@ -223,12 +223,31 @@ POST /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes

223 223
PATCH  /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id
224 224
DELETE /api/v3/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id
225 225
GET    /api/v3/repos/:owner/:repo/projectsV2/:project_number/items
226
GET    /api/v3/repos/:owner/:repo/projectsV2/:project_number/items/:item_id/events
226 227
POST   /api/v3/repos/:owner/:repo/projectsV2/:project_number/items
227 228
PATCH  /api/v3/repos/:owner/:repo/projectsV2/:project_number/items/:item_id
228 229
GET    /api/v3/repos/:owner/:repo/projectsV2/:project_number/fields
229 230
POST   /api/v3/repos/:owner/:repo/projectsV2/:project_number/fields
230 231
```
231 232
233
Promise registry projects use a `promise_state` field with `LIVE`, `GATED`,
234
and `WITHDRAWN` values. Their item responses include `openagents.promise`.
235
Filter items with `promise_state=LIVE|GATED|WITHDRAWN` or
236
`bounty_candidate=true`. Read an item's actor-attributed history with:
237
238
```text
239
GET /api/v3/repos/{owner}/{repo}/projectsV2/{project_number}/items/{item_id}/events
240
```
241
242
Only an `accepted_outcome` evidence entry naming an existing accepted
243
`OpenAgents.Compensation.OutcomeDecision` can certify a `LIVE` promise. Issue,
244
changelog, and forge receipt entries remain visible supporting evidence, but
245
they do not certify `LIVE`; links never certify it.
246
247
The events endpoint is paginated with the standard `page` parameter. Evidence
248
that points to a repository or issue you cannot read is omitted from the item,
249
promise, and event projections.
250
232 251
The repository in the path controls visibility and write authority. Project
233 252
numbers are repository-local. Project creation through this REST path is an
234 253
OpenAgents extension; GitHub Projects V2 creation is not part of the assessed
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -247,7 +247,8 @@

247 247
    20260823054500,
248 248
    20260823060000,
249 249
    20260823070000,
250
    20260823071500
250
    20260823071500,
251
    20260823072000
251 252
  ],
252 253
  "required_tables": [
253 254
    "users",
priv/promises/curated.json added +91

@@ -0,0 +1,91 @@

1
[
2
  {
3
    "id": "promises_registry_v1",
4
    "state": "GATED",
5
    "problem": "Product claims are difficult to compare with shipped work.",
6
    "claim": "The forge exposes a machine-readable registry of product promises.",
7
    "scope": "Public product promises maintained in the OpenAgents forge.",
8
    "acceptance_criteria": ["A reader can list promises by state.", "Every LIVE promise has resolvable evidence."],
9
    "success_metrics": ["Every published promise has one canonical issue."],
10
    "owner": "OpenAgents",
11
    "target": "2026-08-23",
12
    "evidence": [
13
      {
14
        "kind": "issue",
15
        "owner": "OpenAgentsInc",
16
        "repo": "openagents.com",
17
        "number": 92
18
      }
19
    ],
20
    "gate": {
21
      "missing": "An accepted-outcome receipt for the registry launch.",
22
      "owner": "OpenAgents",
23
      "next_review": "2026-09-01"
24
    }
25
  },
26
  {
27
    "id": "autopilot_historical_claude_code_me",
28
    "state": "WITHDRAWN",
29
    "problem": "The original Autopilot depended on a closed provider.",
30
    "claim": "Autopilot can use a provider-independent coding lane.",
31
    "scope": "The historical Claude Code implementation.",
32
    "acceptance_criteria": ["The withdrawn path remains visible.", "A replacement path is recorded."],
33
    "success_metrics": ["No active work depends on the withdrawn path."],
34
    "owner": "OpenAgents",
35
    "target": "2026-08-23",
36
    "withdrawal": {"reason": "The provider terms changed.", "replacement": "Use the forge coding lanes.", "date": "2026-08-23"},
37
    "evidence": []
38
  },
39
  {
40
    "id": "pylon_first_real_model_training_run_v1",
41
    "state": "GATED",
42
    "problem": "Distributed model training needs a public, verifiable first run.",
43
    "claim": "Pylon can coordinate a real model training run.",
44
    "scope": "The first production-scale Pylon training run.",
45
    "acceptance_criteria": ["A run completes on participating nodes."],
46
    "success_metrics": ["A completed training receipt is published."],
47
    "owner": "OpenAgents",
48
    "target": "2026-09-30",
49
    "gate": {"missing": "A completed training receipt.", "owner": "OpenAgents", "next_review": "2026-09-01"},
50
    "evidence": []
51
  },
52
  {
53
    "id": "accepted_outcomes_per_kilowatt_hour_v1",
54
    "state": "GATED",
55
    "problem": "Compute efficiency needs a workload-based metric.",
56
    "claim": "The forge can report accepted outcomes per kilowatt hour.",
57
    "scope": "Pylon and Psionic provider routing.",
58
    "acceptance_criteria": ["Energy and outcome measurements share a receipt.", "The metric is queryable."],
59
    "success_metrics": ["A weekly metric is published."],
60
    "owner": "OpenAgents",
61
    "target": "2026-10-15",
62
    "gate": {"missing": "Energy telemetry linked to accepted outcomes.", "owner": "OpenAgents", "next_review": "2026-09-15"},
63
    "evidence": []
64
  },
65
  {
66
    "id": "forge_bounty_clearing_loop_v1",
67
    "state": "GATED",
68
    "problem": "Verified work does not yet have a complete settlement loop.",
69
    "claim": "A forge issue can move from claim through verification to settlement.",
70
    "scope": "One public bounty issue and its receipts.",
71
    "acceptance_criteria": ["A claim, execution, verification, and settlement are linked."],
72
    "success_metrics": ["One bounty completes end to end."],
73
    "owner": "OpenAgents",
74
    "target": "2026-10-31",
75
    "gate": {"missing": "A verified settlement receipt.", "owner": "OpenAgents", "next_review": "2026-09-30"},
76
    "evidence": []
77
  },
78
  {
79
    "id": "agentic_module_registry_v1",
80
    "state": "GATED",
81
    "problem": "Verified work cannot yet be reused as portable modules.",
82
    "claim": "Agents can discover modules backed by verified work.",
83
    "scope": "Forge receipts and public module metadata.",
84
    "acceptance_criteria": ["A module has a stable identifier.", "Its verification receipt is visible."],
85
    "success_metrics": ["The first verified module is listed."],
86
    "owner": "OpenAgents",
87
    "target": "2026-11-15",
88
    "gate": {"missing": "A module index backed by verification receipts.", "owner": "OpenAgents", "next_review": "2026-10-15"},
89
    "evidence": []
90
  }
91
]
priv/repo/migrations/20260823072000_create_project_item_events.exs added +54

@@ -0,0 +1,54 @@

1
defmodule OpenAgents.Repo.Migrations.CreateProjectItemEvents do
2
  use Ecto.Migration
3
4
  def up do
5
    create table(:project_item_events, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
      add :project_item_id, :id, null: false
8
      add :project_id, :id, null: false
9
10
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :nilify_all)
11
12
      add :actor_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
13
      add :actor_login, :string, null: false
14
      add :kind, :string, null: false
15
      add :from_state, :string
16
      add :to_state, :string
17
      add :changes, :map, null: false, default: %{}
18
      add :occurred_at, :utc_datetime_usec, null: false
19
      timestamps(updated_at: false, type: :utc_datetime_usec)
20
    end
21
22
    create index(:project_item_events, [:project_item_id, :occurred_at])
23
    create index(:project_item_events, [:project_id, :occurred_at])
24
    create index(:project_item_events, [:repository_id])
25
26
    execute("""
27
    CREATE OR REPLACE FUNCTION prevent_project_item_event_rewrite()
28
    RETURNS trigger AS $$
29
    BEGIN
30
      RAISE EXCEPTION 'project item events are append-only';
31
    END;
32
    $$ LANGUAGE plpgsql;
33
    """)
34
35
    execute("""
36
    CREATE TRIGGER project_item_events_prevent_update
37
    BEFORE UPDATE ON project_item_events
38
    FOR EACH ROW EXECUTE FUNCTION prevent_project_item_event_rewrite()
39
    """)
40
41
    execute("""
42
    CREATE TRIGGER project_item_events_prevent_delete
43
    BEFORE DELETE ON project_item_events
44
    FOR EACH ROW EXECUTE FUNCTION prevent_project_item_event_rewrite()
45
    """)
46
  end
47
48
  def down do
49
    execute("DROP TRIGGER IF EXISTS project_item_events_prevent_update ON project_item_events")
50
    execute("DROP TRIGGER IF EXISTS project_item_events_prevent_delete ON project_item_events")
51
    execute("DROP FUNCTION IF EXISTS prevent_project_item_event_rewrite()")
52
    drop table(:project_item_events)
53
  end
54
end
test/mix/tasks/openagents_promises_backfill_test.exs added +139

@@ -0,0 +1,139 @@

1
defmodule Mix.Tasks.Openagents.Promises.BackfillTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Issues
5
  alias OpenAgents.Projects
6
7
  import ExUnit.CaptureIO
8
9
  test "backfills the curated registry idempotently" do
10
    repository =
11
      repository_fixture(%{
12
        owner: "BackfillOrg",
13
        name: "backfill-promises",
14
        visibility: "public"
15
      })
16
17
    {:ok, project} =
18
      Projects.create_project(repository, %{
19
        title: "Promises",
20
        owner: "OpenAgents",
21
        state: "open"
22
      })
23
24
    args = [
25
      "--owner",
26
      repository.owner,
27
      "--repo",
28
      repository.name,
29
      "--project-number",
30
      to_string(project.number)
31
    ]
32
33
    first_output = capture_io(fn -> Mix.Tasks.Openagents.Promises.Backfill.run(args) end)
34
    assert first_output =~ "Backfilled 6 promise(s); skipped 0."
35
    assert length(Projects.list_project_fields(project)) == 1
36
    assert length(Projects.list_project_items(project)) == 6
37
    assert length(Issues.list_issues(repository, state: "all")) == 6
38
    assert Enum.all?(Issues.list_issues(repository, state: "all"), &(&1.state == "open"))
39
40
    Mix.Task.reenable("openagents.promises.backfill")
41
42
    second_output = capture_io(fn -> Mix.Tasks.Openagents.Promises.Backfill.run(args) end)
43
    assert second_output =~ "Backfilled 0 promise(s); skipped 6."
44
    assert length(Projects.list_project_fields(project)) == 1
45
    assert length(Projects.list_project_items(project)) == 6
46
    assert length(Issues.list_issues(repository, state: "all")) == 6
47
  end
48
49
  test "skips an unresolved LIVE promise and continues with later promises" do
50
    repository =
51
      repository_fixture(%{
52
        owner: "BackfillSkipOrg",
53
        name: "backfill-skip",
54
        visibility: "public"
55
      })
56
57
    {:ok, project} =
58
      Projects.create_project(repository, %{
59
        title: "Promises",
60
        owner: "OpenAgents",
61
        state: "open"
62
      })
63
64
    path =
65
      Path.join(
66
        System.tmp_dir!(),
67
        "promises-curated-#{System.unique_integer([:positive, :monotonic])}.json"
68
      )
69
70
    File.write!(
71
      path,
72
      Jason.encode!([
73
        %{
74
          "id" => "unresolved_live",
75
          "state" => "LIVE",
76
          "problem" => "A problem",
77
          "claim" => "A claim",
78
          "scope" => "A scope",
79
          "acceptance_criteria" => ["A criterion"],
80
          "success_metrics" => ["A metric"],
81
          "owner" => "OpenAgents",
82
          "target" => "2026-12-31",
83
          "evidence" => [
84
            %{
85
              "kind" => "accepted_outcome",
86
              "decision_receipt_ref" => "missing-decision"
87
            }
88
          ]
89
        },
90
        %{
91
          "id" => "later_gated",
92
          "state" => "GATED",
93
          "problem" => "A problem",
94
          "claim" => "A claim",
95
          "scope" => "A scope",
96
          "acceptance_criteria" => ["A criterion"],
97
          "success_metrics" => ["A metric"],
98
          "owner" => "OpenAgents",
99
          "target" => "2026-12-31",
100
          "gate" => %{
101
            "missing" => "A receipt",
102
            "owner" => "OpenAgents",
103
            "next_review" => "2026-09-01"
104
          },
105
          "evidence" => []
106
        }
107
      ])
108
    )
109
110
    previous = Application.get_env(:openagents, :promises_curated_path)
111
    Application.put_env(:openagents, :promises_curated_path, path)
112
113
    on_exit(fn ->
114
      if previous do
115
        Application.put_env(:openagents, :promises_curated_path, previous)
116
      else
117
        Application.delete_env(:openagents, :promises_curated_path)
118
      end
119
120
      File.rm(path)
121
    end)
122
123
    args = [
124
      "--owner",
125
      repository.owner,
126
      "--repo",
127
      repository.name,
128
      "--project-number",
129
      to_string(project.number)
130
    ]
131
132
    output = capture_io(fn -> Mix.Tasks.Openagents.Promises.Backfill.run(args) end)
133
134
    assert output =~ "Skipped unresolved_live: LIVE gate failed:"
135
    assert output =~ "Backfilled 1 promise(s); skipped 1."
136
    assert length(Projects.list_project_items(project)) == 1
137
    assert length(Issues.list_issues(repository, state: "all")) == 2
138
  end
139
end
test/openagents/promise_registry_test.exs added +336

@@ -0,0 +1,336 @@

1
defmodule OpenAgents.PromiseRegistryTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Issues
5
  alias OpenAgents.Projects
6
  alias OpenAgents.Projects.ProjectItemEvent
7
  alias OpenAgents.Projects.PromiseRegistry
8
9
  import OpenAgents.CompensationFixtures
10
  import OpenAgents.IssuesFixtures
11
  import OpenAgents.ProjectsFixtures
12
13
  setup do
14
    repository = repository_fixture()
15
    project = project_fixture(repository)
16
17
    {:ok, _field} =
18
      Projects.create_project_field(%{
19
        project_id: project.id,
20
        name: "Promise state",
21
        data_type: "promise_state",
22
        options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
23
      })
24
25
    %{project: project, repository: repository}
26
  end
27
28
  test "LIVE requires an accepted outcome evidence entry", %{
29
    project: project,
30
    repository: repository
31
  } do
32
    issue = issue_fixture(repository)
33
34
    assert {:error, changeset} =
35
             Projects.create_project_item(
36
               %{"issue_number" => issue.number, "values" => promise_values("LIVE", %{})},
37
               project
38
             )
39
40
    assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
41
             errors_on(changeset)
42
43
    {:ok, _closed_issue} = Issues.update_issue(issue, %{"state" => "closed"})
44
45
    decision = outcome_decision_fixture()
46
47
    values =
48
      promise_values("LIVE", %{
49
        "evidence" => [
50
          %{
51
            "kind" => "accepted_outcome",
52
            "decision_receipt_ref" => decision.decision_receipt_ref
53
          }
54
        ]
55
      })
56
57
    assert {:ok, item} =
58
             Projects.create_project_item(
59
               %{"issue_number" => issue.number, "values" => values},
60
               project
61
             )
62
63
    assert %DateTime{} = get_in(item.values, ["promise", "verified_at"])
64
  end
65
66
  test "links do not satisfy LIVE and clients cannot set verification time", %{
67
    project: project,
68
    repository: repository
69
  } do
70
    issue = issue_fixture(repository)
71
72
    values =
73
      promise_values("LIVE", %{
74
        "verified_at" => "2026-01-01T00:00:00Z",
75
        "evidence" => [%{"kind" => "link", "url" => "https://example.com/evidence"}]
76
      })
77
78
    assert {:error, changeset} =
79
             Projects.create_project_item(
80
               %{"issue_number" => issue.number, "values" => values},
81
               project
82
             )
83
84
    assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
85
             errors_on(changeset)
86
87
    gated_values =
88
      promise_values("GATED", %{
89
        "verified_at" => "2026-01-01T00:00:00Z",
90
        "gate" => gate()
91
      })
92
93
    assert {:ok, item} =
94
             Projects.create_project_item(
95
               %{"issue_number" => issue.number, "values" => gated_values},
96
               project
97
             )
98
99
    refute Map.has_key?(item.values["promise"], "verified_at")
100
  end
101
102
  test "closed issue, changelog, and forge receipt evidence do not satisfy LIVE", %{
103
    project: project,
104
    repository: repository
105
  } do
106
    issue = issue_fixture(repository)
107
    {:ok, _closed_issue} = Issues.update_issue(issue, %{"state" => "closed"})
108
109
    for evidence <- [
110
          %{
111
            "kind" => "issue",
112
            "owner" => repository.owner,
113
            "repo" => repository.name,
114
            "number" => issue.number
115
          },
116
          %{"kind" => "changelog", "slug" => "missing-entry"},
117
          %{"kind" => "receipt", "id" => Ecto.UUID.generate()}
118
        ] do
119
      values = promise_values("LIVE", %{"evidence" => [evidence]})
120
121
      assert {:error, changeset} =
122
               Projects.create_project_item(
123
                 %{"issue_number" => issue.number, "values" => values},
124
                 project
125
               )
126
127
      assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
128
               errors_on(changeset)
129
    end
130
  end
131
132
  test "accepted outcome evidence resolves only an accepted decision", %{
133
    project: project,
134
    repository: repository
135
  } do
136
    issue = issue_fixture(repository)
137
    rejected = outcome_decision_fixture("rejected", "utility_failed")
138
139
    rejected_values =
140
      promise_values("LIVE", %{
141
        "evidence" => [
142
          %{
143
            "kind" => "accepted_outcome",
144
            "decision_receipt_ref" => rejected.decision_receipt_ref
145
          }
146
        ]
147
      })
148
149
    assert {:error, changeset} =
150
             Projects.create_project_item(
151
               %{"issue_number" => issue.number, "values" => rejected_values},
152
               project
153
             )
154
155
    assert %{values: ["LIVE requires at least one accepted-outcome evidence entry"]} =
156
             errors_on(changeset)
157
  end
158
159
  test "readable open issue evidence remains visible but cannot satisfy LIVE", %{
160
    project: project,
161
    repository: repository
162
  } do
163
    issue = issue_fixture(repository)
164
165
    evidence = %{
166
      "kind" => "issue",
167
      "owner" => repository.owner,
168
      "repo" => repository.name,
169
      "number" => issue.number
170
    }
171
172
    assert PromiseRegistry.readable_evidence?(evidence, nil)
173
    refute PromiseRegistry.resolvable_evidence?(evidence, nil)
174
175
    values = promise_values("GATED", %{"evidence" => [evidence], "gate" => gate()})
176
177
    assert {:ok, item} =
178
             Projects.create_project_item(
179
               %{"issue_number" => issue.number, "values" => values},
180
               project
181
             )
182
183
    assert get_in(item.values, ["promise", "evidence"]) == [evidence]
184
  end
185
186
  test "GATED requires a complete gate", %{project: project, repository: repository} do
187
    issue = issue_fixture(repository)
188
    values = promise_values("GATED", %{})
189
190
    assert {:error, changeset} =
191
             Projects.create_project_item(
192
               %{"issue_number" => issue.number, "values" => values},
193
               project
194
             )
195
196
    assert %{values: ["GATED requires gate.missing, gate.owner, and gate.next_review"]} =
197
             errors_on(changeset)
198
  end
199
200
  test "WITHDRAWN requires a replacement path", %{project: project, repository: repository} do
201
    issue = issue_fixture(repository)
202
    values = promise_values("WITHDRAWN", %{})
203
204
    assert {:error, changeset} =
205
             Projects.create_project_item(
206
               %{"issue_number" => issue.number, "values" => values},
207
               project
208
             )
209
210
    assert %{
211
             values: [
212
               "WITHDRAWN requires withdrawal.reason, withdrawal.replacement, and withdrawal.date"
213
             ]
214
           } =
215
             errors_on(changeset)
216
  end
217
218
  test "promise IDs are unique within a project", %{project: project, repository: repository} do
219
    first_issue = issue_fixture(repository)
220
    second_issue = issue_fixture(repository)
221
    values = promise_values("GATED", %{"gate" => gate()})
222
223
    assert {:ok, _item} =
224
             Projects.create_project_item(
225
               %{"issue_number" => first_issue.number, "values" => values},
226
               project
227
             )
228
229
    assert {:error, changeset} =
230
             Projects.create_project_item(
231
               %{"issue_number" => second_issue.number, "values" => values},
232
               project
233
             )
234
235
    assert %{values: ["promise.id must be unique within this project"]} = errors_on(changeset)
236
  end
237
238
  test "promise item events identify the actor and state transition", %{
239
    project: project,
240
    repository: repository
241
  } do
242
    issue = issue_fixture(repository)
243
    actor = repository_user_fixture("promise-event-actor")
244
    values = promise_values("GATED", %{"gate" => gate()})
245
246
    assert {:ok, item} =
247
             Projects.create_project_item(
248
               %{"issue_number" => issue.number, "values" => values},
249
               project,
250
               actor
251
             )
252
253
    {[created], 1, 1, 25} = Projects.list_project_item_events(item)
254
    assert created.actor_user_id == actor.id
255
    assert created.actor_login == actor.github_login
256
    assert created.kind == "create"
257
    assert created.from_state == nil
258
    assert created.to_state == "GATED"
259
260
    updated_values = Map.put(values, "Promise state", "WITHDRAWN")
261
262
    updated_values =
263
      Map.put(updated_values, "promise", Map.put(values["promise"], "withdrawal", withdrawal()))
264
265
    assert {:ok, updated} =
266
             Projects.update_project_item(item, %{"values" => updated_values}, actor)
267
268
    {[state_change, _created], 2, 1, 25} = Projects.list_project_item_events(updated)
269
    assert state_change.kind == "state_change"
270
    assert state_change.from_state == "GATED"
271
    assert state_change.to_state == "WITHDRAWN"
272
  end
273
274
  test "promise item events are append-only", %{project: project, repository: repository} do
275
    issue = issue_fixture(repository)
276
    values = promise_values("GATED", %{"gate" => gate()})
277
278
    {:ok, item} =
279
      Projects.create_project_item(%{"issue_number" => issue.number, "values" => values}, project)
280
281
    {[event], 1, 1, 25} = Projects.list_project_item_events(item)
282
283
    assert_raise Postgrex.Error, ~r/project item events are append-only/, fn ->
284
      Repo.update_all(
285
        from(stored in ProjectItemEvent, where: stored.id == ^event.id),
286
        set: [kind: "tampered"]
287
      )
288
    end
289
290
    assert_raise Postgrex.Error, ~r/project item events are append-only/, fn ->
291
      Repo.delete!(event)
292
    end
293
  end
294
295
  test "deleting a promise project leaves its event history", %{
296
    project: project,
297
    repository: repository
298
  } do
299
    issue = issue_fixture(repository)
300
    values = promise_values("GATED", %{"gate" => gate()})
301
302
    {:ok, item} =
303
      Projects.create_project_item(%{"issue_number" => issue.number, "values" => values}, project)
304
305
    {[event], 1, 1, 25} = Projects.list_project_item_events(item)
306
307
    assert {:ok, _deleted} = Projects.delete_project(project)
308
    assert Repo.get(ProjectItemEvent, event.id)
309
  end
310
311
  defp promise_values(state, extra) do
312
    promise =
313
      %{
314
        "id" => "promise_registry_v1",
315
        "problem" => "A problem",
316
        "claim" => "A claim",
317
        "scope" => "A scope",
318
        "acceptance_criteria" => ["A criterion"],
319
        "success_metrics" => ["A metric"],
320
        "owner" => "OpenAgents",
321
        "target" => "2026-12-31",
322
        "evidence" => []
323
      }
324
      |> Map.merge(extra)
325
326
    %{"Promise state" => state, "promise" => promise}
327
  end
328
329
  defp gate do
330
    %{"missing" => "A receipt", "owner" => "OpenAgents", "next_review" => "2026-09-01"}
331
  end
332
333
  defp withdrawal do
334
    %{"reason" => "Replaced", "replacement" => "A new promise", "date" => "2026-08-23"}
335
  end
336
end
test/openagents_web/controllers/project_controller_test.exs modified +241

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.ProjectControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  import OpenAgents.CompensationFixtures
5
4 6
  alias OpenAgents.Issues
5 7
  alias OpenAgents.Projects
6 8

@@ -193,6 +195,224 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

193 195
    end
194 196
  end
195 197
198
  describe "promise registries" do
199
    test "LIVE projections require and expose an accepted outcome", %{conn: conn} do
200
      project = project_fixture(%{title: "Promises", owner: "alice"})
201
202
      {:ok, _field} =
203
        Projects.create_project_field(%{
204
          project_id: project.id,
205
          name: "Promise state",
206
          data_type: "promise_state",
207
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
208
        })
209
210
      {:ok, issue} = create_issue(%{title: "Accepted outcome promise"})
211
      decision = outcome_decision_fixture()
212
213
      values =
214
        promise_values("LIVE", "accepted")
215
        |> put_in(["promise", "evidence"], [
216
          %{
217
            "kind" => "accepted_outcome",
218
            "decision_receipt_ref" => decision.decision_receipt_ref
219
          }
220
        ])
221
222
      {:ok, _item} =
223
        Projects.create_project_item(
224
          %{"issue_number" => issue.number, "values" => values},
225
          project
226
        )
227
228
      conn =
229
        get(
230
          conn,
231
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items?promise_state=LIVE"
232
        )
233
234
      assert %{
235
               "items" => [
236
                 %{
237
                   "openagents" => %{
238
                     "promise" => %{
239
                       "record" => %{"id" => "accepted"},
240
                       "state" => "LIVE",
241
                       "bounty_candidate" => false
242
                     }
243
                   }
244
                 }
245
               ]
246
             } = json_response(conn, 200)
247
    end
248
249
    test "projects expose promise projections and promise filters", %{conn: conn} do
250
      project = project_fixture(%{title: "Promises", owner: "alice"})
251
252
      {:ok, _field} =
253
        Projects.create_project_field(%{
254
          project_id: project.id,
255
          name: "Promise state",
256
          data_type: "promise_state",
257
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
258
        })
259
260
      {:ok, gated_issue} = create_issue(%{title: "Gated promise"})
261
      {:ok, withdrawn_issue} = create_issue(%{title: "Withdrawn promise"})
262
263
      {:ok, _gated_item} =
264
        Projects.create_project_item(
265
          %{"issue_number" => gated_issue.number, "values" => promise_values("GATED", "gated")},
266
          project
267
        )
268
269
      withdrawn_values =
270
        promise_values("WITHDRAWN", "withdrawn")
271
        |> put_in(["promise", "withdrawal"], %{
272
          "reason" => "Replaced",
273
          "replacement" => "A new promise",
274
          "date" => "2026-08-23"
275
        })
276
277
      {:ok, _withdrawn_item} =
278
        Projects.create_project_item(
279
          %{
280
            "issue_number" => withdrawn_issue.number,
281
            "values" => withdrawn_values
282
          },
283
          project
284
        )
285
286
      conn =
287
        get(
288
          conn,
289
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items?promise_state=GATED&bounty_candidate=true"
290
        )
291
292
      assert %{
293
               "items" => [
294
                 %{
295
                   "openagents" => %{
296
                     "promise" => %{
297
                       "record" => %{"id" => "gated"},
298
                       "state" => "GATED",
299
                       "bounty_candidate" => true
300
                     }
301
                   }
302
                 }
303
               ]
304
             } = json_response(conn, 200)
305
    end
306
307
    test "promise item events are paginated and actor-attributed", %{conn: conn, user: user} do
308
      project = project_fixture(%{title: "Promises", owner: "alice"})
309
310
      {:ok, _field} =
311
        Projects.create_project_field(%{
312
          project_id: project.id,
313
          name: "Promise state",
314
          data_type: "promise_state",
315
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
316
        })
317
318
      {:ok, issue} = create_issue(%{title: "Gated promise"})
319
320
      {:ok, item} =
321
        Projects.create_project_item(
322
          %{"issue_number" => issue.number, "values" => promise_values("GATED", "events")},
323
          project,
324
          user
325
        )
326
327
      conn =
328
        get(
329
          conn,
330
          ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items/#{item.id}/events"
331
        )
332
333
      assert %{
334
               "events" => [
335
                 %{"actor_login" => "alice", "kind" => "create", "to_state" => "GATED"}
336
               ],
337
               "pagination" => %{"page" => 1, "total" => 1}
338
             } = json_response(conn, 200)
339
    end
340
341
    test "anonymous readers can read a public registry" do
342
      project = project_fixture(%{title: "Public promises", owner: "alice"})
343
344
      {:ok, _field} =
345
        Projects.create_project_field(%{
346
          project_id: project.id,
347
          name: "Promise state",
348
          data_type: "promise_state",
349
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
350
        })
351
352
      {:ok, issue} = create_issue(%{title: "Public promise"})
353
354
      {:ok, _item} =
355
        Projects.create_project_item(
356
          %{"issue_number" => issue.number, "values" => promise_values("GATED", "public")},
357
          project
358
        )
359
360
      OpenAgents.Repo.update_all(
361
        OpenAgents.Repositories.Repository,
362
        set: [visibility: "public"]
363
      )
364
365
      conn =
366
        build_conn()
367
        |> get(~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")
368
369
      assert %{"items" => [_item]} = json_response(conn, 200)
370
    end
371
372
    test "promise evidence from an unreadable repository is redacted", %{conn: conn} do
373
      project = project_fixture(%{title: "Promises", owner: "alice"})
374
375
      {:ok, _field} =
376
        Projects.create_project_field(%{
377
          project_id: project.id,
378
          name: "Promise state",
379
          data_type: "promise_state",
380
          options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
381
        })
382
383
      source =
384
        repository_fixture(%{owner: "SecretOrg", name: "secret-evidence", visibility: "private"})
385
386
      {:ok, secret_issue} = Issues.create_issue(source, %{title: "Secret evidence"})
387
      {:ok, visible_issue} = create_issue(%{title: "Visible promise"})
388
389
      values =
390
        promise_values("GATED", "redacted")
391
        |> put_in(["promise", "evidence"], [
392
          %{
393
            "kind" => "issue",
394
            "owner" => source.owner,
395
            "repo" => source.name,
396
            "number" => secret_issue.number
397
          }
398
        ])
399
400
      {:ok, _item} =
401
        Projects.create_project_item(
402
          %{"issue_number" => visible_issue.number, "values" => values},
403
          project
404
        )
405
406
      conn =
407
        get(conn, ~p"/api/v3/repos/ProjectTestOrg/project-api/projectsV2/#{project.number}/items")
408
409
      assert %{"items" => [%{"openagents" => %{"promise" => %{"record" => record}}}]} =
410
               json_response(conn, 200)
411
412
      assert record["evidence"] == []
413
    end
414
  end
415
196 416
  describe "create_item" do
197 417
    setup do
198 418
      project = project_fixture(%{title: "Roadmap", owner: "alice"})

@@ -1026,4 +1246,25 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

1026 1246
  end
1027 1247
1028 1248
  defp create_issue(attrs), do: Issues.create_issue(repository(), attrs)
1249
1250
  defp promise_values(state, id) do
1251
    record = %{
1252
      "id" => id,
1253
      "problem" => "A problem",
1254
      "claim" => "A claim",
1255
      "scope" => "A scope",
1256
      "acceptance_criteria" => ["A criterion"],
1257
      "success_metrics" => ["A metric"],
1258
      "owner" => "OpenAgents",
1259
      "target" => "2026-12-31",
1260
      "evidence" => [],
1261
      "gate" => %{
1262
        "missing" => "A receipt",
1263
        "owner" => "OpenAgents",
1264
        "next_review" => "2026-09-01"
1265
      }
1266
    }
1267
1268
    %{"Promise state" => state, "promise" => record}
1269
  end
1029 1270
end
test/openagents_web/live/project_show_live_test.exs modified +93

@@ -2,11 +2,14 @@ defmodule OpenAgentsWeb.ProjectShowLiveTest do

2 2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  import Phoenix.LiveViewTest
5
  import Ecto.Query
6
  import OpenAgents.CompensationFixtures
5 7
  import OpenAgents.LabelsFixtures
6 8
  import OpenAgents.ProjectItemsFixtures
7 9
  import OpenAgents.ProjectsFixtures
8 10
9 11
  alias OpenAgents.Issues
12
  alias OpenAgents.ProjectItems.ProjectItem
10 13
  alias OpenAgents.Projects
11 14
12 15
  setup %{conn: conn} do

@@ -256,6 +259,96 @@ defmodule OpenAgentsWeb.ProjectShowLiveTest do

256 259
           )
257 260
  end
258 261
262
  test "a promise registry renders state columns and promise metadata", %{conn: conn} do
263
    project = project!()
264
265
    {:ok, _field} =
266
      Projects.create_project_field(%{
267
        project_id: project.id,
268
        name: "Promise state",
269
        data_type: "promise_state",
270
        options: %{"values" => ["LIVE", "GATED", "WITHDRAWN"]}
271
      })
272
273
    {:ok, issue} = Issues.create_issue(repository(), %{"title" => "Close the gap"})
274
    decision = outcome_decision_fixture()
275
    {:ok, live_issue} = Issues.create_issue(repository(), %{"title" => "Shipped promise"})
276
277
    {:ok, _item} =
278
      Projects.create_project_item(
279
        %{
280
          "issue_number" => issue.number,
281
          "values" => %{
282
            "Promise state" => "GATED",
283
            "promise" => %{
284
              "id" => "close_the_gap",
285
              "problem" => "A problem",
286
              "claim" => "A claim",
287
              "scope" => "A scope",
288
              "acceptance_criteria" => ["A criterion"],
289
              "success_metrics" => ["A metric"],
290
              "owner" => "OpenAgents",
291
              "target" => "2026-12-31",
292
              "evidence" => [],
293
              "gate" => %{
294
                "missing" => "A receipt",
295
                "owner" => "OpenAgents",
296
                "next_review" => "2026-09-01"
297
              }
298
            }
299
          }
300
        },
301
        project
302
      )
303
304
    {:ok, live_item} =
305
      Projects.create_project_item(
306
        %{
307
          "issue_number" => live_issue.number,
308
          "values" => %{
309
            "Promise state" => "LIVE",
310
            "promise" => %{
311
              "id" => "shipped_promise",
312
              "problem" => "A problem",
313
              "claim" => "A claim",
314
              "scope" => "A scope",
315
              "acceptance_criteria" => ["A criterion"],
316
              "success_metrics" => ["A metric"],
317
              "owner" => "OpenAgents",
318
              "target" => "2026-12-31",
319
              "evidence" => [
320
                %{
321
                  "kind" => "accepted_outcome",
322
                  "decision_receipt_ref" => decision.decision_receipt_ref
323
                }
324
              ]
325
            }
326
          }
327
        },
328
        project
329
      )
330
331
    live_values = put_in(live_item.values, ["promise", "verified_at"], "2026-08-23T05:00:00Z")
332
333
    OpenAgents.Repo.update_all(
334
      from(item in ProjectItem, where: item.id == ^live_item.id),
335
      set: [values: live_values]
336
    )
337
338
    {:ok, view, html} = live(conn, path(project))
339
340
    assert html =~ "LIVE"
341
    assert html =~ "GATED"
342
    assert html =~ "WITHDRAWN"
343
    assert html =~ "close_the_gap"
344
    assert html =~ "A receipt"
345
    assert html =~ "2026-09-01"
346
    assert html =~ "shipped_promise"
347
    assert html =~ "Verified: 2026-08-23 05:00 UTC"
348
    refute html =~ "To Do"
349
    refute has_element?(view, "#new-project-item-form")
350
  end
351
259 352
  test "a missing project number raises rather than rendering an empty board", %{conn: conn} do
260 353
    assert_raise Ecto.NoResultsError, fn ->
261 354
      live(conn, ~p"/OpenAgentsInc/openagents.com/projects/9999")

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