| 2026-07-15 |

C2 sub-task (ii): extend sandbox loop to ip-ranges/ip-addresses; load + verify D-120 bands
...
Closes the logged tooling gap and the D-120 Step 6 load, verified end to end. With
this, C2 is fully closed and Stage 2 close-out (C1..C5) is COMPLETE.
Tooling:
- prod-draft-dump.py: ENDPOINTS += ipam/ip-ranges, ipam/ip-addresses (ranges get a
synthetic start-end key). Verified live.
- sandbox-fidelity-check.py: KEY + EXPECTED_NEW cover the 3 D-120 ranges + 2 service
IPs; both-bounds gives endpoint/address correctness (pure delta -- the frozen draft
has none). Success banner now D-115/D-117/D-118/D-120. Harness +T11/T12 (12->14).
- netbox/d120-compose-bands.py: stdlib, UA-aware, SANDBOX_HOSTS-guarded, dry-by-default,
whole-plan preflight (parent 10.10.1.0/24 exists, endpoints in-parent, start<=end) so
it cannot half-write. Harness 16/16 pins the band values + guards.
Load (operator-approved --commit into office1-netbox, the VR1 apex; sandbox-local):
- created 3 ip-ranges (.2-.49 / .100-.200 / .201-.254) + 2 ip-addresses (.10 netbox,
.11 tailscale), 0 already present. Re-dump -> 3 ranges, 2 IPs. Fidelity check -> exit 0,
"EXACTLY the planned delta (D-115/D-117/D-118/D-120)."
D-120 Step 6 CLOSED (no apex write -- write-back stays deferred). Gauntlet ALL GREEN
(60); repo-lint 0 fail. Workflow C2 row + session-ledger updated: Stage 2 close-out
complete -> branch->main merge point reached (merge remains operator-gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WgVrZE2ryY9aXP7JiBuwj
|

creds-audit: enforce per-site credential consolidation (SEC-009); prevents the DC0/DC1 spread
...
SEC-009 established "all site secrets live in ~/<site>-creds/" but nothing enforced
it, and it had two blind spots: VM-minted secrets (no forcing function to consolidate
-- office1-netbox's sandbox NetBox token went un-consolidated until needed) and silent
sprawl (a loose *.env in ~ had no detector). Standing up DC0/DC1 multiplies both.
Built (operator-directed):
- creds-manifests/<site>.manifest -- declares exactly the secrets a site must hold,
each with mode + provenance (local, or <vm>:<path> for the VM-minted class).
vr1-office1.manifest seeded (11 entries); the live folder audits CLEAN.
- scripts/creds-audit.sh <site> -- verifies the live ~/<site>-creds/ against the
manifest (0700 folder, declared modes, present, non-empty), flags UNDECLARED files,
and scans the home root for SPRAWL. Reads NO secret values (metadata only; harness
T7 asserts no cat/head/tail/od/xxd). CREDS_ROOT/MANIFEST_DIR overrides for testing.
- tests/creds-audit/run-tests.sh (7/7) -- missing VM-secret, wrong mode, undeclared
file, home-root sprawl, non-0700 folder, metadata-only. Auto-discovered by gauntlet.
- dc-dc-phase3 GATE -- non-negotiable close-out: consolidate + manifest each secret AT
MINT TIME, and creds-audit $DC must be CLEAN before a DC is declared done.
- security-ledger SEC-009 ENFORCEMENT note.
Verified: harness 7/7; live creds-audit vr1-office1 -> CLEAN (also tool-confirms the
sandbox-token gap is closed); gauntlet ALL GREEN (59); repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WgVrZE2ryY9aXP7JiBuwj
|

C2 sub-task (i): sandbox re-verified faithful; fidelity-checker scope false-positive fixed
...
Re-ran the HARDENED fidelity check against office1-netbox (the VR1 IPAM apex,
DOCFIX-195) -- read-only dump via the consolidated sandbox token + an SSH tunnel.
Result: exit 0, "upstream draft + EXACTLY the planned delta (D-115/D-117/D-118)."
C2 sub-task (i) is CLOSED; the sandbox is proven faithful.
The hardened checker false-failed on 10.10.0.0/16 and 172.30.0.0/16 being
unscoped -- but those two role-container /16s are unscoped BY DESIGN
(d115-office-carve.py), matching the 27 unscoped role containers already in the
upstream apex. A full cross-check of all 44 delta prefixes against their intended
scope was 44/44 (every DC prefix on the correct DC site; no wrong-target binding).
Fix (netbox/sandbox-fidelity-check.py): assert scope == INTENDED per delta prefix
(EXPECTED_PREFIX_SCOPE; DC half generated by _dc_prefix_scopes so the CIDR set and
scope map cannot drift). Strictly stronger than the old "has a scope?" test -- still
catches a dropped scope, and now catches a WRONG-TARGET binding (a DC0 prefix bound
to vr1-dc1, the D-117 near-miss). NOT fixed by exempting the two CIDRs.
Harness moved to dump format (scope_site), T1 sets each prefix's scope from the
checker's own map, +T9 (wrong-target fails) +T10 (legit-unscoped passes). 10->12.
Gauntlet ALL GREEN (58); repo-lint 0 fail.
SEC-009: the sandbox NetBox token had been MISSED by the consolidation (lived only
at /root/netbox-secrets/api.token on office1-netbox); now consolidated to
~/vr1-office1-creds/vr1-netbox-sandbox.env (0600), copied off the VM without
printing. Ledger addendum recorded.
No NetBox state changed -- dump and check are read-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WgVrZE2ryY9aXP7JiBuwj
|

DOCFIX-195: office1-netbox holds the VR1 IPAM apex role; apex write-back deferred; C2 redefined
...
Operator ruling 2026-07-15: office1-netbox (10.10.1.10) HOLDS THE IPAM APEX
ROLE during VR1 -- the authoritative NetBox every consuming system (OpenTofu,
MAAS, overlays, bundle) derives its values from. It is a working draft (edited
as the buildout proceeds), but it is the apex. netbox.baldurkeep.com takes NO
write during VR1; it is the eventual production apex, frozen as a read-only v1
reference draft. Merge-back is deferred to end-of-deployment (a maybe), NOT a
Stage 2 gate.
The repo encoded the opposite roles and defined close-out C2 (the last Stage 2
gate, and the branch->main merge trigger) as an operator-gated write UPSTREAM.
Under the ruling there is no apex write in Stage 2, so C2 is redefined:
office1-netbox holds the COMPLETE validated dataset -- carve + six-plane roles
+ both DCs + the D-120 compose-/24 child ranges + the two service IPs -- and it
is VERIFIED. C2 stays OPEN.
Finding surfaced while drafting (logged, not fixed): the D-120 objects are
ip-ranges/ip-addresses, object classes the sandbox loop does NOT cover
(prod-draft-dump.py ENDPOINTS and sandbox-fidelity-check.py KEY/EXPECTED_NEW
both stop at rirs/roles/regions/sites/aggregates/prefixes). So the hardened
fidelity check cannot verify the D-120 load; verifying it needs a tool
extension (+harness) or a recorded manual read-back. Confirmed no live system
config points at any NetBox URL -- documentation-only change.
Files: dc-dc-deployment-workflow.md (State + C2 row), dc-dc-netbox-buildout-scope.md
(sec 8.1 + 8.6), session-ledger.md (5 narrative spots + new deferred-item bullet;
machine-derived block re-seeded from the 2026-07-15 scan), design-decisions.md
(D-010 note + D-103 pointer), netbox/README.md (target banner), + changelog.
repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WgVrZE2ryY9aXP7JiBuwj
|

D-120 ADOPTED + EXECUTED: re-IP Office1 services into the .2-.49 static band
...
office1-netbox .201->.10 and office1-tailscale .202->.11, on the wire, no
wipe. NetBox HTTP 200 at .10; tailscale still advertises 10.10.0.0/22.
Method (DC replication inherits it): MAAS refuses interface edits on a
Deployed machine and the only lift is Release->redeploy=WIPE, so the live
address is set on the guest (netplan + curtin cfg, reboot-durable) over
lxc exec (socket immune to the netplan apply), and MAAS-model drift is
accepted (reconciles at next teardown/redeploy). NetBox ALLOWED_HOSTS=*
so no restart; tailscale re-asserted automatically.
Swept SANDBOX_HOSTS .201->.10 in the three importers + test + the on-box
working copy; updated live-state docs; flipped D-120 to ADOPTED with an
Execution record; resolved the re-IP runbook's PENDING markers to the
measured method. Gauntlet ALL GREEN (58); repo-lint 0 fail / 1 legacy WARN.
Still owed: D-120 Step 6 (register the /24 child ranges + service IPs in
the sandbox, feed upstream) rides with C2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpGaiX6UvhSReDwN2ywstJ
|

D-120 PROPOSED: static-services addressing for compose /24s; re-IP plan drafted
...
Office1's persistent services (office1-netbox .201, office1-tailscale .202) sit in
MAAS's deployed-node band -- addressed like ephemeral compute nodes, by MAAS
auto-assign default, not by design. They are NOT in the DHCP dynamic range
(.100-.200), so no collision -- but no static-services sub-layout was ever ratified
for the /24 (D-115 left it "MAAS DHCP/PXE").
D-120 (PROPOSED) defines a functional band layout for EVERY MAAS-managed compose /24
(Office1 + the DCs, which inherit it):
.1 gateway (site containment VM)
.2-.49 STATIC site services (MAAS static-assign, outside the dynamic range)
.50-.99 reserved/spare
.100-.200 MAAS dynamic (unchanged)
.201-.254 deployed nodes (unchanged)
Proposed Office1 addresses: netbox .201->.10, tailscale .202->.11. Recommends
option (a): adopt + re-IP Office1 now, so the DC template is clean. Addresses AWAIT
operator ratification.
Re-IP plan: runbooks/dc-dc-office1-service-reip.md -- gated mutation on two running
services, honest PENDING VERIFICATION markers for the MAAS static-assign CLI and the
netbox-docker ALLOWED_HOSTS / tailscale re-assert (cannot confirm without the
operator-held box keys). Cascades to 20 repo refs incl. SANDBOX_HOSTS .201 in the
two pynetbox importers + their tests. NOT executed.
The layout belongs IN NetBox (the IPAM authority deciding where NetBox lives) --
recorded via the sandbox loop, not prose.
repo-lint 0 fail. No live services touched -- this is a proposal + plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Consolidate credential/env sprawl into ~/vr1-office1-creds/ (SEC-009); seed memory
...
Operator-approved. Three env files were loose in ~ outside the consolidated creds
folder -- .vr1-netbox.env, vr1-office1.env, and vr1-stage1.env. The last was mode
0664 (world-readable) and held TF_VAR_maas_api_key: a real MAAS-secret exposure,
not just untidiness. None created this session (mtimes 07-10..12).
Done (files outside the repo; recorded in the changelog):
- moved all three into ~/vr1-office1-creds/; chmod 600 on every sensitive file
(fixed the two 0664 files: vr1-stage1.env and tailscale-authkey.txt).
- no CONTENTS read -- key NAMES only, to identify each file's purpose.
- fixed a D-119 follow-up the in-repo sweep could not reach: vr1-stage1.env had
TF_VAR_dc1_pool_path/dc2 -> renamed to TF_VAR_vr1_dc0/vr1_dc1_pool_path.
Repo (committed):
- active path refs old ~/.vr1-netbox.env -> ~/vr1-office1-creds/vr1-netbox.env in
prod-draft-dump.py, the phase1 runbook Step 10b, as-built, and the ledger. Dated
changelog/incident records left as historical snapshots.
- as-built secrets table: the three env files + the convention. CORRECTED a
mid-session wrong assumption -- the SANDBOX token is NOT on vcloud; it is on
office1-netbox (/root/netbox-secrets/). vr1-netbox.env is the UPSTREAM token only.
- security-ledger SEC-009 (REMEDIATED) + the standing per-site consolidation convention.
- seeded agent memory (was EMPTY): the creds convention + the closed-test posture,
so neither is re-lost at compaction.
STANDING CONVENTION: per-site ~/<site>-creds/ (0700; files 0600); no loose env in ~;
~/vr1-dc0-creds/ and ~/vr1-dc1-creds/ follow the pattern from day one. Data-loss /
continuity control first; real cred hardening deferred to post-teardown (closed test).
GAUNTLET ALL GREEN (58). repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| 2026-07-14 |

C4 DONE: document the NetBox sandbox loop; add the upstream-write guard it exposed
...
C4 (Stage 2 close-out) -- the sandbox loop is now documented in two places, no
rationale duplicated: docs/dc-dc-netbox-buildout-scope.md section 8 (design: why a
sandbox, the fidelity check + its 2026-07-14 hardening, the write-guard table, the
WAF trap, C2 preconditions) and runbooks/dc-dc-phase1-office1-standup.md Step 10b
(the dump->seed->apply->verify->gated-write sequence, two-host/two-token separation).
Drafted by a subagent, corrected against this session's own fixes before placement.
The guard C4 exposed: roles-aggregates-import.py and dc-dc-prefixes-import.py had NO
hostname gate -- until today the WAF 403 was the only thing stopping an accidental
--commit to the apex, and the UA fix removed that accidental safety. Both now gate a
non-sandbox --commit behind --yes-write-upstream (SANDBOX_HOSTS allowlist), matching
d115-office-carve.py -- so all three write-capable importers behave identically. This
is defence-in-depth with the whole-plan preflights (which prevent a half-write).
Tests: prefixes-import 86->91 (refused without the flag + writes nothing; allowed
with it; sandbox host needs no flag). roles-aggregates 20->24. GAUNTLET ALL GREEN (58).
Stage 2: C1/C3/C4 DONE. C2 (production write) is the only item left -- gated, with
preconditions now documented (re-verify sandbox under the hardened check, run from
office1-netbox, --yes-write-upstream, approve each mutation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Fix the WAF User-Agent gap: the two pynetbox importers could never write upstream
...
A C2 blocker, measured 2026-07-14: upstream netbox.baldurkeep.com 403s the default
Python User-Agent (references/platform-traps.md) -- curl->200, urllib/pynetbox->403.
The stdlib tools (prod-draft-dump/sandbox-seed/d115-office-carve) already send an
accepted UA, but roles-aggregates-import.py and dc-dc-prefixes-import.py built
pynetbox.api() with the DEFAULT UA and never overrode it. So they would 403 against
upstream -- they could only ever have written to the WAF-less sandbox, never to the
apex C2 must feed. The gap was invisible because every real run so far hit the sandbox.
Fix: get_nb() sets nb.http_session.headers["User-Agent"] = "curl/8.5.0" in both
(same value/rationale as the stdlib tools; pynetbox exposes its requests.Session).
Tests: prefixes-import 85->86 (the fake now has .http_session; the UA is asserted at
RUNTIME, not by source grep). roles-aggregates 19->20. GAUNTLET ALL GREEN (58).
Still open for C2 (logged in the changelog): pynetbox is NOT on vcloud, so the two
importers must run on office1-netbox (has pynetbox 7.0.0); the sandbox re-verify
needs the operator-held token; the upstream write stays operator-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
D-119 OpenTofu rename APPLIED -- repo/state back in sync (operator-gated)
...
tofu apply: 11 added, 0 changed, 11 destroyed. tofu plan now reports "No changes".
Pre-apply LIVE re-check confirmed all 11 objects empty (0 leases, 0 volumes, no
domain attached) -- verify-before-mutate, not the earlier measurement. Fresh plan
was 11 add / 11 destroy, all "must be replaced" via the moved{} blocks.
Verified on the host: six vr1-dc0-* networks, three mesh-vr1-* links, vr1-dc0-pool
/vr1-dc1-pool; ZERO old dc1-*/dc2-*/mesh-dc* names remain. Office1 (office1-local/
office1-wan/wan/edge/voffice1) was NOT in the plan and is untouched -- edge and
voffice1 both reachable post-apply.
Applied now, while the objects are empty, so Stage 3's own tofu apply does not
silently bundle this 11-resource rename with real DC provisioning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Add the missing preflight-failure test; reconcile the stale tofu-sync ledger line
...
Two follow-ups a review caught:
1. Write-path fix #3 (whole-plan role preflight in dc-dc-prefixes-import.py) shipped
with NO test exercising it FAILING. Every existing test preseeds all roles, so
they ran the preflight only when it passes -- silently. A future edit could delete
the preflight and the harness would stay green: exactly the "green while the bug is
armed" failure this session has been killing. Added a test that runs main() with one
six-plane role missing and asserts nonzero exit AND that NOTHING was written (no
site, no prefixes). prefixes-import 82 -> 85.
2. The ledger's "Live state" bullet still claimed OpenTofu repo/state were IN SYNC
("tofu plan reports No changes"). False since D-119 -- plan is 11 to add / 11 to
destroy. Reconciled to state the gated destroying apply, contradicting nothing.
GAUNTLET ALL GREEN (58). repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Ledger: write-path bugs 1-3 fixed; flag the sandbox re-verify owed before C2
...
The sandbox was declared "proven faithful" using the fidelity check BEFORE it was
hardened -- i.e. under the version that could false-green. That verdict must be
re-established under the hardened check before C2 feeds the carve upstream. Needs
the operator-held sandbox token; folds into the C2 step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

NetBox write-path hardening: the bugs that would have corrupted the apex at C2
...
Found by adversarial review during D-119; independent of the naming work. These
are what would have bitten during C2 (the production IPAM write Stage 2 needs).
1. roles-aggregates-import.py STILL died half-way through a production write. The
preflight added on 2026-07-13 -- after this same script committed 4 roles, 400'd
on the 5th and left the apex half-populated -- only covered role-NAME collisions.
The ARIN RIR lookup and validate_ula_48() both still ran BELOW the first write,
so an apex without ARIN, or a typo'd ORG_ULA_48, would commit 5 roles (and the
RIRs) and THEN die, with no rollback. The exact failure the preflight was added
to prevent, still armed one section lower. The RIR half of the preflight loop was
a literal no-op. Both checks moved INTO the preflight.
A preflight that does not cover every die() downstream of the first write is not
a preflight.
2. sandbox-fidelity-check.py could return a FALSE GREEN -- and it is the script we
have been citing as PROOF the sandbox is a faithful replica. It field-compared
only SHARED objects; the planned delta was checked merely as a SUBSET of
EXPECTED_NEW. With ZERO of the 36 DC prefixes created, extra={} -> unexpected={}
-> it printed "Nothing lost, nothing stray" and exited 0. EXPECTED_NEW was an
upper bound masquerading as an assertion. It is now BOTH bounds, and delta
prefixes are checked for scope and role (the 17-prefix scope-drop bug relocated
into the delta, where the old check was structurally blind).
3. dc-dc-prefixes-import.py died mid-loop on a missing role -- find_role() was
resolved lazily INSIDE the write loop. Against upstream as it stands TODAY (no
six-plane roles), a --commit creates the site + 4 prefixes then dies on the 5th:
a half-written datacenter. Now a whole-plan preflight resolves every role first.
4. The test fake was KINDER THAN REALITY at the failure point: fake_pynetbox.get()
returned matches[0] on a multi-match where real pynetbox RAISES. NetBox permits
duplicate prefixes (the importer's own docstring anticipates them vs vr0-dc0), so
this would blow up in production while the harness sailed through. It now raises.
TESTS: tests/sandbox-fidelity-check/ is NEW (10/10) -- the checker shipped with NO
harness at all, which is exactly why the false green survived; T3 reproduces it, and
T1 derives its expectation from the checker's own EXPECTED_NEW rather than re-typing
it. roles-aggregates-import 16->19, asserting BY POSITION that ARIN and the ULA are
validated before the first create (the old harness only checked the WORD "PREFLIGHT"
appeared -- green while the landmine was armed).
GAUNTLET ALL GREEN (58). repo-lint 0 fail. Zero NetBox writes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

D-119: region-qualify the VR1 DC namespace -- C3 / gap #19 CLOSED (Stage 3 unblocked)
...
THE BUG: the token `dc0` meant TWO DIFFERENT CLOUDS depending on the file --
VR0's LIVE testcloud in scripts/lib-net.sh, VR1's FIRST DC in the NetBox
importer. One string, two clouds, one of them in production.
THE RULING (operator): the repo adopts the apex's names verbatim.
vr0-dc0 VR0's DC0 the LIVE testcloud (a DIFFERENT region)
vr1-dc0 VR1's FIRST DC GUA 2602:f3e2:f02::/48
vr1-dc1 VR1's SECOND DC GUA 2602:f3e2:f03::/48
Bare dc0/dc1/dc2 are now REJECTED LOUDLY everywhere.
D-119 COMPLETES D-117, it does not reverse it: it executes D-117's own amendment
across the three surfaces D-117 never touched. ZERO NetBox writes -- the apex was
already correct and self-consistent (MEASURED: f02::/48 -> vr1-dc0, f03::/48 ->
vr1-dc1); the REPO was the only surface out of step. Renaming the apex to match
the repo was considered and REJECTED (production IPAM write; VR1 would become the
only 1-indexed region; and vr1-dc1 would mean VR1's FIRST DC while vr0-dc1 means
VR0's SECOND).
THE PRIZE: the importer's DC->site map is now an IDENTITY -- there is no offset
table left to get wrong, and an assert enforces it. The original defect was
precisely a WRONG LOOKUP TABLE. The bug class is deleted, not defended.
TWO REAL BUGS the naming fix ALONE would NOT have closed (found by review sweep):
- BREAK-1: descriptions were built with f"VR1 {dc.upper()} ..." -- under D-119 that
renders "VR1 VR1-DC0 provider-public" on all 36 prefixes. Same class as the
original bug (deriving a label by munging a token), hidden in the description
field where slug-focused review missed it. Now looked up from SITES[name].
- BREAK-2 (the important one): DC_GUA_PREFIX was NEVER cross-checked against --dc.
`--dc vr1-dc0 DC_GUA_PREFIX=<f03>` was ACCEPTED: it writes the SECOND DC's GUA,
carved with the FIRST DC's ULA nibble, scoped to the FIRST DC's site -- a
silently mis-bound datacenter assembled from two disagreeing sources. Identity-
mapping the slug leaves the ADDRESSING free-floating. Now guarded by EXPECTED_GUA.
ALSO: vdc1/vdc2 -> vvr1-dc0/vvr1-dc1 (D-114 amendment). D-114's own DR primitive is
`virsh destroy vdc1`, which read as "destroy VR1 DC1" while MEANING "destroy VR1
DC0". A mislabelled destroy command in a DR drill is not cosmetic. Neither VM is
built, so it is free. voffice1/Office1 KEEP their number (operator ruling).
Env var: DC1_/DC2_V4_SUPERNET -> VR1_DC1_V4_SUPERNET (both old names rejected).
rbd-mirror's --site-name aligned: `--dc vr1-dc0 --site-name dc1` was a re-created
two-namespace collision on one command line.
TESTS: dc-selector 21->30, prefixes-import 40->82 (identity invariant; EXPECTED_GUA
in BOTH mismatch directions; mismatched run writes NOTHING; matching pair still
succeeds; no munged description). GAUNTLET ALL GREEN (57). repo-lint 0 fail.
The old harnesses PINNED THE WRONG MAPPING -- they would have gone green while
enforcing the bug.
THE tofu apply IS NOT DONE -- IT IS GATED. Plan: 11 to add, 0 to change, 11 to
destroy (libvirt object names are ForceNew; moved{} makes the plan reviewable but
cannot suppress a replace). MEASURED SAFE: all 11 are EMPTY -- no guests, no
volumes, Stage 3 hasn't run. Office1's live objects are not in the plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

C1 follow-ups: verify the RDNSS we never set; correct a revert line that did not revert
...
Two defects in the C1 changelog, both found on review:
1. The RA advertises `RDNSS 2602:f3e2:f01 :1` -- a field we never set; OPNsense
auto-defaulted it to the edge's own LAN v6 address. Same failure class we avoided
with mode=unmanaged (advertising a service that may not exist), one field over,
and it slipped in BECAUSE the field was left unset. CHECKED: the edge's Unbound
really does answer DNS on that v6 address (dig from voffice1 resolves; it works
with no v6 egress because Unbound queries upstream over v4). Correct -- but by
luck, not design. Set RDNSS explicitly when this RA config is reused on the
DC0/DC1 edges at Stage 3.
Bonus: this makes "services binding v6" EVIDENCE rather than inference -- a real
v6-bound service answering real queries.
2. The address-revert line said `--commit 10.10.0.1 lan :: 64`, which does NOT clear
the field -- it would SET the unspecified address. The script cannot express a
clear at all (4 args required, address validated). Shipping an unverified revert
is exactly what the delivery rule exists to prevent. Replaced with the honest
GUI clear + a note that a --clear flag would be the real fix.
repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Office1 LAN IPv6 is LIVE and proven on the wire -- Stage 2 close-out C1 CLOSED
...
The last build item on Office1. Executed against the live edge, verified against
the kernel and the wire, not by {"result":"saved"}.
scripts/opnsense-set-interface-v6.sh --commit 10.10.0.1 lan 2602:f3e2:f01 :1 64
POST /api/radvd/settings/addEntry {"entries":{...,"mode":"unmanaged"}}
POST /api/radvd/service/reconfigure
PROVEN: address on the kernel (ifconfig, not the config file); radvd advertising
2602:f3e2:f01 :/64 AdvAutonomous/AdvOnLink with no M/O flags; voffice1
autoconfigured ...:5054:ff:fe6a:87e5 by SLAAC + an RA-learned route; edge->voffice1
GUA ping 0.0% loss. The neighbour MAC matches voffice1's known Kea lease hwaddr.
No collateral damage: v4 10.10.0.1 intact, Kea DHCPv4 still serving udp/67, the
compose-net route still good.
mode=unmanaged is DELIBERATELY not the model's default. `stateless` sets the RA
Other-config flag, pointing clients at a DHCPv6 server -- and Kea DHCPv6 is
enabled=0 with no daemon running. The default would have advertised a server that
does not exist. Field names, the request key (addBase('entries','entries')), the
mode values and the apply endpoint were all READ OFF THE EDGE, not recalled.
OPERATOR RULING -- the v6 NAT was NOT built. A v4-to-v6 NAT (a second OPNsense, or
nginx) was proposed to simulate the v6 the lab will not forward. Withdrawn: it
contradicts D-101 ("native GUA routing, no NAT66"), D-115's amendment says the
missing egress is the upstream's problem and not ours, and a translation box is
precisely the component Roosevelt will NOT have -- maximum delta for zero proof.
nginx is an L7 proxy, not NAT, and proves nothing about RA/ICMPv6/Ceph-over-v6.
Ruling: configure IPv6 internally now; configure the IPv6 edge AFTER deployment.
TRAP recorded: voffice1 has forwarding=1 + accept_ra=0 and STILL autoconfigured --
netplan/systemd-networkd processes RA in USERSPACE, independent of the sysctl.
WAN v6 leg stays DEFERRED: re-measured today, v6 internet is 100% loss from vcloud
while v4 is fine. Internet GUA reachability remains UNPROVABLE in VR1.
repo-lint 0 fail; opnsense-set-interface-v6 harness 40/40.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Consolidate to one session; close last night's two delivery gaps
...
Work had fragmented across FOUR concurrent Claude sessions on this one clone,
including two --permission-mode auto background agents (idle 14-15h, still
holding auto-approve rights against the live cloud) and a second interactive
session that got the same "resume last night's work" prompt six minutes before
this one and started bootstrapping in parallel. All terminated; the supervising
daemon stopped (it had respawned the agents once -- killing the child is not
enough). Working tree was clean and 3-ahead throughout; nothing lost.
Last night's three commits were made by the auto-permission agent and left
UNPUSHED and UNDOCUMENTED. Both closed:
- Pushed 6ed6b9a..522ba7d (the D-113 amendment + the v6 script existed only on
local disk; a jumphost rebuild would have lost them).
- changelog-20260714-opnsense-set-interface-v6.md -- the delivery rule wants
harness + lint + changelog-with-revert; the script shipped with the first two.
Records that it is WRITTEN AND TESTED but NEVER RUN with --commit (C1 is open).
Ledger:
- Machine block re-seeded: next-free D was STALE at 114 (actually 119; D-114..118
are all ADOPTED), and SEC-008 was missing.
- Logged a ledger-scan DEFECT: it reports D-115 as PROPOSED/OPEN, but D-115 is
ADOPTED -- the scan matches "PROPOSED/OPEN" in body prose instead of reading the
Status line. The status twin of the decoy-token rule. Logged, not fixed.
- CORRECTED pinned item 1a, which still instructed the REST API for the LAN IPv6.
That path does not exist (D-113 amendment, measured). It would have sent the
next session at a dead end.
repo-lint 0 fail; opnsense-set-interface-v6 harness 40/40.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

opnsense-set-interface-v6: script the LAN IPv6 the REST API cannot set (D-113 amendment)
...
Operator ruling 2026-07-14: take the PHP-model-over-SSH path, not a GUI click and
not the unproven VIP path. Rationale: the DC0 and DC1 edges get the same treatment
at Stage 3, so this must be a script, not a manual step paid three times.
WHY THIS EXISTS: measured on the live Office1 edge (OPNsense 26.1) -- there is NO
REST endpoint for a base interface's ipaddrv6. Interfaces > [LAN] is served by the
LEGACY page /interfaces.php?if=lan; only the Devices and Neighbors sub-trees were
ever migrated to MVC. D-113(a2)'s claim that "interfaces" are API-covered was
inferred from DHCP/firewall being API-native, never measured, and is false.
WHY IT IS NOT THE FORBIDDEN config.xml PUSH -- the distinction is the whole point:
it never AUTHORS a config. It ships opnsense-set-iface-v6.php, which loads the
edge's LIVE config through OPNsense's own Config singleton, mutates exactly two
leaf fields (ipaddrv6, subnetv6), and lets the vendor's own code serialize it --
byte-for-byte the code path interfaces.php runs on Save. Nothing is replaced
wholesale, so the API-managed Kea DHCP and the 8 firewall rules cannot be
clobbered. Same principle as opnsense-bootstrap-apikey.sh, which mints a key by
calling the vendor's own model rather than re-implementing its $6$ crypt format:
call the interface, do not re-implement the internals.
Safety properties, all pinned by the harness (40/40):
- DRY BY DEFAULT in both halves; --commit required to write.
- The interface COUNT is asserted unchanged before saving AND re-asserted on a
fresh forceReload() read-back. A silent structural drop on a live router fails
loud rather than being found later by a ping that stopped.
- Only ipaddrv6/subnetv6 may be assigned -- the harness counts the assignments.
- Values validated locally BEFORE they ever reach the router.
- Remote commands go through `sh -s`: root's shell on the edge is TCSH, where a
$(...) in a quoted remote command dies QUIETLY.
- Ground truth is the KERNEL (ifconfig), never the config file or a self-report.
NOT YET RUN against the edge -- that is the next, individually-gated step.
repo-lint 0 fail; gauntlet ALL GREEN (57 harnesses, +1).
Revert: git revert HEAD (removes both scripts + the harness; the edge is untouched)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

D-113 AMENDMENT: "interfaces: the REST API" is FALSE -- base-interface addressing has no API
...
Measured on the live Office1 edge (OPNsense 26.1), while starting the D-115 q3
IPv6 deployment. D-113(a2)'s rationale asserts "everything else -- DHCP,
firewall, interfaces: the REST API." The interfaces half was never measured; it
was inferred from DHCP and firewall being API-native, and it is wrong.
Interfaces > [LAN] is served by /interfaces.php?if=lan -- a LEGACY PHP page.
Only the Devices and Neighbors sub-trees were migrated to MVC. There is no API
call that sets LAN's ipaddrv6. Measured: LAN vtnet0 currently has 10.10.0.1/24
and NO IPv6 at all.
What IS API-native (measured, not assumed): radvd (/api/radvd/settings/*,
currently 0 entries), Kea DHCPv6 (/api/kea/*), and Virtual IPs
(/api/interfaces/vip_settings/*, modes ipalias|carp|proxyarp on wan|lan|lo0).
This does NOT reopen (a2). The prohibition D-113 adopted is on hand-authored
config.xml and full rendered pushes -- the clobber path. D-113 separately
records "GUI access is a REQUIREMENT" as the constraint that eliminated VyOS and
plain-Linux, so a one-time operator GUI edit is the vendor interface the
decision PRESERVED, not a violation of it.
It DOES mean the chain is not fully API-scriptable for any edge needing IPv6 --
Office1 now, DC0/DC1 at Stage 3. Logged the open experiment: the radvd model has
NO prefix field (it derives the prefix from the interface's configured IPv6), so
whether an ipalias VIP satisfies that generator is UNKNOWN and is NOT assumed.
Doc only. No cloud state touched -- the audit was entirely read-only (GET).
repo-lint: 0 fail (1 documented legacy warn).
Revert: git revert HEAD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

Pin Stage 2 as the branch merge point; reconcile the Stage 2 tracker; log the OpenTofu naming collision (gap #19)
...
Operator ruling 2026-07-14: branch dc-dc-stage1-phase0-first-exec merges to
main at STAGE 2 CLOSE, not at session end. Recorded in the ledger's PINNED
section (a ruling, not an open question) and in the workflow doc's Stage 2
section, which now carries the C1..C5 close-out checklist that defines what
"closed" means. This is NEW policy: main has never taken a feature merge, so
all 52 commits of VR1 work live only on the branch.
The Stage 2 status block was STALE -- it still listed MAAS-region, LXD, the
LXD-KVM-host registration and both composed service machines as NOT DONE. All
of that landed 2026-07-13, and the route enable + the D-115 carve import have
landed since. Corrected against measured state; the doc has now drifted three
times and says so. Stage 2 is in CLOSE-OUT, not build.
GAP #19 (new, BLOCKING Stage 3): "dc1" now names two different datacenters.
D-117 moved the repo to the apex's 0-indexed convention on the IMPORT PATH
ONLY. opentofu/main.tf still says dc1_planes/dc2_storage for VR1's FIRST and
SECOND DCs, and lib-net.sh's dc0 means VR0's DC0 -- a different region, and a
RUNNING cloud. So the next DC we deploy is simultaneously apex vr1-dc0,
OpenTofu dc1_*, and a $DC value that collides with the live testcloud. A
mechanical sed would hand VR1's first DC VR0's deployed literals. Nothing in
opentofu/ gains a new DC-keyed module until this is closed.
Docs only -- no script, no tooling, no cloud state touched.
repo-lint: 0 fail (1 documented legacy warn).
Revert: git revert HEAD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

D-118 ADOPTED (org ULA fd50:840e:74e2::/48); VR1 DC0 + DC1 IMPORTED; roles importer bug fixed
...
D-118: ORG_ULA_48 = fd50:840e:74e2::/48. RFC 4193 valid (fd -> L=1, 40-bit Global
ID 0x50840e74e2, /48 -> 256 x /56 for D-101's per-DC /56). The load-bearing check
is NOT the RFC conformance -- it is that it does NOT collide with Tailscale's
fd7a:115c:a1e0::/48: office1-tailscale runs --accept-routes, so an overlap would
have been a LIVE tailnet routing conflict, not a paper one.
Provenance stated honestly: the 'CSPRNG-generated' claim cannot be re-verified.
It does not change the ruling (RFC 4193's SHA1 construction is a SHOULD; the
requirement is a pseudo-random 40-bit Global ID). Regenerating would only swap in
a different random number at the cost of re-editing 7 places.
Corrects a FALSE claim: changelog-20260711 called this value 'ratified' when no
D-number ever assigned it. That phantom ratification is why it read as settled for
two days. Same failure mode as D-117: a document asserting authority it lacked.
IMPORTED into the sandbox: six-plane roles + 2 RIRs + 5 aggregates, then VR1 DC0
(18 prefixes) and VR1 DC1 (18). The D-117 fix is now LIVE-PROVEN, not just green
offline: --dc dc0 -> apex site VR1 DC0 (id=8), --dc dc1 -> VR1 DC1 (id=9). ULA
reads DC.NN in the 4th hextet per D-111 (fd50:840e:74e2:220:: .. :350::).
A REAL BUG, found by running roles-aggregates-import.py for the first time for
real: it checked existence by SLUG only, but NetBox also enforces UNIQUE NAMES.
The draft's legacy role 'Replication' (slug repl) blocks the six-plane role (slug
replication, name Replication) -- so it created 4 roles, 400'd on the 5th, and
DIED, leaving the apex HALF-POPULATED with no RIRs, no aggregates, no rollback.
It had never met a NetBox holding the real draft (only empty ones) and it SHIPPED
WITH NO HARNESS. That is why it survived.
- six-plane role renamed 'Replication Plane' (slug unchanged -- lib-net.sh
SPACES6 and the prefix importer look up 'replication')
- the script now PREFLIGHTS every name/slug and writes NOTHING unless the whole
plan is viable. An IPAM importer that can die partway corrupts what it
populates.
- tests/roles-aggregates-import/ CREATED: 16/16 PASS.
Final state PROVEN by sandbox-fidelity-check (now carrying the full planned
delta): the sandbox is the upstream draft + EXACTLY D-115 + D-117 + D-118 --
every shared object field-identical, nothing lost, nothing stray. 90 -> 134
prefixes.
repo-lint 0 fail. run-tests-all: ALL GREEN (56 harnesses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

Prove the sandbox is a FAITHFUL replica, not just a count-matching one
...
netbox/sandbox-fidelity-check.py (+4 assertions in tests/sandbox-seed, 22/22 PASS)
Counts and idempotency CANNOT prove a faithful seed. The region-scope bug proved
it: 17 of 90 prefixes silently lost their scope, every count still matched, and
re-runs were still perfectly idempotent. It was caught by LUCK on a spot-check
that happened to print 'site None'. This is that catch, made deliberate.
Method needs no new plumbing: prod-draft-dump.py is READ-ONLY, so point it at the
sandbox and diff the two JSON dumps FIELD BY FIELD.
RESULT: every shared object is field-level IDENTICAL to upstream, and the delta is
EXACTLY the expected D-115 additions (+edge role, +vr1-off1 site, +8 prefixes).
Nothing missing, nothing unexpected. The sandbox is a PROVEN baseline -- which is
what the later feed-back diff to production depends on.
Also de-misdirects docs/dc-dc-netbox-buildout-scope.md, which still told the next
person to run '--dc dc2' with DC2_V4_SUPERNET -- both of which the new guards now
REJECT by name. Its G5 recommendation (rename the APEX to vr1-dc1/vr1-dc2) is
marked SUPERSEDED: D-117 ruled the opposite. ORG_ULA_48 is now listed there as
UNASSIGNED and BLOCKING, not as a settled literal.
Disclosure: D-117 Option B is PARTIALLY executed -- the import path and the scope
doc are done; the $DC shell selector and the runbook DC prose are NOT. Recorded
in the ledger so 'D-117 ADOPTED' is not misread as 'fully renamed'.
repo-lint 0 fail. run-tests-all: ALL GREEN (55 harnesses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

D-115 office carve IMPORTED into the Office1 NetBox; DC import blocked on ORG_ULA_48
...
netbox/d115-office-carve.py (+ tests/d115-office-carve/ 20/20 PASS)
Applied to the Office1 sandbox: site vr1-off1, roles office + edge (edge is NEW),
and 8 prefixes. Idempotent (re-run: 0 created / 11 present).
10.10.0.0/16 office container the Office v4 block, /22 per office
10.10.0.0/22 office container VR1 Off1's /22
10.10.0.0/24 office active office1-local LAN (Kea on the edge)
10.10.1.0/24 office active lxdbr0 compose net (MAAS DHCP)
172.30.0.0/16 edge container NEW Edge role; mirrors v6 2602:f3e2:fe::/48
172.30.1.0/24 edge active office1-wan, the simulated ISP uplink
2602:f3e2:f01 :/56 office container mirrors VR0 Off0's e01 :/56
2602:f3e2:f01 :/64 office active office subnet (NOT deployed; v6 has no egress)
Every address was already RUNNING. D-115 blesses what is deployed -- zero
renumber. What it fixes is that three of them were SQUAT: on the wire with no
allocation behind them.
Writing upstream is GATED IN CODE: the tool refuses a non-sandbox target unless
given --yes-write-upstream. Feeding the production apex is an operator decision,
never a side effect of running an import.
BLOCKED, deliberately: dc-dc-prefixes-import.py --dc dc0 CANNOT run. It needs
ORG_ULA_48, which is still UNASSIGNED (gap G3; the scope doc recommends
fd50:840e:74e2::/48 but no ADOPTED decision assigns it -- grep ORG_ULA
design-decisions.md returns nothing). Using it anyway would be an inferred value
(hard rule 2). This is a DECISION gate, not a tooling gap: the tooling is fixed,
tested and ready. Recorded in the ledger with the exact commands to unblock.
Also: docs/vr1-office1-as-built.md gains the IPAM table, and records that the
Tailscale 10.10.0.0/22 route is now ENABLED (edge GUI reachable from the
workstation with no tunnel), superseding the tunnel instructions.
repo-lint 0 fail. run-tests-all: ALL GREEN (55 harnesses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

NetBox sandbox seeded from the upstream draft (129 objects) + the two-phase tooling
...
The Office1 sandbox NetBox was EMPTY, and no seeder existed. The documented loop
(upstream draft -> sandbox -> simulate -> feed back) had no first step.
netbox/prod-draft-dump.py READ-ONLY dump of the upstream draft -> JSON artifact
netbox/sandbox-seed.py applies that JSON to a SANDBOX. Dry by default.
netbox/draft/vr1-draft.json the reviewable artifact (90 prefixes, 23 roles, ...)
TWO PHASES, deliberately, rather than one source->dest script:
- the upstream token never travels (dump runs on vcloud; seed runs on
office1-netbox, where the sandbox token already lives)
- 'the sim NEVER writes upstream' becomes STRUCTURAL: the script that writes
has no upstream credential and no upstream URL, and REFUSES to write to the
instance the draft was read from (self-configuring via the draft's _source,
so it keeps working if upstream ever moves -- a hostname denylist would not)
- the JSON is diffable: you can read what is about to be written
Applied to the sandbox: 129 created, then verified idempotent (0 created / 129
present on re-run). vr1-dc0 -> f02::/48 and vr1-dc1 -> f03::/48, matching the
apex exactly, which is what D-117 exists to guarantee.
TWO BUGS FOUND AND FIXED WHILE BUILDING IT:
1. The dry run mapped only dcim.site scopes, so REGION-scoped prefixes lost
their scope -- 17 of 90 upstream, including the ENTIRE VR1 region-scoped
draft. Caught by a spot-check printing 'site None'. Fixed + reseeded
(--update), now bound to region VR1.
2. The upstream-refusal guard sat AFTER json.load() and after the banner: it
was dead code for any malformed draft and READ as safe. Guards go first.
The harness now proves it actually refuses.
tests/sandbox-seed/: 18/18 PASS. repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

D-117 ADOPTED (B, DC-only): fix the off-by-one site binding + make the importer dry-by-default
...
Operator ruling: rename the DC numbers to match the NetBox apex; the office
keeps its number (Office1 -> new site vr1-off1, so nothing deployed changes).
netbox/dc-dc-prefixes-import.py:
--dc dc1|dc2 -> --dc dc0|dc1, bound to the APEX slugs measured live:
dc0 -> vr1-dc0 (2602:f3e2:f02::/48) dc1 -> vr1-dc1 (f03::/48)
It previously mapped dc1 -> vr1-dc1 while treating f02::/48 as dc1, i.e. it
would have written VR1 DC0's prefixes to the OTHER DC's site.
Now DRY BY DEFAULT with --commit (it used to write with no flag at all).
A dry run against a FRESH NetBox now plans the site too (_PlannedSite) rather
than bailing with 'no site id' -- previewing an empty apex is the whole point.
DC2_V4_SUPERNET is REJECTED BY NAME (retired) rather than silently ignored.
Every 'DCn' in prose is region-qualified: 'DC0' meant VR0's rehearsal DC in
some sentences and VR1's first DC in others. No sed could have caught that.
tests/dc-dc-prefixes-import/: 73 checks ALL PASS, incl. regression guards that
the dry run creates NOTHING, that --dc dc0 never touches vr1-dc1, and that the
invented vr1-dc2 site is never created.
FOUND, RECORDED, NOT YET FIXED (D-117 amendment): scripts/lib-net.sh carries a
SECOND, colliding $DC namespace where dc0 already means VR0's DC0. Renaming
VR1's dc1->dc0 there would collide with VR0. Proposed fix: region-qualify the
shell selector to the apex slugs (vr0-dc0/vr1-dc0/vr1-dc1). Does not block the
import; the two selectors are NOT interchangeable meanwhile.
repo-lint 0 fail. run-tests-all: ALL GREEN (53 harnesses).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|

D-117 (PROPOSED): VR1 site naming collision blocks the NetBox import
...
Measured live against the apex: prod NetBox binds 2602:f3e2:f02::/48 -> site
VR1 DC0, but dc-dc-prefixes-import.py maps --dc dc1 -> slug vr1-dc1 and treats
f02::/48 as dc1. The addressing agrees; the labels are off by one. Because that
importer has NO --commit (it WRITES BY DEFAULT), running it as documented against
a prod-seeded NetBox would silently bind DC1's prefixes to the other DC's site.
This is the known G5 gap, promoted to a decision because it blocks the import.
Options A/B/C recorded; A (rename the two skeletal apex sites) recommended, with
the slug-collision rename ORDER called out as a trap.
Also records the upstream-NetBox User-Agent WAF trap in platform-traps.md: the
same token that works from curl gets 403 from urllib/requests/pynetbox. It will
bite pynetbox and looks exactly like an auth failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZBxzAbmqwLW7jEmMG23Xw
|
| 2026-07-13 |

D-116 ADOPTED: no Office1-local GitBucket -- keep using git.baldurkeep.com
...
Operator ruling: "we've decided not to build a separate one on the test cloud. We will continue to
use the current setup and create new repos for deployment tasks as needed."
SUPERSEDES the GitBucket half of D-103 (which gave Office1 three service VMs) and removes GitBucket
from Stage 2's build list entirely. Office1's MAAS-composed service machines are now exactly TWO --
office1-netbox and office1-tailscale -- and both are LIVE.
WHAT THIS ACTUALLY CLOSES, and why it is a simplification rather than a deferral: the Stage 2 runbook
carried an OPEN QUESTION about the Office1-local GitBucket -- "what it mirrors, and under what repo
path, is not decided" -- because a SECOND GitBucket alongside the pre-existing git.baldurkeep.com
raised a real authority/mirroring question nobody had answered. That question is now MOOT. There is
one git service, it is the existing one, and it is authoritative.
Recorded explicitly in D-116 so nobody misreads the scope: D-107's per-DC ARTIFACT mirror (OS/package
artifacts for node provisioning) is a SEPARATE concern and is NOT affected. And if Roosevelt later
wants a site-local git service, that is a fresh decision against real requirements -- not something
this ruling forecloses.
Applied everywhere it was an instruction, not just recorded:
- runbooks/dc-dc-phase1-office1-standup.md: Step 11 replaced with a TOMBSTONE (kept rather than
deleted -- step numbering is referenced elsewhere, and someone WILL come looking after reading
D-103); removed from the sequence, the compose steps, and the open-questions list.
- docs/dc-dc-deployment-workflow.md: dropped from Stage 2's Build and, importantly, from its GATE --
"GitBucket serving" is no longer a gate condition.
- docs/design-decisions.md: D-103 annotated in place with the supersession, so grepping for GitBucket
lands on the amendment rather than the stale instruction.
- docs/vr1-office1-as-built.md + session ledger updated.
repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Tailscale: office1-tailscale joined the SELF-HOSTED tailnet as the Office1 subnet router
...
Operator's control plane is self-hosted (https://tailscale.baldurkeep.com:443), NOT Tailscale's
public one -- so the public login-URL flow from the previous commit was void. Node reset off
controlplane.tailscale.com and re-enrolled against the operator's server with their auth key.
REACHABILITY PROVEN FIRST, not assumed: DNS + TCP 443 + HTTPS to tailscale.baldurkeep.com confirmed
from the node BEFORE running `tailscale up`, so a failure would have been a network fact rather than
a confusing registration error.
AS BROUGHT UP: --login-server=https://tailscale.baldurkeep.com:443 --authkey=<from 0600 file>
--advertise-routes=10.10.0.0/22 --accept-routes --accept-dns=false --hostname=office1-tailscale
Online as 100.64.0.53 (fd7a:115c:a1e0::35), no health warnings, advertising 10.10.0.0/22 -- exactly
the D-115 Office1 carve, so D-107's "Office1 ONLY" front door is enforced by the CARVE itself rather
than by policy alone.
ONE DELIBERATE DEVIATION from the operator's usual command: --accept-dns=false. This is a
MAAS-managed machine and MAAS's bind9 serves its internal names; letting the tailnet take over DNS
would break that resolution. Flagged rather than silently applied -- trivial to flip if MagicDNS is
wanted here.
OPERATOR ACTION STILL REQUIRED: the 10.10.0.0/22 route is ADVERTISED but must be ENABLED on the
control server (Headscale: `headscale routes enable`) before other tailnet nodes route to Office1.
Once enabled, the whole Office1 carve -- edge GUI, MAAS UI, NetBox -- is reachable from the tailnet
with NO SSH tunnels, superseding most of the tunnel commands in the as-built doc.
SECRET HANDLING: the auth key was written by the operator to a 0600 file OUTSIDE this session,
consumed BY PATH, never printed into context, and the copy shipped to the node was SHREDDED after
enrolment (tailscaled holds its own node key now). Tracked as SEC-008 -- a rotation obligation, not
a delete-me, since the key remains on vcloud for re-enrolment.
docs/vr1-office1-as-built.md updated with the tailnet addresses, the exact bring-up command, the
DNS deviation, and the pending route-enable step.
repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Office1: edge route to the compose net + Tailscale subnet router + an as-built connection reference
...
THE ROUTE -- and an OUTAGE I CAUSED AND REVERTED. Adding a LAN gateway on OPNsense for voffice1
SILENTLY STOLE THE DEFAULT ROUTE: the edge's default flipped from 172.30.1.1 (ISP) to 10.10.0.20,
and the site lost egress. Root cause: OPNsense's `defaultgw` field in the API output is DERIVED
FROM `priority` (lower = more preferred), NOT from the value you send -- I sent defaultgw="0", it
came back True, and I had ALREADY run `reconfigure` before reading anything back. Detected within
~2 minutes by checking egress, reverted by deleting the gateway; default route and egress restored.
Redone SAFELY: add the gateway, READ THE CONFIG BACK, verify WAN_GW is still the default, and only
THEN apply. WAN_GW is now priority 1 (explicitly preferred); OFFICE1_LXD_GW is 255. Verified after
apply: default still 172.30.1.1, egress OK, and the edge now routes 10.10.1.0/24 -> 10.10.0.20 and
reaches NetBox. The lesson is in the as-built doc's trap list.
Also re-learned the hard way: root's shell on the edge is tcsh, so `2>&1` in my verification command
died with "Ambiguous output redirect" -- the exact trap already written down in this repo's standing
lessons. Feed remote commands to `sh -s`.
TAILSCALE: office1-tailscale composed by MAAS (2 cores / 2 GiB / 25 GB -- VR0's sizing), deployed
Ubuntu 24.04, Tailscale 1.98.8 installed, IP forwarding on, brought up as a SUBNET ROUTER advertising
10.10.0.0/22 -- exactly the D-115 Office1 carve, so "Office1 only" (D-107) is enforced by the carve
itself. Authenticated via the login-URL flow, NOT an auth key: no tailnet secret passes through the
session. PENDING OPERATOR: authorize the node, and approve the 10.10.0.0/22 route in the admin
console. The tailnet ACL/tag policy remains operator design (runbook open question 5).
NEW: docs/vr1-office1-as-built.md -- the operator-requested connection reference. Every value
MEASURED, not planned: networks + who owns DHCP on each, hosts/services/levels, how to reach each
thing from vcloud AND from a workstation, where every credential lives, and the site's standing
traps. Explicitly NOT an address authority -- NetBox is the IPAM apex; this is operational.
Also noted: MAAS's bind9 intermittently SERVFAILs a first-time external lookup and succeeds on
retry (seen twice -- docker registry, tailscale.com). Retry before diagnosing.
repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

office1-netbox DEPLOYED: NetBox 4.6.4 live on the Office1 headend, three levels deep
...
MAAS composed the machine, PXE-booted it, commissioned it, and DEPLOYED Ubuntu 24.04.4 onto it --
an LXD VM inside voffice1, inside vcloud, which is itself a KVM guest. NetBox 4.6.4 (Django 6.0.6)
runs on it and its API returns HTTP 200. The D-114 chain is complete in its final form.
FOUR FINDINGS. TWO ARE NOW GUARDED IN CODE.
1. MAAS-DISCOVERED SUBNETS HAVE NO GATEWAY (FIXED). MAAS discovers the compose subnet from lxdbr0's
interface, and that discovery carries no gateway_ip. The first deploy produced a machine that
LOOKED healthy -- it had an address and DNS even resolved (MAAS's bind9 is link-local) -- but had
NO default route and NO egress. It only surfaces when the first apt install hangs.
scripts/site-headend-install.sh now sets gateway_ip on the compose subnet; the harness asserts it
(19/19). office1-netbox was released and REDEPLOYED to prove the fix rather than hand-patching a
route onto the running box.
2. NETBOX 4.6 v2 TOKENS: THE WIRE FORMAT IS `nbt_<key>.<plaintext>`, NOT THE API's `token` FIELD.
NetBox 4.6 hashes tokens: `key` is a 12-char PREFIX, plus `plaintext` and an hmac_digest.
Authentication infers the version FROM A PREFIX (TOKEN_PREFIX = 'nbt_') and splits on '.'. So the
57-char assembled form is what authenticates. POST /api/users/tokens/provision/ returns `key` and
`token` as SEPARATE fields, and the `token` field alone is NOT usable -- present it raw and NetBox
parses it as a legacy v1 token and returns 403 "Invalid v1 token", an error that names v1 while
you are holding a v2 token. Anything scripted against this NetBox (including
netbox/dc-dc-prefixes-import.py and any pynetbox client) must ASSEMBLE the token.
Also: netbox-docker's SUPERUSER_API_TOKEN env is NOT honored on this version, and hand-building a
Token row trips the DB's enforce_version_dependent_fields constraint. Call the interface; do not
re-implement it -- the same lesson this repo already learned minting OPNsense API keys, which I
proceeded to re-learn the hard way.
3. A WRONG HYPOTHESIS, RECORDED BECAUSE THE MISTAKE IS REUSABLE. When the token kept 403-ing I
concluded that rotating SECRET_KEY after NetBox had initialised must have broken the token HMAC
pepper, and I WIPED THE DATABASE (docker compose down -v) to re-init. That was WRONG -- the 403
was purely the wire format above. The wipe was harmless because the DB was empty, but the
reasoning was not: on a populated NetBox it would have been destructive. "Invalid v1 token" is a
FORMAT error, not a crypto or key-rotation error. Read the auth code before reaching for a
destructive reset.
4. netbox-docker ships a PUBLIC default SECRET_KEY (it is in the public repo) -- session-forgery
material on any reachable instance. Rotated to a generated value. (SKIP_SUPERUSER=true is the
shipped default, so no default admin/admin exists.)
All secrets (SECRET_KEY, admin password, API token) generated ON the VM, stored 0600 under
/root/netbox-secrets/, never printed into the session.
REACHABILITY CAVEAT, not solved: NetBox is on 10.10.1.201, behind voffice1's NAT. Reachable from
voffice1, NOT from office1-local or a workstation. Making it reachable needs a static route for
10.10.1.0/24 via 10.10.0.20 on the OPNsense edge -- a live edge change, deliberately NOT done
unilaterally.
NEXT: import the D-115 carve into this NetBox.
repo-lint 0 fail. tests/site-headend-install 19/19 PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Ledger: pin Office1 LAN IPv6 as the next step after the NetBox deploy (D-115 q3)
...
Operator sequenced it: NetBox first, then dual-stack. Pinned with its known limit attached so the
next session cannot mistake internal v6 for working v6 -- IPv6 does not egress the lab (measured),
so the LAN leg proves addressing/RA/services-binding and nothing about internet GUA reachability.
The WAN v6 leg is DEFERRED: it would be a path to nowhere and untestable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

D-115 ADOPTED: v4 stays role-based; Office = /22 per office; DC2 moves INSIDE the Cloud /16
...
Operator ruled all three questions:
1. v4 stays ROLE-BASED, fill the missing roles (v6 keeps its region hierarchy; v4 is pooled by
function -- v6 is abundant, v4 is scarce). No DC renumber; D-101's inherit-DC0-unchanged
economy is preserved.
2. /22 per office. VR1 Office1 = 10.10.0.0/22 -- which BLESSES the deployed LAN (10.10.0.0/24) and
LXD compose net (10.10.1.0/24) exactly. ZERO deployment renumber.
3. Register AND deploy dual-stack on Office1 now.
THE CARVE. New v4 roles: Office (10.10.0.0/16, /22 per office) and Edge (172.30.0.0/16, mirroring
v6's Edge Networks fe::/48) so the simulated ISP uplinks stop living inside the Corp OOB block --
office1-wan's 172.30.1.0/24 becomes legitimate. DC2 moves from 10.13.0.0/19 to 10.12.64.0/19,
INSIDE the Cloud /16: free (DC1 holds 10.12.{4,8,12,16,32,36}), still routable to DC1 over the
dark fiber. docs/dc-dc-netbox-buildout-scope.md is corrected -- its 10.13.0.0/19 recommendation sat
outside every allocated block and would have had DC2 squatting too.
v6 is determined by the existing pattern, no design freedom: Office1 = 2602:f3e2:f01 :/56 with
a /64 office subnet, plus a "VR1 Off1" site. (VR0 Off0 = e01 :/56 -> /64; Eugene's Charnelton =
101 :/56. VR1's office /48 already existed and we were simply not using it.)
MEASURED CONSTRAINT, attached before anyone builds on a false premise: IPv6 DOES NOT EGRESS THE LAB.
vcloud has global v6 and a v6 default route, and the v6 gateway IS reachable (RA and L2 work) -- but
v6 INTERNET is unreachable (2606:4700:4700::1111 and 2001:4860:4860::8888 both 100% loss; v4 from the
same host is fine). So deploying dual-stack on Office1 proves v6 ADDRESSING/RA/services-binding-v6 --
most of D-101's family matrix, worth doing -- but it CANNOT prove v6 internet reachability. Public
GUA reachability (v6 public API endpoints, tenant GUA egress) is UNPROVABLE IN VR1 until the lab's
upstream carries v6. That is outside this cloud and this repo. Do not schedule a VR1 test that
depends on it, and do not read a green internal-v6 result as "v6 works end to end".
The NetBox WRITE is deliberately NOT done unilaterally: netbox.baldurkeep.com is production, the
standing architecture is read=source / write=feedback-only, and the Office1 sandbox NetBox (composed,
Ready, not yet deployed) is where this carve gets simulated first.
repo-lint 0 fail -- and its L5 collision guard did its job, catching that my amendment header read
as a second DEFINITION of D-115. Renamed to the AMENDMENT form the guard exempts rather than
weakening the rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

D-115 PROPOSED: Office1 addressing -- the office carve, and the four v4 role gaps it exposed
...
Operator challenge during deployment testing ("I thought there were corp office subnet carves in
the planning") -- and they were right. I had checked for COLLISIONS but never asked the IPAM apex
whether Office1 had an ASSIGNED carve. It does, in v6, and we were not using it.
MEASURED against the live NetBox (read-only), not the repo -- the repo's planning docs cover only
the DC planes, so grepping them would never have found this:
IPv6 is a complete region-hierarchical model. Every region gets a /40; sites are /48s on a FIXED
slot pattern: X00=infrastructure, X01=OFFICES, X02/X03=datacenters. VR1's office /48 ALREADY EXISTS
(2602:f3e2:f01::/48). Offices take one /56 each, then a /64 subnet -- VR0 Off0 = e01 :/56 ->
e01 :/64; Eugene's Charnelton = 101 :/56. So Office1's v6 has NO design freedom: it is
2602:f3e2:f01 :/56 + a /64. Office1 currently has NO IPv6 AT ALL -- D-101's dual-stack family
matrix silently skipped the office.
FOUR v4 GAPS, none previously known:
1. There is NO Office role in v4. Office1's LAN (10.10.0.0/24) and LXD compose net (10.10.1.0/24)
are squat -- colliding with nothing, blessed by nothing.
2. DC2's planned 10.13.0.0/19 is OUTSIDE the Cloud /16 (10.12.0.0/16). The scope doc recommends it;
NetBox never allocated it. DC2 was already set to squat and nobody had noticed.
3. office1-wan (172.30.1.0/24) squats INSIDE 172.16.0.0/12 "Corp OOB Private" -- and it is not OOB,
it is a simulated ISP uplink. A wrong-ROLE problem, not just an unregistered one.
4. "Edge Networks" has a v6 /48 (2602:f3e2:fe::/48) but NO v4 counterpart, so the ISP-sim WAN
segments have nowhere legitimate to live in v4.
THE TENSION TO RULE ON: v6 here is region-hierarchical, v4 is role-based. That asymmetry is
defensible -- v6 is abundant so it gets geography, v4 is scarce so it gets pooled by function.
Mirroring v6's region tree in v4 would force RENUMBERING DC1 off 10.12.x, breaking exactly the
inherit-DC0-unchanged economy D-101 bought to reuse VR0's bundle. Large cost for tidiness.
Recommended option (a): keep v4 role-based and fill the MISSING roles -- an Office /16 carved /22
per office (VR1 Office1 = 10.10.0.0/22, which BLESSES the deployed LAN + compose net exactly, zero
renumber), an Edge role (172.30.0.0/16, mirroring v6's fe::/48) so the ISP-sim WANs stop living in
the OOB block, and DC2 pulled INSIDE the Cloud /16 at 10.12.64.0/19 (free; DC1<->DC2 stay routable).
Option (b) mirrors v6 and renumbers the DCs. Option (c) ratifies the squat verbatim -- rejected.
PROPOSED, not adopted: presenting options, not picking. Nothing is blocked -- Office1 is live and
routing. But the renumber cost is LOWEST RIGHT NOW, before NetBox and GitBucket deploy onto it.
repo-lint 0 fail. No live system touched (NetBox reads were read-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Office1 headend LIVE: MAAS 3.7.2 + LXD 5.21 on voffice1. D-114 PROVEN END TO END.
...
MAAS composed, PXE-booted and COMMISSIONED its first VM inside voffice1. The model works.
vcloud (L1 -- itself a KVM guest, measured)
+- voffice1 (L2) MAAS 3.7.2 region+rack + PostgreSQL 16.14 + LXD 5.21.5
+- office1-netbox (L3) LXD VIRTUAL-MACHINE, MAAS-composed, MAAS-PXE-booted,
leased 10.10.1.100 from MAAS, status READY, 2c/4096MiB detected
THE L3 PROOF IS NOW DEFINITIVE. Earlier today we could only say nested KVM was AVAILABLE
(/dev/kvm + svm present). Now a guest has actually BOOTED, PXE'd, COMMISSIONED and reported its
hardware three levels deep. D-114's Office1 gate is CLEARED. This reproduces VR0's `lxd` +
`tailscale` pattern exactly -- and the Juju-created LXD CONTAINERS remain invisible to MAAS, as in VR0.
THE DHCP SPLIT IS STRUCTURAL, NOT CONVENTIONAL:
10.10.0.0/24 (site LAN) -> Kea on the OPNsense edge MAAS dhcp_on=False
10.10.1.0/24 (lxdbr0) -> MAAS MAAS dhcp_on=True
Separate L2s; lxdbr0 is never bridged onto the site LAN, so they cannot see each other. The
installer asserts after configuring DHCP that MAAS owns NO other subnet, and hard-fails otherwise.
NEW: scripts/site-headend-install.sh + tests/site-headend-install/ (18/18 PASS). It is the sequence
we ACTUALLY EXECUTED, codified -- not written from docs -- and reusable verbatim for DC1/DC2. Its
--check mode was run against the live voffice1 and reports every item OK.
FOUR TRAPS, ALL HIT FOR REAL TODAY, ALL NOW ENCODED AND HARNESS-ASSERTED:
1. `lxd init --auto` FAILS on a MAAS host. It brings up lxdbr0 with LXD's own DHCP+DNS, which starts
a dnsmasq; dnsmasq binds the WILDCARD 0.0.0.0:53 and MAAS's bind9 already holds :53. The error
names the BRIDGE address ("failed to create listening socket for 10.10.1.1"), which sends you
hunting the wrong thing. `ipv4.dhcp=false` + `dns.mode=none` are NOT enough -- LXD still spawns
dnsmasq. The fix is raw.dnsmasq="port=0". Verified after: dnsmasq holds ZERO :53 and ZERO :67.
2. `lxc` READS CONFIG FROM STDIN. Piped to `bash -s` over ssh, the first unredirected `lxc` call
EATS THE REST OF THE SCRIPT as YAML and everything after it silently vanishes. Every lxc call now
ends in </dev/null. Same class as the OPNsense tcsh trap.
3. LXD snap track changes are ONE-WAY -- install DIRECTLY onto 5.21/stable, never via `latest`.
MAAS 3.6/3.7 is incompatible with LXD >= 6.7. (Bonus: core.trust_password exists in 5.21, gone in
6.x -- the pin also preserves the scriptable registration path.)
4. MAAS DHCP on the compose network ONLY. --compose-cidr is REQUIRED with no default so it can never
be picked by accident.
DISCIPLINE: every MAAS/LXD flag was read from the tool's own --help ON THE BOX before use (maas init,
createadmin, vm-host compose), not trusted from docs. All secrets (DB, admin, API key, LXD trust) are
generated on the target, stored 0600 under /root/maas-secrets/, and never printed into the session.
10.10.1.0/24 IS AN UNBLESSED ALLOCATION -- chosen deliberately (DC1=10.12.x, DC2=10.13.0.0/19, tenant
pool=10.20.0.0/16 per D-016; the obvious 10.20.x would have COLLIDED with tenant space), but NetBox
has not assigned it. Register once NetBox exists -- it is one of the VMs this headend composes.
Also: the ops skill is now tuned to this exact stack (new references/platform-traps.md with a
verbatim-error -> cause index; the OPNsense section that still routed sessions into the DELETED
config-ISO path is replaced). Upstream corroboration found for two of our own findings: the libvirt
provider's release notes document that an "in-place" domain update undefines and re-defines the
domain (our measured guest-bounce), and there is an upstream issue for the AppArmor denial.
Gauntlet ALL GREEN (53 harnesses). repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

D-114 follow-through: fix node-vm's nested-virt defect NOW; voffice1 pinned to a reserved address
...
Operator ruling: fixes land as we find them -- only DC1 DEPLOYMENT is gated, not DC1 fixes.
1. modules/node-vm: UNCONDITIONAL `cpu = { mode = "host-passthrough" }`. Same defect just found in
cloudinit-vm: with no cpu block libvirt hands the guest a generic emulated CPU with NO svm. A DC
node runs nova-compute, so it MUST run KVM guests -- there is no correct value other than this,
hence no knob that could only ever be set wrong (contrast cloudinit-vm, where it IS a real per-VM
decision, and opnsense-edge, which disables svm as correct hardening for a router). Not yet
instantiated, so no live call breaks. Without it the DC nodes would have come up looking healthy
and been unable to launch a single instance -- surfacing in Stage 5 as an inscrutable scheduler error.
2. voffice1 moved off the dynamic pool onto a RESERVED address. It first took 10.10.0.100 -- the
first address of Kea's pool (.100-.199). A MAAS region controller must not hold an address that
can move. Added a Kea host reservation via the D-113(a2) REST API (a second live exercise of that
write path): 52:54:00:6a:87:e5 -> 10.10.0.20, deliberately OUTSIDE the pool. add_reservation ->
"saved", service/reconfigure -> "ok". Rebooted; voffice1 came back on 10.10.0.20, .100 released,
/dev/kvm still present. Done now because nothing depends on the address yet.
3. runbooks/dc-dc-phase1-office1-standup.md rewritten to the D-114 model (was: three sibling service
VMs + an unruled Option A/B fork -- it would have actively misdirected whoever ran it). New
sequence: voffice1 via tofu -> first contact (lease/SSH/nested-KVM) -> MAAS on voffice1 -> LXD
(5.21 LTS) -> register LXD as a MAAS VM host -> MAAS-COMPOSE NetBox/GitBucket/Tailscale. The
MAAS/LXD command lines carry explicit PENDING VERIFICATION markers rather than fabricated flags.
docs/dc-dc-deployment-workflow.md Stage 2 + gap register updated to match.
repo-lint 0 fail. opentofu-validate PASS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

D-114: voffice1 is LIVE. cloudinit-vm gains nested virt -- it was silently broken for the model
...
APPLIED, operator-approved: 5 added, 0 changed, 0 destroyed. The running OPNsense edge was NOT
touched (confirmed by plan BEFORE apply -- no in-place update, so no repeat of the 2026-07-13
guest bounce).
THE MODULE WAS SILENTLY BROKEN FOR D-114'S ENTIRE MODEL. modules/cloudinit-vm's libvirt_domain
had NO `cpu` block. With none, libvirt renders a GENERIC EMULATED CPU exposing no `svm` -- and
LXD *virtual machines* are qemu/KVM guests, so NO service VM could ever have been composed into
voffice1. D-114 would have failed at its first step with a confusing "KVM not available" that
pointed nowhere near OpenTofu. Measured proof of the masking: host is an AMD EPYC 9965; a
default-CPU guest was handed an "Opteron_G3".
Fix: `cpu { mode = "host-passthrough" }` always, plus a new `expose_nested_virt` variable with NO
default -- a real per-VM decision. true = pass `svm` through (required for D-114's containment
VMs); false = disable it, matching modules/opnsense-edge. The operator asked whether the edge's
`svm` disable was a leftover of the failed OPNsense deploy: it is NOT. It was tried as a
triple-fault fix on 2026-07-12, did NOT resolve it (the real cause was the memory unit), and was
retained on hardening grounds. A router has no business seeing nested virt. It stays disabled there.
voffice1 as built (MEASURED): Ubuntu 24.04.4 LTS, 16 vCPU / 32 GiB / 600 GiB, single NIC on
office1-local, reports the real EPYC CPU with svm on all 16 cores and /dev/kvm PRESENT, egress to
1.1.1.1 at 4.6ms THROUGH the edge. Cloud-init deliberately MINIMAL (identity + key + guest agent);
MAAS and LXD go in as separate GATED steps rather than buried in a first-boot script that either
silently works or silently does not. The SSH pubkey is read via file(var...) at plan time -- key
material never enters a command line, the repo, or an agent's context; the tfvars holding the path
stays gitignored. network_config matches the NIC by GLOB, not a guessed kernel name (an inferred
value); it came up enp1s0.
TWO GATES CLEARED:
1. KEA HAS SERVED ITS FIRST REAL DHCP LEASE. Standing open item: the daemon was proven, the
SERVICE never was (office1-local had no client). voffice1 -- the first client ever on that LAN
-- took 10.10.0.100, hwaddr 52:54:00:6a:87:e5 (matches its NIC), hostname voffice1, state
active, read back through the D-113(a2) REST API. DHCP now works end to end.
2. D-114's NESTING PROBE PASSES AT L3. Stated honestly: this proves nested KVM is AVAILABLE. The
DEFINITIVE proof is a guest actually booting inside voffice1 -- that lands with the first
composed LXD VM. Not recording L3 as fully proven until then.
FINDING, logged NOT actioned (DC1 is gated behind Office1 per D-114): modules/node-vm has the SAME
missing-cpu-block defect. DC nodes run nova-compute, which needs working KVM -- without the fix
they will come up unable to run a single instance. MUST be fixed before Stage 3.
opentofu-validate PASS, repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

D-114 ADOPTED: VR1 site containment VMs + MAAS-composed LXD VMs for the non-stack machines
...
Operator ruling. AMENDS D-103 (which gave Office1 three sibling service VMs on vcloud).
WHAT THE OPERATOR'S SCREENSHOTS PROVED -- and where I was wrong. I had grepped bundle.yaml,
found only Juju's `lxd:N` CONTAINER placements, and concluded "there is no VR0 precedent for
MAAS composing LXD VMs -- zero." FLATLY WRONG. VR0's MAAS (3.7.2) has an `lxd` machine
registered back into MAAS as an LXD KVM host (LXD 5.21.4), and `tailscale` is a VM COMPOSED
BY MAAS INSIDE IT. The bundle's Juju LXD containers and MAAS's LXD VM host are TWO DIFFERENT
LXD usages in the same cloud; reading only the bundle collapses them into one. Recorded in the
decision because the error is instructive.
The ruling:
1. Non-stack machines (NetBox, GitBucket, Tailscale) become MAAS-COMPOSED LXD VMs -- MAAS-visible,
enlisted/commissioned/deployed/powered. This is VR0's proven `lxd` + `tailscale` pattern per
site, at ~2 cores / 2 GiB each. The Juju-deployed OpenStack services stay in Juju-created LXD
CONTAINERS, invisible to MAAS, exactly as in VR0 -- per the operator, that visibility is not wanted.
2. Each site gets a containment VM (voffice1/vdc1/vdc2). The decisive rationale is the operator's:
full-facility snapshots, and simulating total geographical failure of a facility. D-108's DR
drill currently has NO honest "hard-down a DC" primitive -- `virsh destroy vdc1` IS that primitive.
The flat model cannot offer this at any price.
3. Office1 FIRST, fully up; DC1 GATED behind it.
4. LXD pinned to the 5.21 LTS track: MAAS 3.6/3.7 is INCOMPATIBLE with LXD >= 6.7 (endpoint
consolidation vs pinned pylxd 2.3.5). VR0 is on 5.21.4 -- safe by timing, now safe by decision.
KNOWN RISK: vcloud is itself a KVM guest (measured), so a containment VM puts servers one level
deeper than VR0 runs them. Office1 IS the nesting probe -- LXD VMs are qemu/KVM guests, so it
needs nested KVM at the same L3 depth the DCs would (an earlier draft of the entry said Office1
needed none; corrected in-entry). Office1 is simply the cheap place to prove it: no OpenStack,
no Ceph. DC1's gate additionally proves L4, the depth VR0 has never exercised.
Bonus: voffice1 booting on office1-local takes the FIRST REAL DHCP LEASE from Kea -- a path
proven only at the daemon level, never end to end.
repo-lint 0 fail. No live system touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Fold the AppArmor/libvirt rule into scripts/prereqs -- it gated every VR1 VM boot
...
`/var/lib/libvirt/vr1/** rwk,` in /etc/apparmor.d/local/abstractions/libvirt-qemu had
been applied BY HAND on vcloud and existed ONLY as host state. Nothing in the repo
carried it, so a rebuild or a second host walked straight back into the same wall.
Why it matters: libvirt's stock abstractions/libvirt-qemu grants qemu only the DEFAULT
pool path (/var/lib/libvirt/images). VR1 uses a custom pool parent, which is not in that
abstraction, so AppArmor blocks qemu even with perfect POSIX permissions. The domain
DEFINES fine, then fails to start with a bare qemu "Permission denied" -- and NOTHING in
the libvirt error names AppArmor. It cost a session on 2026-07-12 (DOCFIX-186), was
logged as a PREREQ candidate, and was never actioned until the ledger collapse resurfaced it.
New: scripts/prereqs/install-apparmor-libvirt.sh, wired into install-all.sh (after
install-virtualization -- it edits libvirt's own profile dir) and reported by
check-prereqs.sh.
- Idempotent and deliberately QUIET on a good host: if the rule is present it exits 0
having touched nothing -- it does NOT reload apparmor or bounce libvirtd. Those happen
only on the run that actually adds the rule.
- Writes to local/, the vendor-sanctioned override include, so it survives package upgrades.
- Pool parent is a variable (VR1_POOL_PARENT, default /var/lib/libvirt/vr1), not a literal.
- Non-AppArmor hosts report N/A and SUCCEED -- no manufactured failure on a host it
does not apply to.
Verification: tests/prereqs 32/32 PASS (was 24/24) -- the new cases drive DETECTION off a
pool parent guaranteed absent from the profile, so the negative path is deterministic on
any host: a false OK from --check IS the bug, --dry-run must plan and mutate nothing, and
the planned rule must be exactly `<parent>/** rwk,`. Full gauntlet ALL GREEN (52 harnesses),
repo-lint 0 fail. Run live on vcloud: --check reports OK and leaves the profile byte-identical,
confirming the already-satisfied path is a true no-op.
No host state was mutated by this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Ledger collapse: close the coverage gap in the carry-forward
...
The collapse commit's guards (lint, ASCII, ledger-scan, size) validated FORM, not
COVERAGE. Two ledger regions had only been keyword-grepped, not read. Read them.
Three items surfaced; each was VERIFIED against the repo rather than trusted:
- run-tests-all.sh dirtying the tree with 37 spurious mode-change diffs: NOW FIXED
(the fakebin stubs are committed 100755 -- 36 of them, measured). Not carried.
- `scripts/opnsense-apply-config.sh` (once "wanted"): SUPERSEDED, and dangerous to
build -- its snapshot/scp/install/reboot flow IS the config.xml path D-113(a2)
deleted. Recorded as "do not build this", so nobody resurrects it from the history.
- The tcsh trap it taught is REAL and carried forward: root's shell on the OPNsense
edge is tcsh, so `$(...)` in a quoted remote command dies with "Illegal variable
name" -- QUIETLY. It already cost a silently no-op'd pre-install snapshot. Always
feed remote commands to `sh -s`.
Also carried (this one matters and was nowhere in the open lists): DHCP HAS NEVER
SERVED A REAL LEASE. kea-dhcp4 is bound to udp/67 with the subnet+pool intact, but
office1-local has no client host, so the DAEMON is proven and the SERVICE is not.
The first VM on office1-local (the Stage 2 headend VMs) is the real test -- logged
as that stage's gate rather than an assumption.
Plus a measured doc-accuracy finding: SEC-007 says "D-112(c) makes SSH the only
management path", but the edge's web GUI IS listening on the LAN (10.10.0.1:443,
HTTPS 200) and is correctly firewalled off on the WAN. Not an exposure -- OPNsense
default, and D-113 kept OPNsense *because* the GUI is an operator requirement -- but
the wording is imprecise. Logged, not actioned (hard rule 1).
Guards: repo-lint 0 fail (1 documented legacy warn), 0 non-ASCII. No live system
touched; the edge probes were read-only TCP/HTTP reachability checks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Collapse the three ledger streams into one; reconcile against repo ground truth
...
The ledger carried three separately-owned parallel stream sections (main-chat,
jumphost, shared) plus ~30 append-only session narratives -- 2189 lines, several
of them contradicting each other and the repo. Collapsed to ONE stream, 222 lines,
carrying only what is still OPEN plus the facts that live nowhere else.
The narrative is NOT lost: it is in this commit's parent (f81177a) and, durably,
in the 65 docs/changelog-*.md files, design-decisions.md, and the incident reports.
RECONCILED (repo ground truth wins over any stream's self-claim; all verified):
- machine-derived block was STALE: said D=111/DOCFIX=162/BUNDLEFIX=012 and listed
only SEC-001/003/004. Re-seeded from today's scan: D=114/DOCFIX=195/BUNDLEFIX=013,
six open SEC rows (001/003/004/005/006/007).
- main-chat called backlog items 6/7/8 "not started". FALSE -- keystone-policy-drift.sh,
cloud-snapshot.sh and tests/tenant-acceptance/ all exist. Delivered by the jumphost
stream; this was the cross-stream reconcile the ledger itself had pending.
- item 3 remainder (fold vault-kv-health into cloud-assert): VERIFIED still open.
- item 5 (tenant-cluster-create.sh): VERIFIED still absent.
- R-3/D-063: ledger claimed CLOSED, handoff doc still says PROPOSED/OPEN. D-063 is
RESOLVED/CLOSED in design-decisions.md -- so the HANDOFF DOC is the stale one.
Carried forward as an open item rather than fixed here (hard rule 1).
- D-076: not this repo's to work. D-076..D-099 is a RESERVED source-project band
(D-110), so its "uncommitted worktree draft" was never here. No work lost.
- "beta cluster left at node_count=2" was STALE -- the 2026-07-08 audit found no beta
cluster and no foil1 tenant. Retired.
CARRIED FORWARD (would have been operationally lost -- the skill loads this file):
the API-managed-DHCP edge trap; "updated in-place" still BOUNCES the guest; do not
re-implement OPNsense's key crypto, call the interface; config ISO can never be read
on a nano image; guide<->skill coupling (repo-lint L8); controller model is
admin/controller; State facts; Project-completion.
NEW, measured this session: the apparmor rule /var/lib/libvirt/vr1/** GATES EVERY VR1
VM BOOT but exists only as host state -- it is NOT in scripts/prereqs/, so a rebuild
hits the identical foundational failure. Logged as an open item.
Guards: repo-lint 0 fail (1 documented legacy warn), 0 non-ASCII, ledger-scan
reconciles against the re-seeded machine block. No live system touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

APPLIED + CORRECTION: "updated in-place" does NOT mean "no restart" -- the apply bounced the edge
...
The config_seed/cdrom removal is APPLIED to the live Office1 edge. Apply complete: 0 added,
1 changed, 1 destroyed. `tofu plan` now reports "No changes" -- repo and state are back in sync,
so Stage 3 cannot trip over the divergence.
CORRECTION, recorded because the prediction was WRONG and the mistake is reusable:
I predicted the apply would NOT interrupt the guest, on the grounds that the plan said
"libvirt_domain.vm will be updated IN-PLACE". THE APPLY RESTARTED THE GUEST. Measured: uptime went
from 8:36 to 6 secs across the apply. The dmacvicar/libvirt provider redefines the domain to apply
a disk-list change and BOUNCES it -- a ~30s outage during which the edge was neither routing nor
serving DHCP.
"updated in-place" means the RESOURCE is not replaced. It says NOTHING about whether the GUEST
keeps running. Do not read it as "no interruption" -- that is exactly what I did.
STAGE 3 CONSEQUENCE (the reason this is worth a commit of its own): any future tofu apply that
touches a libvirt_domain's devices -- on a DC edge, or on a node VM -- must be assumed to BOUNCE
THE GUEST, and scheduled/gated as an outage rather than a no-op config tidy-up.
Recovery was clean and needed no intervention. Post-boot, measured: kea-dhcp4 running and bound
udp4 10.10.0.1:67 with subnet 10.10.0.0/24 + pool .100-.199 intact; WAN 172.30.1.2, LAN 10.10.0.1,
default route 172.30.1.1; egress to 1.1.1.1 at 0.0% packet loss; 8 LAN pass rules in pf; serial
console + getty alive (DOCFIX-192 holds). The cdrom is gone from the persistent domain XML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

D-113(a2) COMPLETE: DELETE the config.xml path (template, renderer, ISO builder)
...
Operator ruling ("I'll defer to your lean"): the OPNsense config.xml template is DELETED, not
reduced.
WHY DELETED RATHER THAN REDUCED: the (a2) plan was to shrink the template to a minimal bootstrap
(sshd + root key + console + a seeded API key). That proved UNNECESSARY -- every one of those is
covered without a config.xml at all:
sshd + root key -> the D-112(c) console bootstrap (proven on Office1)
an API key -> scripts/opnsense-bootstrap-apikey.sh (OPNsense's OWN model; no GUI
click, no re-implemented crypto)
DHCP/firewall/interfaces -> the REST API (proven read AND write)
So the provisioning chain has NO config.xml anywhere:
boot factory nano -> console bootstrap -> mint API key -> configure over REST
A config.xml renderer that nobody should ever run is not a safety net -- it is a loaded gun
pointed at a live router. The 2026-07-13 safety sweep is the evidence: the repo still contained
runbook steps telling an operator to render a config and push it to the edge, which by then would
have CLOBBERED live API-managed DHCP.
DELETED: opentofu/templates/opnsense-config.xml.tmpl; scripts/opnsense-render-config.sh +
tests/opnsense-render-config/; scripts/opnsense-build-config-iso.sh +
tests/opnsense-build-config-iso/; the opnsense-edge module's config_seed volume + cdrom disk and
its config_iso_path variable; the xorriso/genisoimage prereq (it existed ONLY for the ISO
builder). Gauntlet 54 -> 52 harnesses (the two deleted scripts took their harnesses with them --
expected arithmetic, not a regression). All files remain in git history.
opentofu/templates/README.md is now a TOMBSTONE explaining what happened and what to use instead.
THE LIVE CHANGE IS NOT APPLIED IN THIS COMMIT. Removing the module's ISO wiring touches an
INSTANTIATED resource (libvirt_volume.config_seed is in tfstate). Measured with `tofu plan` first:
module.office1_opnsense.libvirt_domain.vm will be updated IN-PLACE
module.office1_opnsense.libvirt_volume.config_seed will be destroyed
Plan: 0 to add, 1 to change, 1 to destroy.
NO replacement of the running domain -- confirmed explicitly. A replacement would have DESTROYED
the live, routing, DHCP-serving edge, and would have been grounds to stop.
A LINT GUARD DID ITS JOB: repo-lint's L3 (runbooks must not reference missing scripts) went RED on
the tombstone notes, because they named the deleted paths. The rule was RIGHT and it has no
opt-out (only L4 does). Rather than weaken a guard that exists to stop runbooks pointing at dead
scripts, the tombstones were reworded to name the bare filename instead of the scripts/ path. The
guard stays fully intact; the information is preserved.
Verification: repo-lint 0 fail; opentofu-validate PASS (root + 10/10 modules standalone);
gauntlet ALL GREEN (52).
Revert: git revert this commit (restores template/renderer/ISO builder/harnesses), then
`cd opentofu && tofu apply` to re-create the (inert) config_seed volume + cdrom.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

D-113(a2): API-key bootstrap -- no GUI click, no re-implemented crypto
...
The last unknown in D-113(a2) edge provisioning. The API path was proven (read AND write), but
every new edge still needed a MANUAL GUI CLICK to mint its key -- which would have put a human in
the middle of building DC1's and DC2's edges in Stage 3. Closed.
scripts/opnsense-mint-apikey.php runs ON the edge; calls OPNsense's OWN model
(Auth\User -> apikeys->add()) -- the exact code path
the GUI's ticket button invokes
scripts/opnsense-bootstrap-apikey.sh ships it over SSH, runs it, retrieves the key, wipes
the remote copies
tests/opnsense-bootstrap-apikey/ offline harness, 8 PASS (ssh/scp stubbed)
Gauntlet ALL GREEN (54 harnesses, was 53); repo-lint 0 fail.
VERIFIED END TO END on the live Office1 edge: minted a SECOND key via the vendor model ->
GET core/firmware/status returned HTTP 200 (the vendor-minted key AUTHENTICATES) ->
POST auth/user/del_api_key/<id> -> {"result":"deleted"} -> search_api_key shows 1 row remaining
(the operator's original key, untouched throughout) -> the retired key is now REJECTED (a real
negative test, not just an absence of errors). Temp creds shredded from the jumphost. The edge is
back to its prior state.
THE DESIGN DECISION, and why the alternative was rejected: OPNsense stores API secrets as
crypt(secret,'$6$') -- SHA-512 with an EMPTY salt (verified on the live 26.1 box: the stored hash
is "$6$$" + 86 chars). We COULD mint keys offline and seed them into a bootstrap config.xml. We
deliberately DO NOT. That would couple our provisioning to a vendor hash format we would have to
keep byte-compatible forever, and a mismatch FAILS SILENTLY -- keys that simply never
authenticate, with no error at generation time. Calling the vendor's own generator has no format
to keep in sync. Same principle as D-113(a2) itself: call the interface, do not re-implement the
internals -- which is exactly what DOCFIX-191/192/193 cost us. (An offline-hash attempt was
blocked by the permission classifier mid-session; the block was correct and produced this better
design.)
GUARD CLAUSE WORTH KEEPING (harness T5): the script REFUSES to overwrite an existing creds file.
An existing file means a LIVE key on the edge; overwriting the local copy would strand it there
PERMANENTLY, because the secret is hashed on save and can never be read back. That is
unrecoverable credential loss. T5 asserts both the refusal and that the existing file is left
byte-intact. The secret is never printed -- creation is the only moment it exists in cleartext.
WHAT THIS UNBLOCKS: edge provisioning is now automatable with no human in the loop --
boot factory nano -> D-112(c) console bootstrap (SSH + key) -> opnsense-bootstrap-apikey.sh
-> opnsense-api.sh for DHCP/firewall/interfaces.
There is NO config.xml anywhere in that chain.
STILL OPEN: the template reduction itself. And the chain above raises the real question -- the
template may not need REDUCING so much as DELETING, since the console bootstrap plus the key
minter cover first contact. NOT ruled; not done unilaterally. (Note the opnsense-edge module's
config_seed volume is INSTANTIATED -- it is in tfstate -- so removing the ISO wiring is a LIVE
change, not a docs change.)
Revert: git rm the two scripts + tests/opnsense-bootstrap-apikey/ (nothing live depends on them --
the live edge's key was minted via the GUI and is unaffected).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

Correct the workflow doc's status: it was materially FALSE and would misdirect work
...
docs/dc-dc-deployment-workflow.md is the first thing a fresh session reads. Its status claims
dated from 2026-07-09 and had drifted into being wrong. Corrected against MEASURED state.
Docs only; no code, no live system touched.
gap #2 "UNVALIDATED -- no tofu binary" -> OpenTofu v1.12.3 IS installed; tree validates
(root + 10/10 modules) and is partly APPLIED
(11 resources in state). But 2 modules were
BROKEN and had never been parsed (DOCFIX-194).
gap #17 "no per-site ISP-uplink network -> CLOSED FOR OFFICE1: office1-wan is live
exists anywhere, for ANY site" (virbr11, NAT, 172.30.1.0/24); the edge's WAN
sits on it at 172.30.1.2, egress verified. The
gap's own proposed answer (a dedicated per-site
uplink, NOT a mesh leg) is what got built.
DC1/DC2 STILL have none.
opnsense-edge "BUILT, UNVALIDATED" -> BUILT, INSTANTIATED, RUNNING (routing, NAT,
egress, console, SSH, Kea DHCP on udp/67).
config.xml tmpl "BUILT, fully tested" -> SUPERSEDED by D-113(a2); a full push would now
CLOBBER live API-managed DHCP.
netem-link "BUILT, UNVALIDATED" -> WAS BROKEN; fixed 2026-07-13 (DOCFIX-194).
Stage 2 "NOT YET EXECUTED" -> PARTIALLY EXECUTED.
THE TRAP THIS REMOVES: Stage 2's gate lists "NetBox authoritative" and "GitBucket serving" -- and
NetBox and GitBucket DO answer, at baldurkeep.com. But those are PRE-EXISTING services, NOT
Office1 headend VMs. Measured: office1-opnsense is the ONLY VM on the vcloud host. It would be
very easy to tick that gate as met and walk into Stage 3 with no headend at all. The corrected
State section now says this explicitly.
What actually remains of Stage 2 (the real critical path): the three headend service VMs
(MAAS-region, NetBox, GitBucket) do not exist. Blocked on a genuine decision, not on typing:
Option A (modules/cloudinit-vm) needs user_data/meta_data/network_config designed and an image
source chosen; Option B is manual virt-install, logged as debt. Both cloudinit-vm and base-image
now at least VALIDATE (DOCFIX-194) -- they never had before.
Revert: git revert this commit (restores the stale, false status claims).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

DOCFIX-194: the OpenTofu gate was GREEN over two BROKEN modules
...
scripts/opentofu-validate.sh printed PASS on 2026-07-13 while node-vm and netem-link were flatly
broken -- neither could have applied, and one could not even init. Both are modules Stage 3 and
Stage 4 are built on.
ROOT CAUSE OF THE MISS: root-only validation. `tofu validate` against the ROOT module only parses
modules the root actually INSTANTIATES. A module that is not called -- or is called only from a
commented-out block -- is NEVER PARSED. Root calls dc-planes, dc-storage-pool, mesh-link,
office1-network, opnsense-edge. It does NOT call base-image, cloudinit-vm, maas-vm-host,
netem-link, node-vm -- exactly the set Stage 2 (cloudinit-vm, base-image) and Stage 3 (node-vm,
maas-vm-host, netem-link) depend on. They had never been parsed by any tool.
Only findable because a tofu binary now exists on the jumphost (OpenTofu v1.12.3). The tree was
authored in sessions with no binary to self-check -- exactly the risk opentofu/README.md flagged.
DEFECT 1 -- node-vm: libvirt_volume had a top-level `format = {...}`, which is not a real
attribute ("An argument named format is not expected here"). Schema-verified via
`tofu providers schema -json` (dmacvicar/libvirt v0.9.8): libvirt_volume has NO top-level
`format` -- it nests under `target`. modules/base-image had the correct nesting all along, which
is why it passed and node-vm did not. node-vm builds EVERY DC node (PXE-boot blanks); Stage 3/4
would have failed on first use. The module's own header said "VERIFY with tofu providers schema
-json before the first real apply" -- nobody could, until now.
DEFECT 2 -- netem-link: a destroy-time provisioner referenced var.*, which OpenTofu rejects AT
INIT ("Destroy-time provisioners ... may only reference attributes of the related resource, via
'self', 'count.index', or 'each.key'"). The module could not even initialize. It is the
DR/latency mechanism for Stage 3 and the Stage 6 failover drill. Fixed with the canonical
pattern: stash values in `input`, read back as self.input.*.
THE DURABLE FIX -- S3: the gate now validates EVERY module STANDALONE, in a temp copy (so it
never writes .terraform/ or a module-level lock file into the repo; only the ROOT lock file is
tracked, deliberately). The two bugs are one-line fixes; the real defect was the gate. A gate
that reports green over unparsed code is worse than no gate -- it manufactures confidence.
VERIFICATION: tests/opentofu-validate 9 PASS (T2 skipped -- cannot exercise the missing-binary
guard on a box that has the binary). T8 is the load-bearing case: a fixture whose ROOT IS VALID
and does NOT call a BROKEN module -- root-only validation reports PASS on it, so S3 must FAIL.
If T8 ever goes green, the blind spot is back. T9 proves T8 fails on the defect, not the fixture
shape. T10 covers all 10 real modules. Fixtures use terraform_data (a builtin), so no provider
download is needed.
Gate against the real tree: S1 PASS, S2 PASS, fmt clean, root validate clean, 10/10 modules PASS
standalone. Gauntlet ALL GREEN (53 harnesses); repo-lint 0 fail.
Nothing was instantiated; no live system is affected by either the bug or the fix.
Revert: git revert this commit (restores the two broken modules and the root-only gate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

SAFETY SWEEP: mark the config.xml push path as a LIVE HAZARD (no behaviour change)
...
After D-113(a2) was proven (Office1's config is now API-managed), every instruction in the repo
that still said "render a config.xml and push it to the edge" became ACTIVELY DANGEROUS: a full
config.xml push replaces /conf/config.xml wholesale and drops ~667 migration-populated elements
(measured), including the only 2 firewall pass rules on the box. Following the old runbook steps
against the live edge would clobber it.
Warnings and headers only. No behaviour changes, no live system touched.
Marked (5 files):
runbooks/dc-dc-phase1-office1-standup.md DANGER banner; the config.xml render + config-ISO
sub-steps removed as RUNNABLE instructions (they
would clobber the live edge). Image-prep retained;
history preserved in git + the build changelog.
runbooks/dc-dc-phase2-tofu-dc-substrate.md STOP banner on Step 4.
scripts/opnsense-render-config.sh DANGER header at point-of-use. NOT dead: under
D-113(a2) it is to be reduced to a MINIMAL bootstrap
render. Until then, safe only for a brand-new,
not-yet-booted edge.
scripts/opnsense-build-config-iso.sh RETIRED header quoting the upstream source proving
the importer can never fire on a nano image.
docs/dc-dc-deployment-workflow.md STALE-CONTENT warning atop the tooling gap register.
THE SWEEP ALSO CAUGHT A PRE-EXISTING LANDMINE, unrelated to today's work: dc-dc-phase2 Step 4
still instructs building a CONFIG ISO for DC1's edge -- but D-112 established that ISO can never
be read (opnsense-importer probes for a read-only root; on a pre-installed nano the root is
writable and a factory /conf/config.xml already exists, so it bootstrap_and_exit 0's without
enumerating a single device). That runbook has been telling anyone who follows it to build an
inert artifact and then wonder why the edge came up on factory defaults -- which is EXACTLY the
day lost on 2026-07-12. It was never corrected at the source. Now it is. Its WAN_IF/LAN_IF
"chicken-and-egg" discussion is moot for the same reason: that problem only exists if you try to
seed a full config before first boot, and D-112(c) measures vtnetN AFTER boot.
Changelogs deliberately NOT touched -- they are history, not instructions. Marking history is
noise; marking instructions is safety.
Verification: opnsense-render-config 24 PASS, opnsense-build-config-iso 2 PASS, opnsense-api
21 PASS; repo-lint 0 fail.
STILL OPEN (the actual D-113(a2) migration): reduce the template to a minimal bootstrap, retire
the config-ISO path and the opnsense-edge module's config_seed/cdrom wiring, and rewrite Stage 3's
edge steps around the API. This sweep makes the repo SAFE, not FINISHED.
Revert: git revert this commit (restores the previous, dangerous instructions verbatim).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

CHECKPOINT 2026-07-13: session close (edge routing + serving DHCP; D-113(a2) proven)
...
Session-ledger checkpoint so a fresh session can resume cold.
LIVE STATE (measured): office1-opnsense is UP -- routing, NAT, egress 0.0% loss, serial console,
SSH key management, and kea-dhcp4 serving DHCP on udp/67 (pool .100-.199). 8 LAN pass rules in
pf. Nothing broken, nothing mid-flight.
LANDED TODAY:
1. DOCFIX-193 APPLIED -- DHCP live (was built-but-unapplied at last close)
2. SEC-005 -- GitBucket credential stored; unattended git push now WORKS (the branch had sat
12 commits ahead of origin for two days -- a real data-loss exposure, now closed)
3. SEC-006/007 -- the two known credential exposures put ON the ledger (prose only before)
4. D-113 RULED (a2): stay on OPNsense, move config to the REST API
5. D-113(a2) PROVEN END TO END, read AND write: API -> OPNsense -> Kea -> daemon.
scripts/opnsense-api.sh + harness (21 PASS); gauntlet 53 ALL GREEN.
THE ONE TRAP: DHCP on Office1 is now API-MANAGED. A full rendered config.xml push WOULD CLOBBER
IT. Do NOT run the old scp/install/reboot path against this edge until the template is reduced.
NEXT: reduce opnsense-config.xml.tmpl to a minimal bootstrap (sshd + root key + console) and move
DHCP/firewall/interfaces to the API. Repo work; no live mutation needed to land it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

D-113(a2) write path PROVEN on the live edge: API -> OPNsense -> Kea -> daemon
...
The read half was proven earlier today; this closes the other half. Configuration written
through the REST API reaches the running daemon. (a2) is now demonstrated end to end.
Executed (live, operator-approved, deliberately IDEMPOTENT -- the payload was the exact values
read back from the API moments before, so the edge's behaviour should not change; the point was
to exercise the WRITE path):
POST kea/dhcpv4/set_subnet/93473635-... -> {"result":"saved"}
POST kea/service/reconfigure -> {"status":"ok"}
GROUND TRUTH after the write (the API's {"result":"saved"} is only the service's own verdict):
kea-dhcp4 pid 30027 (RESTARTED), bound udp4 10.10.0.1:67
Kea's OWN generated /usr/local/etc/kea/kea-dhcp4.conf:
interfaces ['vtnet0'], subnet 10.10.0.0/24, pools ['10.10.0.100-10.10.0.199'],
routers 10.10.0.1, domain-name-servers 10.10.0.1
Router unaffected: WAN 172.30.1.2, LAN 10.10.0.1, default route 172.30.1.1, 8 LAN pass rules in
pf, egress to 1.1.1.1 at 0.0% packet loss.
Payload gotcha, measured -- do NOT infer this: a GET response is not directly re-POSTable.
OPNsense GET returns select fields as {value: {selected: 0|1}}; SET expects the comma-joined
selected values, so the GET must be flattened first. And kea/service/reconfigure is the apply
verb -- without it the config saves but the daemon never reloads.
CONSEQUENCE: the config.xml push path is now RETIRED IN PRACTICE for Office1. DHCP is
API-managed, so pushing a full rendered config.xml WOULD CLOBBER IT. Do not run the old
scp/install/reboot path against this edge. Reducing the template to a minimal bootstrap
(sshd + root key + console) is the next step under D-113(a2) and is NOT yet done.
Revert: re-POST the previous values (unchanged, so nothing to undo), or edit in the GUI at
Services > Kea DHCP > DHCPv4 > Subnets. Pre-API config.xml backups remain in /conf/backup/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

D-113(a2) PROVEN: the OPNsense REST API round-trips the edge config
...
The API path adopted in D-113 is now demonstrated working against the LIVE Office1 edge,
closing the question that gated the ruling.
Measured:
GET core/firmware/status -> HTTP 200, OPNsense 26.1 (auth works)
GET kea/dhcpv4/get -> dhcpv4.general.enabled=1, interfaces=lan
POST kea/dhcpv4/search_subnet -> 1 row: subnet 10.10.0.0/24, pools 10.10.0.100-10.10.0.199,
option_data.routers 10.10.0.1, dns 10.10.0.1,
domain office1.vr1.cloud.neumatrix.local, descr "LAN DHCP"
The API reads back EXACTLY what we pushed via config.xml yesterday, and exposes it as TYPED,
NAMED FIELDS -- not free-form XML. That is the (a2) argument made concrete: DOCFIX-191 (missing
sshd), DOCFIX-192 (silenced console) and DOCFIX-193 (an inert ISC <dhcpd> block against a Kea
backend) are NOT EXPRESSIBLE here. You cannot omit a typed field that has a default, and you
cannot address the wrong DHCP backend when the endpoint IS the backend.
GUI gotcha worth recording (26.1): API keys are NOT created in the user-edit dialog, and NOT in
the ApiKeys tab -- that tab wires only search_api_key + del_api_key, so it can list and delete
but CANNOT create. Creation is a ROW ACTION on the Users list: the fa-ticket icon, "Create and
download API key for this user" (source: mvc/app/views/OPNsense/Auth/user.volt). Any instruction
saying "edit user -> API keys -> +" describes the OLD UI and is wrong for 26.1. ApiKeyField::add()
mints key+secret as base64(random_bytes(60)) and stores key|crypt(secret,'$6$') -- the secret is
shown EXACTLY ONCE.
API gotchas: response root key is `dhcpv4`, NOT `dhcp4` (the config XML element name differs from
the API key name -- do not infer one from the other). camelCase actions map to snake_case URLs:
searchSubnetAction -> POST /api/kea/dhcpv4/search_subnet.
SEC-007 amended: ~/vr1-office1-creds/ now also holds the OPNsense API key/secret (0600).
NOT DONE: no WRITE through the API yet -- that is a live mutation and stays gated. And the
config.xml template is still in place: DO NOT push a full rendered config.xml now, it would
clobber API-managed state. Next step is reducing the template to a MINIMAL bootstrap (sshd +
root key + console) and moving DHCP/firewall/interfaces to the API.
Revert: read-only against the edge + docs; nothing to undo. To revoke the credential: GUI ->
System > Access > Users > root row > ApiKeys tab -> delete, then rm ~/vr1-office1-creds/opnsense-api.txt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

D-113(a2) step 1: thin OPNsense REST API client + harness
...
First delivery under the D-113 ruling (stay on OPNsense, move config off hand-authored
config.xml and onto the documented REST API).
scripts/opnsense-api.sh -- [--dry-run] <GET|POST> <api-path> [json-body]
tests/opnsense-api/run-tests.sh -- offline harness, 21 PASS, no network, no real key
Gauntlet ALL GREEN (53 harnesses, was 52). repo-lint 0 fail.
Design decisions that the harness now enforces:
- THE SECRET NEVER REACHES ARGV. Credentials are read from a file and handed to curl via
--config on STDIN. `curl -u key:secret` would put the secret in argv, where any user on the
box can read it out of `ps`. Harness T9 stubs curl with an argv recorder and asserts the
secret is absent from argv and present on stdin -- so a future "simplification" to `curl -u`
turns the harness red. That case is the point of the file.
- The API host is NEVER inferred (hard rule 2): $OPNSENSE_API_HOST unset is a loud failure, not
a silent default of 10.10.0.1 (T3).
- --insecure is deliberate and scoped: the edge's self-signed cert is REGENERATED ON EVERY BOOT
(measured -- "Created web GUI TLS certificate" appears in the config revision trail after each
reboot), so pinning it is pointless. Acceptable ONLY on the private lab LAN leg; never to be
copied to a tenant-facing surface.
- Dry-run reads no credentials (T11), which is what lets the harness prove URL construction
offline.
NOT RUN against the edge yet, and blocked on one thing: the API is alive (lighttpd on 443
answers 401) but root has 0 API keys. Minting one is a GUI action (System > Access > Users >
root > API keys), deliberately NOT automated -- writing apikeys into config.xml by hand is the
exact anti-pattern D-113 just retired. Key lands in ~/vr1-office1-creds/opnsense-api.txt,
operator-only, never read into agent context (SEC-007 covers that directory).
Proof-of-path once the key exists: drive yesterday's DHCP subnet through the API and confirm it
round-trips. If it does, the template retires to a minimal bootstrap config (sshd + root key +
console + apikey) and DHCP/firewall/interfaces move to the API. If it cannot express it, that is
the signal to revisit D-113.
Revert: git rm scripts/opnsense-api.sh && git rm -r tests/opnsense-api/ (nothing live touched).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

D-113 ADOPTED: option (a2) -- stay on OPNsense, move config to the REST API
...
Operator ruling 2026-07-13. Two constraints surfaced during the discussion narrowed the field
decisively, and both are recorded so the reasoning is not re-run from the wrong premise:
1. GUI ACCESS IS A REQUIREMENT (operator). This eliminates VyOS and plain-Linux outright --
neither has a GUI -- and both had been the leading candidates until it was stated.
2. VyOS LTS binaries are subscription-gated (verified against vyos.net/get and the Software
Access Subscription page). Free VyOS means the ROLLING release, a moving target that fights
this repo's appendix-B version-pinning discipline. Real friction, independent of the GUI.
Field reduced to OPNsense / pfSense CE / OpenWrt / IPFire. pfSense rejected (same GUI-owned XML
lineage -- a migration that buys nothing). OpenWrt was the credible alternative: its UCI config
is text-first BY DESIGN, structurally unlike OPNsense's GUI-owned config.xml. It loses on cost:
a new image pipeline, a fresh bootstrap problem, and dnsmasq instead of Kea -- all to escape a
problem that is fixable in place.
The ruling rests on this: the bug class was never OPNsense. It was hand-authoring config.xml
when OPNsense ships a documented REST API with mature Ansible collections that model exactly
what we got wrong (dhcp_subnet, firewall rules, system settings as typed resources). NONE of
DOCFIX-191/192/193 is expressible through the API -- you cannot forget to enable sshd in a
format where sshd is a typed field with a default. (a2) keeps the GUI, keeps a working
routing+DHCP edge, keeps the libvirt fixes, and removes the defect source.
(a1) config.xml templating is now explicitly REJECTED: its cost compounds per feature and it
depends on an undocumented self-heal of an internal format (the 667-element finding).
Live-edge recon (read-only): lighttpd listening on 443/80, API answers 401 (alive), root has
0 API keys. Minting a key via the GUI is the bootstrap step -- hand-editing apikeys into
config.xml would be the exact anti-pattern being retired.
Revert: git revert this commit (docs only; nothing migrated, nothing built).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

D-113 (PROPOSED): is OPNsense the right edge platform? -- and a confound in D-112
...
Operator asked, after a five-bug day, whether something is better suited than OPNsense for
the simulated edges. Written up with alternatives; NOT RULED.
Honest cost accounting: of the five bugs, TWO (DOCFIX-188 memory_unit, DOCFIX-190 ACPI) were
libvirt module bugs that would have hit any guest and whose fixes now protect every VR1 VM --
not OPNsense's fault. The other three (DOCFIX-191 no sshd/key, DOCFIX-192 no console,
DOCFIX-193 no DHCP) share ONE root cause: hand-authoring the appliance's internal, GUI-owned
config.xml. 2026-07-13 adds a fourth of the same kind -- a full-config push drops ~667
migration-populated elements including the only two firewall pass rules, and works ONLY
because OPNsense regenerates them on boot. We depend on an undocumented self-heal of an
internal format. That, not the boot bugs, is the ongoing cost.
THE CONFOUND, and the reason this is worth ruling rather than waving through: D-112 adopted
option (c) with the explicit rationale "C since it is the way people automate opnsense" --
i.e. the REST API (opn-cli, the Ansible collection). What we actually BUILT is config.xml
templating delivered over scp. That satisfies (c)'s letter (provision over the network, post
boot) but NOT the rationale it was ruled on. Every OPNsense-specific bug we are charging
against the platform is an artifact of hand-authoring XML -- precisely what the API exists to
avoid. So the fair comparison is not OPNsense vs VyOS; it is:
(a1) OPNsense + config.xml templating -- status quo; cost COMPOUNDS per feature
(a2) OPNsense + REST API -- what D-112's rationale actually called for
(b) VyOS -- blank-sheet best; discards a working edge
(c) plain Linux (nftables/Kea/FRR + cloud-init) -- max fidelity to our own tooling
(d) pfSense -- REJECT, same config-format lineage
Recommendation (operator rules): stay on OPNsense, and the real choice is a1 vs a2 -- I lean
a2. The hard part is done and measured working, and edges 2/3 reuse the module, so the cost
paid was one-time. But (a1) compounds, and (a2) is the honest reading of D-112's own ruling.
Rule BEFORE Stage 3: it builds two more edges from this module, so ruling later means
migrating three edges instead of one.
Revert: git revert this commit (docs only; nothing built or migrated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

SEC-006/SEC-007: put the two known credential exposures on the ledger
...
Both were flagged only in changelog/session-ledger prose, never as ledger rows -- exactly
the failure mode the security-ledger header warns about ("never only a comment in a script
header, where the libvirt item lived for a week"). An exposure with no row has no owner, no
status, and no revoke trigger.
SEC-006: the NetBox API token pasted into agent chat context during the D-111 IPv6 work.
This one is BURNED, not merely due for rotation -- it exists in a transcript outside our
control, so it needs revoke + reissue. Operator ruling 2026-07-13: DEFERRED, revoke at
completion of this deployment. Recorded as OPEN and explicitly live-and-exposed until then,
so the deferral is visible rather than forgotten.
SEC-007: ~/vr1-office1-creds/ holds the Office1 edge root password, its bcrypt hash, and the
office1_svc SSH private key in plaintext (0700/0600). This is a ROTATION obligation, not a
delete-me: D-112(c) makes SSH the only management path to the edge, so the key must exist.
No change to any live system; documentation only.
Revert: git revert this commit (the rows are the only change).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

SEC-005: store a GitBucket credential on the jumphost so pushes stop failing
...
The jumphost clone could not push (could not read Username for https://git.baldurkeep.com),
which left this branch 12 commits ahead of origin for two days -- all the D-112 / OPNsense
edge work existed only on this box. Now fixed: credential.helper store + a dedicated PAT at
~/.git-credentials (mode 600), written by the operator via a hidden prompt. The token was
never read into agent context; the credential is verified by USING it, never by printing it.
Two GitBucket behaviours measured and recorded, because both cost a cycle:
1. The git username is NOT the web-UI login. The operator signs in with an email; that is
rejected for git transport. Measured against info/refs?service=git-receive-pack:
jesse.austin@neumatrix.com -> 401, JANeumatrix (the git user.name) -> 401,
jesse.austin -> 200 CAN PUSH. Note GitBucket 401s PAT basic-auth on /api/v3/ across the
board while accepting it for git -- so validating a credential against the API gives a
FALSE NEGATIVE. Validate against the receive-pack endpoint.
2. credential.helper store DELETES the stored credential when the server rejects it (git
calls credential reject on a 401). The first bad-username push therefore emptied
~/.git-credentials to 0 bytes, and the follow-up diagnostics were authenticating with an
empty string -- which read as a corrupt file and misdirected the investigation. If the
file mysteriously empties: the credential was rejected, it did not fail to write.
Tooling consequence: validate BEFORE writing.
SEC-005 is OPEN by operator ruling: a long-lived, account-wide PAT now sits in plaintext on
the jumphost (GitBucket has no per-repo token scoping, so the control is rotation, not
scope). Revoke/rotate at v1 close, or immediately if the jumphost is shared or rebuilt.
Revert: git config --global --unset credential.helper && rm -f ~/.git-credentials,
then revoke the token in GitBucket -> Account Settings -> Applications.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|

DOCFIX-193 APPLIED: the Office1 edge now serves DHCP (kea-dhcp4 on udp/67)
...
Executed the live apply that DOCFIX-193 deliberately left pending. Measured end state
on root@10.10.0.1 after reboot: kea-dhcp4 running, bound udp4 10.10.0.1:67, subnet
10.10.0.0/24, pool .100-.199, routers/DNS 10.10.0.1. Router unaffected -- WAN 172.30.1.2,
LAN 10.10.0.1, default route 172.30.1.1, NAT automatic, egress 1.1.1.1 at 0.0% loss,
serial console + getty alive (DOCFIX-192 holds).
Path: render -> scp -> SHA-256 verify on guest (matched) -> install /conf/config.xml ->
reboot. Edge back in ~30s.
The finding that nearly bit us: a full-config push drops ~667 migration-populated
elements, including the ONLY 2 pass rules on the box ("Default allow LAN to any" + IPv6 --
legacy /filter/rule count is 0 in 26.1) against a pf base policy of block-drop-all.
Dropping them = an edge that boots but does not route. Settled NOT by inference but from
the box's own /conf/backup/ trail: three prior pushes of a rule-less config each came back
with both rules regenerated by run_migrations. Post-apply re-confirms it -- 791 elements,
both LAN rules back, webgui TLS cert back, and our Kea subnet4 survived the migration.
The overwrite is self-healing; do not hand-merge to preserve them.
Defect logged, not fixed here: root's shell on OPNsense is tcsh, so the pre-install
snapshot step (a $(...) inside a quoted remote command) died with "Illegal variable name"
and silently no-op'd -- the install ran without its rollback point. Config was recoverable
via /conf/config.xml.prev + /conf/backup/, so no harm, but the guard was absent when
relied upon. Remote commands must be fed to sh -s. The apply path wants to become a
harnessed scripts/opnsense-apply-config.sh; LOGGED as an open gap, out of scope here.
Not verified end-to-end: no client has taken a lease (office1-local has no host yet).
Revert: ssh root@10.10.0.1 'sh -s' <<< 'cp /conf/config.xml.prev /conf/config.xml && reboot'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6Fzre1CxCY8tzwFCudu57
|
| 2026-07-12 |

DOCFIX-193: LAN DHCP was never implemented -- add Kea to the edge config template
...
The operator's goal for the Office1 edge was "router + DHCP for office1-local". The
router half works; the DHCP half was NEVER BUILT. The template had no DHCP section at
all -- no <dhcpd>, no <Kea> -- and nothing served DHCP (measured on the live edge: no
kea/dhcpd/dnsmasq process, nothing on udp/67). The ledger's claim of "DHCP .100-.199"
was false; corrected in place.
OPNsense 26.1 uses KEA. ISC dhcpd is gone, so an old-style <dhcpd> block would have been
inert -- which is the trap this could easily have fallen into.
The Kea shape is NOT invented: it mirrors what OPNsense itself PERSISTS on the live 26.1
box (read back from its config.xml, which its own migrations had populated with an
enabled=0 skeleton), and the field names match the upstream model
src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml.
Render script now derives and VALIDATES, rather than trusting inputs:
- LAN_NETWORK_CIDR derived from LAN_IPADDR/LAN_SUBNET_BITS (the subnet must be the
NETWORK, not the host address -- 10.12.8.1/22 -> 10.12.8.0/22).
- subnet4 uuid is a DETERMINISTIC uuid5 of the LAN CIDR, so re-rendering is idempotent
and does not churn the config.
- DHCP_POOL_START/END are REQUIRED and validated: a pool outside the LAN subnet, a pool
that swallows the router's own LAN address, or reversed bounds all FAIL LOUD. Each of
those is XML-valid and would serve nobody (or fight the router) -- exactly the class of
silent misconfiguration this session has been finding all day.
Harness 15 -> 24 PASS (T10-T13, incl. three negative cases). Gauntlet ALL GREEN (52);
repo-lint 0 fail.
NOT YET APPLIED to the live edge -- that is a separate gated mutation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

D-112(c) EXECUTED: the Office1 OPNsense edge is UP and ROUTING
...
End state, measured over SSH: office1-opnsense, OPNsense 26.1, WAN 172.30.1.2/24 static,
LAN 10.10.0.1/24, default route via 172.30.1.1, NAT active, and egress to the internet
WORKS through the simulated ISP (ping 1.1.1.1, 0% loss). Serial console restored
(kern.console=ttyu0, getty running). Managed over SSH with the service key.
Bootstrap: console (pexpect + virsh console) -> factory login -> config streamed as
base64, SHA-256 verified byte-exact, factory config backed up -> reboot. The DOCFIX-192
fix was then pushed OVER SSH -- the D-112(c) steady-state path proving itself.
Gotchas recorded in the ledger because each cost real time:
- Console settings need TWO reboots. OPNsense regenerates /boot.config and
/boot/loader.conf FROM config.xml *during* boot, so they only take effect on the NEXT
boot. After reboot #1 the loader files were already correct while the running kernel
was still on video. Do not conclude "the fix failed" after one reboot.
- /etc/ttys uses `onifconsole`: getty starts on ttyu0 only if ttyu0 IS the kernel
console. `sysctl kern.console` is ground truth.
- The guest's root shell is CSH (both via console and ssh). It rejects $(...) with
"Illegal variable name." Pipe POSIX scripts to `sh -s`.
- `virsh console` REFUSES a unix-socket chardev. The serial must be a pty + log file.
LEDGER CORRECTION: the 2026-07-12 entry claimed the rendered config provided
"DHCP .100-.199". That is FALSE -- there is NO DHCP configuration in the template at all
(no <dhcpd>, no <Kea>), and nothing serves DHCP on office1-local (measured: no
kea/dhcpd/dnsmasq process, nothing on udp/67). Another optimistic claim from the
max-context session. The operator's goal was "router + DHCP": the ROUTER half is DONE,
the DHCP half was never built. OPNsense 26.1 uses Kea (ISC dhcpd is gone), so this needs
the Kea schema verified upstream -- do not invent it.
repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

DOCFIX-192: the edge config silenced its own ONLY console (no serial, no video device)
...
Third lockout-class defect in the same config, found by executing D-112(c) for real.
After the config was applied and the box rebooted, the serial console went PERMANENTLY
SILENT -- no login prompt, no output, nothing (measured: raw console read returns empty).
The edge VM has NO VIDEO DEVICE, so the serial port is its only console and its only
break-glass path.
Cause: the config set neither <primaryconsole> nor <serialspeed>, so the applied config
did not select the serial console. Boot output still appears (the loader honours the nano
image's own /boot.config -S115200), which is why this looks like a hang rather than a
config choice -- the console dies exactly when OPNsense applies our config.
The box was NOT lost: DOCFIX-191 had just enabled sshd + installed the service key, so it
stayed reachable. That is the only reason this was a finding and not an outage. Two
lockout bugs in one config, and the first one saved us from the second.
Key names VERIFIED against upstream src/www/system_advanced_admin.php:
- `primaryconsole` and `serialspeed` are STRING values.
- There is NO `enableserial` key in OPNsense -- that is a pfSense-ism. Do not add it.
115200 matches the nano image's own /boot.config.
Harness 12 -> 15 PASS. T9b asserts the serial console is retained; T9c guards against
someone "fixing" this by re-adding the bogus pfSense <enableserial> key.
(T9c initially failed on my own template COMMENT, which names enableserial while warning
against it. Tightened to match an actual element. The harness caught it.)
Gauntlet ALL GREEN (52); repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

DOCFIX-191: the rendered edge config would have LOCKED MANAGEMENT OUT (no sshd, no key)
...
Found while building the D-112(c) bootstrap. The config template had:
<ssh><group>admins</group></ssh>
and no authorized key on root. That is OPNsense's DISABLED-sshd shape. So the config
we were about to push would have left the edge with sshd off and no key installed --
reachable only from the serial console. Under D-112(c), SSH *is* the management path,
so this is a defect in the config itself, independent of how it gets delivered. Had the
Importer worked back in July, we would have shipped an unmanageable edge and blamed the
network.
Element names are VERIFIED against upstream, not guessed:
- src/www/system_advanced_admin.php: `enabled` is the literal STRING "enabled";
`permitrootlogin` / `passwordauth` are PRESENCE-checked (isset), so omitting
passwordauth leaves password auth OFF.
- src/etc/inc/auth.inc: `$keys = base64_decode($user['authorizedkeys']);` -- the
<user><authorizedkeys> value is BASE64 and is written to ~/.ssh/authorized_keys.
Changes:
- template: <ssh> gains <enabled>enabled</enabled> + <permitrootlogin>; root <user>
gains <authorizedkeys>{{ROOT_AUTHORIZED_KEYS_B64}}</authorizedkeys>.
- render script: ROOT_AUTHORIZED_KEYS is now REQUIRED (no default -- an edge rendered
without a key is a lockout waiting to happen). PUBLIC key material only; the private
key is never read by this repo. Base64 is computed AFTER the req() loop so a missing
key fails with the friendly message, not a `set -u` crash.
- Key-only auth is deliberate; the serial console stays the break-glass fallback.
Harness 9 -> 12 PASS. T7b asserts sshd is actually enabled; T7c asserts <authorizedkeys>
base64-DECODES back to the supplied key (a round-trip, not a presence check -- a
wrongly-encoded key yields a well-formed config that silently grants no access); T8
asserts a missing key FAILS LOUD.
Note: the harness's existing T1c well-formed-XML check caught a bug in this very change
(an XML comment containing "--", which is illegal). Fixed. That is the harness earning
its keep.
Gauntlet ALL GREEN (52); repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

D-112: record option (a) as MEASURED INFEASIBLE; rule the (c) bootstrap; make the edge console interactive
...
Operator asked to re-examine option (a) (bake /conf/config.xml into the nano) before
committing to (c). Done, and it is BLOCKED on this host -- measured, not assumed:
CONFIG_UFS_FS=m but "# CONFIG_UFS_FS_WRITE is not set" -> UFS driver is READ-ONLY
partition table: MBR, a single FreeBSD a5 partition -> UFS only, no FAT to write
fuse-ufs / ufs2-tools -> not present, not packaged
libguestfs (guestfish / virt-copy-in) -> not installed
Two ways (a) could be forced, both rejected and recorded so they are not re-litigated:
a FreeBSD helper VM (works, xorriso is present to remaster a live ISO -- but it means
maintaining a builder VM forever to deliver one 4.5 KB file), and a raw byte-patch of
the image (probably works; silently corrupts if the file is not block-contiguous, and
is NON-TRANSFERABLE -- you cannot byte-patch a real appliance on Roosevelt hardware,
which is exactly what this repo's governing constraint forbids).
Examining (a) properly STRENGTHENED (c): (a) is not merely less mainstream here, it is
unavailable without a detour costing more than (c)'s entire bootstrap.
Bootstrap RULED (operator): B1 = temporary host leg on virbr2 (reversible; no change to
the D-100 network definition). B2 = one interactive console login to enable SSH and
install the ed25519 pubkey already generated in ~/vr1-office1-creds/, after which
everything is plain SSH/SCP.
Module change enabling B2: the edge's serial becomes a unix socket + a log file, rather
than a write-only file source. Both roles are needed and they are different:
source.unix -> INTERACTIVE bidirectional console. A `file` source is write-only capture
and cannot be typed into, so it cannot bootstrap anything. A unix socket
is scriptable with `socat - UNIX-CONNECT:`, unlike a pty, which needs a
controlling TTY.
log -> preserves full boot capture from power-on (a socket loses anything
emitted before a client connects). That capture is what made both
2026-07-12 boot bugs legible; it is not being dropped.
S1+S2 guards PASS, harness 6/6, repo-lint 0 fail, tofu fmt/validate clean.
Live recreate + host leg remain separately gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

D-112 ADOPTED: option (c) post-boot network provisioning (operator ruling)
...
Operator ruled (c): "C since it is the way people automate opnsense" -- prefer the
mainstream automation path (the OPNsense REST API, as used by opn-cli and the
Ansible OPNsense collection) over image-baking or a media-import trick. The
rendered config (opnsense-config.xml.tmpl) is REUSED unchanged; only the DELIVERY
mechanism changes.
IMPLEMENTATION NOT STARTED -- deliberately. "Push the config over the API" is not
actually reachable from a factory-default OPNsense, and the bootstrap sub-choices
are unruled. Recorded in D-112 so the next session does not discover this the hard
way:
1. NO ROUTE. Factory-default LAN is 192.168.1.1/24 on office1-local, an ISOLATED
libvirt network with no host leg on virbr2 (measured). The WAN side is reachable
at 172.30.1.126 but OPNsense blocks all inbound on WAN by default, so it is not
a way in.
2. NO CREDENTIALED API. The REST API authenticates with an API key+secret (not the
root password), minted via the GUI. SSH is disabled in the factory default.
3. OUR SERIAL IS WRITE-ONLY. The module attaches <serial type='file'> -- a one-way
capture that cannot be typed into. An interactive console means type='pty' +
virsh console (module change + recreate).
Sub-choices B1 (how to reach it) and B2 (how to authenticate first) are left
UNRULED in D-112 rather than picked.
SELF-CORRECTION recorded: I had earlier described option (b) as making the ISO
mechanism "work as designed, unchanged". That was over-claimed -- even with a
read-only root the importer prompts with a 7s timeout and asks for a device name,
so (b) may have been (d) in disguise. Logged so nobody revives (b) on my bad
summary. Note a console LOGIN SHELL is not the rejected (d): (d) was rejected for
racing the importer's 7s timeout; a login shell has no timing race.
The config-ISO path (opnsense-build-config-iso.sh + harness, the module's
config_seed volume + cdrom disk) is RETIRED but left in place -- it is inert, not
harmful. Remove it in the same change that lands (c), not before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

D-112 (PROPOSED): OPNsense Config Importer cannot fire on a nano image -- by design
...
Bug 3 of the Office1 edge boot. With memory (DOCFIX-188) and ACPI (DOCFIX-190)
fixed, OPNsense boots -- but on FACTORY DEFAULTS. The config ISO was never read.
Root cause is architectural, not a bug. rc.syshook.d/import/20-importer runs
`opnsense-importer -b`, whose first act is:
INSTALL="/.probe.for.readonly"
touch ${INSTALL} 2> /dev/null
if [ -f ${INSTALL} -a -f /conf/config.xml ]; then
bootstrap_and_exit 0
fi
That touch is a PROBE FOR A READ-ONLY ROOT. On installer media root is read-only,
the touch fails, the marker is absent, and the importer scans attached media
(cd9660 included). On a pre-installed nano the root is WRITABLE and a factory
/conf/config.xml already exists -- so both conditions hold and it exits
immediately, never enumerating a single device.
Our ISO was correct all along: verified ISO9660 'OPNSENSE_CFG' containing
CONF/CONFIG.XML with our 10.10.0.1, and the guest sees it as cd0 at exactly the
right byte size. Nothing was ever going to read it.
The README's research correctly established that the Importer's scan path supports
ISO9660 -- but never that the Importer RUNS on a nano image. It does not. The
module header has always flagged this mechanism UNVERIFIED; that flag is now
discharged NEGATIVE.
Recorded as D-112 PROPOSED with four options (bake config into the image / switch
to the installer image / post-boot API push / serial expect-scripting). NOT
implemented -- this replaces the config-delivery mechanism of a built surface and
also gates Stage 3, so it is the operator's ruling to make.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

DOCFIX-190: second boot bug -- libvirt domains had ACPI disabled (FreeBSD kernel panic)
...
With DOCFIX-188's memory fix in place the guest finally got its 2 GiB, the kernel
reached interrupt initialisation, and hit a SECOND defect the first bug had masked:
panic: running without device atpic requires a local APIC
apic_init() ... mi_startup() ... db>
None of the three VM modules emitted a `features` block, so libvirt rendered
`-machine pc-i440fx-noble,...,acpi=off`. FreeBSD gets its local APIC from ACPI's
MADT table and the OPNsense kernel has no atpic fallback, so with ACPI off it has
no usable interrupt controller. The guest parks in the ddb debugger -- which is
the 100%-of-one-core spin, while virsh still reports the domain as "running".
Fix: features = { acpi = true, apic = {} } in opnsense-edge, cloudinit-vm and
node-vm. node-vm matters most long term: MAAS drives power via ACPI signalling,
so without it graceful shutdown / MAAS power-off would never have worked.
Guard S2 added. Like memory_unit, `tofu validate` cannot catch this -- the whole
features block is optional, so its absence is schema-valid. Flag renamed
--static-only (--check-memory-unit kept as a back-compat alias). Harness 4 -> 6
PASS; fixtures/s2-bad PASSES S1 and still FAILS S2, proving the guards catch
independent classes rather than one masking the other.
LIVE RESULT (measured): acpi=on; kernel boots to userland; CPU idle, no spin;
DHCP lease 172.30.1.126 hostname "OPNsense" on office1-wan. Both boot bugs closed.
STILL OPEN: the Configuration Importer did not apply -- OPNsense came up on
factory defaults. The ISO is verified well-formed (ISO9660 OPNSENSE_CFG containing
CONF/CONFIG.XML with our 10.10.0.1). This is the mechanism the module header has
always flagged UNVERIFIED. Do not guess the fix; read the Importer's console
output. The unanswered WAN ping is NOT a fault -- OPNsense blocks inbound on WAN.
Gauntlet-relevant suites green; repo-lint 0 fail; tofu fmt/validate clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

DOCFIX-189: re-audit the max-context session's troubleshooting + docs
...
Operator-instructed re-evaluation. The 2026-07-12 session authored its docs and
module comments WHILE WORKING FROM A WRONG DIAGNOSIS, at a context limit, so its
reasoning was re-checked rather than just its one-line bug (DOCFIX-188).
Headline: the settings it changed are mostly fine and are RETAINED. The reasoning
it recorded was not -- it attributed the fault to whatever knob was being turned,
on no evidence, four separate times. It also left one silently-dead knob.
FALSE CAUSAL CLAIMS (corrected at the source, opnsense-edge/main.tf). The disk
shape, q35->i440fx, the serial console and svm-disable were EACH written up as a
contributing cause. None changed the symptom -- the fault sat at a deterministic
262 bytes through all of them. All four settings RETAINED (each defensible on its
own merits); only the false justifications are gone. The serial console earned its
keep, but it REVEALED the fault, it did not change it.
DEAD KNOB (real functional defect). `disk_size_bytes = 16 GiB` did nothing once
DOCFIX-187 made the disk a direct copy; unused variables are legal HCL so neither
validate nor plan complained. MEASURED: the live disk is 11.00 GiB, not 16.
Removed from main.tf + module variables; real sizing is opnsense-prep-image.sh's
GROW (+8G). Proof it was dead: tofu plan is byte-identical before/after removal.
STALE/UNSOURCED FACTS. "Opteron_G3" is a red herring -- the host is really an AMD
EPYC 9965 (Zen 5, family 26); libvirt's CPU-model DB doesn't know family 26 and
falls back to the oldest name, and the prior session partly built its nested-virt
theory on that artifact. "OPNsense's 3 GB min" is unsourced -- annotated, do not
propagate. DOCFIX-186's backing_store parenthetical is stale (same conclusion,
different mechanism). DOCFIX-187's changelog gains a SUPERSEDED-IN-PART header.
KEPT: DOCFIX-186's apparmor + config-iso-staging findings are real and measured.
DOCFIX-185 (operator transport ruling) untouched.
STANDING LESSON: when a change does not fix the symptom, do not write it up as
though it did. A fault that is deterministic and immune to every knob you turn
means you have not yet touched the cause.
Gauntlet ALL GREEN (52); repo-lint 0 fail; tofu fmt/validate/plan clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|
DOCFIX-188 followup: correct the pass criteria (anchor on virsh dominfo, not the QEMU -m string)
...
libvirt normalizes MiB to KiB in the QEMU cmdline, so '-m size=2097152k' would be
SUCCESS, not failure -- the original criterion would have read a correct boot as a
failed one. Anchor on 'virsh dominfo' Max memory, which is unambiguous. Also: re-run
plan AFTER the undefine (the captured in-place plan is not what executes), and
pre-stage the two fallbacks (provider ignores memory_unit -> *1024; boots but no
router -> sizing, not a regression).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

DOCFIX-188: root-cause the OPNsense boot triple-fault -- libvirt memory_unit (guest had 2 MiB)
...
The 2026-07-12 triple-fault was never a CPU/nesting/machine/console problem.
dmacvicar/libvirt >=0.9 reinterprets `memory` as raw libvirt units (KiB), not
MiB (the 0.8-era meaning the modules were authored against). With no
`memory_unit`, `memory = 2048` rendered `<memory unit='KiB'>2048</memory>` ->
QEMU `-m size=2048k` = 2 MiB. boot2 fits in 2 MiB and echoes /boot.config;
/boot/loader does not -- hence the deterministic 262-byte fault, immune to every
knob the prior window turned.
Measured every hop: module input -> tofu state -> domain XML -> virsh dominfo
(Max memory: 2048 KiB) -> live QEMU cmdline.
FOUNDATIONAL: the identical defect was latent in all three VM modules --
opnsense-edge (live blocker), cloudinit-vm (MAAS/NetBox/GitBucket, the next
step), node-vm (Stage 3). Every VR1 VM would have gotten 1024x too little RAM.
- memory_unit = "MiB" on all three libvirt_domain resources (memory_mib = 2048
value untouched -- it now means the 2 GiB always intended).
- Corrected the disproven "svm is ROOT CAUSE" comment in opnsense-edge; the
setting is retained as legitimate hardening, the false claim is not.
- Guard: opentofu-validate.sh S1 + --check-memory-unit flag. `tofu validate`
cannot catch this (memory_unit is optional => omitting it is schema-valid,
which is exactly how it shipped). Harness T3 is a true negative test: the
fixture IS the shipped defect. Harness 1 -> 4 PASS.
- Incident report gains a READ-THIS-FIRST box so nobody re-chases the CPU theories.
Gauntlet ALL GREEN (52 harnesses); repo-lint 0 fail; tofu fmt/validate clean.
Live boot verification remains a separately-gated operator step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y117t1F525ba9r1vXSBETk
|

DOCFIX-187: opnsense-edge module boot fixes + boot-triple-fault incident report
...
Office1 OPNsense edge first-boot debugging. Several correct module fixes made;
the boot is NOT yet resolved (BTX-loader triple-fault under double-nested virt),
documented as an OPEN incident for the next session.
Module (opentofu/modules/opnsense-edge/main.tf), all kept (a working config
needs them, none resolved the fault):
- serial console (nano is serial-only; the module had none) + boot-log capture
- machine q35 -> i440fx (pc)
- disk COW-overlay -> direct per-VM copy (matches virt-install --import)
- CPU host-passthrough with AMD svm disabled (documented AMD nested-virt fix;
note the provider key is `features` plural, not `feature`)
Incident report docs/incident-20260712-opnsense-edge-boot-triplefault.md: full
symptom (boot2 echoes /boot.config then triple-faults loading /boot/loader, a
consistent 262-byte fault confirmed fresh each boot), environment (double-nested
AMD Opteron_G3), everything tried, and ranked next steps (full CPU flags
+kvm_pv_eoi/+kvm_pv_unhalt, add a video device for the -D dual console, mem 4G,
UEFI/OVMF, outer-hypervisor CPU). Ledger checkpoint added; session at context
limit.
repo-lint 0 fail; module tofu validate Success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

Office1 OPNsense edge build: real-ISP-router config + first-boot fixes
...
Bringing the Office1 OPNsense edge online (router + DHCP for office1-local)
before the Office1 NetBox VM. First-ever OPNsense boot in this repo; several
first-execution findings fixed.
DOCFIX-184: opnsense-prep-image.sh decompresses the .img.bz2 via python3's
stdlib bz2 when bunzip2 is absent (no sudo install needed). Harness 3/3.
DOCFIX-185: the OPNsense edge is a normal simulated-ISP router, not an
egress-airgap. Operator clarified the transport model -- each site (DCs +
Office) has its own real ISP connection; dark fiber is East-West/replication
only; D-107 node-airgap is a separate DC concern. Stripped the WAN
egress-control rules (seq 20/21/99) + MIRROR_* tokens from the config template,
render script, and harness (8/8). Flagged a D-100/D-107 amendment note as
follow-up.
DOCFIX-186: instantiate module "office1_opnsense" in main.tf (first real
validation of modules/opnsense-edge). Two infra findings: (1) config_iso_path
must be OUTSIDE the pool dir (create.content.url collides with its own target
volume) -> stage the ISO; (2) apparmor blocks the custom /var/lib/libvirt/vr1/
pool path (foundational -- all VR1 VMs) -> needs a one-time local apparmor rule
(operator sudo). office1-wan NAT network created via virsh (D-103 debt).
repo-lint 0 fail; opnsense-prep-image 3/3; opnsense-render-config 8/8.
Boot blocked only on the apparmor fix (pending operator sudo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| 2026-07-11 |

D-111: align VR1 v6 subcarve to deployed NN mnemonic; fix ULA-gen doc bug
...
Basing VR1 NetBox on the real netbox.baldurkeep.com modeling (operator
directive). A read-only live export confirmed VR0-DC0/Willamette use an NN
net-byte mnemonic with /60-per-plane + /64-active; dc-dc-prefixes-import.py
did not match it (arbitrary contiguous indices, /64-only).
DOCFIX-182 / D-111: carve_v6 replaces carve_ula/carve_gua -- provider :10
(+API-VIP :11) GUA, metal /60 shared by admin :20 / internal :21, data :30,
storage :40, repl :50, all /60+/64 ULA; the ULA /56 is indexed to the GUA site
nibble so the 4th hextet reads DC.NN in both families. Direct-math _sub_at (no
subnet enumeration -- preserves the DOCFIX-181 no-hang property). Harness
40 -> 53 PASS. D-111 ADOPTED in design-decisions.md; scope-doc sub-decision #1
ratified.
DOCFIX-183: fix the invalid-IPv6 ULA-generation sed in the netem/ULA proposal
doc (4+6 -> 2+4+4 hextets, caught by actually running it); ratified VR1 ULA is
fd50:840e:74e2::/48.
repo-lint 0 fail; tests/dc-dc-prefixes-import 53/53.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
|
| 2026-07-10 |
DOCFIX-181: fix carve_gua subnet-enumeration hang (gauntlet was stalling)
...
netbox/dc-dc-prefixes-import.py::carve_gua() list()-ed every /64 subnet of the
GUA prefix (2**24 = ~16.7M for D-101's /40 example) only to use the first ->
hung/OOM. Real import runs would hang identically, not just the test. Fixed to
take the first /64 lazily via next() (O(1)).
Added a faulthandler watchdog to tests/dc-dc-prefixes-import/test_logic.py so a
future blocking regression self-aborts with a traceback after 30s instead of
silently stalling the whole run-tests-all.sh gauntlet -- the false-green that
let the ledger claim "40/40" for a test that never actually completed.
tests/dc-dc-prefixes-import: 40/40 in 0.05s (was: hung indefinitely).
Full gauntlet: ALL GREEN (52 harnesses). repo-lint 0 fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|

DC-DC Stage 1 (dc-dc-phase0): first real execution + scaffold/tooling fixes
...
First end-to-end run of a DC-DC runbook against real infrastructure (vcloud host,
OpenTofu v1.12.3). Applied 13 libvirt objects (6 DC1 planes, dc1/office1/dc2
pools, office1-local, 3 mesh legs, all MTU 9000); prior VR0 nets torn down (wan
kept as a gap-#17 model); DC2 planes deferred (NetBox supernet), DC2 storage wired.
DOCFIX-178: scripts/prereqs/ idempotent workstation prereq installers + a
Prerequisites section in the runbook + tests/prereqs/ (25/25). Runbooks must
not assume prereq runtimes.
DOCFIX-179: OpenTofu scaffold first validation + apply. Per-module
required_providers (child modules do NOT inherit provider SOURCE -> OpenTofu
inferred nonexistent hashicorp/libvirt); tofu fmt -recursive; provider "maas"
deferred to Stage 3 (kept a sensitive key out of Stage-1 plan/state);
repo-lint now skips the .terraform/ provider cache; .terraform.lock.hcl added.
DOCFIX-180: dc-dc-phase0 runbook as-executed corrections folded in at phase
close (two MTU domains, non-pristine-host handling, Step-9 13-resource plan,
-input=false, Known-Gap resolved).
repo-lint 0 fail; prereqs harness 25/25; repo-lint harness 34/34.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|