The decisions log
Every judgment call where the spec was silent or wrong, with its reason, including the ones later reversed
Every deviation and judgment call where SPEC.md was silent, ambiguous, or contradicted
by reality: the smallest solution consistent with the decisions table (SPEC §1), recorded
at the moment it was taken. Golden rule 7 of CLAUDE.md is what produces this file —
spec deviations are logged, not improvised silently.
This is a condensed edition of the working log, prepared for publication: each entry is shortened to the decision and its reason, the one Dutch-language entry (P4-5) is translated, and operational specifics — credentials, secret names, infrastructure endpoints, exact admin-grant mechanics — are summarised rather than reproduced. Entries are shortened, never rewritten: no reasoning has been improved after the fact.
Entry numbering follows the original. Two entries were both numbered D-30 in the working log; they appear here as D-30a and D-30b.
Phase 0 — Core extraction (owlypost repo, 2026-07-08)
This phase restructured the open source core into a publishable workspace package. It was reverted in full the next day (P4-6) — the entries are kept because they are what the reversal reversed.
| # | Decision | Why / consequence |
|---|---|---|
| P0-1 | One package @owlypost/core with subpath exports for the concrete adapters; heavy dependencies as optional peers. | SPEC names one package; the root entry imports no SDK, so the cloud pulls only what it uses. |
| P0-2 | Existing interface names kept (Storage, Llm, EmailProvider), SPEC's names exported as aliases; token usage stays snake_case. | Zero churn on a ~50-method interface; it is persisted in that shape. The cloud maps at its own boundary. |
| P0-3 | The core storage contract includes two tables SPEC omits: section_feedback and digest_schedule. | Phase 1 must give both tenant_id, RLS, adapter scoping and gate coverage. |
| P0-4 | Feedback is an upsert only; profile synthesis stays deferred to digest time. | A thumbs click stays a cheap write, never an LLM call. |
| P0-5 | Mail adapter takes (to, subject, html); subject and HTML composed inside the core; no mailer configured = email disabled. | Replaces env-reading orchestration. Delivery failure only logs; the digest stays ready. |
| P0-6 | Core config carries language, timezone, retention, site URL and recipient — but not ingest cadence. | Scheduling is a shell concern (self-host cron, cloud dispatcher). The core never schedules itself. |
| P0-7 | Tag-based publish flow rather than changesets. | Single package, single maintainer. |
| P0-8 | Development consumes the core as TypeScript source; publishing swaps to a built dist. | Keeps the repo's no-build-step DX. |
| P0-9 | Migrations, scheduler and scripts stay in the self-host app, not the core package. | They are deployment glue; the cloud has its own. |
| P0-10 | New core dependency for OPML parsing; export side is hand-built XML with escaping. | Completes the self-host → cloud migration path promised in SPEC §6. |
| P0-11 | The self-host Docker image no longer prunes dev dependencies. | The worker executes core TypeScript source directly; pruning would remove what it needs. |
| P0-12 | The root .env stays the canonical config location, loaded explicitly. | Preserves the documented self-host UX unchanged. |
| P0-13 | Phase reports live in the private cloud repo, not the public one. | Internal review notes don't belong in a public repository. |
Phase 1 — Multi-tenant foundation (cloud repo, 2026-07-08)
| # | Decision | Why / consequence |
|---|---|---|
| P1-1 | The core is consumed from a sibling checkout until a first release; CI checks out both repos. | Publishing before review would be premature. Superseded by P4-6: a pinned submodule. |
| P1-2 | A local database stack is the Phase 1 dev/test database; the production project connects at deploy time. | Acceptance runs fully local and deterministic; no production secrets in play. |
| P1-3 | No separate schedule table in the cloud: cadence lives on the tenant row and is weekly-only in v1. | Resolves P0-3's table-vs-columns question in SPEC §3's favour with the smallest schema. |
| P1-4 | The preference profile is keyed by tenant, one row each. | The self-host single-row shape doesn't fit multi-tenant. |
| P1-5 | The anonymous role gets zero grants on all tables; internal tables are service-role only. | Defence in depth beyond SPEC §3.1: signed-out browsers can't even probe table shapes. |
| P1-6 | Background functions are guarded by a shared secret header; the dispatcher only fans out and never executes pipeline work. | The functions are public URLs and SPEC is silent on authenticating them. |
| P1-7 | The past-due grace period lives as a constant until entitlements land in Phase 2. | [REVIEW] value from SPEC §4; it moves behind getEntitlements per golden rule 4. |
| P1-8 | "Never digest twice per period" is enforced shell-side from the job-runs table. | Avoids querying a core table outside the adapter (golden rule 2). |
| P1-9 | The core gained an injectable fetcher (a commit in the public repo) for a network-free leakage test. | An extension point rather than a copy — but still a core change made for the cloud. Reverted in P4-6; tests now patch fetch instead. |
| P1-10 | Usage events record run-level events now; per-call cost metering lands in Phase 2. | Metering belongs with entitlements; recording runs keeps the table warm and observable. |
Phase 2 — Accounts, billing, access (cloud repo, 2026-07-08)
| # | Decision | Why / consequence |
|---|---|---|
| P2-1 | Tenants are provisioned by the checkout webhook, not at signup; users without a tenant land on billing. | SPEC §6.2 wants a payment method from minute one, and the status constraint leaves no valid pre-payment state. Abandoned checkouts leave only an auth user. |
| P2-2 | Trial end is set to now+14d at provisioning; later subscription events are the source of truth. | The checkout event doesn't carry it, and fetching the subscription adds a network dependency for a display-only value. |
| P2-3 | Webhook handlers are a guarded state machine: every transition declares its allowed from-statuses; expired is terminal. | Makes out-of-order and duplicate deliveries provably harmless (tested). |
| P2-4 | Model pricing is a constants map in the metering module; unknown models meter at the most expensive known rate. | Cost accounting is not a plan limit. Over-metering an unknown model fails safe for margin. |
| P2-5 | The monthly LLM budget window is the UTC calendar month, not the tenant timezone or the billing cycle. | Smallest implementation; the discrepancy is hours per month on a soft internal cap. |
| P2-6 | Manual-digest rate limit: a 5-minute cooldown, independent of the daily quota. [REVIEW] | SPEC §5 requires the independent limit but names no value. |
| P2-7 | Webhook signature verification lives in its own module; the route is thin glue. | Unit-testable with no framework runtime and no network. |
| P2-8 | Auth and billing UI stay deliberately functional-only. | Phase 3 owns the polish; building it twice would be waste. |
Phase 3 — Polish and onboarding (cloud repo, 2026-07-08/09)
| # | Decision | Why / consequence |
|---|---|---|
| P3-1 | Bundle selection seeds the preference profile by appending each bundle's text, deduped; later manual edits stay authoritative. | SPEC §6.3 wants the first digest to have direction already. |
| P3-2 | The first digest reuses the manual-digest machinery, so it counts toward trial quota as SPEC §6.5 requires. | One code path for "digest now"; scheduled idempotency stays intact. |
| P3-3 | The digest sender is env-overridable; delivery failures only log. | A broken mailer must never take a digest down. |
| P3-4 | Core perf change (public repo): summary generation runs a worker pool. | First-run onboarding went from ~9 min to ~2–3, and self-host benefits equally — but it was still a core change driven by a cloud metric. Reverted in P4-6. |
Phase 4 — Operational hardening (cloud repo, 2026-07-09)
| # | Decision | Why / consequence |
|---|---|---|
| P4-1 | Deletion after expiry wipes all content but keeps the tenant row, membership and billing/usage records. | Billing and audit records are legitimate-interest retention; the privacy promise covers reading behaviour and content, which is what the wipe removes. Flagged in the privacy-page review. |
| P4-2 | An expired_at column set by the webhook starts the 30-day clock; admin revival clears it. | SPEC gives no expiry timestamp, and deriving one from payment events at sweep time would be fragile. |
| P4-3 | Monitoring is an hourly scheduled function with email alerts, plus a health endpoint for external uptime checks. No third-party APM. | The smallest thing that satisfies SPEC §12 for a one-operator product; payment-webhook health is monitored on the provider's side. |
| P4-4 | Go-live steps that need real accounts and DNS are codified as a checklist, not half-automated. | One-time actions with real credentials: a checklist is auditable, scripts against live accounts are not. |
| P4-5 | Owner decision 2026-07-09 (revises SPEC decision 2): the core is not published to a package registry. The cloud pins a commit of the public repo as a git dependency; Renovate bumps the pin. Publish workflow, publish config and the build step are removed. | A published package is unusual for a self-host app and creates library expectations; the upgrade flow (one PR) stays identical. (Translated from the Dutch original.) |
| P4-6 | Owner decision 2026-07-09 (supersedes P4-5 and the entire Phase 0 extraction): the core stays exactly as it is — a flat single-user app on main. The workspace restructure is reverted in full; the cloud consumes the untouched repo as a pinned git submodule through one cloud-owned bridge package, the only importer of vendor internals. Per-tenant configuration reaches the core by env injection for the duration of a run, behind a process-wide mutex. Accepted trade-offs: (a) metering moves to a per-run budget gate, the digest call metered exactly and summaries estimated high, overshoot ≤ one run; (b) the first digest is slow again, because the worker-pool fix lived in the reverted core commits; (c) the env mutex serialises concurrent in-process runs; (d) OPML and the feedback helper never existed in the flat core and are cloud-owned bridge code, not copies. | Implements the three owner requirements literally: the core is never changed for the cloud, upgrades arrive as one bump PR with all gates, and there is no fork or copy. The core's own seams — a storage parameter, lazy env reads, provider injection points — make the untouched-core model workable without touching it. |
Design system rebuild — "Warm Companion" (2026-07-16)
| # | Decision | Why / consequence |
|---|---|---|
| DS-1 | The monospace font is dropped entirely; hosts, cron expressions and error strings render in the body sans. | The design document locks the pairing at two families and names no third. Consequence: those columns no longer align. |
| DS-2 | A minimal status palette is derived (one danger, one success, warnings reuse the accent), with tints made by colour-mixing so no further hexes enter the system. | The design document defines no semantic colour, and accent-only would make an error indistinguishable from a warning. |
| DS-3 | The accent is never used for text; warning copy uses a deepened mix, links are ink plus underline. | The accent measures ~2.9:1 on the page background — it fails AA as text. This keeps the locked white-on-accent CTA intact while staying readable. |
| DS-4 | Hover and press washes are colour-mixed tints of an existing token, not new greys. | The design calls for a slight tint but ships no wash token; tinting keeps one colour family. |
| DS-5 | Fonts load through the framework's font pipeline rather than the local files the design names. | Those files don't exist in the repo, and self-hosting removes a render-blocking CDN import. Revisit when licensed files land. |
| DS-6 | Base element styles and prose styles move into explicit cascade layers. | Unlayered CSS outranks utility classes, so an unlayered heading colour silently beat every utility — it made a headline invisible on the dark panel. |
Auth hardening (2026-07-16)
| # | Decision | Why / consequence |
|---|---|---|
| A-1 | Auth failures travel as an error code in the query string, mapped to copy server-side; the provider's own message only reaches the log. | Two problems, one fix: the provider's wording is developer-facing, and a free-text error parameter was attacker-controllable — anyone could hand a reader a link that rendered phishing text in our own banner. Codes make that inert. |
| A-2 | Signup never reveals whether an address already has an account; both paths land on the same check-your-mail screen. | A deliberate deviation from the task text: confirming existence makes the user list enumerable. Reset already used neutral copy, so signup now matches. |
| A-3 | Route protection moves into middleware driven by a pure route table; the per-page tenant gate stays as a second layer. | Page-level gating was opt-in, so every new file was public until someone remembered. Protected is now the default, at no extra round-trip. |
| A-4 | An unconfirmed address resolves to no current user. | Verification rested entirely on one hosted toggle — no defence in depth, no test. |
| A-5 | The next parameter is removed from the confirm route rather than validated; the destination follows from the link type. | It was an open redirect. A parameter that doesn't exist can't be abused. Cost: a signed-out deep link lands on the dashboard. (Revisited in D-55, which adds one validated implementation.) |
| A-6 | Welcome-mail idempotency uses a table with a primary key, not user metadata. | Metadata is writable by the user it describes and can't settle a double-click race; a primary key can. |
| A-7 | Auth mail reads its own API key variable, falling back to the shared one. | The per-run env injection deletes the shared key during a tenant run, so a welcome mail sent in that window would vanish silently. |
| A-8 | Auth mail templates use web-safe font stacks, table layout and literal hex. | Mail clients strip @font-face and parse neither custom properties nor colour-mix, so the design tokens are pre-resolved. The templates are static files and are kept in sync by hand. |
| A-9 | Privilege escalation closed: the update grant and whole-row update policy on the tenants table are removed. Reads stay. | The policy's own comment claimed those columns were only ever written by the server — nothing enforced it. Any signed-in user could have granted themselves free, unmetered service. The leakage suite only tested cross-tenant isolation and stayed green over it; it now covers within-tenant column authority too. |
| A-10 | The password minimum is 8 everywhere: config, a server-side check, and one constant feeding the input attribute. | The UI said 8, config said 6, and nothing checked server-side — so the real rule was 6 and the attribute was decorative. |
| A-11 | Mail resend frequency tightened to 60s, plus a cooldown cookie. | The default let the new "send it again" button be held down. |
| A-12 | Auth mail links render the per-request redirect target rather than the project's single site URL, with an allow-list. | One hosted project serves both local development and production; pinning the templates to one URL forces a choice between mailing customers a localhost link and making local testing impossible. Verified against a local auth server, including that a foreign host is dropped. |
| A-13 | The root route forwards a confirmation link that carries a token but no path. | When a redirect target is absent or not allow-listed, the provider substitutes the bare site URL — right host, no path, and the reader simply cannot confirm. Found by sending real mail through a local auth server, not by reading docs: one allow-list typo would otherwise mail dead links on launch day. |
Onboarding rebuild + account lifecycle (2026-07-17 … 07-24)
Scope, hosted checkout, weekly-only cadence and all lifecycle terms locked with Esmee in session; design source was a separate concept study.
| # | Decision | Why / consequence |
|---|---|---|
| D-1 | Tenants are provisioned at email verification with a new incomplete status (supersedes P2-1); checkout transitions incomplete → trialing. | Wizard steps 1–4 now run before payment and write tenant-scoped core tables; a staging store would duplicate the adapter surface and sit outside RLS and the leakage test. incomplete is excluded from dispatch, so no digest can exist before payment. |
| D-2 | The wizard is weekly-only, and payment hands off to hosted checkout instead of the mockup's inline card fields. | Monthly cadence does not exist in the core, and a core change is a separate product decision. Hosted checkout was already built and brings tax, SCA and method handling. |
| D-3 | The default schedule becomes Monday 08:00, superseding SPEC §3's Sunday 17:00. | The design's "Monday morning digest" is the product voice now; existing tenants keep their own values. |
| D-4 | "Pick up to five topics" is a named UX constant, not a plan limit; enforcement stays with entitlements. | Golden rule 4 forbids quota numbers outside the plan limits. The five-cap is editorial focus. |
| D-5 | Lifecycle clocks run from tenant creation (= email verification); unverified signups receive no lifecycle mail. | Before verification we cannot legitimately mail anyone, and there is no tenant to track. |
| D-6 | Incomplete onboarding is hard-deleted on day 14, goodbye mail first, no 30-day retention. | Never a paying customer, so the billing-retention rule has nothing to retain. An activity grace prevents deleting someone mid-wizard. |
| D-7 | A deleted account fails login with the same neutral message as a wrong password. | An "account gone" message is an enumeration oracle, contradicting A-2. |
| D-8 | The "still want this?" flow applies only to accounts that actively cancelled; failed-payment expiries keep the plain sweep. | Mailing that at someone whose card failed invites accidental data loss without a conscious decision. |
| D-9 | One predicate covers both wipe clocks. | Satisfies the locked term and SPEC decision 13 at once. |
| D-10 | The welcome mail moves from verification to trial start, guarded by a lifecycle-email table keyed per tenant and kind. | It states the trial end date, which doesn't exist at verification. Same primary-key-as-idempotency pattern, now covering all six lifecycle mails. |
| D-11 | Card brand and last four are captured best-effort by the webhook and read from the database afterwards. | The completion page must render instantly and repeatedly without payment-provider calls; a failed capture degrades, never blocks. |
| D-12 | The trial-ending mail runs off our own daily job, not the provider's event. | The provider fires three days out, the spec says two. Our own clock plus the idempotency guard gives exact timing. |
| D-13 | Cancelled tenants keep being scheduled until the subscription actually ends. | Implements SPEC §4's access table; the previous code skipped them entirely, so cancelling would have stopped digests immediately. |
| D-14 | The paste-a-URL field classifies the core's detection prose into three states in one tested place. | The design needs states, the core returns prose — so a core upgrade that rewords a message fails a unit test instead of silently degrading UX. |
| D-15 | Wizard preferences render into a managed marker block inside the freeform profile; digest language is also stored on the tenant and injected per run. | The core's profile is freeform markdown (golden rule 1 — no schema change); markers make repeat writes safe without clobbering hand edits. |
| D-16 | The summary screen is the last step before payment; after checkout the reader goes straight to the dashboard. | Esmee, during the first live walkthrough: one payoff screen, not two near-identical ones around the payment. |
| D-17 | Deletion semantics revised. Three paths: incomplete onboarding erased at day 14; stopped accounts lose reading content after 30 days but keep sources, topics, profile and login for a year; and a new self-service "delete my account" erases immediately, cancelling the subscription with it. One shared routine behind all three. | Esmee, after the live lifecycle walkthrough: a cancelled account must stay easy to come back to, while an explicit deletion is honoured at once. |
| D-18 | The core's standard 30-day housekeeping now runs after every scheduled ingest, for every tenant. | It existed in the core but was never invoked cloud-side. Piggybacking on ingest needs no new schedule. |
| D-19 | A stale job claim is finalised by the next claim, in the same statement. | Production incident: a runner killed mid-run left a running row forever, which would trip the stuck-jobs alarm hourly and misrepresent run history. Release-on-claim needs no extra job and stays atomic. |
| D-20 | "Run a digest now" hands the work to a background function, falling back to in-process only where no background runtime exists. | Production incident: the request lambda's ~26-second cap killed the run mid-pipeline twice, and the reader saw an application error instead of a digest. |
| D-21 | Settings gains account self-service: name, email, password. A password change requires the current password; an email change uses the provider's double-confirm flow and syncs the payment customer afterwards. | SPEC is silent on account management (golden rule 7). Verifying the current password matters because the provider's update call alone would let any open session take the account over. |
| D-22 | A single "product news" toggle, stored on the tenant, strict opt-in, default off. | Esmee's explicit choice over the softer route local law allows. Nothing consumes the flag yet — no broadcast mail exists. |
| D-23 | The wizard's digest preferences become editable in Settings, writing through the same managed block. | They were set once during onboarding and afterwards only reachable by hand-editing marker comments inside markdown. |
| D-24 | Serverless functions deploy from a pre-bundled directory that inlines each function's entire dependency graph. | The weekend's root cause: the platform bundler shipped the workspace package as raw source, so every core-touching function crashed at cold start — after the platform had already acknowledged the request, hence invisibly. Proven fixed on a scratch site before touching production. |
| D-25 | Backlog cost containment: the trial feed cap drops to 2, and a new ingest_window_days limit drops items published more than N days ago at ingest (paid 30, trial 7). Enforced shell-side in the adapter, wired per run from entitlements — the core stays untouched. | Production incident: the first trial customer's checkout ingest pulled four feeds' full backlog and exhausted the trial LLM budget within 15 minutes, blocking the pipeline for the rest of the month. A feed cap alone doesn't bound cost — items per feed vary by orders of magnitude — but an age window does. |
| D-26 | Alert hygiene: failure and stuck-job checks become edge-triggered (one tick of lookback), the budget check reports only the crossing tick, and a budget-blocked run is gated before claiming a job so it leaves no failed row. The silent-tenant check deliberately stays level-triggered. | The aftermath of D-20/D-24 plus one budget-exhausted trial produced hourly alert mail for a full day after everything was already fixed. Esmee: one notification per fact. Stateless, by matching the lookback to the monitor's own cadence. |
| D-27 | Domain move to the hyphen-free owlypost.com, app on a subdomain, mail split by provider — dropping SPEC §11's separate sending subdomain. | The reputation argument for a sending subdomain assumes bulk mail; a digest goes to the paying owner's own verified address, which behaves like transactional mail. Done before launch, when the change is a rename rather than a migration. |
| D-28 | The paid plan is named Early Owl, resolving the [REVIEW] marker on SPEC decision 10. Invoices stay provider-native. | One database row and one product name, changeable without a deploy. Flagged at decision time: "Early" dates the plan for later joiners. Provider-native invoicing avoids owning VAT-correct document generation. |
| D-29 | No promotion codes, ever. One product, one price; the promo field is removed from checkout. | The launch price is itself the early-adopter deal. A promo field with no valid codes is dead UI that invites coupon hunting. |
| D-30a | A manual digest never suppresses the scheduled one: the never-twice guard now reads scheduled-digest events only. | Incident: a manual test digest at 16:08 silently suppressed the scheduled 17:35 digest. The schedule the user set is the one promise the product always keeps. Trade-off accepted: a test day can yield two mails. |
| D-30b | International checkout: the hardcoded payment-method list is removed so the provider picks per customer location and currency. VAT runs under the EU small-seller scheme until the cross-border threshold, then OSS. | A Dutch-centric method list would show Dutch banks to a German customer and nothing local to anyone else. Dashboard-managed methods make the payment offering an ops knob instead of a deploy. |
| D-31 | Launch-day audit hardening: the three cron functions now require the shared secret with a timing-safe compare and are triggered by an authenticated workflow; a cancel during dunning maps correctly; a paused subscription maps to past-due; the checkout handler reads its price before the one-shot activation; and the active-price lookup gets a real ordering plus a partial unique index. | All five came out of the audit. The most serious: schedule-registered functions proved publicly invocable over plain HTTP, exposing ops status and free sweep-triggering — verified against production, then closed. |
| D-32 | Digest catch-up dispatch: a tenant is due when its most recent digest moment lies within the past six hours, and the never-twice guard is re-keyed to that occurrence rather than a rolling window. | Incident: a Wednesday 10:00 digest silently never sent. After D-31 moved ticks to a best-effort scheduler, gaps of 1–3 hours meant the forward-only 15-minute window needed a tick that never came — and a late tick could not catch up, so the tenant simply skipped a period. Every cron run reported success, so nothing alarmed. |
| D-34 | Ingest catch-up dispatch: the same D-32 fix, finally applied to ingest, with its own occurrence-keyed guard so catch-up ticks don't re-fetch feeds. | Two tenants tripped the silent-tenant warning for days. Same failure mode as D-32, in fact more fragile — one 15-minute slot every six hours instead of one window a day — and the skip is dispatch-side, so it leaves no job row at all and strands a hash-determined subset of tenants rather than the whole fleet. |
| D-33 | Onboarding polish: welcome screen rewritten around "train your owl", status noise removed, and OPML import deleted entirely — wizard, sources card and the cloud-owned bridge module with its dependency. The curated tech topic splits in two. | Live-flow review. The OPML removal is a deliberate deviation from SPEC §6's migration path, to be reintroduced app-side if migration demand ever shows up. |
Admin section (2026-07-28)
| # | Decision | Why / consequence |
|---|---|---|
| D-35 | An /admin section, a logged deviation from SPEC §11 ("admin UI" was out of scope). Admin rights live in their own table, granted only out-of-band — there is no in-app path. Reads are service-role rendered behind an admin gate, with select-only RLS as defence in depth and no authenticated write path (A-9 stands). Status changes are curated actions, not a free dropdown: comping is one guarded update allowed only from statuses with no future-charge risk, plus a kill-switch toggle. | With live customers, comping and cost-watching became routine ops. One operator and live billing means the payment provider stays the single writer of payment-driven statuses, and putting the from-guard inside the update statement makes admin actions atomic against concurrent webhooks. |
| D-36 | A payments mirror table written by the paid-invoice webhook, idempotent on the invoice id, plus per-tenant "earned this month / total" = payments − model spend. Deliberately gross: VAT and processing fees are not subtracted. | The margin question from SPEC §5 is now answerable per customer at a glance; the webhook previously read the amount and threw it away, so no revenue data existed shell-side at all. The payment provider remains the financial source of truth — this is a display mirror. |
| D-37 | Nightly database backups, self-built. SPEC and the runbook assumed the hosting plan's daily backups and point-in-time recovery; the production project turned out to be on the free plan, which has none — with live paying customers. Rather than upgrading at a cost out of proportion to revenue, a nightly scheduled job dumps the relevant schemas, encrypts the dump symmetrically and stores it as a private artifact. Accepted: worst-case ~24h data loss and a manual restore, documented in the runbook. Revisit when revenue supports it. | Golden rule 7: a spec deviation, logged. Zero-cost fix for the single worst risk on the infrastructure list — total data loss with no recovery path. |
| D-38 | The LLM budget stops being invisible to the tenant: a percentage-only card in Settings (never cents, since the cost is included in the price), a dashboard banner when exhausted, and one notice mail per month when a scheduled run is blocked, guarded by a claim table. | SPEC §5 says a capped run must block with a friendly message and never silently degrade — but since D-26 made budget-blocked an expected non-failure, a blocked tenant's digests simply stopped arriving with zero user-facing signal. Percentage-only keeps the [REVIEW] budget numbers freely tunable without user-facing promises in cents. |
Monitor (2026-07-29 … 08-01)
| # | Decision | Why / consequence |
|---|---|---|
| D-39 | The silent-tenant check skips tenants whose budget is exhausted; it stays level-triggered for real outages. | A budget-blocked tenant leaves zero job rows, which is indistinguishable from an outage — so it mailed hourly until month end. The crossing is already reported exactly once and the tenant already gets a notice mail. |
| D-48 | The pipeline records why it stayed quiet, and the monitor reads that instead of re-deriving it. Deliberate skips are written as a skipped job row with a reason; the silent-tenant check warns only when nothing explains the silence; and a tenant whose feeds are all paused is no longer counted as silent. | Third recurrence of the same false alarm (after D-34 and D-39). The root cause was not a missing exception but duplication: the runner already returned a typed reason for every deliberate skip and threw it away, so the monitor kept a second, drifting copy of the same knowledge. Recording it at the source retires the exception list. |
| D-52 | The edge-triggered lookback widens from one hour to four. | D-26 sized the window to the nominal cadence, assuming hourly ticks. The scheduler is best-effort and observed gaps ran to 3h37m, so anything landing in a skipped hour was dropped permanently and without a trace. Accepted cost: exactly-once is gone — a persistent failure can now produce up to four mails. The clean fix anchors the window to the previous tick's real timestamp, which needs a migration against a live database; the constant closes the hole today. |
Admin insights (2026-07-29)
| # | Decision | Why / consequence |
|---|---|---|
| D-40 | A "this month" card on /admin — fleet model spend, average per running account, and the funnel as status badges — computed purely from rows the page already loads. Plus a monthly snapshot table filled by the existing daily job: cost and revenue recomputed every run, funnel counts frozen once a month closes. Nothing reads the history yet. | The per-tenant figures existed but there was no fleet total, so "what do all users cost me this month" needed a calculator. Statuses get overwritten in place, so a closed month's funnel is otherwise unreconstructible — collect first, display later. |
Digest feedback loop, read state, digest mail (2026-07-30)
| # | Decision | Why / consequence |
|---|---|---|
| D-41 | The digest mail is rendered and sent by the shell. The per-run env injection now always strips the mail keys, so the core returns a ready digest and never sends; the shell composes the mail from the stored digest row in its own design system and flips the status on success. The core's own template stays untouched for self-hosters. | SPEC §9's "polished, consistent with the app" mail and §8's manage-delivery link were promised and never built — and restyling inside the core would push cloud branding into the open source project (golden rule 1). Also fixed in passing: the core mail had been deep-linking to anchors the shell page never rendered. |
| D-42 | Digest content language comes from the tenant's stored language instead of a hardcoded English. | The wizard stored a language preference that changed nothing, so a Dutch reader got English output regardless. The hardcoded value cited the "user-facing copy in English" style rule — a misapplication: that governs app chrome, not the reader's material. |
| D-43 | Per-item read state, shell-owned: a new table beside the vendored schema, with adapter-layer queries and the same RLS shape as the core tables. Skipped items carry no toggle and don't count. | SPEC is silent on read status (golden rule 7). Feedback answers "was this good?"; read state answers "did I get through it?" — the archive gave no signal which digests were still unread. The leakage test was extended to cover the new table. |
| D-44 | A thumbs-down opens an optional note (capped, stored through the existing feedback write, never an LLM call); every rating flashes a visible confirmation. | The shell always submitted an empty comment, silently discarding the richest signal — both the digest prompt and profile synthesis render the comment, so without it the owl only ever learned "not this", never "why not". |
Onboarding — curated sources and the trial cap (2026-07-30)
| # | Decision | Why / consequence |
|---|---|---|
| D-46 | The sources step announces the feed cap the moment it is reached — a banner plus disabled add controls — while the cap itself and its enforcement stay exactly where they were. | Previously the only signal was an error banner after clicking one feed too many: the user discovered the limit by failing. With suggestions on display, an unexplained failing button reads as a bug. Also in this pass: every curated suggestion re-verified, dead feeds dropped with per-topic notes. |
| D-45 | Wizard step 4 gains an "ask your AI" flow: a copyable prompt asking the user's own assistant for a first-person reader profile, pasted back into a second managed marker block. | The profile was the one personalization surface a user never touched during onboarding, while it is freeform prompt text whose manual edits synthesis already treats as authoritative — so a pasted self-description needs zero core or schema change. |
Core 1.1.0 upgrade (2026-07-31)
| # | Decision | Why / consequence |
|---|---|---|
| D-47 | The core is bumped to 1.1.0 in one PR — bringing daily retry of failing sources, a digest window bounded to the previous completed digest, and media-aware source types — with the shell's mirroring migration and adapter changes in the same PR. Scheduled digests now ingest first, and the monitor warns on sources in error. | The July incident: one feed sat in error for ten days, invisible on every surface because per-source failures never fail the ingest job, and then a catch-up burst dumped a month of backlog into one digest labelled as a week. Each mechanism now has an owner — retry and window in the core, pre-digest ingest and error visibility in the shell. This is also the upgrade path working as designed: a core release consumed by bumping the submodule, with the gates proving it. |
Sources page (2026-08-01)
| # | Decision | Why / consequence |
|---|---|---|
| D-49 | /sources gains real management — inline category rename, per-feed category move, a new-category affordance running through the same quota check. A first pass added checkboxes and a sticky bulk bar; it was rejected the same day and removed entirely rather than left dormant. The docs stop promising OPML. | Live use: a category typo could not be fixed, and a misfiled feed could only be removed and re-added. The bulk detour is the lesson worth keeping — this page is read far more often than edited, so management affordances belong at icon weight, one row at a time. Fixed in passing: two database test files shared a tenant id while running in parallel, a standing source of random red runs. |
Settings (2026-08-01)
| # | Decision | Why / consequence |
|---|---|---|
| D-50 | The plan panel becomes a real overview: one query feeding a pure, unit-tested narrative — badge, price, the one date that matters, the saved payment method, the source cap — and exactly one action per status. Plus a stored period end (the provider moved it in a recent API version), and reactivation: a fresh checkout against the existing customer, routed by intent metadata to its own guarded transition, so expired stays terminal for the subscription webhooks. | SPEC §4 has promised expired tenants an upgrade banner since day one, but no upgrade path was ever built: the banner pointed at billing and billing had no such button — while D-17 keeps a stopped account's data for a year. A year of customers who wanted to return and could not. |
| D-51 | One button hierarchy: every card-level save is primary, and a new outline-danger variant means the button that only reveals a delete confirmation is quiet while the one that erases is loud. | "Delete my account" was a solid red button under the password field — the most destructive action on the page rendered as its most prominent one. Quiet trigger, loud confirm is now the reusable pattern. |
Product feedback (2026-08-01)
| # | Decision | Why / consequence |
|---|---|---|
| D-54 | In-app feedback lives as one line in the account menu, opening a modal with three drawn faces (sentiment required, note optional and capped). It renders through a portal into the body, because a closed menu element hides its whole subtree. Stored in its own shell table — deliberately not the core feedback table — mailed to a human inbox that is deliberately not the alerts inbox, and archived in admin. | SPEC is silent on product feedback (golden rule 7). The only feedback surface judged the digests; nothing asked about the product, so the one signal a small paid app most needs was the one it never collected. Product feedback must never reach an LLM or a digest, hence the separate table. The client-supplied page path is validated to same-origin, or a hostile value would render as trustworthy context in the admin view. |
| D-55 | Two lifecycle mails invite feedback — after two delivered digests and at 60 days — guarded by the existing idempotency table, asked only of running accounts, and skipped for anyone who wrote to us within 30 days. Both land on a longer form sharing the same sentiment component. The login detour now keeps the destination through a validated internal-path parameter, which also repairs the digest mail's deep links. | The product only ever asked at the moment the user chose to speak, which selects for the strongly annoyed and the unusually kind. These two moments ask everyone else, exactly twice, where there is something to judge. The path parameter is validated against absolute, protocol-relative and backslash variants — otherwise our own login page becomes an open redirect (A-5). Accepted cost: someone who answers the first invite can still receive the second; they are different questions, two months apart. |