Get in touch →
← All posts
Decisions2026.06.03

Six Categories, Chosen Before the First Post

Before a single post existed, this log was already a content collection — just an empty one. No posts, no drafts, nothing migrated in from somewhere else, just a schema waiting for content that didn’t exist yet. That’s an unusual position to design a taxonomy from, because there’s no backlog of real posts to sort and notice patterns in. Normally you’d let categories emerge from what you’ve actually written, then formalize the ones that stuck. Here, the categories had to come first, as a bet about what this log would eventually be about, made with zero posts as evidence.

The bet landed on six fixed categories: architecture, security, operations, postmortem, decisions, services. Fixed as in a closed enum, not a suggested list: src/config/log.ts defines LOG_CATEGORIES as a const tuple, and every other place that needs to know about categories derives from it rather than declaring its own list.

Reusing section boundaries that already existed

Three of those six names (architecture, security, operations) aren’t new vocabulary invented for the blog. They’re the same names as this site’s existing structural pages: /system, /security, and /operations are real, independent routes that document the actual infrastructure: network segmentation, ingress model, storage, the service catalog, deploy pipeline, observability, backups. Those pages exist and are maintained regardless of whether the log has ever mentioned any of it.

The category choice deliberately maps onto that existing boundary instead of inventing a parallel one. A post filed under architecture is a post about the same kind of thing /system documents (topology, compute, storage decisions), just narrated as an entry in a log rather than described as current state on a reference page. Naming the blog category anything else, “infra,” “design,” “platform,” would have created a second vocabulary describing the same territory the static pages already carve out, and a reader (or a future me, writing the fortieth post) would have to hold both vocabularies in mind and map between them. Reusing the name means the mapping is free: if you already know what /security covers, you already know what the security category covers.

postmortem and services don’t map to a static page the same direct way, but they follow the same instinct: name the category after the kind of thing it structurally is, not after a topic that happens to be trendy this month. A postmortem is a specific narrative shape (something happened, here’s what changed after), independent of which subsystem it’s about. services is about what runs and how it’s reached, closer to a catalog entry than a narrative.

Decisions absorbing what could have been static pages

decisions is the one category that’s genuinely new relative to the site’s existing structure, and it’s carrying real design weight. The comment on the Zod schema in src/content.config.ts says it plainly: decisions (meaning architecture decision records) lives in the log instead of as separate static pages under /architecture (or under /system, which is this site’s actual name for that section).

That’s worth pausing on, because the more common pattern is the opposite: a dedicated /adr or /decisions section, hand-built, separate from a blog. The reasoning against that here is that an ADR is, structurally, already a log entry: it has a point in time, a specific choice, a specific set of alternatives considered, and it’s not meant to be edited into a living document afterward the way a reference page is. /system describes what’s true now; a decisions post describes why one option won over another, at the moment that was decided. Giving that its own static section would have meant building and maintaining a second content type that does almost the same job the log collection already does (same rendering pipeline, same per-post metadata, same folder-per-slug layout), just for a different label. Filing it into the log as a category instead means ADRs get versioned history, tags, and a publish date for free, and there’s exactly one collection to maintain instead of two.

Why a closed enum, not free-text categories

The mechanical form of the choice matters as much as the six names themselves. LOG_CATEGORIES is a fixed tuple, content.config.ts derives its Zod category enum straight from it, and categoryColor (also in log.ts) and categoryMeta (in src/i18n/dictionaries/log.ts, for both en and es) are typed against that same list. Adding a seventh category means editing three specific places, and TypeScript will refuse to compile until all three are updated: categoryColor is a Record<LogCategory, string>, so a new category with no color entry is a type error, not a silent gap; categoryMeta in both locale dictionaries is typed satisfies CategoryMeta, so a category missing its label/description in either language fails the same way.

Free-text categories would have skipped all of that friction, and that’s exactly the problem with them. An open string field lets a category get created by accident — a typo, a one-off label picked because it felt right for one specific post, a near-duplicate of something that already exists spelled slightly differently. None of that shows up as an error; it just accumulates as categories with one post each, or categories that quietly fork what should have been the same bucket. A closed enum makes adding a category a deliberate act with a compiler-enforced checklist, which is a small tax paid rarely, in exchange for never accumulating taxonomy debt by accident.

