Baseline the prior database lineage safely

fcf083f7c143 · Christopher David · · parent fba8e4dcf30e

Baseline the prior database lineage safely

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 docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • added docs/operations/staging-migration-lineage.md
  • added lib/openagents/migration_lineage.ex
  • modified lib/openagents/release.ex
  • modified ops/ci/contracts.sh
  • added priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260820160000_reconcile_visitor_identity_constraint.exs
  • added rel/overlays/bin/migration-lineage
  • added test/openagents/migration_lineage_test.exs

Diff

9 files changed, +958 -1

docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +34 -1

@@ -1290,6 +1290,39 @@ change, and first-time feature enablement in one staging candidate.

1290 1290
**Exit criteria:** Another operator can repeat the deploy from the recorded SHA
1291 1291
and obtain the same revision, schema, configuration posture, and checks.
1292 1292
1293
### Gate 13 implementation status
1294
1295
Implemented locally on 2026-08-20:
1296
1297
- Added a reviewed, machine-readable map for the known nonempty prior database
1298
  lineage. It partitions every current migration into shared, baselined,
1299
  reconciling, or genuinely new groups and explains every baselined version.
1300
- Added a release command that classifies empty, current, known prior, already
1301
  baselined, partial, and unknown databases without returning content. It
1302
  validates the known prior migration signature and the required tables,
1303
  columns, key types, constraints, indexes, and forbidden transitional forge
1304
  structures.
1305
- Added a snapshot-gated compatibility bridge. It adds one nullable account
1306
  column and partial index, records only the 14 reviewed migration versions,
1307
  uses the release migration advisory lock, and refuses unknown or partial
1308
  lineages. The current migration inventory test fails if any version is
1309
  omitted or classified twice.
1310
- Added a guarded reconciliation migration for the visitor identity constraint.
1311
  Fresh current-lineage databases were missing the constraint even though the
1312
  changeset named it; prior-lineage databases already have it and remain
1313
  unchanged.
1314
- Rehearsed the bridge and all 20 remaining current migrations on a disposable
1315
  copy of the complete 57-version prior schema. Account, message, forge target,
1316
  build receipt, and deploy receipt probes remained present. Both the candidate
1317
  application and the last-known-good application started against the migrated
1318
  copy.
1319
1320
The [staging migration-lineage runbook](operations/staging-migration-lineage.md)
1321
defines classification, copy rehearsal, snapshot, single-job migration,
1322
rollback compatibility, and evidence collection. Gate 13 remains open until
1323
the exact candidate image and release artifacts are retained and the selected
1324
path runs against the isolated staging target.
1325
1293 1326
## Gate 14: Run the staging regression matrix
1294 1327
1295 1328
Record every case as passed, failed, blocked, or not applicable. A retry does not

@@ -1597,7 +1630,7 @@ this disposition for the current plan:

1597 1630
| --- | --- |
1598 1631
| A1, missing browser tests and incomplete baseline | Resolved by Gate 0. The owned baseline now runs the Node suites, merged Elixir coverage, and release smoke against an exact SHA. |
1599 1632
| A2, staging shares a production database failure domain | Open and blocking Gates 12 and 15. Provision a separate staging database instance before any failure injection or soak. |
1600
| A3, incompatible migration lineage on a nonempty prior database | Open and blocking Gate 13 for every prior-lineage target. Map and rehearse that lineage before deployment. |
1633
| A3, incompatible migration lineage on a nonempty prior database | Mapped and rehearsed locally with a fail-closed release command. Each actual prior-lineage target still requires its own snapshot-copy rehearsal before Gate 13 deployment. |
1601 1634
| A4, component, icon, and palette conflicts | Resolved by Gate 4 and its compiled CSS contract tests. |
1602 1635
| A5, silent runtime configuration failures | Resolved by Gate 5 and its fail-closed runtime readiness checks. |
1603 1636
| A6, repository authorization violations | Resolved by Gate 7 in code, PostgreSQL constraints, and the populated migration rehearsal. |
docs/operations/staging-migration-lineage.md added +133

@@ -0,0 +1,133 @@

1
# Classify and migrate a staging database
2
3
Classify the database before you start a staging candidate. The release
4
supports three safe paths:
5
6
- `empty` identifies a database without application tables. Run all current
7
  migrations.
8
- `current` identifies a database that already uses this repository's
9
  migration lineage. Run the remaining current migrations.
10
- `prior` identifies the one reviewed nonempty lineage from 2026-08-19. Create
11
  a snapshot, apply the reviewed baseline bridge, and then run the remaining
12
  current migrations.
13
14
The command refuses `unknown` and `prior_partial` databases. Do not insert
15
`schema_migrations` rows manually or edit the baseline map during a deployment.
16
17
## Understand the reviewed map
18
19
The [prior-lineage baseline map](../../priv/migration_lineages/prior-2026-08-19.json)
20
partitions every current migration into four disjoint groups:
21
22
- 38 versions are shared by both lineages and already appear in
23
  `schema_migrations`.
24
- 14 current versions have effects that the prior lineage already satisfies.
25
  These include consolidated create migrations and temporary forge repair
26
  migrations that would duplicate tables, convert UUIDs, truncate receipts, or
