# Project Status — v0.6.0 → v0.8.0 Historical Record

> Archived from `.claude/rules/project-status.md` (post-v0.8.3 sync, 2026-07-02).
> These rows represent features shipped after v0.6.0 (2026-05-07) up to and including v0.8.0 (2026-06-17).
> Live table: [.claude/rules/project-status.md](../../../../.claude/rules/project-status.md).

## Major Changes (v0.6.0 → v0.8.0)

| Feature | Issue | Impact |
|---------|-------|--------|
| Admin Setup Wizard + Page-Level Permissions | #194 | Adds `User.permissions` (JSON), `User.password_temporary`, `User.is_bootstrap_admin` fields + migration 0006. Bootstrap one-time setup wizard (`/admin/setup`) creates first real admin; bootstrap credential permanently disabled afterward. `AdminAccountUseCase` + `AdminPermissionRegistry` for account lifecycle. `require_auth(page_key=...)` mandatory per-route gate enforced by AST test. `/admin/accounts` UI for account create/delete/permission-edit with last-accounts guard. Forced password change flow + refresh token revocation. |
| Server-Route RBAC for /v1/user | #199 | Adds the `require_admin` interface dependency (`role == admin` and not `is_bootstrap_admin` → `403 FORBIDDEN`) plus a dedicated API `ForbiddenException`. All `/v1/user` routes (reads + CUD) become admin-only via a single router-level gate (default-deny for new routes); non-admin self-service stays on `/v1/auth/me`. Role is read live from the DB per request, and unauthenticated calls still resolve to 401 before the role check. Non-user `/v1/*` route-level gating remains a follow-up. |
| Centralized Admin Error Handling | #195 | Adds `AdminErrorHandler` + `@admin_error_boundary`, a global `app.on_exception` safety net, and an unauthenticated `/admin/error` page (`src/_core/infrastructure/admin/error_handler.py`). Admin errors route centrally across page boundary / event callbacks / global handler: only 4xx `BaseCustomException.message` is shown (warning), `>=500`/generic show a generic message (negative), and raw `str(exc)` never reaches the UI — full detail goes to the structured log with `context`/`admin_user`/`error_type`/`error_code`. `BaseAdminPage.render_*` delegate to the handler; `str(exc)` leaks removed from docs/ai_usage/accounts/setup/change_password. AST tests enforce the no-leak rule and `/admin/error` gate-exemption (IC-195-1). |
| Admin Loading States | #198 | Adds `button_loading` async context manager (`layout.py`) applied to every admin write button (docs Ask, accounts create/save/remove, setup, change-password, login) — Quasar `loading`+`disable` cleared in `finally`; navigation/refresh kept outside the context. `BaseAdminPage.render_list/render_detail` render a structure-mirroring skeleton (`await ui.context.client.connected()` before fetch so it is visible during slow loads; deleted in `finally`); list rows = `min(page_size, 8)`, detail rows = `max(visible columns, 4)`. |
| Admin Audit Log (Phase 1) | #196 | Adds `src/_core/infrastructure/admin/audit/` (model + `0007` migration + `AdminAuditLogRepository` + `AuditLogger` facade + `safe_user_snapshot` whitelist serializer) and instruments login/logout, account create/update-perms/delete, first-admin create, and password change to record `SUCCESS`/`FAILURE` entries with `error_code` as `failure_reason` (no raw `str(exc)`; no password hash in snapshots). `AuditLogger.log` never raises into the caller (insert errors swallowed via structlog). Phase 2 will add the `/admin/audit-log` UI, the `@audit_action` decorator generalization, read-event opt-in, and the Taskiq retention cleanup job. |
| PydanticAI Prompt-Injection Guardrails (Phase 1+2) | #197 | Structural baseline for OWASP LLM01 (prompt injection) + LLM07 (system prompt leakage) on the two PydanticAI Agent call sites (`src/_core/infrastructure/rag/pydantic_ai_answer_agent.py`, `src/classification/infrastructure/classifier/pydantic_ai_classifier.py`). Both Agents migrate from `system_prompt=` to `instructions=` (modern PydanticAI slot — separated from user prompt parts; sent as a dedicated top-level `instructions` field on the OpenAI Responses provider. Not a secrecy boundary — PydanticAI still stores rendered instructions on the ModelRequest; the value is separation-from-user-input, not concealment) with `Final[LiteralString]` typed persona + tail constants so pyright blocks future user-data interpolation. Shared module `src/_core/infrastructure/llm/prompt_boundaries.py` exposes `escape_for_prompt_xml` (non-idempotent: `&` → `&amp;` first; already-escaped input treated as literal) and the two instruction tails (`RAG_INSTRUCTIONS_TAIL`, `CLASSIFIER_INSTRUCTIONS_TAIL`) that tell the model to treat boundary-wrapped content as untrusted DATA. RAG `_format_prompt` wraps retrieved chunks in `<documents><document index="N"><title>...</title><content>...</content></document></documents>` and the user question in `<user_question>...</user_question>`; classifier wraps user text in `<user_text>` and each label in `<category>`. Every dynamic field — user question, chunk title, chunk content, classifier text, each category — is escaped before insertion; integer `index` attributes are positional only. Auth gate added: `POST /v1/docs/query`, `POST /v1/docs/documents`, `DELETE /v1/docs/documents/{id}` get operation-level `Depends(get_current_user)`; `/v1/classify` router gets router-level dependency. GET docs endpoints intentionally left public (one-line follow-up). Runtime input/output guardrails landed in Phase 3 (#209); observability ledger + red-team suite landed in Phase 5 (#211); remaining follow-ups: per-user rate/budget caps (Phase 4 #210), `trust_level` on Document. |
| PydanticAI Prompt-Injection Guardrails (Phase 3) | #209 | Runtime half on top of #197 Phase 1+2. New `src/_core/infrastructure/llm/guardrails.py` (plain, PydanticAI-decoupled): `detect_prompt_injection` (curated English-imperative regex + zero-width/control-char strip-then-rescan; returns matched rule name for structlog only), `scan_pii` (type-prefixed `email:`/`phone:`/`ipv4:` tokens, normalized — leading US `+1` stripped on 11-digit numbers — so a reformatted number is not a false positive), `find_prompt_leak` (log-only window helper). New exceptions `PromptInjectionDetected` (400) + `GuardrailBlocked` (422) carry NO `details` (the global handler serializes `exc.details` to the response, so the rule/PII stays in structlog only; response `errorDetails` is `null`). RAG adapter: input guard on the user question (block) before `agent.run()`; output PII-fabrication diff over `source_title`+`content` of every chunk vs the answer (block — precise); verbatim prompt-leak (log-only). Classifier adapter: input guard on `text` AND every `categories` element (request-body `list[str]`, not a server registry). `GUARDRAILS_ENABLED` setting (default True) is the DI-wired kill-switch read once at adapter construction. Scope trimmed from the issue body (codex + Plan-agent): no base64/ROT13 decode, no credit-card Luhn, no classifier output guard, prompt-leak kept log-only, `Document.trust_level` deferred (no blocking consumer yet). |
| PydanticAI Prompt-Injection Guardrails (Phase 5) | #211 | Observability + regression half on top of Phase 3. Adds `ai_usage.guardrail_triggered: bool` (migration `0008`, with a `(guardrail_triggered, occurred_at)` index) through model/VO/schema/DTO/factories. `track_agent_usage` is wired **inside** both adapters (`PydanticAIAnswerAgent.answer`, `PydanticAIClassifier.classify`) — Infrastructure→Application; the adapters take an `AgentUsageRecorderProtocol` (injected via `DocsContainer`/`ClassificationContainer` cross-domain DIP) so the concrete `ai_usage` import stays in DI. The flag is set by a duck-typed `is_guardrail_block` class marker on the guardrail exceptions, so `usage_tracker` never imports them (keeps `test_usage_tracker_architecture` green); `_safe_error_code` bounds `error_code` to 50 chars. Input block → zero-token row; output block → consumed tokens (`capture.set_result` before the output guard); both `status='error'` + `guardrail_triggered=True` + `error_code` in `{PROMPT_INJECTION_DETECTED, GUARDRAIL_BLOCKED}`. Telemetry standardized via `guardrail_telemetry.log_guardrail_event` (`agent`/`action`/`stage`/`rule` [+`count`/`types`]); `request_id`/`user_id` bound to structlog contextvars at the request boundary with scoped unbind. Server-side `/v1/usage?guardrailTriggered=` filter + `ai_usage` admin list column. Red-team corpus `tests/integration/_core/infrastructure/llm/test_adversarial_prompts.py` (direct/zero-width → block; base64/rot13/homoglyph → explicit-ALLOW + structural-safety, documented non-goal; indirect-in-document → allow; boundary-tag → escaped; system-extraction → block; prompt-leak → log-only) + a producer-path test pinning the ledger record. An autouse `structlog.reset_defaults()` in `tests/conftest.py` makes `capture_logs()` deterministic under `cache_logger_on_first_use=True`. |
| Admin Audit Log (Phase 2) | #206 | Operator UI + retention pipeline on top of Phase 1. Adds `AdminAuditLogRepository.list_filtered` (summary projection — no `before_state`/`after_state` JSON in the list payload) + `get_by_id` (full row for the detail dialog) + `delete_older_than` (retention cleanup), `AuditLogFilter` VO + `AuditLogSummaryDTO`, and the `@audit_action(action, domain, before_fn, after_fn)` decorator (re-raises wrapped exception so `@admin_error_boundary` still fires; hook failures swallowed via `_safe_capture`). `/admin/audit-log` page is gated by the `audit_log` permission key, now a fixed key in `AdminPermissionRegistry._FIXED_KEYS` and auto-granted to the first real admin; nav drawer + dashboard card exposed; before/after rendered as plain text (`ui.code`) to prevent HTML injection; page does not log its own VIEW (avoids self-loop). `BaseAdminPage.log_reads: bool = False` is the per-domain opt-in for `VIEW_LIST`/`VIEW_DETAIL` audit events. New `TaskiqScheduler` process (`make scheduler` / `run_scheduler_local.py`) reads `schedule=[{"cron": "0 3 * * *"}]` labels via `LabelScheduleSource`; same `@broker.task` is also enqueueable via external cron when the scheduler process isn't deployed. `audit_log_retention_days: int` setting bounded (`ge=1, le=3650`, default 90). Tz-aware datetimes (UI filters, scheduler cutoff, REPL) are normalized to naive UTC inside the repository before binding against the tz-naive `created_at` column on Postgres/asyncpg. `container.wire(modules=[_audit_cleanup])` is called at module level in `worker/bootstrap.py` so Provide markers resolve in both the worker and scheduler processes. |
| Admin Identity Realm Separation | #218 (ADR 049) | Separates admin/operator identity from customer identity into a new `admin_identity` bounded context with its OWN credential store (`admin_identity` + `admin_refresh_token` tables) AND its own JWT token realm (distinct `ADMIN_JWT_SECRET_KEY`/issuer/audience; config validator rejects realm collapse). Shared JWT mechanism (`JwtTokenCodec`) extracted to `src/_core/common/`. Adds `/v1/admin/login\|refresh\|logout`; `require_admin` moves to admin_identity and verifies admin-realm tokens (a customer token on `/v1/user` → `401 INVALID_TOKEN`, the trust boundary). `user` table purged of `role`/`permissions`/`password_temporary`/`is_bootstrap_admin` (migration moves existing admins over). NiceGUI admin + bootstrap seed re-pointed to admin_identity (token-less 4-key session preserved, IC-155-1). project-dna §17 (IC-218-1..7); external IdP documented as extension. Supersedes the #154/#194 single-table admin model. |
| Admin Dashboard Redesign + Design-System Polish | #223 | Reworks the NiceGUI admin landing (`/admin/`) from a flat nav-card grid into an at-a-glance data dashboard: a new `src/_apps/admin/dashboard_metrics.py` read facade gathers domain record counts + audit totals/failures concurrently with per-source isolation (never raises into the page, `error_type`-only logging — no `str(exc)` leak, `count_datas` dynamic-probe quarantined here so `AdminCrudServiceProtocol` stays minimal), and **gates the audit read behind the `audit_log` permission** (least-privilege). Page renders stat cards + a "Recent Activity by Action" chart + a recent-activity table + quick-action nav; CRUD list/detail pages unchanged. Adds the first ECharts builder `c.bar_chart()` to the admin component library (palette-aware fill via `palette_accent`, `--admin-chart-height` token, `AdminClasses.CHART`; cataloged in `admin-design-system.md`). Dark content palette retuned from blue-navy to neutral charcoal/zinc (cohesive across shadcn/supabase/linear chrome, lifted off near-OLED black; page < chrome < card elevation; `.q-card` background bound to `--admin-surface`). Sidebar collapses to an icon-only **mini rail** (top-of-drawer collapse control + divider; nav tooltips + `aria-label`; header hamburger mobile-only via `lt-md`). Codex cross-review across plan + implementation + design rounds. Ships in v0.7.0. |
| Security Review Follow-up (doc-read auth + strict-env CORS) | #227 | Closes two vulnerabilities found by `/security-review` on the v0.7.0 surface, shipped as **v0.7.1**. (1) **Broken access control**: `GET /v1/docs/documents` + `/{id}` were public while `DocumentResponse` returns raw `content` — both now require `Depends(get_current_user)` (e2e 401 test extended). (2) **Credentialed CORS reflection**: `ALLOW_ORIGINS=["*"]` default + `allow_credentials=True` reflected any Origin in stg/prod — a strict-env validator now rejects `*` in `allow_origins` for stg/prod (dev/local/quickstart keep the wildcard). Doc sync: `project-dna` §8 + `security-checklist` §2 corrected to the `admin_identity` realm model + an admin session-cookie hardening item. Cross-reviewed with Codex GPT-5.5 xhigh. |
| Admin Single Toss Theme + Grid Render Fix | #235 / #234 | Reworks the admin shell to a **single Toss Design System theme** and **removes the multi-preset machinery** (`ADMIN_THEME_PALETTE` setting/env, `_PALETTES`, `palette_primary`, `default`/`linear`/`shadcn`/`supabase`). The look is now two token dicts in `theme.py` — `_ROOT_TOKENS` (`:root`/light) + `_DARK_TOKENS` (`.body--dark`); `build_admin_css()` takes no args; rebrand by editing the dicts. Look: TDS grey palette + blue/green/red semantics (`AdminColors` updated; charts read `AdminColors.PRIMARY`), **light-mode chrome flip** (white sidebar in light, dark re-asserted in `.body--dark`), `20px`/pill rounding (`--admin-radius-button`), dark-mode **elevation ladder** (page `#14161b` < chrome `#191f28` < card `#262b35` — shadows don't read on dark), rounded chart bars, per-mode pastel/deep login backdrop + a **login dark-mode toggle** (`render_dark_mode_toggle` promoted from `layout.py`), and global **micro-interactions** (hover-lift/press, `prefers-reduced-motion`-aware). New tokens `--admin-chrome-border`/`--admin-radius-button`/`--admin-login-gradient`. **Also fixes (#234)** the AG Grid v33 empty-admin-list bug: `ag-delay-render` could stick on the grid root in the NiceGUI embed, leaving rows permanently `visibility:hidden` (data present but blank); `_HELPER_CSS` forces `.admin-grid` cells visible. `run_server_local.py` honors a `PORT` env override. Shipped in v0.8.0. |
| Admin Code Cleanup (dashboard redirect + tidy-ups) | #229 | Cleanup found by a Codex cross-review of the v0.7.0 admin code, shipped as **v0.7.2**. **Fixed**: `require_auth` redirected page-denied operators to `/admin/dashboard` (a non-existent route → blank page); now redirects to the real `/admin/` landing. **Refactor (no behavior change)**: `theme.palette_accent`→`palette_primary` (returns `--q-primary`), drawer mini-rail nav state simplified (`nonlocal` bool + returned `collapse_icon` handle, dropping `dict[str,object]`+`isinstance`), dead `app_username` alias removed, dev-process comments stripped from shipped source. `count_datas` getattr quarantine **kept** on merit (CRUD protocol scoped to BaseAdminPage; count is a dashboard-only need). |