The trade is real: six categories decided with zero posts as evidence is a genuine bet, and a bet made this early can be wrong. If a seventh, structurally distinct kind of post shows up later (something that doesn’t fit architecture, security, operations, postmortem, decisions, or services), the enum will have to grow, and TypeScript will make sure that growth touches every place it needs to. That friction is the point, not a flaw in the plan: it’s cheaper to pay a small, forced, type-checked cost on the rare occasion a category genuinely needs to be added than to leave the door open for categories to sprawl unchecked from day one.

Color and locale as forcing functions, not decoration

The categoryColor map in src/config/log.ts looks like a cosmetic detail (each category gets a CSS variable: architecture and decisions share --amber, security and services share --cyan, postmortem gets --terracotta on its own), but it’s doing the same enforcement job as the enum itself, just visually. Because it’s typed as Record<LogCategory, string>, a category with no assigned color is a compile error, not a post that quietly renders without a color accent. That means the visual design of the log can never silently drift out of sync with the taxonomy: there’s no way to add a category and forget to give it a look, because the type system won’t allow the category to exist half-configured.

The locale dictionary carries the same discipline further. categoryMeta lives in src/i18n/dictionaries/log.ts, once for en and once for es, and both are typed satisfies CategoryMeta, a shape derived, again, from the same LogCategory union. That means a category can’t exist with an English label and description but no Spanish equivalent, or vice versa. For a bilingual site, that’s not a minor nicety. An open, free-text category field would have made it entirely possible to end up with categories that only ever got named in whichever language the post that introduced them happened to be written in, leaving the other locale’s category filter UI showing a blank or a fallback string for something that should have a real translated label. The closed enum turns “did we translate every category” from a thing a human has to remember to check into a thing that fails the build if it’s wrong.

What almost went a different way

It’s worth naming the alternative that was actually on the table and rejected, because the reasoning against it is as informative as the reasoning for the chosen design. The obvious alternative to a fixed enum is exactly what most blog platforms default to: an open string field for category, with tags layered on top as a secondary, equally open axis: two flavors of the same unconstrained mechanism, just at different granularities. That’s a perfectly workable design for a blog where the categories genuinely are expected to be numerous and evolving, and where there’s no existing sitewide structure for them to map onto.

Neither of those conditions held here. This site already had exactly the boundaries a taxonomy would want (three static sections covering architecture, security, and operations, plus the ADR-shaped gap that became decisions), so an open category field would have been solving a problem that had already been solved by the site’s existing structure, just badly, by re-deriving a set of buckets that already existed elsewhere under different rules. And because those existing sections are a small, stable, deliberately-curated set rather than something that grows week to week, mirroring them as a closed enum wasn’t a constraint imposed on the blog from outside — it was recognizing that the blog’s natural category boundaries were already fixed in practice, whether or not the schema said so.

Tags as the pressure valve

None of this works, though, if every post has to be shoehorned into one of six buckets with no finer-grained way to describe what it’s actually about. That’s what tags is for: a plain string[], defaulting to empty, completely unconstrained. A post can be category: "security" and carry tags like ["vpn", "wireguard-style", "access-control"], and those tags cost nothing to invent, reuse, or abandon. There’s no compiler check on tags and no enum to extend, because tags aren’t meant to be structural: they’re the escape valve for topic-level specificity that doesn’t deserve its own category.

That split does real work: it means the six-category decision doesn’t have to anticipate every topic this log will ever cover, only the six structural shapes a post can take. Everything more specific than that (which protocol, which subsystem, which incident class) lives in tags, where getting it wrong or changing your mind later costs nothing more than editing a frontmatter array.

The category enum is only half of this collection’s schema story — see Two Content Collections, One Blog for why the English and Spanish sides of the log are two separate collections entirely, rather than one schema with a translated-fields shape.

← All posts