priv/repo/migrations/20260825170000_add_private_content_ciphertext_columns.exs

main at 58e6347eeb72 · 2 KB

defmodule OpenAgents.Repo.Migrations.AddPrivateContentCiphertextColumns do
  @moduledoc """
  The expand half of sealing the private content columns nobody searches
  (issue #193).

  It only adds. Every plaintext column keeps its name, its type, and its rows;
  what it loses is `NOT NULL`, because the release that follows writes the
  ciphertext and leaves the plaintext empty. Retyping a live column here would
  break every node still running the previous release for as long as a rolling
  replacement takes, which is the failure `RELEASE-006` exists around, so the
  plaintext columns are dropped by a contract migration a release later rather
  than by this one.

  The `_present` constraints are what keep "this row has text" true across the
  transition: an un-replaced node writes the plaintext column, a replaced one
  writes the ciphertext column, and neither can write a row with neither.
  `voice_sessions.compaction_summary` gets no such constraint because it is
  optional by design — `OpenAgents.Voice.Retention` nulls it on purge.

  `preference_observation_shape` is left alone deliberately. It bounds
  `octet_length(summary)`, and a `CHECK` is satisfied unless it evaluates to
  false, so a null summary leaves that conjunct null and the constraint
  passes — the same reason `voice_transcript_items_content_bounded` needs no
  edit either.
  """

  use Ecto.Migration

  # {table, plaintext column, ciphertext column, ciphertext upper bound, present check?}
  @columns [
    {:voice_transcript_items, :content, :content_ciphertext, 16_064, true},
    {:voice_sessions, :compaction_summary, :compaction_summary_ciphertext, 8_256, false},
    {:preference_observations, :summary, :summary_ciphertext, 576, true},
    {:project_notes, :body, :body_ciphertext, 131_136, true}
  ]

  def up do
    for {table, plaintext, ciphertext, bound, present?} <- @columns do
      alter table(table) do
        add ciphertext, :binary
      end

      # `compaction_summary` is already nullable, and only the columns that
      # carry a present-check ever had `NOT NULL` to drop.
      if present?, do: execute("ALTER TABLE #{table} ALTER COLUMN #{plaintext} DROP NOT NULL")

      create constraint(table, :"#{table}_#{ciphertext}_bounded",
               check:
                 "#{ciphertext} IS NULL OR octet_length(#{ciphertext}) BETWEEN 30 AND #{bound}"
             )

      if present? do
        create constraint(table, :"#{table}_#{plaintext}_present",
                 check: "#{plaintext} IS NOT NULL OR #{ciphertext} IS NOT NULL"
               )
      end
    end
  end

  def down do
    for {table, plaintext, ciphertext, _bound, present?} <- @columns do
      if present?, do: drop(constraint(table, :"#{table}_#{plaintext}_present"))

      drop constraint(table, :"#{table}_#{ciphertext}_bounded")

      if present?, do: execute("ALTER TABLE #{table} ALTER COLUMN #{plaintext} SET NOT NULL")

      alter table(table) do
        remove ciphertext
      end
    end
  end
end