The 3 new inception_t0 comment lines were 137 chars; trimmed to 112. Every minor
is a latent bug — a lint violation in the diff is not left for later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps definition.version 2->3 for positioning, bybit_4edge, and
bybit_4edge_levered (construction changed: Task 4's per-coin gross cap
0.07 on the positioning sleeve). Pins inception_t0 to
{"date": "2026-07-06", "version": 3} on all three so the forward
anchor recomputes over the existing OOS window instead of resetting
the gate to today. bybit_4edge/bybit_4edge_levered move their pin from
2026-07-07 to 2026-07-06 (positioning's date) so the whole book
re-warms together from the shock's first day.
Updates the existing regression test
test_re_homed_sleeves_pin_their_recovered_inception's expected dates
for bybit_4edge/bybit_4edge_levered accordingly.
Add POSITIONING_COIN_GROSS_CAP constant (0.07) to bybit_forward_track.py with full
validation comment. Wire the constant through bybit_book_persist.py's sleeve-return
computation. Add test verifying the constant value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every minor is a latent bug: the two 'if uncapped and capped:' guards (Task 2+3
tests) would silently pass on an empty store, and the Task 3 '>=' would pass on a
no-op forwarding break. Replaced with a loud non-empty assert + strict '>'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thread coin_gross_cap parameter through _positioning_series and sleeve_returns_from_store to enable per-coin weight capping at the book level. The cap is forwarded only to the positioning edge; other sleeves (tstrend, unlock, xsfunding, stablecoin) remain unchanged.
With coin_gross_cap=None (default), behavior is byte-identical to the previous version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add coin_gross_cap: float | None = None parameter to _book_breakdown signature
- Pass coin_gross_cap to positioning_weights call in _book_breakdown
- Add coin_gross_cap: float | None = None parameter to positioning_returns_from_store signature
- Pass coin_gross_cap through to _book_breakdown in positioning_returns_from_store
- Add docstring note explaining the ex-ante per-coin concentration cap
- Add test_coin_gross_cap_reduces_single_coin_loss_in_book: validates that the cap
reduces worst-day loss on a shock store with one dominating coin crash
All existing tests remain green (24 passed); with coin_gross_cap=None (default),
behavior is byte-identical to prior behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds coin_gross_cap parameter to positioning_weights() to limit concentration
in any single coin. When set, clips each weight to ±cap then renormalizes to
unit gross (Σ|w|=1). When None (default), behavior is byte-identical to before.
Tests verify:
- cap clips weight concentration and maintains unit gross
- None cap produces byte-identical output
- All existing positioning tests still pass
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first live UCITS paper run on bizworx reported EXECUTED/exit-0 but was
degraded: the IEF->CBU0 leg got 0 fills and DBMF hung Submitted.
- Error 478 (contract conflict): the ISIN-qualified UCITS contract kept a
localSymbol/secId conflicting with the pinned conId (requested CBU0 vs
canonical CSBGU0). Strip secId/secIdType/localSymbol after resolve and route
by conId + SMART.
- Error 10349 (TIF preset): the bare MarketOrder had no TIF so the account
preset forced DAY + cancel/reconfirm. Set explicit tif="DAY".
- False-success (the dangerous one): plan.executed counted Cancelled/Submitted
legs as placed, and exec-record booked on any non-blocked run. Now executed
requires EVERY leg filled; a degraded run prints DEGRADED, exits 1, and
skips exec-track booking so the reconciliation gate stays WAIT (never books a
phantom rebalance) — real money errs false-WAIT.
Tests: new test_ucits_exec_fill_status.py + degraded cases in test_execution.py
and test_execute_multistrat_b2a.py (TDD failing-first). 523 exec/forward/gate
tests green, mypy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backtest-only gate (no forward wiring) for the China/India diversifier question,
decided from ~7y of real Yahoo data. Reuses the SSOT marginal_sharpe primitive +
multistrat.book_series verbatim; cost model = max(vol-norm re-sizing turnover,
monthly-rebalance floor) so a quiet co-crasher can't survive at ~0 modeled cost.
Result (10bp UCITS, 2019-08..2026-07): CNYA.L marginal Sharpe -0.099, NDIA.L
-0.048 — both co-crash the book (+0.23 corr) and worsen with cost. KILL both:
the accessible UCITS wrapper carries EM beta, not the mainland inefficiency
(direct A-shares/NSE closed to the EU-retail entity). Same co-crash death as VRP
and unlock. Do NOT wire the em_asia forward sleeve.
Gate is generic — reusable for any single-instrument diversifier candidate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
compare-measured-precompute is now the Dagster bybit_book_precompute asset
(K8sRunLauncher, own memory) — cron + NP deleted. vrp-rebalancer deleted
(shelved/falsified; vrp_nav de-wired from the nightly). Armed factory +
ucits-volume-ibkr in the manifests (source of truth) to match the live state;
bybit-rebalancer (no testnet creds) + ibkr-rebalancer (PRIIPs) stay suspended.
Final: Dagster owns all nightly ETL+precompute; K8s CronJobs own execution only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promotes the full consolidation branch to master (production ref the fund
git-syncs): DB/gitea/DNS migration to bizworx, the fxhnt-ingress proxy, the
.dbn archive work, and the scheduling-consistency upgrade — Dagster now owns
all nightly ETL+precompute via K8sRunLauncher (per-run 6-8Gi Jobs), with the
new bybit_book_precompute asset folding in the compare-measured/backtest-refs
crons. Execution stays in K8s CronJobs (blast-radius isolation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Configure K8sRunLauncher in the dagster-instance ConfigMap: each run launches
as its own K8s Job so the heavy bybit_book_precompute asset (6-8Gi op_tags)
runs isolated, never OOMing the 4Gi daemon. Launched Jobs git-sync the same
code (initContainer + emptyDir /code, same gitea+key), get the fund env +
secrets (DSN/PGPASSWORD/databento/tiingo), and need NO PVC (compute is
Postgres-only; unlock calendar is DB-first, so the RWO surfer PVC stays with
the daemon — no deadlock). Validated: DagsterInstance.from_config loads it as
K8sRunLauncher. No extra NP needed (no postgres ingress NP + no default-deny,
so launched Jobs reach postgres/gitea with open egress).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Jobs/pods/events RBAC to the tailscale-dagster SA (K8sRunLauncher launches
each run as its own Job) and point both dagster containers at the new
fxhnt-cockpit:dagster-k8s-25e3d7c image (dagster_k8s 0.28.22 baked in). The
K8sRunLauncher instance config comes next once the daemon is verified on the
new image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move _persist_bybit_book + _BYBIT_BOOK_SIDS to application/bybit_book_persist.py
(single source of truth; breaks the cli<->assets coupling). Add a new nightly
asset bybit_book_precompute (deps=[bybit_warehouse_refresh]) that wraps the
same helper backtest-refs/bybit-persist-sleeve-ret run — writing bybit_sleeve_ret
+ the report-kind gate refs in one compute-once pass. Tagged
dagster-k8s/config 6Gi req / 8Gi limit so K8sRunLauncher runs it in its own
right-sized Job (never the 4Gi daemon). Wired into combined_book_job + defs;
tests updated (13->14 assets + presence + wiring). CLI commands left intact
(deleted at the cron layer later). 60 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator: go all-Dagster properly + build the image right. Diagnosis
corrected: Dagster ETL was healthy all along (23:30 run SUCCESS, 14 assets,
forward_nav fresh); the stale data is the SUSPENDED compare-measured cron that
writes bybit_sleeve_ret. That cron exists because the compute needs 6-8Gi and
OOM-killed the 4Gi daemon — so the correct all-Dagster fix is K8sRunLauncher
(per-run Jobs with own memory), not daemon-run assets. Plan: add dagster-k8s,
build+push via podman (Argo/Kaniko pipeline is dead), add Job-launch RBAC,
configure K8sRunLauncher + per-asset memory, fold sleeve-ret/backtest-refs into
the DAG, materialize+verify, then delete compare-measured+VRP crons and arm
execution. Also commits the harmless working_directory=/code/src fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diagnosed why bybit went stale over the weekend: the Dagster code-server is
crash-looping (workspace.yaml working_directory=/app but git-sync code is at
/code/src -> no heartbeat -> 23:30 nightly never fires since Sat), AND all
fund crons are suspended from the cutover. Architecture is a deliberate
two-layer split (Dagster ETL @23:30 7-day + K8s cron execution) — operator
chose to keep it and make it consistent. Plan: fix the code-server dir,
catch-up materialize, arm compare-measured/factory/ucits-volume, delete the
dead VRP cron (shelved/falsified) + document. Real-money: only ucits trades.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The .dbn.zst S3 upload finished and verified byte-for-byte vs source
(rclone check --size-only: training 0 diffs/59 files, test 0 diffs/21 files;
80 objects total = source count). Two independent verified archives now exist
(S3 object storage + block snapshots, both in bizworx project, both survive
teardown). Cleaned up uploader/speedtest pods + s3 secrets; resumed the
foxhunt platform pool drain to 0. Compute heading to ~$0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The '132MB / 23 objects' were the first partial .dbn transfers, not garbage;
the resumed upload continues them. Verified singletest/+speedtest/ test files
already deleted (0 objects), bucket holds only legit training/*.dbn*. Marked
DO NOT wipe fxhnt-dbn-archive so it isn't mistaken for cleanable later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator wants the compressed .dbn.zst copied to S3 too (browsable archive
alongside the block snapshots). Diagnosed the earlier stall: node egress
~10-17MB/s (not compression), and too many concurrent big-chunk streams
choked the dev1_l node. Retuned to --transfers 2 --s3-chunk-size 32M and it's
moving (137GiB, ETA ~6-7h). Uploading directly from a foxhunt pod mounting the
live PVCs (CSI import of restored volumes was a rabbit hole). Persistent
monitor watching completion. Snapshots kept as the validated safety net until
the S3 copy is verified. Pool held at 1 node for the uploader.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rclone S3 upload stalled on the starved foxhunt cluster (dev1_l egress +
slow bssd read -> 76KiB/s, ETA 4 days). Pivoted to Scaleway block-volume
snapshots: instant, storage-layer, restorable, no tunnel. Snapshotted both
.dbn PVCs (training 537GB, test 50GB) into the bizworx project so they survive
teardown; both available in ~20s. Cleaned up uploader/inspector pods + s3
secret; resumed the node drain to 0. Stray fxhnt-dbn-archive bucket (132MB
junk) noted for later cleanup. Surfer .dbn already migrated + verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator caught databento .dbn data not covered by the pg migration.
- Surfer 22 .dbn (fund tsmom/carry research universe) migrated into the
bizworx fxhnt-surfer-data PVC; verified from the dagster pod (/data/surfer/
*.dbn + state JSONs). Research assets would've failed on the empty PVC.
- 171GB archived Rust-ML .dbn (training+test PVCs, not fund-used) uploading
to a new PRIVATE Scaleway bucket fxhnt-dbn-archive via an in-cluster rclone
pod — intra-Scaleway (fr-par pod -> fr-par S3), no tunnel (can't move 175GB
over the tailscale hop). S3 creds in a foxhunt secret, never printed.
Bumped platform pool min-size 0->1 so the inspector/uploader can schedule;
resume the drain to 0 after the upload completes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Operator confirmed migration complete + accepted dropping the instant-rollback.
Verified bizworx has zero runtime dep on foxhunt, the pre-cutover dump is
backed up off-cluster (~/Work/fxhnt-cutover-backup, 805 objects), and the fund
is live on bizworx. Scaled all foxhunt workloads (incl postgres/gitea/proxy)
+ cert-manager/tailscale to 0, suspended all cronjobs, set platform pool
min-size=0 so the autoscaler drains the 3 dev1_l nodes to 0. Cluster + all
PVCs preserved (not destroyed) — reversible by scaling back up; terraform
destroy deferred to T12. Rollback is now dump-restore, not instant DNS flip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Installed the playwright chrome channel and drove the local cockpit (bizworx
migrated DB) in a real browser. Full render confirmed: overview forward-tracks
with correct gates (sixtyforty GO, unlock PASS, bybit/multistrat WAIT), 4-edge
book $832,534, and the /paper IBKR card showing the real DU account $1,029,015
(not the 'not connected' empty-state). Matches DB cross-checks. T9 complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Local cockpit (dev-cockpit-local.sh, bizworx context) serves the migrated DB
read-only; numbers cross-checked against the DB: /paper IBKR card $1,029,015
== ibkr_account_nav.nlv latest, bybit Sh 2.11 / unlock Sh 1.03 == backtest_summary.
The /paper IBKR card renders the real DU account (not the 'not connected'
empty-state that degraded in a prior session). Forward tracks intact: anchor
t0 dates are original historical inceptions, not re-incepted to today — DB-anchor
SSOT survived the migration; recon gate recomputes correctly.
Also fixed dev-cockpit-local.sh to pass --extra web --extra pg (bare uv run
failed ModuleNotFoundError: uvicorn/psycopg on a fresh env).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cutover complete, foxhunt frozen-but-intact (rollback available):
- C1 froze foxhunt fund writers (cronjobs suspended, dagster/cockpit/
ib-gateway scaled to 0); infra cronjobs left running.
- C2 final delta pg_dump/restore fxhnt -> bizworx. --clean over the existing
rehearsal copy broke the tsdb catalog; recovered via drop+recreate empty DB
then clean restore (exit 0, 0 errors). All row counts match frozen source
(identical to rehearsal = zero real delta). Lesson: never --clean over a
live timescaledb DB.
- C3 brought bizworx fund live: cockpit serves real migrated data, ib-gateway
1/1 stable (foxhunt released the IBKR session), dagster 3/3.
- C4 flipped Scaleway fxhnt.ai dashboard/cockpit/dagster/git A-records ->
100.93.141.11 (bizworx fxhnt-ingress). Verified over real public DNS:
HTTP 200 + valid cert; git=bizworx Gitea, dashboard=cockpit w/ data, SSH
ls-remote returns real refs.
- C5 armed only fxhnt-ucits-rebalancer (next run 09:00 UTC); rest suspended.
Rollback until T11 = flip DNS back to 100.95.225.27 + un-suspend/scale-up
foxhunt. Next: T9 verify, T10 observe, then triage/teardown. DELETE NOTHING.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fxhnt-ingress proxy (nginx wildcard-TLS + socat SSH + kernel-mode tailscale)
now 3/3 and serving dashboard/cockpit/dagster/git.fxhnt.ai. EVAL P2 PASS:
from the devbox over the tailnet -> proxy tailnet IP 100.93.141.11, all four
hosts return HTTP 200 with a valid *.fxhnt.ai LE cert chain — foxhunt still
fully live, zero DNS change.
Three fixes to get there:
- Added tailscale-fxhnt-ingress SA+Role+RoleBinding (sidecar needs ts-state
Secret RBAC; mirrors tailscale-dagster).
- Re-applied dagster.yaml (its NP ingress fxhnt-ingress edit was committed
but never applied on-cluster -> dagster vhost hung).
- Added non-headless gitea-clusterip Service (ns gitea, 10.32.x): the Helm
gitea-http/gitea-ssh are headless -> resolve to 100.64.x pod IPs the
kernel-mode TS sidecar can't reach (CGNAT overlap). nginx+socat retarget it.
C4 DNS target recorded as 100.93.141.11 in the runbook. Phase-1 prep complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
P1: fxhnt-wildcard Certificate (*.fxhnt.ai) on bizworx. DNS-01 initially
403'd because fxhnt.ai zone is in the foxhunt project (c293eb98) and the
bizworx cert-manager key's DomainsDNSFullAccess was scoped to the bizworx
project only. Domain-move is blocked by Scaleway (external apex 'Root zone
can't be updated'), so fixed via IAM: added a rule granting
DomainsDNSFullAccess scoped to c293eb98 on policy bizworx-prod-runtime-scoped
(scw iam rule create; original rule untouched). Challenge then progressed
past 403 to normal DNS propagation wait.
P2 (draft, not applied): fxhnt-ingress = nginx (wildcard TLS termination) +
socat (git SSH) + tailscale sidecar, fronting the 3 fund vhosts by ClusterIP
(git/dashboard+cockpit/dagster). Uses ClusterIP not pod IPs (bizworx TS
sidecars reach 10.32.x ClusterIPs but not 100.64 pod IPs — CGNAT overlap,
both verified). dagster NP ingress already retargeted to this proxy's label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two corrections found while prepping P2:
1. The earlier 10.32.0.1/32 apiserver-egress fix was WRONG: kube-proxy
DNATs kubernetes.default.svc (10.32.0.1:443) to the REAL apiserver
endpoint 172.16.0.11:6443 BEFORE NetworkPolicy eval, and 172.16.0.0/16
is in the public-HTTPS except list -> still blocked -> tailscale sidecar
crashlooped again -> pod NotReady -> dagster Service had NO endpoints.
Correct fix: allow egress to 172.16.0.11:6443 (post-DNAT dest). Now
3/3 stable, endpoints populate. (apiserver endpoint from
NAME ENDPOINTS AGE
kubernetes 172.16.0.11:6443 85d.)
2. dagster NP ingress referenced foxhunt-only label tailscale-gitlab-proxy;
retargeted to app.kubernetes.io/name=fxhnt-ingress (the bizworx proxy
built in P2) so dagster.fxhnt.ai can be fronted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mapped the full fxhnt.ai ingress: all 13 A-records -> 100.95.225.27
(tailscale-gitlab-proxy pod, ns foxhunt = nginx vhosts + socat + tailscale)
which DIES at teardown, so DNS MUST move. Only git + dashboard/cockpit +
dagster are fund-critical (operator: grafana can go too; api/mail/chat/minio
legacy). Fund pipeline never uses fxhnt.ai (in-cluster git-sync + ClusterIP)
-> DNS flip only affects browser access, low-risk.
TLS resolved: bizworx already has cert-manager + scaleway DNS-01 webhook +
letsencrypt-prod issuer -> *.fxhnt.ai wildcard is just a Certificate. Chosen
cutover = stripped fund-only nginx+tailscale proxy on bizworx fronting the 3
vhosts by ClusterIP; DNS points at its tailnet IP. Written full executable
runbook (P1-P2 pre-flight, C1-C5 window) with VERIFY+ROLLBACK per step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dagster NP's public-HTTPS rule excepts the bizworx service CIDR
(10.32.0.0/16), which contains the apiserver ClusterIP 10.32.0.1. The
tailscale sidecar reads/writes its ts-state Secret via
kubernetes.default.svc, so it timed out (context deadline exceeded) and
crashlooped -> pod stuck 2/3. Add a surgical 10.32.0.1/32:443 egress
allow. daemon+webserver were always healthy; sidecar-only fix -> now 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IEF->CBU0 UCITS remap (515117d, 2026-07-16 12:28) left the paper account
holding IDTM (the old thin Dist line, bought on the 07-15/16 09:00 runs under the
prior IEF->IDTM config). The rebalancer only ever traded its TARGET symbols
(_plan iterates targets.items()), so it never sold the orphaned IDTM — it lingered
in the account NLV and (correctly) tripped the stray-holdings warning.
Fix — scoped, safe self-cleaning:
- superseded_holdings(): the symbols multistrat ITSELF previously traded (per its
own exec_fills) that are still held but no longer a target — priced at the last
fill. Scoped to multistrat's own fills, so a position ANOTHER strategy holds on
the SHARED IBKR account is never touched.
- plan_and_record: adds them at target weight 0 (+ LSEETF/USD spec so a UCITS line
resolves by symbol to SELL) before the rebalance. Best-effort: a scan failure
never blocks a real rebalance.
- _plan: a full EXIT (weight 0 on a held line) ALWAYS trades — hysteresis damps
churn, it must not strand a position we're deliberately closing.
Next UCITS rebalance (09:00) closes IDTM and buys CBU0; the warning clears. Also
prevents future mapping-change legacy from silently accreting in the NLV.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The multistrat ETF book is an UNDER-levered risk-parity book: it runs at ~5.7%
vol vs its own 10% target because leverage is capped at 1x (MAXLEV=1.0), leaving
return on the table. Add a return-defined observe-only shadow (cf.
bybit_4edge_levered) that levers the SAME series to the 10% vol target minus the
HONEST financing drag on the borrowed leg.
- LeveredShadowStrategy (paper_strategies.py): L = min(max_lev, target_vol/vol),
floored at 1x; levered_ret = L·r − (L−1)·financing/252. Never executed.
- multistrat_levered_nav asset (deps=multistrat_nav) publishes its forward track
+ basis-matched backtest ref like the base; registered in the nightly job.
- multistrat_levered registry entry + SIM_BOOKS (Backtest switcher).
- financing_bps=530 GROUNDED in IBKR's real USD schedule (verified 2026-07-18:
Fed funds 3.63% + Pro tiered spread → 5.83% <$100k / ~5.3% $100k-1M).
- leverage_financing_sensitivity() + a cockpit BAND on the levered book's
Backtest view: levered return/vol/Sharpe across 1..6.83% + the break-even
(= the book's 7.7% return), so the verdict is never hostage to one rate.
Honest finding it surfaces: at any realistic IBKR retail rate (~5-7%) levering
this modest-return book LOWERS risk-adjusted return (Sharpe 1.35 -> ~0.9); it
only pays at institutional financing (~1-3%). The financing drag is what makes
the shadow honest (without it, free leverage would look like 13.5%/Sharpe 1.35).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Following the /paper blurb removal, sweep the remaining pages:
- Backtest (sim.html): drop the paragraph-long verdict banner; keep a one-line
plain subtitle ("a what-if on capital & period at real trading cost").
- Measured-cost caption (_sim_result.html): visible text now plain
("real trading cost, per coin — fee + spread · avg drag X%"); the technical
detail (L1 quoted spread / current-snapshot caveat / "not a slider") moves to
the hover tooltip. IBKR book caption likewise plain.
- Hard-coded "vol-targeted @ 20%" label (Overview, paper_crypto, strategy) ->
"each shown on its own" (drops the hard-coded 20% + the jargon).
Tests updated to the new plain copy; technical strings that stay in tooltips
(real L1 quoted spread, the snapshot caveat) are still asserted there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Paper-books landing led with a paragraph-long verdict banner ("Two paper
accounts, one job..."). The two book cards below already convey account value,
holdings and status, so the blurb was redundant text. Keep just the h1 + a
one-line subtitle. Removes the two tests that asserted the banner copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>