27
  drop build history if replayed.
28
- Four reconciliation migrations run normally because they are guarded or
29
  idempotent.
30
- 16 versions are genuinely new and run normally after the baseline.
31
32
The bridge adds only the nullable `users.browser_key_hash` column and its
33
partial unique index. It then records the 14 reviewed versions. It preserves
34
all prior migration rows and all application data.
35
36
Classification checks the required prior migration signature, tables,
37
columns, key column types, constraints, indexes, and absence of temporary forge
38
foreign keys and legacy tables. The map test fails if a current migration is
39
unclassified or appears in more than one group.
40
41
## Classify the target
42
43
Run the command from the exact candidate release with the same staging-only
44
database configuration that the migration job will use:
45
46
```sh
47
bin/migration-lineage check
48
```
49
50
The command prints one content-free JSON object. Save it with the candidate
51
evidence. Confirm its `classification`, `map_digest`, fact counts, and baseline
52
counts. It never returns database names, row contents, credentials, or schema
53
definitions.
54
55
For `empty` or `current`, do not apply a baseline. Run:
56
57
```sh
58
bin/migrate
59
bin/migration-lineage check
60
```
61
62
An empty database becomes `current` after migration.
63
64
## Rehearse a prior-lineage target
65
66
Never baseline the live target first. Restore its latest snapshot into the
67
isolated staging database instance and rehearse on that disposable copy:
68
69
1. Stop all writers to the copy.
70
2. Run `bin/migration-lineage check` and require `prior`.
71
3. Record content-free row counts and integrity queries for accounts,
72
   conversations, messages, forge targets, build receipts, and deploy receipts.
73
4. Create or confirm a snapshot of the copy and record its bounded reference.
74
5. Apply the baseline bridge:
75
76
   ```sh
77
   OPENAGENTS_STAGING_SNAPSHOT_ID=staging-copy-20260820-0001 \
78
     bin/migration-lineage --apply
79
   ```
80
81
6. Require `prior_baselined`, `changed: true`, 14 baseline entries, and zero
82
   missing facts in the JSON result.
83
7. Run `bin/migrate`. Exactly the reconciliation and genuinely new versions
84
   that are absent from the copy should run.
85
8. Run `bin/migration-lineage check` again and require `prior_baselined` with
86
   zero missing facts.
87
9. Start the candidate release with high-risk features disabled. Require
88
   configuration readiness, database connectivity, `/healthz`, and `/status`.
89
10. Start the last-known-good release against the migrated copy with traffic and
90
    workers disabled. This verifies that the additive schema remains rollback
91
    compatible.
92
11. Repeat the content-free counts and integrity queries from step 3. Investigate
93
    any difference before you continue.
94
12. Delete the disposable copy after you retain the sanitized receipt.
95
96
The apply command is idempotent only after the complete bridge succeeds. It
97
refuses a partial baseline so an operator cannot guess how to repair an
98
interrupted or manually modified lineage.
99
100
## Migrate the actual staging target
101
102
After the copy rehearsal passes:
103
104
1. Stop staging writers and verify that no old application instance can restart.
105
2. Create an on-demand Cloud SQL backup or snapshot of the actual staging-only
106
   instance. Record its identifier and completion state.
107
3. Repeat `bin/migration-lineage check` against the actual target. Its result
108
   must match the rehearsed classification and map digest.
109
4. Apply the baseline only if the classification is `prior`, using the actual
110
   snapshot identifier.
111
5. Run `bin/migrate` as a single bounded job. Do not let every application node
112
   race migration during this deployment.
113
6. Recheck the lineage, migration versions, integrity counts, and candidate
114
   startup before you admit traffic.
115
116
If any check fails, keep traffic fenced, preserve the database and logs, and
117
restore the reviewed snapshot into a new isolated target. Do not edit migration
118
history in place and do not point the candidate at a production database.
119
120
## Record evidence
121
122
Retain these content-free fields in the Gate 13 report:
123
124
- Exact Git SHA, image manifest digest, and release version.
125
- Baseline map digest and classification before and after migration.
126
- Snapshot identifier and completion receipt.
127
- Baseline versions recorded and current versions applied.
128
- Before-and-after row counts and integrity-check statuses.
129
- Candidate and last-known-good startup results.
130
- Migration duration, first failure if any, and rollback disposition.
131
132
Do not retain database URLs, passwords, account identifiers, prompts, messages,
133
transcripts, OAuth material, or query results containing user content.
lib/openagents/migration_lineage.ex added +324

@@ -0,0 +1,324 @@

1
defmodule OpenAgents.MigrationLineage do
2
  @moduledoc """
3
  Classifies and safely baselines the known nonempty database lineage.
4
5
  The baseline is an explicit compatibility bridge, not a general migration
6
  repair tool. It refuses unknown and partially modified lineages. Applying it
7
  requires a bounded snapshot reference and is admitted only in tests or in
8
  staging at Gate 13 or later.
9
  """
10
11
  alias Ecto.Adapters.SQL
12
13
  @lock_id 42_424_242
14
  @map_file "migration_lineages/prior-2026-08-19.json"
15
  @snapshot_pattern ~r/\A[a-z0-9][a-z0-9-]{7,127}\z/
