diff --git a/database/schema.sql b/database/schema.sql
index ca5a8e7..bcd3301 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -25,6 +25,30 @@ CREATE TABLE IF NOT EXISTS avatar_reviews (
reviewed_by TEXT REFERENCES users(id),
review_note TEXT
);
+CREATE TABLE IF NOT EXISTS notifications (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ category TEXT NOT NULL CHECK(category IN ('isac', 'contributions', 'comments', 'builds', 'guides', 'system')),
+ source TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ link TEXT,
+ mandatory INTEGER NOT NULL DEFAULT 0 CHECK(mandatory IN (0, 1)),
+ created_at TEXT NOT NULL,
+ read_at TEXT,
+ emailed_at TEXT
+);
+CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications(user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS notifications_user_unread_idx ON notifications(user_id, read_at, created_at DESC);
+CREATE TABLE IF NOT EXISTS notification_preferences (
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ category TEXT NOT NULL CHECK(category IN ('isac', 'contributions', 'comments', 'builds', 'guides', 'system')),
+ in_app_enabled INTEGER NOT NULL DEFAULT 1 CHECK(in_app_enabled IN (0, 1)),
+ browser_enabled INTEGER NOT NULL DEFAULT 0 CHECK(browser_enabled IN (0, 1)),
+ email_frequency TEXT NOT NULL DEFAULT 'none' CHECK(email_frequency IN ('none', 'immediate', 'daily')),
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY(user_id, category)
+);
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'draft', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index 41f7217..c146d3b 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -659,3 +659,79 @@ the default SHD portrait. A stale review must never act on a newer upload.
Historical entries retain metadata and audit evidence even when their image is
no longer available. At narrow widths cards become one column, retain a large
portrait preview, and expose a minimum 44-pixel review action.
+
+### Notification network
+
+Authenticated navigation exposes a compact inbox glyph with a numeric unread
+badge; mobile navigation provides the same explicit `INBOX` destination and
+count. Notifications live under the agent-profile namespace and use an explicit
+profile context trail back to account controls. The inbox is a dense
+communication registry with a source rail, category,
+title, timestamp, body, destination, and textual read state. Unread messages use
+the orange action rail. Mandatory ISAC notices use the blue operational rail and
+must always remain visible in the in-app inbox.
+
+Preferences are channel-by-category rather than global. Each category controls
+in-app, browser, and email delivery independently. ISAC in-app delivery is
+locked on, while its browser and email channels remain optional. Email defaults
+to off and offers `OFF`, `IMMEDIATE`, and `DAILY DIGEST`; browser delivery must
+follow an explicit browser permission gesture. Never imply background push when
+only active-site browser polling is available. At narrow widths preference rows
+stack their identity above channel controls and notification entries collapse
+their source rail above the message.
+
+The main Agent Profile surfaces the five most recent enabled inbox messages
+before authored-content modules and includes unread count as a profile metric.
+This preview preserves mandatory, unread, source, and category states, uses
+single-line truncation at desktop density, and links to either the notification
+destination or full profile inbox. On mobile, metadata becomes its own row and
+message body copy may wrap.
+
+Inbox messages use sender identity and semantic tone. ISAC sources use the SHD
+system mark, blue system labeling, and an explicit `SYSTEM` descriptor;
+community activity uses an agent portrait and `AGENT` descriptor. Positive
+outcomes use green, adverse or corrective outcomes use red, and informational
+content stays neutral. Message titles and body copy state the actual outcome;
+do not expose internal sentiment labels such as `POSITIVE`, `NEGATIVE`, or
+`NEUTRAL`. Reserve visible badges for operationally meaningful severity such as
+`WARNING` or `ERROR`, while the message wording remains understandable without
+depending on color alone.
+
+The inbox command header pairs the page identity with a compact bordered unread
+summary and a same-height preferences action. Filter selects and adjacent
+actions share a 40-pixel control height and vertically centered labels. Within
+each message, the tone/title/timestamp header uses modest vertical padding and a
+subtle lower rule: keep it dense, but never collapse it into a text-height strip.
+On mobile the unread summary and preferences action share one grid row, while
+filter controls stack into full-width, minimum 44-pixel targets.
+
+Page hierarchy uses the shared eyebrow breadcrumb rather than a separate
+breadcrumb row above a command header. It retains the short orange rule, renders
+ancestors as orange links, separates levels with a muted slash, and marks the
+final item as the current page. A top-level page may use a single current item.
+Do not duplicate the same hierarchy in both an eyebrow and a second contextual
+navigation strip; this creates visual overlap and wastes vertical space. The
+breadcrumb always uses intrinsic height and must not inherit the full-height
+primary-navigation treatment. Likewise, in-page command and message headings
+must use their named component classes rather than the global application
+`header` element treatment.
+
+`VIEW DETAILS` opens the standard in-page modal rather than navigating
+immediately. The modal repeats sender, source, category, semantic tone, time,
+full message body, read control, and optional related-page destination. This
+keeps review context in the inbox and makes outbound navigation deliberate.
+
+ISAC message content uses the structured system-transmission body. A compact
+blue `ISAC TRANSMISSION` kicker precedes the summary, and recognized supporting
+fields such as `OVERSIGHT REASON`, `REQUIRED ACTION`, or `DETAILS` become a
+labeled detail row beneath a subtle divider. The expanded message view uses the
+same hierarchy at reading size. Community messages remain conversational body
+copy; do not apply the system-transmission frame to agent comments.
+
+Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete
+message when preview copy is truncated to two lines. A separate bordered orange
+response action navigates directly to the known workflow and uses specific
+language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or
+`VIEW BUILD & REPLY`; never substitute a generic destination label when the
+notification type is known. The same response label appears in the detail
+modal. On mobile, response actions become full-width 44-pixel targets.
diff --git a/package.json b/package.json
index b0338f7..367ff41 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,9 @@
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs",
"db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
+ "notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
+ "notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
+ "notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
"env:encrypt": "node scripts/env-files.mjs encrypt",
"env:decrypt": "node scripts/env-files.mjs decrypt",
"env:encrypt:development": "dotenvx encrypt -f .env.development",
diff --git a/scripts/notification-digest.mjs b/scripts/notification-digest.mjs
new file mode 100644
index 0000000..f919ebc
--- /dev/null
+++ b/scripts/notification-digest.mjs
@@ -0,0 +1,41 @@
+import { connect } from '@tursodatabase/database';
+import nodemailer from 'nodemailer';
+import { resolve } from 'node:path';
+
+if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) throw new Error('SMTP delivery is not configured.');
+const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
+const transport = nodemailer.createTransport({
+ host: process.env.SMTP_HOST,
+ port: Number(process.env.SMTP_PORT || 587),
+ secure: process.env.SMTP_SECURE === 'true',
+ auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
+ requireTLS: process.env.SMTP_SECURE !== 'true',
+ disableFileAccess: true,
+ disableUrlAccess: true
+});
+const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
+const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
+ FROM notifications n
+ JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
+ JOIN user_emails e ON e.user_id=n.user_id
+ WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)).all();
+let sent = 0;
+for (const recipient of recipients) {
+ const items = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
+ JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
+ WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`)).all(recipient.user_id);
+ if (!items.length) continue;
+ const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
+ const rows = items.map((item) => `
Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.
diff --git a/src/routes/api/notifications/unread/+server.ts b/src/routes/api/notifications/unread/+server.ts
new file mode 100644
index 0000000..da022b5
--- /dev/null
+++ b/src/routes/api/notifications/unread/+server.ts
@@ -0,0 +1,18 @@
+import { getSessionUser } from '$lib/server/auth/session';
+import { getDatabase } from '$lib/server/database';
+import { error, json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ cookies }) => {
+ const user = await getSessionUser(cookies);
+ if (!user) error(401, 'Sign in required');
+ const database = await getDatabase();
+ const rows = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link,n.created_at
+ FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
+ WHERE n.user_id=? AND n.read_at IS NULL AND COALESCE(p.browser_enabled,0)=1
+ ORDER BY n.created_at DESC LIMIT 20`)).all(user.id);
+ const count = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
+ LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
+ WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
+ return json({ count, notifications: rows });
+};
diff --git a/src/routes/notifications/+page.server.ts b/src/routes/notifications/+page.server.ts
new file mode 100644
index 0000000..5b14ad3
--- /dev/null
+++ b/src/routes/notifications/+page.server.ts
@@ -0,0 +1,6 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = ({ url }) => {
+ redirect(308, `/account/notifications${url.search}`);
+};
diff --git a/src/routes/terms/+page.svelte b/src/routes/terms/+page.svelte
index 7761f0b..85316b3 100644
--- a/src/routes/terms/+page.svelte
+++ b/src/routes/terms/+page.svelte
@@ -23,7 +23,7 @@
02
ACCOUNTS & SECURITY
You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.
Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.
03
USER CONTENT
You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.
Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.
04
COMMUNITY CONDUCT
Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Community Oversight may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.
- 05
INFORMATION WE PROCESS
Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, and comments. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.
For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.
OAuth and email providers process information under their own policies.
+ 05
INFORMATION WE PROCESS
Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, comments, notification messages, read state, and delivery preferences. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.
For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.
Browser and email notification delivery is optional except that operational ISAC notices remain available in the in-app inbox. Email frequency is configurable by notification category and defaults to off. OAuth and email providers process information under their own policies.
06
ESSENTIAL COOKIES
SHD.build currently uses only first-party cookies that are strictly necessary to provide authentication and protect sign-in flows. We do not currently use analytics, advertising, behavioral-tracking, or personalization cookies. These essential cookies cannot be disabled through a preference control. You may prevent cookies through your browser, but account sign-in will not function correctly.
COOKIE
PURPOSE
DURATION
PROTECTION
shd_session
Keeps you securely signed in and associates requests with your server-side session.
Up to 30 days, or until sign-out or session invalidation.
HTTP-only, SameSite=Lax, Secure in production.
shd_discord_oauth_state
Validates a Discord sign-in or linking response and helps prevent request forgery.
Up to 10 minutes; removed when the callback is processed.
HTTP-only, SameSite=Lax, Secure in production; limited to the Discord authentication path.
The informational notice remembers its dismissal in browser local storage under shd-essential-cookie-notice. It is not sent with web requests and can be removed through your browser’s site-data controls. OAuth state remains in a short-lived HTTP-only cookie because it must be securely validated by the server and unavailable to browser scripts.
If non-essential technologies are introduced later, this policy and the site’s consent controls will be updated before those technologies are enabled where consent is required.
07
RETENTION & DELETION
IP addresses, user-agent strings, normalized device information, and credential references in login history are retained for no more than 30 days, then automatically removed from the event. Non-sensitive facts such as event time, provider, outcome, failure category, and whether a session was created may remain for security and support history.
Deleting your account removes active sessions, credentials, verified email, provider profiles, passkeys, permissions, and uploaded avatar. You may also select immediate purging of retained login-event history during self-deletion. Public contributions may remain attributed to “Deleted Agent.” Backups and records required for fraud prevention, dispute resolution, or law may persist for a limited period where applicable.
08
THIRD-PARTY SERVICES
SHD.build may connect to Discord, email delivery services, and future identity providers. Their services are governed by their own terms. Links to other sites do not imply endorsement. You authorize the minimum provider data requested when choosing a linked sign-in method.