Delete the /api/v3 alias

4eb5b1cb2f2e · AtlantisPleb · · parent 6bef9222b8cc

Delete the /api/v3 alias

The alias existed for clients released before the API moved to /api/v1.
There are none left: @openagentsinc/cli@0.4.0 published today speaks v1
only — 55 references to /api/v1 and none to /api/v3 in the installed
build — and the one machine still running 0.3.4 was upgraded to it before
this landed.

FORGEAPI-002 kept the deletion to what it promised: the plug, its test, one
line in the endpoint, and a paragraph in the API doc. The posture proof
stays and now reads the tree for any file naming the old prefix at all,
because the way the alias would come back is the way it nearly broke before
it left — a rendered url field that looks fine until someone follows it.

Closes #216

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

Deploy story

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

pushed
by user · WAL seq 406 · 2026-08-25T15:39:30.171392Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents_web/endpoint.ex
  • deleted lib/openagents_web/plugs/api_v3_rewrite.ex
  • modified priv/docs/rest-api.md
  • modified test/openagents_web/api_version_posture_test.exs
  • deleted test/openagents_web/plugs/api_v3_rewrite_test.exs

Diff

6 files changed, +21 -103

INVARIANTS.md modified +10 -13

@@ -5733,7 +5733,7 @@ Evidence: `lib/openagents_web/api_error.ex`,

5733 5733
`test/openagents_web/controllers/api_error_contract_test.exs`, and
5734 5734
`test/openagents_web/controllers/api_extension_controller_test.exs`.
5735 5735
5736
### FORGEAPI-002 — The path names this API's version, and `/api/v3` is a dated alias
5736
### FORGEAPI-002 — The path names this API's version, and `/api/v3` is gone
5737 5737
5738 5738
Status: Current
5739 5739

@@ -5750,20 +5750,17 @@ sends GraphQL to `/api/graphql` and `gh issue view` probes `GET /api/v3/meta`,

