# `PhoenixKit.Migrations.Repair.CommentPolicy`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.13.7/lib/phoenix_kit/migrations/repair/comment_policy.ex#L1)

Pure decision logic for the version-comment policy, spec §6.4 R1-R6. Every
function here is DB-free — the caller (`PhoenixKit.Migrations.Repair`) does
the actual reading/writing and passes in already-fetched values.

## Why "floor" here is `PhoenixKit.Migrations.Postgres.initial_version/0`, not a fixed number

§6.4's R3 ("`0 < comment < floor` → hard `BelowFloorError`") is written for
the **post-squash** world, where `floor` is a real number greater than 1.
The chain is squashed at floor 135, so `initial_version/0` is `135` and
`classify/3`'s `:below_floor` branch is LIVE: every install still at
V01..V134 lands in it. (It was dormant while the floor was 1 — no integer
satisfies `0 < comment < 1` — which is why the tests drive it with
synthetic floors like `121` rather than whatever `initial_version/0`
happens to compile to.)

## R1 ("never lower the comment")

Not a branch here — it is a postcondition every function in this module
upholds by construction: `classify/3` never proposes writing a comment at
all (that is `PhoenixKit.Migrations.Repair`'s job, driven by
`should_heal_comment?/2` and `adopt_outcome/2`), and both of those only ever
return a version `>=` what triggered them (`{:stale_low, target}` has
`target > comment` by `marker_cross_check/2`'s own guard; `adopt_outcome/2`
only fires from the NULL-comment branch, where any stamp is a raise).

## R2/R5 as one computation

