# Static Notification Sync Protocol This document defines the static notification file contract and database sync semantics. Protocol verification status: - Sync plan source: `docs/plans/static-notification-sync-plan.md` - Static sync implementation source: `backend/src/core/config/notification/static_sync.py` - Static sync schema source: `backend/src/core/config/notification/static_schema.py` ## Compatibility strategy - Additive evolution only. - Existing `source_key` values are stable identifiers and must not be repurposed. - Changing notification content must not reset user read state. - Removing a file has no effect by default; database pruning requires explicit CLI flag. ## Static file location Static notification files live under: ```text backend/src/core/config/static/notification/notifications/*.yaml ``` The sync command scans all `*.yaml` files in that directory unless a specific `source_key` is requested. ## File schema Each YAML file contains two top-level sections: - `notification` - `targets` Example: ```yaml notification: source_key: welcome_bonus version: 1 type: system status: published published_at: 2026-04-10T08:00:00Z title: 新用户欢迎通知 body: 你已获得注册奖励,可前往积分中心查看。 payload: action: open_route route: /points tab: balance targets: mode: all_users ``` ### notification - `source_key`: required, string, max 128, unique among static notifications - `version`: required, integer, `>= 1` - `type`: required, string, currently `system` - `status`: required, one of `draft`, `published`, `revoked` - `deleted`: optional, boolean, default `false`, soft-delete this notification - `published_at`: optional ISO 8601 timestamp - `title`: required, non-empty string - `body`: required, non-empty string - `payload`: required, must follow the notification payload protocol ### targets - `mode`: required, one of `all_users`, `user_ids` - `user_ids`: required only when `mode = user_ids` Rules: - `mode = all_users`: `user_ids` must be absent - `mode = user_ids`: `user_ids` must be a non-empty UUID list ## Payload contract `notification.payload` reuses the inbox notification payload schema. ### action = "none" ```yaml payload: action: none ``` ### action = "open_route" ```yaml payload: action: open_route route: /points entity_id: optional-id tab: balance ``` Rules: - `route`: required, max 200 - `entity_id`: optional, max 64 - `tab`: optional, max 32 - `url`: must be absent ### action = "open_url" ```yaml payload: action: open_url url: https://example.com/page ``` Rules: - `url`: required, max 500 - `route`, `entity_id`, `tab`: must be absent ## Database mapping Static notifications map to `notifications` rows using: - `source = 'static'` - `source_key = notification.source_key` Additional notification fields: - `source_version = notification.version` - `content_hash = normalized content hash` Required uniqueness: - `UNIQUE(source, source_key)` where `source_key IS NOT NULL` ## Sync semantics ### Create If `(source='static', source_key=...)` does not exist: 1. Create `notifications` 2. Create `user_notifications` for target users ### Update If `(source='static', source_key=...)` already exists: 1. Update `title`, `body`, `payload`, `status`, `published_at`, `source_version`, `content_hash` 2. Keep existing `user_notifications` 3. Do not reset `is_read` or `read_at` ### Revoke If `notification.status = revoked`: 1. Update `notifications.status = 'revoked'` 2. Set `revoked_at` 3. Keep existing `user_notifications` ### Soft delete If `notification.deleted = true`: 1. Set `notifications.deleted_at` 2. Keep existing `user_notifications` 3. Exclude the notification from list/unread queries ### Draft If `notification.status = draft`: 1. Keep or create the main `notifications` row 2. Do not expose it to users through list/unread queries 3. Do not create new `user_notifications` during sync ### File deletion Deleting a YAML file has no database effect by default. Reason: - Prevent accidental revocation/deletion from filesystem changes If the CLI is executed with `--prune`, then static notifications missing from the scanned files are soft-deleted by setting `deleted_at`. ### Target changes Default behavior: - Newly added targets receive new `user_notifications` - Removed targets do not delete existing `user_notifications` This is intentionally non-destructive. If the CLI is executed with `--reconcile-targets`, existing `user_notifications` that are no longer part of the computed target set are deleted. ## CLI contract Backend CLI command: ```bash PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications ``` Supported options: - `--path `: override static notification source path - `--source-key `: sync only one notification - `--dry-run`: validate and compute changes without writing to DB - `--prune`: soft-delete static notifications missing from the scanned files - `--reconcile-targets`: delete extra `user_notifications` not in the current target set Infra wrapper: ```bash ./infra/scripts/dev-migrate.sh sync-notifications ./infra/scripts/dev-migrate.sh sync-notifications -- --dry-run ./infra/scripts/dev-migrate.sh sync-notifications -- --source-key welcome_bonus ./infra/scripts/dev-migrate.sh sync-notifications -- --prune --reconcile-targets ``` ## Failure behavior - Invalid YAML structure must fail the sync run - Duplicate `source_key` across files must fail the sync run - Invalid payload structure must fail the sync run - Missing target users are not auto-created - Database write failure must fail the sync run No partial silent success is allowed.