Skip to content

Releases: StephenSook/context-mod-devvit

v0.6.7

19 May 18:36

Choose a tag to compare

AE Polish wave continued — 33 atomic polishes (#18-#54) shipped across
this session covering 3 adversarial-review rounds (silent-failure-
hunter, code-reviewer, gemini-agent) plus brain-dump audit work.
Real bugs caught + fixed: stats wire-shape mismatch (Polish #38 —
dashboard never showed real stats in production), bucket-boundary
off-by-one (Polish #43), shape-stale snapshot poll-spam (Polish #44),
per-run hang vector (Polish #42), per-action hang vector (Polish #47),
imageRepost fail-OPEN gap (Polish #26), filter regex ReDoS hole
(Polish #35), distinguish action server/client type drift (Polish
#53), LCS browser-hang on large configs (Polish #52), README hero
showed v0.5.x state (Polish #51). Plus shared withTimeout primitive
extraction + dryRunActivity isolation parity (Polish #48), Polish #50
sibling tests for the shared primitive, Polish #49 ARCHITECTURE.md
section 8.5 documents the new orchestrator invariants. 749 tests
green (was 633 at session start). tsc + lint clean. Zero production
npm-audit vulnerabilities. CI all-green across 8 jobs.

Fixed — UX honesty

  • AE Polish #46: "Mod time saved (est.)" label qualifier
    silent-failure-hunter Finding 3. timeSavedMin is a heuristic
    (today * 4 min/action), not a measurement. Without the qualifier
    the stat card lies about precision. Minimal-cascade fix: keep field
    name + wire shape stable, change UI label to surface "(est.)".
    Field-comment in StatsRollup now declares "HEURISTIC ESTIMATE —
    not a measurement" so future devs don't promote it to a real
    measured field.

Security — bounded-input ReDoS defense

  • AE Polish #45: bounded safeTest() wraps RegExp.test in regex
    cache
    — silent-failure-hunter Finding 5. safe-regex v2.x only
    analyzes star-height on the NFA; it does NOT detect backref
    (/^(.*?)\1+$/) or lookaround (/^(?=(a+))\1*$/) ReDoS
    patterns. Both V8-supported, both catastrophic on adversarial
    input. Mod-controlled wiki configs could embed these and slip
    through Pull-Forward #8's safeRegex() check. Defense-in-depth:
    safeTest(re, target) truncates target to 100KB before
    .test(). Bounded input → bounded worst-case backtracking time.
    Doesn't make pattern SAFE but keeps event loop responsive. Wired
    into BOTH src/rules/regex.ts AND src/core/filters.ts. Module
    docblock explicitly calls out the known safe-regex gap. +3 tests
    (short passthrough, 200KB adversarial → bounded <500ms, match-at-
    boundary).

Fixed — stats edges (silent-failure-hunter findings 2 + 4)

  • AE Polish #43: hourlyActions24h bucket boundary off-by-one
    Pre-fix: if (e.ts <= dayStart || ...) continue; excluded events at
    exactly dayStart (24h ago to the ms), but Math.floor((0) / 3_600_000) === 0 would have assigned the dayStart-exact event to
    bucket 0. Bounds check + bucket assignment disagreed at the lower
    edge. Fixed to e.ts < dayStart so events at exactly the boundary
    land cleanly in bucket 0. +2 tests pinning the boundary on both
    ends (dayStart-exact → bucket 0, now-exact → bucket 23).

  • AE Polish #44: shape-stale snapshot DELETED on fall-through
    Polish #40 detected pre-Polish-#38 shape + recomputed, but didn't
    delete the bad key. Result: every /api/stats poll (~10s cadence)
    for the next 1h would re-GET + re-parse the same stale snapshot,
    fall through again, recompute again — defeating the cache. Mirrors
    the Polish #6 corrupt-snapshot delete pattern. +1 test pinning that
    the snapshot key is GONE after a shape-stale fall-through.

Tested — shared primitive coverage

  • AE Polish #50: tests/lib/timeout.test.ts (sibling-convention pin)
    — gemini-agent third-pass review MEDIUM finding. Every other primitive
    in src/lib/ has a sibling test (circuitBreaker.test.ts, idem.test .ts, retry.test.ts, result.test.ts, etc.) but timeout.ts
    (extracted in Polish #48) had coverage only via consumers. Added 12
    direct tests pinning: fast-path resolve, slow-path timeout fires +
    rejects, errFactory called AT timeout (factory not value — preserves
    stack-at-reject semantic), timer cleanup in finally on both
    resolve + reject paths, sub-wrappers use correct budget + correct
    error class, RunTimeoutError/ActionTimeoutError instanceof
    differentiation (catch blocks can tag failures correctly), exported
    constants are sensible (run > action, run > imageRepost's internal
    8s). Refactored timer-side promise to RESOLVE w/ a sentinel (vs
    reject) so Promise.race never settles via rejection — avoids
    unhandled-rejection noise under vitest fake timers + Node strict
    mode. 747 tests green (was 735).

Fixed — sibling-orchestrator parity

  • AE Polish #48: dryRunActivity per-run isolation + timeout +
    shared withTimeout primitive
    dryRunActivity is the
    non-contract sibling that powers the mod-menu "Test rules on this
    item" form. Pre-fix it had the same hang vectors handleActivity
    had before Polish #41/#42: a runRun that throws or hangs would
    stall the form submit until Devvit's request timeout silently
    failed it. Mod gets a confusing "form failed" toast with no
    diagnosis. Fix: extracted withTimeout / runWithTimeout /
    actionWithTimeout / RunTimeoutError / ActionTimeoutError
    from handleActivity.ts into new shared module src/lib/ timeout.ts. Both orchestrators now import from one source.
    dryRunActivity per-run loop now has try/catch + 10s timeout
    matching handleActivity. On timeout/throw, records a run-timeout
    / run-error entry in the DryRunResult so the toast surfaces what
    failed instead of just dropping the run silently. +3 tests
    (throw isolation, timeout fires + next run evaluates, configPresent
    short-circuit). 735 tests green (was 732).

Fixed — orchestrator hang defense (pass 2)

  • AE Polish #47: per-ACTION timeout cap — second-pass code-reviewer
    audit caught this gap. Polish #42 wrapped the rule-eval phase
    (await runRun(...)) in a Promise.race against 10s, but the
    action-dispatch loop (for action in result.actions { await runAction(...) }) was UNGUARDED. Each runAction makes a Reddit
    API call (reddit.remove, reddit.banUser, etc.) — same hang
    vector Polish #42 was meant to close, just one level deeper. A
    Devvit platform hiccup mid-action or a transient Reddit 5xx that
    never closes the connection would stall the action loop + block
    subsequent actions for the same triggered check. Fix:
    actionWithTimeout(p, kind) wraps each await runAction(...) in
    a Promise.race against PER_ACTION_TIMEOUT_MS = 8s. Distinct
    error class ActionTimeoutError. On timeout: log + push action
    result w/ status:'error' + wouldHaveCalled carrying the
    timeout message + continue to next action. Refactored the
    timeout primitive into a shared withTimeout(p, ms, errFactory)
    helper used by both runWithTimeout (Polish #42) and the new
    actionWithTimeout (Polish #47). 8s ceiling is generous —
    Reddit's mod-action SLA is sub-second. +1 test pinning the
    per-action timeout behavior via fake timers. 732 tests green
    (was 731).

Fixed — orchestrator hang defense

  • AE Polish #42: handleActivity per-run TIMEOUT — silent-failure-
    hunter audit (Stephen's "do everything" round) caught this. Polish
    #41 added a try/catch around await runRun(...) which guarded
    THROWS but NOT a hung Promise. A await redis.get(...) against a
    stuck connection or an ungated fetch() returning a Promise that
    never resolves would silently stall the entire for-loop until
    Devvit's platform request-timeout kicked in — no log line, no
    recorded event, runs N+1 never evaluate. HIGH-severity silent
    failure pattern. Fix: runWithTimeout() wraps runRun() in a
    Promise.race against a 10s ceiling. Distinct error class
    (RunTimeoutError) so the recordEvent payload differentiates
    (run-timeout) from (run-error). 10s is generous — the slowest
    legit Phase-4 rule (imageRepost) has an 8s internal fetch timeout
    • 6MB cap, so this gives 2s headroom. +1 test pinning the
      Promise.race behavior via fake timers + assert next-run-evaluates.
      725 tests green (was 724).

Fixed — orchestrator isolation

  • AE Polish #41: handleActivity per-run try/catch — caught
    during deep audit (Stephen's "are you actually done" prompt). The
    for-loop over current.config.runs called runRun() with no
    per-iteration catch. If a rule throws (which it shouldn't, but
    transient external API errors inside the new history / attribution
    / recentActivity / imageRepost rules CAN bubble up through runRule
    → runCheck → runRun, none of which have catches), the throw would
    abort the for-loop and runs N+1, N+2, etc. for the SAME event
    would never evaluate. Polish #26 already added fail-OPEN at the
    imageRepost rule layer, but defense-in-depth at the orchestrator
    closes the gap for ALL rule paths, not just one. Now each runRun
    call is wrapped: throw → log + recordEvent w/ checkName
    (run-error) + status 'error' + truncated message + continue
    to next run. +4 tests pinning the isolation (throw isolates, both
    runs throw passes through, throw + normal-trigger lets actions
    fire, error message 200-char truncation). 724 tests green (was
    720).

Fixed — backwards-compat

  • AE Polish #40: readStatsSnapshot detects pre-Polish-#38 snapshot
    shape + auto-heals
    — Polish #38 added 5 client-shape fields to
    StatsRollup but snapshots written BEFORE the Polish-#38 build
    deploy lack them. The freshness gate parsed.computedAt > now - 3_600_000 would happily return such snapshots, client would see
    hourlyActions24h === undefined, dashboard would fall back to
    ZERO_STATS — exactly the bug Polish #38 was supposed to fix —
    for up to 1 hour after deploy on every install w/ a pre-deploy
    snapshot. Fix: detect Array.isArray(parsed.hourlyActions24h) as
    a shape-fresh signal. Shape-stale snapshots fall through to
    recompute o...
Read more

v0.6.6

19 May 03:22

Choose a tag to compare

Post-AE wrap. v0.6.5 CI caught its own a11y regression (axe Polish #15
test fired on the new role-less aria-label) — fixed in 56803bb.
Plus AI explainer response cache + DESIGN + ARCHITECTURE refresh.

Fixed — a11y

  • role="status" on AI loading div — axe-prohibited-attr fired
    across Chromium + Firefox + WebKit on the v0.6.5 push because the
    AE Polish #4 loading skeleton put aria-label on a plain <div>
    (ARIA labels are prohibited on roleless elements per WCAG412).
    Added role="status" (canonical for advisory live-region updates).
    The AE Polish #15 axe scan caught the regression w/in 1 push
    cycle — working as designed.

Added — performance / cost

  • AI explainer response cache (Tier 1 #151) — per-event 24h Redis
    cache keyed on FNV-1a64 of the event-summary shape. First click pays
    for OpenAI (~3-8s, ~$0.0001 w/ gpt-4o-mini); second+ click returns
    in ~5ms at $0. Cache hit returns BEFORE the per-user rate-limit gate
    so a cache hit doesn't burn rate quota either. Fail-OPEN on Redis
    read OR write blip — we just re-pay for the call. Sub-scoped for
    tenant isolation. Response envelope includes cached: true flag so
    the client can show a "cached" indicator if it wants.

Changed — docs

  • DESIGN.md — last-updated 5/13 → 5/18. Added light-mode override
    • empty-state placeholder + cm-ai-pulse keyframe documentation.
      Storage key inventory updated for Phase 4 author cache + Phase 4.7
      image-hash store + per-user rate-limit + Polish wave fixes.
  • ARCHITECTURE.md — header "Phase 1 → Phase 4 + Wave W + Wave X"
    bumped to "Phase 1 → Phase 4 + Phase 4.7 + 13 hardening waves
    (S through AE)". Section 9 (rate-limit + breaker) documents
    per-user layer + openaiErrors.ts extraction. "What's deliberately
    NOT here" Phase 4.7 line flipped from "deferred" to "SHIPPED 5/18".

Tests

597 still passing (cache + a11y fix exercised by existing test paths).

v0.6.5

19 May 02:26

Choose a tag to compare

Wave AE wrap-up release. 31/31 audit findings closed across
Critical (8/8), Pull-Forward (10/10), and Polish (16/16). Submission
docs refreshed for the May 27 deadline + the v0.6.x reality.

Changed — docs

  • blog-post-draft.md — v0.2.0 + 256 tests → v0.6.4 + 594 tests;
    "what ships" rewritten to enumerate Phase 4 SHIPPED, Phase 4.7
    image-repost SHIPPED, distinguish action, NOT combinator, windowSec,
    migration tool, 13 hardening waves w/ specific finding examples
  • demo-video-runbook.md — recording window 5/17-19 → 5/24-26;
    fallback path marked historical
  • outreach-drafts.md — 3 send-window refreshes for the May 27
    hard deadline

Added — test gap close

  • triggers-recovery.test.ts (NEW, 3 tests) — pins the AD LOW #10
    fix; double-Redis-failure scenario for /post-submit + /comment-submit
    now exercised. Agent D finding #2 closed.
  • a11y.spec.ts AI panel scan (+1 test) — axe-core scans the AI
    explainer loading skeleton + friendly-error state. Agent D #7 closed.

Refactored

  • recentEvents pre-v1 back-stamp removed (Agent B #8) — minting a
    nonce on every READ caused inconsistent dedup if a pre-v1 row was
    ever written back. v0.5.x is past skeleton; pre-v1 rows would be a
    corruption signal. Drop loudly instead.

Tests

594 → 597 passing (+3 recovery tests; AI-panel a11y is e2e-only +
doesn't count in the unit-test total).

v0.6.4

19 May 01:57

Choose a tag to compare

Wave AE Polish Tier — second batch (3 items). All 13 Polish items now
shipped across v0.6.3 + v0.6.4.

Fixed — silent failures

  • normalize.ts extractTypedField (Agent B #9): getUserByUsername's
    untyped shape used to silently default isModerator → false if Devvit
    renamed the field in a minor release → all authors appear non-mod →
    mod-bypass filters stop matching → bot starts removing mods' own
    posts. Added extractTypedField() that type-checks each field + warn-
    logs missing/wrong-type fields so ops sees shape drift before mass
    mis-moderation lands. Fail-OPEN semantics preserved.

Fixed — operational correctness

  • migrations.ts returns Result (Agent B #10): runMigrations() used
    to return void + caller advanced the schema-version pointer
    regardless. A failed migration was recorded as success → next upgrade
    skipped retry → state corruption permanent. Now returns
    Result<void, string>; /app-upgrade trigger only advances the pointer
    on success.

Changed — CI

  • Coverage hard-gate (Agent D #6): vitest --coverage was
    continue-on-error:true. A coverage-thresholds failure (or vitest
    crash) shipped green. Now fails the job — realistic thresholds are
    cheap to loosen in vitest.config.ts rather than mask in CI.

Tests

594 still passing (no new tests — type-validation + Result refactor
exercised by existing passing test paths).

v0.6.3

19 May 01:52

Choose a tag to compare

Wave AE Polish Tier — first batch (10 items shipped). All Agent C
(frontend judge-first-look) + Agent B (silent-failure) + Agent D
(test-gap) findings closed.

Changed — frontend judge-UX

  • Empty-component layout reservations (Agent C #3): RuleStatsTable,
    RuleCountChips, ModActivityFeed all returned null when empty. First
    rule firing / first mod action popped each component into existence +
    shifted every section below. All three now render heading + empty
    placeholder of the same approximate height.
  • ErrorBanner suppression during initialLoad (Agent C #6): banner
    used to render ABOVE shimmer-loading skeletons during the first 1-2s,
    saying "Telemetry API unreachable" — contradictory + made the whole
    bot look down on install. Gated on !initialLoad so the skeleton speaks
    alone during load.
  • OnboardingTour mount delay (Agent C #7): modal opened at t=0 on
    top of skeleton dashboard — tour was pointing at nothing. Now gated
    on !initialLoad so the modal mounts only after real dashboard chrome
    renders.
  • AI explainer UX polish (Agent C #5):
    • 3-dot animated pulse + "thinking" word during the 2-8s OpenAI wait
      (was static "thinking…" label)
    • Skeleton placeholder for where the explanation will land — no more
      layout pop when response arrives
    • friendlyExplainError() maps known failure patterns (auth, rate
      limit, breaker, missing key, Redis degraded, timeout) to mod-
      friendly sentences instead of raw server-stack text
    • aria-busy / aria-live polite on the loading state for screen-reader
      accessibility
    • Sparkle emoji wrapped in aria-hidden for screen-reader hygiene

Fixed — silent failures

  • recentEvents shape validation (Agent B #3): migrate() v:1 case
    used to cast blindly. A poisoned member like
    {v:1, actions:"not-array"} propagated to statsRollup.ts:51 + crashed
    /api/stats. New isValidRecentEventShape() type-checks every required
    field; bad members throw in migrate() + are logged + dropped.
  • statsRollup corrupt-key cleanup (Agent B #6): a malformed snapshot
    used to be re-parsed on every dashboard poll (~30s). Now DEL'd on
    parse failure so subsequent reads skip the wasted GET + parse.
  • circuitBreaker fail-OPEN log upgrade (Agent B #7): warn → error
    • tagged breaker_unavailable. Combined w/ retryWithJitter a single
      user click can hammer external services 3× during a Redis blip;
      ops should see that billing surface.
  • requireModerator narrow catch (Agent B #5): catch-all 500 used to
    cover transient Reddit-API blips. Now classifyTransient() distinguishes
    network/timeout/429/5xx → 503 (retry hint implied) from programming
    errors → 500.

Changed — CI

  • e2e-cross-browser runs on PRs too (Agent D #5): was gated push-to-
    main-only — PRs got zero Firefox/WebKit signal, cross-engine regressions
    only surfaced after merge + needed reverts. cancel-in-progress already
    false so PR force-push won't pile up.

Added — test gap close

  • demo_mod_alice/bob obfuscation pin (Agent D #4): pins the AD
    CRITICAL fix (f9c1bf4) — /mod-activity?demo=1 MUST contain
    demo_mod_alice + demo_mod_bob, MUST NOT contain real handles
    (CowSufficient3840, vinhbin). Privacy-claim regression protection.
  • recentEvents poisoned-member drop (+2): inner-loop validation
    pinned.
  • requireModerator transient classification (+3): 503 on 5xx,
    ECONNRESET, 429.

Tests

588 → 594 passing (+6 across recentEvents validation, requireModerator
classifier, demo username pin).

v0.6.2

19 May 01:31

Choose a tag to compare

Wave AE Pull-Forward Tier continues — 3 more items shipped post-v0.6.1.
All 10 Pull-Forward items now complete.

Added — security

  • safe-regex catastrophic-backtracking guard in src/rules/regex.ts
    a pattern that compiles cleanly but fails NFA-shape analysis (e.g.
    (a+)+$) is cached as null + logged. Closes Codex MED finding —
    prevents mod-config-induced event-loop DoS.

Added — upstream FoxxMD parity

  • windowSec param on history, attribution, recentActivity rules.
    Counts only entries within the last N seconds before applying thresholds.
    Without it, the cache TTL was the only window control. Mods can now
    write "5+ comments in the LAST HOUR" or "30%+ self-promo domains in
    the LAST DAY". Optional, defaults unlimited (preserves prior behavior).
  • scripts/migrate-upstream-config.mjs — one-shot YAML→JSON5 migrator
    for upstream FoxxMD CM configs. Applies 10 schema renames, drops the
    3 unsupported rule kinds + 7 unsupported action kinds + 3 top-level
    cuts, emits // CUT: header naming everything dropped. Exit 0 clean /
    2 cuts-happened. Closes the operator-adoption story — 15+ FoxxMD
    operators no longer face "translate by hand" as the porting tax.

Tests

578 → 588 passing (+10 for migration script, +2 for safe-regex).

v0.6.1

19 May 01:20

Choose a tag to compare

Wave AE Pull-Forward Tier — 6 items shipped between v0.6.0 (Phase 4.7)
and this tag. All upstream-FoxxMD parity wins + a security gap closure.

Added — upstream FoxxMD parity

  • distinguish action — marks bot comments w/ the green moderator [M]
    tag. Sticky variant for comments pins the bot's reply to the top of
    the thread. Devvit API: Post.distinguish() (0 args) vs
    Comment.distinguish(makeSticky?: boolean) — sticky is comment-only
    per Reddit. Closes Agent A finding #4 ("bot comments look amateur").
  • NOT combinatorRuleSetRule + Check-level both accept
    combinator: 'NOT' now. Triggers iff NONE of the nested rules trigger.
    Short-circuits on first hit. Use case: "catch new accounts EXCEPT
    trusted contributors" — wrap the trust check in NOT inside an outer
    AND. Closes Agent A finding #6 (most common real-world mod pattern).

Fixed — silent bug

  • normalizeComment populates parent post title — title regex on
    comment triggers used to never match (item.title was hardcoded ''). Now
    fetches reddit.getPostById(payload.post.id) and surfaces post.title
    on the Item shape. Mustache templates can use {{item.title}} on
    comment triggers. Closes Agent A finding #5 ("bot looks broken when
    judge writes title-regex on comment trigger").

Changed — security

  • Per-user rate limit on /api/explain-event — layered on top of
    the per-sub 30/hr cap. Per-user 10/hr key cm:rl:explain:{sub}:{username}
    closes the "malicious/runaway mod burns the sub's whole OpenAI quota"
    hole. Denial message tells the mod other mods can still use Explain
    so they don't think the bot is down. Closes Agent F finding #8.

Changed — perf

  • RegExp compile cache in src/rules/regex.ts — was compiling on
    every runRegexRule call. Module-level Map keyed on pattern\x00flags
    (NUL-separated to prevent "a","b" colliding with "ab",""). Invalid
    patterns cache null so no re-throw + re-log on subsequent calls.
    20-regex config = 20x compile elision per event. Closes Agent A
    finding #3.

Fixed — schema

  • History/recentActivity count fields capped at 100FETCH_LIMIT=100
    was silently applied in authorHistory.ts but no schema validation. A
    mod writing postCountGt: 200 got a rule that could never trigger but
    AJV passed it + dashboard showed no warning. AJV now rejects
    unreachable thresholds at parse time. Closes Agent A finding #2.

Removed

  • react-window + @types/react-window uninstalled. Installed in
    Wave AA (v0.5.1) as forward-looking for a scale-trigger that never
    fired. Zero uses in src/. ROADMAP §"Installed but not wired" section
    removed entirely. Re-install is a one-liner if the trigger ever fires.

Tests

561 → 576 passing (+15 across regex cache, distinguish, NOT combinator
at both RuleSet + Check levels).

v0.6.0

19 May 00:55

Choose a tag to compare

Phase 4.7 image-repost detection SHIPPED. Vinh's 0.10 perceptual-blockhash
spike landed clean GO (commits 00feca5 + 19e94f0) — Stephen called
ship after Phase 5 buffer analysis. The full image-hash pipeline (decode +
hash + per-sub store + rule + worker) is now wired.

Added — Phase 4.7 image-repost detection

  • src/image/decode.tsfetchAndDecode w/ 8s timeout + 6MB byte
    cap + Accept header that excludes WebP (jpeg-js can't decode it).
    Branches on Content-Type → UPNG (PNG) or jpeg-js (JPEG). Returns
    discriminated DecodeResult w/ phase tag for fail-OPEN classification.
  • src/image/hash.tscomputeBlockhash via blockhash-core 16-bit
    grid (256-bit hash → 64 hex chars). hammingDistance util w/ Brian
    Kernighan bit-count for the per-event hot path.
  • src/state/imageHashStore.ts — JSON-list per-sub store at
    cm:{sub}:img:hash:recent (cap 500 entries, 30d TTL refreshed on
    write). findSimilar does O(N) Hamming comparison; recordHash
    dedupes by postId. Fail-OPEN on Redis error.
  • src/rules/imageRepost.tsrunImageRepostRule: skip non-image
    posts, decode → hash → findSimilar → record (always, AFTER lookup so
    the post can't match itself) → trigger if match within threshold
    (default 8/256 bits per upstream CM "same image" convention).
  • src/routes/scheduler.ts /image-hash-worker filled in for the
    backfill case where a mod adds the rule AFTER posts already processed.
  • examples/repost-image-watch.json5 — demo-ready, ships behind
    dryRun: true w/ comment + report actions.
  • PostSubmitPayload.post.preview: new field on the V2 trigger payload
    shape, populates Item.imageUrl via pickPreviewVariant (selects the
    largest variant ≤640px — Vinh's optimal RAM zone, 0-2/256 bit hash
    drift vs full-res).
  • Schema entry in src/schema/app.schema.json Rule oneOf union w/
    AJV-gated hammingThreshold (0..256) + windowDays (1..365).
  • Devvit fetch allowlist already covers i.redd.it, preview.redd.it,
    external-preview.redd.it, external-i.redd.it (Vinh added pre-spike).

Added — npm deps

  • upng-js@^2.1.0 — PNG decoder, pure JS, no native bindings
  • jpeg-js@^0.4.4 — JPEG decoder, pure JS
  • blockhash-core@^0.1.0 — perceptual blockhash, pure JS
  • Bundle cost: +88KB to dist/server/index.cjs (per Vinh's spike measurement)

Added — tests

  • tests/image/hash.test.ts (+7) — blockhash shape, determinism, Hamming
    edge cases (0/1/4/256), length-mismatch throw
  • tests/state/imageHashStore.test.ts (+9) — findSimilar empty / within-
    threshold / above-threshold / corruption-tolerant / fail-OPEN; recordHash
    prepend / dedupe / cap / no-op
  • tests/rules/imageRepost.test.ts (+7) — skip non-image, skip no-id,
    fail-OPEN on decode + blockhash, no-match-records, match-triggers,
    honors custom threshold + windowDays

Tests

538 → 561 passing (+23 for Phase 4.7).

v0.5.5

18 May 23:03

Choose a tag to compare

Wave AE Critical Tier (8 sections) — Stephen's deepest "what are you
holding back?" audit: 6 parallel sub-agents (code-explorer, two
silent-failure-hunters, pr-test-analyzer, gemini-agent, general-purpose)
across 6 project sections returned 62 findings. Critical Tier shipped
8 of them as atomic commits in this release.

Fixed — CRITICAL (judge-facing UX destroyers)

  • Wiki path mismatchActionBar.tsx, EmptyState.tsx, +3 demo
    video docs pointed mods at /wiki/contextmod (upstream FoxxMD path).
    Devvit port uses /wiki/botconfig/contextmod. Any judge installing
    fresh + clicking the Wiki button got a 404 on their first attempt.
    v0.5.2 closed the README side; v0.5.5 closes the remaining 5 surfaces.
  • Light mode broken — Z4 light-mode MVP (v0.5.0) added overrides for
    body / text / borders / bg / pre but missed .glass. Cards, modals,
    KeyboardOverlay all rendered invisible white-on-white when mode flipped
    to #fafaf9. Frontend agent flagged as #1 destroyer. Added dark-on-
    light glass overrides.
  • Hard-mute claim was FALSE since v0.3.0 — dashboard mute button
    wrote to cm:muted-rules:{sub} but runCheck never read it. README/
    CHANGELOG/writeup all promised "dashboard mute stops the bot" — wrong
    for 30+ days. Wired isRuleMuted(sub, runName, checkName) into
    runCheck.ts with new runName? 5th param threaded from runRun.ts.
    Mute button finally works as documented.

Fixed — CRITICAL (correctness / silent failures)

  • AuthorHistory 429-swallow → mass false-positive moderation
    getPostsByUser / getCommentsByUser caught Reddit errors broadly +
    returned empty arrays. commentCountLt: 5 then fired TRUE on EVERY
    user during a Reddit rate-limit outage. The single worst silent
    failure in the codebase. Added degraded: boolean flag — true on
    fetch throw, NOT cached (preserves retry semantics), consulted by all
    three Phase 4 rules to skip evaluation on degraded reads.
  • configStore.publish() unwrapped — Redis blip mid-INCR/SET leaked
    an allocated rev with no payload → getCurrentRev threw "cfg payload
    missing" forever, moderation permanently broken until manual Redis
    intervention. Wrapped each phase with new PublishError class
    (4-state discriminator: allocate-rev / write-payload / read-pointer /
    advance-pointer) so callers can show "publish failed, retry" instead
    of a 500.
  • dryRun idempotency marker not written — runAction short-circuited
    BEFORE reserveAction in dry-run mode (despite the docstring promising
    otherwise). Toggling dryRun: false after testing could re-fire
    every action a Devvit retry re-delivered. Fixed: dry-run now reserves
    • commits the done marker, skips ONLY the side-effect. Added
      bypassIdempotency ActionContext flag for the mod-menu dryRunActivity
      sibling (read-only, repeatable, no retry concern).

Added — test gap close

  • /api/health/deep regression suite (NEW, 6 tests) — the v0.5.3
    requireModerator gate had ZERO coverage. Pins non-mod 403, rate-limit
    60/min cap, Redis throw envelope, Reddit throw envelope, success
    shape, and Redis-set-OK-but-get-mismatch failover edge case.
  • runCheck-mute regression suite (NEW, 4 tests) — pins the
    hard-mute contract so a future refactor that drops the mute check
    fails CI loudly. Verifies fail-open behavior + dry-run sibling safety.
  • PublishError suite (+4 in configStore.test.ts) — pins all 4
    failure phases + cause preservation.
  • AuthorHistory degraded suite (+3 in authorHistory.test.ts) +
    history-rule degraded skip (+2 in history.test.ts) — pins both the
    cache-write semantics (degraded NOT cached) and the rule-side skip
    semantics (Lt threshold never false-positives on degraded read).
  • dryRun marker suite (+4 in runAction.test.ts) — pins reserve +
    commit on dry-run, skipped-locked behavior, commitAction failure
    harmlessness, bypassIdempotency semantics, safety-violation refusal.

Changed — CI

  • .github/workflows/ci.yml concurrency — switched from per-ref +
    cancel-in-progress=true (intermediate commits showed red ❌
    "cancelled" on rapid push) to per-SHA + cancel-in-progress=false
    (every commit runs full CI to completion). Zero compute cost on
    public repos.

Tests

516 → 538 passing (+22 across Critical Tier).

v0.5.4

18 May 22:06

Choose a tag to compare

Wave AD-review — Stephen requested a deep-dive review of the v0.5.3
ship. Dispatched pr-review-toolkit:code-reviewer +
pr-review-toolkit:silent-failure-hunter agents in parallel; both
surfaced real regressions in the AD Tier-1 #3 + Phase 4 work that the
local triple-gate didn't catch.

Fixed — CRITICAL (security / correctness)

  • isTransientOpenaiError '5' substring buglower.includes('5')
    matched any error string containing the digit 5. Real exposure:
    'Paste a rule JSON5 in the form field, then submit.' (the
    /explain-rule-submit empty-input error) was being classified as a
    transient OpenAI outage → recordFailure → breaker opened on a typo,
    punishing the entire install. Tightened to a word-boundary 5xx regex
    (?:^|\D)5\d{2}(?:\D|$).
  • isTransientOpenaiError 'timed out' vs 'timeout'
    classifier checked lower.includes('timeout') (one word) but
    explainEvent.ts:151 emits 'OpenAI request timed out after 30s. Retry.'
    (two words). Real OpenAI 30s timeouts were NOT tripping the breaker,
    defeating the entire X37/X43 + AD Tier-1 #3 fix. Added 'timed out'
    alongside 'timeout'.
  • apiKey resolve outside try in /explain-event + /explain-rule-submit
    getOpenaiKey + settings.get were called BEFORE the try wrapping
    the OpenAI call. A Redis blip or settings throw 500'd the route
    with NO log.error, NO breaker classification, NO json response.
    Wrapped in their own try → 503 + structured log on failure. Does
    NOT trip the breaker (Redis/settings being down isn't an OpenAI
    outage).

Fixed — HIGH

  • simulate-rule-submit toast contradiction — when every sample
    failed to normalize, formatSimulationToast returned
    "No recent posts to simulate against." while the AD Tier-1 #2
    suffix said "(N/N skipped — normalize error)". Now returns a
    dedicated "Simulation aborted: every sample failed to normalize
    (first: ...)" toast with the first error excerpt.
  • simulate-rule-submit skip-counter loses error message — the
    per-post catch only logged + counted, the toast just said
    "normalize error" with no actionable info. Now captures
    firstSkipError and appends "(N/M skipped — first: <60 chars>)"
    so the mod sees a real cause.

Fixed — LOW

  • isTransientOpenaiError extracted to src/lib/openaiErrors.ts
    was duplicated byte-for-byte in api.ts + forms.ts w/ a
    "keep in sync" mirror comment. Both copies had the same two
    CRITICAL bugs above; the mirror approach already drifted (one
    copy had an inline // 5xx HTTP comment, the other didn't).
    Eliminates the drift class entirely.
  • triggers.ts recordEvent unwrapped in config-read-fail recovery
    the catch blocks exist to prevent a Redis blip from 500-ing the
    trigger handler (Devvit retry storm). But the recovery path called
    recordEvent (which writes Redis). If the same blip was ongoing,
    the recovery 500'd anyway. Wrapped each recordEvent in its own try.

Added — test gap close

  • tests/lib/openaiErrors.test.ts (NEW, 11 tests) — pins both
    CRITICAL behaviours + user-config exclusions so a future "small
    tweak" can't re-introduce the substring or word-mismatch bugs.
  • tests/routes/api-auth.test.ts — added explicit wire-shape
    assertion body === {ok:true, explanation: 'why'} so the
    internal-Result-to-wire-envelope mapping at api.ts:259 is now
    test-pinned (previously only spy call counts were asserted).
  • tests/core/explain-event.test.tsvalidateEventSummary
    "accepts a well-formed event" now asserts r.value === baseEvent
    so a regression returning {ok:true} w/o the success field can't
    pass.

Tests

505 → 516 passing (+11 classifier tests).