Skip to content

feat: API delivery destination (api:user / api:channel) + Diadem partner pack - #171

Open
jfberry wants to merge 35 commits into
developfrom
feature/api-delivery-destination
Open

feat: API delivery destination (api:user / api:channel) + Diadem partner pack#171
jfberry wants to merge 35 commits into
developfrom
feature/api-delivery-destination

Conversation

@jfberry

@jfberry jfberry commented Jul 23, 2026

Copy link
Copy Markdown
Owner

API delivery destination (api:user / api:channel)

Adds a third delivery platform alongside Discord and Telegram: api. An api destination is an HTTP endpoint (operated by a third party) that Poracle POSTs rendered alerts to, wrapped in a stable, versioned JSON envelope, with full send / edit / delete lifecycle and retry semantics. Destinations are created and managed entirely through the v2 REST API — there is no chat-command surface.


📖 For the Diadem authors — start here

The complete behavioural contract you implement your receiver against is:

➡️ docs/api-delivery-receiver-spec.md

It is self-contained (no PoracleNG internals) and covers: the single POST endpoint, auth headers, the send/edit/delete envelope, the response contract (what each status code makes Poracle do), idempotency & ordering, the clean/expires_at lifecycle, managing destinations via the v2 API, a worked example, and a 10-point implementation checklist.

The payload your receiver parses is the canonical schema in docs/superpowers/specs/2026-07-19-api-delivery-destination-design.md §1.7, shipped in executable form as the partner pack fallbacks/dts/diadem.toml (one self-contained entry per alert type).

Please review the receiver spec and leave comments — this PR is open for your feedback before we finalise the contract.


