# Semantic Contracts: Maintenance Banner for Dashboards **Feature Branch**: `031-maintenance-banner` **Created**: 2026-05-21 **Status**: Phase 1 — Design Contracts **Updated**: 2026-05-25 — Added APIKey contracts ## Backend Contracts ### [DEF:MaintenanceEvent:Model] @COMPLEXITY 1 @PURPOSE SQLAlchemy ORM model for maintenance event records. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model] Columns: id (str PK), task_id (str, loose reference to TaskManager), environment_id (str — snapshot of target env at creation), tables (JSON), start_time (datetime tz, required), end_time (datetime tz, nullable), message (text?), status (enum: pending/applying/active/partial/ending/completed/failed), created_at, updated_at. ### [DEF:MaintenanceDashboardBanner:Model] @COMPLEXITY 1 @PURPOSE Canonical "one chart per dashboard" entity — enforces invariant exactly one banner chart per dashboard regardless of active event count. Unique partial index on (environment_id, dashboard_id) WHERE status='active'. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model] Columns: id (str PK), environment_id (str), dashboard_id (int), chart_id (int? — Superset markdown chart ID), banner_text (text — current aggregated markdown), status (enum: active/removed), created_at, updated_at. ### [DEF:MaintenanceDashboardState:Model] @COMPLEXITY 1 @PURPOSE Junction linking events to dashboards. References shared MaintenanceDashboardBanner — does not own chart_id directly. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model] @RELATION DEPENDS_ON -> [MaintenanceEvent:Model] @RELATION DEPENDS_ON -> [MaintenanceDashboardBanner:Model] Columns: id (str PK), event_id (str FK→maintenance_events.id), dashboard_id (int), banner_id (str FK→maintenance_dashboard_banners.id), status (enum: pending_apply/active/removing/removed/apply_failed/removal_failed), created_at, updated_at. Index: (dashboard_id, status). ### [DEF:MaintenanceSettings:Model] @COMPLEXITY 1 @PURPOSE Single-row configuration table for maintenance mode settings. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model] Columns: id (str PK, "default"), target_environment_id (str), display_timezone (str, default "UTC"), banner_template (text — markdown with optional `{start_time}`, `{end_time}`, `{message}`; default includes all three), dashboard_scope (enum: published_only/draft_only/all), excluded_dashboard_ids (JSON), forced_dashboard_ids (JSON), updated_at. ### [DEF:APIKey:Model] @COMPLEXITY 1 @PURPOSE Service-to-service static bearer token for external tool authentication. Raw key (format: `ssk_<43 URL-safe chars>`) shown once at creation; only SHA-256 hash stored. Environment-scoped, permission-gated, revocable. @LAYER models @FILE backend/src/models/maintenance.py (or backend/src/models/api_key.py) @RELATION DEPENDS_ON -> [Base:Model] @INVARIANT Raw key NEVER stored in DB. Only `key_hash = SHA256(token)` persisted. Prefix (`first 11 chars of token`) stored unhashed for admin identification. @INVARIANT One key per external tool (named). Multiple keys per tool possible but not recommended — use key rotation. Columns: id (UUID PK), key_hash (str UNIQUE, SHA-256 of raw key), prefix (str, first 11 chars), name (str), environment_id (str|null — scoped to one env; null = all), permissions (JSON str[]), active (bool), created_at, expires_at (datetime|null), last_used_at (datetime|null). ### [DEF:APIKeyPrincipal:Dependency] @COMPLEXITY 2 @PURPOSE FastAPI dependency resolving an APIKey DB row into a lightweight principal object for RBAC checks. Activated by `X-API-Key` header. Falls through when header absent (allowing JWT to take over). @LAYER dependencies @FILE backend/src/dependencies.py or backend/src/core/auth/api_key.py @RELATION DEPENDS_ON -> [APIKey:Model] @RELATION DEPENDS_ON -> [FastAPI.Header] @PRE X-API-Key header may be present. DB session available. @POST If header valid and key active → returns APIKeyPrincipal(name, environment_id, permissions). If header absent → returns None (fall through to JWT). If invalid/expired → raises HTTPException(401). Verification flow: hash incoming header, lookup in DB by hash, check active + expires_at, update last_used_at. ### [DEF:APIKeyAdminRoutes:Module] @COMPLEXITY 2 @PURPOSE Admin-only API key CRUD endpoints. Keys are read-once secrets. @LAYER api @FILE backend/src/api/routes/admin/api_keys.py (new) @RELATION DEPENDS_ON -> [APIKey:Model] @RELATION DEPENDS_ON -> [has_permission("admin:settings", "WRITE")] @INVARIANT Raw key returned ONLY in POST response — never in GET. @INVARIANT DELETE sets active=false — preserves audit trail (no physical deletion). Endpoints: - `GET /api/admin/api-keys` — list all (active + revoked) - `POST /api/admin/api-keys` — generate new key (returns `{id, raw_key, prefix, name, ...}`) - `DELETE /api/admin/api-keys/{id}` — revoke key (active=false) --- ### [DEF:MaintenanceSchemas:Module] @COMPLEXITY 1 @PURPOSE Pydantic request/response schemas for maintenance API endpoints. @LAYER schemas @FILE backend/src/api/routes/maintenance/_schemas.py @RELATION DEPENDS_ON -> [pydantic.BaseModel] Request schemas: - `MaintenanceStartRequest` — tables, start_time, end_time(opt), message(opt), **environment_id (required — per FR-001 2026-05-25 revision)** - `MaintenanceSettingsUpdate` — all optional partial-update fields Response schemas: `MaintenanceStartResponse`, `MaintenanceEndResponse`, `MaintenanceSettingsResponse`, `MaintenanceEventItem`, `MaintenanceEventListResponse`, `MaintenanceDashboardBannerState`. ### [DEF:SupersetDashboardsWriteMixin:Class] @COMPLEXITY 4 @PURPOSE Extends SupersetClient with dashboard write operations. @LAYER core @FILE backend/src/core/superset_client/_dashboards_write.py @RELATION DEPENDS_ON -> [SupersetClientBase:Class] @RELATION DEPENDS_ON -> [NetworkClient:Class] @PRE SupersetClient is initialized with valid bearer token and environment base URL. @POST Chart created/updated/deleted in Superset. Dashboard position_json updated. @SIDE_EFFECT Modifies Superset dashboards via REST API. @RATIONALE Direct chart API faster than export-import cycle for single-chart ops. Methods: `create_markdown_chart`, `update_markdown_chart`, `update_dashboard_layout` (insert at (0,0), 12 cols, shift existing down), `remove_chart_from_layout`, `delete_chart`, `get_dashboard_layout`. ### [DEF:SqlTableExtractor:Module] @COMPLEXITY 2 @PURPOSE Extract schema.table from virtual dataset SQL+Jinja. Two-phase: Jinja span detection, regex global extraction, sqlparse filtering only on SQL spans (string literals). @LAYER services @FILE backend/src/services/sql_table_extractor.py @RELATION DEPENDS_ON -> [sqlparse] @RATIONALE Table names in {% set %} blocks must survive; stripping Jinja first loses them. ### [DEF:MaintenanceService:Class] @COMPLEXITY 4 @PURPOSE Core business logic: discovery, banner placement/removal, text rebuild. @LAYER services @FILE backend/src/services/maintenance_service.py @RELATION DEPENDS_ON -> [SupersetClient:Class] @RELATION DEPENDS_ON -> [SupersetDashboardsWriteMixin:Class] @RELATION DEPENDS_ON -> [SqlTableExtractor:Module] @RELATION DEPENDS_ON -> [MaintenanceEvent:Model] @RELATION DEPENDS_ON -> [MaintenanceDashboardState:Model] @RELATION DEPENDS_ON -> [MaintenanceDashboardBanner:Model] @RELATION DEPENDS_ON -> [MaintenanceSettings:Model] @RELATION DEPENDS_ON -> [TaskManager:Class] @PRE Active DB session. Superset client initialized. @POST Event/state/banner records updated. Charts created/updated/deleted. @SIDE_EFFECT Superset mutations; DB writes; WebSocket progress events. @RATIONALE Single orchestrator ensures consistency between local state and Superset. Methods: `start_maintenance` (idempotency by (tables, start, end)), `end_maintenance`, `end_all_maintenance`, `find_affected_dashboards`, `ensure_banner_chart` (shared chart per dashboard), `build_banner_text` (template substitution), `rebuild_banner` (update_markdown_chart). ### [DEF:MaintenanceRoutes:Module] @COMPLEXITY 4 @PURPOSE FastAPI route handlers for maintenance endpoints. Dual auth: JWT (existing) + API Key via `require_api_key_or_jwt`. Environment scoping enforced per FR-001 revision. @LAYER api @FILE backend/src/api/routes/maintenance/_routes.py @RELATION DEPENDS_ON -> [MaintenanceService:Class] @RELATION DEPENDS_ON -> [MaintenanceSchemas:Module] @RELATION DEPENDS_ON -> [TaskManager:Class] @RELATION DEPENDS_ON -> [AuthMiddleware:Class] @RELATION DEPENDS_ON -> [APIKeyPrincipal:Dependency] @RELATION DEPENDS_ON -> [require_api_key_or_jwt:Function] 8 endpoints: - `POST /start` — dual auth (JWT or API key with `maintenance:start`), requires `environment_id` - `POST /{id}/end` — dual auth (JWT or API key with `maintenance:end`), validates key scope against event's stored env - `POST /end-all` — dual auth (JWT or API key with `maintenance:end_all`) - `GET /events` — JWT only, auto-expires stale events - `GET /dashboard-banners` — JWT only - `GET /settings` — JWT only - `PUT /settings` — JWT only (admin) ### [DEF:MaintenanceRouter:Module] @COMPLEXITY 2 @PURPOSE APIRouter assembly with prefix and tag. @LAYER api @FILE backend/src/api/routes/maintenance/_router.py @RELATION DEPENDS_ON -> [MaintenanceRoutes:Module] --- ## Frontend Contracts ### [DEF:MaintenanceStore:Module] @COMPLEXITY 3 @PURPOSE Svelte 5 rune store for maintenance state. Shared by management page and Dashboard Hub badge. @LAYER store @FILE frontend/src/lib/stores/maintenance.svelte.js @RELATION DEPENDS_ON -> [requestApi:Function] @RATIONALE Shared store avoids prop drilling across routes. Uses `$effect(subscribe)` per ADR-0007. State: `events` ($state), `settings` ($state), `dashboardBanners` ($state Map), `isLoading`, `error`. Auto-polls events every 30s; WebSocket covers task progress. ### [DEF:MaintenanceApiClient:Module] @COMPLEXITY 2 @PURPOSE Typed fetch wrappers for maintenance API. @LAYER api @FILE frontend/src/lib/api/maintenance.js @RELATION DEPENDS_ON -> [requestApi:Function] 7 functions mirroring the 7 API endpoints. ### [DEF:MaintenanceBannerPage:Component] @COMPLEXITY 3 @PURPOSE Management page at `/maintenance`. Compose settings panel + events table from store. @LAYER route @FILE frontend/src/routes/maintenance/+page.svelte @RELATION DEPENDS_ON -> [MaintenanceStore:Module] @RELATION DEPENDS_ON -> [MaintenanceSettingsPanel:Component] @RELATION DEPENDS_ON -> [MaintenanceEventsTable:Component] @UX_STATE idle/loading/action_in_progress/error/empty @UX_FEEDBACK Toast via `addToast()` from `$lib/toasts.js` ### [DEF:MaintenanceSettingsPanel:Component] @COMPLEXITY 3 @PURPOSE Collapsible settings form for maintenance-specific configuration. API key management is handled centrally in Settings (see [DEF:ApiKeySettingsPanel:Component]). @LAYER component @FILE frontend/src/lib/components/MaintenanceSettingsPanel.svelte @RELATION DEPENDS_ON -> [MaintenanceStore:Module] @RELATION DEPENDS_ON -> [Select:Component] (from `$lib/ui/Select.svelte`) @RELATION DEPENDS_ON -> [Input:Component] (from `$lib/ui/Input.svelte`) @RELATION DEPENDS_ON -> [SearchableMultiSelect:Component] (from `$lib/components/ui/SearchableMultiSelect.svelte`) @RATIONALE Reuses existing form atoms (Select, Input) and dashboard picker (SearchableMultiSelect) — zero new UI primitives needed. Form fields: - Environment: `` text - Scope: native `` (matches ScheduleConfig pattern) - Excluded/Forced: `` with dashboards as `{id, name}` - Banner template: `