Skip to content

ADR-0004: `is_approved` is an in-worker function; spec-03 R2 name + transforms-timing drifts

ADR-0004: is_approved is an in-worker function; spec-03 R2 name + transforms-timing drifts

Section titled “ADR-0004: is_approved is an in-worker function; spec-03 R2 name + transforms-timing drifts”

Status: Accepted (2026-05-31) Date: 2026-05-31 Deciders: @ArneFfm Master design impact: Refines D27 (saga re-validates is_approved at use-time) and the spec-03 §4.4/§10.2 references. No new master D-row needed — this records how the D27 contract is realised given the worker topology that actually shipped. Spec amended: docs/superpowers/specs/2026-05-23-03-ad-moderation-design.md §1, §3, §5, §7, §9, §12.

Three drifts surfaced between spec 03 (written 2026-05-23) and the code/infra that shipped under Epic #3 PR-A..E. They are small but each is a correctness or single-source-of-truth concern, so they are resolved together here before the is_approved PR (Epic #3 PR-1) builds on them.

Spec 03 §7 lists is_approved(ad_id) as a service-binding RPC, and §12 (D27 contract) states it “is a service-binding RPC (not HTTP), <50 ms p95 — required to fit saga step budget.” That framing assumed the auction engine (spec 04) and payment saga (spec 05) would be separate Workers reaching the moderation worker across a service binding.

That is not how the code shipped. Both consumers live inside apps/api:

  • apps/api/src/auction-do.ts — the AuctionDO (spec 04) that gates a winning bid at auction close.
  • apps/api/src/payment-saga-stub.ts — the spec-05 saga seam that re-validates at use-time (D27).

is_approved itself reads moderation_decisions from the same DB (D1) binding that apps/api already holds. A service-binding RPC would mean apps/api calling itself over a network hop for a single indexed D1 row read — pure overhead, and it would force the moderation read path into a second Worker for no benefit.

Spec 03 §3 and §9 name the creatives bucket nyl-creatives. The Terraform that actually provisions it (single shared state, master infra convention) creates nowyourlink-creatives, and apps/api/apps/queue bind that name. The repo is the source of truth for infra; the spec name is a pre-implementation guess.

Spec 03 §9 says renditions are “Performed inline post-EXIF-strip (single decode, multiple encodes — no R2 round-trip).” The pipeline that shipped instead enqueues q-transforms (consumed by apps/queue/src/transforms-consumer.ts) at upload-complete, decoupling rendition generation from the upload request. Inline rendition would block the upload response on WASM image work and couple the upload Worker’s CPU budget to image size; the async queue keeps the upload path fast and lets renditions retry independently of moderation.

  1. is_approved is an in-worker exported function, not a service-binding RPC. It lives at apps/api/src/ad-approval.ts as isAdApproved(db, adId, opts?) returning { approved, at, reason? }, plus a pure decideApproval(row, atIso) for the latest-decision rule. Consumers (auction-do.ts, payment-saga-stub.ts) import it directly. The <50 ms p95 budget is met trivially — one indexed moderation_decisions read by ad_id, no network hop. If 04/05 are ever extracted into their own Workers, the function is re-exposed behind a service binding at that point; the calling contract ({ approved, at, reason? }) does not change.
  2. The R2 creatives bucket is nowyourlink-creatives. Terraform (single state) is authoritative; the spec is corrected to match. No second bucket, no rename.
  3. Renditions are generated asynchronously via q-transforms, enqueued at upload-complete. The “inline” wording in §9 is corrected to describe the queue path.
isAdApproved(db, adId, { at?, now? }?) -> Promise<{
approved: boolean, // latest decision (as-of `at`) has to_state='approved'
at: string | null, // ISO created_at of that determining decision; null if none
reason?: string, // determining decision's reason_code when NOT approved (may be absent if null)
}>
  • Latest-decision rule (§5): approval is true iff the most-recent moderation_decisions row for the ad (ordered created_at DESC, rowid DESC — rowid breaks the rare same-millisecond tie deterministically by insertion order) has to_state = 'approved'. A later revoked/rejected row is the latest row, so it flips approved to false without a special case.
  • at parameter (D27 use-time): an optional as-of instant. Only decisions with created_at <= at are considered; default is now. This lets the saga ask “was this ad approved as of the moment we charge?”
  • Never cached. Per D27 the saga re-reads at use-time; callers must not memoise the result across a saga step boundary.
  • Fail-closed on absence: no decisions ⇒ { approved: false, at: null }. A pending/draft ad is never approved.
  • Writer invariant: every moderation_decisions writer MUST store created_at as a millisecond-precision Date.toISOString() (the format the consumer and admin decision paths already use). The created_at <= ? as-of filter and the created_at DESC ordering are lexicographic string comparisons, which are only correct because every writer uses that one fixed-width UTC format. A backfill or manual SQL fix that inserts a different timestamp shape would silently break ordering — so this is a hard contract on writers, not an observed coincidence.
OptionWhy rejected
Keep is_approved as a service-binding RPCapps/api would call itself over the network for one D1 row; adds latency + a second Worker to host the read, with zero isolation benefit while 04/05 are co-located.
Cache is_approved in KV / DO memoryViolates D27’s use-time re-validation. A revoked ad must read false immediately at the next saga step, not after a TTL.
Rename the R2 bucket to nyl-creatives to match the specChurns infra in a single Terraform state for a cosmetic match; the repo is the source of truth for names.
Keep inline rendition per §9Blocks the upload response on WASM encodes and couples upload-Worker CPU to image size; async q-transforms keeps the upload path fast and retries renditions independently.
  • PR-1 ships ad-approval.ts + tests only; it does not wire auction-do.ts/payment-saga-stub.ts internals (those land with specs 04/05). The function is the stable seam they gate against.
  • The naming clash is gone: spec, Terraform, and bindings all say nowyourlink-creatives.
  • Spec 03 §1/§3/§5/§7/§9/§12 are edited to match this ADR; scripts/sync-specs.mjs regenerates the docs-site copy.
  • D27’s “cheap RPC” intent is preserved as “cheap in-worker read”; the saga still re-validates at use-time and never caches.