Releases: StephenSook/context-mod-devvit
v0.6.7
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.timeSavedMinis a heuristic
(today * 4min/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 inStatsRollupnow 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-regexv2.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'ssafeRegex()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 BOTHsrc/rules/regex.tsANDsrc/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:
hourlyActions24hbucket boundary off-by-one —
Pre-fix:if (e.ts <= dayStart || ...) continue;excluded events at
exactlydayStart(24h ago to the ms), butMath.floor((0) / 3_600_000) === 0would have assigned the dayStart-exact event to
bucket 0. Bounds check + bucket assignment disagreed at the lower
edge. Fixed toe.ts < dayStartso 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/statspoll (~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
insrc/lib/has a sibling test (circuitBreaker.test.ts,idem.test .ts,retry.test.ts,result.test.ts, etc.) buttimeout.ts
(extracted in Polish #48) had coverage only via consumers. Added 12
direct tests pinning: fast-path resolve, slow-path timeout fires +
rejects,errFactorycalled 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/ActionTimeoutErrorinstanceof
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:
dryRunActivityper-run isolation + timeout +
sharedwithTimeoutprimitive —dryRunActivityis 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: extractedwithTimeout/runWithTimeout/
actionWithTimeout/RunTimeoutError/ActionTimeoutError
fromhandleActivity.tsinto new shared modulesrc/lib/ timeout.ts. Both orchestrators now import from one source.
dryRunActivityper-run loop now has try/catch + 10s timeout
matching handleActivity. On timeout/throw, records arun-timeout
/run-errorentry 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. EachrunActionmakes 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 eachawait runAction(...)in
a Promise.race againstPER_ACTION_TIMEOUT_MS = 8s. Distinct
error classActionTimeoutError. On timeout: log + push action
result w/status:'error'+wouldHaveCalledcarrying the
timeout message +continueto next action. Refactored the
timeout primitive into a sharedwithTimeout(p, ms, errFactory)
helper used by bothrunWithTimeout(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:
handleActivityper-run TIMEOUT — silent-failure-
hunter audit (Stephen's "do everything" round) caught this. Polish
#41 added a try/catch aroundawait runRun(...)which guarded
THROWS but NOT a hung Promise. Aawait redis.get(...)against a
stuck connection or an ungatedfetch()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()wrapsrunRun()in a
Promise.raceagainst 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).
- 6MB cap, so this gives 2s headroom. +1 test pinning the
Fixed — orchestrator isolation
- AE Polish #41:
handleActivityper-run try/catch — caught
during deep audit (Stephen's "are you actually done" prompt). The
for-loop overcurrent.config.runscalledrunRun()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 eachrunRun
call is wrapped: throw → log +recordEventw/ 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:
readStatsSnapshotdetects pre-Polish-#38 snapshot
shape + auto-heals — Polish #38 added 5 client-shape fields to
StatsRollupbut snapshots written BEFORE the Polish-#38 build
deploy lack them. The freshness gateparsed.computedAt > now - 3_600_000would 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: detectArray.isArray(parsed.hourlyActions24h)as
a shape-fresh signal. Shape-stale snapshots fall through to
recompute o...
v0.6.6
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 putaria-labelon a plain<div>
(ARIA labels are prohibited on roleless elements per WCAG412).
Addedrole="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 includescached: trueflag 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.
- empty-state placeholder + cm-ai-pulse keyframe documentation.
- 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
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
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 defaultisModerator → falseif 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
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
- 3-dot animated pulse + "thinking" word during the 2-8s OpenAI wait
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.
- tagged
- 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=1MUST 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
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
windowSecparam onhistory,attribution,recentActivityrules.
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
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
distinguishaction — 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").NOTcombinator —RuleSetRule+ 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
normalizeCommentpopulates parent post title — title regex on
comment triggers used to never match (item.title was hardcoded ''). Now
fetchesreddit.getPostById(payload.post.id)and surfacespost.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 keycm: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 onpattern\x00flags
(NUL-separated to prevent"a","b"colliding with"ab",""). Invalid
patterns cachenullso 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 100 —
FETCH_LIMIT=100
was silently applied in authorHistory.ts but no schema validation. A
mod writingpostCountGt: 200got 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-windowuninstalled. 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
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.ts—fetchAndDecodew/ 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
discriminatedDecodeResultw/ phase tag for fail-OPEN classification.src/image/hash.ts—computeBlockhashvia blockhash-core 16-bit
grid (256-bit hash → 64 hex chars).hammingDistanceutil 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).findSimilardoes O(N) Hamming comparison;recordHash
dedupes by postId. Fail-OPEN on Redis error.src/rules/imageRepost.ts—runImageRepostRule: 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-workerfilled 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: truew/ comment + report actions.- PostSubmitPayload.post.preview: new field on the V2 trigger payload
shape, populatesItem.imageUrlviapickPreviewVariant(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.jsonRule oneOf union w/
AJV-gatedhammingThreshold(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 bindingsjpeg-js@^0.4.4— JPEG decoder, pure JSblockhash-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 throwtests/state/imageHashStore.test.ts(+9) — findSimilar empty / within-
threshold / above-threshold / corruption-tolerant / fail-OPEN; recordHash
prepend / dedupe / cap / no-optests/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
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 mismatch —
ActionBar.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 tocm:muted-rules:{sub}butrunChecknever read it. README/
CHANGELOG/writeup all promised "dashboard mute stops the bot" — wrong
for 30+ days. WiredisRuleMuted(sub, runName, checkName)into
runCheck.tswith newrunName?5th param threaded fromrunRun.ts.
Mute button finally works as documented.
Fixed — CRITICAL (correctness / silent failures)
- AuthorHistory 429-swallow → mass false-positive moderation —
getPostsByUser/getCommentsByUsercaught Reddit errors broadly +
returned empty arrays.commentCountLt: 5then fired TRUE on EVERY
user during a Reddit rate-limit outage. The single worst silent
failure in the codebase. Addeddegraded: booleanflag — 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 →getCurrentRevthrew "cfg payload
missing" forever, moderation permanently broken until manual Redis
intervention. Wrapped each phase with newPublishErrorclass
(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). TogglingdryRun: falseafter 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
bypassIdempotencyActionContext flag for the mod-menu dryRunActivity
sibling (read-only, repeatable, no retry concern).
- commits the done marker, skips ONLY the side-effect. Added
Added — test gap close
/api/health/deepregression 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-muteregression 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 +causepreservation. - AuthorHistory degraded suite (+3 in
authorHistory.test.ts) +
history-rule degraded skip (+2 inhistory.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.ymlconcurrency — 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
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 bug —lower.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 checkedlower.includes('timeout')(one word) but
explainEvent.ts:151emits'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.getwere called BEFORE the try wrapping
the OpenAI call. A Redis blip or settings throw 500'd the route
with NOlog.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,formatSimulationToastreturned
"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
firstSkipErrorand 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 inapi.ts+forms.tsw/ 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 HTTPcomment, the other didn't).
Eliminates the drift class entirely. - triggers.ts
recordEventunwrapped 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
assertionbody === {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.ts—validateEventSummary
"accepts a well-formed event" now assertsr.value === baseEvent
so a regression returning{ok:true}w/o the success field can't
pass.
Tests
505 → 516 passing (+11 classifier tests).