R2's "stale-LOW comment" (schema ahead of what the comment claims) and R5's
"lying-HIGH ... comment ahead of schema" warning are opposite readings of
the same number: the highest version whose objects are *all* structurally
present (`highest_fully_present_version/1`, the generalized form of
`PhoenixKit.Migrations.Postgres.heal_version_comment/2`'s single hardcoded
`{83, ...}` marker probe — spec's own word for it is "generalized").
`marker_cross_check/2` compares that number against the comment and reports
whichever side is warranted (or `:consistent` when they agree).

# `branch`

```elixir
@type branch() ::
  :not_installed
  | {:below_floor, pos_integer()}
  | :adopt_required
  | :comment_unreadable
  | {:in_range, pos_integer()}
  | {:above_current, pos_integer()}
```

Which R-branch a given raw comment falls into.

# `cross_check`

```elixir
@type cross_check() ::
  :consistent | {:stale_low, pos_integer()} | {:ahead_of_schema, pos_integer()}
```

Outcome of comparing the comment against the highest fully-present version.

# `raw_comment`

```elixir
@type raw_comment() :: :absent | nil | non_neg_integer()
```

Raw comment as read by `PhoenixKit.Migrations.Repair.Probe.raw_comment/2`.

# `adopt_outcome`

```elixir
@spec adopt_outcome(verify_clean? :: boolean(), floor :: pos_integer()) ::
  {:stamp, pos_integer()} | :no_stamp
```

R4's `--adopt` gate: stamp `floor` iff the post-apply verify pass came back
fully clean (zero `:missing`, zero error-severity divergences, every
floor-level data invariant held) — `verify_clean?` is that single boolean,
computed by the caller from a `PhoenixKit.Migrations.Repair.Report.t()`.
Never partially adopts.

    iex> CommentPolicy.adopt_outcome(true, 121)
    {:stamp, 121}
    iex> CommentPolicy.adopt_outcome(false, 121)
    :no_stamp

# `classify`

```elixir
@spec classify(raw_comment(), floor :: pos_integer(), current :: pos_integer()) ::
  branch()
```

Classifies a raw comment against `floor`/`current` into one of R3-R6's
branches (R1/R2 are cross-cutting, not classification outcomes — see
moduledoc). `:absent` (table doesn't exist) and a phantom `0` comment (never
written by any real migration, but treated defensively the same as `nil`)
both fall outside R1-R6's own enumeration; `:not_installed`/`:adopt_required`
make that explicit rather than leaving them undefined.

    iex> CommentPolicy.classify(:absent, 121, 151)
    :not_installed
    iex> CommentPolicy.classify(nil, 121, 151)
    :adopt_required
    iex> CommentPolicy.classify(0, 121, 151)
    :adopt_required
    iex> CommentPolicy.classify(90, 121, 151)
    {:below_floor, 90}
    iex> CommentPolicy.classify(135, 121, 151)
    {:in_range, 135}
    iex> CommentPolicy.classify(200, 121, 151)
    {:above_current, 200}

# `concurrent_migration?`

```elixir
@spec concurrent_migration?(raw_comment(), raw_comment()) :: boolean()
```

Detects the "chain `up/1` advanced the comment mid-repair" race (spec's
concurrency rule, S18): compares the raw comment read right after the
advisory lock was acquired against a second raw read taken right before the
final verify pass. Any difference — not just an increase — aborts, since a
decrease would mean a `down/1` ran concurrently, an equally unsafe race.

    iex> CommentPolicy.concurrent_migration?(135, 135)
    false
    iex> CommentPolicy.concurrent_migration?(135, 142)
    true
    iex> CommentPolicy.concurrent_migration?(nil, nil)
    false
    iex> CommentPolicy.concurrent_migration?(nil, 121)
    true

# `floor_verify_clean?`

```elixir
@spec floor_verify_clean?([PhoenixKit.Migrations.Repair.Report.finding()]) ::
  boolean()
```

Whether a report-only verify pass counts as "clean" for `adopt_outcome/2`'s
`verify_clean?` argument: zero `:missing` findings and zero error-severity
findings. (`data_invariants_hold?` is threaded separately by the caller —
invariant failures already surface as error-severity findings in a
well-formed report, but `PhoenixKit.Migrations.Repair` computes this from
the finding list directly so the gate does not depend on that always being
true.)

    iex> CommentPolicy.floor_verify_clean?([%{kind: :pending, severity: :info}])
    true
    iex> CommentPolicy.floor_verify_clean?([%{kind: :missing, severity: :repairable}])
    false
    iex> CommentPolicy.floor_verify_clean?([%{kind: :wrong_shape, severity: :error}])
    false

# `highest_fully_present_version`

```elixir
@spec highest_fully_present_version([{pos_integer(), boolean()}]) :: non_neg_integer()
```

Generalizes `PhoenixKit.Migrations.Postgres.heal_version_comment/2`'s single
`{83, marker_query}` entry to the whole manifest: the highest version for
which *every* manifest object with that `since` is structurally present,
given a per-`since`-bucket presence result. `0` if nothing is present at
all (or the input is empty).

`presence_by_since` need not be sorted or exhaustive — every distinct
`since` bucket the caller actually probed, in any order. Correctness
depends on the caller having probed **every** `since` value from the
smallest up to (at least) the answer; a caller that skips a low `since`
bucket entirely gets a result as if that bucket were fully present, which
is why `PhoenixKit.Migrations.Repair.Probe`'s caller probes the whole
manifest, not just "since above the comment".

    iex> CommentPolicy.highest_fully_present_version([{53, true}, {114, true}, {137, false}, {142, true}])
    114
    iex> CommentPolicy.highest_fully_present_version([{53, true}])
    53
    iex> CommentPolicy.highest_fully_present_version([{53, false}])
    0
    iex> CommentPolicy.highest_fully_present_version([])
    0

# `marker_cross_check`

```elixir
@spec marker_cross_check(comment :: pos_integer(), highest :: non_neg_integer()) ::
  cross_check()
```

The R2/R5 cross-check (see moduledoc). `highest` is
`highest_fully_present_version/1`'s result.

    iex> CommentPolicy.marker_cross_check(135, 140)
    {:stale_low, 140}
    iex> CommentPolicy.marker_cross_check(135, 130)
    {:ahead_of_schema, 130}
    iex> CommentPolicy.marker_cross_check(135, 135)
    :consistent

# `should_heal_comment?`

```elixir
@spec should_heal_comment?(cross_check(), heal_comment_requested? :: boolean()) ::
  {:heal, pos_integer()} | :no_heal
```

Whether `--heal-comment` should stamp the comment forward, given the R2/R5
cross-check and whether the flag was passed. Only `{:stale_low, _}` is ever
healed — `:consistent` and `{:ahead_of_schema, _}` never propose writing
anything (R1: healing "ahead of schema" would mean *lowering* the comment
toward `highest`, which R1 forbids outright; R2's normal missing-object
healing is what addresses that case instead).

    iex> CommentPolicy.should_heal_comment?({:stale_low, 140}, true)
    {:heal, 140}
    iex> CommentPolicy.should_heal_comment?({:stale_low, 140}, false)
    :no_heal
    iex> CommentPolicy.should_heal_comment?(:consistent, true)
    :no_heal
    iex> CommentPolicy.should_heal_comment?({:ahead_of_schema, 130}, true)
    :no_heal

---

*Consult [api-reference.md](api-reference.md) for complete listing*
