🔮 Bold Little Oracle ▸ docs/overnight/blo-content-sprint/LIVE-CONTENT-SYSTEM.md
updated 2026-07-16

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:

  1. Offline floor: the app never requires network. Bundled base-deck.json renders everything; remote content is additive.
  2. Foreground-only fetch: the weekly fetch fires only in the app-foreground handler (AppStateactive), never BGAppRefreshTask, never notification prefetch. This makes the weekly-open gate count engagement, not the OS scheduler.
  3. No identifier leaves the device. The only server-side record anywhere is (path, status-class, ISO-week) counts.
  4. 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"
  }
}

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 } ]
}

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": "…"
}

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;
  }
};

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:

Mechanics:

  1. 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).
  2. On launch and on Restore, the app caches owned product IDs in AsyncStorage (ownedSkus: string[]). Renderer rule: pack card bodies render iff manifest.sku ∈ ownedSkus OR card.id ∈ manifest.preview.
  3. Lifetime early-access: provenance of ownership = existing lifetime SKU in ownedSkus; if held and today ≥ lifetimeEarlyFrom, the pack is browsable/purchasable a week early. Pure client logic.
  4. 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.
  5. Upgrade path if ever needed (not built now): Tier-1 HMAC Worker verifying the StoreKit JWS (jwsRepresentationIos from 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)

Stage 2 — Writing (human, Sunday, ~45–60 min)

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:

  1. Refuses unless the draft file has ≥1 commit authored outside the seed cron (the mechanical “a human touched this” guard).
  2. Moves draft → content/weekly/current.json, sets publishedAt, re-runs lint.
  3. 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.
  4. git commit -m "weekly: 2026-W38" && git push → Pages deploys → live by 18:00 Berlin.
  5. 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).

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:


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)


10. Open Items Carried From Strategy (unresolved on paper, tracked here)

  1. WAE-on-free-plan verification is milestone S’s gate-blocker; KV fallback is specced.
  2. Install→unlock 2% stays provisional until week-2 ASC data replaces it.
  3. Sassy-grief register proves out only with outside readers on real Missing Cards.
  4. App Review acceptance of the one-pass claim surgery: review notes drafted in NOW, but reviewer discretion remains.
  5. installSalt for the deterministic draw never leaves the device — confirm this phrasing is covered by the FAQ tracking answer before v1.2 metadata freeze.