Skip to content
bflo.sh

The master blueprint — a blog as files, by design

The product design of record: how a file-based blog maps onto a fixed tier model — content model, publication policy, contract, and the delivery roadmap.

Adapted from the repo's docs/master-blueprint.md — Part of Internals: the platform's design set, published as content on the platform it describes.

Status: Ratified (2026-07-05). This design of record spans every tier. ADRs 00050008 are Accepted, and the read boundary is published as contract v0.2.0 (contract/openapi.yaml + contract/asyncapi.yaml; see ../contract/CHANGELOG.md). Tier owners implement against the published contract version, never against this document (Rule 1, rules-of-engagement.md).

Delivery (2026-07-07): Phases 0–4 are complete and verified — the full roadmap (§11) is delivered at contract v0.8.1 (v0.8.0 + a description-only patch settling the curated-nav semantics), ADRs 0005–0017. See how-it-works.md for the end-to-end technical tour.

Reading order: this sits after architecture.md. It applies the template's fixed tier model to one concrete product; it does not restate or override it.

1. Vision & scope

A personal publishing platform — a blog — where a single author publishes articles by writing files, not by operating a UI. There is no administrative panel: content is authored out-of-band (files edited in an editor, synced via git or a mounted volume) and the system's job is to read those files, understand their structure and schedule, and serve them over a clean REST API to a Next.js reader front-end.

The defining architectural choice: the filesystem is the content source of truth. The platform reads a content directory recursively, treats each subdirectory as a section and each Markdown file as an article, honours a publication schedule declared in each file's front-matter, and projects all of it into fast, cacheable read models. PostgreSQL and Redis remain present and authoritative for everything they store — they just hold a derived index and cache of content, not the content's origin (see §10, ADR-0005).

1.1 Goals

  • Author writes a Markdown file → it appears on the site, on schedule, with zero UI steps.
  • Arbitrarily nested content tree (sections within sections) mirrored 1:1 in the URL space.
  • Scheduled publishing: an article declares when it goes live; it stays invisible until then.
  • Fast, statically-cacheable reads; a small, honest, read-only public API.
  • Fits the template's tiers and rules unchanged: frontend → contract ← backend, and backend Http → Domain → Data.

1.2 Non-goals (this line)

Non-goal Why / where it goes instead
Admin panel / CMS UI Explicit product decision. Authoring is file-based, out-of-band.
Public write/comment/auth surface The API is read-only and unauthenticated (public blog). Any future write surface is a new contract major + auth ADR.
Content stored in a database as SoR Filesystem is SoR; Postgres is a derived index (ADR-0005).
Multi-tenant / multi-author accounts Single author (front-matter author is a label, not an identity).
WYSIWYG, media library, plugin system Out of scope; revisit per-project via ADR.

2. How it maps onto the template