16
17
  @type classification ::
18
          :empty | :current | :prior | :prior_baselined | :prior_partial | :unknown
19
20
  @doc "Returns a bounded classification without changing the database."
21
  @spec classify(module()) :: {:ok, map()} | {:error, atom()}
22
  def classify(repo) when is_atom(repo) do
23
    with :ok <- ensure_admitted() do
24
      {:ok, classify_repo(repo, load_map!())}
25
    end
26
  end
27
28
  @doc "Applies the known baseline bridge after a staging snapshot exists."
29
  @spec baseline(module(), String.t()) :: {:ok, map()} | {:error, atom()}
30
  def baseline(repo, snapshot_ref) when is_atom(repo) and is_binary(snapshot_ref) do
31
    with :ok <- ensure_admitted(),
32
         :ok <- validate_snapshot_ref(snapshot_ref) do
33
      map = load_map!()
34
35
      repo.transaction(fn ->
36
        _lock = SQL.query!(repo, "SELECT pg_advisory_xact_lock($1)", [@lock_id])
37
        before = classify_repo(repo, map)
38
39
        case before.classification do
40
          "prior" ->
41
            apply_bridge!(repo)
42
            insert_baseline_versions!(repo, baseline_versions(map))
43
            after_baseline = classify_repo(repo, map)
44
45
            if after_baseline.classification != "prior_baselined" do
46
              repo.rollback(:baseline_postcondition_failed)
47
            end
48
49
            result(after_baseline, snapshot_ref, true)
50
51
          "prior_baselined" ->
52
            result(before, snapshot_ref, false)
53
54
          "prior_partial" ->
55
            repo.rollback(:partial_lineage_forbidden)
56
57
          _other ->
58
            repo.rollback(:lineage_not_baselineable)
59
        end
60
      end)
61
      |> normalize_transaction()
62
    end
63
  end
64
65
  def baseline(_repo, _snapshot_ref), do: {:error, :invalid_baseline_request}
66
67
  @doc "Returns one bounded JSON result for the release operator command."
68
  @spec command!(module(), String.t(), String.t() | nil) :: String.t()
69
  def command!(repo, "check", _snapshot_ref) do
70
    case classify(repo) do
71
      {:ok, result} -> encode_result("checked", result)
72
      {:error, reason} -> raise "migration lineage check refused: #{bounded_reason(reason)}"
73
    end
74
  end
75
76
  def command!(repo, "apply", snapshot_ref) do
77
    case baseline(repo, snapshot_ref) do
78
      {:ok, result} -> encode_result("baselined", result)
79
      {:error, reason} -> raise "migration lineage baseline refused: #{bounded_reason(reason)}"
80
    end
81
  end
82
83
  def command!(_repo, _mode, _snapshot_ref),
84
    do: raise("migration lineage command mode is invalid")
85
86
  @doc "Loads the reviewed baseline map and its source digest."
87
  @spec map!() :: map()
88
  def map!, do: load_map!()
89
90
  defp classify_repo(repo, map) do
91
    versions = migration_versions(repo)
92
    baseline_versions = baseline_versions(map)
93
    prior? = subset?(map.data["prior_signature_versions"], versions)
94
    current? = Enum.any?(map.data["current_root_versions"], &MapSet.member?(versions, &1))
95
    baseline_count = Enum.count(baseline_versions, &MapSet.member?(versions, &1))
96
97
    fact_counts =
98
      fact_counts(repo, map.data, if(baseline_count == 0, do: :before, else: :after))
99
100
    bridge = bridge_state(repo)
101
    application_table_count = application_table_count(repo)
102
103
    classification =
104
      cond do
105
        application_table_count == 0 ->
106
          :empty
107
108
        prior? and baseline_count == 0 and bridge == :absent and fact_counts.missing == 0 ->
109
          :prior
110
111
        prior? and baseline_count == length(baseline_versions) and bridge == :complete and
112
            fact_counts.missing == 0 ->
113
          :prior_baselined
114
115
        prior? and (baseline_count > 0 or bridge != :absent) ->
116
          :prior_partial
117
118
        current? and not prior? ->
119
          :current
120
121
        true ->
122
          :unknown
123
      end
124
125
    %{
126
      schema: "openagents.migration-lineage-status.v1",
127
      classification: Atom.to_string(classification),
128
      lineage_id: map.data["lineage_id"],
129
      map_digest: map.digest,
130
      migration_count: MapSet.size(versions),
131
      baseline_entries_present: baseline_count,
132
      baseline_entries_required: length(baseline_versions),
133
      required_facts_present: fact_counts.present,
134
      required_facts_missing: fact_counts.missing,
135
      bridge_state: Atom.to_string(bridge)
136
    }
137
  end
138
139
  defp fact_counts(repo, map, phase) do
140
    phase_indexes =
141
      if phase == :before,
142
        do: map["pre_baseline_required_indexes"],
143
        else: []
144
145
    facts =
146
      Enum.map(map["required_tables"], &table_exists?(repo, &1)) ++
147
        Enum.flat_map(map["required_columns"], fn {table, columns} ->
148
          Enum.map(columns, &column_exists?(repo, table, &1))
149
        end) ++
