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 with JSON cache by default, optional PostgreSQL import/storage support, and web dismiss 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; |
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": [
{
"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 URL resolution¶
web_url is derived in two stages during each poll cycle (run_poll_cycle).
Fast path — pure string mapping in _map_subject_api_url_to_web, no API calls:
Subject type |
API path pattern |
Web URL |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Enrichment path — resolve_web_urls() makes a targeted GitHub API call for notification types where no 1:1 URL mapping exists. Only runs when web_url is still None after the fast path and subject_url is available.
Subject type |
API call |
Resolved to |
|---|---|---|
|
|
|
If enrichment fails (API error, empty response, unknown type), web_url stays None and the UI renders the title as plain text rather than a link.
The enricher is injected via the WebUrlEnricher Protocol. GitHubNotificationsClient implements it, and run_poll_cycle calls resolve_web_urls(notifications, enricher=input.client) before scoring/rules. If no enricher is provided (or resolution fails), fast-path behavior still applies and unresolved web_url values remain None.
3. Configuration¶
YAML file, default path corvix.yaml. Generate a starter with corvix init-config.
3.1 github¶
github:
token_env: GITHUB_TOKEN # env var holding the PAT
api_base_url: https://api.github.com
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
When enabled, Corvix runs provider-based enrichment before scoring/rules. Current provider:
github_latest_comment: fetches latest comment metadata forreason == commentnotifications.
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. |
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 for migration and database-backed workflows. |
|
local build |
Runs |
|
local build |
Runs |
Shared volume corvix_state is mounted at /data. The poller writes notifications.json there; the web service reads it. 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 ( |
|
web |
Bind host for uvicorn (default |
|
web |
Bind port for uvicorn (default |
|
web |
Enable uvicorn reload ( |
9. Current gaps¶
Poller and web still use the shared JSON cache by default; PostgreSQL is not yet the default live storage path.
The poller/web shared file has no explicit locking; behavior relies on filesystem semantics of full-file writes.
Multi-user auth and browser push notifications are not yet part of the active runtime path.
Planned Architecture¶
10. Multi-user support¶
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
When auth.mode is single_user or absent, the system behaves as today: one token from env, JSON cache. When multi_user, tokens come from the users table and PostgreSQL is required.
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.When
database.url_envis set, use Postgres. Otherwise, fall back to JSON.New CLI command
corvix migrate-cachereads the JSON file and inserts records into PostgreSQL for the configured user.Docker Compose already provisions PostgreSQL and passes
DATABASE_URL— only the app needs to start using it.
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¶
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¶
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¶
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.