The root route lived only in run.py (dev server), so gunicorn wsgi:app
never registered it and the bare domain 404'd in every deployment. Move
it into create_app so dev and prod both redirect / to the login page.
The full-key reveal modal and its click/copy handlers referenced the
plaintext key display that was dropped when keys became hashed. They
matched no elements (harmless no-ops), so remove the orphaned modal
markup and handlers; toggle/delete handlers are untouched.
Keys are now stored as a SHA-256 digest in the existing key column (64
chars, no schema change). Lookups hash the presented key; rows still
holding plaintext are matched and upgraded to their hash on first use.
The full key is shown once at creation and the dashboard displays a
masked placeholder thereafter.
- run.py and the __init__ SocketIO comment referenced the eventlet
worker; production uses gthread. Copy-pasting the old run.py guidance
would have redeployed the exact config wsgi.py warns against.
- README/CLAUDE tech-stack tables said Flask 2.3.3 (actual: 3.1.3);
CLAUDE architecture tree listed a rule_cache.py that doesn't exist.
- Drop the per-request token count that was computed and stored on the
content object but never read (the AI paths recount as needed).
- Remove the conflicting-AI-decisions manual-review branch: the pipeline
early-exits on the first AI match, so it could never see >1 AI result.
- Read/reset the process-global cache-store counter under the cache lock
so it can't tear against the locked increment.
- Resolve the /api/moderate rate-limit bucket from the request's API key
directly (hashed) instead of request.api_key, which isn't populated yet
at the limiter layer — so per-key limits actually apply rather than
always falling back to per-IP.
- Bound registration password length to match the login form (8-200),
so an over-long password can't register an account that can never log in.
- Flash generic messages on admin user/data operation failures and log
the detail server-side instead of surfacing raw exception text.
- Only allow manual decisions on content still awaiting review (status
'flagged'), matching the bulk path, so a decided item isn't overwritten.
- Build the manual-review API-user search's accessible-project list by
membership rather than ownership, so member-only users get results.
- Make API-key and API-user usage counters atomic (single UPDATE ...
SET col = col + 1) so concurrent moderations for the same key/user
don't lose increments to a read-modify-write race.
- Persist each moderation outcome (content status + user counters +
result rows) in a single transaction via save_moderation_outcome,
replacing three independent commits plus a no-op commit that could
leave a decided status with no result rows on partial failure.
- Include rule.action in the batched AI cache fingerprint and clear the
shared result cache when a rule is created/updated/toggled/deleted, so
edits take effect immediately instead of after the 1h TTL.
- Flag (not approve) content that exceeds the per-request chunk cap:
partial coverage must not yield a clean approval.
Bots and link prefetchers hit /auth/google/callback directly with no
OAuth session, making Authlib raise mismatching_state. The broad
except Exception logged it at error level, promoting every crawl into a
Sentry issue (SENTRY#1386). Catch OAuthError specifically and log at
info so it stays a breadcrumb; genuine flow failures still fall through
to the error-level handler.
engineio/async_drivers/_websocket_wsgi.py intentionally raises
StopIteration after a successful WebSocket upgrade to tell gunicorn
"I'm keeping this connection open, don't finalize the response." It's a
protocol signal, not a real error, but Sentry's WSGI instrumentation
captures it anyway and it gets reported as AUTOMOD-3E.
Only drop it on the /socket.io/ path so we don't accidentally hide a
genuine StopIteration bubbling up from elsewhere.
Cloud Run workers were dying with a TypeError in sentry_sdk.init because
`enable_logs=True` isn't a valid kwarg until sentry-sdk 2.40. Our Phase 0
pin of 2.18.0 was older than the kwarg. Two defences:
- Bump sentry-sdk[flask] to >=2.40.0,<3.0.0 so the kwarg works.
- Catch TypeError in create_app() and fall back to sentry_sdk.init without
enable_logs, so future kwarg churn across sentry-sdk versions can't
brick the whole app at boot — error reporting still initialises, we
just log a warning and lose the logs feature.
The eventlet "10 RLock(s) were not greened" warning in those Cloud Run
logs is not the crash cause; gunicorn's eventlet worker monkey-patches
after its own import of gunicorn internals, and the warning is benign
for our workload. The sentry-sdk TypeError was the actual blocker.
Concurrency / performance
- Drop the global threading.RLock in DatabaseService. It was wrapping every
_safe_execute and turning the 8-wide thread pool into a 1-wide one;
Flask-SQLAlchemy's scoped_session already gives each thread its own
session, so the lock was protecting nothing while serialising every DB
op in the process.
- moderate_content now awaits _process_rules and _handle_no_matches; both
push the blocking OpenAI call to asyncio.to_thread so the event loop
stays responsive while waiting on the model.
- DiscordNotifier shares a single module-level requests.Session (instead
of one per instance) and exposes an async wrapper that runs the POST
via asyncio.to_thread, so the moderation request doesn't pin its loop
on Discord latency / 429 retry backoff.
- load_user simplified to a single User.query.get(user_id). Previously it
spun a fresh ThreadPoolExecutor and asyncio event loop on every
authenticated request just to await an async wrapper around the same
primary-key lookup.
- ResultCache now LRU-evicts the oldest entries when at capacity with no
expired entries to free, instead of silently dropping new writes.
_cleanup_expired_entries now sets _last_cleanup_time so the periodic
throttle applies even when called from the hot-path fallback.
Data integrity
- APIUser gained UniqueConstraint(project_id, external_user_id).
get_or_create_api_user catches IntegrityError, rolls back, and reads the
winner's row so concurrent submits for the same external user no longer
produce duplicate APIUser rows that split stats.
- ProjectMember gained UniqueConstraint(project_id, user_id). The
invitation accept flow catches the IntegrityError and treats it as a
benign duplicate-click.
- New scripts/dedup_before_unique_constraints.py to merge any pre-existing
duplicate rows so the new constraints can be added cleanly. Dry-run by
default, --apply to commit.
- Fix the API-user stats detached-instance bug: increment_api_user_stats
in the DB service now does query + mutation + commit in one
_safe_execute call. Previously the orchestrator mutated a detached ORM
instance outside any session and the changes silently never persisted,
so per-user counters drifted.
- Manual-review decision flow now commits the moderation result first,
then atomically commits the APIUser stat bump in the same request
context (instead of relying on the detached-instance pattern).
Correctness
- ContentType enum is now {text, markdown, html} to match the route's
whitelist. Previously the schema declared text/image/video/audio while
the route accepted text/markdown/html, so markdown/html submissions
died with a Pydantic 400 before reaching the route.
- Project invitation emails are normalised to lowercase on store and
compared case-insensitively on accept/decline. Mixed-case stored
invitations were silently locking recipients out of accepting their
own invite.
- Admin create_user lowercases the email and requires 8-char passwords
(matching the public registration policy). The previous 6-char floor
was weaker than the public path despite admin-created accounts often
being privileged.
- AI moderator response parsing now uses (content or "").strip() — OpenAI
returns content=None on safety refusals and the raw .strip() raised
AttributeError that the surrounding except didn't catch.
Security / correctness
- Privilege escalation: rule/api-key/project-update routes now gate on
can_manage_members instead of is_member; plain members could previously
rewrite or disable moderation rules and delete API keys.
- Project.is_owner() AttributeError on non-admin API-user deletion (route
+ template) replaced with user_id comparison.
- Flask-Limiter on /auth/login, /auth/register, /auth/change-password,
and /api/moderate (per API key id).
- Remove silent SECRET_KEY fallback: docker-compose fails hard if unset;
production config validator refuses to boot without SECRET_KEY and
OPENAI_API_KEY.
- Require verified email in Google OAuth auto-link flow.
- User-supplied regex now evaluated via the regex package with a 1s
timeout, closing the ReDoS vector.
- Cap batched AI chunk count (max 40) and concurrent workers (200 -> 10).
- openai>=2.32 (from 1.55.3 so reasoning_effort kwarg works); add regex,
Flask-Limiter, gunicorn, eventlet, psycogreen; drop the stdlib-shadowing
pypi asyncio package.
Production runtime
- New wsgi.py: eventlet.monkey_patch + psycogreen.patch_psycopg so DB
queries cooperate with the event loop; sets SOCKETIO_ASYNC_MODE=eventlet
for app startup.
- Dockerfiles run as non-root app user and CMD gunicorn with
--worker-class eventlet. run.py refuses FLASK_CONFIG=production so a
misconfigured deploy fails loudly.
- cloudbuild.yaml stops managing env vars/secrets on deploy — it was
about to clobber secret-backed env vars with plain ones. Runtime config
is now owned by the Cloud Run service.
SocketIO async-mode fix (was causing full site lockup during API calls)
- async_mode picked via SOCKETIO_ASYNC_MODE env var, default "threading".
The previous sys.modules auto-detect false-positived because
flask_socketio / python-engineio import eventlet on load whenever it is
installed, pinning a non-monkey-patched eventlet server in front of
blocking stdlib I/O.
Real-time dashboard updates
- New content_received WebSocket event fires when the API accepts a
submission; UI renders a "Processing…" row immediately and updates it
in place when moderation_update arrives.
- Fix stale updated_at timestamp in moderation_update payloads.
Batched AI rule evaluation
- Replace the per-rule parallel fan-out (N OpenAI calls) with a single
batched call evaluating all AI-prompt rules at once. On a 6-rule
default project this drops safe-content cost from 5-12s to ~1-4s.
- response_format=json_object + max_completion_tokens=4000 so the model
emits strict JSON and stops at the close-brace.
- OPENAI_REASONING_EFFORT config: minimal/low/medium/high for gpt-5 /
o-series; accepts "none" on gpt-5.4. Works around the gpt-5-nano case
where reasoning tokens ate the full output budget and returned empty
responses.
Client errors
- Malformed / non-UTF-8 JSON bodies now return 400 with a clear message
about UTF-8 encoding instead of a generic 500.
Logging
- Single INFO line per moderation: the existing
"Content <id>: decision in Zs" summary.
- Per-stage timing, websocket scheduling, and batched-AI call details
moved to DEBUG so they are available when diagnosing latency without
cluttering steady-state logs.
Raised the maximum number of workers from 150 to 200 in ai_moderator.py and rule_processor.py to improve parallel processing performance for chunk and rule analysis.
Raised the maximum number of workers from 100 to 150 in ai_moderator.py and rule_processor.py to improve parallel processing performance for chunk and rule analysis.
Lowered the maximum number of workers in ThreadPoolExecutor from 200 to 100 in ai_moderator.py and rule_processor.py to optimize resource usage and prevent potential overload during parallel processing.
Raised ThreadPoolExecutor worker limits from 50 to 200 in AI moderation and rule processing to improve OpenAI API concurrency. Increased SQLAlchemy pool_size to 50, pool_timeout to 60, max_overflow to 100, and DB_THREAD_POOL_WORKERS to 100 for better database performance under load.
Raised thread pool worker limits from 10 to 50 in AI moderation and rule processing for improved parallelism under load. Increased max cache size in result_cache.py from 10k to 50k entries to handle higher concurrency. Updated OpenAI client connection pool and limits for better burst handling. Increased max chunk size for content processing to 150k characters for efficiency with large context models.
Refactored content statistics queries in dashboard routes to use a single optimized database query via db_service.get_content_counts_by_status. The new implementation reduces multiple queries to a single grouped query, improving performance when retrieving content status counts for projects.
Introduces a new /api/test/error endpoint to intentionally trigger various exceptions (e.g., ZeroDivisionError, KeyError, ValueError, etc.) for testing error monitoring tools like Sentry. This endpoint is for testing purposes only and should not be enabled in production.
Simplifies the logging statement when a chunk is rejected by calculating the number of remaining chunks in a variable and using it in the log message. This improves readability and maintainability.
Redirect authenticated users from login/register to dashboard. Increase AI moderator chunk size and add early exit on rejection for faster moderation. Expand result cache size and TTL, refine aggressive cleanup to only remove expired entries, and add warnings when cache is full but no expired entries are found.
Refines the cache information display by checking for stored cache results instead of relying solely on request time and cache hits. This provides more accurate feedback about cache usage in moderation requests.
Improved readability of the debug log statement by calculating the number of chunks in a separate variable before logging. No functional changes to chunking logic.
Introduces a context wrapper in AIModerator to ensure Flask application context is available during parallel chunk processing. Also updates logging for chunking and refines cache info reporting in ModerationOrchestrator for improved clarity and accuracy.
Updated ai_moderator.py to process content chunks in parallel using ThreadPoolExecutor for improved performance. Modified base.html to display the real-time connection indicator only for authenticated users.
Eliminated several info and error log statements related to content size, chunking, and API call details to reduce log verbosity and potential exposure of sensitive content. This streamlines the logging output and focuses on essential warnings and errors.
Adds a hard character count limit (50,000 chars) for content analyzed with custom prompts to address issues with inaccurate token counting. Content exceeding this limit is split by character count to ensure safe processing, improving reliability when tiktoken misestimates token usage.
Replaces token count debug logs with more detailed error logs including content length, prompt length, message lengths, content type, and a preview of the content. This aids in identifying cost-related issues with OpenAI API usage.
Introduces a check to raise a ValueError if a custom moderation prompt is too large to allow sufficient space for content and output. Adds detailed logging for prompt and content token counts to aid debugging and prevent exceeding model context window limits.
Adjusts the calculation of available tokens for content to use a larger safety margin and buffer, accounting for tokenizer inaccuracies and large custom prompts. Adds logging for unusually large prompts and enforces a hard cap on content tokens to prevent exceeding model context limits.
Eliminated the Rule Cache statistics card from the system health admin template and expanded the AI Result Cache card to full width. This simplifies the cache statistics section and focuses on AI Result Cache metrics.
Changed the default value for OPENAI_MAX_OUTPUT_TOKENS from 128000 to 500 in AIModerator. This likely addresses performance or cost concerns by limiting the maximum number of output tokens generated.
Improved the token calculation logic in AIModerator to better estimate prompt and message overhead, ensuring more accurate content token allocation. Updated OPENAI_MAX_OUTPUT_TOKENS default in config.py to 500, reflecting the typical size of AI moderator responses.
Replaces detailed exception messages with generic error messages in API, dashboard, and manual review routes to prevent leaking sensitive information. Also updates the error handler to return a generic internal server error message.
Added HTML escaping in rules.js to prevent XSS when displaying rule details and editing forms. Updated manual_review/index.html to use DOM manipulation instead of HTML string concatenation for selected content in the bulk action modal, improving security and robustness.
Added integrity and crossorigin attributes to external CDN resources in base.html and dashboard/content.html for enhanced security. Updated .claude/settings.local.json to allow additional Bash commands: curl, openssl dgst, and openssl base64.
Updated docstrings, comments, and UI labels to remove the '(GDPR compliance)' note from user data deletion actions. This change clarifies the purpose of these actions without referencing GDPR specifically.
Updates API user listing, detail, and deletion routes and templates to maintain the selected project filter (project_id) across navigation and actions. This ensures users remain within the context of the filtered project when viewing, searching, or deleting API users, and provides clearer feedback and navigation in the UI.
Implements GDPR-compliant user data deletion in the admin and manual review interfaces, including backend service methods and UI modals for confirmation. Adds search and deletion endpoints, updates templates for user data management, and improves quick search functionality for API users. Also corrects the repository URL in the installation guide.