150
        Enum.map(map["required_column_types"], fn {qualified_column, type} ->
151
          [table, column] = String.split(qualified_column, ".", parts: 2)
152
          column_type?(repo, table, column, type)
153
        end) ++
154
        Enum.map(map["required_constraints"], &constraint_exists?(repo, &1)) ++
155
        Enum.map(map["required_indexes"], &index_exists?(repo, &1)) ++
156
        Enum.map(phase_indexes, &index_exists?(repo, &1)) ++
157
        Enum.map(map["forbidden_constraints"], &(not constraint_exists?(repo, &1))) ++
158
        Enum.map(map["forbidden_tables"], &(not table_exists?(repo, &1)))
159
160
    present = Enum.count(facts, & &1)
161
    %{present: present, missing: length(facts) - present}
162
  end
163
164
  defp bridge_state(repo) do
165
    column? = column_exists?(repo, "users", "browser_key_hash")
166
    index? = index_exists?(repo, "users_browser_key_hash_index")
167
168
    case {column?, index?} do
169
      {false, false} -> :absent
170
      {true, true} -> :complete
171
      _partial -> :partial
172
    end
173
  end
174
175
  defp application_table_count(repo) do
176
    %{rows: [[count]]} =
177
      SQL.query!(repo, """
178
      SELECT count(*)
179
        FROM pg_catalog.pg_tables
180
       WHERE schemaname = current_schema()
181
         AND tablename <> 'schema_migrations'
182
      """)
183
184
    count
185
  end
186
187
  defp migration_versions(repo) do
188
    if table_exists?(repo, "schema_migrations") do
189
      %{rows: rows} =
190
        SQL.query!(repo, "SELECT version FROM schema_migrations ORDER BY version", [])
191
192
      rows |> Enum.map(fn [version] -> version end) |> MapSet.new()
193
    else
194
      MapSet.new()
195
    end
196
  end
197
198
  defp table_exists?(repo, table) do
199
    exists?(
200
      repo,
201
      "SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1",
202
      [table]
203
    )
204
  end
205
206
  defp column_exists?(repo, table, column) do
207
    exists?(
208
      repo,
209
      "SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2",
210
      [table, column]
211
    )
212
  end
213
214
  defp column_type?(repo, table, column, type) do
215
    exists?(
216
      repo,
217
      "SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 AND udt_name = $3",
218
      [table, column, type]
219
    )
220
  end
221
222
  defp constraint_exists?(repo, constraint) do
223
    exists?(
224
      repo,
225
      "SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND constraint_name = $1",
226
      [constraint]
227
    )
228
  end
229
230
  defp index_exists?(repo, index) do
231
    exists?(
232
      repo,
233
      "SELECT 1 FROM pg_catalog.pg_indexes WHERE schemaname = current_schema() AND indexname = $1",
234
      [index]
235
    )
236
  end
237
238
  defp exists?(repo, query, parameters) do
239
    case SQL.query!(repo, query, parameters) do
240
      %{num_rows: count} when count > 0 -> true
241
      _none -> false
242
    end
243
  end
244
245
  defp apply_bridge!(repo) do
246
    _column = SQL.query!(repo, "ALTER TABLE users ADD COLUMN browser_key_hash bytea", [])
247
248
    _index =
249
      SQL.query!(repo, """
250
      CREATE UNIQUE INDEX users_browser_key_hash_index
251
          ON users (browser_key_hash)
252
       WHERE browser_key_hash IS NOT NULL
253
      """)
254
255
    :ok
256
  end
257
258
  defp insert_baseline_versions!(repo, versions) do
259
    Enum.each(versions, fn version ->
260
      _result =
261
        SQL.query!(
262
          repo,
263
          "INSERT INTO schema_migrations (version, inserted_at) VALUES ($1, NOW()) ON CONFLICT (version) DO NOTHING",
264
          [version]
265
        )
266
    end)
267
  end
268
269
  defp baseline_versions(map) do
270
    Enum.map(map.data["baseline_entries"], & &1["current_version"])
271
  end
272
273
  defp subset?(required, actual) do
274
    Enum.all?(required, &MapSet.member?(actual, &1))
275
  end
276
277
  defp load_map! do
278
    path = :openagents |> :code.priv_dir() |> to_string() |> Path.join(@map_file)
279
    bytes = File.read!(path)
280
    data = Jason.decode!(bytes)
281
282
    if data["schema"] != "openagents.migration-lineage.v1" or
283
         data["lineage_id"] != "prior-2026-08-19" do
284
      raise "migration lineage map has an invalid identity"
285
    end
286
287
    %{data: data, digest: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)}
288
  end
289
290
  defp ensure_admitted do
291
    environment = Application.get_env(:openagents, :runtime_environment)
292
    staging_gate = Application.get_env(:openagents, :staging_gate, 0)
293
294
    if environment == :test or (environment == :staging and staging_gate >= 13) do
295
      :ok
296
    else
297
      {:error, :migration_lineage_not_admitted}
298
    end
299
  end
300
301
  defp validate_snapshot_ref(snapshot_ref) do
302
    if Regex.match?(@snapshot_pattern, snapshot_ref),
303
      do: :ok,
