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.
Context
Section titled “Context”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.
(a) is_approved transport
Section titled “(a) is_approved transport”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— theAuctionDO(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.
(b) R2 bucket name
Section titled “(b) R2 bucket name”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.
(c) Transforms enqueue timing
Section titled “(c) Transforms enqueue timing”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.
Decision
Section titled “Decision”is_approvedis an in-worker exported function, not a service-binding RPC. It lives atapps/api/src/ad-approval.tsasisAdApproved(db, adId, opts?)returning{ approved, at, reason? }, plus a puredecideApproval(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 indexedmoderation_decisionsread byad_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.- The R2 creatives bucket is
nowyourlink-creatives. Terraform (single state) is authoritative; the spec is corrected to match. No second bucket, no rename. - Renditions are generated asynchronously via
q-transforms, enqueued at upload-complete. The “inline” wording in §9 is corrected to describe the queue path.
is_approved contract (authoritative)
Section titled “is_approved contract (authoritative)”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_decisionsrow for the ad (orderedcreated_at DESC, rowid DESC—rowidbreaks the rare same-millisecond tie deterministically by insertion order) hasto_state = 'approved'. A laterrevoked/rejectedrow is the latest row, so it flipsapprovedto false without a special case. atparameter (D27 use-time): an optional as-of instant. Only decisions withcreated_at <= atare considered; default isnow. 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_decisionswriter MUST storecreated_atas a millisecond-precisionDate.toISOString()(the format the consumer and admin decision paths already use). Thecreated_at <= ?as-of filter and thecreated_at DESCordering 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.
Alternatives considered
Section titled “Alternatives considered”| Option | Why rejected |
|---|---|
Keep is_approved as a service-binding RPC | apps/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 memory | Violates 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 spec | Churns infra in a single Terraform state for a cosmetic match; the repo is the source of truth for names. |
| Keep inline rendition per §9 | Blocks 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. |
Consequences
Section titled “Consequences”- PR-1 ships
ad-approval.ts+ tests only; it does not wireauction-do.ts/payment-saga-stub.tsinternals (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.mjsregenerates 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.