Corvix — Design Specification¶
Living document. Sections 1–9 describe the current implementation. Later sections contain roadmap and historical design notes.
1. Purpose¶
Corvix fetches a user’s GitHub notifications, scores and filters them with configurable rules, caches the results locally, and presents them through a terminal CLI or a web dashboard.
Current state: single-user workflows backed by PostgreSQL (required), shared by the poller and web service, with web dismiss/mark-read operations.
Target state: multi-user server with PostgreSQL persistence, two-way notification management, theming, and browser push notifications.
2. Data Model¶
2.1 Notification (raw)¶
Normalized view of one GitHub notification thread. Constructed via Notification.from_api_payload().
Field |
Type |
Source |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
derived human-facing GitHub URL; |
|
|
configured account id (for example |
|
|
display label for the configured account |
2.2 NotificationRecord (processed)¶
Wraps a Notification with the output of scoring and rule evaluation. This is what gets persisted and rendered.
Field |
Type |
Description |
|---|---|---|
|
|
Raw notification data |
|
|
Computed priority score |
|
|
True if any matched rule has |
|
|
Names of rules that matched |
|
|
Actions executed (e.g. |
|
|
Local dismissed flag used to hide/remove records |
|
|
Enrichment payload map used by context-aware rules |
2.3 Cache file schema¶
JSON file at the path configured in state.cache_file:
{
"generated_at": "<ISO 8601 UTC timestamp>",
"notifications": [
{
"account_id": "work",
"account_label": "Work",
"thread_id": "1",
"repository": "owner/repo",
"reason": "mention",
"subject_title": "Fix bug",
"subject_type": "PullRequest",
"unread": true,
"updated_at": "2024-01-01T00:00:00Z",
"thread_url": "https://api.github.com/notifications/threads/1",
"subject_url": "https://api.github.com/repos/owner/repo/pulls/1",
"web_url": "https://github.com/owner/repo/pull/1",
"score": 1.0,
"excluded": false,
"matched_rules": ["high-priority"],
"actions_taken": ["mark_read"],
"dismissed": false,
"context": {
"github": {
"latest_comment": {
"author": {"login": "codecov[bot]"}
}
}
}
}
]
}
Canonical persisted schema is the flattened record returned by NotificationRecord.to_dict().
2.4 Hydration and URL resolution¶
Canonical fields are completed during a dedicated hydration stage in each poll cycle (run_poll_cycle), before enrichment/scoring/rules.
Fast path — pure string mapping in map_subject_api_url_to_web, no API calls:
Subject type |
API path pattern |
Web URL |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Hydration fallback path — providers run in order and may call the GitHub API when subject_url or web_url is missing.
github.thread_subjectbackfillssubject_urlfromthread_urlpayload (subject.url).github.web_urlresolvesweb_urlfromsubject_url.
Subject type |
API call |
Resolved to |
|---|---|---|
|
|
|
|
|
|
If hydration fails (API error, empty response, unknown type), web_url stays None and the UI renders the title as plain text rather than a link.
3. Configuration¶
YAML file, default path corvix.yaml. Generate a starter with corvix init-config.
3.1 github¶
github:
accounts:
- id: primary
label: Primary
token_env: GITHUB_TOKEN
api_base_url: https://api.github.com
Multi-account setups add more entries to github.accounts.
Legacy top-level github.token_env and github.api_base_url keys are still accepted as fallbacks for compatibility.
3.2 polling¶
polling:
interval_seconds: 300 # watch loop sleep between cycles
per_page: 50 # GitHub API page size (max 50)
max_pages: 5 # page cap per cycle
all: false # include already-read notifications
participating: false # only notifications the user is participating in
3.3 state¶
state:
cache_file: ~/.cache/corvix/notifications.json
3.4 scoring¶
scoring:
unread_bonus: 15.0 # flat bonus for unread
age_decay_per_hour: 0.25 # subtracted per hour of age
reason_weights: # keyed by GitHub reason string
mention: 50
review_requested: 40
assign: 30
author: 10
repository_weights: # keyed by org/repo
your-org/critical-repo: 25
subject_type_weights: # keyed by subject.type
PullRequest: 10
title_keyword_weights: # substring match (case-insensitive)
security: 20
urgent: 15
3.5 enrichment¶
enrichment:
enabled: false
max_requests_per_cycle: 25
github_latest_comment:
enabled: false
timeout_seconds: 10
github_pr_state:
enabled: false
timeout_seconds: 10
When enabled, Corvix runs provider-based context enrichment before scoring/rules. URL hydration always runs independently of this setting. Current providers:
github_latest_comment: fetches latest comment metadata forreason == commentnotifications.github_pr_state: fetches pull request state metadata for pull-request notifications.
3.6 rules¶
Rules are evaluated in order: global rules first, then per-repository rules for the notification’s repo. All matching rules contribute — there is no short-circuit.
rules:
global:
- name: mute-bot-noise
match:
title_regex: ".*\\[bot\\].*"
actions:
- type: mark_read
exclude_from_dashboards: true
per_repository:
your-org/infra:
- name: mute-chore-prs
match:
title_contains_any: ["chore", "deps"]
actions:
- type: mark_read
exclude_from_dashboards: true
MatchCriteria fields¶
All fields are optional. Unset fields are treated as “match anything”.
Field |
Type |
Semantics |
|---|---|---|
|
|
Exact match against |
|
|
Glob match ( |
|
|
Exact match against |
|
|
Exact match against |
|
|
Case-insensitive substring OR |
|
|
|
|
|
Exact match |
|
|
Score must be ≥ this value |
|
|
Notification must be newer than this |
|
|
Predicates over enriched context paths |
ContextPredicate fields:
Field |
Type |
Semantics |
|---|---|---|
|
|
Dot path within |
|
|
One of |
|
|
Operator value (optional for |
|
|
Case-fold string comparisons for supported operators |
All active criteria must match (AND logic). title_contains_any is OR within itself.
RuleAction types¶
Currently implemented action types:
|
Behaviour |
|---|---|
|
Calls |
|
Calls |
3.7 dashboards¶
dashboards:
- name: triage
group_by: repository # repository | reason | subject_type | none
sort_by: score # score | updated_at | repository | reason | subject_type | title
descending: true
include_read: false
max_items: 100
match: # optional MatchCriteria sub-filter
reason_in: ["mention", "review_requested"]
ignore_rules: # optional per-dashboard exclusions (in addition to global rule excludes)
- reason_in: ["comment"]
context:
- path: github.latest_comment.is_ci_only
op: equals
value: true
Global rule exclusions (rule.exclude_from_dashboards) are applied to every dashboard. Dashboard-level ignore_rules are applied on top of those globals for the selected dashboard.
3.8 auth¶
auth:
mode: single_user # single_user | multi_user
session_secret: ""
3.9 database¶
database:
url_env: DATABASE_URL
4. Pipeline¶
One poll cycle executes the following steps in sequence, per notification:
fetch_notifications()
└─ enrich_notifications() # provider-based, fail-open
└─ for each Notification:
score = score_notification(notification, scoring_config)
eval = evaluate_rules(notification, score, rule_set, context)
result = execute_actions(notification, eval.actions, client, apply_actions)
→ NotificationRecord(notification, score, eval.excluded,
eval.matched_rules, result.actions_taken,
context)
└─ cache.save(records)
4.1 Scoring formula¶
score = unread_bonus (if unread)
+ reason_weights[reason]
+ repository_weights[repository]
+ subject_type_weights[subject_type]
+ sum(weight for keyword in title_keyword_weights if keyword in title.lower())
- age_hours * age_decay_per_hour
age_hours is computed relative to an injectable now (defaults to datetime.now(UTC)). The score can be negative.
4.2 Enrichment¶
Enrichment providers run before scoring/rules and attach namespaced payloads to NotificationRecord.context. Failures are fail-open: poll cycles continue, and errors are reported in the polling summary.
4.3 Rule evaluation¶
evaluate_rules() iterates global_rules + per_repository[notification.repository]. For each rule whose MatchCriteria passes, it accumulates: matched rule name, its actions, and sets excluded = True if exclude_from_dashboards is set. All matching rules contribute; there is no early exit.
4.4 Action execution¶
execute_actions() deduplicates actions by type before executing. Supported actions:
mark_readdismiss
mark_read is skipped when already read. dismiss can mark a record as dismissed when a record context is provided. In dry-run mode, actions are recorded as dry-run:* entries.
The MarkReadGateway and DismissGateway protocols decouple action execution from concrete clients for testing.
5. Storage¶
NotificationCache reads and writes a single JSON file. The full snapshot (all polled records, including excluded ones) is replaced atomically on each save.
PostgresStorage also exists and supports upsert-based persistence by (user_id, thread_id). The migrate-cache CLI command imports JSON cache snapshots into PostgreSQL.
6. CLI¶
Entry point: corvix (corvix.cli:main). All subcommands accept --config PATH (default corvix.yaml).
Command |
Description |
|---|---|
|
Write starter YAML. |
|
One fetch → process → cache cycle. |
|
Runs |
|
Renders dashboards from cache using Rich tables. Does not poll. |
|
Starts Litestar web server. |
|
Imports cache records into PostgreSQL using |
7. Web API¶
Framework: Litestar. Served via uvicorn. Config loaded on every request from the path in CORVIX_CONFIG env var.
Method |
Path |
Description |
|---|---|---|
|
|
Single-page HTML dashboard app served from |
|
|
Same SPA shell, with dashboard selected from URL path (bookmarkable). |
|
|
Returns |
|
|
Returns available UI theme presets. |
|
|
Returns |
|
|
Loads cache, runs |
|
|
Calls GitHub dismiss API and marks the local record dismissed for the account-scoped notification. |
|
|
Backward-compatible dismiss route using the default configured account. |
|
|
Calls GitHub mark-read API and updates local cache state for the account-scoped notification. |
|
|
Backward-compatible mark-read route using the default configured account. |
The SPA auto-refreshes every 15 seconds, populates a dashboard selector from /api/snapshot, and renders grouped tables in a responsive layout.
/api/snapshot response shape¶
{
"name": "<dashboard name>",
"sort_by": "score",
"descending": true,
"generated_at": "<ISO 8601 or null>",
"total_items": 42,
"groups": [
{
"name": "<group key>",
"items": [
{
"thread_id": "...",
"repository": "org/repo",
"reason": "mention",
"subject_type": "PullRequest",
"subject_title": "...",
"unread": true,
"updated_at": "...",
"score": 63.5,
"matched_rules": [],
"actions_taken": []
}
]
}
],
"dashboard_names": ["triage", "overview"]
}
8. Deployment¶
Docker Compose services¶
Service |
Image |
Role |
|---|---|---|
|
|
PostgreSQL service; the shared store for the poller and web. |
|
local build |
One-shot |
|
local build |
Runs |
|
local build |
Runs |
The poller writes notification records and poller status to PostgreSQL; the web service reads them from the same database. There is no shared filesystem volume between the two. Both services mount ./config:/app/config.
Optional development override: if live-reload/source mounts are needed, add a compose override file instead of treating them as the default runtime setup.
Environment variables¶
Variable |
Used by |
Description |
|---|---|---|
|
poller, web |
GitHub PAT source ( |
|
both |
Path to YAML config file inside the container. |
|
both |
PostgreSQL URL source ( |
|
poller |
When |
|
web |
Bind host for uvicorn (default |
|
web |
Bind port for uvicorn (default |
|
web |
Enable uvicorn reload ( |
9. Current gaps¶
Single-user deployments share a fixed seeded
user_id; multi-user auth (per-user sessions/records) is not yet part of the active runtime path.Browser push notifications are not yet part of the active runtime path.
Planned Architecture¶
10. Multi-user support¶
Status: Pending (some prerequisite pieces are implemented, but multi-user auth/session runtime is not).
10.1 Motivation¶
The current design binds to a single GITHUB_TOKEN from the environment. Supporting multiple GitHub users requires per-user token storage, user-scoped data, and authentication on the web layer.
10.2 Database schema¶
Activate the existing PostgreSQL container. Use alembic for migrations (src/corvix/migrations/).
CREATE TABLE users (
id UUID PRIMARY KEY,
github_login TEXT UNIQUE NOT NULL,
github_token TEXT NOT NULL, -- encrypted with Fernet, key derived from session_secret
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE notification_records (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
thread_id TEXT NOT NULL,
repository TEXT NOT NULL,
reason TEXT NOT NULL,
subject_title TEXT NOT NULL,
subject_type TEXT NOT NULL,
unread BOOLEAN NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
thread_url TEXT,
score FLOAT NOT NULL,
excluded BOOLEAN NOT NULL DEFAULT false,
matched_rules TEXT[] DEFAULT '{}',
actions_taken TEXT[] DEFAULT '{}',
dismissed BOOLEAN NOT NULL DEFAULT false,
snapshot_at TIMESTAMPTZ NOT NULL,
UNIQUE(user_id, thread_id)
);
CREATE TABLE user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id),
theme TEXT NOT NULL DEFAULT 'default',
browser_notify BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE push_subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
endpoint TEXT NOT NULL,
p256dh_key TEXT NOT NULL,
auth_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(user_id, endpoint)
);
Use INSERT ... ON CONFLICT (user_id, thread_id) DO UPDATE so each poll cycle upserts rather than replacing. This preserves the dismissed flag across poll cycles — the critical difference from the current full-snapshot-replace approach.
10.3 Storage abstraction¶
Introduce a StorageBackend protocol in storage.py:
class StorageBackend(Protocol):
def save_records(self, user_id: str, records: list[NotificationRecord], generated_at: datetime) -> None: ...
def load_records(self, user_id: str) -> tuple[datetime | None, list[NotificationRecord]]: ...
def dismiss_record(self, user_id: str, thread_id: str) -> None: ...
def get_dismissed_thread_ids(self, user_id: str) -> list[str]: ...
NotificationCache continues to work for single-user CLI mode. A new PostgresStorage class implements the same protocol for multi-user mode. services.py accepts StorageBackend instead of NotificationCache.
10.4 New config sections¶
auth:
mode: single_user # single_user | multi_user
session_secret: "..." # required in multi_user mode, signs session cookies
database:
url_env: DATABASE_URL # env var holding the PostgreSQL connection string
PostgreSQL is required in all modes. When auth.mode is single_user or absent, the GitHub token comes from the environment and records are stored under a fixed seeded user_id. When multi_user, tokens come from the users table and records are scoped per user.
Separation of concerns: the YAML config remains the system config (scoring weights, rules, dashboards). Per-user state (token, preferences, subscriptions) lives in the database.
10.5 Per-user polling¶
services.py gains run_poll_cycle_for_user(config, client, storage, user_id, apply_actions). The watch loop iterates all registered users, instantiating a GitHubNotificationsClient per user with their decrypted token.
Scalability note: sequential polling is O(N users) per cycle. Acceptable for tens of users. For larger deployments, fan out to a task queue (arq + Redis). Not in initial scope.
10.6 Web authentication¶
Session-based auth using Litestar’s session middleware with signed cookies.
Method |
Path |
Auth |
Description |
|---|---|---|---|
|
|
No |
Create account with |
|
|
No |
Validate token, create session. |
|
|
Yes |
Clear session. |
All existing /api/* endpoints become user-scoped: the session injects user_id into request state.
Future enhancement: GitHub OAuth App flow (redirect → authorize → callback). More complex but better UX. Not in initial scope — manual token entry is sufficient for multi-user MVP.
10.7 Token security¶
GitHub PATs stored in PostgreSQL must be encrypted at rest. Use cryptography.fernet.Fernet with a key derived from auth.session_secret via PBKDF2. Tokens are decrypted only at poll time and for on-demand API calls (dismiss). Never returned in API responses.
10.8 Migration path¶
Non-breaking, incremental:
Add
StorageBackendprotocol. MakeNotificationCacheconform.Add
PostgresStorage.PostgreSQL is required: the poller and web both use it (the JSON cache is no longer a live store).
CLI command
corvix migrate-cachereads a legacy JSON file and inserts records into PostgreSQL for the configured user.Docker Compose provisions PostgreSQL, applies migrations via a one-shot
migrateservice, and passesDATABASE_URLto the app.
10.9 Config re-reading¶
Currently _load_runtime_config() reads YAML from disk on every web request. In multi-user mode, cache the parsed AppConfig in Litestar application state on startup, reloaded via a file watcher or SIGHUP.
11. Two-way dismiss¶
Status: Done (dismiss action type and web endpoints are implemented).
11.1 GitHub API mapping¶
Corvix action |
GitHub API call |
Effect |
|---|---|---|
|
|
Thread moves from unread to read |
|
|
Thread is removed from the inbox entirely (“Done” in GitHub UI) |
DELETE is permanent — the notification cannot be un-dismissed on GitHub. The thread will not reappear in future poll results unless there is new activity on it.
11.2 Domain changes¶
Add dismissed: bool = False to NotificationRecord. Update to_dict() and from_dict() accordingly.
11.3 Ingestion changes¶
Add to GitHubNotificationsClient:
def dismiss_thread(self, thread_id: str) -> None:
url = self._build_url(f"/notifications/threads/{thread_id}", {})
self._request_no_content(url, method="DELETE")
11.4 Action execution¶
Add a DismissGateway protocol alongside MarkReadGateway. Extend execute_actions to handle action_type == "dismiss":
Skip if already dismissed.
In dry-run mode: record
dry-run:dismiss.In apply mode: call
gateway.dismiss_thread(thread_id), setdismissed = True.
New rule action type:
actions:
- type: dismiss
11.5 Web endpoint¶
POST /api/notifications/{thread_id}/dismiss
Requires auth. Resolves the user, calls client.dismiss_thread(thread_id), marks dismissed = True in storage, returns 204.
11.6 SPA changes¶
Add a dismiss button (e.g. ×) per notification row. On click, POST to the dismiss endpoint. On success, remove the row from the DOM. Since DELETE on GitHub is permanent, show a brief undo grace period (delay the API call by ~3 seconds, show “Undo” toast) before committing.
12. Theming¶
Status: Done for runtime theme presets and /api/themes; multi-user DB-backed preference persistence is still pending.
12.1 Approach¶
The current SPA already uses CSS custom properties (--bg, --ink, --surface, --accent, --line, --ok, --muted). Theming is a JS-only operation: apply a preset by setting these variables on document.documentElement.style.
12.2 Theme presets¶
Defined as a JS object in the SPA:
const THEMES = {
default: { bg: "#f2efe8", ink: "#181818", surface: "#fffdf8", accent: "#a13d2d", line: "#d7cdbf", ok: "#1e7a4f", muted: "#5f5a50" },
dark: { bg: "#1a1a2e", ink: "#e0e0e0", surface: "#16213e", accent: "#e94560", line: "#333355", ok: "#4ecca3", muted: "#8888aa" },
solarized: { bg: "#fdf6e3", ink: "#657b83", surface: "#eee8d5", accent: "#cb4b16", line: "#93a1a1", ok: "#859900", muted: "#93a1a1" },
};
12.3 Persistence¶
Single-user / no auth: store selected theme name in
localStorage.Multi-user: store in
user_preferences.themeviaPUT /api/preferences/theme. The SPA loads the preference on init.
12.4 API¶
Method |
Path |
Auth |
Description |
|---|---|---|---|
|
|
No |
Returns |
|
|
Yes |
Body: |
12.5 SPA theme picker¶
Add a theme dropdown next to the existing dashboard selector in the header. On change, apply the CSS variables immediately and persist the choice.
12.6 Why not separate CSS files¶
The SPA is an embedded string. CSS custom properties make theming a runtime JS operation with zero extra HTTP requests. This avoids the need to extract static assets or add a build step.
Future consideration: as more UI features land (dismiss buttons, push permission UI, login form), the embedded string will become unwieldy. Consider extracting to src/corvix/web/static/ with Litestar’s static file serving. This is not blocked by theming itself.
13. Browser notifications¶
Status: Pending (notification event model/dispatcher scaffolding exists; browser push delivery is not implemented yet).
13.1 Architecture¶
Three components:
Service Worker — registered by the SPA, receives push events, shows system notifications.
Push subscription — browser generates a subscription (endpoint + keys), SPA sends it to the server.
Server-side push — the poller detects new high-priority notifications and pushes to all of the user’s subscriptions.
13.2 VAPID keys¶
Web Push requires a VAPID key pair. Generate once, store persistently:
browser_notifications:
enabled: true
vapid_private_key_env: VAPID_PRIVATE_KEY
vapid_public_key_env: VAPID_PUBLIC_KEY
Generate with pywebpush or openssl. The public key is served to the SPA; the private key is used server-side to sign push messages.
13.3 Service Worker¶
Served at GET /sw.js (new Litestar route, same embedded-string pattern):
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon.png',
data: { url: data.url }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
13.4 SPA subscription flow¶
On page load, if browser_notify is enabled for the user:
navigator.serviceWorker.register('/sw.js')Notification.requestPermission()registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: <VAPID public key> })POST /api/push/subscribewith the subscription JSON.
13.5 Push trigger conditions¶
Configurable in YAML:
browser_notifications:
enabled: true
min_score: 40.0 # only push notifications scoring above this
reasons: ["mention", "review_requested"] # only push for these reasons
cooldown_minutes: 5 # suppress re-push for the same thread_id within this window
After each poll cycle, the poller compares new/updated records against the trigger conditions. For qualifying notifications, it sends a push to all of the user’s subscriptions via pywebpush.
13.6 API endpoints¶
Method |
Path |
Auth |
Description |
|---|---|---|---|
|
|
No |
Returns the public VAPID key for the SPA. |
|
|
Yes |
Save push subscription to DB. |
|
|
Yes |
Remove push subscription. |
13.7 New module: push.py¶
Handles push delivery:
def send_push(subscription_info: dict, payload: dict, vapid_private_key: str, vapid_claims: dict) -> None: ...
def notify_user(user_id: str, records: list[NotificationRecord], config: BrowserNotificationConfig) -> None: ...
Called from run_poll_cycle_for_user after records are saved.
13.8 Constraints¶
Web Push requires HTTPS in production (service workers only register on secure origins or
localhost).VAPID keys must be persistent across restarts.
Subscription endpoints can expire or become invalid;
send_pushmust handle 410 Gone by removing the subscription from the DB.
14. Implementation plan¶
Dependency graph¶
Phase A: Theming ──────────────────────────────── (independent)
Phase B: Database layer ─────┬─── Phase C: Two-way dismiss
│
├─── Phase D: Multi-user auth
│
└─── Phase E: Browser notifications
(also depends on Phase D)
Recommended sequence¶
Step |
Phase |
Status |
Scope |
Dependencies |
|---|---|---|---|---|
1 |
A |
✅ Done |
Theming: CSS variable presets, theme picker in SPA, |
None |
2 |
B |
✅ Done |
Database: schema, alembic, |
None |
3 |
C |
✅ Done |
Two-way dismiss: |
Step 2 (for |
4 |
D |
Pending |
Multi-user auth: session middleware, register/login/logout endpoints, per-user polling, token encryption |
Step 2 |
5 |
A+ |
Pending |
Theming DB persistence: |
Steps 1 + 4 |
6 |
E |
Pending |
Browser notifications: service worker, VAPID, push subscriptions, trigger logic in poller |
Steps 2 + 4 |
Steps 1 and 2 can be done in parallel. Step 3 can be built and tested in single-user mode before step 4 lands.
New dependencies¶
Package |
Purpose |
Phase |
|---|---|---|
|
Async PostgreSQL client for Litestar handlers |
B |
|
Sync PostgreSQL client for CLI commands |
B |
|
Schema migrations |
B |
|
Fernet encryption for stored tokens |
D |
|
Web Push delivery |
E |
Key risks¶
Token storage: encrypted PATs in PostgreSQL is a significant security responsibility. Compromise of
session_secret+ DB access exposes all tokens.SPA complexity: the embedded HTML string approach will strain under theming controls, dismiss buttons, push permission UI, and login forms. Extract to
src/corvix/web/static/early (during Phase A or B) to avoid compounding tech debt.Dismiss permanence:
DELETE /notifications/threads/{id}cannot be undone. Must implement a client-side undo grace period to prevent accidental dismissals.HTTPS requirement: browser notifications require HTTPS in production. Local dev works on
localhost, but any networked deployment needs TLS termination.Polling scalability: sequential per-user polling is O(N). Fine for tens of users. Task queue needed beyond that.