304
      else: {:error, :invalid_snapshot_reference}
305
  end
306
307
  defp result(classification, snapshot_ref, changed?) do
308
    classification
309
    |> Map.put(:snapshot_ref, snapshot_ref)
310
    |> Map.put(:changed, changed?)
311
  end
312
313
  defp normalize_transaction({:ok, result}), do: {:ok, result}
314
  defp normalize_transaction({:error, reason}), do: {:error, reason}
315
316
  defp encode_result(status, result) do
317
    result
318
    |> Map.put(:status, status)
319
    |> Jason.encode!()
320
  end
321
322
  defp bounded_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
323
  defp bounded_reason(_reason), do: "lineage_operation_failed"
324
end
lib/openagents/release.ex modified +17

@@ -20,6 +20,23 @@ defmodule OpenAgents.Release do

20 20
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
21 21
  end
22 22
23
  @doc "Classifies or baselines the reviewed prior database lineage."
24
  @spec migration_lineage(String.t(), String.t() | nil) :: :ok
25
  def migration_lineage(mode, snapshot_ref \\ nil) do
26
    load_app()
27
28
    for repo <- repos() do
29
      {:ok, output, _apps} =
30
        Ecto.Migrator.with_repo(repo, fn started_repo ->
31
          OpenAgents.MigrationLineage.command!(started_repo, mode, snapshot_ref)
32
        end)
33
34
      IO.puts(output)
35
    end
36
37
    :ok
38
  end
39
23 40
  @doc "Rewrap retained GitHub grants with the configured active vault key."
24 41
  def rotate_github_tokens do
25 42
    load_app()
ops/ci/contracts.sh modified +2

@@ -7,10 +7,12 @@ repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)

7 7
cd "$repo_root"
8 8
9 9
ops/ci/reference-check.sh
10
sh -n rel/overlays/bin/migration-lineage
10 11
sh -n ops/staging/cleanup-run.sh
11 12
elixir ops/ci/docs-check.exs
12 13
MIX_ENV=test mix test --warnings-as-errors \
13 14
  test/openagents/log_safety_test.exs \
15
  test/openagents/migration_lineage_test.exs \
14 16
  test/openagents/runtime_config_test.exs \
15 17
  test/openagents/staging_cleanup_test.exs \
16 18
  test/openagents_web/icon_affordances_test.exs \
priv/migration_lineages/prior-2026-08-19.json added +257

@@ -0,0 +1,257 @@

