priv/repo/migrations/20260825220000_create_system_memories.exs

58e6347eeb72 · 8 KB

defmodule OpenAgents.Repo.Migrations.CreateSystemMemories do
  use Ecto.Migration

  # The third memory bucket: what the network as a whole has learned, rather
  # than what one account asked to have remembered.
  #
  # A wrong `user` memory misleads one session. A wrong `system` memory would
  # reach every session, so the row carries the things that make a claim
  # answerable — who wrote it, when it was observed true, and what evidence
  # stands behind it — and the table refuses a row that carries none of them.
  #
  # Two decisions are load-bearing here, and both are database predicates
  # rather than changeset validations (MEMORY-004):
  #
  #   * A system candidate with an empty evidence list cannot exist. The write
  #     path refuses it too, but a constraint is what makes it unrepresentable
  #     rather than merely unwritten by the code that exists today.
  #
  #   * An admission record can only name a `system` row. The composite foreign
  #     key on `(id, bucket)` enforces that without any read of `memories`, so
  #     the admission path never issues a query across the account boundary
  #     MEMORY-010 draws.
  #
  # Rows in the `user` and `learned` buckets carry none of the system columns,
  # and the constraint says so in both directions.
  def up do
    alter table(:memories) do
      # `sys:` prefixed. The prefix is a routing convention, not a boundary —
      # the boundary is the admission record and the write authorization.
      add :slug, :string
      add :entity, :string

      # `ledger` or `glass`, never lower. A system memory's body reaches every
      # agent by definition, which is content plus metadata; a claim that
      # cannot ship its content is not a system memory.
      add :tier, :string

      # The date the claim was observed true, distinct from `inserted_at`.
      # `inserted_at` orders the chain; `as_of` dates the claim, so a stale
      # truth reads as dated rather than as current.
      add :as_of, :date

      # The author's own claim, and nothing more. Effective status is derived
      # from `memory_admissions`, so a row that says `admitted` with no steward
      # record behind it still reads as a candidate.
      add :admission, :string

      # A non-empty list of `{kind, ref, digest}`. `receipt` points at a forge
      # receipt, `memory` at a prior admitted row, `url` at public material;
      # the digest is what keeps evidence from being swapped after admission.
      add :evidence_refs, :jsonb
    end

    # The original shape constraint named two buckets. Recreating it is how the
    # third one becomes writable at all.
    drop constraint(:memories, :memories_shape)

    create constraint(:memories, :memories_shape,
             check: """
             bucket IN ('user','learned','system')
             AND char_length(body) BETWEEN 1 AND 2000
             AND (source_ref IS NULL OR char_length(source_ref) BETWEEN 1 AND 200)
             AND (superseded_by_id IS NULL OR superseded_by_id <> id)
             """
           )

    # The system columns, present together on a system row and absent together
    # on every other row. Stating the absent half matters as much as the
    # present half: it keeps a `user` row from quietly carrying a tier nothing
    # reads and nobody set deliberately.
    #
    # Every column is asserted `IS NOT NULL` before it is compared, and that is
    # not belt and braces. A check constraint passes when it evaluates to NULL,
    # so `tier IN ('ledger','glass')` alone admits a row with no tier at all,
    # and a length test alone admits a candidate whose evidence list is absent
    # rather than empty — which is the exact hole this constraint exists to
    # close.
    create constraint(:memories, :memories_system_shape,
             check: """
             (
               bucket <> 'system'
               AND slug IS NULL
               AND entity IS NULL
               AND tier IS NULL
               AND as_of IS NULL
               AND admission IS NULL
               AND evidence_refs IS NULL
             ) OR (
               bucket = 'system'
               AND slug IS NOT NULL
               AND slug LIKE 'sys:%'
               AND char_length(slug) BETWEEN 5 AND 200
               AND (entity IS NULL OR char_length(entity) BETWEEN 1 AND 200)
               AND tier IS NOT NULL
               AND tier IN ('ledger','glass')
               AND as_of IS NOT NULL
               AND admission IS NOT NULL
               AND admission IN ('candidate','admitted','rejected')
               AND evidence_refs IS NOT NULL
               AND jsonb_typeof(evidence_refs) = 'array'
               AND jsonb_array_length(evidence_refs) BETWEEN 1 AND 20
               AND NOT jsonb_path_exists(evidence_refs, '$[*] ? (!(@.type() == "object"
                     && exists(@.kind ? (@ == "receipt" || @ == "memory" || @ == "url"))
                     && exists(@.ref ? (@.type() == "string" && @ != ""))
                     && exists(@.digest ? (@.type() == "string" && @ != ""))))')
             )
             """
           )

    # What the composite foreign key below points at. A memory's bucket cannot
    # change out from under an admission record while one references it.
    create unique_index(:memories, [:id, :bucket], name: :memories_id_bucket_index)

    # Admission is a receipt, not a field the author sets.
    #
    # The record is a row of its own rather than a column on the candidate for
    # the reason a promise flip is a receipt rather than a flag: the account
    # that wrote the claim is not the account that may admit it, and the store
    # keeps every verdict rather than the last one written over the others.
    create table(:memory_admissions, primary_key: false) do
      add :id, :binary_id, primary_key: true

      # The candidate this record judges, and the bucket it must be in. The
      # bucket rides the row so the composite foreign key can pin it.
      add :memory_id, :binary_id, null: false
      add :memory_bucket, :string, null: false

      # The account that wrote the record. A steward at the time of the write;
      # the row records who, and the role check runs where the row is created.
      add :steward_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false

      # `adm:<memory_id>` for an admission. The slug namespace is the spec's;
      # `chl:` and `ref:` join it when challenge and refutation land.
      add :slug, :string, null: false

      # `admission` today. `challenge` and `refutation` are named here because
      # they are the same enum, not because this issue writes them.
      add :role, :string, null: false

      # `admitted` or `rejected` on an admission record.
      add :verdict, :string

      # Why. A verdict without a ground cannot be argued with.
      add :ground, :text, null: false

      # Append-only: inserted, never updated, so there is no `updated_at`.
      timestamps(type: :utc_datetime_usec, updated_at: false)
    end

    execute("""
    ALTER TABLE memory_admissions
    ADD CONSTRAINT memory_admissions_memory_fkey
    FOREIGN KEY (memory_id, memory_bucket)
    REFERENCES memories (id, bucket) ON DELETE CASCADE
    """)

    create constraint(:memory_admissions, :memory_admissions_shape,
             check: """
             memory_bucket = 'system'
             AND role IN ('admission','challenge','refutation')
             AND char_length(slug) BETWEEN 1 AND 200
             AND char_length(ground) BETWEEN 1 AND 2000
             AND (
               (role = 'admission'
                AND verdict IS NOT NULL
                AND verdict IN ('admitted','rejected')
                AND slug = 'adm:' || memory_id::text)
               OR (role <> 'admission' AND verdict IS NULL)
             )
             """
           )

    # Deriving a candidate's status reads its records newest last.
    create index(:memory_admissions, [:memory_id, :inserted_at])
    create index(:memory_admissions, [:steward_id])
  end

  def down do
    drop table(:memory_admissions)
    drop constraint(:memories, :memories_system_shape)
    drop index(:memories, [:id, :bucket], name: :memories_id_bucket_index)
    drop constraint(:memories, :memories_shape)

    create constraint(:memories, :memories_shape,
             check: """
             bucket IN ('user','learned')
             AND char_length(body) BETWEEN 1 AND 2000
             AND (source_ref IS NULL OR char_length(source_ref) BETWEEN 1 AND 200)
             AND (superseded_by_id IS NULL OR superseded_by_id <> id)
             """
           )

    alter table(:memories) do
      remove :slug
      remove :entity
      remove :tier
      remove :as_of
      remove :admission
      remove :evidence_refs
    end
  end
end