# Changelog 2026-07-31 -- the `prefer-ipv6` / v6 VIP charm research

Session changelog (GA-R2/D1: ONE per session). Branch `dc-dc-stage5-preconditions`.
Status claims live ONLY in `docs/CURRENT-STATE.md`.

Context: bundle deploy attempt 1 failed 2026-07-31 with
`unknown option "prefer-ipv6"` on barbican, and the operator ruled *"Research what
those 6 charms do with v6 VIPs first (Recommended)"*. Stage 5 is blocked on that
research. This session executes it. **Read-only throughout: no cloud mutation, no
overlay edit, no ruling adopted.**

---

## Item 1 -- `docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt` (NEW)

**What.** The capture answering the ruled question, plus two findings that came out
of the same measurement.

**Method, and why it is not the Charmhub `config-yaml` API.** The attempt-1 root
cause was measured against `config-yaml`, which answers only *is the knob declared*.
It cannot answer *what the charm does*. This research downloads the charm each app
actually deploys -- `juju download <charm> --channel <pinned> --base ubuntu@22.04
--arch amd64` from voffice1 -- and reads the templates and code inside it. Revisions
are recorded in the capture because `2024.1/stable` moves. Two declaring charms
(keystone 857, cinder 820) were downloaded as CONTROLS: without one, "binds v4 only"
and "binds v6 by another mechanism" cannot be told apart.

**The answer.** `prefer-ipv6` is not what makes HAProxy bind `:::port`, and never was,
for any of the thirteen. In both template families the v6 frontend bind is gated on
`ipv6_enabled = not is_ipv6_disabled()` -- a read of the kernel
`net.ipv6.conf.all.disable_ipv6` sysctl. Measured `0` on a live MAAS-deployed jammy
node in this DC. The v6 VIP is assigned by pacemaker on per-address family detection,
also independent of the option. Vault ships no haproxy template at all and its
listener is the hardcoded literal `[::]:8200`.

**What the option really does:** it is a unit address-family switch --
`get_relation_ip()` returns early with the unit's IPv6 address for EVERY relation,
ignoring the network-space binding; plus `bind_host ::`, ip6-localhost stats, and
keystone's `sync_db_with_multi_ipv6_addresses`. The value that sounds like a bind
address, `haproxy_host`, is consumed by NO template in any charm downloaded.

**Consequence recorded, not acted on:** `provider-bundle-check` invariant 9's
mechanism claim is factually wrong; when a ruling lands the invariant must be
REPLACED with a charm-schema-aware one and proven able to fail in both directions,
never deleted to go green.

**Revert.** `git rm docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt`. The
file is a read-only capture; nothing depends on it but the CURRENT-STATE entry in
item 2.

## Item 2 -- `docs/CURRENT-STATE.md` section 1: the research result

**What.** The research outcome appended under the 2026-07-31 ruling block that
records the question -- the answer, its evidence, the invariant-9 consequence, the
new question about the seven declaring charms, the option-name sweep result, and the
runbook defect. Required in the same commit by GA-R1 rule 8 (C1); repo-lint L10
enforces it.

**Revert.** `git revert` this commit, or delete the appended block; the preceding
ruling text is unchanged and still reads correctly on its own.

## Item 3 -- MEASUREMENT ONLY (no artifact): full option-name sweep of the dc0 deploy input

**What.** Attempt 1 proved `juju deploy --dry-run` does not validate config option
NAMES, and juju aborts the whole bundle atomically on the first bad one -- so a second
unknown option elsewhere costs another full attempt. Every option assignment in
attempt 1's exact input (`bundle.yaml` + `vr1-dc0-vips` + `vr1-dc0-machines` +
`vr1-dc0-octavia-pki`) was compared against each charm's own `config.yaml` downloaded
at its pinned channel.

**Result: 56 applications, 81 option assignments, 20 charm schemas, 0 unresolvable,
and the only findings are `prefer-ipv6` on the same six.** No further option-name
failure is waiting for attempt 2. An unreadable or unrecognised schema REFUSES rather
than passing.

**Not built as a gate, deliberately.** Hard rule 1 plus the standing operator
directive: hardening is out of scope unless it blocks the deploy, and this one-shot
measurement already de-risks attempt 2. The method is reproduced verbatim in the
capture so it can be re-run or promoted later. CURRENT-STATE already records that no
gate in this repo reads a charm config schema.

**Scope, stated so it is not over-read.** It checked option NAMES, for the dc0 input
only. Two extensions are OWED before the redeploy and are sequenced after the ruling:
option VALUE TYPES (every downloaded `config.yaml` carries the `type:` field this pass
discarded), and the dc1 input, which is unswept.

**Revert.** Nothing to revert -- no repo artifact was created by this item.

## Item 5 -- MEASUREMENT ONLY: what option (b) would actually cost

**What.** Before putting the fork to the operator, the implementation cost of (b) was
measured rather than assumed. `prefer-ipv6: true` is INJECTED UNCONDITIONALLY by
`scripts/render-dc-overlays.py:243-246` for every app whenever `fam == "dual"`, and
`family: dual` is a SINGLE TOP-LEVEL key at `render/values/vr1-dc{0,1}-vips.yaml:3` --
not per-app data.

**Consequence.** There is no value to edit. (b) means teaching the renderer which
charms declare the option, re-pointing `tests/render-drift`, and replacing
`provider-bundle-check` invariant 9 -- a code change with a harness, not a one-line
data fix. The injection's own inline comment repeats the mechanism claim this
session's research refutes.

**Revert.** Nothing to revert.