1
{
2
  "schema": "openagents.migration-lineage.v1",
3
  "lineage_id": "prior-2026-08-19",
4
  "prior_signature_versions": [
5
    20260816170746,
6
    20260816203029,
7
    20260816205329,
8
    20260816211100,
9
    20260816211900,
10
    20260816220500,
11
    20260816222500,
12
    20260816224000,
13
    20260816233000,
14
    20260817000500,
15
    20260817070000,
16
    20260817080000,
17
    20260817125732,
18
    20260817170050,
19
    20260818003948,
20
    20260818150000,
21
    20260819011000,
22
    20260819012000,
23
    20260819013000
24
  ],
25
  "current_root_versions": [
26
    20260816214000,
27
    20260816214200
28
  ],
29
  "shared_versions": [
30
    20260816214500,
31
    20260816214735,
32
    20260816215500,
33
    20260816220000,
34
    20260816224054,
35
    20260816230000,
36
    20260816231500,
37
    20260816231919,
38
    20260816234500,
39
    20260816235500,
40
    20260817002000,
41
    20260817003500,
42
    20260817005000,
43
    20260817010500,
44
    20260817012000,
45
    20260817012100,
46
    20260817020000,
47
    20260817020100,
48
    20260817030000,
49
    20260817030100,
50
    20260817040000,
51
    20260817040100,
52
    20260817050000,
53
    20260817060000,
54
    20260817065533,
55
    20260817140345,
56
    20260817171456,
57
    20260818003237,
58
    20260818003358,
59
    20260818003949,
60
    20260818150100,
61
    20260818160000,
62
    20260818230000,
63
    20260818232337,
64
    20260818234500,
65
    20260819010000,
66
    20260819080000,
67
    20260819170000
68
  ],
69
  "baseline_entries": [
70
    {
71
      "current_version": 20260816214000,
72
      "satisfied_by": [20260817070000, 20260817080000, 20260817125732],
73
      "reason": "The prior lineage already created the users table and GitHub identity columns."
74
    },
75
    {
76
      "current_version": 20260816214200,
77
      "satisfied_by": [
78
        20260816170746,
79
        20260816203029,
80
        20260816205329,
81
        20260816211100,
82
        20260816211900,
83
        20260816220500,
84
        20260816222500,
85
        20260816224000,
86
        20260816233000,
87
        20260817000500,
88
        20260818003948,
89
        20260818150000
90
      ],
91
      "reason": "The prior lineage already created the conversation, turn, receipt, provider-step, and tool-step tables."
92
    },
93
    {
94
      "current_version": 20260819214108,
95
      "satisfied_by": [20260817070000],
96
      "reason": "The prior GitHub account migration already created OAuth attempts."
97
    },
98
    {
99
      "current_version": 20260819223229,
100
      "satisfied_by": [20260819011000],
101
      "reason": "The prior lineage already created UUID forge fleet targets in the final receipt shape."
102
    },
103
    {
104
      "current_version": 20260819223230,
105
      "satisfied_by": [20260819012000],
106
      "reason": "The prior lineage already created UUID forge build receipts in the final pre-hardening shape."
107
    },
108
    {
109
      "current_version": 20260819223231,
110
      "satisfied_by": [20260819013000],
111
      "reason": "The prior lineage already created UUID forge deployment receipts."
112
    },
113
    {
114
      "current_version": 20260819233053,
115
      "satisfied_by": [20260817070000, 20260817170050],
116
      "reason": "The prior lineage already added account constraints and leaderboard policy; the bridge adds users.browser_key_hash before baselining this version."
117
    },
118
    {
119
      "current_version": 20260820025008,
120
      "satisfied_by": [20260819011000, 20260819013000],
121
      "reason": "The prior forge target and deployment identifiers are already UUIDs; replay would convert them to bigint."
122
    },
123
    {
124
      "current_version": 20260820025112,
125
      "satisfied_by": [20260819013000],
126
      "reason": "The prior forge deployment receipt already has the nodes column."
127
    },
128
    {
129
      "current_version": 20260820025133,
130
      "satisfied_by": [20260819013000],
131
      "reason": "The prior forge deployment receipt already has result, canary, timing, and node columns."
132
    },
133
    {
134
      "current_version": 20260820031230,
135
      "satisfied_by": [20260819011000, 20260819012000, 20260819013000],
136
      "reason": "The prior forge tables already use UUID identifiers; replay would truncate receipt data."
137
    },
138
    {
139
      "current_version": 20260820031258,
140
      "satisfied_by": [20260819012000],
141
      "reason": "The prior forge build target identifier is already a UUID."
142
    },
143
    {
144
      "current_version": 20260820031313,
145
      "satisfied_by": [20260819012000, 20260819013000],
146
      "reason": "The prior receipt tables already omit the temporary target foreign keys."
147
    },
148
    {
149
      "current_version": 20260820040000,
150
      "satisfied_by": [20260819011000, 20260819012000],
151
      "reason": "The prior target and build tables already use the aligned receipt schemas; replay would drop build history."
152
    }
153
  ],
154
  "bridge_changes": [
155
    {
156
      "operation": "add_nullable_column",
157
      "table": "users",
158
      "column": "browser_key_hash",
159
      "type": "bytea"
160
    },
161
    {
162
      "operation": "add_partial_unique_index",
163
      "index": "users_browser_key_hash_index",
164
      "predicate": "browser_key_hash IS NOT NULL"
165
    }
166
  ],
167
  "reconciliation_versions_to_run": [
168
    20260820030850,
169
    20260820030959,
170
    20260820120000,
171
    20260820160000
172
  ],
173
  "new_versions_to_run": [
174
    20260819202148,
175
    20260819202444,
176
    20260819202647,
177
    20260819202649,
178
    20260819203939,
179
    20260819203943,
180
    20260819203944,
181
    20260820032524,
182
    20260820073810,
183
    20260820074227,
184
    20260820074644,
185
    20260820082100,
186
    20260820085203,
187
    20260820130000,
188
    20260820140000,
189
    20260820150000
190
  ],
191
  "required_tables": [
192
    "users",
193
    "visitors",
194
    "conversations",
195
    "messages",
196
    "turns",
197
    "turn_receipts",
198
    "turn_provider_steps",
199
    "turn_tool_steps",
200
    "github_oauth_attempts",
201
    "forge_fleet_targets",
202
    "forge_builds",
203
    "forge_deploys"
204
  ],
205
  "required_columns": {
206
    "users": ["id", "github_id", "github_login", "github_name", "github_avatar_url", "status", "banned_at", "ban_reason_code", "last_authenticated_at", "github_token_ciphertext", "public_leaderboard_opted_out"],
207
    "visitors": ["id", "browser_key_hash", "user_id"],
208
    "conversations": ["id", "visitor_id"],
209
    "messages": ["id", "conversation_id", "role", "content", "status", "provider_response_id", "modality", "voice_session_id", "provider_item_id", "transcript_kind", "interrupted", "work_job_id", "search_vector"],
210
    "turns": ["id", "conversation_id", "user_message_id", "assistant_message_id", "status", "error_message", "error_code", "provider_response_id", "started_at", "completed_at"],
211
    "turn_receipts": ["id", "turn_id", "schema_version", "status", "model_id", "persona_id", "persona_digest", "role_id", "role_digest", "role_selection", "instruction_digest", "input_digest", "input_message_count", "input_bytes", "tool_catalog_digest", "blueprint_revision", "blueprint_digest", "program_artifact_id", "program_artifact_digest", "program_artifact_receipt", "memory_snapshot_ref", "profile_memory_snapshot_ref", "preference_snapshot_ref", "used_preferences", "experience_bank_ref", "used_experiences", "used_source_refs", "used_tool_step_refs", "used_memory_evidence", "usage", "provider_started_at", "provider_completed_at"],
212
    "turn_provider_steps": ["id", "turn_receipt_id", "sequence", "provider_id", "model_id", "status", "provider_response_id", "usage", "error_code", "started_at", "completed_at"],
213
    "turn_tool_steps": ["id", "turn_id", "turn_receipt_id", "sequence", "provider_call_id", "provider_item_id", "provider_response_id", "tool_name", "tool_version", "module_id", "module_artifact_digest", "executor_implementation_digest", "routing_receipt_id", "side_effect_class", "invocation_key", "attribution_policy_id", "attribution_policy_version", "attribution_policy_digest", "billable", "billable_attribution_key", "cost_units", "catalog_digest", "raw_arguments", "argument_digest", "status", "outcome_digest", "outcome_receipt_ref", "usage", "result", "error", "executor_id", "executor_disclosure", "target_receipt_refs", "attribution_refs", "requested_at", "started_at", "completed_at"],
214
    "github_oauth_attempts": ["id", "state_digest", "expires_at", "consumed_at"],
215
    "forge_fleet_targets": ["id", "repo", "sha", "promoted_by", "status", "details"],
216
    "forge_builds": ["id", "repo", "sha", "target_id", "modules", "warnings", "tests", "duration_ms", "artifact"],
217
    "forge_deploys": ["id", "repo", "sha", "target_id", "modules", "nodes", "result", "canary", "push_to_live_ms"]
218
  },
219
  "required_column_types": {
220
    "users.id": "uuid",
221
    "users.github_id": "int8",
222
    "users.github_avatar_url": "text",
223
    "messages.search_vector": "tsvector",
224
    "turn_tool_steps.raw_arguments": "text",
225
    "forge_fleet_targets.id": "uuid",
226
    "forge_builds.id": "uuid",
227
    "forge_builds.target_id": "uuid",
228
    "forge_builds.modules": "_varchar",
229
    "forge_deploys.id": "uuid",
230
    "forge_deploys.target_id": "uuid",
231
    "forge_deploys.modules": "_varchar",
232
    "forge_deploys.nodes": "_varchar"
233
  },
234
  "required_constraints": [
235
    "users_status_check",
236
    "users_ban_state_check",
237
    "visitors_identity_source_check",
238
    "forge_fleet_target_status",
239
    "forge_deploys_result"
240
  ],
241
  "required_indexes": [
242
    "users_github_id_index",
243
    "visitors_browser_key_hash_index",
244
    "visitors_user_id_index",
245
    "github_oauth_attempts_state_digest_index"
246
  ],
247
  "pre_baseline_required_indexes": [
248
    "forge_builds_repo_sha_target_id_index"
249
  ],
250
  "forbidden_constraints": [
251
    "forge_builds_target_id_fkey",
252
    "forge_deploys_target_id_fkey"
253
  ],
254
  "forbidden_tables": [
255
    "forge_builds_phase3_legacy"
256
  ]
257
}
priv/repo/migrations/20260820160000_reconcile_visitor_identity_constraint.exs added +32

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