What's included

  • delivery.APISender implementing the existing Sender interface — envelope construction, send/edit/delete, full status classification (2xx / 429 Retry-After / 404·410 permanent / 401·403 drop-without-disabling / 5xx·network retry+backoff), and a log_only dry-run mode.
  • Full pipeline integration — dispatcher registration, a dedicated api concurrency lane in the fair queue (so api traffic can't starve behind Discord), DM-vs-channel rate-limit classification, and a unified TargetClass helper (which also fixes a latent telegram:topic bug).
  • Human-type allow-listapi:user / api:channel validated on v1 and v2 human-create (422 on unknown types; colon-free destination-id check).
  • The Diadem partner packfallbacks/dts/diadem.toml, all 16 alert types, selected via [api_delivery] template = "diadem" (a second partner just adds <partner>.toml with id = "<partner>").
  • A real-enrichment conformance test — renders every pack entry through the actual enrichment pipeline against testdata.json fixtures and asserts required fields are non-empty, so a template field that drifts from enrichment output fails CI rather than silently shipping an empty value.
  • Prerequisite (also in this PR): per-user {{distance}}/{{bearing}} now resolve for all alert types, not just Pokémon — a pre-existing renderer gap the api schema depends on.
  • Config: a documented [api_delivery] block in config.example.toml.

Deliberately deferred (documented, not defects)

  • Monotonic revision (edits currently send revision: 0; the receiver spec documents the "apply every edit" idempotency model, which is correct as-is).
  • enrichForType has no showcase case → the operator-side /api/dts/enrich preview and !poracle-test showcase don't work yet. Real Showcase delivery is unaffected (it runs through its own handler), and the Showcase payload schema is still pinned by the conformance test.
  • nest has no bundled testdata fixture, so its pack entry isn't conformance-covered (field names verified by review).

Validation

go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./... — all green.

Every change went through TDD + independent code review; the payload pack in particular went through a review→fix→re-review loop that caught and corrected several field-mapping defects before this PR.


🤖 Generated with Claude Code

jfberry and others added 29 commits July 23, 2026 17:44
Adds a third delivery platform alongside Discord and Telegram: `api`,
with `api:user` / `api:channel` destination types delivered by POSTing a
versioned envelope to an operator-configured HTTPS endpoint.

Part 1 is the receiver contract the third party implements against —
envelope schema, send/edit/delete ops on a single endpoint, response and
status-code semantics, idempotency via (message_id, revision), and the
expiry/clean lifecycle. Part 2 is the processor implementation plan:
new APISender, the platform-enumeration points that need an `api` case,
and the human-type validation gap the new type exposes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The payload is a contract we propose and the receiver agrees to, not an
operator free-for-all. Replaces the placeholder section with a concrete
schema derived from the curated template-field registry served at
GET /api/dts/fields/{type}, plus the sanitisation rules that produce it
(drop deprecated aliases, raw webhook names, emoji, and operator-instance
map links; prefer timestamps over formatted strings; snake_case keys).

Full field mapping for pokemon, and payload-key -> registry-field tables
for the remaining fourteen alert types.

Documents that distance_m / bearing_deg are pokemon-only: renderGrouped
handles every other type and populates the per-user layer with
userDistanceTrack alone, so {{distance}} and {{bearing}} resolve to
nothing there. CLAUDE.md's claim that these are "patched afterwards" is
wrong and is flagged for correction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Partner templates ship as one self-contained file per integration —
fallbacks/dts/diadem.toml, first implementor Diadem — so an agreed schema
can be distributed to operators as a single drop-in artefact.

No partials: DTS partials load from one global config/partials.json, so a
partner pack cannot ship its own without merging into a file the operator
also owns. The common block is repeated in all fifteen entries; the schema
conformance test is what catches drift between the copies, which is why it
is not optional.

Partner identity rides the existing (type, platform, id, language)
selection key as id = "diadem", selected via [api_delivery] template.
Two loader changes fall out: the fallbacks/dts/ walker accepts .json only
and needs .toml, and ResolveTemplate needs a platform argument so an empty
rule template resolves to the api default rather than the Discord one.

Also documents the mandatory numeric guards — an absent field renders as
empty string, yielding invalid JSON and a silent fallback-message body.

Separately: per-user distance/bearing turn out to be a real defect, not a
design limit. The matcher populates Distance/Bearing/CardinalDirection for
every alert type (matching/generic.go:120), but only the pokemon-only PVP
path surfaces them as template fields, and renderGrouped's correctness
precondition is guaranteed by that very gap. Fixed as a prerequisite in
its own spec, so the api schema can promise the fields unconditionally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…maryView

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…insertion

Adding positionalPerUser above renderForUsers left renderForUsers's
existing doc comment glued onto positionalPerUser with no blank line
separator, so it read as (part of) the wrong function's docs.
- Core implementation plan (2026-07-22-api-delivery-core.md): 9 tasks taking
  the api platform from config through a working APISender, pipeline
  integration, human-type validation, renderer plumbing, a minimal starter
  template, and publishing the receiver contract. Full Diadem pack + edit
  revision + envelope enrichment (in_reply_to/media/tracking_uids/areas)
  deferred to a follow-up plan.
- Standalone receiver specification (api-delivery-receiver-spec.md): the
  behavioural contract handed to third-party receiver implementers (Diadem
  first), extracted from the design doc's Part 1 so partners don't read our
  internals.
- Spec: message_id switched from ULID to UUIDv4 (google/uuid already vendored;
  colon-free uniqueness is the real requirement, not time-ordering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the generic HTTP "api" delivery platform config: api:user /
api:channel destinations POST a rendered envelope to a single
operator-configured endpoint. Enabled defaults false; validation
rejects enabled=true with an empty endpoint. Inert until the
dispatcher (Task 5) and default template (Task 7) consume it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add APISender implementing Send (Edit/Delete land in Task 4) plus the
versioned op:"send" envelope: destination, lifecycle (clean/editable
via db.IsClean/db.IsEdit), location, expiry, and payload. SentID
encodes "<destID>:<messageID>[:<providerID>]" for later Edit/Delete
addressing. Includes a deliberate Task-3 stub `do` (happy-path +
basic non-2xx) that Task 4 replaces with full retry/backoff/status
classification.

Adds Job.TemplateID (delivery.go) — the envelope builder needs it and
no prior task introduced the field; full resolver plumbing is a later
task.
Adds Edit/Delete (parsing the "<dest>:<messageID>[:<providerID>]" SentID
format) and replaces the Task 3 stub do() with full retry/backoff and
status classification, matching FairQueue.processJob's contract: 2xx
success, 404/410 permanent (except delete, where 404 is already-gone
success), 401/403/other-4xx drop-without-counting, 429 honours
Retry-After, 5xx/network retries then fails. APISender now fully
satisfies delivery.Sender (compile-time asserted). WaitForRateLimit
switched to the injectable clock (s.now()) for testability consistency
with applyBackoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eID plumbing, toml fallback packs

Task 7 of the API Delivery Core plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ship fallbacks/dts/api.toml (raid + pokemon, numeric fields guarded so a
sparse webhook still yields valid JSON) and prove the path end to end:
render via the real DTS renderer, feed the job into a real APISender
pointed at an httptest server, assert the captured envelope's payload/
alert_type/destination.

Also closes two review gaps from Tasks 7:
- TestRenderAlertAPINoPingPerUser forces the renderPerUser code path
  (not just renderGrouped) to confirm the api ping-skip holds there too.
- TestAPIStarterPackSparsePokemonValidJSON loads the real shipped
  api.toml pokemon entry and renders it with iv/cp/level/distance/
  despawnTimestamp all absent, pinning the numeric-guard requirement
  against the actual artifact rather than a copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ample

Final-review fixes:

- CRITICAL: the receiver spec documented (message_id, revision) as the
  idempotency key with monotonic revision, but the implementation sends
  revision:0 for send/edit/delete (monotonic revision is a deferred
  follow-up). A conforming receiver deduping on that pair would treat every
  edit and delete as a duplicate of the send and silently no-op them,
  breaking the edit/clean-deletion lifecycle. Rewrote §5/§1.5 idempotency:
  revision is reserved (always 0); edits are idempotent full replacements
  applied in order (per-destination serialization); dedupe only identical
  network retries. Fixed the internally-inconsistent delete example
  (revision 1 -> 0) and the checklist. Marked in_reply_to/tracking_uids/
  areas/media as reserved (not emitted in this version) for accurate
  hand-off. Mirrored across both the standalone receiver spec and the
  design doc's Part 1.

- Added a commented, disabled-by-default [api_delivery] block to
  config.example.toml (CLAUDE.md requires example.toml to document all
  options; the sibling opt-in sections already do).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completion plan so Diadem reviews a finished feature: all 15 canonical
payload-pack entries, the tracking_uids/areas/in_reply_to envelope fields,
a per-type conformance test, doc re-sync, and one cleanup. Locked in
brainstorming: revision stays reserved (apply-every-edit is already
correct); static_map lives in the payload not an envelope media field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pack is fallbacks/dts/diadem.toml with id=diadem, selected via
[api_delivery] template=diadem (new shipped default). Replaces the generic
api.toml/id=default starter; second partners add <partner>.toml/id=<partner>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the 2-type api.toml starter with a self-contained 15-type
partner pack (fallbacks/dts/diadem.toml) transcribing the api-delivery
payload schema (design doc §1.7), and switches the shipped
[api_delivery] default template from "default" to "diadem".

The pokemon-schema entry ships as DTS type "monster" rather than the
literal "pokemon" spelling used in the design doc's exemplar: that is
the actual template-type string RenderPokemon queries at render time,
so a "pokemon"-typed entry would never be selected for real delivery.
See task-2-report.md for full rationale and the several dts_fields.go
registry-vs-enrichment-reality field substitutions applied across the
fort/maxbattle/quest/weatherchange entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…case entry

Fixes real field-mapping defects found in review, each verified against
the actual internal/enrichment/*.go source (not the stale dts_fields.go
registry):

- maxbattle: pokestop_name now sources station_name (gymName never set for
  this type); added quick_move/charge_move (populated via addMoveFields).
- fort-update: change_type now sources changeTypeText (changeType was never
  stored to the map).
- raid/egg/rsvpChanges: added rsvps[] (time/timeslot/going_count/maybe_count).
- Added a 16th entry, type = "showcase" — real Showcase webhooks render via
  the dedicated "showcase" template type (cmd/processor/showcase.go), not
  "incident"; without this entry no message was ever sent for Showcase
  alerts on the api platform.
- Minor fidelity adds: invasion rewards/lineup (gruntRewardsList/
  gruntLineupList), gym previous_control_name, quest/questSummary
  conditions_en, raid/rsvpChanges boss.weaknesses.

Verified via a throwaway render test (all 16 changed-entry cases, populated
+ sparse, asserted json.Valid and correct field values) before deleting it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hand-built enrichment maps can't catch the empty-field-from-wrong-name
class (the maxbattle/fort bugs); render the real pack against real
enrichment (enrichForType on testdata fixtures) and assert non-empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jfberry and others added 3 commits July 23, 2026 17:44
…n_reply_to emitted

media.static_map is gone from the envelope; static_map is now a payload
common-block field alongside icon_url/map_urls, matching what
fallbacks/dts/diadem.toml actually renders. in_reply_to, tracking_uids,
and areas are documented as emitted (previously flagged "not emitted in
this version") to match internal/delivery/api.go. revision remains the
sole reserved/always-0 envelope field.

Also fixes two doc/code drifts found while re-syncing: the design doc's
§2.2 claimed revision was MessageTracker-derived and incrementing, which
contradicts the hardcoded revision:0 in api.go; and several "fifteen
entries" references were stale against the actual 16-entry diadem.toml
(15 alert types + a dedicated showcase entry). §1.7.5 now also names the
real conformance test (api_pack_conformance_test.go).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified dead: grep for APIConcurrency turned up only the declaration,
no reader or assignment anywhere. Actual API-lane concurrency flows
through the separate QueueConfig.ConcurrentAPI field, which is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t all)

Final-review fix: §1.7.5 overstated the conformance test as asserting every
documented key non-empty; it asserts a curated required-key subset per type
(the fields prone to silent breakage), plus valid JSON. Notes the nest and
Showcase-preview coverage gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jfberry
jfberry force-pushed the feature/api-delivery-destination branch from 711fdec to b9f7eb8 Compare July 23, 2026 16:45
jfberry and others added 3 commits July 23, 2026 17:48
… helper)

develop now supports enrichForType("showcase") (#173),
so the api-pack conformance test's showcase subtest falls through to the
standard enrichForType path like every other type, and the
enrichShowcaseForConformance mirror of processTestShowcase is deleted.
Closes the cleanup called for in the #172 acceptance criteria.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olute expiry

Whole-design review fixes (code wave):

- api transient failures no longer count toward auto-disable: every api
  destination shares ONE endpoint, so 5xx/timeout failures are perfectly
  correlated — counting them would mass-disable every destination during a
  receiver outage. Only PermanentError (404/410, genuinely per-destination)
  escalates for api; Discord/Telegram behaviour unchanged. Tests pin both
  directions.
- (nil,nil) sender drops now count as DeliveryTotal{dropped} instead of
  "ok" and no longer reset the failure counter — a misconfigured secret is
  visible in metrics, and a drop is not evidence of destination health.
- Envelope expires_at now uses an absolute expiry stamped at render time
  (Job.ExpiresAt; OverrideCleanTTH when set) so queue latency between
  render and send can't shift the reported expiry late.
- System notifications (rate-limit breach, disable notices) now carry
  MsgType "system" -> api envelope alert_type "system" with the documented
  {"content": text} payload. Inert for Discord/Telegram (untracked jobs).
- broadcast excludes api destinations (they were swept into the Discord
  bucket and would have received Discord-shaped payloads).
- !userlist gains the "api" platform filter.
- buildSnapshot skips api deliveries (buttons are Discord-only; snapshots
  for api were pure pogreb churn).
- fallbacks/testdata.json gains a nest fixture (nest/park); the pack
  conformance test now covers all 16 diadem entries with zero skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second wave of the whole-design review:

- New envelope field template_type — the DTS template TYPE that rendered
  the payload (monster/monsterChanged/rsvpChanges/showcase/...), distinct
  from alert_type (the source type, stable across a message's lifecycle).
  Verification showed the spec's documented alert_type list was wrong:
  handlers emit "fort-update" not "fort", and change events keep the
  source type (monsterChanged renders carry alert_type "pokemon") — so
  receivers need template_type to key payload parsing. questSummary
  dispatch also built its Job with no MsgType at all; it now stamps
  MsgType/TemplateType "questSummary" + TemplateID.

- Receiver spec + design doc re-synced to verified reality:
  * corrected alert_type list (13 source types + system) and documented
    template_type as the payload-schema key
  * failure table: only 404/410 escalate toward disable; 5xx never does
  * edit-of-unknown-message rule: return 2xx, never 404
  * ordering promise scoped to sends/edits (expiry deletes may overlap)
  * new "Delivery guarantees & system messages" section (alert limits,
    alert_type "system" with {"content"} payload)
  * management-API secret vs delivery secret disambiguated
  * conformance guarantee scoped to the unmodified shipped pack
  * checklist updated (template_type keying, system messages, edit-404)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant