BLO Living-Content System — Concrete Design
Implements the council-final strategy: free hand-written Week’s Card, foreground-only fetch, two provenance frames, count-only Worker, no subscription, no tracking, bundled deck as permanent offline floor. Solo-dev + always-on Mac + Cloudflare. All hour estimates are solo evenings-grade, conservative.
One constraint governs everything below (the AI bright line): no generated sentence ever ships as card text, notification text, or any user-facing copy. The pipeline’s LLM stages are seed research (bullet fragments and dates only, never sentences) and QA lint — never drafting, never rephrasing. Where this doc says “draft,” a human hand wrote it. This is a brand claim (“Not AI-generated”) that is load-bearing on the store listing; the pipeline is architected so it cannot violate it (the publish script rejects any card file whose git history lacks a manual-edit commit — see §5).
1. Architecture Overview
ALWAYS-ON MAC (production) CLOUDFLARE (delivery)
┌──────────────────────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Sunday ritual (human) │ │ Pages project: blo-content │
│ │ │ (static, git-deployed) │
│ seed brief ──► Joshua writes card ──► lint ──► commit│ │ │
│ (cron, Mon) (both voices, hand) (script) (git) │──►│ content/weekly/current.json │
│ │ │ content/packs/<slug>/manifest.json │
│ blo-content repo (git) ── push ── Pages auto-deploy │ │ content/deck/base.json │
└──────────────────────────────────────────────────────┘ │ │ │
│ ▼ │
│ Count Worker (route: content/*) │
│ → Workers Analytics Engine │
│ (path, status-class, ISO-week) │
│ no IP, no UA, no cookie, no ts │
└───────────────┬─────────────────────┘
│ GET (foreground open
│ only, ETag, 24h throttle)
┌───────────────────────────────────────────────────────────────────────────┴─────────────────────┐
│ APP (Expo SDK 54 / RN 0.81) │
│ │
│ lib/remoteContent.ts ──► AsyncStorage cache {etag, cachedAt, body} │
│ │ any failure → silent fall back to cache → bundled base-deck.json (permanent floor) │
│ ▼ │
│ deck merge (base ∪ owned packs ∪ week's card) ──► deterministic daily draw (date-seeded, │
│ anti-repeat trailing 14) ──► reveal UI ──► branded share card (QR + per-format campaign token) │
│ │
│ expo-notifications: LOCAL ONLY — fixed weekly scheduled reminder (default Sun 19:00 Berlin, │
│ user-adjustable) + daily nudge with rotating hand-written lines. No push tokens, no server. │
│ │
│ expo-iap (existing): lifetime non-consumable (untouched) + future pack non-consumables. │
│ Entitlement check is 100% on-device (StoreKit restore). No accounts. No analytics SDK. │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
Mermaid (same system, data-flow view):
flowchart LR
subgraph Mac["Always-on Mac"]
A[Mon cron: seed brief\nbullets+dates only] --> B[Joshua hand-writes\nboth voices]
B --> C[lint.ts: dedup, idioms,\nschema, both-voices]
C --> D[git commit + push]
end
D --> E[Cloudflare Pages\nblo-content site]
E --> F[Count Worker\ncontent/* routes]
F --> G[(Workers Analytics Engine\npath, status, ISO-week)]
F --> H[App: remoteContent.ts\nforeground-only fetch, ETag]
H --> I[(AsyncStorage cache)]
I --> J[Deck merge + daily draw]
K[Bundled base-deck.json\noffline floor] --> J
J --> L[Reveal UI + share card\nQR w/ campaign token]
M[Local weekly notification\nSun 19:00, no server] -.invites.-> J
Key properties, restated as invariants:
- Offline floor: the app never requires network. Bundled
base-deck.jsonrenders everything; remote content is additive. - Foreground-only fetch: the weekly fetch fires only in the app-foreground handler (
AppState→active), neverBGAppRefreshTask, never notification prefetch. This makes the weekly-open gate count engagement, not the OS scheduler. - No identifier leaves the device. The only server-side record anywhere is
(path, status-class, ISO-week)counts. - Publish = git push. No CMS, no admin panel, no database. Kill switch = revert commit + push (live in ~1 min via Pages deploy).
2. Content JSON Schemas
All content files share an envelope. schemaVersion is bumped only on breaking shape changes; minAppVersion lets old binaries skip content they can’t render (defense against “shipped a card that crashes v1.1.0”).
2.1 content/weekly/current.json — The Week’s Card
{
"schemaVersion": 1,
"minAppVersion": "1.2.0",
"kind": "weekly",
"week": "2026-W38",
"publishedAt": "2026-09-20",
"provenance": "fresh",
"card": {
"id": "wk-2026-w38",
"title": "The Long Middle",
"front": { "pg": "…hand-written gentle front…", "sassy": "…hand-written sassy front…" },
"back": { "pg": "…hand-written gentle back…", "sassy": "…hand-written sassy back…" },
"theme": "waiting"
},
"frame": {
"fresh": "written this week, by hand",
"bench": "a card for any week like this one"
}
}
provenanceis enum:"fresh" | "bench". The renderer picks the honest line fromframebased on it — the honesty is structural, not a label bolted on. Bench cards (from the 6-card evergreen bench) ship withprovenance: "bench"and theirpublishedAtis the bench card’s original write date, never faked.- Exactly one file, overwritten weekly. No archive endpoint, no backlog, no streak — killed by strategy.
- Same card for everyone; free tier included.
2.2 content/deck/base.json — Second Printing base deck (remote mirror)
Same 72-item shape as the bundled src/content/base-deck.json, wrapped:
{
"schemaVersion": 1,
"minAppVersion": "1.2.0",
"kind": "deck",
"deckId": "base",
"printing": 2,
"version": 3,
"cards": [ { "id": 1, "themeId": "ACT-01", "theme": "action", "front": { "pg": "...", "sassy": "..." }, "back": { "pg": "...", "sassy": "..." }, "tags": ["..."], "printing": 2 } ],
"retired": [ { "id": 19, "themeId": "ACT-11", "theme": "action", "front": { "pg": "...", "sassy": "..." }, "back": { "pg": "...", "sassy": "..." }, "tags": ["..."], "printing": 1, "retiredIn": 2, "supersededBy": 19 } ]
}
versionis a monotonically increasing integer; the app replaces its cached deck only when remoteversion> cachedversionandminAppVersionis satisfied.retired[]feeds the read-only First Printing archive screen (lifetime holders): purchased content is superseded in the draw pool, never deleted from the device. The bundled binary also carries the retired cards so the archive works offline.- Same-day card pull/replace: edit JSON, bump
version, git push. App picks it up on next foreground open.
2.3 content/packs/<slug>/manifest.json + cards.json — paid packs
{
"schemaVersion": 1,
"minAppVersion": "1.2.0",
"kind": "pack",
"packId": "missing-cards",
"title": "The Missing Cards",
"tagline": "The cards for the hard days.",
"sku": "blo.pack.missingcards",
"cardCount": 20,
"version": 1,
"availability": {
"generalFrom": "2026-10-05",
"lifetimeEarlyFrom": "2026-09-28"
},
"preview": ["mc-03", "mc-11"],
"cardsUrl": "/content/packs/missing-cards/cards.json",
"sha256": "…"
}
availabilityimplements the FAQ “priority previews” promise: lifetime holders see each pack 1 week early — pure client-side date + entitlement check, no server gating.previewlists the 2 free preview cards (full text lives incards.json; non-preview cards render only when the SKU is owned — see §4).content/packs/index.jsonlists all pack manifests so the app discovers new packs without a binary update.
2.4 content/meta/notifications.json — rotating notification lines
{
"schemaVersion": 1,
"kind": "notification-lines",
"version": 4,
"weekly": ["Obadiah's been writing. Come see.", "…~5 hand-written variants…"],
"daily": ["…~30 hand-written lines, Obadiah's voice…"]
}
Local notifications are scheduled on-device with lines sampled from the cached copy. Bundled fallback set ships in the binary. Copy invites, never asserts arrival (“has arrived” is banned by lint).
3. CDN Layout on Cloudflare
One Pages project (blo-content), git-deployed from a dedicated content repo on the Mac (keep it out of the app repo so a content push never touches app CI):
blo-content/ → https://content.boldlittleoracle.com/
├── content/
│ ├── weekly/current.json ← the one weekly asset path (gate metric source)
│ ├── deck/base.json ← Second Printing mirror + retired[]
│ ├── packs/
│ │ ├── index.json
│ │ └── missing-cards/{manifest.json, cards.json}
│ └── meta/notifications.json
├── _headers ← cache-control per path (below)
└── functions/ ← (none — counting is a Worker route, not Pages Functions)
_headers:
/content/weekly/current.json
Cache-Control: public, max-age=300, must-revalidate
/content/deck/*
Cache-Control: public, max-age=3600, must-revalidate
/content/packs/*
Cache-Control: public, max-age=3600, must-revalidate
Short TTL on weekly/current.json so the kill switch is fast; Pages serves ETags natively so 304s are free.
3.1 The Count Worker (the sensor — verify BEFORE v1.2 submission)
A separate Worker (blo-counter) with a route on content.boldlittleoracle.com/content/*, bound to a Workers Analytics Engine dataset. It proxies to Pages (fetch(request) passthrough via the custom domain’s origin) and writes one datapoint per request:
export default {
async fetch(request, env) {
const url = new URL(request.url);
const response = await fetch(request); // passthrough to Pages
const isoWeek = isoWeekOf(new Date()); // e.g. "2026-W38"
env.COUNTS.writeDataPoint({
blobs: [normalizePath(url.pathname), String(Math.floor(response.status / 100)) + "xx", isoWeek],
doubles: [1],
indexes: [normalizePath(url.pathname)]
});
return response;
}
};
- Stores no IPs, no user-agents, no cookies, no timestamp finer than the ISO-week bucket (WAE records its own ingest timestamp; queries only ever GROUP BY the week blob — documented in the disclosure).
normalizePathcollapses to the three canonical paths so query cardinality stays trivial.- Weekly-open gate query (run from a Mon-morning cron on the Mac via the WAE SQL API):
SELECT blob3 AS week, SUM(_sample_interval * double1) FROM blo_counts WHERE blob1 = '/content/weekly/current.json' AND blob2 IN ('2xx','3xx') GROUP BY week— divided by ASC trailing-90-day installs (manual pull, one row/week in a tracking sheet). - NOW-item verification: deploy the Worker + WAE binding on the current (free) plan, curl the path 20×, confirm datapoints queryable. WAE is available on Workers Free (with sampling at high volume — irrelevant at BLO scale). If this fails for any reason, fallback instrument: a tiny Worker that increments
(path, week)keys in KV (1k writes/day free ≫ needed). v1.2 does not ship until one of these is verified. ~0.5 day incl. verification.
Cost: €0 at any realistic BLO scale (Workers Free = 100k req/day; 175 users × ~2 fetches/day ≈ 0.35% of quota).
4. Entitlement Approach
Tier 0 (honor-system), deliberately. Pack JSON lives at public URLs; the app decides what to render based on on-device StoreKit state via expo-iap (getAvailablePurchases() on launch + Restore button). Rationale, per strategy and research:
- Piracy exposure for €4.99 of oracle-card text is ~nil; the people who’d curl the JSON were never buyers.
- Zero backend, zero identifiers → “No tracking” stays literally true with no asterisks beyond the disclosed count Worker.
- Apple-as-merchant-of-record keeps the Einzelunternehmer EÜR trivial; no Stripe until the Paper Oracle gate opens (physical goods only).
Mechanics:
- All pack SKUs are non-consumable IAPs (submitted standalone in ASC — the first non-consumable, the lifetime unlock, is already approved, so no binary is needed per new pack; only pack #1 rides in whatever binary is current for the entitlement-explainer screen it triggers).
- On launch and on Restore, the app caches owned product IDs in AsyncStorage (
ownedSkus: string[]). Renderer rule: pack card bodies render iffmanifest.sku ∈ ownedSkusORcard.id ∈ manifest.preview. - Lifetime early-access:
provenanceof ownership = existing lifetime SKU inownedSkus; if held andtoday ≥ lifetimeEarlyFrom, the pack is browsable/purchasable a week early. Pure client logic. - Entitlement explainer screen (ships dormant in v1.2): one plain screen in Obadiah’s voice shown before any pack purchase button — the “your lifetime unlock covers everything you’ve ever bought here, forever; this is a new deck” text from the strategy, verbatim.
- Upgrade path if ever needed (not built now): Tier-1 HMAC Worker verifying the StoreKit JWS (
jwsRepresentationIosfrom expo-iap) statelessly in a Worker and returning signed pack URLs — no accounts, no DB, “no tracking” survives. ~2–4 days, gated behind actual evidence of a piracy problem. RevenueCat is explicitly rejected: third-party SDK with an app-user-ID phoning home would put an asterisk on the paywall promise.
5. Weekly Production Pipeline (Sunday ritual, ~1.5–1.75 h all-in)
The strategy’s hard ceiling: LLM = research assistant + QA linter, never author. So the pipeline is seed-brief → Joshua hand-writes → lint → publish, not LLM-draft → edit. Exact tooling, all on the always-on Mac, all reusing Oannes patterns:
Stage 1 — Seed brief (automated, Monday 08:00 cron)
launchdjob runsblo-seed.ts(Bun/Node script): pulls the coming week’s season signals — solstice/equinox distance, school-calendar rhythm (DE), daylight delta, upcoming secular moments — plus 3–5 zeitgeist keywords from RSS headlines reduced to bullet fragments and dates only, never sentences (a formatter strips anything with a verb phrase >4 words; a seed that arrives as a well-turned sentence is a draft, per the governance doc, and is dropped).- Output:
briefs/2026-W38.mdcommitted to the content repo + posted to the private Discord channel (existing Oannes webhook pattern). ~six bullets, ~40 words total.
Stage 2 — Writing (human, Sunday, ~45–60 min)
- Joshua reads the brief, writes both voices by hand in
weekly/drafts/2026-W38.json(any editor; ablo new-weekscript scaffolds the JSON envelope with week/date prefilled). - Gut-check per strategy: “does this survive tomorrow’s headlines?” — season’s emotional weather, never the news.
- If the week is skipped:
blo bench-promote <bench-id>copies a bench card into position withprovenance: "bench"and its original write date. Bench must stay ≥4 deep before any pack-writing burst is scheduled;lintwarns when it drops below.
Stage 3 — Lint (automated, ~2 min)
blo lint weekly/drafts/2026-W38.json — a local script that shells to claude -p (headless Claude Code on the Mac, existing subscription creds) for the semantic checks and pure code for the mechanical ones:
| Check | How |
|---|---|
| Schema validation (envelope, both voices present, id format) | zod, pure code |
| Dedup vs all 144+ existing voice-texts | embedding cosine via claude -p judgment pass over nearest candidates; flags >0.85 similarity |
| Banned idioms (datable 2021–23), “Stop…” sassy opener counter, tiny/small/today ration | regex + wordlist, pure code |
| “Arrival” claims in any notification copy | wordlist |
| Voice-register check (sassy never teases the wound on heavy themes) | claude -p flag-only — it outputs PASS/FLAG + the offending span, and is prompt-forbidden from suggesting replacement phrasing |
Lint output is advisory except schema + dedup, which block. The linter never emits candidate text — its prompt ends with “you must not propose alternative wordings.”
Stage 4 — Publish (one command, ~2 min)
blo publish 2026-W38:
- Refuses unless the draft file has ≥1 commit authored outside the seed cron (the mechanical “a human touched this” guard).
- Moves draft →
content/weekly/current.json, setspublishedAt, re-runs lint. - Renders the branded share asset (Remotion still-frame or a small Satori/resvg script — wordmark, two-voice motif, date, App Store QR with per-format campaign token, newsletter URL footer) into
out/2026-W38-{story,square,dark}.png. ~already-built pattern from Oannes/Remotion. git commit -m "weekly: 2026-W38" && git push→ Pages deploys → live by 18:00 Berlin.- Posts a confirmation + the share PNGs to Discord.
Stage 5 — Outbound post (human, ~10 min)
Joshua posts the identical story-format asset to @boldlittleoracle (Instagram) with its campaign-tagged QR. Part of the ritual, not a separate commitment. (Day-60 rule: if the Instagram token attributes zero installs, the same slot swaps to Pinterest.)
Pack production reuses stages 2–4 with blo lint packs/missing-cards/cards.json; publishing a pack = commit manifest + cards + bump packs/index.json.
6. Notification Flow (local-only, no server, no tokens)
v1.2 binary
├── Weekly reminder: expo-notifications scheduled local notification,
│ trigger: weekly, default Sun 19:00 Europe/Berlin, user-adjustable in Settings.
│ Copy: sampled from cached notifications.json "weekly" lines
│ ("Obadiah's been writing. Come see.") — invitational, never asserts arrival.
│ Card is published by 18:00; fetch runs on open, so it's there when they arrive.
│ Deep link: blo://weekly
├── Daily nudge (existing feature, overhauled): user-pickable time,
│ line sampled from ~30 rotating hand-written "daily" lines, deep link blo://draw.
└── Rescheduling: on every foreground open, the app re-schedules the next 4 weekly
+ 7 daily notifications with freshly sampled lines (iOS local-notification
queue is finite; topping up on open keeps copy rotating without background tasks).
- No push tokens, no APNs key, no Expo push service, no token registry. A push token is a device identifier; the strategy forbids it. BGAppRefreshTask is not used for anything.
- Notification permission stays opt-in with the existing soft-ask.
7. Offline Fallback & Client Fetch Logic
lib/remoteContent.ts (~2 dev days incl. tests), the only network code in the app:
onForeground(): # AppState listener → 'active'
if now - lastFetchAt < 24h: return # throttle
for path in [weekly/current.json, deck/base.json, packs/index.json (+owned pack manifests)]:
GET path with If-None-Match: cachedEtag (8s timeout, no retry storm — one attempt)
304 → touch cachedAt
200 → validate: schemaVersion known? minAppVersion ≤ appVersion? zod-parse ok?
(pack cards.json also: sha256 matches manifest?)
valid → AsyncStorage.set({etag, cachedAt, body}); swap into memory atomically
invalid → keep cache, log nothing (no analytics), show nothing
network error → silent; use cache
lastFetchAt = now
resolveDeck():
base = cached deck if version > bundled.version else bundled base-deck.json # permanent floor
packs = cached packs where sku ∈ ownedSkus
weekly = cached weekly if week is current ISO-week else none # stale week's card
# simply doesn't show
Rules:
- Never block first render on network. Fetch is fire-and-forget after UI mount.
- Weekly card UI states: fresh frame / bench frame / (no current card cached) → the tile simply shows the daily draw; no error state, no spinner shame.
- Deterministic daily draw: seed =
sha256(installSalt + localDateString)→ index into merged draw pool, excluding the trailing 14 drawn ids (persisted ring buffer).installSaltis a random UUID generated on first launch, stored only on-device — gives per-user variety with zero identifiers leaving the device. - Airplane mode end-to-end test is a release-blocking checklist item for v1.2.
8. Versioning & Kill Switch
| Concern | Mechanism |
|---|---|
| Content shape change | schemaVersion int; app hard-skips unknown versions (keeps cache + bundle) |
| Content needs newer app | minAppVersion semver per file; old binaries skip silently |
| Deck/pack content revision | version int, monotonic; app replaces cache only on increase |
| Integrity (packs) | sha256 in manifest checked before swap |
| Bad card live NOW | git revert && git push → Pages redeploy ≈ 1 min; 5-min max-age on weekly path caps exposure; clients pick it up next foreground open |
| Whole-layer kill switch | content/meta/flags.json { "remoteContentEnabled": false } fetched first; when false the app behaves exactly like v1.1 (bundle only) — the escape hatch if the CDN layer ever misbehaves in the field |
| Binary compat window | Content repo CI (a pre-push git hook, not cloud CI) runs zod schemas for every schemaVersion still in the field |
Git is the audit log: every card that ever shipped, who wrote it (always Joshua), and when, is git log on the content repo.
9. Build Plan — S/M/L Milestones
All solo-dev hours; “d” = focused day ≈ 6h. Total to v1.2 submission: ~7–10 days of dev + the writing evenings, matching the strategy’s estimates.
S — Sensor + skeleton (do first; ~2.5 d) — NOW items, zero binary changes
| Task | Est |
|---|---|
Content repo + Pages project + custom domain + _headers |
2 h |
| Count Worker + WAE binding + verification on current plan (curl test, SQL query back) — v1.2 gate-blocker | 4 h |
| KV-counter fallback (only if WAE verification fails) | (2 h contingency) |
Seed-brief cron (blo-seed.ts + launchd + Discord webhook) |
3 h |
blo CLI skeleton: new-week, lint (schema+regex checks), publish (commit/push) |
4 h |
| Baseline capture: ASC metrics snapshot doc; SBP enrollment check | 1 h |
| Governance docs (disclosure posture + AI bright line, one page) | 1 h (writing) |
Exit criteria: curl content…/weekly/current.json returns a test card; WAE query returns counts; one dry-run publish end-to-end.
M — v1.2 binary (~5–6.5 d dev) — one binary, one review cycle
| Task | Est |
|---|---|
lib/remoteContent.ts: foreground-only fetch, ETag+AsyncStorage cache, zod validation, minAppVersion/schemaVersion guards, flags.json kill switch, offline floor |
2 d |
| Two-frame weekly card UI (fresh/bench provenance rendering) | 0.5 d |
| Deterministic daily draw + anti-repeat ring buffer | 1 d |
| Branded share card (render on-device via react-native-view-shot; QR with per-format campaign token; newsletter footer) | 1–1.5 d |
| Notification overhaul: fixed weekly local reminder (user-adjustable), rotating lines from cached JSON, deep links, reschedule-on-open | 1–1.5 d |
First Printing archive screen (read-only, lifetime holders, reads retired[]) |
0.5–1 d |
| Entitlement explainer screen (dormant) | 0.5 d |
| Claim surgery metadata: listing EN+de-DE, paywall, FAQ (count-Worker + QR disclosures, AI bright line), App Review notes | 0.5 d (copy mostly pre-drafted in NOW) |
| Airplane-mode + stale-cache + kill-switch QA pass | 0.5 d |
Parallel writing (not dev time): 6-card bench before launch; Second Printing refresh (4–6 evenings); sassy-grief register decision + outside readers.
Exit criteria: v1.2 submitted with all claim surgery in one pass; weekly ritual runs twice end-to-end (bench cards) before submission so launch week isn’t the first live test.
L — Post-launch, gated (dev only when gates open)
| Task | Gate | Est |
|---|---|---|
| Missing Cards pack plumbing: pack fetch in remoteContent, preview rendering, expo-iap non-consumable purchase + explainer wiring, ASC SKU | Weekly-open ≥30% ×4 wks | 1–1.5 d dev (writing is the real cost: 10–12 evenings) |
| Seasonal pack (reuses 100% of pack plumbing) | Missing Cards ≥5 buyers/60 d | 0.5 d + writing |
| Reflection note + uncapped history (on-device only) | v1.3 | 2–3 d |
| Pick-a-card 3-spread | v1.3 | 2–3 d |
| Paper Oracle Stripe preorder page (USt-on-Anzahlung set-aside, Widerruf terms) | ≥25 waitlist emails + landed-cost sheet + LUCID | 1–2 d |
| Tier-1 HMAC entitlement Worker | only on evidence of real piracy | 2–4 d, parked |
| Widget | weekly-open gate green post-v1.3 | parked |
Recurring load (steady state)
- Sunday ritual (card + lint + publish + Instagram post): 1.5–1.75 h/wk — the only permanent commitment.
- Monday gate-metrics pull (WAE query + ASC row into tracking sheet): 10 min, cron-assisted.
- Infra cost: €0/mo (all Cloudflare free-tier by orders of magnitude; local notifications; no push service; LLM lint pennies on existing Claude access).
10. Open Items Carried From Strategy (unresolved on paper, tracked here)
- WAE-on-free-plan verification is milestone S’s gate-blocker; KV fallback is specced.
- Install→unlock 2% stays provisional until week-2 ASC data replaces it.
- Sassy-grief register proves out only with outside readers on real Missing Cards.
- App Review acceptance of the one-pass claim surgery: review notes drafted in NOW, but reviewer discretion remains.
installSaltfor the deterministic draw never leaves the device — confirm this phrasing is covered by the FAQ tracking answer before v1.2 metadata freeze.