The resolved architecture decisions for dionysus and the reasoning behind each — captured so a future session has the WHY, not just the WHAT. This is a companion to the full design spec; the spec is the source of truth, this note is the decision record. Reflects spec v2, produced after a 6-lens adversarial review.
For Agents
Source spec (do not duplicate — read it for full detail): spec v2 at
/Users/levander/coding/dionysus/docs/superpowers/specs/2026-06-08-dionysus-news-engine-design.md. Status: Phase 1 (Ingest Skeleton) is BUILT + reviewed + post-build-audited + committed (final commit21332bd, pushed towowjeeez/dionysus) — these decisions are now realized in code for the fast-path slice; see dionysus-phase1-ingest-skeleton for what shipped + the durable learnings the review and the post-build security/correctness/DRY audit caught (Phase 1 plan:docs/superpowers/plans/2026-06-08-dionysus-phase1-ingest.md). dionysus is a news-aggregation microservice in the trading empire (see AiTrading, sibling to polymarket-fetch). The@deployagent referenced throughout is the levandor-infra project at/Users/levander/levandor/terraform. v2 changed four decisions and added one — see the next section.
What changed in v2 (the adversarial review)
A 6-lens adversarial review surfaced two near-existential flaws and several costly assumptions. The net effect: dionysus is now backtest-safe and validates cheaply before any big spend.
| Change | From (v1) | To (v2) | Why it mattered |
|---|---|---|---|
| Bitemporal from day 1 (NEW, critical) | Only publish/fetch time tracked | Two time axes (valid_time vs knowledge_time), append-only everything, as_of query param | The design leaked future data into “as-of-T” views — enrichment, signals, entities, and GDELT revisions all caused look-ahead bias. Existential for a backtest-driven trading backbone. Retrofitting later is brutal. |
| Phase-gated infra — managed-first | Self-host all on a dedicated ~$765/mo VM upfront | v1 = Qdrant Cloud + Supabase + NATS on the existing e2-small (~$65-190/mo); self-host at Phase 2 | Don’t spend ~$765/mo before validation (75-92% cheaper). Managed tiers also patch the no-backup finding for free. Migration later is low-risk (snapshot + pg_dump), no lock-in. |
Host tailscaled, not embedded | Embedded Tailscale via libtailscale Rust crate | Bind services to the VM’s existing host tailnet node | The tailscale-rs crate self-declares “unstable and insecure”, is unaudited, and is DERP-only (no NAT traversal) — disqualifying for a constant DB/bus plane. tsnet is a v0.1.0 FFI wrapper, not all-Rust. |
| Filtered validation corpus | ”Every article since 2015” | English + priority entities/themes (Elon + target markets), 2015-02-18→now | GDELT GKG = 1.1 billion articles for 2015-2020 alone. “Every article” = billions of rows + a big VM. Broaden after validation. |
| Transactional outbox | Dual-write (publish-before-persist) | Persist + outbox row in 1 tx, relay publishes at-least-once | NATS+Postgres+Qdrant aren’t atomic; a crash mid-write loses or duplicates events. |
| Per-event msg-id + lifecycle | Shared Nats-Msg-Id = article_id, no terminal state | Nats-Msg-Id = {id}:{stage}:{revision}, status+revision, terminal news.failed | Shared msg-id made JetStream drop the enriched event as a dup of detected. |
The full §-by-§ changelog is at the top of spec v2.
Decisions at a glance
| # | Decision | Choice | One-line WHY |
|---|---|---|---|
| 1 | Scope | Platform-first, filtered v1 corpus | Reusable engine; validate on an EN + priority-entity slice before boiling the ocean |
| 2 | Data strategy | Hybrid GDELT-spine + tiered full-text | GDELT gives entities + dates + tone free at global scale; full text is partial-coverage by design |
| 3 | Storage | Bitemporal Postgres + Qdrant | Backtest needs as-of reconstruction; Qdrant for filtered-ANN at scale |
| 4 | Infra (v1) | Managed: Qdrant Cloud + Supabase + NATS on existing e2-small | ~765/mo; managed backups included |
| 5 | Infra (Phase 2) | Self-host on a sized VM | Migrate via Qdrant snapshot + pg_dump once validated; clean interfaces, no lock-in |
| 6 | Transport | NATS JetStream + transactional outbox | Durable/replayable pub-sub; outbox closes the dual-write gap |
| 7 | Language | All-Rust + API embeddings | Matches stack; GDELT does NER, embeddings are just API calls |
| 8 | Networking | Host tailscaled (not embedded) | Embedded Rust crate is unaudited / DERP-only; ride the VM’s existing tailnet node |
| 9 | Observability | SigNoz via OTel | Empire already has the OTel→SigNoz pipeline wired |
| 10 | Backtest-safety | Bitemporal from day 1 | Look-ahead bias is existential for a trading backbone; retrofitting is brutal |
Time-depth note
v1 backfills 2015-02-18 → now — GKG 2.0 is where the rich free data lives. This is no longer a standalone “decision 9”; it’s folded into the filtered v1 corpus (decision 1) and the data-sourcing detail. 2006 full-text via paid archives stays deferred (six-figure spend).
1. Platform-first (filtered v1 corpus)
Decision: Build dionysus as a reusable engine that many empire apps subscribe to (not a vertical slice), but make v1 a filtered validation corpus — English-language news about a curated priority entity/theme set (Elon Musk + target markets), 2015-02-18 → now — and broaden by relaxing filters once validated.
Why (platform-first): The user explicitly chose this over the narrow path. The motivating use case (an Elon-tweet-frequency predictor) is just the first consumer — the same news signal + retrieval is needed by future trading flows. This is the concrete realization of the “flows triggered by news updates” idea from the AiTrading vision. A vertical slice would have to be rebuilt the moment a second algo wants news data.
Why (filtered v1, CHANGED in v2): The original spec said “every article since 2015.” The adversarial review caught the scale reality: GDELT GKG is ~1.1 billion articles for 2015-2020 alone (~700k-1M/day). “Every article” = billions of rows + a big VM before anything is proven. Validating on a small EN + priority-entity slice keeps v1 cheap and fast, and the corpus broadens later by simply relaxing filters. Pin the expected unique-story count at plan time — it sizes everything (vectors, VM, embedding cost).
Consequence: The motivating predictor is a separate consumer service with its own spec → plan → build. dionysus supplies the signal and retrieval, never the model. This is also why the engine stays all-Rust (decision 7) — ML lives in the consumers, not here.
2. Hybrid GDELT-spine + tiered full-text
Decision: Use GDELT 2.0 as the data spine (all articles); fetch full article text through a tiered strategy behind the Source/extraction trait — (a) live best-effort fetch with a bounded connect_timeout, (b) a CcNewsSource for historical bodies via Common Crawl CC-NEWS WARCs. Full-text is partial-coverage by design; 2006 archives stay deferred.
Why: GDELT already does the two hardest, most expensive things for free and at global scale: it extracts “who is the article about” (persons / orgs / locations via the GKG) and it indexes by date + tone. Rebuilding global NER + date indexing in-house would be enormous. By leaning on GDELT, entities and dates become first-class queryable dimensions on day one at zero extraction cost. Full text is the expensive, lower-value-per-byte part, so it’s fetched lazily — and when a fetch fails, the article simply stays metadata-only (graceful degradation).
Full text is partial by design — track a hit-rate SLI (CHANGED in v2)
“Opportunistic fetch only” was naive: live re-fetch of old articles is impossible (paywalls, dead links, anti-bot). Free historical full text exists only via Common Crawl CC-NEWS (~Aug 2016+) — so v1 adds a
CcNewsSourcefor backfill bodies. Every article carries afull_text_availableflag; embeddings/semantic search are an explicitly partial-coverage feature gated on a tracked fetch hit-rate SLI (§11 of the spec). Articles before ~Aug 2016 are largely metadata-only.
GDELT license requires attribution that propagates downstream
GDELT permits commercial use + redistribution, but requires attribution (citation + link to gdeltproject.org). That obligation must propagate to subscriber apps — it’s carried in the NATS event schema docs,
dionysus-api, andDEPLOY.md. Not optional.
2006 full-text deferred — it's a six-figure cost
Full-text archives back to 2006 require paid archives (LexisNexis / Factiva) — a six-figure spend. Deferred to a future
Sourceconnector. v1 backfills 2015-02-18 → now (GKG 2.0 start), the era where the rich free GKG data lives.
3. Storage = bitemporal Postgres + Qdrant
Decision: Postgres for structured signals (source of truth + analytics), Qdrant for vectors — and the whole Postgres model is bitemporal and append-only (see decision 10 for the why-it’s-existential).
Why (the split): The correlation/backtest work the consumers do is fundamentally SQL/time-series — “news about entity X, bucketed per timeframe, with volume + tone + theme mix.” That belongs in Postgres, where the composite index (entity_id, published_at) is the workhorse and signals is time-partitioned. Vectors are a different access pattern: filtered-ANN at millions of vectors (semantic search filtered by date / entity / source / theme — and by knowledge-time). Qdrant is purpose-built for that with payload indexes + scalar quantization. One store can’t do both well, so use the right tool for each and link them by article_id.
Qdrant config detail: point id = deterministic f(article_id, chunk_index) (no scalar vector_id column — re-upsert is then an idempotent overwrite). Payload {article_id, published_at, knowledge_ts, entity_ids[], source, themes[], tone, language} indexed on published_at + knowledge_ts + entity_ids. Scalar (int8) quantization + on_disk originals + always_ram quantized, dim ≤ 1024. As-of semantic queries filter knowledge_ts <= as_of.
v1 Postgres = Supabase (managed), NOT a self-hosted container (CHANGED in v2)
The v1 prior was “dedicated Postgres container on a self-hosted VM.” v2 uses managed Supabase Postgres for validation (daily backups included — patches the no-backup finding for free). It is still not the shared
mgmtSupabase that polymarket-fetch uses — dionysus gets its own Supabase project to avoid noisy-neighbor at scale. Self-hosted Postgres returns at Phase 2 (decision 5).
Partition
article_entities— it's the billion-row tableThe
article_entitiesjunction (article ↔ entity, withmention_count+knowledge_ts) can hit billions of rows at full corpus scale. It is time-partitioned alongsidesignals. Relabels are new rows, never rewrites (append-only).
4 & 5. Phase-gated infra — managed-first (CHANGED in v2)
Decision: v1 runs on managed infra — Qdrant Cloud + Supabase Postgres + a single NATS JetStream container on the existing polymarket-infra e2-small (~$65-190/mo total). Phase 2 (after validation) migrates to self-hosted Qdrant + Postgres + NATS on a sized VM, via Qdrant snapshot + Supabase pg_dump.
Why (the change): The original spec said “self-host all on a dedicated VM” — but that VM is ~$765/mo (n2-highmem-16 class), and the user did not want to spend that before validation. The managed path is 75-92% cheaper for the validation phase:
| v1 (managed) | Phase 2 (self-host) | |
|---|---|---|
| Qdrant | Qdrant Cloud (free <~500k vec → ~$30-90/mo Standard for 1-5M) | self-hosted container |
| Postgres | Supabase Pro (~$25-85/mo, daily backups included, skip PITR) | self-hosted container + pd-ssd |
| NATS | container on existing e2-small (~$12/mo, already paid) | container on sized VM |
| Total | ~$65-190/mo | ~$765/mo + SSD disk |
Managed tiers also patch the no-backup finding for free during validation. Migration later is low-risk and lock-in-free — Qdrant snapshot + pg_dump restore behind clean interfaces. Self-hosting stateful Postgres/Qdrant on the empire VM is a net-new @deploy capability (data disk + snapshots + pg_dump→GCS) — deferring it to Phase 2 is exactly why managed-first is the right validation move.
The recurring cost is the VM, not the embeddings
Embeddings are cheap: backfill ~9-58/mo. The real recurring spend is the VM. That’s the whole reason to defer self-hosting until the corpus scale (pinned unique-story/vector count) actually justifies it.
6. Transport = NATS JetStream + transactional outbox
Decision: Stand up a NATS JetStream broker (dionysus-nats) as the empire’s first message bus, and close the dual-write gap with a transactional outbox.
Why (the bus): The empire had no existing bus — polymarket-fetch talks to Supabase directly and fires Telegram fire-and-forget. A platform that many apps subscribe to needs durable, replayable, subject-partitioned pub/sub. JetStream gives durability (subscribers that were down catch up), replay (backfill consumers can re-read history), and subject hierarchies that let subscribers filter.
Why (the outbox, NEW in v2): NATS + Postgres + Qdrant are not atomic — the v1 “publish-before-persist” dual-write could publish an event with no DB row (or vice-versa) on a crash. Fix: each path writes its Postgres rows and an outbox row in one transaction; a relay then publishes at-least-once. Persist-then-publish makes “published but no row” impossible. Postgres is the source of truth; a reconcile sweeper re-drives stuck status=enriching rows and “enrichment says vector exists but Qdrant missing.” Consumer idempotency (not the JetStream dedup window) is the correctness authority.
Subject design (versioned event schemas):
news.detected.<entity|theme|source>.<id>— fast tier, GDELT metadata only, published ASAPnews.enriched.<entity|theme|source>.<id>— complete tier (carriesvector_point_id,full_text_available)signal.entity.<id>— time-series rollup updatesnews.failed.<…>— terminal event when slow-path retries exhaust (so everydetectedgets a follow-up — NEW)
Payloads stay small (IDs + status + revision + knowledge_ts + traceparent, never full text). An article may carry multiple entities (entity_ids[] in the payload for content filtering). Consumers treat events as unordered + idempotent keyed by (article_id, stage, revision). Backtests read the bitemporal store with as_of, NOT the stream (replay = live delivery only). Retention capped (~7-30 d).
Nats-Msg-IdMUST be per-event, not per-article (CHANGED in v2)v1 used
Nats-Msg-Id = article_id— which made JetStream dedup drop the enriched event as a duplicate of detected (same id, different stage). v2:Nats-Msg-Id = {article_id}:{stage}:{revision}— unique per event. This is a subtle, easy-to-reintroduce bug; it’s in the durable-gotchas list below for that reason.
7. Language = all-Rust with API-based embeddings
Decision: Write the whole engine in Rust. Embeddings go through a swappable Embedder trait backed by a hosted API. No Python in the engine.
Why: The user’s entire stack is Rust (polymarket-fetch, levandor-infra tooling). The usual reason to reach for Python — ML / NER — partly applies (entity resolution is real work, see the durable gotchas), but it’s handled in Rust against GDELT’s pre-extracted entities, and embeddings are just HTTP API calls. So there’s no ML training workload inside the engine that would justify a Python worker. ML consumers (the Elon predictor) are separate services and can be whatever language they want.
Design detail: the Embedder trait makes the model swappable and mockable for tests. v1 = a cost-efficient hosted model (Voyage / OpenAI-3-small / Cohere-v3 class); exact model + dimension + pricing pinned at plan time because dimension drives the Qdrant config.
Deferred escape hatch
If API embedding cost at scale ever justifies it, a local/self-hosted embedding model + Python worker can be added behind the same
Embeddertrait — without touching the rest of the engine. That’s the whole point of putting embeddings behind a trait.
8. Networking = host tailscaled (NOT embedded) (CHANGED in v2)
Decision: Bind services to the VM’s existing host tailscaled node (tag:cloud); the serving/control plane is tailnet-only. No embedded per-service Tailscale identities in v1.
Why (the change): The v1 plan was “Tailscale-native via the libtailscale Rust crate” — embedding a userspace node per unit. The review disqualified that crate:
tailscale-rsself-declares “unstable and insecure”, is unaudited, and is DERP-only (no NAT traversal) — fatal for a constant DB/bus plane that needs reliable, low-latency direct connections.tsnetis a v0.1.0 FFI wrapper around the Go library — not all-Rust, immature.
The VM already runs host tailscaled (it’s the only SSH path in). Riding that existing node is the safe, proven option. Authz is still via Tailscale WhoIs. Revisit embedded nodes only if/when the crate is audited (deferred, see open questions).
"Tailnet-private" applies to serving, NOT ingest (honest egress, NEW in v2)
The original framing implied “nothing leaves the tailnet.” That’s only true for the serving/control plane (API, NATS, DB clients). The ingest data plane egresses to GDELT, CC-NEWS, scraped sites, the embedding vendor, and (v1) managed Qdrant Cloud + Supabase. The only way to keep article text fully in-tailnet is a self-hosted embedding model (deferred). Managed Qdrant/Supabase are secured by API key + TLS. The spec was reworded to say this honestly.
9. Observability = SigNoz via OTel (reuse the empire pipeline)
Decision: Instrument with tracing + opentelemetry-otlp and read auto-injected OTEL_* env vars. Do not configure OTel in secrets.
Why: The empire already has an OTel collector → SigNoz Cloud (eu2) wired up by levandor-infra (Spec 2b — see observability-flow). The Ansible apps role auto-injects OTEL_EXPORTER_OTLP_ENDPOINT / _PROTOCOL / OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES into every container. So dionysus gets observability essentially for free — it just emits.
Do NOT put OTel config in
secrets/dionysus.envThe
OTEL_*env vars are injected by the apps role at deploy time. Hard-coding them in the secrets file would conflict with / override the empire pipeline. Leave them out.
Design details:
- Span per pipeline stage (ingest → parse → enrich → fetch → embed → upsert → publish) → per-stage latency visible in SigNoz.
- Trace context propagated through NATS via the
traceparentheader → a subscriber’s processing links back to the originating article (empire-wide distributed traces). - Key SLIs:
detect_latency(feed →news.detected, p50/p95/p99),enrich_latency, GDELT file lag, full-text fetch hit-rate (the partial-coverage health metric), embedding API latency / error rate / cost counter, NATS publish + consumer lag,pending_embed/ dirty-bucket backlog, reconcile-sweeper actions. - Alerts: GDELT lag,
detect_latencyp99, embedding error/cost spike, backlog growth, node-offline (tailnet), Qdrant Cloud reachability (new managed dependency).
10. Bitemporal from day 1 (NEW)
Decision: Make the entire Postgres model bitemporal and append-only from the first commit. Every fact carries two time axes; dionysus-api accepts an as_of parameter on every read.
valid_time— when the fact was true in the world (article publish / event time).knowledge_time(knowledge_ts) — when dionysus learned it.
Why (existential): dionysus is a backtest-driven trading backbone. The original spec was NOT backtest-safe — it tracked only publish/fetch time, so reconstructing an “as-of-T” view leaked future data:
- Enrichment (full text, tone, embeddings) computed after T would appear in a T-view.
- Signals recomputed later would show counts/tone that weren’t known at T.
- Entity labels + alias map improved over time would retroactively relabel old articles.
- GDELT revisions (GDELT re-issues/corrects records) would silently rewrite history.
Any of these is look-ahead bias — a backtest that “knows the future” produces fantasy returns. That is existential for a trading system. And retrofitting bitemporality later is brutal: it touches every table, every write path, every query, and invalidates any backtest run before the retrofit. So it’s done now, on day 1.
How:
- Append-only enrichment + signals keyed by
knowledge_ts(SCD-2 revisions; latest-as-of-T = maxknowledge_ts <= T). Recompute-from-base over adirty_bucketsset, never destructive update. - Immutable GDELT vintages — a re-issued GDELT record creates a new knowledge-time row; backtests pin a vintage.
- Versioned alias map + embeddings —
entity_aliases.alias_version+knowledge_ts; re-embeds create new versions stamped with model+config, never overwrite. as_ofquery param on semantic search, signal query, and article fetch — filtersknowledge_ts <= as_ofeverywhere (Qdrant payload + Postgres).- Backtest-safety test (TDD): an as-of query at T must never observe a fact with
knowledge_ts > T.
Quick mental model
“What did dionysus know, and when did it know it?” Two clocks, never one. Backtests read the bitemporal store with
as_of— they do not replay the NATS stream (replay = live delivery only).
@deploy coordination (phase-gated)
For Agents
@deployis the deployment agent for the levandor-infra project at/Users/levander/levandor/terraform(mirroredgithub.com/wowjeeez/terraform). It owns the GCP VM, Ansible, Artifact Registry + WIF, the OTel→SigNoz pipeline, anddeploys/apps.yml. dionysus and @deploy meet at a clean contract — see gcp-app-deploy-design and agent-guide-configure-app-deploy for the deploy mechanics. In v2 the contract is phase-gated — v1 is a minimal footprint; the heavy lift (sized VM + backup/DR + stateful containers) is Phase 2, deferred by going managed-first.
v1 — dionysus produces (minimal @deploy footprint):
Dockerfile.dionysus— multi-stagerust:1-bookworm → debian:bookworm-slim,rustls-tls(no OpenSSL),ca-certificatesonlyrust-toolchain.tomlpinned; clippy pedantic + nursery.github/workflows/release.yml→ callswowjeeez/terraform/.github/workflows/build-push.yml@main→ imageeurope-west3-docker.pkg.dev/aerobic-tesla-490112-r3/apps/dionysus:latestdeploys/apps.ymlentries:dionysus-ingest(service),dionysus-api(service),dionysus-backfill(kind: service— NOTjob, see gotchas), plus one infra containerdionysus-natson the existing e2-small. Qdrant + Postgres are managed off-VM (Qdrant Cloud + Supabase) — no infra containers for them.secrets/dionysus.envtemplate:DIONYSUS_DB_URL,QDRANT_URL+QDRANT_API_KEY, embedding API key (vendor-side hard spend cap),GDELT/CCNEWSconfig (OTel env auto-injected — NOT here; noTS_AUTHKEYsince services ride the host node)DEPLOY.md— services, env, schema migration (sqlx advisory-lock migrations, single migrator), GDELT attribution obligation
v1 — @deploy must provide: repo added to ci_github_repos (WIF binding); confirm AR / WIF / OTLP identifiers against the live terraform repo. No sized VM, no new persistence capability, no Tailscale auth-key/tag work (host node already exists).
Phase 2 prerequisites (when self-hosting, post-validation):
- A sized dedicated GCP VM (europe-west3) + a dedicated
pd-ssddata disk — sized from the pinned vector count - Backup/DR = GCE scheduled snapshots + nightly
pg_dump/ Qdrant-snapshot → GCS (a net-new empire capability — Empire has zero backup/DR today) - Stateful-container support in the Ansible
appsrole (named volumes, correct uid/gid, exempt stateful services from the unconditional restart-every-reconcile) - Tailscale tagging for any new node
Reused empire gotchas (baked into the design)
These are hard-won lessons from prior empire work, deliberately designed into dionysus from the start:
HTTP clients MUST set
connect_timeout, not justtimeoutThe silent 75-minute hang from polymarket-fetch came from a client that had a request
timeoutbut noconnect_timeout— a stuck TCP connect never tripped the request timeout. Every HTTP client in dionysus (full-text fetch, embedding API) setsconnect_timeoutand a per-itemtokio::timeout. See polymarket-fetch-deploy for the original incident context.
Resumable / idempotent staging-first ingest (the txArchive pattern)
Writes are keyed by
article_id(upsert) and consumers are idempotent keyed by(article_id, stage, revision); NATS dedups via the per-eventNats-Msg-Id(not per-article — see decision 6). Ingest resumes fromingest_checkpointsafter any crash (the checkpoint cursor commits in the same tx as the outbox row). One bad article never kills a batch; poison messages land infailed_articlesfor dead-letter handling. This mirrors the staging-first, resumable pattern proven in the txArchive work.
Backfill embedding is a real one-time API cost — estimate with VERIFIED pricing
Backfill embedding is a genuine one-time API spend, run as a resumable long-running service (NOT a
kind: job— see the durable gotchas). At the filtered v1 corpus scale it’s ~9-58/mo); the recurring cost that matters is the VM, not embeddings.
Durable gotchas (reference)
For Agents
These are reference-type facts surfaced by the adversarial review — they describe GDELT, Common Crawl, the Empire deploy substrate, and verified Rust crate state, and are unlikely to change. Treat them as constraints, not decisions. They are why several v2 decisions look the way they do.
GDELT reality
GDELT scale — it is enormous
GDELT GKG is ~1.1 billion articles for 2015-2020 alone, ~700k-1M/day. The
article_entitiesjunction can reach billions of rows → it must be partitioned (time-partitioned, alongsidesignals). This is the hard reason v1 uses a filtered corpus (decision 1).
GDELT entities are RAW strings, not disambiguated KB IDs
GDELT gives raw entity name strings — “Elon Musk”, “Musk”, “@elonmusk” are not merged. Entity resolution is real first-class work (normalize → alias dictionary → optional Wikidata/KB linking for priority entities; concurrent first-sighting via
INSERT ... ON CONFLICT(normalized_alias) DO NOTHING RETURNING entity_id; audited merges), not an alias-table afterthought. Precision/recall on the priority set must be validated before any consumer trusts the(entity_id, published_at)index.
GDELT format/coverage boundaries
GKG 2.0 (the rich 15-min format) starts 2015-02-18. GKG 1.0 (2013, different schema) and Events-to-1979 (no GKG richness) are out of v1 scope. Free historical full text exists only via Common Crawl CC-NEWS (~Aug 2016+) — live re-fetch of old articles is impossible. Full text is partial-coverage by design → track a fetch hit-rate SLI + carry a
full_text_availableflag per article.
GDELT license = attribution, and it propagates
Commercial use + redistribution are permitted, but attribution is required (citation + link to gdeltproject.org) and that obligation must propagate to downstream subscriber apps. Carried in the NATS event schema docs,
dionysus-api, andDEPLOY.md.
Empire deploy substrate
kind: jobis a 15-min one-shot — cannot run a backfillThe Empire’s
kind: job= a systemd one-shot withTimeoutStartSec=900(15-min hard kill) and noRestart. A multi-day GDELT backfill cannot run there. →dionysus-backfilliskind: servicewith an “exit when caught up” guard.
Empire has ZERO backup/DR; the apps role only handles stateless containers
The Ansible
appsrole assumes stateless containers (host bind-mounts, restarted every reconcile), and the Empire has no backup/DR at all. Self-hosting stateful Postgres/Qdrant/NATS is therefore a net-new@deploycapability (dedicated data disk + scheduled snapshots +pg_dump→GCS + exempting stateful services from the unconditional restart). This is a Phase 2 prerequisite, and is exactly why v1 uses managed Supabase + Qdrant Cloud (their tiers include backups for free).
Cost shape — the VM is the recurring cost, not embeddings
Embeddings are cheap (
9-58/mo live). The real recurring spend is the VM ($765/mo self-hosted). Defer self-hosting until the pinned vector count justifies it.
Consistency & messaging
Dual-write gap → transactional outbox; per-event msg-id
NATS + Postgres + Qdrant are not atomic. Use a transactional outbox (persist +
outboxrow in one tx, relay publishes at-least-once). AndNats-Msg-Idmust be per-event —{id}:{stage}:{revision}— otherwise JetStream dedup drops the enriched event as a duplicate of detected (same article id). Both are in decision 6; repeated here because they’re easy to silently reintroduce.
Verified Rust crate pins
Crate pins (verified at review time)
async-nats0.49qdrant-client1.18sqlx0.8.6 — split features:runtime-tokio+tls-rustls-ring-webpki. Single migrator. Phase 1 correction:sqlx::migrate!().run()’sMigratoralready takes its own advisory lock — do NOT add a manualpg_advisory_locksandwich around it (see [[dionysus-phase1-ingest-skeleton#3-sqlxmigraterun-already-takes-its-own-advisory-lock|3.sqlx::migrate!().run()already takes its own advisory lock]]).opentelemetry-otlp0.32dom_smoothie/article_scraper— full-text extraction (benchmark both)csv— GDELT parsing- Tailscale: no usable all-Rust embedded crate —
tailscale-rsis unaudited/DERP-only,tsnetis a v0.1.0 FFI wrapper → use hosttailscaled(decision 8).
Open questions (verify at plan time)
Pulled from spec v2 — these gate the implementation plan:
- Pin embedding model + dimension (≤ 1024) + verified current pricing (dimension drives Qdrant config); confirm Qdrant Cloud paid per-GB rate + free-tier idle-deletion policy in the console
- Pin the priority entity/theme filter set + expected unique-story / vector count for v1 (it sizes everything)
- GDELT GKG 2.0/2.1 column spec + master-file-list mechanics; CC-NEWS WARC access pattern + realized full-text hit-rate
- Signal feature finalization (dispersion / novelty / coverage-accel / event-type-mix) + bucket sizes (≤15-min for priority entities?) + canonical event-time semantics (GDELT crawl vs publish) for lead/lag
- gRPC vs REST for
dionysus-api(confirm consumer preference) - Phase-2 VM size (RAM/disk) — derive from the pinned vector count (with quantization)
- Elon historical tweet-data source — X API is now pay-per-use (2026); free datasets have deleted-tweet/timestamp gaps → pin a vetted vintage + audit completeness (predictor scope; out of engine scope but must exist)
- Whether/when the Tailscale embedded crate gets audited (would re-enable per-service nodes — decision 8)
Related
- dionysus — project overview + deployable units + latency-first design
- dionysus-phase1-ingest-skeleton — Phase 1 build report: what shipped + the durable learnings the review caught (these decisions, realized in code)
- AiTrading — the empire vision dionysus serves
- polymarket-fetch — sibling service; origin of the
connect_timeout+ idempotent-ingest gotchas - polymarket-fetch-deploy — the 75-min hang + Supabase-IPv6 deploy incidents
- levandor-infra — the
@deployproject (GCP VM, Ansible, AR + WIF, OTel→SigNoz) - gcp-app-deploy-design — how apps onboard to the empire deploy layer
- agent-guide-configure-app-deploy — procedural how-to for adding a new app
- observability-flow — the OTel → SigNoz pipeline dionysus plugs into
- gcp-terraform-ansible-gotchas — empire deploy gotchas to expect