1
defmodule OpenAgents.Repo.Migrations.ReconcileVisitorIdentityConstraint do
2
  use Ecto.Migration
3
4
  def up do
5
    execute("""
6
    DO $$
7
    BEGIN
8
      IF NOT EXISTS (
9
        SELECT 1
10
          FROM pg_constraint
11
         WHERE conname = 'visitors_identity_source_check'
12
           AND conrelid = 'visitors'::regclass
13
      ) THEN
14
        ALTER TABLE visitors
15
          ADD CONSTRAINT visitors_identity_source_check
16
          CHECK (
17
            (browser_key_hash IS NOT NULL AND user_id IS NULL) OR
18
            (browser_key_hash IS NULL AND user_id IS NOT NULL)
19
          );
20
      END IF;
21
    END
22
    $$;
23
    """)
24
  end
25
26
  def down do
27
    # The prior database lineage already owns this constraint. A down migration
28
    # cannot distinguish that inherited constraint from one created above, so
29
    # preserving it is the only rollback-compatible operation.
30
    :ok
31
  end
32
end
rel/overlays/bin/migration-lineage added +21

@@ -0,0 +1,21 @@

1
#!/bin/sh
2
set -eu
3
4
cd -P -- "$(dirname -- "$0")"
5
6
case "${1:-check}" in
7
  check) action=check ;;
8
  --apply) action=apply ;;
9
  *) echo "usage: bin/migration-lineage [check|--apply]" >&2; exit 64 ;;
10
esac
11
12
if [ "$action" = apply ]; then
13
  : "${OPENAGENTS_STAGING_SNAPSHOT_ID:?OPENAGENTS_STAGING_SNAPSHOT_ID is required}"
14
fi
15
16
./prepare-config >/dev/null
17
18
export OPENAGENTS_MIGRATION_LINEAGE_ACTION="$action"
19
20
exec ./openagents eval \
21
  'OpenAgents.Release.migration_lineage(System.fetch_env!("OPENAGENTS_MIGRATION_LINEAGE_ACTION"), System.get_env("OPENAGENTS_STAGING_SNAPSHOT_ID"))'
test/openagents/migration_lineage_test.exs added +138

@@ -0,0 +1,138 @@

1
defmodule OpenAgents.MigrationLineageTest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.MigrationLineage
7
  alias OpenAgents.Repo