5750 5750
and this application serves neither, so the prefix buys no working command.
5751 5751
The one surface it does serve, the `gh api` passthrough, already reaches
5752 5752
`/api/v1` when it is given a full URL. `gh` is therefore not a supported
5753
client, and `/api/v3` is a migration alias for released clients with a
5754
deletion scheduled, not a compatibility promise. The reasoning and the
5755
measurements are
5753
client, and `/api/v3` was a migration alias rather than a compatibility
5754
promise. The reasoning and the measurements are
5756 5755
`docs/decisions/0009-serve-a-github-shaped-api-not-a-gh-compatible-one.md`.
5757 5756
5758
Because the alias is temporary, nothing may come to depend on it. Every
5759
versioned route is declared at `/api/v1`, and every URL a response emits names
5760
`/api/v1`, so a link this API hands a client survives the deletion.
5761
`OpenAgentsWeb.Plugs.ApiV3Rewrite` is the only file under `lib/` that names the
5762
old prefix at all, and a proof reads the tree for that, because the reasoning
5763
does not survive on care alone: the trace ingest route landed mid-rename and
5764
returned a `url` field naming `/api/v3/traces/{id}`, a link that would have
5765
gone dead the day the alias did. Deleting the alias stays what it should be, a
5766
one-file change with one test.
5757
The alias was deleted on 2026-08-25 (#216), the day `@openagentsinc/cli@0.4.0`
5758
was published and the one client still on the old prefix was upgraded to it.
5759
Nothing under `lib/` names the old prefix now, and a proof reads the tree for
5760
that rather than trusting care: the trace ingest route landed mid-rename and
5761
returned a `url` field naming `/api/v3/traces/{id}`, a link that went dead the
5762
day the alias did. Every versioned route is declared at `/api/v1` and every URL
5763
a response emits names `/api/v1`.
5767 5764
5768 5765
Evidence: `lib/openagents_web/plugs/api_v3_rewrite.ex`,
5769 5766
`lib/openagents_web/router.ex`,
lib/openagents_web/endpoint.ex modified -1

@@ -67,6 +67,5 @@ defmodule OpenAgentsWeb.Endpoint do

67 67
  # Analytics hints only; identity still comes from the session.
68 68
  plug PostHog.Integrations.Plug
69 69
70
  plug OpenAgentsWeb.Plugs.ApiV3Rewrite
71 70
  plug OpenAgentsWeb.Router
72 71
end
lib/openagents_web/plugs/api_v3_rewrite.ex deleted -32

@@ -1,32 +0,0 @@

1
defmodule OpenAgentsWeb.Plugs.ApiV3Rewrite do
2
  @moduledoc """
3
  Transparently rewrites `/api/v3/*` requests to `/api/v1/*`.
4
5
  The API moved from `/api/v3` to `/api/v1` in one deploy, so clients released
6
  against the old prefix keep working while the fleet upgrades. This plug
7
  modifies `conn.path_info` before the router sees it, rather than redirecting,
8
  because the old clients `POST` and do not follow redirects reliably.
9
10
  The alias is dated, not permanent. It exists for released clients, not for
11
  `gh`: `gh` reaches a non-github.com host at `/api/v3`, but its ported
12
  commands run on GraphQL and `GET /api/v3/meta`, neither of which this
13
  application serves, so keeping the prefix would not make `gh` work. See
14
  `docs/decisions/0009-serve-a-github-shaped-api-not-a-gh-compatible-one.md`
15
  and `INVARIANTS.md`, FORGEAPI-002.
16
17
  This module is the only place in `lib/` that names the old prefix. Nothing
18
  routes at it and no response emits it, which is what makes deleting the alias
19
  a one-file change.
20
  """
21
  @behaviour Plug
22
23
  @impl true
24
  def init(opts), do: opts
25
26
  @impl true
27
  def call(%Plug.Conn{path_info: ["api", "v3" | rest]} = conn, _opts) do
28
    %{conn | path_info: ["api", "v1" | rest]}
29
  end
30
31
  def call(conn, _opts), do: conn
32
end
priv/docs/rest-api.md modified +3 -3

@@ -357,9 +357,9 @@ They are not a promise that GitHub's own tooling runs against this host.

357 357
  https://openagents.com/api/v1/repos/OWNER/REPO/issues`, but `curl` and the
358 358
  `openagents` CLI do the same job without the confusion.
359 359
360
Requests to `/api/v3` still answer today. That prefix is a temporary alias for
361
clients released before the API moved to `/api/v1`, and it is scheduled for
362
removal. Send new work to `/api/v1`.
360
`/api/v3` no longer answers. It was a migration alias for clients released
361
before the API moved to `/api/v1`, and it was removed on 2026-08-25 once the
362
last client using it was upgraded. Send everything to `/api/v1`.
363 363
364 364
## Know the compatibility limits
365 365
test/openagents_web/api_version_posture_test.exs modified +8 -11

@@ -1,17 +1,16 @@

1 1
defmodule OpenAgentsWeb.ApiVersionPostureTest do
2 2
  @moduledoc """
3
  Proves FORGEAPI-002: the version in the path is this API's own, and `/api/v3`
4
  is a dated alias nothing is allowed to depend on.
3
  Proves FORGEAPI-002: the version in the path is this API's own, and the
4
  `/api/v3` alias is gone.
5 5
6
  The alias is one plug and one deletion away from gone. These assertions keep
7
  it that way, because the failure mode is quiet: a route or a rendered link
8
  written against the old prefix works perfectly until the day the alias is
9
  removed, and then it does not.
6
  The alias was removed on 2026-08-25 (#216), once the only client that used
7
  it was upgraded. These assertions keep the old prefix from coming back by
8
  the same quiet route it would have left by: a link or a rendered `url`
9
  written against it looks fine until someone follows it.
10 10
  """
11 11
12 12
  use ExUnit.Case, async: true
13 13
14
  @alias_plug "lib/openagents_web/plugs/api_v3_rewrite.ex"
15 14
  @legacy_prefix "api/v3"
16 15
17 16
  test "every versioned route is declared at /api/v1" do

@@ -26,15 +25,13 @@ defmodule OpenAgentsWeb.ApiVersionPostureTest do

26 25
27 26
    #{Enum.map_join(misversioned, "\n", &"  #{&1}")}
28 27
29
    The path names this API's version. Old prefixes are served by
30
    OpenAgentsWeb.Plugs.ApiV3Rewrite, never declared in the router.
28
    The path names this API's version, and no other version is served.
31 29
    """
32 30
  end
33 31
34
  test "the alias plug is the only file under lib/ that names the old prefix" do
32
  test "no file under lib/ names the old prefix" do
35 33
    offenders =
36 34
      Path.wildcard("lib/**/*.ex")
37
      |> Enum.reject(&(&1 == @alias_plug))
38 35
      |> Enum.filter(&String.contains?(File.read!(&1), @legacy_prefix))
39 36
40 37
    assert offenders == [], """
test/openagents_web/plugs/api_v3_rewrite_test.exs deleted -43

@@ -1,43 +0,0 @@

1
defmodule OpenAgentsWeb.Plugs.ApiV3RewriteTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  alias OpenAgentsWeb.Plugs.ApiV3Rewrite
5
6
  describe "unit test call/2" do
7
    test "rewrites api/v3 prefix to api/v1" do
8
      conn = %Plug.Conn{path_info: ["api", "v3", "repos", "owner", "repo", "issues"]}
9
      rewritten = ApiV3Rewrite.call(conn, [])
10
11
      assert rewritten.path_info == ["api", "v1", "repos", "owner", "repo", "issues"]
12
    end
13
14
    test "rewrites root api/v3 path" do
15
      conn = %Plug.Conn{path_info: ["api", "v3"]}
16
      rewritten = ApiV3Rewrite.call(conn, [])
17
18
      assert rewritten.path_info == ["api", "v1"]
19
    end
20
21
    test "leaves api/v1 and other paths untouched" do
22
      conn_v1 = %Plug.Conn{path_info: ["api", "v1", "repos"]}
23
      assert ApiV3Rewrite.call(conn_v1, []) == conn_v1
24
25
      conn_status = %Plug.Conn{path_info: ["status"]}
26
      assert ApiV3Rewrite.call(conn_status, []) == conn_status
27
    end
28
  end
29
30
  describe "integration through Endpoint" do
31
    test "GET /api/v3 transparently answers from the /api/v1 root document", %{conn: conn} do
32
      res = get(conn, "/api/v3")
33
34
      assert json_response(res, 200)["api_version"] == "v1"
35
    end
36
37
    test "GET /api/v3/repos/:owner/:repo/issues transparently answers from /api/v1", %{conn: conn} do
38
      res = get(conn, "/api/v3/repos/OpenAgentsInc/openagents.com/issues")
39
40
      assert json_response(res, 200)["issues"] != nil
41
    end
42
  end
43
end

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