# 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: `<Select>` with env options
- Timezone: `<Input>` text
- Scope: native `<input type="radio" bind:group>` (matches ScheduleConfig pattern)
- Excluded/Forced: `<SearchableMultiSelect>` with dashboards as `{id, name}`
- Banner template: `<textarea>` with live preview div
- Save: `<Button isLoading={isSaving}>` from `$lib/ui/Button.svelte`

Collapsible via `<details><summary>` (matches migration page pattern).

@UX_STATE idle/saving/saved/error

### [DEF:MaintenanceEventsTable:Component]
@COMPLEXITY 3
@PURPOSE Active/completed events table with removal actions.
@LAYER component
@FILE frontend/src/lib/components/MaintenanceEventsTable.svelte
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
@RELATION DEPENDS_ON -> [MaintenanceApiClient:Module]
@RELATION DEPENDS_ON -> [Button:Component] (from `$lib/ui/Button.svelte`)

Uses inline table pattern from health page (`min-w-full divide-y`), skeleton rows (`animate-pulse`), empty state (`border-dashed bg-gray-50`), badges (inline Tailwind `rounded-full`), confirmation via native `confirm()`. Spinner via `Button(isLoading={true})`.

@UX_STATE idle/removing/error

### [DEF:DashboardGridBannerIndicator:Component]
@COMPLEXITY 2
@PURPOSE Orange badge in Dashboard Hub row when maintenance active.
@LAYER component
@FILE frontend/src/lib/components/DashboardMaintenanceBadge.svelte
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]

Badge: `rounded-full px-2.5 py-0.5 text-xs font-medium bg-orange-100 text-orange-700` (matches existing badge convention). Tooltip via native `title` attribute (matches codebase-wide pattern). No badge when inactive. Skeleton: `animate-pulse bg-gray-200 h-5 w-24 rounded-full`.

@UX_STATE no_maintenance/maintenance_active/loading

### [DEF:ApiKeySettingsPanel:Component]
@COMPLEXITY 2
@PURPOSE Centralized admin section in System Settings (`/settings`) for managing API keys. Keys are cross-cutting infrastructure — any tool (maintenance, migration, translation) can consume them. NOT embedded in Maintenance panel.
@LAYER component
@FILE frontend/src/lib/components/settings/ApiKeySettingsPanel.svelte (new) or embedded in frontend/src/routes/settings/SystemSettings.svelte
@RELATION DEPENDS_ON -> [requestApi:Function]
@PRE User has admin role (admin:settings WRITE). 
@POST API key CRUD operations dispatched. New key raw value shown once in a copyable `<code>` block.
@RATIONALE Read-once key display: raw key shown in a `<pre>` block with Copy button. After user dismisses, raw key is gone forever. List shows prefix only.
@RATIONALE Centralized architecture: API keys live in Settings — not coupled to Maintenance. Future tools (migration API, data export, etc.) all use the same key infrastructure. Maintenance endpoints are just the first consumer.

UI elements:
- Table: columns `Name`, `Prefix` (monospace), `Environment`, `Permissions`, `Active`, `Created`, `Revoke` (button)
- "Generate Key" form: name input, environment select, permissions checkboxes (start/end/end_all), optional expiry date
- After generation: modal/expanding panel with raw key in `<pre>` block + "Copy to clipboard" button + warning "Copy this key now — it won't be shown again"
- Revoke: confirmation dialog, then `DELETE /api/admin/api-keys/{id}`

@UX_STATE idle/generating/generated/revoking/error