8
9
  @snapshot_ref "local-snapshot-20260820"
10
11
  test "the baseline map partitions every current migration" do
12
    %{data: map, digest: digest} = MigrationLineage.map!()
13
14
    current_versions =
15
      "priv/repo/migrations/*.exs"
16
      |> Path.wildcard()
17
      |> Enum.map(fn path ->
18
        path |> Path.basename() |> String.slice(0, 14) |> String.to_integer()
19
      end)
20
      |> MapSet.new()
21
22
    baseline_versions = Enum.map(map["baseline_entries"], & &1["current_version"])
23
24
    classified_versions =
25
      map["shared_versions"] ++
26
        baseline_versions ++
27
        map["reconciliation_versions_to_run"] ++ map["new_versions_to_run"]
28
29
    assert MapSet.new(classified_versions) == current_versions
30
    assert length(classified_versions) == MapSet.size(current_versions)
31
    assert digest =~ ~r/\A[0-9a-f]{64}\z/
32
  end
33
34
  test "a current-lineage database is classified without mutation" do
35
    assert {:ok, result} = MigrationLineage.classify(Repo)
36
    assert result.classification == "current"
37
    assert result.bridge_state == "complete"
38
  end
39
40
  test "the known prior lineage is baselined once without deleting account data" do
41
    user = repository_user_fixture("lineage-account")
42
    prepare_prior_lineage!()
43
44
    assert {:ok, before} = MigrationLineage.classify(Repo)
45
    assert before.classification == "prior"
46
    assert before.required_facts_missing == 0
47
48
    assert {:ok, result} = MigrationLineage.baseline(Repo, @snapshot_ref)
49
    assert result.classification == "prior_baselined"
50
    assert result.changed
51
    assert result.snapshot_ref == @snapshot_ref
52
    assert user_exists?(user.id)
53
    assert column_exists?("users", "browser_key_hash")
54
    assert index_exists?("users_browser_key_hash_index")
55
56
    assert {:ok, repeated} = MigrationLineage.baseline(Repo, @snapshot_ref)
57
    refute repeated.changed
58
59
    encoded = MigrationLineage.command!(Repo, "check", nil)
60
    refute encoded =~ user.github_login
61
    assert Jason.decode!(encoded)["classification"] == "prior_baselined"
62
  end
63
64
  test "an unknown or partially modified lineage is refused" do
65
    prepare_prior_lineage!()
66
    [first_baseline | _rest] = baseline_versions()
67
    insert_version!(first_baseline)
68
69
    assert {:ok, %{classification: "prior_partial"}} = MigrationLineage.classify(Repo)
70
71
    assert {:error, :partial_lineage_forbidden} =
72
             MigrationLineage.baseline(Repo, @snapshot_ref)
73
74
    Repo.query!("DELETE FROM schema_migrations")
75
    insert_version!(20_260_101_000_000)
76
77
    assert {:ok, %{classification: "unknown"}} = MigrationLineage.classify(Repo)
78
79
    assert {:error, :lineage_not_baselineable} =
80
             MigrationLineage.baseline(Repo, @snapshot_ref)
81
  end
82
83
  test "baseline mutation requires a bounded snapshot reference" do
84
    assert {:error, :invalid_snapshot_reference} = MigrationLineage.baseline(Repo, "missing")
85
86
    assert {:error, :invalid_snapshot_reference} =
87
             MigrationLineage.baseline(Repo, "snapshot/contains/path")
88
  end
89
90
  defp prepare_prior_lineage! do
91
    Repo.query!("DELETE FROM schema_migrations")
92
    Repo.query!("DROP INDEX users_browser_key_hash_index")
93
    Repo.query!("ALTER TABLE users DROP COLUMN browser_key_hash")
94
    Repo.query!("ALTER TABLE users ALTER COLUMN github_avatar_url TYPE text")
95
96
    Repo.query!("""
97
    CREATE UNIQUE INDEX forge_builds_repo_sha_target_id_index
98
        ON forge_builds (repo, sha, target_id)
99
    """)
100
101
    %{data: map} = MigrationLineage.map!()
102
    Enum.each(map["prior_signature_versions"], &insert_version!/1)
103
  end
104
105
  defp baseline_versions do
106
    %{data: map} = MigrationLineage.map!()
107
    Enum.map(map["baseline_entries"], & &1["current_version"])
108
  end
109
110
  defp insert_version!(version) do
111
    Repo.query!(
112
      "INSERT INTO schema_migrations (version, inserted_at) VALUES ($1, NOW())",
113
      [version]
114
    )
115
  end
116
117
  defp column_exists?(table, column) do
118
    %{num_rows: count} =
119
      Repo.query!(
120
        "SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = $2",
121
        [table, column]
122
      )
123
124
    count == 1
125
  end
126
127
  defp index_exists?(index) do
128
    %{num_rows: count} =
129
      Repo.query!("SELECT 1 FROM pg_indexes WHERE indexname = $1", [index])
130
131
    count == 1
132
  end
133
134
  defp user_exists?(id) do
135
    %{rows: [[count]]} = Repo.query!("SELECT count(*) FROM users WHERE id::text = $1", [id])
136
    count == 1
137
  end
138
end

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