Users page, README and config.example updates, implementation notes, the rollout runbook, site-layout 404/500 pages, human-readable download sizes, dark-mode and narrow-screen polish, and a smoke test over every dashboard route; also removes zdiff3 ancestor markers left by earlier merges. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
22 KiB
Implementation notes (shared brief for all implementation agents)
Authoritative spec: docs/plans/2026-08-15-the-daily-epub.md. Read it fully before writing code.
For curation (§3.5, §3.6 and §3.9 of that spec) the authority is now
docs/plans/2026-09-02-personalized-curation-v2.md; see "Curation v2" below.
This file records implementation-time decisions and verified external facts. Follow both.
Verified external facts (2026-08-15)
- DeepSeek model id is confirmed:
deepseek-v4-flash(version DeepSeek-V4-Flash-0731). Pricing per 1M tokens: $0.0028 cache-hit input, $0.14 cache-miss input, $0.28 output. OpenAI-compatible API athttps://api.deepseek.com/v1, supportsresponse_format: {"type":"json_object"}. - epub-to-xtc-converter (github.com/bigbag/epub-to-xtc-converter) has no global npm bin.
It is invoked as:
node <repo>/cli/index.js convert book.epub -o book.xtch -f xtch -c settings.json(-f xtc= 1-bit,-f xtch= 2-bit grayscale;initsubcommand generates default settings). Therefore config must be fully general:xtc.command = "node",xtc.args = ["/path/to/epub-to-xtc-converter/cli/index.js", "convert"]and the code appends<input.epub> -o <output.xtch> -f <format>(plus-c <settings>). Missing/failed converter is non-fatal (log + continue).- Corrected 2026-08-15 (post-M8, verified by running it).
-cis not optional in practice: the converter validates settings before opening the EPUB and exits 2 withConfiguration errors: - Font path is required. Set font.path in your config file.There is no built-in font default, and the shippedcli/settings.jsonpoints at/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf, which most servers do not have. Soxtc.settingsis effectively required wheneverxtc.enabled. Deps also neednpm installinsidecli/(commander, jszip, minimatch, sharp). - That same error also means "I could not read your config."
loadSettingsincli/settings.jsguards withfs.existsSync(configPath), which returnsfalseonEACCESexactly as it does for a missing file, then silently falls back toDEFAULT_SETTINGS(font.path: null). Since/etc/daily-epubis0750 daily-epub:daily-epub, running the converter by hand as any other account reproduces the "Font path is required" error against a valid file. Verify assudo -u daily-epub. - Output size. XTCH is a pre-rendered page bitmap: 480×800 at 2bpp = ~96 KB/page. A
20-article issue rendered at
font.size = 34came to 1,088 pages ≈ 104 MB, in ~13 s.retention_days = 21therefore implies ~2 GB inpublish.xtc_dir.
- Corrected 2026-08-15 (post-M8, verified by running it).
- CrossPoint's OPDS browser can only acquire EPUBs. Verified against the
yokki-vans/InkPointXsources (an open fork of CrossPoint/CrossInk). Two independent blockers, either one fatal for serving XTC over OPDS:lib/OpdsParser/OpdsParser.cppsets an entry'shrefonly for an acquisition link whosetypeis exactlyapplication/epub+zip(strcmp), or for a navigation link (application/atom+xml).endElementthen drops any entry with an emptyhref. Anapplication/octet-streamacquisition link therefore yields an empty list and the UI reportsSTR_NO_ENTRIES— "No entries found".OpdsBookBrowserActivity::downloadBookbuilds the destination filename assanitizeFilename(author + " - " + title) + ".epub"— hardcoded, ignoring the URL andContent-Disposition— andReaderActivitydispatches on extension only (FsHelpers::hasXtcExtension→.xtc/.xtch, no magic-byte sniffing). So even a mistyped XTC link downloads into a file the device will not open. The three OPDS failure strings are distinct and worth reading precisely:STR_FETCH_FEED_FAILED("Failed to fetch feed"),STR_PARSE_FEED_FAILED("Failed to parse feed") andSTR_NO_ENTRIES("No entries found") — only the last is reachable after a successful fetch and parse. Consequence: the built-in feed serves EPUBs (both editions), XTC is published but not advertised, andpublish.xtc_diris swept by count. See the amendment in spec §3.11.
- X4 firmware rendering limits (from the
epub-to-xtc-converteroptimizer's header, which cites papyrix-reader): 464×788 usable viewport, max image decode 2048×3072, baseline JPEG only, no GIF/SVG/WebP, max 1500 CSS rules and simple selectors only (tag,.class,tag.class— no descendant combinators), max word length 200 chars, images under 20 px treated as decorative. These bind the(X4).epubread natively off BookOrbit; they do not bind the.xtch, which CREngine pre-renders to page bitmaps (convertnever callsoptimizeEpub— the two subcommands are independent).epub/x4.rsandstyle-x4.csssatisfy all of them;x4::simplify_xhtmlsoft-hyphenates pastMAX_WORD_CHARSandthe_x4_stylesheet_uses_no_descendant_selectorsguards the selector rule. - Wikipedia Current Events portal pages are created empty a day ahead.
Portal:Current_events/2026_August_15was created 2026-08-14T03:30Z as a 192-byte stub and did not get its first news item until 2026-08-15T13:28Z. The 05:30 America/New_York timer fires at ~09:30Z, so the issue day's own page is always an unpopulated stub — its only<li>elements are thecurrent-events-navbaredit/history/watch links, which the extractor drops, soextract_eventscorrectly returnsNone.world::fetch_with_fallbacktherefore walks back up toMAX_LOOKBACK_DAYSand the section is datelined with the day it actually covers, not the masthead date.
Verified external facts (2026-09-02, curation v2)
- Anthropic Messages API (verified 2026-09-02 against the bundled Claude API reference):
POST https://api.anthropic.com/v1/messageswith headersx-api-key,anthropic-version: 2023-06-01,content-type: application/jsonandanthropic-beta: server-side-fallback-2026-07-01. Model idclaude-opus-5; pricing $5.00 / M input, $25.00 / M output, cache reads 0.1× input ($0.50/M), cache writes 1.25× ($6.25/M); the minimum cacheable prefix is 512 tokens. No sampling parameters (temperature,top_p,top_kare a 400) and nothinkingblock — adaptive thinking is on by default and depth is set withoutput_config: {"effort": "high"}(low | medium | high | xhigh | max). The system prompt goes insystem: [{type: "text", text, cache_control: {type: "ephemeral"}}]; no assistant prefill, JSON is asked for in the prompt and parsed tolerantly."fallbacks": "default"(with the beta header) routes a request the safety classifiers would refuse to a fallback model server-side; a response can still end withstop_reason: "refusal"on HTTP 200, which the code treats as an error that degrades the call to DeepSeek. Usage fields:input_tokens(uncached remainder),cache_creation_input_tokens,cache_read_input_tokens,output_tokens. Timeout 300 s; retry 429/5xx/network, never 400. Key only fromDAILY_EPUB_PROVIDERS__ANTHROPIC__API_KEY(the provider registry below; the pre-registryDAILY_EPUB_ANTHROPIC__API_KEYis a startup error). - Gemini 3.8 Flash over the OpenAI-compatible endpoint (beta, verified 2026-09-02):
POST https://generativelanguage.googleapis.com/v1beta/openai/chat/completionswithAuthorization: Bearer <key>, the standardmessages/temperature/response_format: {"type": "json_object"}body. Model idgemini-3.8-flash. Reasoning depth is the OpenAIreasoning_effortfield, which Google maps onto Gemini 3.x'sthinking_level(minimal | low | medium | high;noneis not accepted by 3.x models). Usage: implicit cache hits are reported inprompt_tokens_details.cached_tokens(the same field DeepSeek now fills), andcompletion_tokensalready includes the thinking tokens thatcompletion_tokens_details.reasoning_tokensbreaks out — so output is priced fromcompletion_tokensalone, never the sum. Prices per 1M tokens (promotional through 2026-12-31): $0.75 input, $0.075 cache read, $3.75 output (thinking included); from 2027-01-01 $1.50 / $0.15 / $7.50. No cache-write charge. Key only fromDAILY_EPUB_PROVIDERS__GEMINI__API_KEY. Shipped as[providers.gemini], unreferenced until a role names it. - Voyage AI embeddings (verified 2026-09-02):
POST https://api.voyageai.com/v1/embeddingswithAuthorization: Bearer <key>; body{input: [...], model: "voyage-4-lite", input_type: "document" | "query", truncation: true, output_dimension: 512, output_dtype: "float"}. Up to 1,000 inputs and 1M tokens per request, 32k tokens per input. Vectors are unit-normalized, so dot product = cosine. $0.02 / M tokens after a 200M-token free allocation. Key only fromDAILY_EPUB_VOYAGE__API_KEY.
Cross-cutting implementation decisions
- sqlx usage: use runtime queries (
sqlx::query(...).bind(...)) and manual row mapping (orsqlx::FromRowderive withquery_as). Do not use the compile-time checkedquery!/query_as!macros (they require DATABASE_URL/offline data at build time). Migrations viasqlx::migrate!("./migrations")embedded at compile time. - Time:
jiffeverywhere; day boundaries and--dateinterpretation in the configured timezone (America/New_Yorkdefault). Store timestamps in SQLite as RFC3339 UTC strings. - Errors: modules return
thiserrorerror types oranyhow::Result;main.rsusesanyhow. Pipeline stages are best-effort where the spec says so (social, XTC, world briefing, images). - HTTP: one shared
reqwest::Client(rustls, gzip, no cookies, 10s timeouts, UAthe-daily-epub/1.0 (personal rss digest; contact tyler@hallada.net)), passed by clone. - LLM: a hand-rolled
reqwestclient, notasync-openai(the published crate exposes neitherClientnorCreateChatCompletionRequestat the pinned version). Every LLM call goes throughcurate/llm.rs:LlmClient { provider, system_prompt, model, effort, max_concurrent_requests, meter, backend, retry }over theChatBackendtrait, with two wire protocols —OpenAiCompatibleBackend({base_url}/chat/completions, bearer key,response_format: json_object,reasoning_effortwhen the provider has aneffort) andAnthropicBackend(Messages API, facts above). Providers are config, not code: the[providers.<name>]registry (kind = openai | anthropic,base_url,model,effort,max_daily_usd,max_concurrent_requests,price_*) is aBTreeMap<String, ProviderConfig>, and[llm] bulk = "<name>"/editor = "<name>"assign the two roles by name (editor = ""means everything runs on bulk; both roles on one provider share one client and one ceiling).LlmClient::for_provider(name, &cfg, ..)dispatches onkind;Llms::from_config(&config, prompt, &meters)builds the roles;editor_or_bulk()degrades to bulk when the editor client is missing or its meter is tripped. OneUsageMeterper referenced provider (llm::provider_meters), keyed by provider name — the same key used forruns.provider_costs_json, the UTC-day spend preload and the log lines — plus Voyage's own.LlmClient.provideris the config name, never the kind. The system prompt is sent first and byte-identical within a run so every provider's prefix cache hits. Keys come only fromDAILY_EPUB_PROVIDERS__<NAME>__API_KEY(figment lower-cases the path, so provider names are[a-z0-9_]+);daily-epub config checkprints the resolved roles without opening the database. - Testing: unit tests inline per module; integration tests in
tests/over fixture JSON intests/fixtures/. Never hit the network in tests:MockBackend(ChatBackend) and the embedding mock (EmbeddingBackend) stand in for all three providers.--skip-llmmakes zero LLM calls (admission by cheap signals,select_without_llmby utility, excerpt summaries);--skip-embeddingsmakes zero Voyage calls. - Style: rustfmt defaults,
cargo clippyclean-ish, nounwrap()outside tests, tracing spans per pipeline stage. - File ownership: waves of agents work in parallel on disjoint files. Do not edit files
outside your assigned set (module wiring in
main.rs/mod.rsis done by the scaffold and the integration wave). If you need a helper from another module that doesn't exist yet, add a// TODO(integration): ...note and code against the stub signature. - Dedupe module: normalize/dedupe (§3.2) lives in
src/dedupe.rs(canonical URL fn + clustering), called from the generate pipeline between ingest and extraction. - World briefing (§3.8) lives in
src/world.rs. - Askama templates in
src/epub/templates/(*.xhtmlaskama templates +style.css,style-x4.css). Askama 0.12+ configured viaaskama.tomlif needed. - Determinism: chapter ids
art-{entry_id}, stable filenames, issue regeneration for the same date replaces prior rows/files (idempotent upsert everywhere).
Curation v2 (2026-09-02)
The personalized ranker is specified in docs/plans/2026-09-02-personalized-curation-v2.md
(§0 settled decisions, §3 target pipeline, §19 configuration, §21 the seven landed steps);
docs/plans/2026-09-02-curation-v2-progress.md records per-step deviations. Facts an
implementer needs that are easy to get wrong:
- Tables (
migrations/0002_curation_v2.sql,0003_drop_scores.sql; never edit0001_init.sql):rating_events(append-only; the current verdict is the latestexplicitevent),article_embeddingsandinterest_embeddings(f32 little-endian BLOBs,input_hash= sha256 of the embedded text),article_assessments(stage IN ('triage', 'deep'), reused whilemodelandprompt_versionmatch andassessed_atis withinassessment_reuse_days;--rescoreignores the cache),candidate_runs(one row per considered article per run, upserted with every column set on each stage transition),runs.config_json/runs.provider_costs_json,issue_articles.why.ratings,feed_priorsandscoresare dropped;kvkeepsingest_watermark,taste_profile,taste_profile_learned,profile_version. - Feedback:
Voteisloved | good | down(NotForMe); the HMAC message stays{issue_date}/{article_id}/{vote}.Vote::parse("up")→Lovedandauth::verify_tokenstill accepts tokens signed over the literalupsegment because published issues carry those links. Keep both. - Budget day: each provider's
UsageMeteris preloaded with the spend of earlier runs on the UTC date of the run'sstarted_at, summed fromruns.provider_costs_json(db::spend_for_dateby nominal issue date is gone). A tripped meter skips that provider's remaining calls; the paper always publishes. - Lock:
src/lock.rstakeslibc::flock(LOCK_EX | LOCK_NB)on<database_path>.lockforgenerate,profile rebuild,features backfillandbackfill-social; a second writer exits with " is already running".serve,explain,stats,ratings,features pruneanddb migratenever take it. - Retention:
telemetry::pruneremovesarticle_embeddingsof unrated, unpublished articles older thanembedding_retention_days(120) andcandidate_runsrows plusarticle_assessmentsolder thantelemetry_retention_days(180).features pruneruns it on demand;generateruns it once after publishing, best effort. - Keys:
DAILY_EPUB_PROVIDERS__<NAME>__API_KEYandDAILY_EPUB_VOYAGE__API_KEYmap ontoProviderConfig.api_key/VoyageConfig.api_keythrough figment; the fields exist only for that mapping and are never documented in TOML, logged, or stored (Config::providers_redacted()is what reachesruns.config_json). - Provider registry (2026-09-02, after step 7): the
[deepseek]and[anthropic]tables and the top-levelmax_daily_usdare gone.[llm]holds the role names and the role-level knobs (triage_batch_size,deep_batch_size,score_temperature,editorial_temperature);[providers.deepseek],[providers.anthropic]and[providers.gemini]ship inconfig.example.tomland areConfig::default()key for key. Stale shapes fail at load, naming the new key: a[deepseek]/[anthropic]header, a top-levelmax_daily_usd, any of the four role keys outside[llm], or aDAILY_EPUB_DEEPSEEK__*/DAILY_EPUB_ANTHROPIC__*environment variable. Batching concurrency (triage,assess) is the bulk provider'smax_concurrent_requests; the summaries fan out at the summary provider's.Models { bulk, editor, summaries }in the colophon and Behind the paper stay model ids taken from the built clients. - Provider rejections (2026-09-02):
curate::batchbisects a triage or deep batch that fails with a non-transient error (Api,Refusal,EmptyResponse, or a response that parses to zero items) down to single articles; a rejected single is retried once on the editor client when it is another provider. An article both refuse gets anarticle_assessmentsrow withkind = 'provider_rejected',score/fitNULL,rationale = "<provider>: <message ≤ 200 chars>", the bulkmodeland the stage'sprompt_version; the cache loaders skip it (leaving the assessment absent) while it is withinassessment_reuse_days,--rescoreignores it, andadmit::hygienenever treats it as a low score. Because a recovered article's row carries the editor's model, reuse accepts rows whosemodelis either configured model (triage::reusable_models).
Web dashboard (2026-09-03)
Verified on the production host and against the implemented dependency graph on 2026-09-03:
- Host authorization and units: polkit 124 supports JavaScript rules. The installed rule must
grant user
daily-epubonlyorg.freedesktop.systemd1.manage-units, verbstart, for units matching^daily-epub-job@[a-z0-9-]+\.service$.daily-epub.servicenow needsSupplementaryGroups=systemd-journalto read job logs and/etc/daily-epubinReadWritePathsto atomically replaceconfig.toml. The job template deliberately omitsMemoryDenyWriteExecutebecause generate jobs can launch Node's JIT; the server retains it. - Settings writes: direct
toml_edit 0.25performs typed, comment/order-preserving updates. A candidate file is loaded throughConfig::loadbefore its permissions are copied and it is renamed over the original. The process therefore needs write access to the containing directory, not only the file. Every changed dotted key is written toconfig_changes. - Authentication stack:
axum-loginis pinned to git revision151c72d7a1b4646830f86b4332e6bd6e34d719a7, whose graph containstower-sessions 0.15. The localSqliteSessionStoreuses this crate's existing sqlx 0.9 pool because the published tower SQLx store is incompatible.password-auth 1.0supplies Argon2id PHC hashes;tower_governor 0.8throttles login POSTs. Its smart IP extractor trusts forwarded headers, so production must bind to loopback and accept traffic only from the configured reverse proxy. - Test environment: router tests use
tower::ServiceExt::oneshot, temp SQLite databases andMockRunner; they do not bind or invoke systemd. This sandbox forbids loopback listeners, so the fourcurate::llm::tests::anthropic_*tests, the three OpenAI tests using the same fake listener,extract::tests::relative_urls_resolve_against_the_url_we_landed_on, the five listener-basedserver::tests::*, andtests/m7_server.rsare filtered only for sandbox runs. The complete suite is expected to run outside the sandbox.
Implementation-time decisions recorded while landing dashboard steps 1–7:
/files/*remains public when Basic auth is not configured, preserving existing OPDS acquisition behavior. With Basic auth configured, either valid Basic credentials or any valid web session authorizes a download. This intentionally resolves the plan's conflicting request to redirect unauthenticated downloads in favor of its compatibility acceptance criterion.- Final reports are attached to an issue after
finish_run, when publish timing and status are complete. Issue snapshots omit article bodies and rehydrate them fromarticles; old rows use the reduced fallback renderer. The CLI password prompt usesrpasswordafter it became available to the orchestrator. - Flash handlers extract the exact tower session installed by the auth layer through
Extension<Session>. Dashboarddownforms persist the establishednot_for_melabel. Ratings-page feed credit includes rating decay because that is whatsignals::feed_ratesactually uses; the stored prompt verdict count is inferred from the prompt text becauseTasteProfile.verdictsis not persisted. - Dynamic list SQL is assembled only from fixed fragments and allow-listed sort/filter names,
wrapped in sqlx 0.9's
AssertSqlSafe; all user values remain bound parameters. Funnel bars count rows that reached each stage because a row stores the stage where it stopped. SVG/meter attributes replace inline styles under the CSP. Article pages omit the nominal extract method because database reconstruction currently hard-codes it and would display misleading data. - Shipped providers cannot be removed: deleting one would cause
Config::default()to restore it. They remain editable and may be unreferenced; custom providers are removable. An absent setting already equal to its submitted default stays absent. Settings that are captured while building the server, session, or throttle layers carry restart notices. - A job start reloads a hand-edited config before inserting and starting the unit, retaining the
last-good config if reload fails. The offline lifecycle test uses
features-prune; generate run-id linkage is covered separately. CLI duration statistics retain finished dry runs for byte-identical output, while dashboard run series and overview sparklines exclude them.