diff --git a/public/banners/adding-a-domain-to-someone-elses-mail-server.png b/public/banners/adding-a-domain-to-someone-elses-mail-server.png
new file mode 100644
index 0000000..dcda91d
Binary files /dev/null and b/public/banners/adding-a-domain-to-someone-elses-mail-server.png differ
diff --git a/public/banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png b/public/banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
new file mode 100644
index 0000000..4043073
Binary files /dev/null and b/public/banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png differ
diff --git a/public/banners/my-backup-was-using-someone-elses-api-quota.png b/public/banners/my-backup-was-using-someone-elses-api-quota.png
new file mode 100644
index 0000000..cf531b0
Binary files /dev/null and b/public/banners/my-backup-was-using-someone-elses-api-quota.png differ
diff --git a/public/banners/space-reclamation-wasnt-stuck-it-was-waiting.png b/public/banners/space-reclamation-wasnt-stuck-it-was-waiting.png
new file mode 100644
index 0000000..9bebf5b
Binary files /dev/null and b/public/banners/space-reclamation-wasnt-stuck-it-was-waiting.png differ
diff --git a/public/og/adding-a-domain-to-someone-elses-mail-server.png b/public/og/adding-a-domain-to-someone-elses-mail-server.png
new file mode 100644
index 0000000..10a6d21
Binary files /dev/null and b/public/og/adding-a-domain-to-someone-elses-mail-server.png differ
diff --git a/public/og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png b/public/og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
new file mode 100644
index 0000000..55642f0
Binary files /dev/null and b/public/og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png differ
diff --git a/public/og/my-backup-was-using-someone-elses-api-quota.png b/public/og/my-backup-was-using-someone-elses-api-quota.png
new file mode 100644
index 0000000..610d911
Binary files /dev/null and b/public/og/my-backup-was-using-someone-elses-api-quota.png differ
diff --git a/public/og/space-reclamation-wasnt-stuck-it-was-waiting.png b/public/og/space-reclamation-wasnt-stuck-it-was-waiting.png
new file mode 100644
index 0000000..1323006
Binary files /dev/null and b/public/og/space-reclamation-wasnt-stuck-it-was-waiting.png differ
diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs
index 0b453fd..3008f93 100644
--- a/scripts/banner-gen/generate.mjs
+++ b/scripts/banner-gen/generate.mjs
@@ -1288,6 +1288,74 @@ BANNERS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = {
],
};
+BANNERS['my-backup-was-using-someone-elses-api-quota'] = {
+ titlebar: "root@dsm - 1 TB seed, 0.27%",
+ lines: [
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "rclone about gdrive-restic" },
+ { t: 'dim', text: "2.88 GB of ~1075 GB uploaded - 0 snapshots" }, { t: 'err', text: "403 Quota exceeded - consumer projects/202264815644" },
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "own OAuth client - Desktop app - Publish app" },
+ { t: 'ok', text: "the seed spends my quota now" }, { t: 'hl', text: "the callback accepts ONE request: no reload, no second tab" },
+ { t: 'dim', text: "old side: 1.012 TiB -> 3.800 GiB reclaimed" }, { t: 'dim', text: "guid = sha256(id): repointing a plan cannot fork history" },
+ ],
+ flow: [
+ { n: '1', label: "find what writes it" },
+ { n: '2', label: "repoint the repo" },
+ { n: '3', label: "own the client" },
+ { n: '4', label: "seed" },
+ ],
+};
+
+BANNERS['space-reclamation-wasnt-stuck-it-was-waiting'] = {
+ titlebar: "root@nas - volume1, 09:29",
+ lines: [
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "df -h /volume1 (every 5 min)" },
+ { t: 'dim', text: "08:59-09:24 flat at 1134.1 GiB" }, { t: 'ok', text: "09:29 +69.0 GiB - Class snapshot rotated" },
+ { t: 'ok', text: "09:34 +46.8 GiB - Video snapshot rotated" }, { t: 'err', text: "15:34 +5.0 GiB - the recycle bin held almost nothing" },
+ { t: 'hl', text: "retention 1: a delete frees at tomorrow's rotation" }, { t: 'dim', text: "the afternoon wrote -47.2 GiB: reclaim was a rounding error" },
+ { t: 'dim', text: "volume1 98% used - volume2 100%" },
+ ],
+ flow: [
+ { n: '1', label: "sample the number" },
+ { n: '2', label: "read the schedule" },
+ { n: '3', label: "know retention" },
+ { n: '4', label: "alert on the trend" },
+ ],
+};
+
+BANNERS['adding-a-domain-to-someone-elses-mail-server'] = {
+ titlebar: "mail.hoelee.com - one more domain",
+ lines: [
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "add domain + MX/SPF/DKIM/DMARC" },
+ { t: 'ok', text: "inbound works first try - RCPT_MAILCOW_DOMAIN" }, { t: 'err', text: "outbound: 554 antispam policy (4 RBLs clean, no rDNS)" },
+ { t: 'err', text: "via relay: 550 From header sender domain not verified" }, { t: 'hl', text: "PTR is per IP - every domain shares one reverse record" },
+ { t: 'dim', text: "forwarding needs Sieve, and still cannot leave the box" }, { t: 'dim', text: "a relay needs a verified sender per domain, forever" },
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "POP pull on the receiving side" },
+ ],
+ flow: [
+ { n: '1', label: "four records" },
+ { n: '2', label: "test from outside" },
+ { n: '3', label: "check the reverse" },
+ { n: '4', label: "pull, don't forward" },
+ ],
+};
+
+BANNERS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = {
+ titlebar: "report.pdf - what is really inside",
+ lines: [
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "pdffonts report.pdf" },
+ { t: 'err', text: "before: one CJK face + Roboto, bold was synthetic" }, { t: 'dim', text: "variable font: parser reads glyf only, ignores fvar -> Thin 100" },
+ { t: 'ok', text: "after: 8 static faces, 4 families, embedded" }, { t: 'hl', text: "font-weight 600 -> rewritten to bold for the PDF engine" },
+ { t: 'dim', text: "GB2312 subsetting cut real name characters: 犇 骉 頔 祎 堃" }, { t: 'dim', text: "glyph lint fails the build instead of printing a box" },
+ { t: 'prompt', text: "$" }, { t: 'cmd', text: "page counts: zh 23/23, en 28/29, bm 30/30" },
+ ],
+ flow: [
+ { n: '1', label: "files in the repo" },
+ { n: '2', label: "static cuts only" },
+ { n: '3', label: "map two weights" },
+ { n: '4', label: "lint every glyph" },
+ ],
+};
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs
index 00a4c22..7ce9698 100644
--- a/scripts/og-gen/generate.mjs
+++ b/scripts/og-gen/generate.mjs
@@ -392,6 +392,26 @@ TERMINALS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = `
TypeError: t is not a function (a parameter named t)
$typeof t === 'function' ?lang=pseudo clean-> script reaches the end
`;
+TERMINALS['my-backup-was-using-someone-elses-api-quota'] = `
+ $restic backup --repo gdrive:restic
+ 403 RATE_LIMIT_EXCEEDED - consumer: projects/202264815644
+ $own OAuth client + Publish app-> the quota is mine
`;
+
+TERMINALS['space-reclamation-wasnt-stuck-it-was-waiting'] = `
+ $synostgreclaim --get-all-status
+ state running, schedule 168x1, run_all_time: always true
+ $sample free space every 5 min-> +121 GiB, then -47 GiB written
`;
+
+TERMINALS['adding-a-domain-to-someone-elses-mail-server'] = `
+ $dig +short MX newdomain.com -> mail.hoelee.com
+ 554 5.7.1 Mail rejected due to antispam policy
+ $reverse lookup 41.216.187.102-> nothing: no PTR
`;
+
+TERMINALS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = `
+ $pdffonts report.pdf
+ before: one CJK face, bold was synthetic
+ $8 static faces embedded-> same look everywhere
`;
+
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
diff --git a/src/content/posts/adding-a-domain-to-someone-elses-mail-server.md b/src/content/posts/adding-a-domain-to-someone-elses-mail-server.md
new file mode 100644
index 0000000..e294601
--- /dev/null
+++ b/src/content/posts/adding-a-domain-to-someone-elses-mail-server.md
@@ -0,0 +1,154 @@
+---
+title: "Adding a Domain to Someone Else's Mail Server"
+description: "Receiving mail took four DNS records and worked first try. Sending from it, and forwarding it, hit two walls that no DNS record can fix."
+pubDate: 2025-05-06
+updatedDate: 2026-10-16
+category: notes
+tags: ["email", "dns", "mailcow", "smtp", "deliverability"]
+ogImage: /og/adding-a-domain-to-someone-elses-mail-server.png
+banner: /banners/adding-a-domain-to-someone-elses-mail-server.png
+draft: false
+---
+
+## The task looked like a DNS job
+
+A domain needed mail. The mail server is my own VPS running mailcow, so this
+is "add the domain, add the records, create the mailbox" — the kind of task
+that takes twenty minutes and that I have done several times.
+
+It took longer, because two of the things I assumed about mail servers are
+not true, and because of one property of the internet that no amount of
+server configuration can change.
+
+## Inbound: four records and a real test
+
+Inbound mail needs exactly four things, and all four live in the domain's
+own DNS (Cloudflare here):
+
+```text
+MX ninelore.com → mail.hoelee.com (priority 10)
+TXT @ → v=spf1 mx -all
+TXT dkim._domainkey → (the selector the MTA publishes)
+TXT _dmarc → (policy + reporting address)
+```
+
+Configured and then tested the only way that counts: mail sent **from
+outside** the estate (through a third-party relay) into the new address, and
+then read the server's own log line — `rspamd` recording
+`RCPT_MAILCOW_DOMAIN(ninelore.com)` as the message was accepted into the
+mailbox. Receiving worked on the first try. Inbound is genuinely the easy
+half, and it is easy precisely because it depends only on records you
+control.
+
+## Outbound is where the ground moves
+
+Two separate walls, each with its own error, and neither is a config mistake
+you can fix by editing a zone file.
+
+**Wall one: the anti-spam policy that is really a missing PTR.** A direct
+delivery attempt to the destination provider came back:
+
+```text
+554 5.7.1 Mail rejected due to antispam policy
+```
+
+Before blaming the remote, I checked the usual causes: four major
+blocklists, all clean, and the domain's own SPF/DKIM/DMARC all correct. The
+remaining explanation was the one with no DNS record in my control — the
+sending IP had no reverse DNS. A receiving server that cannot map the
+connecting IP back to a name treats it as a source it cannot vouch for, and
+no amount of correct SPF outweighs that.
+
+**Wall two: relaying your way around it does not work either.** The obvious
+next move is to send everything through the outbound relay that already
+works. That attempt produced:
+
+```text
+550-From header sender domain not verified (gmail.com)
+```
+
+Read that error carefully, because it kills a whole family of designs. The
+relay enforces its verified-sender policy against the **From header domain**
+— and a *forwarded* message keeps the original sender's From. So the moment
+you forward mail from an outside sender, you are asking your relay to send
+as `gmail.com`, which it will never allow. mailcow makes this worse in a way
+that is worth knowing before you try: it selects the relay by **sender**
+domain, so a message with an outside From does not even reach the relay you
+configured.
+
+## The forwarding feature that does not exist
+
+The plan was to create the mailbox and forward a copy to my main address.
+That is two surprises in a row:
+
+- mailcow has no "mailbox forwarding" setting. Writing the mailbox address
+ into an alias' `goto` list is rejected (`is_alias_or_mailbox`) because
+ every mailbox already carries a self-alias record and the code skips the
+ address when computing destinations.
+- Keeping the mailbox *and* forwarding requires a **Sieve filter**, which is
+ a different mechanism with a different failure mode — and it lands on wall
+ one and wall two anyway, because the message still has to leave the box.
+
+So I enabled **nothing**. A forward whose outbound path cannot deliver does
+not fail quietly: it sends the rejection back to the innocent original
+sender — backscatter — and every one of those bounces spends the reputation
+of an IP that has none to spare. The stable answer for "this new mailbox
+should reach my inbox" turned out to be a **POP pull on the receiving
+side**: my mail client fetches from `mail.hoelee.com:995` and nothing has to
+be sent from the VPS at all.
+
+## PTR is one per IP, not one per domain
+
+This is the part I keep having to explain, including to myself:
+
+- The reverse record is a property of the **IP address**. There is no
+ per-domain PTR, and there is nothing to add the next time a domain is
+ added.
+- Its correct value is the name the mail server itself announces — in this
+ case `mail.hoelee.com`, which is what postfix sends as its
+ HELO/myhostname. The matching forward record
+ (`mail.hoelee.com → 41.216.187.102`) already exists, and the pair is what
+ receivers verify: **forward-confirmed rDNS**. One half without the other
+ is worth nothing.
+- Every domain on that IP is judged by that same reverse record. A hundred
+ domains, one PTR.
+- Who sets it: whoever runs the reverse zone. Here the SOA for both
+ `216.41.in-addr.arpa` and `187.216.41.in-addr.arpa` is the VPS host's own
+ nameserver, so the record is a provider-side change — look for an rDNS/PTR
+ field in the VM or network panel first, and otherwise open a ticket with
+ the exact value above.
+
+I checked the current state rather than assuming: the forward lookup
+resolves (`mail.hoelee.com → 41.216.187.102`) and the reverse lookup for
+that IP still returns nothing. So direct delivery continues to be rejected,
+and the only outbound path that works is the relay — with its
+verified-sender requirement, which new domains have to satisfy there too.
+
+## Three things to expect, so you are not surprised later
+
+1. **PTR is not a reputation.** It removes a hard rejection; it does not
+ make you trusted. A new IP typically needs weeks of ordinary mail before
+ the big providers stop folding it into spam, and starting from a fresh IP
+ means starting that clock from zero.
+2. **PTR fixes your own outbound and does nothing for forwarding.** A
+ forwarded message carries someone else's From, and if that domain
+ publishes `p=reject` — banks and large providers do — it is blocked at
+ the far end no matter how clean your setup is.
+3. **Relaying has a per-domain precondition.** If a domain sends through a
+ relay, that domain must be registered and verified as a sender with the
+ relay. It is an extra step per domain forever, and it is independent of
+ everything in your own DNS.
+
+## What I would do differently
+
+1. **Decide mailbox-or-forward before creating the domain.** Forwarding is
+ the option with the longest list of things that must be true; a POP pull
+ on the receiving side has almost none.
+2. **Check the sending IP's reverse record before promising anyone mail.**
+ One reverse lookup would have told me which of the two walls I was about
+ to hit.
+3. **Treat a receiving-side pull as a first-class design, not a
+ workaround.** "The message never has to leave my server" removes three
+ whole categories of failure at once.
+4. **Keep the four inbound records as a checklist.** That half is boring,
+ verifiable, and was the part that worked first try.
diff --git a/src/content/posts/embedding-fonts-so-a-pdf-looks-the-same-everywhere.md b/src/content/posts/embedding-fonts-so-a-pdf-looks-the-same-everywhere.md
new file mode 100644
index 0000000..28a48bb
--- /dev/null
+++ b/src/content/posts/embedding-fonts-so-a-pdf-looks-the-same-everywhere.md
@@ -0,0 +1,158 @@
+---
+title: "Embedding Fonts So a PDF Looks the Same on Every Machine"
+description: "Four families, eight faces, two renderers and one CSS quirk: what it took to stop a generated report depending on whatever fonts the server happened to have."
+pubDate: 2025-01-22
+updatedDate: 2026-10-16
+category: engineering
+tags: ["pdf", "fonts", "mpdf", "typography", "php"]
+ogImage: /og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
+banner: /banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
+draft: false
+---
+
+## The requirement was one sentence
+
+A report generator has to produce a document that looks the same everywhere:
+Chinese headings in a serif, Chinese body text in a sans, Latin headings and
+cover numerals in a display serif, Latin body text and tables in Inter.
+Weights matter — medium for emphasis, semibold for headings — across three
+languages and two PDF renderers.
+
+And the fonts must travel with the document. Not "the server has them
+installed", not "the reader probably has them" — **embedded**, so a print
+shop in another country sees the same pages.
+
+The old pipeline failed on the last clause in a way that was invisible: it
+depended on fonts installed on the server by a package manager, and it only
+ever embedded a single CJK face. The second weight did not exist, so "bold"
+was the renderer's synthetic approximation, and the only way to notice was
+to open the PDF and look at what was actually inside it.
+
+## Rule one: read the fonts back out of the finished PDF
+
+"Embedded" is a claim about the output, so I measured the output. The same
+document, rendered before and after:
+
+| | before | after |
+|---|---|---|
+| zh page count | 23 | **23** |
+| en page count | 28 | **29** |
+| bm page count | 30 | **30** |
+| fonts actually embedded | one CJK face + Roboto | Source Han Serif Medium + SemiBold · Source Han Sans Regular + Medium · Cormorant Garamond Medium + SemiBold · Inter Regular + SemiBold + Medium · Source Han Sans for the CJK runs inside Latin reports |
+| render time / peak memory | baseline | same order (±10%, noise) |
+| file size | baseline | roughly +3% |
+
+The glyph counts per face are the receipt: in the Chinese report, 15,988
+glyphs land in the body sans, 2,247 in its medium weight, 634 in the heading
+serif medium, 222 in its semibold. That is the difference between "the font
+is on the server" and "the font is in the file" — a reader in another
+country sees the same four faces, and a page count that moved by exactly one
+page in one language (the Latin metrics are wider than what they replaced)
+is the honest cost.
+
+## Rule two: static instances only, and the reason is in the parser
+
+The obvious modern choice — ship one variable font per family — is wrong
+here, for two measured reasons:
+
+1. **The PDF engine's font parser reads `glyf`/`loca` only.** Feed it an
+ OpenType file with PostScript/CFF outlines and it refuses:
+ `Fonts with postscript outlines are not supported`. A variable font is
+ not the problem; CFF outlines are.
+2. **It does not read the `fvar` table at all**, so a variable font silently
+ resolves to its *default instance* — and the default instances of the CJK
+ families are the thinnest ones (Thin 100 and ExtraLight 200, visible in
+ the `name` table). Every page would render in a hairline weight, with no
+ error anywhere in the pipeline.
+
+So the build step takes the upstream variable fonts and **instantiates
+static cuts**: it runs the instancer, then rewrites `name`,
+`OS/2.usWeightClass` and `head.macStyle` so the file identifies itself as
+the weight it now is (the source's own name still says "Thin"). A manifest
+records family, weight class, sha256, glyph count and code-point count for
+every file, and the licence files ship next to them.
+
+## Rule three: the engine speaks two weights, so map onto two slots
+
+The renderer's CSS handling honours exactly `normal` and `bold` — its source
+says so in as many words ("Does not support: bolder, lighter, 100..900").
+Numeric weights are therefore meaningless to it, while the headless-Chromium
+renderer in the same pipeline honours them properly.
+
+The mapping that works: each family declares an **R slot** and a **B slot**
+(regular-ish and heavier), `font-weight: 500` lands on the R slot of the
+intended family, and `600` is **rewritten to `bold`** on the PDF side only,
+which lands on the B slot. Chromium keeps `600` as written. Two tests pin
+it: the B slot must be heavier than the R slot (read from `usWeightClass`,
+not assumed), and `font-weight: 600` must never appear in the stylesheet
+handed to the PDF engine.
+
+One subtlety worth the paragraph: a "medium" weight is not an emphasis
+mechanism in this pipeline, it is a *family member*. Chinese headings use
+the serif's medium as their regular and its semibold for emphasis; Latin
+headings do the same with the display serif. Getting that wrong is invisible
+on a screen and obvious in print.
+
+## Rule four: do not subset against a character set you do not control
+
+The first attempt subset the CJK fonts to the union of two national
+character sets and produced a ~29 MB bundle instead of ~50 MB. Then I ran
+the name probe and found these were cut:
+
+```text
+犇 骉 頔 祎 廸 玧 祾 赟 堃
+```
+
+Every one of them is a real given name character. The report prints a
+customer's name on the cover, and the name is free input — so a subset-based
+build will eventually put a tofu box on the most prominent page of the
+product. Full coverage is ~31k code points and roughly 50 MB of embedded
+fonts; that is a fine price for never showing a customer an empty rectangle.
+
+## Rule five: make missing glyphs a build failure, not a surprise
+
+The gate runs three checks and exits non-zero on any of them:
+
+- every font file declared in the manifest is present, and its sha256
+ matches the file on disk;
+- every character in the content library (all three languages) plus the
+ brand strings, plus probe classes — simplified, traditional, CJK
+ punctuation, full-width forms, mixed-script, digits, Latin extended,
+ symbols — plus the name characters, has a glyph **in the face that will
+ actually print it**;
+- the lookup uses the PDF engine's own font parser rather than a third-party
+ library, so the check cannot disagree with the renderer.
+
+The known edges are written down instead of hidden. Dingbats and emoji (✅ ✔)
+exist in none of these text fonts and the engine cannot draw colour emoji,
+so they are kept out of the report body. The display serif has only 974
+glyphs and does not draw symbols like ① ★ ℃, which is why the
+content-library check covers *all* slots rather than just the body. And CJK
+characters inside a Latin report must go through the dedicated CJK slot —
+two tests keep the renderers from disagreeing about that.
+
+## What I would do differently
+
+1. **Choose faces and weights before styling anything.** Retrofitting a
+ second weight after the layout exists means re-checking every page count
+ by hand.
+2. **Read the renderer's source before choosing a font format.** The
+ variable-font decision looked like a taste question and was actually a
+ parser question — two files settled it in ten minutes.
+3. **Treat everything you ship inside a document as a licence artifact.**
+ Font files enter the repo with their licences, or they enter someone
+ else's legal review.
+4. **Measure what got embedded.** Page count, glyph-per-face counts, file
+ size, render time: all four moved, and three of them moved in ways I
+ would have guessed wrong.
+
+## The result
+
+Before: one CJK face and a UI font, with synthetic bold, depending on what
+the server had installed. After: four families and eight static faces
+embedded in the file, weights that are real members of their families, page
+counts within one page of the previous layout, and a lint gate that fails
+the build when a character has no glyph — so the failure mode moved from "a
+customer sees a box" to "CI refuses the release". Which is the same trade as
+everywhere else in this pipeline: make the mistake impossible to ship rather
+than easy to notice.
diff --git a/src/content/posts/my-backup-was-using-someone-elses-api-quota.md b/src/content/posts/my-backup-was-using-someone-elses-api-quota.md
new file mode 100644
index 0000000..883f62b
--- /dev/null
+++ b/src/content/posts/my-backup-was-using-someone-elses-api-quota.md
@@ -0,0 +1,169 @@
+---
+title: "My Backup Was Crawling Because I Was Using Someone Else's API Quota"
+description: "A 1 TB restic seed stalled at zero bytes. The log named a Google project I did not own: rclone's shared OAuth client, quota pooled with every rclone user alive."
+pubDate: 2026-10-15
+updatedDate: 2026-10-16
+category: notes
+tags: ["rclone", "backup", "google-drive", "oauth", "restic"]
+ogImage: /og/my-backup-was-using-someone-elses-api-quota.png
+banner: /banners/my-backup-was-using-someone-elses-api-quota.png
+draft: false
+---
+
+## The subscription ended, and I had to move a terabyte
+
+The call came in the least convenient way: the OneDrive account holding my
+nightly backups had no subscription any more, and I had a 1.5-year Google
+subscription sitting unused. Move the backup, keep the history, don't lose a
+night.
+
+The first thing I hit was not a technical problem: I could not remember
+*how* the backup was wired. rclone? Duplicati? Something Synology shipped?
+On a three-host estate with 78 compose stacks — the one in
+[Running Production Infrastructure Solo](/posts/running-production-infrastructure-solo/)
+— "which tool writes my backups" is a question I should have been able to
+answer from memory and could not. So step one was reading the machine.
+
+## What was actually there
+
+Two rclone configs, which is the kind of detail that bites: the GUI config
+that Backrest (the restic front end) uses, plus a **separate stale CLI
+copy** that a cron job or a human shell had been using at some point. Change
+one and you have changed half the system.
+
+The migration itself is boring when done in the right order, and I did it
+read-first:
+
+```text
+rclone.conf append [gdrive-*] + [gdrive-*-crypt]
+rclone/rclone.conf (CLI copy) same two remotes
+Portainer stack 177 env → the new crypt remote; config :ro → :rw
+restic init new repo on Google Drive
+Backrest SetConfig dsm-repo URI → the new location
+```
+
+Before touching the plan URIs I checked one property in the source, because
+everything depended on it: a restic repository's identity is
+`guid = sha256(id)` — a stable value derived from the repo id, **not** from
+its URL. So repointing a plan at a new backend cannot silently fork the
+history. All six plans came out byte-identical to the originals, and the
+repo `id`/`guid` were untouched.
+
+## Then the seed stopped moving, and the log named a stranger
+
+The new repo seeded to 2.88 GB of roughly 1075 GB — 0.27% — and then slowed
+to a crawl. The live log had the answer:
+
+```text
+'Read requests per minute' ... reason: RATE_LIMIT_EXCEEDED
+consumer: projects/202264815644 <- not my project
+quota_limit_value: 840000
+```
+
+`202264815644` is **rclone's own shared OAuth project**. My terabyte seed
+was drawing on a per-minute read quota pooled with every rclone user on
+earth, which is why it degraded from ~750 KB/s to nothing while my NAS, my
+disk and my link were all idle. By the time I measured, the seed was fully
+stalled — **0 bytes in 90 seconds**, 159 errors, still `rateLimitExceeded` —
+and the six nightly plans had all failed with the same wall:
+
+```text
+Fatal: unable to save snapshot: unexpected HTTP response (500)
+googleapi: Error 403: Quota exceeded for quota metric 'Queries'
+ and limit 'Requests per minute' ...
+```
+
+"Slow" and "blocked" look similar in a progress bar and are completely
+different problems. Ninety seconds of sampled zeros is what made the call.
+
+## You need your own OAuth client — and the console is unavoidable
+
+There is exactly one real fix: stop using the shared client and bring your
+own. That means a Google Cloud project, an OAuth consent screen, and a
+credential. Two settings cost real time:
+
+- **Application type = Desktop app.** rclone's redirect is
+ `http://127.0.0.1:53682`, and that client type permits it without extra
+ configuration.
+- **Publish the app.** Leave it in "Testing" and Google expires your refresh
+ token **every 7 days** — which turns "working" into a silent weekly
+ outage.
+
+I checked whether I could shortcut the console with the CLI that was already
+authenticated here. I could not, and the reason is worth knowing: `gcloud`
+was signed in only as a **service account** in an unrelated project, and
+service accounts cannot create consent screens or OAuth client IDs. The
+credential has to belong to a project a human owns. It was a dead end, and I
+verified that instead of asserting it.
+
+## Three traps in the authorization flow
+
+1. **Publishing an unverified app can hard-block a restricted scope.**
+ `.../auth/drive` is restricted, and an app that is published *without*
+ completing Google's verification review is sometimes blocked outright
+ with no bypass. In my case the bypass existed — the "Google hasn't
+ verified this app" screen, then **Advanced → Go to rclone-backup
+ (unsafe)** — but that is a coin flip on scope and review state, not a
+ guarantee, and "unsafe" is only a label meaning the app never went
+ through a paid audit.
+2. **The callback accepts exactly one request.** A reload, or a second tab,
+ hits `127.0.0.1:53682` with no code and kills the attempt. My first try
+ died exactly that way (`no code returned`), and it looks like a
+ credentials problem. One tab. Don't reload it.
+3. **Finish the consent screen on the machine that runs rclone.** The
+ redirect goes to `localhost`, so a phone or another PC cannot complete it
+ — the browser has to be the one on the same host as the process waiting
+ on that port.
+
+Then: read the token, write `client_id` + `client_secret` + `token` into
+**both** configs (the GUI one and the stale CLI copy), and restore the two
+flags that matter for a long unattended upload: `use_trash = false` and
+`stop_on_upload_limit = true`.
+
+## The old side: verify by reading, not by asking
+
+Purging the old remote reported an error — `itemNotFound` — and the space
+came back anyway:
+
+```text
+Used: 1.012 TiB -> 3.800 GiB
+Free: ~0 -> 1.007 TiB
+DSM folder: present -> gone Trashed: 0 B
+```
+
+The error was rclone complaining *after* the work finished. Also worth
+recording: the tool's documentation says personal accounts cannot
+`permanentDelete`, which would have meant emptying the recycle bin by hand —
+in practice the trash was already empty and roughly 1 TB was reclaimed with
+no browser step. Documentation describes the API; the API is not always what
+you get.
+
+One more measured thing, because it changed my next decision: pausing the
+file-sync container that shared that machine halved the load average — 16.70
+→ 8.89 — which is the difference between "the network is slow" and "the box
+is busy".
+
+## What I would do differently
+
+1. **Own the OAuth client from day one.** A shared client id is a queue you
+ are standing in, with no status page and no one to call.
+2. **Answer "what writes my backups" from a document, not from memory.** Two
+ rclone configs is a normal amount of drift for a system nobody has drawn
+ in a year.
+3. **Read the destination, not the exit code.** The purge "failed" and freed
+ a terabyte; a successful-looking rename can land nothing. Same lesson as
+ [It Reported Success. Nothing Had Changed.](/posts/it-reported-success-nothing-had-changed/)
+ — this estate keeps teaching it.
+4. **Never start a multi-day upload while the last few percent of your
+ attention is elsewhere.** Checking for `RATE_LIMIT_EXCEEDED` at 0.27%
+ costs minutes; discovering it after two days costs the window.
+
+## The result, honestly stated
+
+The plumbing works: the plans live on Google Drive, the repo identity is
+unchanged, the seed is now spending **my** quota instead of a shared one,
+and the old side gave back about a terabyte. What it is not, yet, is a
+backup: at the time I wrote this, the first seed had uploaded 2.88 GB of
+~1075 GB and the new repo had **zero snapshots** — nothing is restorable
+until the first snapshot lands. That is the number I watch, because "the
+transfer is running" and "you can restore" are different claims.
diff --git a/src/content/posts/space-reclamation-wasnt-stuck-it-was-waiting.md b/src/content/posts/space-reclamation-wasnt-stuck-it-was-waiting.md
new file mode 100644
index 0000000..11a9071
--- /dev/null
+++ b/src/content/posts/space-reclamation-wasnt-stuck-it-was-waiting.md
@@ -0,0 +1,124 @@
+---
+title: "Space Reclamation Wasn't Stuck. It Was Waiting for Tomorrow at 09:29."
+description: "The Storage Manager card said 'running' all day and free space hadn't moved. 121 GiB had come back in two steps — and the afternoon ate 47 GiB of it."
+pubDate: 2026-10-14
+updatedDate: 2026-10-16
+category: devops
+tags: ["synology", "btrfs", "snapshots", "storage", "nas"]
+ogImage: /og/space-reclamation-wasnt-stuck-it-was-waiting.png
+banner: /banners/space-reclamation-wasnt-stuck-it-was-waiting.png
+draft: false
+---
+
+## The question I asked was the wrong question
+
+I emptied a few folders, kicked off Space Reclamation on the NAS, and
+waited. Hours later the Storage Manager card still read **running**, and
+free space looked the way it had looked in the morning. My question was "why
+didn't it reclaim anything".
+
+It had reclaimed — twice — and one of those reclaims was larger than
+anything I had deleted. The measurement answered a different question than
+the one I asked, which is the interesting part.
+
+## Sample free space on a timer before believing anything
+
+I sampled `volume1` free space every five minutes for the day instead of
+looking at a progress card:
+
+| time | free | event |
+|---|---|---|
+| 08:59 → 09:24 | 1134.1 GiB | flat — nothing reclaiming |
+| **09:29** | **+69.0 GiB** | Class snapshot `…10.14-09.29.02` rotated |
+| **09:34** | **+46.8 GiB** | Video snapshot `…10.14-09.30.35` rotated |
+| **15:28:45** | — | DSM log: `Start to empty all recycle bins.` |
+| **15:34** | **+5.0 GiB** | recycle-bin empty finished — only 5 GiB was in there |
+| 16:35 → 17:39 | **−39 GiB** | new data written (a burst) |
+| 15:00 → 20:44 | **−47.2 GiB** | net: the volume *lost* space that afternoon |
+
+Two reclaims, ~121 GiB in total, and then 47 GiB of new writes swallowed the
+afternoon. "Nothing came back" was a reading error on my side: I compared
+the morning to the evening, and the evening had a day of writing in it.
+
+The recycle bin — the thing I had bothered to empty by hand — was **5 GiB**.
+The snapshots, which I had not thought about at all, were **115.8 GiB**.
+That ratio is the whole story.
+
+## Why the card said "running" and meant nothing
+
+```text
+$ synostgreclaim --get-all-status --verbose
+{"state":"running","num_subvolume_remain":1,"now_runnable":true}
+ schedule_table: 168 × 1
+ run_time_option: run_all_time
+```
+
+`state: running` with a schedule table of 168 ones and `run_all_time` means
+the task is configured to be "running" **24×7, by design**. It is a
+schedule, not a progress indicator — so the card will read the same thing on
+a day when nothing is scheduled to happen at all. Meanwhile the machine had
+no `synostgreclaim` process, no btrfs balance, and md2/dm-3 I/O of roughly
+zero. Nothing was being reclaimed at that moment, and nothing was stuck
+either.
+
+Then the logs, which are mostly red herrings on DSM:
+
+- the reclaim task itself logs nothing;
+- `/var/log/space_operation.log` is LUKS and volume-list spam;
+- `/var/log/synorecycle.log` is `synounlinkd` path noise;
+- Log Center for the day contained only my own recycle-bin line and a few
+ UPS on-battery/back-online blips.
+
+A UI state field that is always true and three log files that never contain
+the answer: the only instrument left is free space over time.
+
+## The mechanism nobody tells you about
+
+Space Reclamation frees **deleted btrfs subvolumes**. It cannot free data
+that a snapshot is still pinning. Those two shares keep **retention 1** —
+one generation each — so anything I delete becomes free space only at the
+**next day's rotation, around 09:29 / 09:30**, in a step of tens of
+gigabytes, and then it is silent for 24 hours.
+
+That is exactly what the two jumps were. It also means the honest answer to
+"I deleted 50 GB and nothing happened" on this box is: "wait for tomorrow
+morning, then check again" — and if the deleted data lived inside a
+snapshot, the answer is "it frees when that snapshot expires, and not
+before".
+
+## What I would do differently
+
+1. **Sample the number, not the status.** A five-minute timer on free space
+ answers "is anything happening" in a way no task card can, and it made
+ the regrowth visible at the same time.
+2. **Read the schedule field, not the state field.** `run_all_time` explains
+ a permanent "running" without any bug existing.
+3. **Know each share's retention.** It is the clock that decides when a
+ delete becomes free space; without it, every deletion looks broken for a
+ day.
+4. **Separate the two ledgers.** Recycle bin (5 GiB) and snapshots (115.8
+ GiB) behave nothing alike and are two different mental models.
+5. **Re-check the denominator.** The volume was losing ~1.5 GiB/h to normal
+ writes; a reclaim that returns 121 GiB and a day that consumes 47 GiB
+ look identical if you only compare two snapshots of the same number.
+
+## What the measurement actually says about the machine
+
+I went in expecting to fix a stuck job and came out with a storage trend
+instead:
+
+```text
+volume1 42 TB 98% used 1.2 TB free ~-1.5 GiB/h lately (-344 GiB over 8 days)
+volume2 16 TB 100% used 70 GB free ~-19 GiB / 2 days
+health btrfs corruption_errs 0 on both volumes
+ all md arrays [UUUU] / [UU], no balance running
+ read/write_io_errs identical to the September baseline
+```
+
+So the useful conclusion is not "the reclaim works". It is that reclaim is a
+rounding error next to the write rate, deletion is not the lever I thought
+it was while retention is 1, and the trend — not the task card — is what I
+should be alerting on. That is the same lesson as
+[Your Disk-Full Alert Is Lying](/posts/your-disk-full-alert-is-lying/),
+arriving from the other direction: alert on the consequence (bytes free over
+time), because the mechanism's own status is not evidence.
diff --git a/src/content/posts/zh/adding-a-domain-to-someone-elses-mail-server.md b/src/content/posts/zh/adding-a-domain-to-someone-elses-mail-server.md
new file mode 100644
index 0000000..d3584a6
--- /dev/null
+++ b/src/content/posts/zh/adding-a-domain-to-someone-elses-mail-server.md
@@ -0,0 +1,83 @@
+---
+title: "给别人家的邮件服务器加一个域名"
+description: "收信只要四条 DNS 记录,一次就通了;而从它发信、以及转发它,撞上两堵任何 DNS 记录都修不了的墙。"
+pubDate: 2025-05-06
+updatedDate: 2026-10-16
+category: notes
+tags: ["email", "dns", "mailcow", "smtp", "deliverability"]
+ogImage: /og/adding-a-domain-to-someone-elses-mail-server.png
+banner: /banners/adding-a-domain-to-someone-elses-mail-server.png
+draft: false
+---
+
+## 这件事看起来像个 DNS 活
+
+有个域名要收信。邮件服务器是我自己那台跑 mailcow 的 VPS,所以这就是「加域名、加记录、建信箱」——二十分钟的活,而且我做过好几次。
+
+这次花了更久,原因是我关于邮件服务器的两个假设并不成立,另外还因为互联网有一条任何服务器配置都改不了的性质。
+
+## 收信:四条记录,加一次真测试
+
+收信恰好只需要四样东西,而且都住在域名自己的 DNS 里(这里是 Cloudflare):
+
+```text
+MX ninelore.com → mail.hoelee.com (优先级 10)
+TXT @ → v=spf1 mx -all
+TXT dkim._domainkey → (MTA 发布的那个选择器)
+TXT _dmarc → (策略 + 报告地址)
+```
+
+配好之后,只用一种算数的方式测:**从这套环境外面**(走第三方中继)寄进新地址,然后去读服务器自己的日志行——`rspamd` 在收下这封信时记下 `RCPT_MAILCOW_DOMAIN(ninelore.com)`。收信一次就通。**收信确实是容易的那一半**,而它之所以容易,正因为它只依赖你自己能控制的记录。
+
+## 出信才是地面移动的地方
+
+两堵独立的墙,各有各的错误,而且都不是改一改区域文件就能修的配置失误。
+
+**第一堵墙:那条反垃圾策略,本质上是缺一条 PTR。** 一次直投尝试被对方退回:
+
+```text
+554 5.7.1 Mail rejected due to antispam policy
+```
+
+在怪远端之前,我先排掉常见原因:四个主流黑名单全干净,域名自己的 SPF/DKIM/DMARC 也都正确。剩下的解释就是那条不在我控制里的记录——**发信 IP 没有反向 DNS**。一台收信服务器如果无法把连进来的 IP 映射回一个名字,就会把它当成一个它无法背书的来源;再正确的 SPF 也压不过这一点。
+
+**第二堵墙:用中继绕过去也不行。** 下一个显然的动作是让所有信走那条已经能用的出信中继。结果是:
+
+```text
+550-From header sender domain not verified (gmail.com)
+```
+
+这行错误要仔细读,因为它一次打死了一整类设计。中继的「已验证寄件人」策略,检查的是 **From 头里的域名**——而**转发**出去的信保留的是原寄件人的 From。所以从你转发第一封来自外部寄件人的信开始,你就是在要求中继以 `gmail.com` 的身份发信,而它永远不会允许。mailcow 在这里还有个值得先知道的坑:它按**寄件人**域名选中继,所以一封 From 是外部域的信,根本不会走到你配的那条中继上。
+
+## 那个并不存在的「信箱转发」功能
+
+原计划是建好信箱,再转发一份到我的主邮箱。结果连着两个意外:
+
+- mailcow **没有**「信箱转发」这个设置。把信箱地址写进别名的 `goto` 列表会被拒(`is_alias_or_mailbox`),因为每个信箱自带一条 self-alias 记录,代码在算目标时会跳过这个地址。
+- 想「保留信箱**并且**转发」只能走 **Sieve 过滤器**——那是另一套机制、另一种失败形态——而它最后仍然落在第一堵墙和第二堵墙上,因为这封信终究得离开这台机。
+
+所以我**什么都没启用**。一条出信路径送不出去的转发,不会安静地失败:它会把退信发回给无辜的原始寄件人(backscatter),而每一封这样的退信都在花一个本来就没有余量的 IP 的信誉。对「这个新信箱要送到我的收件箱」这件事,稳定的答案是**收件侧的 POP 拉取**:我的邮件客户端从 `mail.hoelee.com:995` 拉信,VPS 什么都不用发。
+
+## PTR 是一个 IP 一条,不是一个域一条
+
+这是我一直需要解释、包括对我自己解释的部分:
+
+- 反向记录是**IP 地址**的属性。不存在「每个域一条 PTR」,下次再加域也没有任何 PTR 要动。
+- 它正确的值就是邮件服务器自己对外报的名字——这里是 `mail.hoelee.com`,也就是 postfix 用作 HELO/myhostname 的那个。正向记录(`mail.hoelee.com → 41.216.187.102`)已经存在,**这一对**才是收信方要验证的东西:**forward-confirmed rDNS**。缺任何一半都一文不值。
+- 那个 IP 上所有域名都用同一条反向记录来评判。一百个域名,一条 PTR。
+- 谁来设:跑反向区的人。这里 `216.41.in-addr.arpa` 与 `187.216.41.in-addr.arpa` 两级的 SOA 都是 VPS 商自己的名字服务器,所以它是**服务商侧**的改动——先去 VM / 网络面板里找有没有 rDNS/PTR 输入框,没有就提工单,把上面那个确切的值给他们。
+
+我没有假设,而是去查了当前状态:正向解析正常(`mail.hoelee.com → 41.216.187.102`),**那个 IP 的反向解析依旧什么都不返回**。所以直投仍然会被拒,唯一能用的出信路径是中继——而它那条「已验证寄件人」的要求,新域名一样得满足。
+
+## 三件要提前知道的事,免得以后被吓到
+
+1. **PTR 不是信誉。** 它拆掉的是一次硬拒;它不会让你被信任。新 IP 通常需要几周的日常通信,大厂才会停止把它塞进垃圾箱——而从全新 IP 起步,就是把这口钟从零开始拨。
+2. **PTR 修的是你自己的出信,对转发毫无帮助。** 转发信带的是别人的 From;如果那个域发布了 `p=reject`(银行和大厂都会),那么不管你这边多干净,它在远端都会被挡。
+3. **中继有一个按域的前置条件。** 一个域要走中继,就必须先在中继那边注册并验证它为寄件人。这是每个域永远多出来的一步,且与你自己的 DNS 里的一切无关。
+
+## 如果重来一次
+
+1. **在建域之前先定:要信箱还是要转发。** 转发是「必须成立的条件」最长的那条路;收件侧 POP 拉取几乎没有条件。
+2. **在向任何人承诺邮件之前,先查发信 IP 的反向记录。** 一次反向查询就能告诉我接下来会撞上哪堵墙。
+3. **把「收件侧拉取」当一等设计,而不是权宜之计。** 「这封信根本不用离开我的服务器」一次消掉三大类故障。
+4. **把那四条收信记录当成一张清单。** 那一半很无聊、可验证,而且是唯一一次就通的部分。
diff --git a/src/content/posts/zh/embedding-fonts-so-a-pdf-looks-the-same-everywhere.md b/src/content/posts/zh/embedding-fonts-so-a-pdf-looks-the-same-everywhere.md
new file mode 100644
index 0000000..b8f71ee
--- /dev/null
+++ b/src/content/posts/zh/embedding-fonts-so-a-pdf-looks-the-same-everywhere.md
@@ -0,0 +1,82 @@
+---
+title: "把字体嵌进 PDF,让它在任何机器上都长一样"
+description: "四个字族、八个字面、两个渲染引擎,外加一条 CSS 怪癖:为了不让生成的报告依赖服务器上恰好装了什么字体,我做了什么。"
+pubDate: 2025-01-22
+updatedDate: 2026-10-16
+category: engineering
+tags: ["pdf", "fonts", "mpdf", "typography", "php"]
+ogImage: /og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
+banner: /banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
+draft: false
+---
+
+## 需求只有一句话
+
+一个报告生成器必须产出在任何地方都长得一样的文档:中文标题用宋体、中文正文用黑体、英文标题与封面数字用一款展示衬线体、英文正文和表格用 Inter。字重也要对——强调用 medium、标题用 semibold——而且是三语、两个 PDF 渲染引擎都要对。
+
+同时,**字体必须跟着文档走**。不是「服务器装过」,也不是「读者大概有」,而是**嵌进文件里**,这样另一个国家的印厂看到的页面是一样的。
+
+老管线在最后这句上栽得很隐蔽:它依赖服务器上用包管理器装过的字体,而且**只嵌入了一款中文字体**。第二档字重根本不存在,于是「粗体」是渲染器的合成近似;而想发现这件事,唯一的办法是打开 PDF 去看里面到底装了什么。
+
+## 规则一:从成品 PDF 里把字体读回来
+
+「已嵌入」是一个关于**输出**的断言,所以我量的是输出。同一份文档,改前与改后:
+
+| | 改前 | 改后 |
+|---|---|---|
+| zh 页数 | 23 | **23** |
+| en 页数 | 28 | **29** |
+| bm 页数 | 30 | **30** |
+| 真正嵌进去的字体 | 一款中文字体 + Roboto | 思源宋 Medium + SemiBold · 思源黑 Regular + Medium · Cormorant Garamond Medium + SemiBold · Inter Regular + SemiBold + Medium · 拉丁报告里的汉字走思源黑 |
+| 渲染时间 / 峰值内存 | 基线 | 同一量级(±10%,属噪声) |
+| 文件体积 | 基线 | 约 +3% |
+
+每个字面的字形数是收据:中文报告里 15,988 个字形落在正文黑体、2,247 个落在它的 medium 档、634 个落在标题宋体的 medium、222 个落在它的 semibold。这就是「字体在服务器上」与「字体在文件里」的区别——另一个国家的读者看到的是同样四个字面,而代价是某一种语言正好多一页(拉丁字体的度量比它替换掉的那个更宽)。
+
+## 规则二:只出静态实例,理由写在解析器里
+
+现代做法看起来该是「每个字族发一个可变字体」,但在这里是错的,有两个实测理由:
+
+1. **PDF 引擎的字体解析器只读 `glyf`/`loca`。** 喂它一个 PostScript/CFF 轮廓的 OpenType 文件,它直接拒绝:`Fonts with postscript outlines are not supported`。问题不是「可变」,是 CFF 轮廓。
+2. **它完全不读 `fvar` 表**,所以可变字体会**静默地**解析成它的**默认实例**——而中文字族的默认实例恰恰是最细的那一档(`name` 表里写得清清楚楚:Thin 100 与 ExtraLight 200)。整份报告会变成发丝体,而整条管线里不会报任何一个错。
+
+所以构建步骤拿上游的可变字体**实例化成静态切面**:跑实例化器,然后把 `name`、`OS/2.usWeightClass` 和 `head.macStyle` 改写成它现在真实的字重(源文件的名字里还写着 Thin)。产物旁边有一份 manifest,逐文件记下 family、字重、sha256、字形数与码位数;许可证也随文件一起进仓库。
+
+## 规则三:引擎只会说两种字重,那就映射到两个槽位
+
+渲染引擎的 CSS 只认 `normal` 和 `bold`——它的源码自己就这么写("Does not support: bolder, lighter, 100..900")。所以数字字重对它毫无意义;而同一条管线里的无头 Chromium 又完全认它们。
+
+可用的映射是:每个字族声明一个 **R 槽**和一个 **B 槽**(常规一档、更重一档),`font-weight: 500` 落到目标字族的 R 槽,`600` **只在 PDF 那一侧被改写成 `bold`**,落到 B 槽。Chromium 侧照写 `600`。两条测试钉住它:B 槽必须比 R 槽重(读 `usWeightClass`,不是假设),且 `font-weight: 600` 绝不允许出现在交给 PDF 引擎的样式表里。
+
+有一处细节值得单写一段:在这个管线里,**medium 不是强调手段,而是一个字族成员**。中文标题用宋体的 medium 当常规、用 semibold 做强调;英文标题用展示衬线体做同样的事。这里搞错,屏幕上完全看不出来,而印出来一目了然。
+
+## 规则四:不要按你控制不了输入的字集做子集化
+
+第一版把中文字体按两个国家字集的并集做了子集,产物从约 50 MB 降到约 29 MB。然后我跑了姓名探针,发现这些字被裁掉了:
+
+```text
+犇 骉 頔 祎 廸 玧 祾 赟 堃
+```
+
+每一个都是真实的人名用字。报告会把客户姓名印在封面上,而姓名是自由输入——所以一套基于子集的构建,迟早会在产品最醒目的那一页上放一个豆腐块。全字形覆盖是约 31k 码位、约 50 MB 的嵌入字体;为了永远不给客户看一个空方块,这个价钱很便宜。
+
+## 规则五:把「缺字」变成构建失败,而不是惊喜
+
+闸门跑三项检查,任何一项不过就非零退出:
+
+- manifest 里声明的每个字体文件都在,且 sha256 与磁盘上的文件一致;
+- 内容库(三语全部)+ 品牌字面 + 各类探针(简体、繁体、CJK 标点、全角、中英混排、数字、拉丁扩展、符号)+ 姓名用字,**每一个字在「它实际会被哪一档字体印出来」的那一档里都有字形**;
+- 查字形用的是 PDF 引擎自己的解析器,而不是第三方库,所以这条检查不可能和渲染器互相矛盾。
+
+已知边界写在明处,不藏起来:✅ ✔ 这类符号在这几款正文字体里都没有字形,而引擎也画不了彩色 emoji,所以它们不出现在报告正文;拉丁标题那款展示衬线体只有 974 个字形,不画 ① ★ ℃,这也是为什么内容库的检查覆盖**所有**槽位而不只是正文;而拉丁报告里的汉字必须走专门的 CJK 槽位——两条测试防止两个渲染器在这点上产生分歧。
+
+## 如果重来一次
+
+1. **在动样式之前先定字族与字重。** 版式做完之后再去加第二档字重,意味着每一页页数都要手工复核一遍。
+2. **选字体格式之前先读渲染器的源码。** 可变字体看着是审美问题,实际上是解析器问题——两个文件、十分钟就定了。
+3. **把要嵌进文档的一切当作许可证产物。** 字体文件连同许可证一起进仓库,否则就得进别人的法务审查。
+4. **量「到底嵌进去了什么」。** 页数、每个字面的字形数、文件体积、渲染时间:四项都动了,其中三项的方向和我猜的相反。
+
+## 结果
+
+改前:一款中文字体加一款界面字体、靠合成粗体、还取决于服务器装过什么。改后:四个字族、八个静态字面真正嵌在文件里,字重是各个字族真实的成员,页数与上一版相差不超过一页,而且有一条 lint 闸门:某个字没有字形时直接让构建失败——于是失败形态从「客户看到一个方块」变成了「CI 拒绝发版」。这跟这条管线里其它地方的取舍是同一个:**让错误发不出去,而不是让错误容易被看见。**
diff --git a/src/content/posts/zh/my-backup-was-using-someone-elses-api-quota.md b/src/content/posts/zh/my-backup-was-using-someone-elses-api-quota.md
new file mode 100644
index 0000000..609be9a
--- /dev/null
+++ b/src/content/posts/zh/my-backup-was-using-someone-elses-api-quota.md
@@ -0,0 +1,95 @@
+---
+title: "我的备份爬得比爬虫还慢,因为我在用别人的 API 配额"
+description: "1 TB 的 restic 种子传输卡在零字节。日志里点名的那个 Google 项目不是我的:那是 rclone 的共享 OAuth 客户端,配额和全世界每个 rclone 用户共池。"
+pubDate: 2026-10-15
+updatedDate: 2026-10-16
+category: notes
+tags: ["rclone", "backup", "google-drive", "oauth", "restic"]
+ogImage: /og/my-backup-was-using-someone-elses-api-quota.png
+banner: /banners/my-backup-was-using-someone-elses-api-quota.png
+draft: false
+---
+
+## 订阅到期了,我得搬一个 TB
+
+这件事以最不方便的方式找上门:承载我每晚备份的那个 OneDrive 账号已经没有订阅了,而手上还躺着一个 1.5 年没用的 Google 订阅。把备份搬过去、保住历史、别丢一晚。
+
+第一件撞上的不是技术问题:**我记不清这套备份到底是谁在写**。rclone?Duplicati?还是群晖自带的什么?在一套 78 个 compose 栈、三台机器的环境里(就是[《一个人跑生产基础设施》](/posts/running-production-infrastructure-solo/)里那套),「我的备份是谁写的」本该是我张口就答的问题,而它当时不是。所以第一步是去读机器。
+
+## 现场到底有什么
+
+**两份 rclone 配置**,正是那种会咬人的细节:Backrest(restic 的前端)用的是 GUI 那份,而另外还有一份**陈旧的 CLI 副本**,被某个 cron 或某次人手 shell 用过。改了一份,等于只改了一半系统。
+
+搬迁移本身在顺序对的时候很无聊,而我是先读后动的:
+
+```text
+rclone.conf 追加 [gdrive-*] + [gdrive-*-crypt]
+rclone/rclone.conf(CLI 副本) 同样两个 remote
+Portainer stack 177 env → 新的 crypt remote;配置 :ro → :rw
+restic init 在 Google Drive 上建新仓库
+Backrest SetConfig dsm-repo 的 URI → 新位置
+```
+
+在动计划 URI 之前,我先去源码里确认了一件一切依赖它的事:**restic 仓库的身份是 `guid = sha256(id)`** —— 一个由仓库 id 派生的稳定值,**不是**从 URL 推出来的。所以把计划指到新后端,不可能悄悄把历史分叉。六个计划事后逐字节与原版一致,仓库的 `id`/`guid` 未被动过。
+
+## 然后种子停住了,而日志里点了一个陌生人的名字
+
+新仓库灌到 1075 GB 里的大约 2.88 GB —— 0.27% —— 然后慢成爬行。实时日志里有答案:
+
+```text
+'Read requests per minute' ... reason: RATE_LIMIT_EXCEEDED
+consumer: projects/202264815644 <- 不是我的项目
+quota_limit_value: 840000
+```
+
+`202264815644` 是 **rclone 自己的共享 OAuth 项目**。我那个 TB 级种子吃的是每分钟读取配额,和地球上每一个 rclone 用户共池——这就是为什么它从约 750 KB/s 掉到零,而我的 NAS、我的磁盘、我的线路全都闲着。等我去量的时候,种子已经完全停摆:**90 秒内 0 字节**、159 个错误、依旧 `rateLimitExceeded`;而六个夜间计划也全撞同一堵墙:
+
+```text
+Fatal: unable to save snapshot: unexpected HTTP response (500)
+googleapi: Error 403: Quota exceeded for quota metric 'Queries'
+ and limit 'Requests per minute' ...
+```
+
+在进度条里,「慢」和「被挡住」看起来一样,而它们是两个完全不同的问题。**采样 90 秒得到的零**,才是让我做出这个判断的东西。
+
+## 你必须有自己的 OAuth 客户端,而控制台绕不过去
+
+唯一的真修法只有一个:别再用共享客户端,用你自己的。也就是一个 Google Cloud 项目、一个 OAuth 同意屏、一个凭据。两个设置花掉了真正的时间:
+
+- **Application type 必须选 Desktop app。** rclone 的回调是 `http://127.0.0.1:53682`,只有这个类型能不用额外配置就允许它。
+- **要点「Publish app」。** 留在 Testing 状态,Google 会**每 7 天**让刷新令牌过期一次——那就把一个「能跑」的系统变成了每周一次的静默故障。
+
+我查过能不能用机器上已经认证好的 CLI 抄近路。不能,而原因值得记下来:这里的 `gcloud` 只以一个**别人的项目里的服务账号**登录,而服务账号**无法**创建同意屏或 OAuth 客户端 ID。凭据必须属于一个真人拥有的项目。这是一条死路——我是验证出来的,不是猜的。
+
+## 授权流程里的三个坑
+
+1. **未经验证就发布的应用,可能会硬性堵死受限 scope。** `.../auth/drive` 是受限 scope,一个**已发布但未完成 Google 审核**的应用有时会被直接拒绝且**没有旁路**。我这一次旁路是存在的——「Google hasn't verified this app」页面,然后 **Advanced → Go to rclone-backup (unsafe)**——但那取决于 scope 与审核状态,是一枚硬币,而不是保证;而「unsafe」只是个标签,意思是从没走过付费审核。
+2. **回调只接受一次请求。** 刷新页面,或者开第二个标签,都会在**没有 code** 的情况下打到 `127.0.0.1:53682`,把这次尝试弄死。我第一次就是这么死的(`no code returned`),而它看起来像凭据错了。**一个标签,别刷新。**
+3. **同意屏必须在跑 rclone 的那台机器的浏览器里完成。** 回调指向 `localhost`,所以手机或另一台电脑完不成——浏览器必须和等待那个端口的进程在同一台主机上。
+
+之后就简单了:把 token 读出来,把 `client_id` + `client_secret` + `token` 写进**两份**配置(GUI 那份和陈旧的 CLI 副本),并恢复两个对长时无人值守上传很关键的开关:`use_trash = false` 与 `stop_on_upload_limit = true`。
+
+## 旧的那一半:靠读,不靠问
+
+清空旧 remote 时报了错——`itemNotFound`——而空间照样回来了:
+
+```text
+Used: 1.012 TiB -> 3.800 GiB
+Free: ~0 -> 1.007 TiB
+DSM 文件夹: 在 -> 不在 回收站: 0 B
+```
+
+那个错误是 rclone 在**干完活之后**发的抱怨。另外值得记一笔:工具的文档说个人账号无法 `permanentDelete`,按它你就得手动清回收站——实际是回收站已经是空的,约 1 TB 就这么回来了,不需要任何浏览器步骤。文档描述的是 API,而 API 不总是你拿到的东西。
+
+还有一条量出来的事,因为它改变了我下一个决定:把那台机器上共享的同步容器暂停之后,负载均值直接减半——16.70 → 8.89。这就是「网络慢」和「机器忙」的区别。
+
+## 如果重来一次
+
+1. **从第一天起就用自己的 OAuth 客户端。** 共享客户端 ID 是一条你排在里面、却看不到进度、也找不到人负责的队伍。
+2. **「我的备份是谁写的」要写在文档里,不靠记忆。** 两份 rclone 配置,对一个一年没人画过图的系统来说是很正常的漂移量。
+3. **读目的地,别读退出码。** 那次清空「失败」了,却释放了一个 TB;而一次看起来成功的改名,可能什么都没落地。跟[《它报告成功,其实什么都没变》](/posts/it-reported-success-nothing-had-changed/)是同一课——这套环境一直在教这一课。
+4. **不要在注意力只剩最后百分之几的时候启动一个要跑几天的上传。** 在 0.27% 时发现 `RATE_LIMIT_EXCEEDED` 只花几分钟;跑了两天才发现,花掉的是整个窗口。
+
+## 结果,诚实地说
+
+管道是通的:计划现在住在 Google Drive 上、仓库身份没变、种子花的是**我的**配额而不是共享池、旧的那一侧退回了大约 1 TB。但它现在**还不是一个备份**:写这篇的时候,第一轮种子才上传了约 1075 GB 里的 2.88 GB,而且新仓库里**快照数为零**——在第一张快照落地之前,什么都还原不了。那是我盯着的数字,因为「传输在跑」和「你能还原」是两句不同的话。
diff --git a/src/content/posts/zh/space-reclamation-wasnt-stuck-it-was-waiting.md b/src/content/posts/zh/space-reclamation-wasnt-stuck-it-was-waiting.md
new file mode 100644
index 0000000..626c081
--- /dev/null
+++ b/src/content/posts/zh/space-reclamation-wasnt-stuck-it-was-waiting.md
@@ -0,0 +1,83 @@
+---
+title: "空间回收没卡住,它只是在等明天早上 09:29"
+description: "存储管理员那张卡整天写着 running,可用空间一动不动。其实 121 GiB 已经分两步回来了——然后下午又被写掉了 47 GiB。"
+pubDate: 2026-10-14
+updatedDate: 2026-10-16
+category: devops
+tags: ["synology", "btrfs", "snapshots", "storage", "nas"]
+ogImage: /og/space-reclamation-wasnt-stuck-it-was-waiting.png
+banner: /banners/space-reclamation-wasnt-stuck-it-was-waiting.png
+draft: false
+---
+
+## 我问错了问题
+
+我清掉几个文件夹,在 NAS 上点了空间回收,然后等。几个小时后,存储管理员那张卡还写着 **running**,可用空间看起来跟早上一样。我的问题是「它为什么什么都没回收」。
+
+**它回收过了——两次**,而且其中一次比我删掉的东西大得多。测量回答的是另一个问题,而这才是这件事里最有意思的地方。
+
+## 在相信任何东西之前,先给可用空间上定时采样
+
+我没有去看那张进度卡,而是给 `volume1` 的可用空间做了当天每五分钟一次的采样:
+
+| 时间 | 可用 | 事件 |
+|---|---|---|
+| 08:59 → 09:24 | 1134.1 GiB | 平——什么都没在回收 |
+| **09:29** | **+69.0 GiB** | Class 快照 `…10.14-09.29.02` 轮转 |
+| **09:34** | **+46.8 GiB** | Video 快照 `…10.14-09.30.35` 轮转 |
+| **15:28:45** | — | DSM 日志:`Start to empty all recycle bins.` |
+| **15:34** | **+5.0 GiB** | 清回收站结束——里面只有 5 GiB |
+| 16:35 → 17:39 | **−39 GiB** | 新数据写入(一阵爆发) |
+| 15:00 → 20:44 | **−47.2 GiB** | 净值:这个卷那天下午**丢**了空间 |
+
+两次回收,合计约 121 GiB;然后 47 GiB 的新写入把下午吃掉了。「什么都没回来」是我这边的读法错误:我拿早上和晚上比,而晚上里面多了一整天的写入。
+
+我特意手动清空的回收站是 **5 GiB**;我压根没想到的快照是 **115.8 GiB**。这个比例就是全部故事。
+
+## 为什么那张卡写着 running,却什么都不代表
+
+```text
+$ synostgreclaim --get-all-status --verbose
+{"state":"running","num_subvolume_remain":1,"now_runnable":true}
+ schedule_table: 168 × 1
+ run_time_option: run_all_time
+```
+
+`state: running` 加上 168 个 1 的调度表和 `run_all_time`,意思是这个任务被配置成**按设计 24×7 都处于 running**。它是调度状态,不是进度指示器——所以在某天完全无事可排的日子,这张卡照样这么写。而当时那台机器上**没有** `synostgreclaim` 进程、没有 btrfs balance、md2/dm-3 的 I/O 基本为零。那一刻没有任何东西在被回收,也没有任何东西卡住。
+
+然后是日志,而 DSM 上它们大多是红鲱鱼:
+
+- 回收任务本身什么都不记;
+- `/var/log/space_operation.log` 是 LUKS 和卷列表的噪音;
+- `/var/log/synorecycle.log` 是 `synounlinkd` 的路径噪音;
+- 当天的 Log Center 里只有我自己那条清回收站的记录,外加几条 UPS 断电/来电的提示。
+
+一个永远为真的 UI 状态字段,加上三个永远不会给出答案的日志文件:唯一剩下的仪器,就是**随时间变化的可用空间**。
+
+## 没人告诉你的那个机制
+
+空间回收释放的是**已删除的 btrfs 子卷**。它释放不了仍然被快照钉住的数据。那两个共享文件夹的保留策略是 **retention 1**(各留一代),所以**我删掉的任何东西,都要等到第二天的轮转、大约 09:29 / 09:30 才会变成可用空间**,一次跳几十 GB,然后安静 24 小时。
+
+那两次跳变正是这个。它也就意味着,对于「我删了 50 GB 却什么都没发生」这个问题,在这台机器上的诚实答案是:「等明天早上再看」——而如果删掉的数据本来就在某个快照里面,答案是「等那个快照过期,之前不会释放」。
+
+## 如果重来一次
+
+1. **采样那个数字,不要采样状态。** 每五分钟量一次可用空间,比任何任务卡都更能回答「现在有没有事情在发生」,而且它同时让「回长」变得可见。
+2. **读调度字段,不读状态字段。** `run_all_time` 解释了那个永久 running,而且不需要任何 bug 存在。
+3. **搞清楚每个共享文件夹的保留策略。** 它是决定「删除何时变成可用空间」的那口钟;不知道它,每一次删除都会看起来坏掉一整天。
+4. **把两本账分开。** 回收站(5 GiB)和快照(115.8 GiB)行为完全不同,也是两套心理模型。
+5. **重新检查分母。** 这个卷平时就在以约 1.5 GiB/时 的速度被正常写入吃掉;回收回来 121 GiB、当天又花掉 47 GiB,只要你只比同一个数字的两个快照,这两件事看起来完全一样。
+
+## 测量真正告诉我的,是这台机器的趋势
+
+我本来是去修一个卡住的任务,结果带回来的是一条存储趋势:
+
+```text
+volume1 42 TB 已用 98% 剩 1.2 TB 近来约 -1.5 GiB/时(8 天 -344 GiB)
+volume2 16 TB 已用 100% 剩 70 GB 约 -19 GiB / 2 天
+健康 两个卷 btrfs corruption_errs 均为 0
+ 所有 md 阵列 [UUUU] / [UU],没有在跑 balance
+ read/write_io_errs 与九月基线一致
+```
+
+所以有用的结论不是「回收是好的」。而是:**相对于写入速率,回收只是一个舍入误差**;在保留策略为 1 的前提下,删除并不是我以为的那根杠杆;而该拿来做告警的是趋势,不是那张任务卡。这跟[《你的磁盘快满告警在撒谎》](/posts/your-disk-full-alert-is-lying/)是同一课,只是从另一个方向来的:对**后果**告警(随时间变化的可用字节),因为机制自己的状态不是证据。