You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add an on-disk store of fully-resolved enrichment views keyed by delivered message ID, with TTL matched to the alert's TTH and a long-tail age-based safety sweep. This is the substrate for any feature that needs to re-render an alert in a different shape, or take action against it, after the message has already been sent — ephemeral buttons (#109), action buttons (mute, unsubscribe), late-arriving edits, summary digests that reference past alerts, etc.
Design choices
Save the resolved view, not the raw layers, not pre-rendered output
We considered three approaches:
A. Save the resolved per-delivery view. One snapshot per delivered message (per user, per channel), containing the fully-merged LayeredView map plus alert metadata. B. Save raw layers per event and reconstruct the view at click time, swapping per-user data dynamically. C. Pre-compute every possible response at fire time and store the rendered bytes.
Recommendation: A.
The snapshot is exactly what the user saw — no view-reconstruction logic at click time. Re-rendering is just render(template, view).
DTS render latency is low single-digit ms; Discord's 3s interaction window has plenty of headroom.
Pre-computing (C) locks operators into the responses they configured when the alert fired. Adding a new button or changing a response template later would require re-firing alerts.
Per-event raw-layer storage (B) saves some duplicated bytes across users but costs significant complexity for the reconstruction path; click-rate workloads don't justify the optimisation.
Per-delivery snapshots scale storage with audience size, but each record is small and self-contained. The simpler model wins.
Per-delivery, not per-event
Each delivered message gets its own snapshot. A raid that fans out to 100 DMs + 5 channels = 105 snapshots. Channels-as-humans (the existing Poracle model where channels are humans rows with type=discord:channel) means "per delivered destination" and "per snapshot" are the same statement.
Trade-off: more snapshots than a per-event store, but each is self-contained and trivial to read. The simplicity is worth the storage.
Snapshot record structure
typeSnapshotstruct {
// IdentityMessageIDstring// pogreb keyTargetstring// human_id (DM user or channel)TargetTypestring// "dm" / "channel" / "webhook" — affects which buttons are validCreatedAtint64ExpiresAtint64// TTH-derived; background sweep handles overruns// Alert metadata — the "out-of-band" fields actions needAlertTypestring// "monster" / "raid" / "incident" — source webhook typeTemplateTypestring// resolved template type ("raid" / "rsvpChanges" / "incident")// Template identity — both forms, so consumers can either re-render the// exact entry the user saw OR re-resolve against the original intent.// When these differ, the selection chain fell back from the requested id.TemplateRequestedstring// the literal value from the tracking rule (may be empty = "use default")TemplateSelectedstring// the DTS entry id actually picked by the 6-priority selection chainLanguagestringPlatformstring// "discord" / "telegram"// Every tracking rule that fired for this deliveryTrackingUIDs []int64// Geographic context (for area-scoped actions)MatchedAreas []string// The fully-resolved LayeredView this user actually sawViewmap[string]any
}
Reasoning on each non-obvious piece:
View is the resolved merged map, not raw layers. Re-rendering on click is just render(template, view). If a click handler needs clicker-specific data (channel-message "directions from me" pressed by someone other than the original target), compute it fresh from the clicker's stored location and merge — no need to retain the raw layer split in the snapshot.
TrackingUIDs []int64 is what "mute this rule" and "unsubscribe from this rule" buttons need. A pokemon delivery can match multiple rules in one go (basic IV + PVP great + PVP ultra). The full list lets a button offer "mute the IV rule" vs "unsub from all PVP rules" vs "unsub from this species entirely."
AlertType vs TemplateType split — same distinction we landed in raid-rsvp for tracker semantics. For action buttons it matters: a mute action reads AlertType="raid" to know which tracking table to touch, not the resolved template name "rsvpChanges".
TemplateRequested and TemplateSelected together. The pair captures both intent and outcome. TemplateRequested is the literal value from the tracking rule (often empty, meaning "use the configured default"); TemplateSelected is the entry id the selection chain ultimately picked. If they differ, fallback happened — useful for diagnostics ("why didn't this user see template X?") and for "re-render with original intent" buttons that might prefer to redo the selection chain rather than pin to the historic outcome. The pair (TemplateType, TemplateSelected, Platform, Language) locates an entry unambiguously in currently-loaded DTS for exact re-render; if the entry is gone, the selection chain runs as a graceful fallback.
TargetType is needed by Interactive buttons on alert messages with ephemeral responses #109's button visibility rules — some buttons (unsubscribe, user-scoped mute) only make sense on DM deliveries. Snapshot carries this so click-time visibility checks don't need a humans table lookup.
No raw webhook fields stored separately. The view already has them via alias fallback. Revisit if a concrete need surfaces.
MatchedAreas earns its place because area-scoped actions ("mute this area for an hour") are plausible and recovering area names from the view via alias is awkward.
Implementation note: threading TemplateRequested
The render pool currently resolves the template via the selection chain and forwards the resolved entry downstream — the requested template id (from the tracking rule) is consumed and not propagated. Capturing it for the snapshot is a small refactor in processor/cmd/processor/render.go: pass the requested id through alongside the resolved entry so the sender can include it in the snapshot write.
Storage stack
pogreb for on-disk persistence — separate instance from geocache, same dependency.
No in-memory hot tier in v1. pogreb reads are fast and button click rates are human-paced. Add ttlcache layer later if metrics ever show contention.
We already use pogreb in internal/geocoding/cache.go. No new dependency.
The 6-priority template selection chain and the rest of the render pipeline are unchanged; this is a new sidecar store.
Lifecycle
Write: at delivery success, in the sender after the message goes on the wire. Atomic point — if the bot dies before save, that one message's buttons won't work. Soft failure; ephemeral "this alert has expired" response covers it. Write errors (disk full, pogreb corruption) are non-fatal: log a warn, continue. Snapshot writes never block delivery.
Read: on button interaction (or whatever consumer arrives next).
Delete (normal): at ExpiresAt. Piggyback on MessageTracker's clean-deletion callback when applicable so a deleted message also drops its snapshot.
Delete (safety sweep): background sweep on a long interval deletes any snapshot older than configured max_age (default 7 days, configurable). This catches orphaned entries from crash/restart timing where the TTL expiry path didn't run. Expected steady state never hits this — it's only ever cleaning up missed expiries.
Edits and re-snapshotting
Message edits write a new snapshot that overwrites the previous one for the same MessageID. The render pipeline for an edit resolves whatever template the edit calls for (e.g. an RSVP update transitions from the raid template to the rsvpChanges template), and that template's buttons + view become the new snapshot.
Implications:
Snapshots are always "what the user currently sees" — never stale due to edits.
The button set attached to a message can change between edits (the compact rsvpChanges template can declare a different set than the full raid template).
The render path must emit the components block on every edit call, because Discord's edit semantics replace components by default. The existing edit code in delivery/discord.go needs to thread components through edits, not just initial sends.
Reply chains (raid → rsvpChanges) work the same way: each delivery (original send and each subsequent edit) writes its own snapshot.
This collapses the "snapshot staleness on edited messages" problem entirely — the design says edits are full re-renders, so the snapshot stays accurate by construction.
Average normal TTL ~30 min → peak in-flight ~6k entries × 10 KB ≈ 60 MB working set.
7-day safety-net retention only applies to orphaned entries that escaped normal expiry — not the steady-state churn. Expected disk footprint is bounded by the working set plus a small orphan tail.
pogreb's compaction handles the churn shape comfortably (similar to MessageTracker's clean-deletion entries).
Format and versioning
Format: JSON via encoding/json. Simple, debuggable, plays nicely with pogreb's []byte values. Revisit msgpack/gob if size becomes a problem.
Schema version: include a version int at the top of the serialised record. On read, if the version is newer than the binary supports → drop the snapshot and respond ephemerally "alert has expired" (graceful no-op). On older version → either upgrade in-place or drop, depending on the change.
The store is opt-in, defaulting to off. Two reasons:
Disk usage isn't free — even a ~60 MB working set is something operators should agree to before we start writing it. Sneaky disk usage is a bad default.
Buttons require snapshots. With enabled = false, the render path skips the components block entirely — no buttons attached to any message, regardless of what the DTS says. Operators who don't want buttons don't pay the snapshot cost.
The path, retention, and sweep cadence have sensible defaults; operators only touch them for tuning.
Interaction with buttons
The button-rendering code (in #109) checks the snapshot config at startup: if enabled = false, buttons declared in DTS are silently skipped during alert render. The DTS still loads without error — operators can keep button definitions in their templates and turn them on later by flipping the snapshot knob. This avoids load-time errors that would force operators to strip button declarations to disable the feature.
Metrics
For operator visibility:
poracle_snapshot_writes_total{result="ok|fail"} — counter of write attempts.
poracle_snapshot_reads_total{result="hit|miss|stale"} — counter of read attempts (miss = no entry; stale = version too new).
poracle_snapshot_store_bytes — gauge of approximate disk usage.
poracle_snapshot_sweep_deletions_total — counter of safety-sweep deletions (should be near-zero in healthy operation).
poracle_snapshot_age_seconds — histogram of snapshot age at delete time.
Snapshot inspection API
For debugging — an admin-protected endpoint exposes the stored snapshot for a given message:
GET /api/snapshots/<messageID>
Requires x-poracle-secret (existing API auth).
Returns the Snapshot struct as JSON.
404 if no snapshot exists for that messageID.
No list endpoint in v1 — operators with debugging needs know the messageID from logs or Discord.
Open questions
Pogreb instance separation. Lean toward a dedicated instance — keeps geocache's working set independent and lets the safety sweep run without affecting geocode operations.
max_age default. 7 days as a starting point. Long enough that operators investigating "why didn't this button work yesterday" can find the snapshot; short enough that orphans don't accumulate. Configurable for operators with different audit needs.
Sweep cadence. Hourly is probably fine — the sweep just walks pogreb's key space looking at ExpiresAt. Not on the hot path.
Channel vs DM View content. Channels have no per-user enrichment (no distance/bearing). DMs do. The View field shape is identical either way; channel snapshots just have those fields empty. Document, don't special-case.
Summary
Add an on-disk store of fully-resolved enrichment views keyed by delivered message ID, with TTL matched to the alert's TTH and a long-tail age-based safety sweep. This is the substrate for any feature that needs to re-render an alert in a different shape, or take action against it, after the message has already been sent — ephemeral buttons (#109), action buttons (mute, unsubscribe), late-arriving edits, summary digests that reference past alerts, etc.
Design choices
Save the resolved view, not the raw layers, not pre-rendered output
We considered three approaches:
A. Save the resolved per-delivery view. One snapshot per delivered message (per user, per channel), containing the fully-merged
LayeredViewmap plus alert metadata.B. Save raw layers per event and reconstruct the view at click time, swapping per-user data dynamically.
C. Pre-compute every possible response at fire time and store the rendered bytes.
Recommendation: A.
render(template, view).Per-delivery, not per-event
Each delivered message gets its own snapshot. A raid that fans out to 100 DMs + 5 channels = 105 snapshots. Channels-as-humans (the existing Poracle model where channels are
humansrows withtype=discord:channel) means "per delivered destination" and "per snapshot" are the same statement.Trade-off: more snapshots than a per-event store, but each is self-contained and trivial to read. The simplicity is worth the storage.
Snapshot record structure
Reasoning on each non-obvious piece:
Viewis the resolved merged map, not raw layers. Re-rendering on click is justrender(template, view). If a click handler needs clicker-specific data (channel-message "directions from me" pressed by someone other than the original target), compute it fresh from the clicker's stored location and merge — no need to retain the raw layer split in the snapshot.TrackingUIDs []int64is what "mute this rule" and "unsubscribe from this rule" buttons need. A pokemon delivery can match multiple rules in one go (basic IV + PVP great + PVP ultra). The full list lets a button offer "mute the IV rule" vs "unsub from all PVP rules" vs "unsub from this species entirely."AlertTypevsTemplateTypesplit — same distinction we landed in raid-rsvp for tracker semantics. For action buttons it matters: a mute action readsAlertType="raid"to know which tracking table to touch, not the resolved template name"rsvpChanges".TemplateRequestedandTemplateSelectedtogether. The pair captures both intent and outcome.TemplateRequestedis the literal value from the tracking rule (often empty, meaning "use the configured default");TemplateSelectedis the entry id the selection chain ultimately picked. If they differ, fallback happened — useful for diagnostics ("why didn't this user see template X?") and for "re-render with original intent" buttons that might prefer to redo the selection chain rather than pin to the historic outcome. The pair(TemplateType, TemplateSelected, Platform, Language)locates an entry unambiguously in currently-loaded DTS for exact re-render; if the entry is gone, the selection chain runs as a graceful fallback.TargetTypeis needed by Interactive buttons on alert messages with ephemeral responses #109's button visibility rules — some buttons (unsubscribe, user-scopedmute) only make sense on DM deliveries. Snapshot carries this so click-time visibility checks don't need ahumanstable lookup.MatchedAreasearns its place because area-scoped actions ("mute this area for an hour") are plausible and recovering area names from the view via alias is awkward.Implementation note: threading
TemplateRequestedThe render pool currently resolves the template via the selection chain and forwards the resolved entry downstream — the requested template id (from the tracking rule) is consumed and not propagated. Capturing it for the snapshot is a small refactor in
processor/cmd/processor/render.go: pass the requested id through alongside the resolved entry so the sender can include it in the snapshot write.Storage stack
target:messageID(mirrorsMessageTracker's convention).Snapshotstruct.We already use pogreb in
internal/geocoding/cache.go. No new dependency.The 6-priority template selection chain and the rest of the render pipeline are unchanged; this is a new sidecar store.
Lifecycle
ExpiresAt. Piggyback onMessageTracker's clean-deletion callback when applicable so a deleted message also drops its snapshot.max_age(default 7 days, configurable). This catches orphaned entries from crash/restart timing where the TTL expiry path didn't run. Expected steady state never hits this — it's only ever cleaning up missed expiries.Edits and re-snapshotting
Message edits write a new snapshot that overwrites the previous one for the same
MessageID. The render pipeline for an edit resolves whatever template the edit calls for (e.g. an RSVP update transitions from theraidtemplate to thersvpChangestemplate), and that template's buttons + view become the new snapshot.Implications:
rsvpChangestemplate can declare a different set than the fullraidtemplate).componentsblock on every edit call, because Discord's edit semantics replace components by default. The existing edit code indelivery/discord.goneeds to thread components through edits, not just initial sends.This collapses the "snapshot staleness on edited messages" problem entirely — the design says edits are full re-renders, so the snapshot stays accurate by construction.
Storage cost — back-of-envelope
pogreb's compaction handles the churn shape comfortably (similar to
MessageTracker's clean-deletion entries).Format and versioning
encoding/json. Simple, debuggable, plays nicely with pogreb's[]bytevalues. Revisit msgpack/gob if size becomes a problem.versionint at the top of the serialised record. On read, if the version is newer than the binary supports → drop the snapshot and respond ephemerally "alert has expired" (graceful no-op). On older version → either upgrade in-place or drop, depending on the change.Configuration
The store is opt-in, defaulting to off. Two reasons:
enabled = false, the render path skips thecomponentsblock entirely — no buttons attached to any message, regardless of what the DTS says. Operators who don't want buttons don't pay the snapshot cost.The path, retention, and sweep cadence have sensible defaults; operators only touch them for tuning.
Interaction with buttons
The button-rendering code (in #109) checks the snapshot config at startup: if
enabled = false, buttons declared in DTS are silently skipped during alert render. The DTS still loads without error — operators can keep button definitions in their templates and turn them on later by flipping the snapshot knob. This avoids load-time errors that would force operators to strip button declarations to disable the feature.Metrics
For operator visibility:
poracle_snapshot_writes_total{result="ok|fail"}— counter of write attempts.poracle_snapshot_reads_total{result="hit|miss|stale"}— counter of read attempts (miss = no entry; stale = version too new).poracle_snapshot_store_bytes— gauge of approximate disk usage.poracle_snapshot_sweep_deletions_total— counter of safety-sweep deletions (should be near-zero in healthy operation).poracle_snapshot_age_seconds— histogram of snapshot age at delete time.Snapshot inspection API
For debugging — an admin-protected endpoint exposes the stored snapshot for a given message:
x-poracle-secret(existing API auth).Snapshotstruct as JSON.No list endpoint in v1 — operators with debugging needs know the messageID from logs or Discord.
Open questions
max_agedefault. 7 days as a starting point. Long enough that operators investigating "why didn't this button work yesterday" can find the snapshot; short enough that orphans don't accumulate. Configurable for operators with different audit needs.ExpiresAt. Not on the hot path.Viewcontent. Channels have no per-user enrichment (no distance/bearing). DMs do. TheViewfield shape is identical either way; channel snapshots just have those fields empty. Document, don't special-case.Out of scope
Dependencies
None — foundation issue. Consumers: #109 (buttons), #110 (TOML format for the buttons that consume this).