Nothing about the tier model changes. This blueprint only fills the tiers with content.

        ┌─────────────────────────────────────────────────────────────┐
        │  Content root (filesystem)  —  the CONTENT source of truth    │
        │  storage/app/content/**  (recursive: metadata.json + *.md)    │
        └───────────────────────────┬───────────────────────────────────┘
                                     │ read + parse (Data layer, one-way)
        ┌────────────────────────────▼──────────────────────────────────┐
        │  backend/  (Laravel API)                                       │
        │    Http    listArticles / getArticle / getSection(Tree) …      │
        │    Domain  content model, publication policy, ports            │
        │    Data    Filesystem ContentSource → Postgres index + Redis   │
        └───────────────┬───────────────────────────────────────────────┘
                        │ conforms-to
                 ┌──────▼───────┐        consumes (generated client)   ┌───────────────┐
                 │  contract/   │◄──────────────────────────────────── │  frontend/    │
                 │ OpenAPI 3.1  │                                      │   Next.js      │
                 │ AsyncAPI 3.1 │                                      │  reader UI     │
                 └──────────────┘                                      └───────────────┘
  • frontend/ and backend/ couple only through contract/. The front-end never reads the content directory or the database — it consumes the published REST API.
  • infra/ provides the runtime unchanged (DDEV: PHP 8.5, PostgreSQL 16, Redis, Node 22), plus one wiring addition: the CONTENT_ROOT path/volume (§7).

3. Content model — the storage layout

The content root is a directory tree scanned recursively. Two file kinds carry meaning:

On disk Means Companion of
a directory a Section — a node in the content tree its metadata.json
metadata.json in a directory that section's metadata (title, order, …) the directory
a *.md file an Article — a leaf; front-matter + Markdown body itself
any other file (*.png, *.jpg, …) a content asset referenced by nearby articles

3.1 Example tree

storage/app/content/                 ← CONTENT_ROOT (configurable)
├── metadata.json                    ← root section metadata
├── hello-world.md                   ← article at path:  hello-world
├── engineering/                     ← section:  engineering
│   ├── metadata.json
│   ├── my-first-post.md             ← article:  engineering/my-first-post
│   ├── assets/
│   │   └── diagram.png              ← asset:    engineering/assets/diagram.png
│   └── deep-dives/                  ← nested section:  engineering/deep-dives
│       ├── metadata.json
│       └── concurrency.md           ← article:  engineering/deep-dives/concurrency
└── travel/
    ├── metadata.json
    └── japan-2025.md                ← article:  travel/japan-2025
  • Path = identity. An article's canonical id is its slug path relative to the content root, without the .md extension: engineering/deep-dives/concurrency. A section's id is its directory path: engineering/deep-dives. Both are the natural, human-readable keys and map 1:1 onto front-end URLs.
  • Slugs are kebab-case. Filenames and directory names are the slug segments; the indexer validates the charset and rejects/reports anything that would not round-trip a URL.
  • metadata.json is optional but recommended. When absent, the section defaults to a humanised directory name and alphabetical ordering — a missing companion is a soft warning, not a hard failure.

3.2 Section companion — metadata.json

{
  "title": "Engineering",
  "description": "Notes on building software.",
  "order": 10,
  "visibility": "public",
  "cover": "assets/engineering-cover.jpg"
}
Field Type Meaning Default
title string Display name of the section humanised directory name
description string Short blurb for section index pages / feeds null
order integer Sort weight among siblings (ascending) 1000, then title A→Z
visibility public | hidden hidden prunes the whole subtree from the API public
cover string Path (relative to this directory) to a cover asset null

3.3 Article — Markdown file with front-matter

Each article is a UTF-8 Markdown file whose header is a YAML front-matter block delimited by ---, followed by the Markdown body:

---
title: "Understanding Concurrency"
description: "Threads, async, and the models in between."
author: "Ada Lovelace"
published_at: 2026-06-01T09:00:00Z
scheduled_at: 2026-07-10T09:00:00Z    # optional — see §3.4
updated_at:   2026-06-15T12:00:00Z
tags: [concurrency, systems]
draft: false
cover: "assets/diagram.png"
slug: "concurrency"                   # optional — defaults to the filename
---

Concurrency is not parallelism. …the Markdown body renders from here.
Field Type Required Meaning
title string Article title. Absent → article is reported invalid and skipped.
description string Summary for lists, <meta> tags, and feeds.
author string | string[] Display author(s). A label, not an account.
published_at date-time (ISO-8601, UTC) Canonical publication instant / displayed date.
scheduled_at date-time (ISO-8601, UTC) Optional go-live gate; overrides published_at for visibility only.
updated_at date-time Last meaningful edit; shown as "updated".
tags string[] Free-form tags; normalised to kebab-case slugs for browsing.
draft boolean true ⇒ never public, regardless of dates. Default false.
cover string Path (relative to the file's directory) to a cover asset.
slug string Override the URL segment; defaults to the filename stem.

Unknown front-matter keys are preserved as opaque extra metadata (never trusted for behaviour) so authors can annotate freely without breaking the parser.

3.4 Publication state machine (the scheduling rule)

Visibility is a pure function of front-matter and the current time — the single most important invariant in the Domain layer. Precedence is explicit so published_at and scheduled_at never contradict:

go-live instant  =  scheduled_at  ??  published_at        (when the article becomes public)
display date      =  published_at  ??  scheduled_at        (the date shown to readers)

state(article, now):
    draft == true                 → DRAFT       (never public)
    go-live instant is null       → DRAFT       (no publish instant declared)
    go-live instant  >  now       → SCHEDULED   (hidden until the instant arrives)
    go-live instant  <= now       → PUBLISHED   (public)
State Public API surfaces it? Notes
DRAFT Missing/null go-live instant, or draft: true.
SCHEDULED Go-live instant is in the future; becomes PUBLISHED automatically.
PUBLISHED Only these appear in any public list or fetch.

Read-time gating is the source of truth. Because visibility is time-derived, the API is correct even with no background job: every query filters go-live <= now(). The scheduler (§5.4) is an optimisation and notifier, not a correctness dependency — it re-warms caches and emits content.article-published the moment a SCHEDULED article crosses into PUBLISHED.

4. The two content stores — SoR vs. index

Filesystem (SoR)            Postgres (derived index)         Redis (cache)
─────────────────           ─────────────────────────        ────────────────────
metadata.json + *.md   ──►  articles, sections tables   ──►  rendered HTML by content-hash
 (author writes here)       (query, filter, paginate,        section tree snapshot
                             full-text search)                tag/author aggregates
        ▲                            ▲                                 ▲
        └─ reindex reads ────────────┴── reindex writes ──────────────┘
                    (idempotent, hash-guarded, one direction)
  • Filesystem — the only place a human edits. Owns truth for content.
  • PostgreSQL 16 — a rebuildable projection: one row per article / section, with the parsed metadata, the resolved state, go_live_at, tags, and a content_hash. Powers listing, filtering, pagination, and (phase 2) full-text search. Hierarchy is stored as a materialised path (candidate: the ltree extension) so subtree queries are index-friendly.
  • Redis — caches the expensive, stable outputs: rendered+sanitised article HTML keyed by content_hash, the section-tree snapshot, and tag/author aggregates. Invalidated by content hash, so a re-render only happens when a file actually changed.

This refines — does not violate — ADR-0003: Postgres stays the system of record for everything it holds; content simply originates on disk and is projected in. Recorded as ADR-0005 (§10).

5. Backend design (backend/)

Layered Http → Domain → Data, exactly as architecture.md mandates. Ownership below follows the roster in team.md.

5.1 Domain (app/Domain/**domain-engineer)

The heart of the system; framework- and storage-agnostic.

  • Value objects / entities: ContentPath (validated slug path, traversal-safe), Article, ArticleSummary, Section, SectionTree, Tag, Author, PublicationState.
  • Publication policy: PublicationPolicy::state(frontMatter, Clock): PublicationState implementing §3.4 against an injected Clock (so scheduling is unit-testable without real time).
  • Use-case actions (one per API operation): ListPublishedArticles, GetArticleByPath, GetSection, GetSectionTree, ListTags, SearchArticles (phase 2). Each returns Domain results; none knows about HTTP or Eloquent.
  • Ports (interfaces Domain defines, Data implements):
    • ContentSource — enumerate the tree, read a raw article/section, resolve an asset path.
    • ContentIndex — the query side: paginated published lists, fetch-by-path, subtree, tag catalogue, search.
    • RenderedContentCache — get/put rendered HTML by content hash.
    • MarkdownRenderer — Markdown → sanitised HTML + table of contents.
    • Clock — current instant.
  • Domain events: ArticlePublished, ContentReindexed — shapes mirror contract/asyncapi.yaml (§6.2).

5.2 Data (app/Models/**, app/Infrastructure/**, database/**data-engineer)

Implements every Domain port; owns all storage concerns.

  • FilesystemContentSource — implements ContentSource over Laravel Storage / Flysystem rooted at CONTENT_ROOT. Walks recursively, parses YAML front-matter, computes a per-file content_hash, and refuses to resolve any path that escapes the root (traversal guard).
  • Index schema (Postgres): sections (path, parent_path, title, order, visibility, cover) and articles (path, section_path, slug, title, description, authors, go_live_at, display_date, updated_at, tags, state, content_hash, body_excerpt, search vector). Indexes: go_live_at, section_path (subtree via materialised path / ltree), GIN on tags and on the FTS tsvector.
  • PostgresContentIndex — implements ContentIndex; all list queries filter state = PUBLISHED AND go_live_at <= now() and page by opaque cursor (PageMeta).
  • RedisRenderedContentCache — implements RenderedContentCache, keyed by content hash; documented TTL + hash-keyed invalidation.
  • MarkdownRenderer — CommonMark pipeline (e.g. league/commonmark) + an HTML sanitiser allowlist; output is safe to inject client-side (§8).
  • Migrations/factories/seeders stay in lock-step with the schema; a seeder can point at a fixture content tree for tests.

5.3 Http (app/Http/**, routes/**backend-api)

  • Thin controllers: validate query params (form requests), invoke one Domain action, map the result to an API resource matching the published contract exactly.
  • Route note: article/section paths contain /, so the catch-all route parameter uses an unencoded-slash constraint, e.g. Route::get('/articles/{path}')->where('path', '.+').
  • One exception renderer → RFC 9457 application/problem+json for every 4xx/5xx (404 for unknown or not-yet-public path — the API never reveals that a SCHEDULED/DRAFT item exists). No ad-hoc error JSON.
  • Public endpoints are unauthenticated; per-IP rate limiting and CORS restricted to the front-end origin are applied at this layer.

5.4 Indexing pipeline & scheduler

 trigger ─────────────► reindex(ContentSource → ContentIndex)
   ├─ artisan content:reindex        (manual / CI / post-deploy)          idempotent,
   ├─ Laravel scheduler tick (~1 min)  (picks up file edits + SCHEDULED    hash-guarded:
   │                                    → PUBLISHED transitions)           unchanged files
   └─ (optional) filesystem watcher    (near-instant local authoring)      are skipped
                                   │
                                   ├─ upsert changed rows, prune deleted
                                   ├─ warm Redis for changed content hashes
                                   └─ emit content.article-published / content.reindexed
  • content:reindex is idempotent and hash-guarded: it diffs content_hash per file and only re-parses/re-renders what changed — cheap to run every minute.
  • The scheduler run is what turns a SCHEDULED article PUBLISHED in the index and emits the event; read-time gating already made it correct, so the event is for cache-warming and downstream notification (e.g. front-end on-demand revalidation, §6.2).
  • Malformed front-matter / duplicate slugs / bad charset ⇒ the offending item is skipped, counted, and logged; content.reindexed carries the failure count so problems are observable without an admin UI.

6. The contract (contract/) — published as v0.2.0

The API is read-only and public. The shapes below were published as contract v0.2.0 by contract-owner (minor/additive over the 0.1.0 skeleton) — reviewed, versioned, linted, and released ahead of implementation. Phase-2 /search and /authors/{author} are deferred to later minor bumps. Conventions from contract/README.md are honoured throughout: operationId + description per operation, kebab-case paths, camelCase JSON, cursor pagination via PageMeta, RFC 9457 errors, EventEnvelope for events.

6.1 REST surface (OpenAPI 3.1)

Method & path operationId Purpose Key params
GET /health getHealth Liveness + deps (adds a content check)
GET /articles listArticles Paginated published articles, newest first cursor, pageSize, section, tag, author
GET /articles/{path} getArticle One published article, full rendered body path (slug path)
GET /sections getSectionTree The nested public section tree depth?
GET /sections/{path} getSection One section + its subsections & article summaries path, cursor, pageSize
GET /tags listTags Tag catalogue with counts
GET /tags/{tag} listArticlesByTag Published articles for a tag tag, cursor, pageSize
GET /search (phase 2) searchArticles Full-text search over published articles q, cursor, pageSize
GET /authors/{author} (phase 2) listArticlesByAuthor Published articles by author label author, cursor, pageSize

Core schemas (boundary shapes — never the persistence rows):

  • ArticleSummarypath, slug, title, description, authors[], sectionPath, publishedAt, updatedAt, tags[], readingMinutes, coverImageUrl.
  • ArticleArticleSummary + body (sanitised HTML), tableOfContents[], prev/next (adjacent-article refs for navigation).
  • SectionNode (tree) — path, title, order, children[] (recursive).
  • Section (detail) — path, title, description, order, coverImageUrl, subsections[] (SectionNode), articles[] (ArticleSummary) + meta (PageMeta).
  • Tagslug, label, count. Authorslug, name, count.
  • Reuse the skeleton's Problem, PageMeta, cursor, pageSize verbatim.

Rendering decision (recommended): the backend returns server-rendered, sanitised HTML in Article.body, plus a structured tableOfContents. Rationale: one trusted rendering + sanitising pipeline (not shipped to every client), consistent output for pages and feeds, and safe SSR injection on the front-end. bodyFormat: "html" is declared in the schema so a future raw-Markdown variant is an additive change. (Ratify as ADR-0007.)

Assets: images referenced by articles live in the content tree. Options for contract-owner to decide: (a) a backend GET /assets/{path} that streams content-root files with correct content-types, or (b) rewrite asset URLs at render time to a static/CDN origin. Option (a) keeps the boundary self-contained and is the recommended default.

6.2 Events (AsyncAPI 3.1)

Two channels, both wrapping the mandatory EventEnvelope:

Channel address When data payload
content.article-published a SCHEDULED article crosses to PUBLISHED (or a new published article is indexed) path, slug, title, sectionPath, publishedAt
content.reindexed a reindex run completes articlesIndexed, sectionsIndexed, skipped, durationMs, contentVersion

Primary consumer: the front-end's on-demand revalidation (turn a publish into an immediate page refresh). content.reindexed's skipped count is the "content health" signal in the absence of an admin panel.

7. Infra / platform (infra/platform-engineer)

Baseline is unchanged (ADR-0003/0004). Additions this product needs:

  • CONTENT_ROOT env var (default storage/app/content), wired in backend/.env.example; in DDEV it may be a bind-mounted host directory or a git-synced path so authoring happens on the host with a normal editor. Document the sync mechanism as a known manual step per the infra/ convention ("an undocumented manual step is a platform bug").
  • Scheduler: php artisan schedule:work (or cron) running the ~1-minute reindex tick — add as a docker-compose.<service>.yaml worker following the frontend-service pattern, or via DDEV post-start, owned by platform-engineer.
  • Health: GET /health gains a content check (content root readable + last reindex fresh) so the existing infra verify (curl …/health) covers content wiring too.
  • CI unchanged in shape: contract-lint → backend verify → frontend verify.

8. Cross-cutting concerns

Concern Approach
XSS / sanitisation Markdown → HTML through an allowlist sanitiser server-side; even owner-authored content is sanitised. Front-end injects trusted HTML only.
Path traversal ContentPath value object + FilesystemContentSource reject any resolved path escaping CONTENT_ROOT; /assets/{path} shares the guard.
Leakage of unpublished SCHEDULED/DRAFT items return 404 (never 403) — their existence is never disclosed.
Caching / invalidation Redis keyed by content_hash (documented TTLs); Postgres index is the query cache; front-end uses ISR + event-driven on-demand revalidation. Invalidation rules are documented decisions, not folklore.
Performance Cursor pagination; go_live_at, section-path, tag GIN, and FTS indexes; hash-guarded reindex avoids needless re-render.
Observability Structured logs for skipped/malformed content; content.reindexed metrics; health content check.
Accessibility Semantic prose rendering, heading hierarchy from the ToC, alt text from asset metadata; a11y is in-scope per component (frontend/README.md).
SEO / feeds Front-end emits sitemap.xml and an RSS/Atom feed from listArticles; per-article <meta>/OpenGraph from summary fields.

9. Frontend design (frontend/frontend-engineer)

Consumes the API only through the generated client (openapi-typescript from contract/openapi.yaml), one fetch wrapper handling the Problem shape uniformly — never hand-written shapes, never backend/content access.

  • Routing (App Router): a catch-all app/[...slug]/page.tsx mirrors the content tree — resolve slug as an article (getArticle) and fall back to a section (getSection); plus /, /tags/[tag], /search, /authors/[author].
  • Rendering strategy: SSG + ISR for published pages (paths enumerated from listArticles/getSectionTree at build, revalidated on an interval), with on-demand revalidation driven by content.article-published so a scheduled post appears promptly. SSR fallback for not-yet-generated paths.
  • Article rendering: Article.body arrives as sanitised HTML → render into a styled prose container; ToC and prev/next from the payload. No client-side Markdown parser.
  • Explicit loading and error states on every remote call; regenerate the client on every contract bump (typecheck failing until call sites adopt is intentional).

10. Decisions to ratify (ADRs)

This blueprint implied decisions that are "expensive to reverse or that another member could unknowingly violate" — i.e. ADR-worthy. They are now Accepted and stewarded by contract-owner (adr/README.md); each row links to its record:

ADR Decision
ADR-0005 Filesystem is the content source of truth; PostgreSQL is a derived, rebuildable index; Redis caches rendered output. Refines ADR-0003's "system of record" for content.
ADR-0006 The public API is read-only and unauthenticated; content is authored out-of-band; no admin panel. Any write/auth surface is a future major + auth ADR.
ADR-0007 Markdown is rendered to sanitised HTML server-side and delivered as Article.body; single rendering/sanitising pipeline.
ADR-0008 Scheduled publishing is enforced by read-time gating (correctness) plus a scheduler that emits content.article-published (cache-warming/notification).

11. Delivery roadmap (contract-first sequencing)

Each phase ends at its tier's Definition of Done (CLAUDE.md → verify commands). Cross-tier work is routed through contract-owner; parallel same-tier edits use worktree isolation. Each phase also begins at a gate: its task list is presented for explicit user confirmation before execution (from Phase 2 onward) — see orchestration.md → Phase-start confirmation gate.

Status (2026-07-07): Phases 0–3 COMPLETE and verified; Phase 4 (Enrichment) COMPLETE — all five tracks (4a search, 4c assets & covers, 4b authors, 4d on-demand revalidation, 4e full-content feed) delivered and lead-verified. Contract v0.8.1 published (v0.8.0 plus the description-only curated-nav clarification); ADRs 0005–0017 Accepted. Notable since the original design: the client and API were split across hosts (ADR-0010 — the Next client on the bare host, the API on the api. host under a /v1 base); the reader UI was redesigned to the "Opened File" system (two review rounds); Mermaid diagrams render client-side (ADR-0011 assets, ADR-0012 Mermaid); author identity is settled as a free label with server-published slugs (ADR-0013); site identity lives in the root content metadata, served by GET /site (ADR-0014); unlisted pages + curated site.nav + the one-h1 render invariant landed as ADR-0015 (the About page is the first unlisted nav page); and the AsyncAPI events gained their first consumer — the relay → signed webhook → revalidateTag loop (ADR-0016), making publishing hands-off (save the file; fresh in well under 90 s — measured 21 s to appear, 51 s to prune). The live content root holds the published article set; the former example tree is now the test-fixture set (backend/tests/fixtures/content).

Phase Owner(s) Deliverable Done when
0 · Bootstrap platform-engineer DDEV up (Laravel + Next + Postgres + Redis), CONTENT_ROOT wired, scheduler service, example content tree ddev restart green; /health content check passes
1 · Contract contract-owner Publish v0.2.0: articles + sections + tags endpoints, core schemas, both events; ratify ADR-0005..0008 ddev contract-lint clean; CHANGELOG entry; adoption tasks routed
2 · Backend domain-engineerdata-engineerbackend-api Domain model + policy + ports; filesystem source + Postgres index + reindex/scheduler + Redis cache; controllers conforming to v0.2.0 ddev composer verify green; endpoints match contract; no forbidden edges
3 · Frontend frontend-engineer Generated client, catch-all routing, ISR + article rendering, sitemap/RSS ddev frontend npm run verify green; reads live API
4 · Enrichment contract-owner + owners Search (FTS) ✅, assets & covers ✅, authors browse ✅, on-demand revalidation ✅, full-content feed ✅ (ADR-0017, no contract change) Each rode its own contract bump + tier verify

12. Open questions

  1. Content sync — how does the content root reach the running backend in each environment: git pull on deploy, bind-mounted volume, or object-storage sync? (Infra ADR.)
  2. Draft preview — with no admin panel, is a signed preview-token route wanted for SCHEDULED/DRAFT articles, or is git-branch preview enough? (Would add an authenticated read path — contract + auth ADR.)
  3. Assetsratify GET /assets/{path} vs. render-time URL rewriting to a static origin RESOLVED (ADR-0011): backend GET /v1/assets/{path} streaming content-root files, originals as-is, behind a fail-closed media-type allowlist; cover/in-body URLs rewritten to it.
  4. Author identitykeep author as a free label, or introduce an authors.json RESOLVED (ADR-0013): authors are free-label strings (no accounts/bios); the server publishes a canonical slug per byline author, and /authors, /authors/{author}, ?author= operate on it.
  5. i18n — single-locale now; if needed later, is locale a top-level section, a path convention, or a front-matter field? (Decide before it calcifies.)

Cross-references