<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Octa</title><description>Systems notes &amp; runtime investigations. Engineering lab documenting architecture decisions, runtime research, and performance investigations.</description><link>https://octa.page/</link><language>en-us</language><managingEditor>contacto@christianecg.com (Christian Elías)</managingEditor><atom:link href="https://octa.page/rss.xml" rel="self" type="application/rss+xml"/><item><title>Serving every article as an RFC-style plain text document</title><link>https://octa.page/doc/serving-articles-as-rfcs/</link><guid isPermaLink="true">https://octa.page/doc/serving-articles-as-rfcs/</guid><description>Every article now has a /doc/:slug.txt version rendered as a classic IETF RFC: 72-column body, chronological OCTA-n numbering, numbered sections, links collected into a References section — generated at build time from the Markdown source with marked&apos;s lexer.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every article on this site is now also served as an RFC-style plain text document at &lt;code&gt;/doc/:slug.txt&lt;/code&gt;, alongside the existing HTML and raw Markdown (&lt;code&gt;/doc/:slug.md&lt;/code&gt;) versions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ curl -s https://octa.page/doc/timezone-propagation-flow.txt | head -6
Octa Engineering Notes                                   Christian Elías
Request for Comments: OCTA-20                                  octa.page
Category: Informational                                        June 2026


     Timezone Propagation: How IANA Changes Reach Your Application
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This site already hosts two articles about IPoAC and a real Internet Draft submitted to the IETF, so the format is on brand rather than a random gimmick.&lt;/p&gt;
&lt;h2&gt;Format&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Header&lt;/strong&gt;: classic two-column RFC header. The RFC number is the article&amp;#39;s chronological position across the whole notebook — the first published entry is &lt;code&gt;OCTA-1&lt;/code&gt;. This also keeps the header inside 72 columns, which long slugs would not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Abstract&lt;/strong&gt;: the frontmatter &lt;code&gt;description&lt;/code&gt;, verbatim.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Status of This Memo&lt;/strong&gt;: fixed IETF boilerplate, linking the canonical HTML version and the &lt;code&gt;.md&lt;/code&gt; source.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Body&lt;/strong&gt;: 72-column prose with 3-space indent, &lt;code&gt;1.&lt;/code&gt; / &lt;code&gt;1.1.&lt;/code&gt; numbered sections from H2/H3, &lt;code&gt;o&lt;/code&gt; bullets, code blocks verbatim.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;References&lt;/strong&gt;: external links in the prose become &lt;code&gt;label [n]&lt;/code&gt; markers, with URLs accumulated into a numbered References section at the end.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Footer&lt;/strong&gt;: &lt;code&gt;Author — Informational — [Page 1]&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Implementation&lt;/h2&gt;
&lt;p&gt;One static endpoint, &lt;code&gt;src/pages/doc/[slug].txt.ts&lt;/code&gt;, ~170 lines, no new dependencies. Instead of using &lt;code&gt;marked&lt;/code&gt;&amp;#39;s HTML renderer (already a dependency for the RSS feed), the endpoint walks &lt;code&gt;marked.lexer()&lt;/code&gt; tokens directly and emits text: headings pick up section numbers, inline links mutate a shared &lt;code&gt;refs&lt;/code&gt; array, and a 5-line word-wrapper handles the 72-column constraint. &lt;code&gt;getStaticPaths&lt;/code&gt; collects all entries across collections and sorts by date to assign the OCTA number.&lt;/p&gt;
&lt;p&gt;HTML article pages advertise both alternates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;link rel=&amp;quot;alternate&amp;quot; type=&amp;quot;text/markdown&amp;quot; href=&amp;quot;.../doc/slug.md&amp;quot;&amp;gt;
&amp;lt;link rel=&amp;quot;alternate&amp;quot; type=&amp;quot;text/plain&amp;quot; title=&amp;quot;RFC-style plain text&amp;quot; href=&amp;quot;.../doc/slug.txt&amp;quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deliberately skipped: tables and raw HTML pass through verbatim, and real RFC pagination (58-line pages, form feeds, per-page &lt;code&gt;[Page N]&lt;/code&gt; footers) is deferred until the joke proves it deserves it.&lt;/p&gt;
</content:encoded><category>notes</category><category>astro</category><category>plain-text</category><category>rfc</category><category>content-formats</category></item><item><title>Leap Seconds After 2035: What Was Actually Decided, and the Proposals on the Table</title><link>https://octa.page/doc/retiring-leap-seconds/</link><guid isPermaLink="true">https://octa.page/doc/retiring-leap-seconds/</guid><description>The 2022 CGPM resolution doesn&apos;t abolish leap seconds — it raises the tolerated UT1−UTC difference, with the practical effect that insertions cease. A survey of replacement proposals (leap minute, Levine&apos;s rate adjustments, BIPM&apos;s Continuous UTC group) plus a 4-year scheduled-block variant.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In November 2022, the 27th General Conference on Weights and Measures (CGPM, Resolution 4) decided that the maximum allowed value of the difference UT1−UTC will be increased by or before 2035. Read that carefully: it does not abolish leap seconds, and it does not break the connection between UTC and UT1. It raises a tolerance. The practical effect is that leap second insertions cease — but the resolution&amp;#39;s second half is just as important: the CIPM is tasked with proposing a new maximum value that ensures UTC remains usable for at least a century, for review at the 28th CGPM in 2026. The replacement mechanism is deliberately left as future work.&lt;/p&gt;
&lt;p&gt;A detail that gives the decision texture: the 2035 date was a compromise. Russia pushed to extend it to 2040 because GLONASS, unlike GPS, incorporates leap seconds into its signal structure — retiring them means reengineering the constellation&amp;#39;s timekeeping.&lt;/p&gt;
&lt;h2&gt;Why leap seconds exist&lt;/h2&gt;
&lt;p&gt;UTC counts SI seconds from atomic clocks. UT1 &lt;em&gt;is&lt;/em&gt; the Earth&amp;#39;s rotation, expressed as a timescale. UTC was not designed to track the planet as a primary goal — it combines atomic time with occasional one-second adjustments to remain practically close to UT1. The Earth is not a stable oscillator: tidal friction with the Moon, earthquakes, solar activity, and core–mantle dynamics all perturb the rotation. Historically, the divergence has required roughly one leap second every 1–3 years, though the rotation is irregular and cannot be predicted over long horizons.&lt;/p&gt;
&lt;p&gt;The IERS (International Earth Rotation and Reference Systems Service, whose Earth Orientation Center operates at the Paris Observatory) announces an insertion whenever |UT1−UTC| approaches the tolerated bound (0.9 s) — with less than six months of notice, via Bulletin C. Since 1972 there have been 27 leap seconds, all positive, leaving UTC 37 seconds behind TAI.&lt;/p&gt;
&lt;h2&gt;Why they cause more problems than they solve&lt;/h2&gt;
&lt;p&gt;A positive leap second produces the timestamp &lt;code&gt;23:59:60&lt;/code&gt;, which POSIX time cannot represent. Every system handles it differently, because there is no standard handling:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Who&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Step&lt;/td&gt;
&lt;td&gt;Default NTP / kernel&lt;/td&gt;
&lt;td&gt;Repeat or freeze a second at midnight UTC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smear&lt;/td&gt;
&lt;td&gt;Google, AWS, others&lt;/td&gt;
&lt;td&gt;Stretch the second across a window — but each operator picks its own window and curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ignore&lt;/td&gt;
&lt;td&gt;Unsynced systems&lt;/td&gt;
&lt;td&gt;Drift until the next NTP correction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Smearing is an operational convention, not a standards-defined behavior: Google&amp;#39;s 24-hour linear smear and other operators&amp;#39; variants are mutually incompatible by design. Two NTP-synchronized systems using different strategies disagree with each other for the duration of the event.&lt;/p&gt;
&lt;p&gt;The insertion lands first on the Stratum 1 servers of the NTP pool — they receive the leap indicator from the reference clocks (atomic/GNSS) and propagate it downward. The blast radius is asymmetric: critical infrastructure (banking, trading, telecom, distributed systems with strict ordering requirements) is where leap seconds hurt. The application layer of everyday non-critical products mostly never notices — it simply trusts whatever time NTP delivers.&lt;/p&gt;
&lt;p&gt;And then there is the negative leap second. It has never been applied. The Earth&amp;#39;s rotation has been accelerating in recent years, making it plausible for the first time — and it would force every system to skip from &lt;code&gt;23:59:58&lt;/code&gt; to &lt;code&gt;00:00:00&lt;/code&gt;. It would exercise code paths that have never run in production at global scale, which makes its operational risk genuinely difficult to quantify: some software would fail, some is prepared, much of it has never been tested against the case at all.&lt;/p&gt;
&lt;h2&gt;The proposals on the table&lt;/h2&gt;
&lt;p&gt;No formal standard exists yet for what happens after 2035, and standardization work is unlikely to conclude before the late 2020s. But the design space is not empty:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The leap minute.&lt;/strong&gt; Let the drift accumulate until it amounts to a full minute — a block spanning decades. Predictable, plannable, and it defers the operational event to a future generation. Its critics note it concentrates all the risk of today&amp;#39;s leap second into one larger, rarer, even-less-rehearsed event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Algorithmic rate adjustments (Levine, NIST, 2024).&lt;/strong&gt; Judah Levine&amp;#39;s paper &lt;em&gt;&amp;quot;A Proposal to Change the Leap-Second Adjustments to UTC&amp;quot;&lt;/em&gt; proposes replacing discrete jumps with an algorithmic rate-adjustment (steering) method — corrections distributed over time instead of discontinuities. In one of its formulations, adjustments on the order of −13 s per decade starting in 2035.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The BIPM Task Group on Continuous UTC&lt;/strong&gt; has been working on exactly this family of solutions, feeding into the CIPM proposal due for the 2026 CGPM.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What follows is a variant in that same family — periodic, fixed-date, rate-based — contributed as a point in the design space, not as a novelty. Its distinguishing choices are the block length, the notice period, and where in the stack the adjustment lives.&lt;/p&gt;
&lt;h2&gt;A variant: scheduled 4-year correction blocks&lt;/h2&gt;
&lt;p&gt;The current mechanism mixes two problems that deserve separate answers: &lt;strong&gt;how much&lt;/strong&gt; to correct (a geophysical measurement — the IERS&amp;#39;s job) and &lt;strong&gt;how&lt;/strong&gt; to apply the correction (an engineering standard — currently left to each operator&amp;#39;s improvisation). The variant separates them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Accumulate corrections in fixed 4-year blocks, aligned to leap years.&lt;/strong&gt; At the close of each block, the IERS publishes the net correction accumulated during it — positive or negative — and the IANA tz database and downstream standards carry the schedule.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Two years of notice, always.&lt;/strong&gt; The correction published at the close of a block takes effect two years later and is applied across the following block:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Announced&lt;/th&gt;
&lt;th&gt;Covers drift from&lt;/th&gt;
&lt;th&gt;Applied during&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;2036-01-01&lt;/td&gt;
&lt;td&gt;2032–2036&lt;/td&gt;
&lt;td&gt;2038–2041&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2040-01-01&lt;/td&gt;
&lt;td&gt;2036–2040&lt;/td&gt;
&lt;td&gt;2042–2045&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2044-01-01&lt;/td&gt;
&lt;td&gt;2040–2044&lt;/td&gt;
&lt;td&gt;2046–2049&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Each application window starts where the previous one ends — announcements and applications interleave, but two corrections are never active at once.&lt;/p&gt;
&lt;p&gt;Why 4 years and not 2, 5, or 8: it aligns with the civil leap-year cycle (a familiar planning rhythm), it typically accumulates only 2–3 seconds per block (keeping the correction rate negligible and the UT1 bound single-digit), and it halves the number of standardization events versus a 2-year cycle without stretching the bound the way an 8-year cycle would.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. One mandatory application method: proportional tick-rate adjustment.&lt;/strong&gt; During the application block, the UTC second is lengthened or shortened by the proportional fraction of the total correction. Spreading 3 seconds across 4 years is a rate offset of ~2.4×10⁻⁸ — orders of magnitude below the crystal drift NTP already disciplines on every machine, every day.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. The adjustment lives at the top of the stack, not the bottom.&lt;/strong&gt; This is the architectural decision that makes it a standard rather than a smear: the rate adjustment is part of the disseminated UTC timescale itself. The reference laboratories and GNSS systems broadcast already-adjusted time; NTP Stratum 1, PTP grandmasters, kernels, and applications receive plain UTC and implement nothing. Today&amp;#39;s smearing is the exact inverse — applied downstream, per operator, each with its own window and curve.&lt;/p&gt;
&lt;p&gt;That is also the answer to the obvious objection, &lt;em&gt;&amp;quot;this is just a permanent leap smear&amp;quot;&lt;/em&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Today&amp;#39;s smear&lt;/th&gt;
&lt;th&gt;This variant&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Who decides the curve&lt;/td&gt;
&lt;td&gt;Each operator&lt;/td&gt;
&lt;td&gt;The standard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where it&amp;#39;s applied&lt;/td&gt;
&lt;td&gt;Downstream (NTP service, kernel)&lt;/td&gt;
&lt;td&gt;At the timescale source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Window&lt;/td&gt;
&lt;td&gt;Hours around the event, operator-specific&lt;/td&gt;
&lt;td&gt;The full block, fixed by schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interoperability&lt;/td&gt;
&lt;td&gt;Two synced systems can disagree&lt;/td&gt;
&lt;td&gt;One timescale, by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;The bound, shown&lt;/h3&gt;
&lt;p&gt;The claim is that |UTC−UT1| stays within roughly ±10 seconds. It should not have to be taken on faith:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assume a sustained high drift rate ≈ 0.7 s/year
(historically, one leap second every 1–3 years ⇒ 0.33–1.0 s/year)

accumulation window:   4 y × 0.7 ≈ 2.8 s
notice period:         2 y × 0.7 ≈ 1.4 s
during application:    4 y × 0.7 ≈ 2.8 s   (new drift, corrected next cycle)

worst-case |UTC − UT1| ≈ 2.8 + 1.4 + 2.8 ≈ 7 s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Roughly ±7 s in the sustained worst case, ~±10 s with margin for the rotation doing something the model didn&amp;#39;t expect. Compare the trade-offs:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Max |UTC−UT1|&lt;/th&gt;
&lt;th&gt;Notice&lt;/th&gt;
&lt;th&gt;Correction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Today&lt;/td&gt;
&lt;td&gt;0.9 s&lt;/td&gt;
&lt;td&gt;&amp;lt; 6 months&lt;/td&gt;
&lt;td&gt;discrete leap second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leap minute&lt;/td&gt;
&lt;td&gt;~60 s&lt;/td&gt;
&lt;td&gt;decades&lt;/td&gt;
&lt;td&gt;discrete leap minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Levine-style rate adjustment&lt;/td&gt;
&lt;td&gt;model-dependent&lt;/td&gt;
&lt;td&gt;fixed dates&lt;/td&gt;
&lt;td&gt;periodic rate steering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-year blocks (this variant)&lt;/td&gt;
&lt;td&gt;~10 s&lt;/td&gt;
&lt;td&gt;2 years, always&lt;/td&gt;
&lt;td&gt;continuous rate adjustment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;The trade-off, explicitly&lt;/h3&gt;
&lt;p&gt;The price is the bound: ±10 s instead of today&amp;#39;s ±0.9 s. What it buys is predictability, a permanent 2-year planning horizon, and one correction mechanism instead of N incompatible ones. And it makes explicit the interpretation the 2022 resolution already gestures at — this part is opinion, not fact: UTC stops chasing the Earth&amp;#39;s rotation, because tracking the planet is UT1&amp;#39;s job. UTC becomes what it has been in practice all along — the machines&amp;#39; working timescale.&lt;/p&gt;
&lt;p&gt;The astronomers will keep debating the long-term relationship between the two scales. Every proposal above lets that debate happen on a multi-year horizon, without a six-month fuse attached to every second.&lt;/p&gt;
</content:encoded><category>timezone</category><category>utc</category><category>ut1</category><category>leap-seconds</category><category>ntp</category><category>iers</category><category>timekeeping</category></item><item><title>Timezone Propagation: How IANA Changes Reach Your Application</title><link>https://octa.page/doc/timezone-propagation-flow/</link><guid isPermaLink="true">https://octa.page/doc/timezone-propagation-flow/</guid><description>How IANA timezone changes travel from the tzdb release through CLDR, OS updates, and language runtimes — including Node.js&apos;s Date/Intl split where the same environment can produce two different answers, and the British Columbia permanent DST workaround.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When the IANA timezone database makes a decision about how time works in a region, that change doesn&amp;#39;t instantly reach your JavaScript &lt;code&gt;Date&lt;/code&gt; object or your Python datetime library. Instead, it travels through a complex ecosystem of standards bodies, operating systems, language runtimes, and third-party libraries—each with their own release cycles, prioritization strategies, and sometimes their own interpretations of the rules.&lt;/p&gt;
&lt;p&gt;This journey can take weeks, months, or in extreme cases, never happen at all. Understanding that pipeline is essential for anyone building systems that rely on reliable timezone handling.&lt;/p&gt;
&lt;h2&gt;The Propagation Pipeline&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph TD
    tz[&amp;quot;IANA tz Database&amp;lt;br/&amp;gt;(tzdb)&amp;quot;]
    
    cldr[&amp;quot;CLDR/ICU&amp;lt;br/&amp;gt;(Unicode Consortium)&amp;quot;]
    os[&amp;quot;Operating Systems&amp;lt;br/&amp;gt;(glibc, Windows)&amp;quot;]
    thirdparty[&amp;quot;Third-Party Libs&amp;lt;br/&amp;gt;(Moment, Day.js, Luxon)&amp;quot;]
    
    bundled[&amp;quot;Language Runtimes&amp;lt;br/&amp;gt;(Node.js, Java, Go,&amp;lt;br/&amp;gt;bundled CLDR/ICU)&amp;quot;]
    
    apps[&amp;quot;User Applications&amp;quot;]
    
    tz --&amp;gt;|&amp;quot;CLDR adoption&amp;lt;br/&amp;gt;(1-2 months)&amp;quot;| cldr
    tz --&amp;gt;|&amp;quot;OS updates&amp;lt;br/&amp;gt;(varies)&amp;quot;| os
    tz --&amp;gt;|&amp;quot;Direct adoption&amp;quot;| thirdparty
    
    cldr --&amp;gt;|&amp;quot;Runtime releases&amp;lt;br/&amp;gt;(weeks-months)&amp;quot;| bundled
    os --&amp;gt;|&amp;quot;System updates&amp;lt;br/&amp;gt;(varies)&amp;quot;| apps
    bundled --&amp;gt;|&amp;quot;Direct dependency&amp;quot;| apps
    thirdparty --&amp;gt;|&amp;quot;Library updates&amp;quot;| apps
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Timeline: From IANA Decision to User System&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;tz release&lt;/strong&gt; (immediate)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IANA releases a new rule via the &lt;a href=&quot;https://lists.iana.org/hyperkitty/list/tz@iana.org/&quot;&gt;tz mailing list&lt;/a&gt; and updates &lt;a href=&quot;https://data.iana.org/time-zones/tzdb/&quot;&gt;data.iana.org/time-zones/tzdb/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Published in the NEWS file&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;CLDR adoption&lt;/strong&gt; (typically 1–2 months)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Unicode CLDR interprets the tz change and integrates it into its schema&lt;/li&gt;
&lt;li&gt;CLDR includes not just tz rules but also metadata: display names, exemplar cities, etc.&lt;/li&gt;
&lt;li&gt;Not always a 1:1 mapping (CLDR sometimes applies its own interpretation)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;OS updates&lt;/strong&gt; (highly variable)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Linux distributions&lt;/strong&gt;: 1–3 months (depends on distro policy)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;macOS&lt;/strong&gt;: aligned with CLDR releases, typically 1–2 months&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Windows&lt;/strong&gt;: can lag significantly; Microsoft maintains its own TZDB mirror but updates are not automatic&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android&lt;/strong&gt;: depends on the OS version and manufacturer updates&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Language runtime releases&lt;/strong&gt; (weeks to months)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Runtimes with bundled CLDR (Node.js, Java, Go) release updates on their own schedule&lt;/li&gt;
&lt;li&gt;Some runtimes are more aggressive (quick releases); others batch changes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Third-party libraries&lt;/strong&gt; (unpredictable)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Some libraries depend on CLDR and are updated automatically&lt;/li&gt;
&lt;li&gt;Others maintain their own timezone data&lt;/li&gt;
&lt;li&gt;Maintainer activity is critical—abandoned libraries can lag for years&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Node.js: The Duality Problem&lt;/h2&gt;
&lt;p&gt;Node.js presents a unique and often overlooked situation: it has &lt;strong&gt;two simultaneous sources of timezone information&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;Source 1: Native &lt;code&gt;Date&lt;/code&gt; (from the Operating System)&lt;/h3&gt;
&lt;p&gt;The JavaScript &lt;code&gt;Date&lt;/code&gt; object relies on the system&amp;#39;s tzdata:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const now = new Date();
now.toString(); 
// Uses the OS tzdata to determine local timezone offset
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When you call &lt;code&gt;new Date()&lt;/code&gt; and ask for the local representation, Node.js queries the operating system&amp;#39;s timezone database (via &lt;code&gt;libc&lt;/code&gt; on Unix-like systems, the Windows Registry on Windows). If your OS hasn&amp;#39;t updated its tzdata yet, your &lt;code&gt;Date&lt;/code&gt; object won&amp;#39;t reflect the latest rules either.&lt;/p&gt;
&lt;h3&gt;Source 2: &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; (from Bundled ICU)&lt;/h3&gt;
&lt;p&gt;The Intl API is built on ICU (International Components for Unicode), which is bundled into the Node.js binary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const formatter = new Intl.DateTimeFormat(&amp;#39;ca-AD&amp;#39;, {
  timeZone: &amp;#39;Europe/Andorra&amp;#39;,
});
// Uses bundled ICU data, which can differ from OS data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This formatter uses the CLDR/ICU data that was compiled into your Node.js binary at build time. If your Node.js version was built 6 months ago and tz made a change last week, the &lt;code&gt;Intl&lt;/code&gt; API will use outdated data until you upgrade Node.&lt;/p&gt;
&lt;h3&gt;The Mismatch&lt;/h3&gt;
&lt;p&gt;The same JavaScript environment can give you &lt;em&gt;two different answers&lt;/em&gt; for the same timezone:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Example: hypothetical timezone rule change
const d = new Date(&amp;#39;2026-06-15T12:00:00Z&amp;#39;);

// Using Date (OS-based)
console.log(d.toLocaleString(&amp;#39;en-US&amp;#39;, { timeZone: &amp;#39;America/New_York&amp;#39; }));
// May use outdated rules if OS hasn&amp;#39;t updated

// Using explicit Intl (ICU-based)
const formatter = new Intl.DateTimeFormat(&amp;#39;en-US&amp;#39;, { 
  timeZone: &amp;#39;America/New_York&amp;#39; 
});
console.log(formatter.format(d));
// May use different rules if Node.js binary is newer than OS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This split is a well-known pain point in JavaScript environments. Different environments handle it differently—some prefer OS consistency, others prefer Intl consistency.&lt;/p&gt;
&lt;h2&gt;Case Study: British Columbia&amp;#39;s Permanent Daylight Time&lt;/h2&gt;
&lt;p&gt;British Columbia (Canada) announced in late 2023 that it would permanently observe daylight time—effectively staying on &amp;quot;summer time&amp;quot; year-round. This created a problem that exposed the limits of CLDR&amp;#39;s design.&lt;/p&gt;
&lt;p&gt;CLDR&amp;#39;s mental model assumes regions transition between &lt;strong&gt;a standard time and a daylight time&lt;/strong&gt;. Every year, the clock springs forward and falls back. But British Columbia doesn&amp;#39;t follow this pattern anymore. It stays on daylight time constantly.&lt;/p&gt;
&lt;p&gt;When a region stops participating in the normal DST cycle entirely, CLDR has no natural way to represent it. The schema assumes transitions happen &lt;em&gt;periodically&lt;/em&gt; (spring forward, fall back). A permanent shift breaks that assumption.&lt;/p&gt;
&lt;h3&gt;IANA&amp;#39;s Workaround&lt;/h3&gt;
&lt;p&gt;Rather than wait for CLDR to redesign its schema, IANA&amp;#39;s timezone maintainers invented a solution: &lt;strong&gt;a fictional future DST transition&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The tz database now schedules a fake DST transition far in the future. On that date, the region transitions from &amp;quot;summer time&amp;quot; to... &amp;quot;summer time&amp;quot; again. The net effect: no actual time change, but the CLDR rules engine processes it as a normal transition and produces the correct output.&lt;/p&gt;
&lt;p&gt;From the &lt;code&gt;tzdata&lt;/code&gt; NEWS file:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A region that wants to stay on permanent daylight time can be represented as having a DST transition that loops back to itself, effectively freezing the clock in the DST state.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is a clever hack, but it reveals an important truth: &lt;strong&gt;the timezone database is constrained by the tools that consume it&lt;/strong&gt;. IANA doesn&amp;#39;t just define rules; it also has to work around limitations in the systems that implement those rules.&lt;/p&gt;
&lt;p&gt;The specific discussion:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://lists.iana.org/hyperkitty/list/tz@iana.org/thread/NZCHEWN5WZWIC3OQDNHBX4U7Z4PTCJYY/&quot;&gt;IANA tz mailing list thread on British Columbia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://data.iana.org/time-zones/tzdb/NEWS&quot;&gt;IANA tzdb NEWS file&lt;/a&gt; (search for &amp;quot;Canada/Vancouver&amp;quot; or &amp;quot;permanent&amp;quot;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Propagation Matters&lt;/h2&gt;
&lt;p&gt;The reality of timezone propagation creates several practical implications:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;You can&amp;#39;t rely on immediate consistency.&lt;/strong&gt; A timezone rule change made today won&amp;#39;t be reflected everywhere in your infrastructure for weeks or months.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-system mismatches are possible.&lt;/strong&gt; Your web server (running a freshly updated OS) might calculate time differently than the user&amp;#39;s browser (running an older version of Node or Chrome).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Library choice matters.&lt;/strong&gt; Moment.js might reflect changes faster than the native &lt;code&gt;Date&lt;/code&gt; object, or vice versa, depending on when maintainers push updates.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CI/CD and testing become essential.&lt;/strong&gt; If you care about correct timezone behavior, you can&amp;#39;t just trust whatever system you&amp;#39;re testing on. You need explicit tests for known edge cases and irregular transitions.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;What to Do&lt;/h2&gt;
&lt;p&gt;If you&amp;#39;re building systems that handle timezone-dependent logic:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;For coordination:&lt;/strong&gt; Always store and transmit times in UTC. Conversion to local time is a &lt;em&gt;display&lt;/em&gt; concern, not a storage concern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For testing:&lt;/strong&gt; Don&amp;#39;t assume your system&amp;#39;s timezone data is current. Write tests that explicitly test known edge cases and transitions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For Node.js:&lt;/strong&gt; Be aware that &lt;code&gt;Date&lt;/code&gt; and &lt;code&gt;Intl&lt;/code&gt; can disagree. For critical applications, pick one and stick with it, or synchronize them explicitly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For production:&lt;/strong&gt; Monitor timezone-related issues in your observability stack. A timezone propagation delay is invisible until someone tries to book a flight.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>timezone</category><category>iana</category><category>tz</category><category>cldr</category><category>internationalization</category></item><item><title>Completing the IPoAC Stack: Why DoAC Had to Exist</title><link>https://octa.page/doc/completing-the-ipoac-stack/</link><guid isPermaLink="true">https://octa.page/doc/completing-the-ipoac-stack/</guid><description>The IPoAC protocol family had no naming layer for 35 years. DoAC fills the gap with the AA resource record, the Pigeon of Last Resort bootstrap procedure, retransmission semantics calibrated for multi-day transit, and DNSSEC key distribution via Trusted Courier Pigeon.</description><pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In 2001, the Bergen Linux User Group sent actual ping packets over actual pigeons across actual Bergen, Norway. Round-trip time: 3,000 seconds. Packet loss: 55%. The experiment was a real implementation of RFC 1149, a document the IETF published in 1990 as an April Fools joke. The joke had become operational.&lt;/p&gt;
&lt;p&gt;That experiment exposed a gap that nobody had bothered to document: the IPoAC protocol stack was incomplete. You could transmit IP datagrams over pigeons. You could not resolve a hostname.&lt;/p&gt;
&lt;p&gt;I noticed this during a university networking course, years after the Bergen experiment. RFC 1149 came up as comic relief. I mentioned, in a remote session mid-pandemic, that there was an obvious hole in the stack. Nobody in the room understood what I was talking about. The comment went nowhere.&lt;/p&gt;
&lt;p&gt;It came back to me earlier this year while reading the transcript of Root KSK Ceremony 61, held in April 2026. The ceremony is the process by which ICANN physically generates and signs the DNSSEC root key, a procedure involving Hardware Security Modules, auditors, Faraday cages, and multiple safety deposit boxes. It is, in its own way, as absurd as putting a DNS query on a pigeon. The connection clicked. I went back to the April Fools RFC family and wrote &lt;a href=&quot;https://datatracker.ietf.org/doc/draft-cruzgonzalez-ipoac-dns/&quot;&gt;draft-cruzgonzalez-ipoac-dns-00&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;What the IPoAC stack looked like before DoAC&lt;/h2&gt;
&lt;p&gt;The family consists of three RFCs, each technically rigorous, each published on April 1st:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RFC 1149&lt;/strong&gt; (1990): IP datagrams on avian carriers. Defines the physical medium (pigeon), the maximum segment size (256 milligrams), and the basic forwarding model. No higher-layer services.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RFC 2549&lt;/strong&gt; (1999): IPoAC with Quality of Service. Extends RFC 1149 with QoS markings. Introduces the concept of Differentiated Services on a per-feather basis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RFC 6214&lt;/strong&gt; (2011): Adaptation for IPv6. Addresses the expanded address space. Notes that IPv6 addresses are too long to fit comfortably on a standard leg band.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All three assume the destination address is already known. None of them address how a host on an avian-carrier network discovers that address. For twenty years, the implicit answer was: hardcode it.&lt;/p&gt;
&lt;p&gt;That is not a protocol. That is a workaround.&lt;/p&gt;
&lt;h2&gt;The gap&lt;/h2&gt;
&lt;p&gt;In conventional networking, DNS is so fundamental that it&amp;#39;s easy to forget it&amp;#39;s a separate layer. IP gives you reachability. DNS gives you names. You need both for a functional network. Remove DNS from any modern stack and you&amp;#39;re back to editing &lt;code&gt;/etc/hosts&lt;/code&gt; and distributing IP addresses out-of-band.&lt;/p&gt;
&lt;p&gt;The IPoAC stack had exactly this problem. A host that wanted to reach &lt;code&gt;loft.example.com&lt;/code&gt; had to already know the IP address of &lt;code&gt;loft.example.com&lt;/code&gt;. If that address changed (pigeon renumbering events are, apparently, catastrophically disruptive), the operator had to manually update every message attached to every outbound Carrier. At scale, this is unmanageable. At any scale, it&amp;#39;s inelegant.&lt;/p&gt;
&lt;p&gt;DoAC closes this. It is to IPoAC what DNS is to IP: the naming layer the transport layer was always missing.&lt;/p&gt;
&lt;h2&gt;Design decisions&lt;/h2&gt;
&lt;h3&gt;The AA resource record&lt;/h3&gt;
&lt;p&gt;DNS already has a record type for geographic location: LOC (RFC 1876). LOC encodes latitude, longitude, altitude, and precision. It was designed for documentation and geolocation, not for Carrier navigation.&lt;/p&gt;
&lt;p&gt;DoAC needed something different. The AA (Avian Address) record encodes what a Carrier actually needs to find a Loft: coordinates for navigation, a preferred approach heading (to minimize headwind exposure and avoid raptor nesting sites), and a preferred cruising altitude band. LOC&amp;#39;s precision encoding is irrelevant here; sub-meter accuracy matters less than knowing which side of the building to approach from.&lt;/p&gt;
&lt;p&gt;The wire format is 12 bytes: two signed 32-bit integers for latitude and longitude in microdegrees (network byte order), a 16-bit unsigned heading in degrees magnetic north, an 8-bit altitude band, and 8 reserved bits.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                 1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           LATITUDE                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           LONGITUDE                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|             HEADING           |   ALT-BAND    |   (reserved)  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Microdegrees give sub-meter precision globally. The Bergen Loft, for reference:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;example.com.  86400  IN  AA  60.391263 5.322054 270 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;270 degrees: westerly approach. Altitude band 1: medium, 100–300m AGL. Bergen is on the coast. This is not a random example.&lt;/p&gt;
&lt;h3&gt;The bootstrapping problem&lt;/h3&gt;
&lt;p&gt;Every name resolution system eventually hits a version of the same problem: to use the naming service, you must first locate the naming service. DNS resolves this with hardcoded root hints. DHCP resolves it with link-local broadcast. DoH (RFC 8484) resolves it by requiring the resolver&amp;#39;s IP address to be configured out-of-band.&lt;/p&gt;
&lt;p&gt;In IPoAC, the out-of-band channel is physical. You go to the Loft, you get a Carrier trained to its address. DoAC names this procedure (Manual Resolver Bootstrap, MRB) and formalizes the ongoing solution: the Pigeon of Last Resort (PoLR), a pre-trained Carrier with a hardcoded destination, maintained at every host as the entry point to the recursive resolution infrastructure.&lt;/p&gt;
&lt;p&gt;The PoLR is operationally equivalent to the configured recursive resolver in &lt;code&gt;/etc/resolv.conf&lt;/code&gt;, with the difference that misconfiguration results in a lost bird rather than a timeout.&lt;/p&gt;
&lt;p&gt;Hosts SHOULD maintain at least two PoLR Carriers. A single PoLR is a single point of failure, which remains, as always, inadvisable.&lt;/p&gt;
&lt;h3&gt;Retransmission&lt;/h3&gt;
&lt;p&gt;RFC 1035 recommends retransmitting after approximately 5 seconds. This value assumes the transport layer operates in milliseconds. IPoAC does not.&lt;/p&gt;
&lt;p&gt;DoAC sets the minimum retransmission interval at 72 hours, with a seasonal jitter factor derived from average wind speed at the Carrier&amp;#39;s latitude of origin. The retransmission limit is 1: dispatching multiple Carriers for the same query risks out-of-order delivery if one selects a more efficient flight path, producing query ID collisions that are difficult to debug and impossible to explain to management.&lt;/p&gt;
&lt;p&gt;After 21 days without a response, the query is declared lost.&lt;/p&gt;
&lt;h3&gt;DNSSEC&lt;/h3&gt;
&lt;p&gt;DNSSEC requires cryptographic signing of DNS records. The signing key must reach the signing Loft. In DoAC, that means physical transport by Trusted Courier Pigeon (TCP), an acronym collision the document notes and considers appropriate.&lt;/p&gt;
&lt;p&gt;Two timing problems emerge immediately.&lt;/p&gt;
&lt;p&gt;The first is key rollover. DNSSEC rollover involves distributing new keying material before the old key expires. In DoAC, distributing keying material takes days. Rollover windows that are measured in hours in conventional deployments must be measured in weeks. Rollover MUST be scheduled outside migration season (March–May, September–November in the Northern Hemisphere) when Carriers are liable to deliver keying material to the wrong Loft.&lt;/p&gt;
&lt;p&gt;The second is RRSIG expiry. DNSSEC signatures have validity periods. If a signature expires while the Carrier carrying the signed response is still in flight, the response will be invalid on arrival. This condition, In-Flight Expiry (IFE), is a known operational hazard with no equivalent in conventional DNS deployments. The mitigation is straightforward: set RRSIG validity periods longer than your worst-case round-trip transit time.&lt;/p&gt;
&lt;p&gt;The interaction between IFE and migration season is, as the document notes, considered particularly hazardous.&lt;/p&gt;
&lt;h2&gt;What this is, technically&lt;/h2&gt;
&lt;p&gt;DoAC is a complete DNS transport specification for a constrained, high-latency, physically-mediated network. The constraints are real (payload size, retransmission behavior, timing assumptions) and the solutions are internally consistent. The record type is properly defined with a wire format and zone file presentation syntax. The IANA considerations request a real registry with a real policy (Standards Action for future altitude band assignments).&lt;/p&gt;
&lt;p&gt;The humor is in the subject matter. The protocol engineering is not.&lt;/p&gt;
&lt;p&gt;The draft is currently under consideration for publication as an Independent Submission RFC, targeting April 1, 2027, the only appropriate publication date for a document of this kind.&lt;/p&gt;
</content:encoded><category>systems</category><category>ietf</category><category>dns</category><category>ipoac</category><category>protocol-design</category><category>rfcs</category></item><item><title>My First Internet Draft: DNS over Avian Carriers</title><link>https://octa.page/doc/my-first-internet-draft/</link><guid isPermaLink="true">https://octa.page/doc/my-first-internet-draft/</guid><description>Notes on submitting draft-cruzgonzalez-ipoac-dns-00 to the IETF: what DoAC specifies, the AA resource record wire format, the Pigeon of Last Resort bootstrap problem, DNSSEC over Trusted Courier Pigeon, and the path to publication as an April Fools RFC.</description><pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I published my first Internet-Draft to the IETF Datatracker: &lt;a href=&quot;https://datatracker.ietf.org/doc/draft-cruzgonzalez-ipoac-dns/&quot;&gt;draft-cruzgonzalez-ipoac-dns-00&lt;/a&gt;. It specifies DNS over Avian Carriers (DoAC) — hostname resolution for networks operating over pigeon-based transport. I&amp;#39;ve contacted the Independent Submissions Editor to target publication as RFC on April 1, 2027.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s an Internet-Draft&lt;/h2&gt;
&lt;p&gt;An Internet-Draft (I-D) is a working document in the IETF standards process. Anyone can submit one. It expires after 6 months if not renewed. It&amp;#39;s not an RFC — but it&amp;#39;s the required first step to become one.&lt;/p&gt;
&lt;p&gt;RFCs have different publication streams. Most protocol standards go through IETF Working Groups. The &lt;strong&gt;Independent Submissions&lt;/strong&gt; stream is for documents outside any WG&amp;#39;s scope, reviewed and published by the Independent Submissions Editor (ISE). That&amp;#39;s the correct path for April Fools RFCs.&lt;/p&gt;
&lt;h2&gt;The April Fools RFC tradition&lt;/h2&gt;
&lt;p&gt;The IETF has published humor RFCs every April 1st for decades. They are technically rigorous, properly formatted, and go through the same editorial process as any other RFC. The IPoAC family is the canonical example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RFC 1149&lt;/strong&gt; (1990) — IP over Avian Carriers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RFC 2549&lt;/strong&gt; (1999) — IPoAC with Quality of Service&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RFC 6214&lt;/strong&gt; (2011) — Adaptation of RFC 1149 for IPv6&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;RFC 1149 was actually &lt;a href=&quot;https://en.wikipedia.org/wiki/IP_over_Avian_Carriers#Real-life_implementation&quot;&gt;implemented in Bergen, Norway in 2001&lt;/a&gt; by the Bergen Linux User Group. The example Loft in DoAC is located at Bergen&amp;#39;s coordinates (60.391263, 5.322054). This is not a coincidence.&lt;/p&gt;
&lt;h2&gt;What DoAC specifies&lt;/h2&gt;
&lt;p&gt;DoAC closes the gap in the IPoAC protocol stack: prior work defined how to transmit IP datagrams over pigeons, but never addressed how a host on such a network resolves a hostname.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;New resource record: AA (Avian Address)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                 1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           LATITUDE                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           LONGITUDE                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|             HEADING           |   ALT-BAND    |   (reserved)  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;LATITUDE and LONGITUDE are signed 32-bit big-endian integers in microdegrees (degrees × 10⁶). Zone file presentation uses decimal degrees:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;example.com.  86400  IN  AA  60.391263 5.322054 270 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The Pigeon-Before-Egg Problem&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To dispatch a Carrier to resolve a hostname, you first need the IP address of the resolver&amp;#39;s Loft. If that address is in DNS, you have a circular dependency. DoAC resolves this with the &lt;strong&gt;Pigeon of Last Resort (PoLR)&lt;/strong&gt;: a pre-trained Carrier whose destination is hardcoded at provisioning time, analogous to a resolver of last resort. Hosts SHOULD maintain at least two.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retransmission&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RFC 1035 recommends retransmitting unanswered queries after ~5 seconds. DoAC implementations MUST NOT retransmit until 72 hours have elapsed. The retransmission limit is 1. After 21 days without a response, the query MUST be declared lost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;DNSSEC&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Zone Signing Keys are physically transported by Trusted Courier Pigeon (TCP — the acronym collision is noted and considered appropriate). Key rollover MUST be scheduled outside migration season. Signatures that expire while a Carrier is in flight constitute &lt;strong&gt;In-Flight Expiry (IFE)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security considerations&lt;/strong&gt; include: Hawk-in-the-Middle (HitM) attacks, Denial of Flight (DoF), pigeon spoofing via adversarial dye application, replay attacks via taxidermied Carrier, and the Covert Feather Channel — a side channel encoded in feather arrangement that requires a licensed ornithologist to detect.&lt;/p&gt;
&lt;h2&gt;The submission process&lt;/h2&gt;
&lt;p&gt;The draft is authored in RFC XML v3 format (xml2rfc). The toolchain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Validate before submitting
idnits draft-cruzgonzalez-ipoac-dns-00.xml

# Submit at
# https://datatracker.ietf.org/submit/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The submission flow: upload XML → Datatracker generates TXT and HTML → confirm via email → draft is live. The only warning idnits produced was non-ASCII characters (×, −, –) used intentionally in the wire format diagrams. Zero errors, zero flaws.&lt;/p&gt;
&lt;p&gt;After submission, I emailed &lt;code&gt;rfc-ise@rfc-editor.org&lt;/code&gt; directly — the ISE doesn&amp;#39;t find your draft on their own. The email referenced the Datatracker URL, noted the April Fools context, and requested an April 1, 2027 publication date.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s next&lt;/h2&gt;
&lt;p&gt;The ISE will acknowledge, possibly assign an external reviewer, and eventually forward to IESG for conflict review (~4 weeks). If no WG objects, the RFC Editor processes it and coordinates the publication date. AUTH48 — author approval of the final text — happens in the final stretch.&lt;/p&gt;
&lt;p&gt;The draft expires 2026-12-10. If the process extends past that, a -01 refresh is required.&lt;/p&gt;
</content:encoded><category>notes</category><category>ietf</category><category>dns</category><category>ipoac</category><category>rfcs</category></item><item><title>Injecting git config per-process with GIT_CONFIG_KEY_n</title><link>https://octa.page/doc/git-config-env-injection/</link><guid isPermaLink="true">https://octa.page/doc/git-config-env-injection/</guid><description>Injecting git safe.directory per-process via GIT_CONFIG_COUNT and GIT_CONFIG_KEY_n environment variables — no file writes, no sudo — the correct fix for deploy agents running as root against repositories owned by www-data.</description><pubDate>Sun, 07 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Git refuses to operate on a repository owned by a different user than the current process:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fatal: detected dubious ownership in repository at &amp;#39;/var/www/html/avelor.es&amp;#39;
To add an exception for this directory, call:
    git config --global --add safe.directory /var/www/html/avelor.es
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The standard fix is &lt;code&gt;git config --global --add safe.directory &amp;lt;path&amp;gt;&lt;/code&gt;, which writes to &lt;code&gt;~/.gitconfig&lt;/code&gt;. This silently fails when the process running git has a different home directory — a systemd service running as root, for instance, has &lt;code&gt;/root/.gitconfig&lt;/code&gt;, not the user&amp;#39;s.&lt;/p&gt;
&lt;h2&gt;The env var approach&lt;/h2&gt;
&lt;p&gt;Since git 2.31, config can be injected per-process via environment variables without touching any file on disk:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GIT_CONFIG_COUNT=1
GIT_CONFIG_KEY_0=safe.directory
GIT_CONFIG_VALUE_0=*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This adds &lt;code&gt;safe.directory = *&lt;/code&gt; to the config for that process only. The wildcard trusts all directories.&lt;/p&gt;
&lt;p&gt;In Node.js, when spawning deploy subprocesses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const env = Object.assign({}, process.env, {
  GIT_CONFIG_COUNT: &amp;#39;1&amp;#39;,
  GIT_CONFIG_KEY_0: &amp;#39;safe.directory&amp;#39;,
  GIT_CONFIG_VALUE_0: &amp;#39;*&amp;#39;,
}, extraEnv); // project-level env overrides last
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;gitconfig&lt;/code&gt; writes, no &lt;code&gt;sudo&lt;/code&gt;, no per-repo exception lists to maintain. The config lives in the process environment and disappears with it.&lt;/p&gt;
&lt;h2&gt;When this matters&lt;/h2&gt;
&lt;p&gt;Any deploy agent that runs as root (or a different user than the web server) and executes git commands against repos owned by &lt;code&gt;www-data&lt;/code&gt; or another service account hits this. Running &lt;code&gt;git config --global&lt;/code&gt; as your shell user does nothing for that process.&lt;/p&gt;
</content:encoded><category>runtime</category><category>git</category><category>deploy</category><category>linux</category><category>systemd</category></item><item><title>floo: a minimal deploy agent for self-hosted VPS</title><link>https://octa.page/doc/floo-deploy-agent/</link><guid isPermaLink="true">https://octa.page/doc/floo-deploy-agent/</guid><description>floo runs an HTTP deploy agent on the server with YAML-defined project steps, scoped tokens stored as SHA-256 hashes, and real-time log streaming via SSE. systemd as the process supervisor, no Docker, no managed platforms.</description><pubDate>Sat, 06 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every VPS deployment ends up as the same ritual: SSH in, &lt;code&gt;git pull&lt;/code&gt;, &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;pm2 restart&lt;/code&gt;. Automating it properly means either exposing SSH credentials to CI or paying for a platform that wraps a container runtime you don&amp;#39;t need.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;floo&lt;/code&gt; runs an HTTP agent on the server. You configure projects and their steps in a YAML file. You issue tokens — one per project, scoped, shown once, stored hashed. From your machine or a CI pipeline, you run &lt;code&gt;floo myapp&lt;/code&gt; and the deploy logs stream back in real time via SSE.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;projects:
  avelor.es:
    cwd: /var/www/html/avelor.es
    steps:
      - git fetch origin master
      - git reset --hard origin/master
      - npm ci
      - pm2 restart avelor --update-env
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;floo avelor.es
→ deploying avelor.es  4 steps
[1/4] git fetch origin master ✓
[2/4] git reset --hard origin/master ✓
...
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Architecture decisions&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Tokens hashed at rest.&lt;/strong&gt; SHA-256, shown once at issuance. The server stores no raw token — a dump of the token file is useless to an attacker.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-project token scoping.&lt;/strong&gt; A token for &lt;code&gt;avelor.es&lt;/code&gt; returns 403 against any other project name. A compromised token can&amp;#39;t enumerate projects or deploy anything else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SSE for log streaming.&lt;/strong&gt; No WebSocket, no polling. The response is a &lt;code&gt;text/event-stream&lt;/code&gt; that stays open while steps execute. &lt;code&gt;floo&lt;/code&gt; the client reads it and prints each line as it arrives.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;systemd as the process supervisor.&lt;/strong&gt; &lt;code&gt;floo agent install&lt;/code&gt; writes and enables a systemd unit. No wrapper scripts, no custom daemon logic, no PID file juggling. &lt;code&gt;systemctl restart floo&lt;/code&gt; is the ops interface.&lt;/p&gt;
&lt;h2&gt;What I ruled out&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Webhook-only approaches&lt;/strong&gt; — no streaming, no live feedback during a deploy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker-based solutions&lt;/strong&gt; — the sites running on this VPS are PHP + Node apps. A container runtime is overhead that doesn&amp;#39;t pay for itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Managed platforms&lt;/strong&gt; — Render, Railway, Fly — each has constraints on build environment or charges for what&amp;#39;s effectively a small Node process and a git pull.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Implementation&lt;/h2&gt;
&lt;p&gt;~700 lines of Node.js across six modules. The agent is a plain &lt;code&gt;http.createServer&lt;/code&gt;. Steps run as &lt;code&gt;sh -c &amp;lt;cmd&amp;gt;&lt;/code&gt; subprocesses with stdout/stderr piped back line by line. The client is a fetch loop over the SSE response. Config is YAML via &lt;code&gt;js-yaml&lt;/code&gt;, tokens stored as JSON.&lt;/p&gt;
&lt;p&gt;Release pipeline builds platform binaries via Node SEA (Single Executable Applications) and publishes to npm and a Homebrew tap on each tag.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g @avelor/floo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;v0.2.1. Repo: &lt;a href=&quot;https://github.com/avelor-es/floo&quot;&gt;github.com/avelor-es/floo&lt;/a&gt;&lt;/p&gt;
</content:encoded><category>notes</category><category>deploy</category><category>nodejs</category><category>cli</category><category>self-hosted</category><category>systemd</category></item><item><title>Bifrost: what changed when a private tunnel became a public package</title><link>https://octa.page/doc/bifrost-multi-peer-tunnel-relay/</link><guid isPermaLink="true">https://octa.page/doc/bifrost-multi-peer-tunnel-relay/</guid><description>Design changes required to turn a single-user WebSocket tunnel into a self-hostable package: named peers with subdomain routing, scoped tokens stored as SHA-256 hashes, backpressure limits, dead connection detection, and queue drain on disconnect.</description><pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;/doc/websocket-tunnel-relay/&quot;&gt;original relay&lt;/a&gt; was a single-user private tool: one hardcoded token, one active connection, no queue limits. Turning that into &lt;code&gt;@avelor/bifrost&lt;/code&gt; — a package anyone can self-host — required changing every assumption that came from &amp;quot;only I will use this.&amp;quot;&lt;/p&gt;
&lt;h2&gt;Named tunnels with subdomain routing&lt;/h2&gt;
&lt;p&gt;The original relay had one fixed public URL. Bifrost introduces named peers: each client registers under a name (e.g. &lt;code&gt;preview&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;) and incoming requests are routed by &lt;code&gt;Host&lt;/code&gt; subdomain first, path prefix second.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function nameFromHost(host) {
  const m = (host || &amp;#39;&amp;#39;).match(/^([a-z0-9][a-z0-9-]*)\.tunnel\./i);
  return m ? m[1].toLowerCase() : null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;preview.tunnel.example.com&lt;/code&gt; resolves to the &lt;code&gt;preview&lt;/code&gt; peer. If no subdomain matches, the relay falls back to path-based routing (&lt;code&gt;/_bifrost/{name}&lt;/code&gt;) and then to &lt;code&gt;default&lt;/code&gt;. Multiple clients can be connected simultaneously — the relay holds them in a &lt;code&gt;Map&amp;lt;name, ws&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The trade-off from the original design stands: subdomain routing requires a wildcard cert and a wildcard &lt;code&gt;ServerAlias&lt;/code&gt; in Apache/nginx. The relay doesn&amp;#39;t manage those — it reads the &lt;code&gt;Host&lt;/code&gt; header and trusts the upstream proxy to handle TLS.&lt;/p&gt;
&lt;h2&gt;Token scoping&lt;/h2&gt;
&lt;p&gt;The original auth was a string equality check against a single env var. Bifrost stores tokens as SHA-256 hashes (raw value shown once at issue time, never persisted) with a &lt;code&gt;scope&lt;/code&gt; field.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function validate(raw, name) {
  const hash  = hashToken(raw);
  const entry = readTokens().find(t =&amp;gt; t.hash === hash);
  if (!entry) return null;
  if (entry.scope === &amp;#39;*&amp;#39;) return entry;
  return entry.scope === (name || &amp;#39;default&amp;#39;).toLowerCase() ? entry : null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A token scoped to &lt;code&gt;preview&lt;/code&gt; cannot connect as &lt;code&gt;api&lt;/code&gt;. A token scoped to &lt;code&gt;*&lt;/code&gt; connects to any name. This lets you issue short-lived per-subdomain tokens for CI environments without exposing a global credential.&lt;/p&gt;
&lt;p&gt;Revocation is a list filter: &lt;code&gt;tokens.filter(t =&amp;gt; t.id !== id)&lt;/code&gt;. No database, no key rotation ceremony — just a JSON file and a 4-byte opaque ID for human reference.&lt;/p&gt;
&lt;h2&gt;Backpressure&lt;/h2&gt;
&lt;p&gt;The original relay had no queue limit and no body size cap. Both are problems the moment multiple users share the same relay.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const MAX_QUEUE = 256;
const MAX_BODY  = 10 * 1024 * 1024; // 10 MB

if (queue.size &amp;gt;= MAX_QUEUE) {
  errorResponse(res, 429, req);
  return;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Body limit fires early, during streaming — &lt;code&gt;req.destroy()&lt;/code&gt; is called before the full body arrives, so a 100 MB upload doesn&amp;#39;t exhaust memory before the 413 is sent.&lt;/p&gt;
&lt;p&gt;The queue limit of 256 is conservative. Each entry holds a live &lt;code&gt;ServerResponse&lt;/code&gt; reference and a timer. Unbounded queue growth under a slow or disconnected peer is the obvious failure mode.&lt;/p&gt;
&lt;h2&gt;Dead connection detection&lt;/h2&gt;
&lt;p&gt;WebSocket &lt;code&gt;close&lt;/code&gt; events aren&amp;#39;t reliable over misbehaving networks. The relay pings every active peer every 30 seconds and terminates any that don&amp;#39;t respond:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;setInterval(() =&amp;gt; {
  for (const [name, ws] of peers) {
    if (ws._pingPending) {
      ws.terminate(); // forceful, no close handshake
      continue;
    }
    ws._pingPending = true;
    ws.ping();
  }
}, 30_000).unref();

ws.on(&amp;#39;pong&amp;#39;, () =&amp;gt; { ws._pingPending = false; });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_pingPending&lt;/code&gt; is set before the ping and cleared on pong. A missed pong means the next interval fires with the flag still set. Two intervals minimum before termination — 30s for the first ping, up to 60s total for a dead connection to be evicted.&lt;/p&gt;
&lt;h2&gt;Client resilience: 1008 means stop&lt;/h2&gt;
&lt;p&gt;The client reconnects on any unexpected close — except code &lt;code&gt;1008&lt;/code&gt; (policy violation). That code means the server actively rejected the connection: bad token, name already taken, invalid path. Retrying is pointless.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;ws.on(&amp;#39;close&amp;#39;, (code, reason) =&amp;gt; {
  if (intentionalClose) return;
  if (code === 1008) {
    process.stderr.write(R + &amp;#39;rejected: &amp;#39; + Z + (reason?.toString() || &amp;#39;unauthorized&amp;#39;) + &amp;#39;\n&amp;#39;);
    cleanup(1);
    return;
  }
  scheduleReconnect();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Network errors get exponential backoff: &lt;code&gt;Math.min(1000 * 2^retries, 30_000)&lt;/code&gt;. Retry counter resets on a successful &lt;code&gt;open&lt;/code&gt; event, so a stable reconnection doesn&amp;#39;t carry penalty from earlier failures.&lt;/p&gt;
&lt;h2&gt;Queue drain on disconnect&lt;/h2&gt;
&lt;p&gt;When a peer disconnects, the relay doesn&amp;#39;t wait for the 30-second timeouts on pending requests — it drains the queue immediately with 502:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;ws.on(&amp;#39;close&amp;#39;, () =&amp;gt; {
  peers.delete(name);
  for (const [id, entry] of queue) {
    if (entry.peerName !== name) continue;
    clearTimeout(entry.timer);
    errorResponse(entry.res, 502, entry.req);
    queue.delete(id);
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;peerName&lt;/code&gt; stored with each queue entry is the link back. Without it, a disconnect would leave up to 256 requests hanging for 30 seconds each — a noticeable freeze for any webhook handler watching for a response.&lt;/p&gt;
</content:encoded><category>architecture</category><category>websocket</category><category>networking</category><category>bifrost</category><category>open-source</category></item><item><title>Building @avelor/vhost: From Repeated Pain to Open Source Tool</title><link>https://octa.page/doc/building-avelor-vhost/</link><guid isPermaLink="true">https://octa.page/doc/building-avelor-vhost/</guid><description>@avelor/vhost generates Apache VirtualHost configs from a YAML file — with a managed-config marker for gradual migration, a default-vhost that blocks unrecognized requests, and content-negotiated error pages that serve HTML or JSON based on the Accept header.</description><pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every new domain starts the same way. Copy a VirtualHost template, replace three strings, uncomment the SSL block after Certbot runs, verify the HTTP→HTTPS redirect is there. Repeat for aliases. The template is maybe 40 lines. After the tenth domain, you have ten variants, some with subtle differences: a missing &lt;code&gt;AllowOverride All&lt;/code&gt;, a wrong log path, an alias that didn&amp;#39;t make it to the &lt;code&gt;RewriteCond&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The other half of the problem is invisible. Apache&amp;#39;s default behavior when a VirtualHost doesn&amp;#39;t match is to fall through to the first &lt;code&gt;.conf&lt;/code&gt; alphabetically. On a server with ten domains, an unrecognized request — a crawler, a scanner, a misconfigured DNS — silently gets served content from whichever site happens to sort first. No error page. No indication anything went wrong.&lt;/p&gt;
&lt;p&gt;The fix for both problems is the same: stop treating VirtualHost configs as files you edit and start treating them as artifacts you generate.&lt;/p&gt;
&lt;h2&gt;The tool&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;@avelor/vhost&lt;/code&gt; is a Node.js CLI that takes a &lt;code&gt;sites.yml&lt;/code&gt; and produces Apache configs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;sites:
  avelor.com:
    aliases: [www.avelor.com]
    mode: static
    root: /home/avelor/avelor

  api.avelor.es:
    mode: proxy
    port: 3000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sudo vhost apply&lt;/code&gt; generates the &lt;code&gt;.conf&lt;/code&gt; files, runs &lt;code&gt;a2ensite&lt;/code&gt;, runs &lt;code&gt;apache2ctl configtest&lt;/code&gt;, and reloads Apache. Every generated file has &lt;code&gt;# Generated by @avelor/vhost&lt;/code&gt; on the first line. That comment is load-bearing — &lt;code&gt;vhost check&lt;/code&gt; reads it to distinguish generated configs from hand-written ones.&lt;/p&gt;
&lt;h2&gt;Gradual migration&lt;/h2&gt;
&lt;p&gt;The tool was designed around an existing server with hand-written configs. Requiring a full migration upfront is a non-starter: ten domains, some with complex rewrite rules, some with custom headers. One bad config and the whole server is down.&lt;/p&gt;
&lt;p&gt;The managed marker solves this without ceremony. Every &lt;code&gt;.conf&lt;/code&gt; that predates &lt;code&gt;vhost apply&lt;/code&gt; shows up in &lt;code&gt;vhost check&lt;/code&gt; as &lt;code&gt;manual config&lt;/code&gt; — functional, not yet under source-of-truth control:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ✓  avelor.com        managed   /home/avelor/avelor
  !  old-client.com    manual config   not in sites.yml
  !  staging.app.com   manual config   → run: vhost apply staging.app.com to migrate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Migration is one site at a time. Add the domain to &lt;code&gt;sites.yml&lt;/code&gt;, run &lt;code&gt;vhost apply &amp;lt;domain&amp;gt;&lt;/code&gt;, the generated config overwrites the manual one. When &lt;code&gt;vhost check&lt;/code&gt; shows no &lt;code&gt;manual config&lt;/code&gt; entries, the migration is complete.&lt;/p&gt;
&lt;h2&gt;Error pages via content negotiation&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;vhost apply&lt;/code&gt; installs built-in error pages for the full 4xx/5xx range. The design constraint: a server that hosts both web apps and APIs needs error responses in the right format depending on who&amp;#39;s asking.&lt;/p&gt;
&lt;p&gt;The implementation uses Apache&amp;#39;s &lt;code&gt;MultiViews&lt;/code&gt; content negotiation with static files. Each error code has three variants:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/var/www/vhost-errors/
  404.html   404.json   404.xml
  500.html   500.json   500.xml
  ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ErrorDocument&lt;/code&gt; directive points to the path without extension:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apache&quot;&gt;ErrorDocument 404 /vhost-errors/404
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apache picks &lt;code&gt;.html&lt;/code&gt;, &lt;code&gt;.json&lt;/code&gt;, or &lt;code&gt;.xml&lt;/code&gt; based on the request&amp;#39;s &lt;code&gt;Accept&lt;/code&gt; header. No PHP, no CGI, no runtime. The JSON response is &lt;code&gt;{&amp;quot;code&amp;quot;: 404, &amp;quot;message&amp;quot;: &amp;quot;Page not found.&amp;quot;}&lt;/code&gt;. The HTML page shows the status code large, a generic message, and &lt;code&gt;Avelor · vhost&lt;/code&gt; fixed at the bottom.&lt;/p&gt;
&lt;h2&gt;Why open source&lt;/h2&gt;
&lt;p&gt;Avelor already publishes independent developer tools. The pattern is consistent: if the problem is universal and the solution has no coupling to private infrastructure, it goes public.&lt;/p&gt;
&lt;p&gt;The Apache VirtualHost problem isn&amp;#39;t specific to Avelor. Any developer running multi-domain Apache on a VPS encounters it. The tool is a YAML-to-Apache-config transformer with a CLI and nothing else. Keeping it private would be the stranger choice.&lt;/p&gt;
</content:encoded><category>systems</category><category>apache</category><category>vhost</category><category>cli</category><category>developer-tools</category><category>open-source</category></item><item><title>Why I Built mesh: Local Dev Proxy with Named Services and Failure Injection</title><link>https://octa.page/doc/why-i-built-mesh/</link><guid isPermaLink="true">https://octa.page/doc/why-i-built-mesh/</guid><description>mesh is a local dev proxy where services get names instead of port numbers, and a YAML rules block injects failures at the network layer — 503s, delays, per-request rates — without touching backend code or adding mocks.</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every fullstack project starts the same way: three terminals open, one running on &lt;code&gt;:3000&lt;/code&gt;, one on &lt;code&gt;:4000&lt;/code&gt;, one on &lt;code&gt;:5000&lt;/code&gt;. You mix them up. You hardcode &lt;code&gt;localhost:4000&lt;/code&gt; somewhere and forget to change it before pushing. You test happy paths because injecting a 503 means either touching the API code or reaching for a mock library.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mesh&lt;/code&gt; solves both problems in a single YAML file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  app: 3000
  api: 4000

rules:
  api:
    - path: /payments
      status: 503
      rate: 30
    - path: /slow-endpoint
      delay: 2000
      rate: 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sudo mesh route&lt;/code&gt; writes &lt;code&gt;/etc/hosts&lt;/code&gt;, starts an HTTP/HTTPS proxy on &lt;code&gt;127.0.0.1&lt;/code&gt;, and cleans up on exit. Services are available at &lt;code&gt;app.test&lt;/code&gt; and &lt;code&gt;api.test&lt;/code&gt;. If mkcert is installed, HTTPS is automatic.&lt;/p&gt;
&lt;h2&gt;The failure injection part&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;rules&lt;/code&gt; block is the reason this exists. It intercepts matching requests at the proxy layer — no mocks, no code changes, no test harness. You can fail 30% of &lt;code&gt;/payments&lt;/code&gt; calls with a 503, delay &lt;code&gt;/slow-endpoint&lt;/code&gt; by 2 seconds, or combine both. The rate is per-request, resolved at runtime via &lt;code&gt;Math.random()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This lets you test how your frontend handles partial outages without touching the backend at all.&lt;/p&gt;
&lt;h2&gt;What I ruled out&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;hotel&lt;/code&gt;&lt;/strong&gt; — closest prior art, abandoned since 2023, no HTTPS, no failure injection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caddy / Traefik&lt;/strong&gt; — production-grade reverse proxies. Config overhead is real. Not what you reach for in a dev project.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mocking the fetch layer&lt;/strong&gt; — changes test behavior vs. real behavior. The proxy intercepts at the network level, so the client code runs exactly as it would in production.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Implementation&lt;/h2&gt;
&lt;p&gt;~400 lines of Node.js, no framework. The proxy is &lt;code&gt;http-proxy&lt;/code&gt;. TLS comes from &lt;code&gt;mkcert&lt;/code&gt;. Config is plain YAML parsed with &lt;code&gt;js-yaml&lt;/code&gt;. Hot-reload watches the config file and mutates the in-memory service/rule maps in place so existing connections survive.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install -g @avelor/mesh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repo: &lt;a href=&quot;https://github.com/avelor-es/mesh&quot;&gt;github.com/avelor-es/mesh&lt;/a&gt;&lt;/p&gt;
</content:encoded><category>notes</category><category>proxy</category><category>developer-tools</category><category>local-development</category><category>fault-injection</category><category>nodejs</category><category>cli</category></item><item><title>CV as Code: Generating a PDF from an Astro page with Playwright</title><link>https://octa.page/doc/cv-as-code-playwright-pdf/</link><guid isPermaLink="true">https://octa.page/doc/cv-as-code-playwright-pdf/</guid><description>Generating a print-ready PDF CV from a static Astro page using Playwright headlessly — why @page margins are ignored by Playwright, why astro-pdf was skipped, and ATS compatibility pitfalls with em dashes and special characters.</description><pubDate>Sun, 31 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The CV lives in &lt;code&gt;src/pages/cv-print.astro&lt;/code&gt; — a fully static, print-optimized HTML page with &lt;code&gt;noindex&lt;/code&gt;. Playwright visits it headlessly and saves the output to &lt;code&gt;public/&lt;/code&gt;, where the download link already points.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm dev          # start local server on :4321
pnpm cv:pdf       # run scripts/generate-cv-pdf.mjs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The generation script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { chromium } from &amp;#39;playwright&amp;#39;;

const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto(&amp;#39;http://localhost:4321/cv-print&amp;#39;, { waitUntil: &amp;#39;networkidle&amp;#39; });
await page.pdf({
  path: &amp;#39;public/Christian_Elias_Cruz_Gonzalez_esp.pdf&amp;#39;,
  format: &amp;#39;A4&amp;#39;,
  printBackground: false,
  margin: { top: &amp;#39;18mm&amp;#39;, right: &amp;#39;18mm&amp;#39;, bottom: &amp;#39;14mm&amp;#39;, left: &amp;#39;18mm&amp;#39; },
});

await browser.close();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The page uses &lt;code&gt;@page { size: A4; margin: ... }&lt;/code&gt; so margins are declared twice — once in CSS for browser preview, once in &lt;code&gt;page.pdf()&lt;/code&gt; for the headless render. In practice Playwright ignores &lt;code&gt;@page&lt;/code&gt; margins and uses the ones passed to &lt;code&gt;pdf()&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Why not &lt;code&gt;astro-pdf&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;astro-pdf&lt;/code&gt; hooks into the build and visits pages post-build via a preview server. That works but adds build time and a dev dependency that trails Astro major releases. A standalone script is simpler: edit the page, run &lt;code&gt;pnpm cv:pdf&lt;/code&gt;, commit the PDF. No build coupling.&lt;/p&gt;
&lt;h2&gt;ATS considerations&lt;/h2&gt;
&lt;p&gt;The resulting PDF is text-based (not a rasterized image), so ATS parsers extract content correctly. A few patterns that break ATS even in text PDFs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Em dashes (&lt;code&gt;—&lt;/code&gt;) in date ranges — some parsers (Greenhouse, Lever) misread them as unknown characters. Use simple hyphens.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;+N&lt;/code&gt; prefix on numbers inside bullets — some parsers treat &lt;code&gt;+&lt;/code&gt; as a boolean search operator. Write &lt;code&gt;más de N&lt;/code&gt; or &lt;code&gt;over N&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;→&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; in prose — replace with plain text equivalents before generating.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>notes</category><category>astro</category><category>playwright</category><category>pdf</category><category>tooling</category></item><item><title>Feature flag services don&apos;t store user IDs — and the consequence of that</title><link>https://octa.page/doc/feature-flags-stateless-users/</link><guid isPermaLink="true">https://octa.page/doc/feature-flags-stateless-users/</guid><description>Tessera stores no user records — rollout consistency comes from deterministic hashing, not a user registry. The consequence: exposure analytics require a separate events table. How LaunchDarkly, Statsig, and PostHog Flags handle the same tradeoff.</description><pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Tessera holds no record of which users exist. No user table, no user IDs, no stored profiles. The evaluation endpoint receives a context and returns a result. Nothing persists about the user.&lt;/p&gt;
&lt;p&gt;This is not a privacy decision primarily — it&amp;#39;s a correctness decision. Storing users would require a registration step that creates a coordination problem: a user must exist in Tessera before they can be evaluated. That&amp;#39;s a distributed state dependency with no benefit.&lt;/p&gt;
&lt;h2&gt;How evaluation stays consistent without storage&lt;/h2&gt;
&lt;p&gt;For percentage rollouts, consistency comes from the hash being deterministic:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bucket = murmur3(flagId + userId) % 100
enabled = bucket &amp;lt; rolloutPercentage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same &lt;code&gt;userId&lt;/code&gt;, same &lt;code&gt;flagId&lt;/code&gt;, always the same &lt;code&gt;bucket&lt;/code&gt;. The result is stable across requests, across servers, across time — without ever writing the user ID to a database.&lt;/p&gt;
&lt;p&gt;For user-list rules, the rule itself stores the IDs (&lt;code&gt;userId IN [&amp;#39;a1b2&amp;#39;, &amp;#39;c3d4&amp;#39;, ...]&lt;/code&gt;). Tessera knows who gets the flag because the operator explicitly listed them, not because users were registered.&lt;/p&gt;
&lt;h2&gt;The consequence&lt;/h2&gt;
&lt;p&gt;If you need to answer &amp;quot;how many users currently have this flag active?&amp;quot; — you can&amp;#39;t. Not without evaluating every user ID you know about, one by one.&lt;/p&gt;
&lt;p&gt;The correct solution is exposure events: the SDK reports each flag read back to a collection endpoint.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// POST /v1/events
{
  &amp;quot;events&amp;quot;: [
    { &amp;quot;flag&amp;quot;: &amp;quot;nueva-ui&amp;quot;, &amp;quot;userId&amp;quot;: &amp;quot;a1b2c3d4&amp;quot;, &amp;quot;value&amp;quot;: true }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The events table becomes the source of truth for analytics. This is how LaunchDarkly, Statsig, and PostHog Flags work internally. The dashboard&amp;#39;s impression counts come from event aggregation, not from querying a user registry.&lt;/p&gt;
&lt;p&gt;Without exposure events, the service is still complete for controlling rollouts. You just can&amp;#39;t measure exposure rates until you add the reporting layer.&lt;/p&gt;
&lt;h2&gt;What Tessera does store about users&lt;/h2&gt;
&lt;p&gt;One thing only: user-level overrides.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;user_overrides (flag_id, environment, platform_user_id, value)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When an operator adds a specific user to a permanent override (beta tester, internal account), that &lt;code&gt;platform_user_id&lt;/code&gt; is written. It&amp;#39;s stored as an opaque string — whatever the client passes. Tessera doesn&amp;#39;t know or care what it represents.&lt;/p&gt;
</content:encoded><category>architecture</category><category>feature-flags</category><category>privacy</category></item><item><title>Separating the flag evaluation API from the dashboard in the same monorepo</title><link>https://octa.page/doc/feature-flags-hono-next-split/</link><guid isPermaLink="true">https://octa.page/doc/feature-flags-hono-next-split/</guid><description>Why Tessera&apos;s feature flag evaluation API runs on Hono rather than Next.js API routes — cold start latency, deployment coupling, and framework overhead — and how a shared monorepo makes the split cheap while keeping evaluation logic in one place.</description><pubDate>Mon, 25 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Tessera&amp;#39;s monorepo has two apps:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apps/
  eval-api/    ← Hono (Node.js)
  dashboard/   ← Next.js App Router
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They share types from &lt;code&gt;packages/core&lt;/code&gt; but run as separate processes. The split exists because the eval API is in the hot path of every flag read in every client application, and Next.js API routes carry overhead that has no place there.&lt;/p&gt;
&lt;h2&gt;What &amp;quot;hot path&amp;quot; means here&lt;/h2&gt;
&lt;p&gt;Every server component render, every middleware execution, every PHP page load that reads feature flags hits &lt;code&gt;POST /v1/flags&lt;/code&gt;. In a Next.js app with 10 flagged code paths per request, that&amp;#39;s one network call per page render — not ten, because the SDK fetches all flags at once and caches locally. But that one call needs to be fast.&lt;/p&gt;
&lt;p&gt;A Hono handler on Node.js for this route is approximately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;app.post(&amp;#39;/v1/flags&amp;#39;, async (c) =&amp;gt; {
  const token = await verifyToken(c.req.header(&amp;#39;Authorization&amp;#39;))
  const flags = await loadFlags(token.projectId)          // memory cache, 5s TTL
  const result = evaluate(flags, await c.req.json())      // pure function, no I/O
  return c.json(result)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No framework boot, no middleware chain, no RSC runtime, no edge config resolution. The request goes in, the hashmap comes out.&lt;/p&gt;
&lt;h2&gt;Why Next.js API routes don&amp;#39;t belong here&lt;/h2&gt;
&lt;p&gt;Next.js API routes run inside the Next.js runtime. That runtime does useful things for a dashboard — RSC, streaming, image optimization, middleware composition — none of which matter for a JSON endpoint that does a token lookup, a cache read, and a pure function call.&lt;/p&gt;
&lt;p&gt;The overhead isn&amp;#39;t theoretical. Next.js API routes have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cold start latency when the runtime hasn&amp;#39;t warmed up&lt;/li&gt;
&lt;li&gt;A heavier request lifecycle (middleware, headers normalization, response wrapping)&lt;/li&gt;
&lt;li&gt;Coupling to the deployment model of the dashboard (if the dashboard is on Vercel, the API is too)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The dashboard has none of these constraints. Page loads are user-initiated, tolerate 200–400ms, and benefit from everything Next.js provides.&lt;/p&gt;
&lt;h2&gt;The monorepo makes this cheap&lt;/h2&gt;
&lt;p&gt;Both apps live in the same repo, share types and the evaluation logic from &lt;code&gt;packages/core&lt;/code&gt;, and are deployed independently. The eval-api gets its own process, its own port, its own subdomain (&lt;code&gt;flags.tessera.dev&lt;/code&gt;). The dashboard is a separate Next.js app that talks to the eval-api like any other client.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;packages/core/src/evaluator.ts
  ↑ imported by
apps/eval-api/src/routes/eval.ts   (runs the evaluation)
apps/dashboard/...                  (uses the dashboard&amp;#39;s own API routes for CRUD)
packages/sdk-next/src/server.ts    (calls eval-api over HTTP)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The evaluation logic is written once in &lt;code&gt;core&lt;/code&gt; and runs in both the API (at request time) and potentially in the SDK (for offline/cached evaluation). Neither app owns it.&lt;/p&gt;
</content:encoded><category>architecture</category><category>feature-flags</category><category>hono</category><category>next-js</category><category>performance</category><category>monorepo</category></item><item><title>CSS print layout: floats, BFC, and image containment</title><link>https://octa.page/doc/css-print-float-bfc/</link><guid isPermaLink="true">https://octa.page/doc/css-print-float-bfc/</guid><description>Four print layout behaviors that break screen assumptions: why floats beat CSS Grid for variable-height sidebars, why DOM order matters for floats, why overflow:hidden on a float sibling breaks multi-page layouts, and how to draw rules that actually print.</description><pubDate>Mon, 25 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;@media print&lt;/code&gt; breaks several assumptions that hold in screen layout. Three interact badly when you mix a floated sidebar with article images.&lt;/p&gt;
&lt;h2&gt;Float instead of grid for variable-height sidebars&lt;/h2&gt;
&lt;p&gt;CSS Grid stretches both columns to the same height. In a two-column print layout where the sidebar is shorter than the article, the grid leaves a blank column on subsequent pages — the sidebar column reserves space even when empty.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;float: right&lt;/code&gt; on the sidebar solves this. The sidebar occupies only as much vertical space as its content; once it ends, the article expands to full width.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@media print {
  .layout { display: block; }
  .sidebar { float: right; width: 6.5cm; margin-left: 0.5cm; }
  .article { padding: 0.5cm 0.4cm 0.5cm 0; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;clear: both&lt;/code&gt; is required on any element that must start below the float — otherwise it wraps around the sidebar at its natural document position.&lt;/p&gt;
&lt;h2&gt;DOM order matters for floats&lt;/h2&gt;
&lt;p&gt;Unlike Grid (where &lt;code&gt;grid-column&lt;/code&gt; overrides source order), floats respect DOM position. A sidebar that appears after the article in the DOM floats to the right starting at the end of the article, not at the top.&lt;/p&gt;
&lt;p&gt;The sidebar must precede the article in the DOM. For screen Grid, explicit &lt;code&gt;grid-column&lt;/code&gt; / &lt;code&gt;grid-row&lt;/code&gt; assignments restore the visual order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.sidebar { grid-column: 2; grid-row: 1; }
.article { grid-column: 1; grid-row: 1; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;code&gt;overflow: hidden&lt;/code&gt; on a float sibling creates a permanent BFC&lt;/h2&gt;
&lt;p&gt;The obvious fix for images overflowing into the sidebar column is to add &lt;code&gt;overflow: hidden&lt;/code&gt; to the article element, which creates a Block Formatting Context that shrinks it to avoid the float. This works — but the BFC persists for the entire article, so every page is rendered as if the sidebar is still present. The narrow column follows the article onto page 2 and beyond.&lt;/p&gt;
&lt;p&gt;The correct fix: don&amp;#39;t create a BFC on the article. Instead, constrain images directly with an explicit &lt;code&gt;max-width&lt;/code&gt; that fits within the article column at its narrowest point (alongside the sidebar):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@media print {
  figure {
    max-width: 10.5cm; /* page width − sidebar − margins */
    break-inside: avoid;
  }
  figure img {
    width: 100%;
    max-height: 9cm;
    height: auto;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Images are then constrained by the figure&amp;#39;s box, not by the article&amp;#39;s BFC. On page 2, the figure is narrower than the full page but the article itself is not — an acceptable tradeoff.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;background&lt;/code&gt; on pseudo-elements does not print&lt;/h2&gt;
&lt;p&gt;Lines drawn via &lt;code&gt;background&lt;/code&gt; on &lt;code&gt;::before&lt;/code&gt; / &lt;code&gt;::after&lt;/code&gt; pseudo-elements (a common pattern for decorative rules between flex items) are suppressed by default in print — browsers treat them as background graphics, which users typically disable.&lt;/p&gt;
&lt;p&gt;Use &lt;code&gt;border-top&lt;/code&gt; with &lt;code&gt;height: 0&lt;/code&gt; instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* won&amp;#39;t print */
.rule::before { content: &amp;#39;&amp;#39;; flex: 1; height: 1px; background: black; }

/* prints reliably */
.rule::before { content: &amp;#39;&amp;#39;; flex: 1; height: 0; border-top: 1px solid black; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Grayscale images&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@media print {
  img { filter: grayscale(100%); }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One line. Color is preserved on screen; print output is monochrome. No build-time conversion needed.&lt;/p&gt;
</content:encoded><category>runtime</category><category>css</category><category>print</category><category>layout</category><category>browser</category></item><item><title>JSON-LD Entity Graph for Brand Disambiguation on Minimal Sites</title><link>https://octa.page/doc/jsonld-entity-graph-brand-disambiguation/</link><guid isPermaLink="true">https://octa.page/doc/jsonld-entity-graph-brand-disambiguation/</guid><description>How to use JSON-LD with @id, sameAs, and rel=me to establish a coherent entity graph that disambiguates a brand sharing its name with unrelated entities — the three-node minimum, Wikidata&apos;s role, and what structured data cannot do.</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When a brand name is shared by multiple unrelated entities — a watch retailer, a consulting firm, a capital fund — Google has no reliable way to determine which result belongs to which entity unless the sites themselves publish structured signals. For a minimal site with a single indexed page, the problem is acute: no content volume, no backlink density, no topical authority. The only lever is the entity graph.&lt;/p&gt;
&lt;h2&gt;The Problem with Unnamed Entities&lt;/h2&gt;
&lt;p&gt;A JSON-LD block without &lt;code&gt;@id&lt;/code&gt; is a hint. Google reads it, may or may not associate it with a Knowledge Graph node, and continues. Two sites publishing the same &lt;code&gt;&amp;quot;name&amp;quot;: &amp;quot;Avelor&amp;quot;&lt;/code&gt; with no canonical identifier produce ambiguous signals that Google resolves arbitrarily — usually by favoring whichever has more content or backlinks.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@id&lt;/code&gt; changes this. It declares a URI as the canonical identifier for the entity, making the JSON-LD node addressable and linkable across documents:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;@context&amp;quot;: &amp;quot;https://schema.org&amp;quot;,
  &amp;quot;@type&amp;quot;: &amp;quot;Organization&amp;quot;,
  &amp;quot;@id&amp;quot;: &amp;quot;https://avelor.es/#organization&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;Avelor&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any other document that references &lt;code&gt;&amp;quot;@id&amp;quot;: &amp;quot;https://avelor.es/#organization&amp;quot;&lt;/code&gt; is now making a verifiable claim about the same node — not about a similarly-named entity.&lt;/p&gt;
&lt;h2&gt;The Three-Node Graph&lt;/h2&gt;
&lt;p&gt;A minimal site needs at minimum three nodes to establish a coherent entity graph:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph LR
  WS[&amp;quot;WebSite\n/#website&amp;quot;]
  ORG[&amp;quot;Organization\n/#organization&amp;quot;]
  WP1[&amp;quot;WebPage\n/pronunciacion#webpage&amp;quot;]
  WP2[&amp;quot;WebPage\n/manifesto#webpage&amp;quot;]

  WS --&amp;gt;|publisher| ORG
  WP1 --&amp;gt;|isPartOf| WS
  WP1 --&amp;gt;|about| ORG
  WP2 --&amp;gt;|isPartOf| WS
  WP2 --&amp;gt;|about| ORG
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;WebSite&lt;/code&gt; → &lt;code&gt;Organization&lt;/code&gt; via &lt;code&gt;publisher&lt;/code&gt; establishes that the domain is owned by the entity. &lt;code&gt;WebPage&lt;/code&gt; → &lt;code&gt;Organization&lt;/code&gt; via &lt;code&gt;about&lt;/code&gt; tells Google that each page is &lt;em&gt;about&lt;/em&gt; the entity, not merely hosted there. Without the &lt;code&gt;about&lt;/code&gt; relation, secondary pages are just pages — they don&amp;#39;t strengthen entity recognition.&lt;/p&gt;
&lt;p&gt;In practice, both the &lt;code&gt;WebSite&lt;/code&gt; and &lt;code&gt;Organization&lt;/code&gt; nodes live in a single JSON-LD array on the homepage:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[
  {
    &amp;quot;@context&amp;quot;: &amp;quot;https://schema.org&amp;quot;,
    &amp;quot;@type&amp;quot;: &amp;quot;WebSite&amp;quot;,
    &amp;quot;@id&amp;quot;: &amp;quot;https://avelor.es/#website&amp;quot;,
    &amp;quot;url&amp;quot;: &amp;quot;https://avelor.es&amp;quot;,
    &amp;quot;name&amp;quot;: &amp;quot;Avelor&amp;quot;,
    &amp;quot;publisher&amp;quot;: { &amp;quot;@id&amp;quot;: &amp;quot;https://avelor.es/#organization&amp;quot; }
  },
  {
    &amp;quot;@context&amp;quot;: &amp;quot;https://schema.org&amp;quot;,
    &amp;quot;@type&amp;quot;: &amp;quot;Organization&amp;quot;,
    &amp;quot;@id&amp;quot;: &amp;quot;https://avelor.es/#organization&amp;quot;,
    &amp;quot;name&amp;quot;: &amp;quot;Avelor&amp;quot;,
    &amp;quot;url&amp;quot;: &amp;quot;https://avelor.es&amp;quot;,
    &amp;quot;foundingDate&amp;quot;: &amp;quot;2018&amp;quot;,
    &amp;quot;logo&amp;quot;: {
      &amp;quot;@type&amp;quot;: &amp;quot;ImageObject&amp;quot;,
      &amp;quot;@id&amp;quot;: &amp;quot;https://avelor.es/#logo&amp;quot;,
      &amp;quot;url&amp;quot;: &amp;quot;https://avelor.es/og-image.png&amp;quot;,
      &amp;quot;width&amp;quot;: 1200,
      &amp;quot;height&amp;quot;: 630
    },
    &amp;quot;sameAs&amp;quot;: [
      &amp;quot;https://www.linkedin.com/company/avelor-es/&amp;quot;,
      &amp;quot;https://github.com/avelor-es&amp;quot;,
      &amp;quot;https://www.wikidata.org/wiki/Q139894905&amp;quot;,
      &amp;quot;https://wellfound.com/company/avelor&amp;quot;
    ]
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;sameAs as Cross-Domain Corroboration&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;sameAs&lt;/code&gt; is where external profiles become structural. Each entry is a claim that the listed URL represents the same real-world entity. Google cross-references these: if LinkedIn, GitHub, Wikidata, and Wellfound all describe something called &amp;quot;Avelor&amp;quot; with &lt;code&gt;avelor.es&lt;/code&gt; as the website, the entity is corroborated from four independent sources.&lt;/p&gt;
&lt;p&gt;The order of &lt;code&gt;sameAs&lt;/code&gt; entries doesn&amp;#39;t matter. Coverage does. Wikidata carries disproportionate weight because it is Google&amp;#39;s primary source for Knowledge Graph entities — a Wikidata entry with &lt;code&gt;P856&lt;/code&gt; (official website) pointing to the domain creates a bidirectional link that Google can verify.&lt;/p&gt;
&lt;h2&gt;rel=&amp;quot;me&amp;quot; as the HTML Layer&lt;/h2&gt;
&lt;p&gt;JSON-LD operates at the semantic layer. &lt;code&gt;rel=&amp;quot;me&amp;quot;&lt;/code&gt; operates at the HTML layer and is processed independently by Google (and IndieWeb-aware tools). Publishing both for the same set of profiles removes the dependency on Google&amp;#39;s JSON-LD parser being authoritative:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;https://github.com/avelor-es&amp;quot; /&amp;gt;
&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;https://www.linkedin.com/company/avelor-es/&amp;quot; /&amp;gt;
&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;https://www.wikidata.org/wiki/Q139894905&amp;quot; /&amp;gt;
&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;https://wellfound.com/company/avelor&amp;quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These live in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; on every page via the shared layout, so every URL on the domain carries the ownership signal — not just the homepage.&lt;/p&gt;
&lt;h2&gt;Choosing Which Pages to Index&lt;/h2&gt;
&lt;p&gt;The standard advice for minimal brand sites is to index only the homepage. The counterintuitive move is to index pages whose &lt;em&gt;content is about the brand name itself&lt;/em&gt; — even if they have no conventional SEO value.&lt;/p&gt;
&lt;p&gt;A pronunciation guide (&lt;code&gt;/pronunciacion&lt;/code&gt;) is a page whose entire subject is the word &amp;quot;Avelor&amp;quot;: its phonetics, its syllable stress, its distinctiveness. Google indexes this as a document whose topic is the brand name. A manifesto whose every paragraph invokes the same name does the same. Neither page targets keywords. Both pages reinforce entity identity.&lt;/p&gt;
&lt;p&gt;The constraint is that these pages must not collapse the brand into a market vertical. A &lt;code&gt;/services&lt;/code&gt; page indexed with keywords like &amp;quot;software consulting Mexico&amp;quot; creates topical associations that constrain how Google categorizes the entity. Pages about the name, the philosophy, the pronunciation — these are safe.&lt;/p&gt;
&lt;h2&gt;logo as ImageObject&lt;/h2&gt;
&lt;p&gt;Declaring &lt;code&gt;logo&lt;/code&gt; as a plain URL string works but leaves resolution ambiguous. As an &lt;code&gt;ImageObject&lt;/code&gt; with explicit dimensions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&amp;quot;logo&amp;quot;: {
  &amp;quot;@type&amp;quot;: &amp;quot;ImageObject&amp;quot;,
  &amp;quot;@id&amp;quot;: &amp;quot;https://avelor.es/#logo&amp;quot;,
  &amp;quot;url&amp;quot;: &amp;quot;https://avelor.es/og-image.png&amp;quot;,
  &amp;quot;width&amp;quot;: 1200,
  &amp;quot;height&amp;quot;: 630
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Google uses the logo in the Knowledge Panel. The &lt;code&gt;@id&lt;/code&gt; on the &lt;code&gt;ImageObject&lt;/code&gt; makes it referenceable from other entities if the domain ever expands to multiple sub-brands.&lt;/p&gt;
&lt;h2&gt;What This Doesn&amp;#39;t Solve&lt;/h2&gt;
&lt;p&gt;Entity graphs establish identity. They do not generate rankings for non-branded queries. A site with one indexed page and no backlinks will not appear for any search term that isn&amp;#39;t the brand name itself, regardless of how complete the structured data is.&lt;/p&gt;
&lt;p&gt;The graph is a precondition for Knowledge Panel appearance and branded search consolidation — nothing more. External corroboration (editorial backlinks, directory listings, Google Business Profile) is what converts a well-formed graph into visible search presence.&lt;/p&gt;
</content:encoded><category>architecture</category><category>seo</category><category>structured-data</category><category>json-ld</category><category>knowledge-graph</category><category>schema-org</category></item><item><title>RSS as a cross-site content bridge in Astro SSG</title><link>https://octa.page/doc/rss-as-cross-site-content-bridge/</link><guid isPermaLink="true">https://octa.page/doc/rss-as-cross-site-content-bridge/</guid><description>Using RSS as a stateless content contract between two independent Astro static sites, with GitHub Actions repository_dispatch to trigger rebuilds only when Markdown content changes — not on every commit.</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Two static sites. One is a portfolio (&lt;a href=&quot;https://christianecg.com&quot;&gt;christianecg.com&lt;/a&gt;), built with Astro and deployed to GitHub Pages. The other is this notebook (&lt;a href=&quot;https://octa.page&quot;&gt;octa.page&lt;/a&gt;), where active writing happens. The portfolio had a &lt;code&gt;/blog&lt;/code&gt; section — but all the articles in it were from 2021–2022. The writing had moved to octa.page and the two were completely siloed.&lt;/p&gt;
&lt;p&gt;The technical debt: a blog section showing stale content while the active equivalent existed elsewhere and neither site knew about the other.&lt;/p&gt;
&lt;h2&gt;Why RSS and not something else&lt;/h2&gt;
&lt;p&gt;The options were roughly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Shared CMS or database&lt;/strong&gt; — overkill for two static sites. Introduces infrastructure neither site needs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git submodule or monorepo&lt;/strong&gt; — couples the two repos, complicates deploys.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Direct fetch from the octa.page source files&lt;/strong&gt; — would require shared repo access and tight coupling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RSS&lt;/strong&gt; — octa.page already exposes &lt;code&gt;/rss.xml&lt;/code&gt;. It&amp;#39;s a stable contract. Stateless, versionless, requires nothing from the consumer.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;RSS wins by elimination. It&amp;#39;s also the right abstraction: octa.page doesn&amp;#39;t need to know christianecg.com exists. The feed is an implementation detail of octa&amp;#39;s public interface, consumed unilaterally.&lt;/p&gt;
&lt;h2&gt;Build-time consumption in Astro&lt;/h2&gt;
&lt;p&gt;Astro SSG runs &lt;code&gt;fetch&lt;/code&gt; at build time in the page&amp;#39;s frontmatter. No runtime, no hydration — the HTML is generated once with whatever the feed returns at that moment.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;let octaArticles: Article[] = [];
try {
  const res = await fetch(&amp;#39;https://octa.page/rss.xml&amp;#39;);
  const xml = await res.text();
  octaArticles = [...xml.matchAll(/&amp;lt;item&amp;gt;([\s\S]*?)&amp;lt;\/item&amp;gt;/g)].flatMap(([, item], i) =&amp;gt; {
    const title = item.match(/&amp;lt;title&amp;gt;(.*?)&amp;lt;\/title&amp;gt;/)?.[1]?.trim() ?? &amp;#39;&amp;#39;;
    const link  = item.match(/&amp;lt;link&amp;gt;(.*?)&amp;lt;\/link&amp;gt;/)?.[1]?.trim() ?? &amp;#39;&amp;#39;;
    const pub   = item.match(/&amp;lt;pubDate&amp;gt;(.*?)&amp;lt;\/pubDate&amp;gt;/)?.[1]?.trim() ?? &amp;#39;&amp;#39;;
    const tags  = [...item.matchAll(/&amp;lt;category&amp;gt;(.*?)&amp;lt;\/category&amp;gt;/g)].map(m =&amp;gt; m[1]);
    const date  = pub ? new Date(pub).toISOString().split(&amp;#39;T&amp;#39;)[0] : &amp;#39;&amp;#39;;
    if (!title || !date) return [];
    return [{ id: `octa-${i}`, title, date, excerpt: &amp;#39;&amp;#39;, tags, source: &amp;#39;Octa&amp;#39;, url: link, external: true, idx: 0 }];
  });
} catch {
  // RSS fetch failed — show only local articles
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few constraints drive the implementation:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No DOMParser in Node.js.&lt;/strong&gt; Browser APIs aren&amp;#39;t available in the build context. Regex over the raw XML string is the pragmatic choice for a feed this simple. A proper XML parser (&lt;code&gt;fast-xml-parser&lt;/code&gt; etc.) would be justified if the feed had CDATA sections or nested namespaces — this one doesn&amp;#39;t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Graceful degradation is non-negotiable.&lt;/strong&gt; A failed &lt;code&gt;fetch&lt;/code&gt; during build shouldn&amp;#39;t break the site. The &lt;code&gt;try/catch&lt;/code&gt; ensures the page builds with only local articles if the feed is unreachable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Merging and sorting happen in-process.&lt;/strong&gt; Local articles and RSS articles are normalized to the same &lt;code&gt;Article&lt;/code&gt; type, merged into a single array, and sorted by date. The &lt;code&gt;external&lt;/code&gt; flag drives rendering decisions (badge, &lt;code&gt;target=&amp;quot;_blank&amp;quot;&lt;/code&gt;, &lt;code&gt;↗&lt;/code&gt; vs &lt;code&gt;→&lt;/code&gt;).&lt;/p&gt;
&lt;h2&gt;The freshness problem&lt;/h2&gt;
&lt;p&gt;Build-time consumption means the portfolio goes stale as soon as octa.page publishes something new. The obvious mitigation — a scheduled nightly rebuild — has the wrong model: it rebuilds christianecg.com on a fixed interval regardless of whether octa.page changed.&lt;/p&gt;
&lt;p&gt;The correct trigger is octa.page&amp;#39;s own deploy completing.&lt;/p&gt;
&lt;h2&gt;Cross-repo dispatch&lt;/h2&gt;
&lt;p&gt;GitHub Actions supports &lt;code&gt;repository_dispatch&lt;/code&gt;: an HTTP event that triggers a workflow in another repo. The pattern:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;octa.page&lt;/strong&gt; dispatches after deploy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;trigger-christianecg:
  needs: [deploy, check-content]
  if: needs.check-content.outputs.md_changed == &amp;#39;true&amp;#39;
  runs-on: ubuntu-latest
  steps:
    - name: Trigger christianecg.com rebuild
      run: |
        curl -s -X POST \
          -H &amp;quot;Authorization: token ${{ secrets.CHRISTIANECG_DEPLOY_TOKEN }}&amp;quot; \
          -H &amp;quot;Accept: application/vnd.github.v3+json&amp;quot; \
          https://api.github.com/repos/ChristianECG/christianecg.com/dispatches \
          -d &amp;#39;{&amp;quot;event_type&amp;quot;:&amp;quot;octa-deployed&amp;quot;}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;christianecg.com&lt;/strong&gt; listens for the event:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;on:
  push:
    branches: [main]
  workflow_dispatch:
  repository_dispatch:
    types: [octa-deployed]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;if: needs.check-content.outputs.md_changed == &amp;#39;true&amp;#39;&lt;/code&gt; condition is the key constraint. A style change, config update, or tooling commit on octa.page still triggers a full build and deploy of octa — but doesn&amp;#39;t cascade to christianecg.com. The dispatch only fires when &lt;code&gt;.md&lt;/code&gt; files are in the diff.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;check-content&lt;/code&gt; job detects this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;check-content:
  runs-on: ubuntu-latest
  outputs:
    md_changed: ${{ steps.check.outputs.md_changed }}
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 2
    - id: check
      run: |
        if git diff --name-only HEAD~1 HEAD 2&amp;gt;/dev/null | grep -q &amp;#39;\.md$&amp;#39;; then
          echo &amp;quot;md_changed=true&amp;quot; &amp;gt;&amp;gt; $GITHUB_OUTPUT
        else
          echo &amp;quot;md_changed=false&amp;quot; &amp;gt;&amp;gt; $GITHUB_OUTPUT
        fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fetch-depth: 2&lt;/code&gt; gives access to &lt;code&gt;HEAD~1&lt;/code&gt;. Without it, a shallow clone has no parent to diff against.&lt;/p&gt;
&lt;h2&gt;Trade-offs&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Freshness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Near-zero delay when dispatch works. Falls back to manual &lt;code&gt;workflow_dispatch&lt;/code&gt; if the PAT expires or the hook fails.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Coupling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One-directional. octa.page doesn&amp;#39;t know christianecg.com exists. christianecg.com depends on the RSS contract, which is stable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Failure modes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RSS unreachable at build time → local-only blog, no build failure. Dispatch token expired → stale portfolio until manual rebuild.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Complexity added&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One PAT, one secret, ~20 lines of YAML across two repos.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The RSS contract is the right boundary. It decouples publication from consumption, keeps both sites independently deployable, and adds no shared infrastructure.&lt;/p&gt;
</content:encoded><category>architecture</category><category>rss</category><category>astro</category><category>static-site</category><category>github-actions</category><category>ci-cd</category></item><item><title>av.js — self-hosted analytics embed</title><link>https://octa.page/doc/av-js-analytics-embed/</link><guid isPermaLink="true">https://octa.page/doc/av-js-analytics-embed/</guid><description>API reference for av.js: the minimal analytics snippet for Avelor-hosted projects. Covers the embed pattern, the pageview payload structure, custom event API, session ID strategy, and implementation constraints like keepalive fetches and localhost filtering.</description><pubDate>Sat, 23 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;av.js&lt;/code&gt; is a minimal analytics snippet that reports pageviews and custom events to &lt;code&gt;analytics.avelor.es/collect&lt;/code&gt;. Drop a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag, set &lt;code&gt;data-project&lt;/code&gt;, done.&lt;/p&gt;
&lt;h2&gt;Embed&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;script src=&amp;quot;https://analytics.avelor.es/av.js&amp;quot; data-project=&amp;quot;project-slug&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;data-project&lt;/code&gt; is required — without it the script exits immediately. Requests from &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, and &lt;code&gt;::1&lt;/code&gt; are also silently skipped.&lt;/p&gt;
&lt;h2&gt;What it sends on load&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;POST /collect&lt;/code&gt; with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;project_id&amp;quot;: &amp;quot;project-slug&amp;quot;,
  &amp;quot;url&amp;quot;: &amp;quot;/path?query&amp;quot;,
  &amp;quot;referrer&amp;quot;: &amp;quot;https://example.com&amp;quot;,
  &amp;quot;session_id&amp;quot;: &amp;quot;lxk3r2abc9d&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;session_id&lt;/code&gt; is generated once per tab and stored in &lt;code&gt;sessionStorage&lt;/code&gt; under &lt;code&gt;_av_sid&lt;/code&gt;. It survives navigation within the tab but not across tabs or sessions.&lt;/p&gt;
&lt;h2&gt;Custom events&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;window.avelor.track(&amp;#39;event-name&amp;#39;, { key: &amp;#39;value&amp;#39; });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Event name is truncated to 100 chars. &lt;code&gt;properties&lt;/code&gt; is a free-form object or &lt;code&gt;null&lt;/code&gt;. Full payload:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;type&amp;quot;: &amp;quot;event&amp;quot;,
  &amp;quot;project_id&amp;quot;: &amp;quot;project-slug&amp;quot;,
  &amp;quot;session_id&amp;quot;: &amp;quot;lxk3r2abc9d&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;click-cta&amp;quot;,
  &amp;quot;url&amp;quot;: &amp;quot;/&amp;quot;,
  &amp;quot;properties&amp;quot;: { &amp;quot;variant&amp;quot;: &amp;quot;A&amp;quot; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Implementation notes&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;keepalive: true&lt;/code&gt; on the fetch call — the request survives page navigation, so events sent just before a redirect are not lost.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;credentials: &amp;#39;omit&amp;#39;&lt;/code&gt; — no cookies or auth headers sent to the collector.&lt;/li&gt;
&lt;li&gt;Network errors are swallowed — a dead collector does not break the host page.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>notes</category><category>analytics</category><category>javascript</category><category>self-hosted</category></item><item><title>Using the URL fragment as an encryption key carrier</title><link>https://octa.page/doc/url-fragment-as-encryption-key/</link><guid isPermaLink="true">https://octa.page/doc/url-fragment-as-encryption-key/</guid><description>Client-side AES-256-GCM encryption where the key lives exclusively in the URL fragment — never transmitted to the server. The design, the constraints around browser history and intermediaries, and why out-of-band key delivery defeats the UX goal.</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The URL fragment (&lt;code&gt;#everything-after-the-hash&lt;/code&gt;) is never sent to the server. Browsers strip it before making the HTTP request. This makes it a natural channel for passing a secret that the server must never see — specifically, an encryption key for a one-time secret.&lt;/p&gt;
&lt;h2&gt;The design&lt;/h2&gt;
&lt;p&gt;When a secret is created:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The browser generates a random AES-256-GCM key via &lt;code&gt;crypto.subtle.generateKey&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It encrypts the plaintext locally. The encrypted blob (IV + ciphertext) is sent to the server as the &lt;code&gt;content&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;The server stores only the ciphertext. It has no key and cannot decrypt.&lt;/li&gt;
&lt;li&gt;The share URL is constructed as &lt;code&gt;https://vault.example.com/s/{token}#{key_b64u}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function encryptContent(plaintext) {
  const key    = await crypto.subtle.generateKey({ name: &amp;#39;AES-GCM&amp;#39;, length: 256 }, true, [&amp;#39;encrypt&amp;#39;]);
  const iv     = crypto.getRandomValues(new Uint8Array(12));
  const cipher = await crypto.subtle.encrypt({ name: &amp;#39;AES-GCM&amp;#39;, iv }, key, new TextEncoder().encode(plaintext));
  const rawKey = new Uint8Array(await crypto.subtle.exportKey(&amp;#39;raw&amp;#39;, key));

  const combined = new Uint8Array(iv.byteLength + cipher.byteLength);
  combined.set(iv);
  combined.set(new Uint8Array(cipher), iv.byteLength);

  return {
    content: toBase64url(combined),  // sent to server
    keyB64:  toBase64url(rawKey),    // goes in the URL fragment
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the recipient opens the link:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The browser reads &lt;code&gt;location.hash.slice(1)&lt;/code&gt; — the key, which was never sent to the server.&lt;/li&gt;
&lt;li&gt;It POSTs to the token URL. The server marks the secret as viewed (&lt;code&gt;viewed_at = NOW()&lt;/code&gt;) and returns the ciphertext.&lt;/li&gt;
&lt;li&gt;The browser decrypts locally.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const keyB64   = location.hash.slice(1);
const rawKey   = base64urlToBytes(keyB64).buffer;
const key      = await crypto.subtle.importKey(&amp;#39;raw&amp;#39;, rawKey, { name: &amp;#39;AES-GCM&amp;#39;, length: 256 }, false, [&amp;#39;decrypt&amp;#39;]);

const res      = await fetch(location.pathname, { method: &amp;#39;POST&amp;#39; });
const data     = await res.json();  // { ok: true, content: &amp;quot;&amp;lt;base64url&amp;gt;&amp;quot; }

const combined = base64urlToBytes(data.content);
const iv       = combined.slice(0, 12);
const cipher   = combined.slice(12);
const plain    = await crypto.subtle.decrypt({ name: &amp;#39;AES-GCM&amp;#39;, iv }, key, cipher);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What the server knows&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Stored&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Token&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Random 32-byte hex&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ciphertext&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;AES-GCM output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Never transmitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plaintext&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Never transmitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;viewed_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Timestamp of first reveal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A server compromise exposes token metadata and ciphertext. Without the key — which only exists in the URL fragment and the recipient&amp;#39;s clipboard — the ciphertext is opaque.&lt;/p&gt;
&lt;h2&gt;Constraints&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The fragment is in browser history.&lt;/strong&gt; If the recipient&amp;#39;s browser syncs history, the key travels with it. For very high-sensitivity secrets, recipients should open the link in a private window and clear it afterward.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One-time enforcement is server-side only.&lt;/strong&gt; The burn semantics (set &lt;code&gt;viewed_at&lt;/code&gt;, return 410 on subsequent requests) happen on the server. A network-level attacker who can observe and replay the POST before the server processes it could retrieve the ciphertext twice. Without the fragment key, ciphertext copies are equivalent — but it means &amp;quot;one-time&amp;quot; is a server policy, not a cryptographic guarantee.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The key is in the URL.&lt;/strong&gt; Any proxy, CDN, or browser extension that logs full URLs will capture the key. The fragment exclusion is a browser-to-server guarantee — intermediaries that inspect the page before the browser processes it are outside the model.&lt;/p&gt;
&lt;h2&gt;Why not a separate key delivery channel?&lt;/h2&gt;
&lt;p&gt;Delivering the key out-of-band (email, SMS) and the ciphertext via a link would be more secure — but it defeats the purpose of a self-contained share link. The fragment approach makes the UX of &amp;quot;share one link&amp;quot; possible while keeping the server out of the trust chain for anything that happens before the browser sends the request.&lt;/p&gt;
</content:encoded><category>architecture</category><category>cryptography</category><category>web-crypto</category><category>security</category></item><item><title>A WebSocket relay as a self-hosted tunnel</title><link>https://octa.page/doc/websocket-tunnel-relay/</link><guid isPermaLink="true">https://octa.page/doc/websocket-tunnel-relay/</guid><description>A single Node.js process that exposes localhost over public HTTPS using a persistent outbound WebSocket from the CLI — token auth in the handshake, request queuing with UUID correlation, 30s timeout, and no inbound ports on the developer&apos;s machine.</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A tunnel that exposes &lt;code&gt;localhost:3000&lt;/code&gt; at a public HTTPS URL doesn&amp;#39;t need SSH. A WebSocket relay achieves the same result with nothing but an outbound connection from the developer&amp;#39;s machine, a small Node.js process on the server, and a shared secret for auth. No keys to distribute, no port forwarding rules, no firewall exceptions.&lt;/p&gt;
&lt;h2&gt;The problem with SSH reverse tunnels in public CLIs&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ssh -R 9000:localhost:3000 user@server -N&lt;/code&gt; works, but it requires the user to have an SSH key authorized on the server. That&amp;#39;s fine for a private tool. The moment the CLI becomes public, every user needs their own SSH key provisioned — which means a registration flow, key management, and a security surface on the server&amp;#39;s &lt;code&gt;authorized_keys&lt;/code&gt;. Token-based auth is simpler and easier to revoke.&lt;/p&gt;
&lt;h2&gt;Architecture&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;sequenceDiagram
    participant Browser as Browser / webhook sender
    participant Apache as Apache (tunnel.avelor.es)
    participant Relay as relay.js (127.0.0.1:9001)
    participant CLI as avelor CLI
    participant Local as localhost:3000

    CLI-&amp;gt;&amp;gt;Relay: WebSocket connect (Bearer token)
    Browser-&amp;gt;&amp;gt;Apache: HTTP request
    Apache-&amp;gt;&amp;gt;Relay: HTTP proxy
    Relay-&amp;gt;&amp;gt;CLI: serialize request over WS
    CLI-&amp;gt;&amp;gt;Local: forward HTTP
    Local--&amp;gt;&amp;gt;CLI: response
    CLI--&amp;gt;&amp;gt;Relay: serialize response over WS
    Relay--&amp;gt;&amp;gt;Apache: write response
    Apache--&amp;gt;&amp;gt;Browser: response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The relay process listens only on &lt;code&gt;127.0.0.1&lt;/code&gt;. Apache handles TLS and proxies both HTTP traffic and the WebSocket upgrade to the relay. The CLI initiates the only outbound connection — no inbound ports are opened on the developer&amp;#39;s machine.&lt;/p&gt;
&lt;h2&gt;Request lifecycle&lt;/h2&gt;
&lt;p&gt;Every incoming HTTP request becomes a message on the WebSocket channel. The relay assigns a &lt;code&gt;uuid&lt;/code&gt; to the request and stores a reference to the &lt;code&gt;res&lt;/code&gt; object in a &lt;code&gt;Map&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const id = randomUUID();
queue.set(id, { res, timer });
peer.send(JSON.stringify({ id, method, url, headers, body }));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The body is base64-encoded to survive JSON serialization cleanly across binary payloads (file uploads, compressed responses, etc.).&lt;/p&gt;
&lt;p&gt;The CLI receives the message, forwards it to the local server as a plain HTTP request, and sends back:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;{ id, status, headers, body }  // body is base64
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The relay looks up the &lt;code&gt;id&lt;/code&gt; in the queue, writes the response headers and body, and resolves the original HTTP request. From the browser&amp;#39;s perspective it was a normal round-trip.&lt;/p&gt;
&lt;p&gt;If the CLI doesn&amp;#39;t respond within 30 seconds, the relay times out with a 504 and removes the pending entry. If the WebSocket disconnects mid-flight, all pending requests are resolved with 502.&lt;/p&gt;
&lt;h2&gt;Apache configuration&lt;/h2&gt;
&lt;p&gt;Two proxy rules are needed: one for the WebSocket handshake, one for regular HTTP traffic.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apache&quot;&gt;# WebSocket upgrade — must come before the catch-all ProxyPass
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/_tunnel$ ws://127.0.0.1:9001/_tunnel [P,L]

ProxyPass        / http://127.0.0.1:9001/
ProxyPassReverse / http://127.0.0.1:9001/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mod_proxy_wstunnel&lt;/code&gt; handles the WebSocket leg. The rewrite rule targets only &lt;code&gt;/_tunnel&lt;/code&gt; so that all other paths fall through to the HTTP proxy and get relayed as tunneled requests.&lt;/p&gt;
&lt;h2&gt;Auth&lt;/h2&gt;
&lt;p&gt;The CLI sends &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; in the WebSocket handshake headers. The relay checks it before accepting the connection:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;wss.on(&amp;#39;connection&amp;#39;, (ws, req) =&amp;gt; {
  const auth = (req.headers[&amp;#39;authorization&amp;#39;] || &amp;#39;&amp;#39;).trim();
  if (auth !== `Bearer ${TOKEN}`) {
    ws.close(1008, &amp;#39;unauthorized&amp;#39;);
    return;
  }
  // ...
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The token is transmitted only over TLS (Apache enforces HTTPS), and it lives in the CLI&amp;#39;s local config file — never in the URL or logs. Since this is a single-user tool, there&amp;#39;s also a hard limit of one active connection at a time: a second &lt;code&gt;connect&lt;/code&gt; event closes the new socket immediately.&lt;/p&gt;
&lt;h2&gt;The &lt;code&gt;--run&lt;/code&gt; flag&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;avelor tunnel 3000 --run &amp;quot;npm run dev&amp;quot;&lt;/code&gt; spawns the child process first, then polls &lt;code&gt;127.0.0.1:3000&lt;/code&gt; with HEAD requests at 500ms intervals until the port accepts connections. Only then does it open the WebSocket. This avoids the race condition where the tunnel is active before the dev server is ready.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;function waitForPort(port, timeout = 30_000) {
  return new Promise((resolve, reject) =&amp;gt; {
    const deadline = Date.now() + timeout;
    function attempt() {
      const req = http.request({ hostname: &amp;#39;127.0.0.1&amp;#39;, port, method: &amp;#39;HEAD&amp;#39;, path: &amp;#39;/&amp;#39; }, () =&amp;gt; resolve());
      req.on(&amp;#39;error&amp;#39;, () =&amp;gt; {
        if (Date.now() &amp;gt;= deadline) return reject(new Error(`port ${port} not ready`));
        setTimeout(attempt, 500);
      });
      req.end();
    }
    attempt();
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On &lt;code&gt;SIGINT&lt;/code&gt;, the CLI closes the WebSocket and kills the child process.&lt;/p&gt;
&lt;h2&gt;Trade-offs&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Single connection&lt;/strong&gt; is a deliberate constraint. The relay was designed for testing webhooks locally, not for serving production traffic. One active tunnel keeps the relay stateless and the auth model trivial.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Full buffering&lt;/strong&gt; — requests and responses are fully buffered before forwarding. This means no streaming support and an implicit limit on payload size (whatever fits in Node&amp;#39;s default buffer). For the webhook testing use case this is fine; for anything involving large file uploads it would need chunked forwarding over multiple messages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No subdomain isolation&lt;/strong&gt; — there is one fixed public URL. This is intentional: a wildcard subdomain would require a wildcard TLS certificate and dynamic nginx/Apache config. The simpler model is one URL, one user, one token.&lt;/p&gt;
</content:encoded><category>architecture</category><category>websocket</category><category>networking</category><category>reverse-proxy</category></item><item><title>tailscale serve sets REMOTE_ADDR to 127.0.0.1 for every request</title><link>https://octa.page/doc/tailscale-serve-remote-addr/</link><guid isPermaLink="true">https://octa.page/doc/tailscale-serve-remote-addr/</guid><description>tailscale serve is a reverse proxy, not a network filter — it collapses all caller identities to 127.0.0.1, silently bypassing any REMOTE_ADDR-based auth guard. The only reliable identity signal is the Tailscale-User-Login header it injects.</description><pubDate>Thu, 21 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;tailscale serve&lt;/code&gt; terminates HTTPS at the Tailscale layer and forwards plain HTTP to a local backend. From the application&amp;#39;s perspective, every request originates at &lt;code&gt;127.0.0.1&lt;/code&gt; — regardless of which Tailscale node made the request. This collapses the identity of all callers to the loopback address.&lt;/p&gt;
&lt;h2&gt;Why it matters&lt;/h2&gt;
&lt;p&gt;The typical pattern for Tailscale-gated PHP apps is to check &lt;code&gt;REMOTE_ADDR&lt;/code&gt; against the Tailscale CGNAT range (&lt;code&gt;100.64.0.0/10&lt;/code&gt;) or to trust the &lt;code&gt;Tailscale-User-Login&lt;/code&gt; header injected by &lt;code&gt;tailscale serve&lt;/code&gt;. These two signals are mutually exclusive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you check &lt;code&gt;REMOTE_ADDR&lt;/code&gt; for Tailscale IPs → you get the real node IP, but &lt;code&gt;tailscale serve&lt;/code&gt; is not in the picture (you&amp;#39;re listening directly on the Tailscale interface or binding to the node&amp;#39;s IP).&lt;/li&gt;
&lt;li&gt;If you rely on &lt;code&gt;Tailscale-User-Login&lt;/code&gt; → the proxy is in the picture, and &lt;code&gt;REMOTE_ADDR&lt;/code&gt; is &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A guard like this silently bypasses all auth when &lt;code&gt;tailscale serve&lt;/code&gt; is the deployment method:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$isLocal = in_array($_SERVER[&amp;#39;REMOTE_ADDR&amp;#39;], [&amp;#39;127.0.0.1&amp;#39;, &amp;#39;::1&amp;#39;], true);
if ($isLocal) return; // exits before checking Tailscale-User-Login
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Tailscale-User-Login&lt;/code&gt; branch is never reached. Every request is treated as a trusted local caller.&lt;/p&gt;
&lt;h2&gt;The fix&lt;/h2&gt;
&lt;p&gt;Drop the &lt;code&gt;REMOTE_ADDR&lt;/code&gt; shortcut entirely. When &lt;code&gt;tailscale serve&lt;/code&gt; is the proxy, the only reliable identity signal is the injected header:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;function requireTailscaleAuth(): void
{
    $login = $_SERVER[&amp;#39;HTTP_TAILSCALE_USER_LOGIN&amp;#39;] ?? &amp;#39;&amp;#39;;
    if ($login !== &amp;#39;allowed@example.com&amp;#39;) {
        http_response_code(403);
        header(&amp;#39;Content-Type: text/plain&amp;#39;);
        echo &amp;#39;Forbidden&amp;#39;;
        exit;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;isLocal&lt;/code&gt; escape hatch is often added for local development. It&amp;#39;s unnecessary: dev environments using &lt;code&gt;php -S&lt;/code&gt; route through a custom router that doesn&amp;#39;t call the auth function, so removing the bypass doesn&amp;#39;t break anything.&lt;/p&gt;
&lt;h2&gt;The underlying mechanic&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;tailscale serve&lt;/code&gt; is a reverse proxy, not a network-layer filter. It listens on the Tailscale interface, handles TLS, injects identity headers, and makes a new outbound connection to &lt;code&gt;localhost:PORT&lt;/code&gt;. The application sees a fresh TCP connection from the loopback. There is no X-Forwarded-For or equivalent by default — the node IP is only available in the injected &lt;code&gt;Tailscale-User-*&lt;/code&gt; headers.&lt;/p&gt;
&lt;p&gt;This is architecturally correct behavior for a proxy, but it breaks assumptions borrowed from traditional firewall-based access control where IP address implies identity.&lt;/p&gt;
</content:encoded><category>runtime</category><category>tailscale</category><category>php</category><category>security</category><category>networking</category></item><item><title>Building gob.co.nz: A Static Corrections Register</title><link>https://octa.page/doc/building-gob-co-nz/</link><guid isPermaLink="true">https://octa.page/doc/building-gob-co-nz/</guid><description>Architecture notes for gob.co.nz, a static public corrections register for the tech industry — stack decisions, Astro 6 Content Layer footguns, build-time OG image generation with sharp, and Pagefind client-side search integration.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;gob.co.nz is a public corrections register for the tech industry — a &lt;em&gt;fe de erratas&lt;/em&gt; that documents revised positions, failed predictions, unfulfilled promises, and dissolved consensus. Each entry is a citable URL someone sends in a conversation. The aesthetic is deliberately institutional: a government gazette that happens to be on the web.&lt;/p&gt;
&lt;h2&gt;Stack decisions&lt;/h2&gt;
&lt;p&gt;The constraints were clear from the start: zero client-side JS unless strictly necessary, content portable as plain Markdown, fast enough to not think about performance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Astro 6&lt;/strong&gt; for static generation. Same reasoning as Octa — pure HTML by default, islands opt-in. The difference here is that Pagefind is included from day one (deferred on Octa). At content volume, client-side search is more useful than a filter bar alone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tailwind CSS v4&lt;/strong&gt; via &lt;code&gt;@tailwindcss/vite&lt;/code&gt; directly in &lt;code&gt;astro.config.mjs&lt;/code&gt;. The old &lt;code&gt;@astrojs/tailwind&lt;/code&gt; integration is incompatible with Tailwind v4 — it targets the v3 PostCSS plugin. The correct setup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// astro.config.mjs
import tailwindcss from &amp;#39;@tailwindcss/vite&amp;#39;;

export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;@astrojs/tailwind&lt;/code&gt; in integrations. CSS theme tokens live in &lt;code&gt;@theme {}&lt;/code&gt; blocks inside &lt;code&gt;global.css&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iA Writer Quattro&lt;/strong&gt; via &lt;code&gt;@fontsource&lt;/code&gt;. Monospace with document aesthetics — fits the bureaucratic design brief without adding a separate font-loading strategy.&lt;/p&gt;
&lt;h2&gt;Content model&lt;/h2&gt;
&lt;p&gt;Four entry types, each a different failure mode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;posicion-revisada    → industry used to say X, now says Y
promesa-incumplida   → someone said X would happen, it didn&amp;#39;t
prediccion-fallida   → X was forecasted, the opposite occurred
consenso-disuelto    → everyone agreed on X, no longer do
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each entry has structured fields (&lt;code&gt;declaration&lt;/code&gt;, &lt;code&gt;origin&lt;/code&gt;, &lt;code&gt;origin_year&lt;/code&gt;, &lt;code&gt;correction&lt;/code&gt;, &lt;code&gt;cause&lt;/code&gt;, &lt;code&gt;status_since&lt;/code&gt;) plus optional body markdown. The constraint: if &lt;code&gt;correction&lt;/code&gt; can&amp;#39;t reflect documented consensus — not an opinion — the entry isn&amp;#39;t ready to publish.&lt;/p&gt;
&lt;h2&gt;Astro 6 Content Layer API footguns&lt;/h2&gt;
&lt;p&gt;Three breaking changes from Astro 4/5 that aren&amp;#39;t obvious from the migration docs:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Config location.&lt;/strong&gt; Content collection config must be at &lt;code&gt;src/content.config.ts&lt;/code&gt;, not &lt;code&gt;src/content/config.ts&lt;/code&gt;. The old path silently fails.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Glob loader required.&lt;/strong&gt; Collections no longer auto-discover content. Every collection needs an explicit loader:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { glob } from &amp;#39;astro/loaders&amp;#39;;

const erratas = defineCollection({
  loader: glob({ pattern: &amp;#39;**/*.md&amp;#39;, base: &amp;#39;./src/content/erratas&amp;#39; }),
  schema: z.object({ ... }),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;render()&lt;/code&gt; is a module import, not an instance method.&lt;/strong&gt; In Astro 4 you&amp;#39;d call &lt;code&gt;entry.render()&lt;/code&gt;. In Astro 6:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { render } from &amp;#39;astro:content&amp;#39;;

const { Content } = await render(entry);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Calling &lt;code&gt;entry.render()&lt;/code&gt; throws at runtime with no useful error message.&lt;/p&gt;
&lt;h2&gt;OG image generation&lt;/h2&gt;
&lt;p&gt;Per-entry OG images are generated at build time using &lt;code&gt;sharp&lt;/code&gt; to convert SVG to PNG. The SVG is composed in TypeScript — layout, type badge with color, title wrapping, subtitle.&lt;/p&gt;
&lt;p&gt;Two issues that took iteration to fix:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Font rendering in SVG.&lt;/strong&gt; The original used iA Writer Quattro for the OG image — predictably, &lt;code&gt;librsvg&lt;/code&gt; (what &lt;code&gt;sharp&lt;/code&gt; uses internally) doesn&amp;#39;t have that font. The SVG renders with a fallback that breaks the layout. Fix: use &lt;code&gt;Arial, Helvetica, sans-serif&lt;/code&gt; in the SVG. Predictable across environments.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Title overflow.&lt;/strong&gt; &lt;code&gt;wrapText()&lt;/code&gt; was splitting at 52 characters assuming 52px font. At 52px on a 1040px canvas, the actual character limit is ~26 for proportional fonts. The fix is to split conservatively (maxChars=26) and cap at 2 lines, truncating with &lt;code&gt;…&lt;/code&gt; if needed.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;text-transform: uppercase&lt;/code&gt; doesn&amp;#39;t work in SVG attributes — apply &lt;code&gt;.toUpperCase()&lt;/code&gt; in JS before inserting the text node.&lt;/p&gt;
&lt;h2&gt;Pagefind&lt;/h2&gt;
&lt;p&gt;Pagefind runs post-build as a separate command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&amp;quot;build&amp;quot;: &amp;quot;astro build &amp;amp;&amp;amp; pagefind --site dist&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It requires a &lt;code&gt;data-pagefind-body&lt;/code&gt; attribute on the content container. Without it, Pagefind indexes all pages including nav and footer text. With it, pages without the attribute are excluded — which means the index is scoped to entry bodies only.&lt;/p&gt;
&lt;p&gt;Not available in dev. The tradeoff is acceptable: search is a production concern.&lt;/p&gt;
&lt;h2&gt;git hygiene&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;.astro/&lt;/code&gt; directory (generated files: content modules, data store, type declarations) was committed by mistake in the initial setup — the bootstrapped &lt;code&gt;.gitignore&lt;/code&gt; only excluded &lt;code&gt;node_modules/&lt;/code&gt; and &lt;code&gt;dist/&lt;/code&gt;. The fix:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git rm -r --cached .astro/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then add &lt;code&gt;.astro/&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;. This removes the directory from the index without deleting local files, so Astro&amp;#39;s dev server continues working.&lt;/p&gt;
&lt;h2&gt;Design system&lt;/h2&gt;
&lt;p&gt;The design brief was &amp;quot;government gazette&amp;quot; — white background, dense information, no decoration. Three decisions that define the aesthetic:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Color only for type classification.&lt;/strong&gt; Each of the four entry types has one muted color used exclusively for its badge. No other color in the UI except structural rules.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dark mode via CSS custom properties.&lt;/strong&gt; All colors are &lt;code&gt;var(--color-*)&lt;/code&gt; tokens. The &lt;code&gt;@media (prefers-color-scheme: dark)&lt;/code&gt; block overrides the tokens — no class toggling, no JS. The dark palette is soft: &lt;code&gt;#1e1e1b&lt;/code&gt; background, not pure black.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Band colors decoupled from paper.&lt;/strong&gt; The header masthead is a dark band (&lt;code&gt;--color-band-bg&lt;/code&gt;). In dark mode, if the band uses the paper color as text and the paper color inverts, the contrast breaks. The fix: dedicated &lt;code&gt;--color-band-text&lt;/code&gt; and &lt;code&gt;--color-band-muted&lt;/code&gt; tokens that are independent of &lt;code&gt;--color-paper&lt;/code&gt;, so the band stays legible regardless of mode.&lt;/p&gt;
</content:encoded><category>architecture</category><category>astro</category><category>static-site</category><category>content-architecture</category><category>tailwind</category><category>pagefind</category></item><item><title>Building Octa: Architecture Decisions for a Static Engineering Notebook</title><link>https://octa.page/doc/building-octa/</link><guid isPermaLink="true">https://octa.page/doc/building-octa/</guid><description>Design decisions behind Octa: why Astro over Next.js, how the type/project dual-classification model works, the sidebar architecture, Mermaid diagram rendering without a build-time headless browser, and what was deliberately left out.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Octa is not a blog. It&amp;#39;s a structured notebook for engineering thinking — closer to an internal memo system than a publishing platform. This document records the decisions made during its initial construction.&lt;/p&gt;
&lt;h2&gt;What it needs to do&lt;/h2&gt;
&lt;p&gt;The core requirement is &lt;em&gt;durable technical writing&lt;/em&gt; that stays readable without maintenance. That means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No database. No CMS. No API dependencies.&lt;/li&gt;
&lt;li&gt;Content portable as plain Markdown.&lt;/li&gt;
&lt;li&gt;Fast enough to not think about performance.&lt;/li&gt;
&lt;li&gt;Simple enough to not break over time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The anti-requirements were equally clear: no SEO optimization, no engagement mechanics, no social features.&lt;/p&gt;
&lt;h2&gt;Framework: Astro 6&lt;/h2&gt;
&lt;p&gt;Static generation with minimal client-side JavaScript was the primary constraint. Astro satisfies this better than the alternatives:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Next.js (static export)&lt;/strong&gt; ships a React runtime by default. Even a static export loads the hydration layer. For a content site that needs exactly one interactive feature (Mermaid diagrams), that&amp;#39;s excess weight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Astro&lt;/strong&gt; generates pure HTML by default. JS islands are opt-in per component. The entire site — sidebar, navigation, article layout — runs zero client JS except the Mermaid renderer on article pages.&lt;/p&gt;
&lt;p&gt;The tradeoff: Astro&amp;#39;s Content Layer API (v5+) has a strict isolation requirement for &lt;code&gt;getStaticPaths&lt;/code&gt;. Any module-level constant referenced inside the function must be re-declared inline, because the function runs in a separate build scope. This is a footgun worth documenting.&lt;/p&gt;
&lt;h2&gt;Content model&lt;/h2&gt;
&lt;p&gt;Six content types map to six Astro collections:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/content/architecture   → system design, tradeoffs
/content/runtime        → browser behavior, scheduling
/content/pulse          → the Pulse observability project
/content/systems        → long-form reasoning
/content/notes          → short-form technical notes
/content/investigations → (legacy, not exposed in navigation)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All share a single frontmatter schema:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;title: string
date: date
tags: string[]
project: string?      # cross-cutting — links to /project/:name
series: string?       # groups sequential posts
pinned: boolean       # surfaces in sidebar &amp;quot;Pinned&amp;quot; count
status: published | draft
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;project&lt;/code&gt; field is the key architectural choice. Types categorize &lt;em&gt;what kind of content&lt;/em&gt; something is. Projects categorize &lt;em&gt;what it&amp;#39;s about&lt;/em&gt;. An architecture note about Pulse&amp;#39;s ingestion pipeline has &lt;code&gt;type: architecture&lt;/code&gt; and &lt;code&gt;project: pulse&lt;/code&gt;. Both lenses are available from the sidebar without duplication.&lt;/p&gt;
&lt;h2&gt;URL structure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;/doc/:slug      → individual articles
/type/:type     → type index pages
/project/:name  → project index pages
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flat &lt;code&gt;/doc/:slug&lt;/code&gt; means slugs must be unique across all content directories. This is a mild constraint in practice — filenames that describe the content are naturally unique. The benefit is clean URLs without implementation leaking into them. A document about scheduler behavior doesn&amp;#39;t need &lt;code&gt;/runtime/&lt;/code&gt; in its URL.&lt;/p&gt;
&lt;h2&gt;Sidebar design&lt;/h2&gt;
&lt;p&gt;The sidebar has three groups in a single scroll:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Index&lt;/strong&gt; — aggregate views (all, this month, pinned, drafts)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Types&lt;/strong&gt; — the five active content types&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Projects&lt;/strong&gt; — derived at build time from &lt;code&gt;project&lt;/code&gt; frontmatter values across all collections&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Projects group is dynamic: it only renders if at least one published document has a &lt;code&gt;project&lt;/code&gt; field. No configuration required to add a new project — write a document with the field and it appears.&lt;/p&gt;
&lt;p&gt;Originally the sidebar had a tab switcher between Sections and Projects. This was removed. Tabs imply that only one view is useful at a time, which creates a navigation hierarchy that doesn&amp;#39;t exist conceptually — Types and Projects are parallel classification systems, not alternatives.&lt;/p&gt;
&lt;h2&gt;Design system&lt;/h2&gt;
&lt;p&gt;The visual system follows the Observatory brief: dark-only, no rounded corners, no gradients, hairline rules, monospace metadata.&lt;/p&gt;
&lt;p&gt;The accent color (&lt;code&gt;oklch(74% 0.07 240)&lt;/code&gt; — desaturated blue) is used structurally, not decoratively:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Active nav item&lt;/li&gt;
&lt;li&gt;Type labels in tables&lt;/li&gt;
&lt;li&gt;Project badge in article breadcrumb&lt;/li&gt;
&lt;li&gt;Build status dot&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nowhere else. This keeps the accent meaningful as a signal rather than a decoration.&lt;/p&gt;
&lt;p&gt;Fonts are self-hosted Geist variable fonts (woff2) copied from the &lt;code&gt;geist&lt;/code&gt; npm package to &lt;code&gt;/public/fonts/&lt;/code&gt;. This avoids Google Fonts requests and keeps the font stack self-contained. The variable format covers the weight range (300–500) used across the UI in a single file per typeface.&lt;/p&gt;
&lt;h2&gt;Mermaid diagrams&lt;/h2&gt;
&lt;p&gt;Diagrams render client-side using the &lt;code&gt;mermaid&lt;/code&gt; npm package. The approach:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Astro renders &lt;code&gt;\&lt;/code&gt;``mermaid&lt;code&gt;code blocks as&lt;/code&gt;&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;...&lt;/code&gt;&lt;/pre&gt;` in the HTML.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in &lt;code&gt;ArticleLayout.astro&lt;/code&gt; queries all &lt;code&gt;.language-mermaid&lt;/code&gt; elements, replaces each &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; with a container &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;, and calls &lt;code&gt;mermaid.render()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This works for both &lt;code&gt;.md&lt;/code&gt; and &lt;code&gt;.mdx&lt;/code&gt; files without a remark plugin or build-time headless browser. The tradeoff is that diagrams require JS — acceptable for a technical audience.&lt;/p&gt;
&lt;p&gt;The mermaid theme is configured to match the Observatory design system:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;mermaid.initialize({
  theme: &amp;#39;base&amp;#39;,
  themeVariables: {
    primaryColor:       &amp;#39;#171c25&amp;#39;,
    primaryTextColor:   &amp;#39;#dde0e6&amp;#39;,
    lineColor:          &amp;#39;rgba(255,255,255,0.35)&amp;#39;,
    fontFamily:         &amp;#39;Geist Mono, ui-monospace, monospace&amp;#39;,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example diagram — the content routing architecture:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;graph LR
  A[&amp;quot;/content/**/*.md&amp;quot;] --&amp;gt; B[&amp;quot;Astro Content Layer&amp;quot;]
  B --&amp;gt; C[&amp;quot;/doc/:slug&amp;quot;]
  B --&amp;gt; D[&amp;quot;/type/:type&amp;quot;]
  B --&amp;gt; E[&amp;quot;/project/:name&amp;quot;]
  B --&amp;gt; F[&amp;quot;/index&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What was left out&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Search&lt;/strong&gt; (Pagefind) — deferred. The table layout on the homepage already functions as a scannable index. Search adds value at volume (50+ documents).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Series linking&lt;/strong&gt; — the &lt;code&gt;series&lt;/code&gt; field is stored in frontmatter and displayed in article metadata, but there&amp;#39;s no &amp;quot;previous / next&amp;quot; navigation yet. At low volume this isn&amp;#39;t needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Light mode&lt;/strong&gt; — explicitly out of scope. A dark engineering notebook with a single theme is simpler to maintain and fits the target aesthetic.&lt;/p&gt;
</content:encoded><category>architecture</category><category>astro</category><category>static-site</category><category>design-systems</category></item></channel></rss>