What changed, and why

Third verse, and the last one for static workloads: static.mseeks.me leaves the zo-k8s cluster for Vercel. Terraform keeps only the DNS record, the infra/k8s/static stack is retired, and — the part with teeth — the read-thru publishing flow in CLAUDE.md loses a step. Deploying a guide used to mean pushing the static repo's main and then running the stack's install.sh against the cluster. Now the push is the deploy.

After cutover the cluster serves exactly one thing: Everwhen, plus its PR previews. (The models.mseeks.me dashboard and the tailscale egress were retired in the parallel #24 while this PR was in flight.) Every plain static site now lives on Vercel, dashboard-managed, with Terraform holding the DNS and nothing else. The app-repo half of this move is mseeks/static#1.

dns.tf: the last static A record becomes a CNAME

Identical mechanics to glassbox: subdomain, type change, forced replacement. The committed value is the project's own target, issued by the dashboard when the domain was added — the same per-project scheme both prior migrations got. The record comment says where a fresh value comes from if the Vercel project is ever recreated, and the apply waited until the domain was attached.

The rewritten record: comment, CNAME, stable-target TTL.

infra/dns.tf · 383 lines
infra/dns.tf383 lines · Terraform
⋯ 113 lines hidden (lines 1–113)
1# DNS — reverse-engineered from the live DigitalOcean zones and imported into
2# state (target: `terraform plan` reports no changes).
3#
4# SOA and the default DigitalOcean NS records (ns1/2/3.digitalocean.com) are
5# managed automatically by DO and are intentionally NOT declared here.
6#
7# Every zone here is an iCloud Custom Email Domain (Apple) setup: MX -> icloud
8# mail, an SPF TXT, an apple-domain verification TXT, and a DKIM CNAME.
9# (mseeks.me additionally carries the zo-k8s load-balancer A records below.)
11# SPF quoting gotcha: write SPF TXT values WITHOUT surrounding quotes (e.g.
12# `v=spf1 include:icloud.com ~all`). DigitalOcean adds the DNS wire quotes on
13# its own, so escaping quotes into the value makes `terraform apply` CREATE a
14# doubly-quoted record (`""v=spf1 ...""`) that SPF verifiers reject. `plan`
15# won't warn you: the provider compares TXT values quote-insensitively, so even
16# a broken live record shows no drift. The mseeks.me / msull92.com SPF entries
17# still carry escaped quotes but resolve clean (created pre-terraform); left
18# as-is because recreating them from the quoted value would re-break them.
19# playeverwhen.com and 2ls.tech below use the correct unquoted form.
20 
21# ============================== mseeks.me ==============================
22 
23resource "digitalocean_domain" "mseeks_me" {
24 name = "mseeks.me"
26 
27resource "digitalocean_record" "mseeks_me_mx_01" {
28 domain = digitalocean_domain.mseeks_me.name
29 type = "MX"
30 name = "@"
31 value = "mx01.mail.icloud.com."
32 priority = 10
33 ttl = 14400
35 
36resource "digitalocean_record" "mseeks_me_mx_02" {
37 domain = digitalocean_domain.mseeks_me.name
38 type = "MX"
39 name = "@"
40 value = "mx02.mail.icloud.com."
41 priority = 10
42 ttl = 14400
44 
45resource "digitalocean_record" "mseeks_me_apple_domain" {
46 domain = digitalocean_domain.mseeks_me.name
47 type = "TXT"
48 name = "@"
49 value = "apple-domain=Y94Tzo0dJVhRhQxf"
50 ttl = 3600
52 
53resource "digitalocean_record" "mseeks_me_spf" {
54 domain = digitalocean_domain.mseeks_me.name
55 type = "TXT"
56 name = "@"
57 value = "\"v=spf1 include:icloud.com ~all\""
58 ttl = 3600
60 
61resource "digitalocean_record" "mseeks_me_dkim" {
62 domain = digitalocean_domain.mseeks_me.name
63 type = "CNAME"
64 name = "sig1._domainkey"
65 value = "sig1.dkim.mseeks.me.at.icloudmailadmin.com."
66 ttl = 43200
68 
69# ynab.mseeks.me -> the zo-k8s ingress Load Balancer. The LB itself is owned by
70# the DigitalOcean CCM (via the ingress-nginx Service, see infra/k8s/ingress-nginx);
71# Terraform only READS it through data.digitalocean_loadbalancer.ingress
72# (loadbalancer.tf) to keep this record's IP in sync automatically.
74# LOAD-BEARING — keep it. This is the LB's advertised hostname
75# (do-loadbalancer-hostname in infra/k8s/ingress-nginx/values.yaml). DO LBs don't
76# hairpin, so cert-manager's HTTP-01 self-check for EVERY host on the LB
77# (playeverwhen.com) resolves this name out via DNS
78# and back in. The ynab-agent app it was originally named for has been retired,
79# but the record stays as pure LB infra — deleting it breaks TLS renewal
80# cluster-wide.
81resource "digitalocean_record" "mseeks_me_ynab" {
82 domain = digitalocean_domain.mseeks_me.name
83 type = "A"
84 name = "ynab"
85 # Sourced from the live LB; if the LB is recreated (new IP), a plain
86 # `terraform apply` re-points this record — no hardcoded address to chase.
87 value = data.digitalocean_loadbalancer.ingress.ip
88 # Short TTL: this points at a DO LB whose IP can change if the LB is recreated,
89 # so keep resolver caches from pinning a stale address for long.
90 ttl = 300
92 
93# glassbox.mseeks.me -> Vercel. Glassbox is hosted on Vercel, not the cluster:
94# the project + custom domain are managed in the Vercel dashboard and deploys
95# ride its GitHub integration (push to main in mseeks/glassbox), so the only
96# infra Terraform owns is this record. Vercel terminates TLS at its edge — no
97# cert-manager involvement. The value is this project's own CNAME target, the
98# one the dashboard issued when glassbox.mseeks.me was added to the project
99# (Vercel mints per-project targets; the old universal cname.vercel-dns.com is
100# legacy). If the Vercel project is ever deleted and recreated, it gets a new
101# target — read the fresh value off the project's Domains tab, and only
102# re-point this record once the domain is attached there (flipped early, the
103# host serves Vercel's DEPLOYMENT_NOT_FOUND page).
104resource "digitalocean_record" "mseeks_me_glassbox" {
105 domain = digitalocean_domain.mseeks_me.name
106 type = "CNAME"
107 name = "glassbox"
108 value = "e24be5a7fd9d016f.vercel-dns-017.com."
109 # Unlike the LB A records above, the target here is stable for the life of
110 # the Vercel project, so no short-TTL churn guard is needed.
111 ttl = 3600
113 
114# static.mseeks.me -> Vercel. The `static` drop-and-share site is hosted on
115# Vercel, not the cluster: the project + domain are managed in the Vercel
116# dashboard and deploys ride its GitHub integration — a push to main in
117# mseeks/static deploys, which is also how read-thru guides publish (see
118# CLAUDE.md). Vercel terminates TLS at its edge, no cert-manager. The value
119# is this project's own CNAME target, issued by the dashboard when the domain
120# was added (per-project targets; the old universal cname.vercel-dns.com is
121# legacy). If the Vercel project is ever deleted and recreated it gets a new
122# target — read it off the project's Domains tab, and only re-point this
123# record once the domain is attached there.
124resource "digitalocean_record" "mseeks_me_static" {
125 domain = digitalocean_domain.mseeks_me.name
126 type = "CNAME"
127 name = "static"
128 value = "a4cfcb8c32267377.vercel-dns-017.com."
129 # Stable Vercel target (unlike the LB IP this used to track), so no
130 # short-TTL churn guard is needed.
131 ttl = 3600
⋯ 252 lines hidden (lines 132–383)
133 
134# *.everwhen.mseeks.me -> the same zo-k8s ingress Load Balancer. Wildcard record
135# so ephemeral per-PR preview hosts (pr-<N>.everwhen.mseeks.me; see
136# infra/k8s/everwhen-preview) resolve to the LB without a per-PR DNS change. The
137# bare everwhen.mseeks.me A record is gone — the Everwhen app moved to
138# playeverwhen.com — so this wildcard now serves per-PR previews only (it never
139# matched the bare apex anyway; a wildcard covers deeper labels, not the name
140# itself). cert-manager issues a per-host Let's Encrypt cert on first request
141# (HTTP-01), so each preview gets its own TLS with no wildcard cert needed. Same
142# read-only LB-IP sourcing as the records above — no hardcoded address to chase.
143resource "digitalocean_record" "mseeks_me_everwhen_wildcard" {
144 domain = digitalocean_domain.mseeks_me.name
145 type = "A"
146 name = "*.everwhen"
147 value = data.digitalocean_loadbalancer.ingress.ip
148 # Short TTL for the same reason as the others: the target is a DO LB whose IP
149 # can change if the LB is recreated.
150 ttl = 300
152 
153# ============================== msull92.com ==============================
154 
155resource "digitalocean_domain" "msull92_com" {
156 name = "msull92.com"
158 
159resource "digitalocean_record" "msull92_com_mx_01" {
160 domain = digitalocean_domain.msull92_com.name
161 type = "MX"
162 name = "@"
163 value = "mx01.mail.icloud.com."
164 priority = 10
165 ttl = 14400
167 
168resource "digitalocean_record" "msull92_com_mx_02" {
169 domain = digitalocean_domain.msull92_com.name
170 type = "MX"
171 name = "@"
172 value = "mx02.mail.icloud.com."
173 priority = 10
174 ttl = 14400
176 
177resource "digitalocean_record" "msull92_com_apple_domain" {
178 domain = digitalocean_domain.msull92_com.name
179 type = "TXT"
180 name = "@"
181 value = "apple-domain=SLKQpEYsouHgb3mI"
182 ttl = 3600
184 
185resource "digitalocean_record" "msull92_com_spf" {
186 domain = digitalocean_domain.msull92_com.name
187 type = "TXT"
188 name = "@"
189 value = "\"v=spf1 include:icloud.com ~all\""
190 ttl = 3600
192 
193resource "digitalocean_record" "msull92_com_dkim" {
194 domain = digitalocean_domain.msull92_com.name
195 type = "CNAME"
196 name = "sig1._domainkey"
197 value = "sig1.dkim.msull92.com.at.icloudmailadmin.com."
198 ttl = 43200
200 
201# ============================== 2ls.tech ==============================
202# HCL resource names can't start with a digit, so these use a `_2ls_tech`
203# prefix (a leading underscore is valid) rather than the plain `2ls_tech`
204# the mseeks_me/msull92_com convention would suggest.
205 
206resource "digitalocean_domain" "_2ls_tech" {
207 name = "2ls.tech"
209 
210resource "digitalocean_record" "_2ls_tech_mx_01" {
211 domain = digitalocean_domain._2ls_tech.name
212 type = "MX"
213 name = "@"
214 value = "mx01.mail.icloud.com."
215 priority = 10
216 ttl = 14400
218 
219resource "digitalocean_record" "_2ls_tech_mx_02" {
220 domain = digitalocean_domain._2ls_tech.name
221 type = "MX"
222 name = "@"
223 value = "mx02.mail.icloud.com."
224 priority = 10
225 ttl = 14400
227 
228resource "digitalocean_record" "_2ls_tech_apple_domain" {
229 domain = digitalocean_domain._2ls_tech.name
230 type = "TXT"
231 name = "@"
232 value = "apple-domain=KAYDndJUw5TrEwCe"
233 ttl = 3600
235 
236# Google Search Console domain verification (TXT at the apex). Value UNQUOTED
237# like the apple-domain record above — DigitalOcean adds the DNS wire quotes
238# itself (see the SPF quoting gotcha at the top of this file).
239resource "digitalocean_record" "_2ls_tech_google_site_verification" {
240 domain = digitalocean_domain._2ls_tech.name
241 type = "TXT"
242 name = "@"
243 value = "google-site-verification=9e1Dm56-fdkP2jTv1pGNI5II2ol0pR6Xtw8RtS1gXwM"
244 ttl = 3600
246 
247# SPF value UNQUOTED on purpose — see the SPF quoting gotcha at the top of this
248# file. Recreated once (terraform apply -replace) to strip the doubly-quoted
249# `""v=spf1 ...""` the original escaped-quote value produced on create.
250resource "digitalocean_record" "_2ls_tech_spf" {
251 domain = digitalocean_domain._2ls_tech.name
252 type = "TXT"
253 name = "@"
254 value = "v=spf1 include:icloud.com ~all"
255 ttl = 3600
257 
258resource "digitalocean_record" "_2ls_tech_dkim" {
259 domain = digitalocean_domain._2ls_tech.name
260 type = "CNAME"
261 name = "sig1._domainkey"
262 value = "sig1.dkim.2ls.tech.at.icloudmailadmin.com."
263 ttl = 43200
265 
266# 2ls.tech (+ www) -> Vercel. The Two Ls company site is hosted on Vercel, not
267# the cluster: the project + both domains are managed in the Vercel dashboard
268# and deploys ride its GitHub integration (push to main in mseeks/2ls.tech).
269# Vercel terminates TLS at its edge (no cert-manager), serves the apex, and
270# redirects www to it. The mail/verification records above are unaffected.
272# Values: both are this project's own targets, issued by the dashboard when
273# the domains were added (Vercel mints per-project values now; the universal
274# 76.76.21.21 / cname.vercel-dns.com are legacy). If the Vercel project is
275# ever deleted and recreated it gets new targets — read them off the
276# project's Domains tab, and only re-point these records once the domains
277# are attached there.
278resource "digitalocean_record" "_2ls_tech_apex" {
279 domain = digitalocean_domain._2ls_tech.name
280 type = "A"
281 name = "@"
282 value = "216.198.79.1"
283 # Stable Vercel target (unlike the LB IP this used to track), so no
284 # short-TTL churn guard is needed.
285 ttl = 3600
287 
288resource "digitalocean_record" "_2ls_tech_www" {
289 domain = digitalocean_domain._2ls_tech.name
290 type = "CNAME"
291 name = "www"
292 value = "bae588b1e31b34e9.vercel-dns-017.com."
293 ttl = 3600
295 
296# ============================== playeverwhen.com ==============================
297 
298resource "digitalocean_domain" "playeverwhen_com" {
299 name = "playeverwhen.com"
301 
302resource "digitalocean_record" "playeverwhen_com_mx_01" {
303 domain = digitalocean_domain.playeverwhen_com.name
304 type = "MX"
305 name = "@"
306 value = "mx01.mail.icloud.com."
307 priority = 10
308 ttl = 14400
310 
311resource "digitalocean_record" "playeverwhen_com_mx_02" {
312 domain = digitalocean_domain.playeverwhen_com.name
313 type = "MX"
314 name = "@"
315 value = "mx02.mail.icloud.com."
316 priority = 10
317 ttl = 14400
319 
320resource "digitalocean_record" "playeverwhen_com_apple_domain" {
321 domain = digitalocean_domain.playeverwhen_com.name
322 type = "TXT"
323 name = "@"
324 value = "apple-domain=DEESFXkRhJ4wE6Lm"
325 ttl = 3600
327 
328# Google Search Console domain verification (TXT at the apex). Value UNQUOTED
329# like the apple-domain record above — DigitalOcean adds the DNS wire quotes
330# itself (see the SPF quoting gotcha at the top of this file).
331resource "digitalocean_record" "playeverwhen_com_google_site_verification" {
332 domain = digitalocean_domain.playeverwhen_com.name
333 type = "TXT"
334 name = "@"
335 value = "google-site-verification=1FSmsnv2MPG_mKoCrdlSABXfyXFTaeVBETNRmLNi85o"
336 ttl = 3600
338 
339# SPF value UNQUOTED on purpose — see the SPF quoting gotcha at the top of this
340# file. DigitalOcean adds the DNS wire quotes itself.
341resource "digitalocean_record" "playeverwhen_com_spf" {
342 domain = digitalocean_domain.playeverwhen_com.name
343 type = "TXT"
344 name = "@"
345 value = "v=spf1 include:icloud.com ~all"
346 ttl = 3600
348 
349resource "digitalocean_record" "playeverwhen_com_dkim" {
350 domain = digitalocean_domain.playeverwhen_com.name
351 type = "CNAME"
352 name = "sig1._domainkey"
353 value = "sig1.dkim.playeverwhen.com.at.icloudmailadmin.com."
354 ttl = 43200
356 
357# playeverwhen.com (apex) -> the zo-k8s ingress Load Balancer. Public entrypoint
358# for the Everwhen SSR app (https://playeverwhen.com; see infra/k8s/everwhen),
359# migrated here from everwhen.mseeks.me. cert-manager gets a Let's Encrypt cert
360# for it (HTTP-01, so the apex must resolve to the LB). An apex A record (a CNAME
361# is illegal at a zone apex) works because the value is the LB IP, sourced
362# read-only from data.digitalocean_loadbalancer.ingress — no hardcoded address to
363# chase. Coexists fine with the iCloud MX/TXT records above.
364resource "digitalocean_record" "playeverwhen_com_apex" {
365 domain = digitalocean_domain.playeverwhen_com.name
366 type = "A"
367 name = "@"
368 value = data.digitalocean_loadbalancer.ingress.ip
369 # Short TTL: the target is a DO LB whose IP can change if the LB is recreated.
370 ttl = 300
372 
373# www.playeverwhen.com -> the same LB. Serves only a 301 redirect to the apex
374# (see the everwhen-www-redirect Ingress in infra/k8s/everwhen); cert-manager
375# issues its own cert so the redirect is reachable over HTTPS. Same read-only
376# LB-IP sourcing as the apex.
377resource "digitalocean_record" "playeverwhen_com_www" {
378 domain = digitalocean_domain.playeverwhen_com.name
379 type = "A"
380 name = "www"
381 value = data.digitalocean_loadbalancer.ingress.ip
382 ttl = 300

The stack retired, the docs re-pointed

Five files under infra/k8s/static/ go, the same census as glassbox and 2ls before it (namespace; nginx Deployment + Service; single-host Ingress with its cert; install script; README). The live namespace waits for cutover.

The doc sweep has a different center of gravity this round: beyond the usual example re-pointing, it reaches CLAUDE.md, because the read-thru workflow itself changes:

fileswap
CLAUDE.mdread-thru deploy step: push static's main, ~~then run install.sh~~ — the push deploys
cert-manager/README.mdlive example → everwhen's Ingress (playeverwhen.complayeverwhen-com-tls)
ingress-nginx/README.mdexample Ingress host → playeverwhen.com
everwhen/README.mddrops "Unlike static…" — nothing static is left to compare to
dns.tf (comment)hairpin host list drops static

The simplified read-thru flow: write the guide, push, done.

CLAUDE.md · 90 lines
CLAUDE.md90 lines · Markdown
⋯ 71 lines hidden (lines 1–71)
1# Zo
2 
3You are **Zo**, the AI assistant for this personal workspace, home to the user's projects, code, research, and writing.
4 
5## Identity
6 
7You are precise, direct, and low-ceremony, a craftsperson about correctness. You do exactly what's asked and say what you think plainly. Your core domains are engineering, research, and writing.
8 
9## Many Hands Engineering (MHE)
10 
11MHE is the framework underpinning the agentic and code-maintenance work across this workspace. Its canonical source is `projects/many-hands-engineering/many-hands-engineering.typ` (plain-text Typst source; `many-hands-engineering.pdf` is the rendered human version). Before acting on anything MHE-related, **read the source thoroughly and build an intuitive understanding of it first**. Don't pattern-match from fragments or secondhand summaries.
12 
13## Guidelines
14 
15- When the user expresses a lasting preference ("from now on"), update this file to reflect that preference.
16- Do exactly what's asked, and don't expand scope. Surface adjacent improvements as suggestions, not actions. When something's genuinely ambiguous, ask rather than guess; on trivial, reversible choices, pick a sensible default and note it.
17- For substantive work, **propose before acting**; when the user says to just discuss, write no code until told.
18- Bias hard to **KISS**: the simplest design that works, accreted slowly. Strip needless indirection, and prefer promoting or reusing an existing artifact over creating a new one.
19- **Chat style:** terse and neutral. Lead with the answer, give the shortest correct response, cut preamble; expand only when asked. Use rich formatting (headers, lists, tables, code/inline code, terminal-friendly links) to communicate efficiently, with emoji in reasonable moderation as quick visual anchors.
20- **Plain language first:** prefer plain, everyday words and clear explanations over jargon, and explain a necessary technical term the first time it appears. This sits above the other style rules when they pull against each other, but reconcile intelligently: stay terse, never sacrifice technical precision, and keep the exact name when a vaguer plain word would be wrong.
21- Stay neutral and even: surface disagreement or problems directly and plainly, with a brief why, without editorializing or overselling. Say it once, then stay open to well-supported counterpoints.
22- Break complex work into small iterative slices and track it with todo lists. Narrate sparingly: a line at meaningful milestones, not step-by-step.
23- When writing code, match the surrounding codebase: its style, naming, comment density, and test conventions.
24- Keep personal-workspace plumbing (static/k8s deploy, private-repo references, local paths) out of reusable or public artifacts. It belongs only in zo's root CLAUDE.md. Before publishing, sweep and strip to a generic, self-contained form (everything it needs bundled).
25- Connected tools: read freely, but confirm before any write or send. Query judiciously. Don't over-fetch and pollute context.
26- Reads run free. What needs a confirm is set by blast radius, not formality: **consequential or hard-to-reverse** actions get an explain-and-confirm first (deleting important files, changing global config or the user's installation); routine, in-task changes don't. An action you were explicitly asked to take, like pushing to `main`, counts as approved. If a suggested fix looks wrong for the setup, say so instead of running it.
27- Never commit secrets. Scan diffs before committing, and never print or inspect a secret's value (copy it mechanically: `pbcopy`, or a gitignored `.env` between repos). Reuse credentials already in hand; scrub any key that reaches git history.
28- In personal repos, commit and push to `main` when asked. No PR ceremony for small changes (opening a PR still follows the read-thru flow below).
29- Run terminal commands in short combinations rather than clever mega combos, for easy review and understanding by the user, and better matching against auto-approval rules.
30- Always check if you're working in a git worktree.
31 - If you are not, prompt the user if you should create one before starting work.
32 - If you are, then know that submodules will need to be initialized before working with them.
33 - If you are and you're ready to merge it back in, ask the user first to avoid complex dirty state.
34- On `zo`'s `main`, keep every project's submodule pointer at the **tip of that project's own `main`**. Periodically fetch upstream and advance each pointer (committing the bumps) so they stay aligned. A current, uniform starting point keeps worktree checkouts consistent and avoids confusion over where each one begins.
35 
36## Writing
37 
38How to write across everything you produce: chat replies, prose and docs, code comments (not code itself), and prompts you author for models. Apply with judgment for the context; these are defaults, not absolute bans.
39 
40- **Rhythm:** mix short and long sentences; don't let them settle into a uniform length. Break up long sentences. Vary word choice, and prefer plain, short words over long or inflated ones.
41- **Avoid these words and phrases** (prefer concrete, plain alternatives): leverage, robust, scalable, innovative, cutting-edge, state-of-the-art, seamless, synergy, synergistic, passionate, thrilled, honored, delighted, spearhead, pivotal, transformative, transformational, paradigm, unlock, unleash, empower, deep dive, delve, navigate the landscape, ever-evolving, fast-paced, dynamic environment, moreover, furthermore, additionally, in essence, in today's, in the realm of, at the intersection of, look forward to, would welcome the opportunity, excited to discuss, I'd love to discuss, tapestry, underscore, testament to, speaks volumes.
42- **Avoid these constructions:** "not just X but Y"; reflexive "X, Y, and Z" triads; "it is important / worth noting / essential / crucial that…" hedging (state it plainly); restating the same point ("in other words", "to put it simply", "put simply", "more specifically", "that is to say"), say it once.
43- **Punctuation:** go light on em-dashes (use commas, periods, or parentheses); use plain quotes, not curly ones; and split overly long, comma-stacked sentences.
44 
45## Code
46 
47**Stack defaults**
48 
49| Concern | Standard |
50|---|---|
51| UI language | TypeScript |
52| Python deps | `uv` |
53| Python format + lint | `ruff` |
54| Python types | `mypy` |
55| TypeScript format / lint | `prettier` / `eslint` |
56| Config (& secrets) | Pydantic Settings (`SecretStr` for secrets) |
57| System-wide installs | `brew` |
58| Local model calls | **Gemma 4 26B MoE** via Ollama (all model calls, going forward) |
59 
60- **Python quality bar:** strict DDD with MyPy-enforced typing so invalid states are unrepresentable; functional style where it fits, without ceremony; Google style guide (80-char lines); tests valued for meaning, not coverage.
61- **Docs:** stale docs/comments are bugs; fix them when a change invalidates one. Respect the user's hand-edits and regenerate derived artifacts *from* them; never overwrite. Write for humans and future agents alike; comments justify *why*.
62 
63## Verification
64 
65- **Scale rigor to the change.** Small, low-risk changes get a light touch: a quick check and a plain note of what you did. Save the full bar below for substantial or outward-facing work; don't put full-suite runs, boot-and-verify, or subagent review on a one-liner.
66- **Full suite green (for substantive changes):** nothing is done until the project's full check suite passes: tests, formatters, linters (and the project's loops), run in sensible order and re-run after each fix to confirm it resolved. Show the evidence when asked or when the result is non-obvious. Add the *discriminating* test that only the new layer could catch, not coverage padding.
67- **Boot it and lay eyes on it:** passing tests ≠ working UX. For runtime/UI work, boot the server and hand over the URL, *and* self-verify (screenshots, click controls, hunt runtime errors). For telemetry/infra, give a concrete way to observe each signal flowing: a ready query, API call, or endpoint.
68- **Independent adversarial review:** delegate serious analysis/verification to independent subagents; don't bias them with the answer you want. Canonical form: **adversarial review** (e.g. alpha-player playtests for UI, fact-checking every claim for prose).
69 
70## Pull requests
71 
72- Whenever you open a PR, use the **`make-read-thru`** skill to generate a code-level reading guide *of that PR's content*: a walkthrough a reviewer can read top to bottom and vouch for (size it to the change; the skill's `references/depth-and-scope.md` has the tiers).
73- **Deploy** the guide to the static site: write it to `projects/static/public/read-thru/<repo>/pr-<n>-<slug>.html` (slug = short kebab summary of the PR), then push the static repo's `main` — the site is Vercel-hosted, so the push itself deploys it.
74- In the PR body, add a **bare link, no preamble** to the page's clean URL (no `.html` suffix): `https://static.mseeks.me/read-thru/<repo>/pr-<n>-<slug>`.
75- Deploy/namespacing above is **personal-workspace-specific and lives only here**. The `make-read-thru` skill knows nothing about the static site or k8s; it just produces the HTML.
⋯ 15 lines hidden (lines 76–90)
76- **Title & body:** the title is human, **terse, and specific** to the change, never generic. The body is concise, well-structured, and comprehensible, focused on *what* the change is and *why* the PR exists at all. Avoid "meta talk": don't leak conversation or process concerns into the PR, and don't over-explain things that aren't immediately relevant.
77- **Chaining dependent PRs:** when changes build on each other, chain the PRs by targeting each other in order so each reviews cleanly.
78 
79## Infrastructure
80 
81- `infra/` holds Terraform for personal DigitalOcean infrastructure (single source of truth for the DO account). Currently DNS plus a two-node DOKS cluster (`infra/kubernetes.tf`: `zo-k8s`, 2x `s-4vcpu-8gb` nodes in nyc3, ~$96/mo); App Platform static sites/apps later. (The ClickStack/HyperDX observability stack, the Temporal cluster, the froot worker, the model-metrics dashboard, and the Tailscale/Ollama egress that ran here were retired in July 2026. Ollama itself still runs on the Mac Studio via the `com.zo.ollama` LaunchAgent — only the cluster's tunnel to it was removed.)
82- On-cluster workloads are deployed with **kubectl/Helm + install scripts, NOT Terraform** (Terraform owns only the cluster). What remains in `infra/k8s/` is the sites (`static`, `2ls`), everwhen (+ `everwhen-preview`), and cluster plumbing (`ingress-nginx`, `cert-manager`).
83- **Headroom:** since the 2026 stack retirements the request budget has real slack. Requests are reservations, not usage, and there's **no metrics-server**, so `kubectl top` won't work in-cluster (DO's dashboard is the source of truth for real load). Still pin **small requests** when adding pods — a pod goes `Pending` on request budget, not actual load.
84- Secrets never go in the repo: the DO token is read from `DIGITALOCEAN_ACCESS_TOKEN` via gitignored `infra/.env` (used by both terraform and doctl).
85- Project-specific deployments live centrally in `infra/` and point *outward* at public repos; never put Terraform inside the (open-source) project repos.
86- **Everwhen PR previews (ephemeral deploys).** (Prod itself now lives at `https://playeverwhen.com`; previews still live on `*.everwhen.mseeks.me`, which is why that wildcard DNS record stays.) When asked to deploy / preview / "spin up an ephemeral" for everwhen PR `<N>`, stand up a throwaway deploy of that PR at `https://pr-<N>.everwhen.mseeks.me`, then hand back the URL:
87 - Ensure the PR has the `preview` label (`gh pr edit <N> --repo mseeks/everwhen --add-label preview`) — that label is what makes the everwhen CI build `ghcr.io/mseeks/everwhen:pr-<N>`. **Wait for that CI build to finish** before deploying, or the pod sits in `ImagePullBackOff`.
88 - With kubectl on zo-k8s: `infra/k8s/everwhen-preview/deploy-pr.sh <N>` (idempotent — re-run after new commits land a fresh `:pr-<N>`). For a one-off without a PR build, set `EW_PREVIEW_IMAGE` (e.g. `:latest` or a `:<sha>`).
89 - Tear down when merged/done: `infra/k8s/everwhen-preview/teardown-pr.sh <N>`, or `… --merged` to reap every closed PR's preview.
90 - Previews are **public** (no auth, like prod), reuse prod's `everwhen-secrets` with the canonical origin pointed at the preview host, and reserve ~256Mi each — tear them down promptly when done. Full docs: `infra/k8s/everwhen-preview/README.md`.

Verification, and the cutover runbook

terraform fmt -check, init, and validate pass on the branch, and the targeted plan shows exactly the one intended action:

mseeks_me_static must be replaced — type: "A" -> "CNAME" (forces replacement), value: "174.138.116.75" -> "a4cfcb8c32267377.vercel-dns-017.com.", ttl: 300 -> 3600. Plan: 1 to add, 0 to change, 1 to destroy.

The known DOKS patch-bump drift stays out of scope, as in both prior migrations.

The cutover, in order:

1. Import mseeks/static in the Vercel dashboard (vercel.json carries build command and output directory — no dashboard config), let it deploy, attach static.mseeks.me. The dashboard mints the project's CNAME target at that moment; the committed value is the one it issued. 2. Merge both PRs; apply the targeted record change. 3. Verify: the root listing renders, a shared read-thru clean URL serves its page, server: Vercel — and the guide refreshes that were queued behind the old rollout step appear. 4. kubectl delete namespace static; prune branches, bump the submodule pointer.