## Item 4 -- LOGGED NOT FIXED: the dc0 deploy block omits a non-no-op overlay

**What.** `runbooks/dc-dc-phase4-juju-bundle-per-dc.md:553-557` gives the dc0 deploy
without `overlays/vr1-dc0-machines.yaml`, while the dc1 block three lines below
includes its machines overlay. Measured, the overlay's ONLY delta to the merged input
is `ovn-chassis.options.bridge-interface-mappings = 'br-ex:52:54:00:8c:2a:8c
br-ex:52:54:00:50:48:88'` -- the two dc0 compute provider MACs. Deploying dc0 exactly
as the runbook reads would leave ovn-chassis with no provider bridge mapping, which
surfaces later as tenant networks with no external path, not as a deploy error.
Attempt 1 as executed correctly included the overlay.

**Why not fixed here.** Hard rule 1 -- this session's step is the research. It is
DOCFIX material and is recorded in CURRENT-STATE and in the capture so it cannot be
lost.

**Revert.** Nothing to revert.

## Item 6 -- THE RULING, and the three-part build that implements it

**Ruling.** Recorded under GA-R5 in `docs/design-decisions.md` as a D-101 RULING NOTE
dated 2026-07-31 (OPS under GA-R3; D-101's matrix UNAMENDED), and in
`docs/CURRENT-STATE.md`. Committed and pushed BEFORE any dependent work. Operator
utterance, verbatim: *"I want it to use IPv6, if there is spam mechanisms being applied
then that is bad. Even if it doesn't cause an issue now, it might in the future. A clean
IPv6 network is better than one with unusable and possibly future breaking
configurations."* -- and, because that stated a principle rather than selecting an
option, a confirming exchange: *"Yes -- keep the v6 legs, remove only the option"*.

**(i) `scripts/provider-bundle-check.py` -- `PREFER_IPV6_CHARMS` (NEW) + invariant 9
REPLACED.** The constant is the measured authority, keyed by CHARM name (an application
name is the deployer's choice; only a charm has a schema) and carrying the revisions and
a re-measure-if-a-pin-moves warning. Invariant 9's old text -- "prefer-ipv6 makes HAProxy
bind :::port in ADDITION to *:port, so the two must travel together" -- is refuted by
this session's measurement and is REPLACED, never deleted: 9a the option is only legal on
a charm that declares it (asserted on PRESENCE, since `prefer-ipv6: false` is the same
fatal `unknown option` to juju as `true`); 9b on a declaring charm it still travels with
the v6 legs, keeping the L3-9 protection intact for the seven; 9c arity.

**(ii) `scripts/render-dc-overlays.py` + both overlays re-rendered.** The renderer emits
the option only for a declaring charm, reading the set from provider-bundle-check with
`ast` rather than restating it (the read-don't-restate rule `APP_OCTET` already uses).
`render()` keeps its purity property -- the set is a PARAMETER, and the default REFUSES
rather than defaulting to empty, because an empty set renders a plausible-looking
artifact with the option nowhere. The values-file headers were updated too, since they
carried the refuted mechanism claim. **Measured delta: exactly six lines removed per DC
and nothing else -- every `vip` string byte-identical, so all v6 legs are retained.**

**(iii) `tests/provider-bundle-check/` 44 -> 48.** T43 reproduces the attempt-1 defect;
T44 proves presence-not-truthiness; T45 is the positive control (without it, 9a could be
satisfied by rejecting everything); T46 asserts the DIAGNOSIS. T21's comment was
re-pointed to its real rationale -- the assertion is unchanged and still correct, for a
different reason than the one originally written.

**Mutation pass -- six mutations, every one killed tests.** 9a deleted (T43+T44 die); 9a
keyed on value (T44 dies); 9b restored to all charms (T19+T45 die, 10 failures); the
charm list widened with barbican+vault (T43-T45 die); renderer emitting for every dual
app, and `prefer6_charms()` returning empty instead of refusing (both kill
`render-drift`). Source restored byte-identical after each.

**ONE NEW ASSERTION WAS DECORATION AND WAS REPLACED.** T46's first form mutated only a
vip; a non-declaring charm with no option has `prefer6 == dual == False` and reaches
neither branch, so it could not fail under ANY mutation of the charm list. Re-written to
assert that an input matching BOTH rules is diagnosed by 9a rather than 9b -- a
misleading diagnosis would send the next session to add v6 legs when the fix is to remove
the option -- and re-proven against the fold-9a-into-9b mutation. **Third time in a week
that "prove each new assertion can FAIL" has caught a real hole.**

**Verification.** Gauntlet ALL GREEN (93); repo-lint 0 fail; the fixed dc0 deploy input
now sweeps CLEAN on option names (75 assignments, 20 schemas, 0 unknown, 0 unresolvable).

**Revert.** `git revert` this commit. It restores the previous renderer behaviour, the
previous invariant 9 and the previous overlays together -- they must move as one, since
a re-rendered overlay without the matching renderer fails `tests/render-drift`.

---

## What was NOT done, stated so it is not assumed

- **R2 is unamended and D-101's matrix is unchanged.** The dual-stack posture does not
  move; every v6 VIP leg is retained.
- **Nothing changed for the SEVEN charms that DO declare the option** -- that is a
  separate, newly-raised question and was deliberately not bundled into the ruling
  (GA-R5: one decision per exchange).
- **No cloud state changed.** The `vr1-dc0` model is still empty; no `juju deploy` was
  run this session.
- **No charm-schema GATE was built.** The one-shot sweep is a measurement; a gate is
  logged as owed.
