Files
ui/_docs/00_problem/acceptance_criteria.md
T
Oleksandr Bezdieniezhnykh f7dd6c98d8
ci/woodpecker/push/build-arm Pipeline failed
[AZ-501] [AZ-502] Cycle 2 Step 14 security audit + inline fixes
Security audit (5 phases) → reports under _docs/05_security/.

AZ-501 (F-SAST-1, HIGH): Externalize hardcoded Google Geocode key
from mission-planner/src/config.ts to VITE_GOOGLE_GEOCODE_KEY via
new GeocodeService.ts; fail-soft warn when unset; STC-SEC1D static
deny-list gate; +5 unit tests in tests/mission_planner_geocode.test.ts.

AZ-502 (F-DEP-1, HIGH): Force vite>=6.4.2 and postcss>=8.5.10 via
package.json overrides in both roots; clean reinstall clears all
bun audit advisories.

Test-spec sync (Step 12) + Update Docs (Step 13) deltas: AC-43, AC-44,
NFT-SEC-09b, FT-P-61, FT-N-17, ripple log, batch_12 report.

Pending user actions: revoke Google + OWM keys (AC-6 / AZ-499 AC-7).

229 PASS / 13 SKIP / 0 FAIL on static + fast suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 05:31:11 +03:00

81 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Acceptance Criteria — Azaion UI
> Output of `/document` Step 6c. Criteria derived from **measurable values
> already evidenced in code or config**: server-side hard caps, validation
> rules, health checks, perf configs, and architectural non-negotiables.
> Aspirational targets without a concrete check are explicitly marked.
**Status**: synthesised-from-verified-docs (Step 6c — `/document`)
**Date**: 2026-05-10
---
## Format
Every criterion must have a measurable value. Each row carries a unique ID
(`AC-NN`), the criterion, a measurement method, and the source-of-truth.
| AC | Criterion | Measurable value | How to measure | Source |
|----|-----------|------------------|----------------|--------|
| AC-01 | Authenticated requests carry the HttpOnly refresh cookie | `credentials:'include'` on every authenticated `fetch` and on the refresh call | Static check (linter / test) on `src/api/client.ts` and `src/auth/AuthContext.tsx`; runtime test that 401 → POST refresh → retry succeeds | `src/api/client.ts:44`; `_docs/02_document/04_verification_log.md` F2 |
| AC-02 | Bearer is never written to client storage | Zero `localStorage.*` / `sessionStorage.*` calls touching the bearer | Code-search regression test (Grep on `src/`) | P3; `_docs/02_document/architecture.md` § 7 |
| AC-03 | Refresh cookie attributes | Cookie issued by `admin/` MUST carry `Secure HttpOnly SameSite=Strict` | Server-side concern; UI test asserts the cookie is non-readable from JS (`document.cookie` does not contain the refresh token) | `_docs/02_document/architecture.md` § 7 |
| AC-04 | Numeric enums match the suite spec on the wire | `AnnotationStatus`, `MediaStatus`, `Affiliation`, `CombatReadiness` numeric values match the spec verbatim | Unit test asserting each enum's values; contract test on every `api.*()` payload using these enums | P9; `src/types/index.ts`; `04_verification_log.md` enum drift |
| AC-05 | Annotation save endpoint | Save POSTs to `/api/annotations/annotations` (doubly-prefixed) | Integration test asserting the URL and body shape (must include `Source`, `WaypointId`, `videoTime`) | `src/features/annotations/AnnotationsPage.tsx:39`; `04_verification_log.md` F5 + finding #32 |
| AC-06 | Selected-flight persistence path | Selection persists via `PUT /api/annotations/settings/user` with `{selectedFlightId}` (NOT a dedicated `/api/flights/select` endpoint) | Integration test on `FlightContext.selectFlight` round trip | `src/components/FlightContext.tsx:24,31,34,44`; `04_verification_log.md` F3 |
| AC-07 | Bulk-validate works | `POST /api/annotations/dataset/bulk-status` transitions selected items to `AnnotationStatus.Validated` | E2E test: select N items → click Validate → assert status update | `src/features/dataset/DatasetPage.tsx:65-73,142-146`; `04_verification_log.md` F9 |
| AC-08 | Live-GPS SSE per selected flight | `createSSE('/api/flights/${flightId}/live-gps', ...)` is open while a flight is selected; closes on unselect | Integration test: select flight, observe EventSource open; deselect, observe close | `src/features/flights/FlightsPage.tsx:67`; F13 |
| AC-09 | Annotation-status SSE | `createSSE('/api/annotations/annotations/events', ...)` open during `06_annotations` page lifetime | Integration test on subscribe / unsubscribe | `src/features/annotations/AnnotationsSidebar.tsx:25`; F14 |
| AC-10 | Upload size cap | Server-side hard cap is `client_max_body_size 500M`; UI error path on 413 produces a user-visible message | nginx config check; integration test posts 501 MB → asserts 413 + UI surfaces | `nginx.conf` `client_max_body_size 500M`; `architecture.md` § 6 |
| AC-11 | Bundle size budget | Initial JS (gzipped) ≤ **~2 MB** target | `vite build` artifact size measured in CI; **no gate today** — adding the gate is a Phase B task | `architecture.md` § 6 NFR row "Bundle size" — **target, not currently enforced** |
| AC-12 | i18n coverage | Every user-visible string has both an `en.json` and `ua.json` entry; no string literals in components beyond proper-noun acronyms | Lint rule + assertion test that `Object.keys(en) === Object.keys(ua)` | P6; `src/i18n/i18n.ts` |
| AC-13 | i18n language detection / persistence | `i18next` `lng` resolves from a detector (cookie / `Accept-Language`) and persists across reloads. **Currently `lng:'en'` is hardcoded** — Step 4 fix | Manual + integration test that toggling language in Header survives reload | `src/i18n/i18n.ts`; finding |
| AC-14 | Destructive actions require `ConfirmDialog` | Class delete (`AdminPage.handleDeleteClass`) and other destructive flows MUST present `ConfirmDialog`; **`alert()` is forbidden** | Static check + integration test for delete flows | O10; finding B4; `MediaList` `alert()` finding |
| AC-15 | a11y — `ConfirmDialog` | `role=dialog` + `aria-modal=true` + focus-trap + Esc-to-cancel | Component test using `@testing-library/react` | finding (`ConfirmDialog` lacks `aria-modal/role=dialog`) |
| AC-16 | a11y — Header flight dropdown | `role=combobox`, `aria-expanded`, Esc-to-close, focus-trap, outside-click handler attached only when open | Component test | finding (`Header.tsx` outside-click handler always attached; missing combobox roles) |
| AC-17 | a11y — `ProtectedRoute` spinner | `role=status` + accessible label; loading state has a timeout | Component test asserting a11y attributes; integration test asserting timeout fallback | finding |
| AC-18 | Browser support | Chromium-based + Firefox latest 2 versions render the SPA correctly | Manual smoke (no `browserslist` enforcement today) | `architecture.md` § 6 — **manual / aspirational** |
| AC-19 | Mobile responsiveness | Header bottom-nav variant renders at < 768 px; main pages render at ≥ 768 px | Manual smoke at the two breakpoints | `Header.tsx:113-129`; `architecture.md` § 6 |
| AC-20 | OpenWeatherMap key NOT in source | `import.meta.env.VITE_OWM_API_KEY` (and `VITE_OWM_BASE_URL`); zero hardcoded keys in any `src/` or `mission-planner/` module | Static check (regex against the previously-committed literal — `STC-SEC1`, `STC-SEC1B`, `STC-SEC1C`); CI step | P10; closed cycle 2 / 2026-05-12 by AZ-448 (main SPA), AZ-499 (mission-planner); see also AC-42 |
| AC-21 | UserSettings persistence — panel widths | Panel-width changes via `useResizablePanel` write back to `PUT /api/annotations/settings/user`; reload restores widths | Integration test: change width → reload → assert restored | P11; `src/hooks/useResizablePanel.ts` (current violation) |
| AC-22 | RBAC client-side route gates | `/admin` and `/settings` redirect non-privileged users to `/flights` (or `/login` if not authenticated). Server-side 403 is the authoritative gate; UI gate is convenience | Integration test: log in as non-admin → navigate to `/admin` → assert redirect | finding (`/admin` route lacks role-gate — security PRIORITY) |
| AC-23 | Auth refresh transparency | One refresh = one network round trip; **no UI re-render past `<ProtectedRoute>`** | Integration test asserting `<ProtectedRoute>` does not unmount during refresh | `architecture.md` § 6 NFR row "Auth refresh"; `04_verification_log.md` F2 |
| AC-24 | SSE bearer-rotation handling | When the bearer rotates (refresh), open SSE connections **must** reconnect with the new bearer | Integration test: open SSE → trigger refresh → assert reconnection. **Currently NOT implemented (Step 8 hardening)** | `ADR-008`; `architecture.md` § 7 |
| AC-25 | Detect endpoint correctness | Sync image detect uses `POST /api/detect/${mediaId}`. **Async video detect (`F7`) — when implemented in Phase B — uses `POST /api/detect/video/${mediaId}` returning a job ID + SSE on `/api/detect/stream/${jobId}`**. Long-video flows MUST send `X-Refresh-Token` (per `_docs/10_auth.md`) | Integration tests per path | `src/features/annotations/AnnotationsSidebar.tsx:39`; F6 / F7 / F14 |
| AC-26 | Numeric input hygiene | Numeric form inputs in `09_settings` and `08_admin` reject empty input rather than silently writing `0` | Component tests on `parseInt(v) || 0` patterns (currently a finding) | finding B4 |
| AC-27 | Save error surfacing | `09_settings` save handlers (`saveSystem`, `saveDirs`) use `try/finally` to reset `saving:true`; failure is surfaced via toast / inline error | Integration test that simulates a 500 on PUT and asserts state reset | finding B4 |
| AC-28 | Annotation overlay time window | The on-canvas annotation overlay window is asymmetric `[-50 ms, +150 ms]` around the current frame (matches WPF source `_thresholdBefore=50ms / _thresholdAfter=150ms`). **Currently symmetric ±200 ms** — Step 4 fix | Component test asserting overlay membership at `currentTime ± 50/150 ms` | finding #6; `04_verification_log.md` §2d |
| AC-29 | `mediaType` is typed | All `mediaType` references use the `MediaType` enum (`None=0`, `Image=1`, `Video=2`); zero magic literals | Static check (Grep `mediaType\s*===\s*[0-9]`) | finding #5 / #10; P9 |
| AC-30 | Class delete confirmation | `AdminPage.handleDeleteClass` shows `ConfirmDialog` before issuing `DELETE /api/admin/classes/${id}` | Integration test | finding B4 |
| AC-31 | `mission-planner/` is not in the production bundle | `vite build` output does not include any `mission-planner/**` chunk | Bundle inspection; static-import check | `vite.config.ts`; `ADR-009`; P2 |
| AC-32 | CI tags + labels | Image is pushed with `${branch}-arm` tag and OCI labels (`org.opencontainers.image.{revision,created,source}`) | Pipeline assertion on the push step | `.woodpecker/build-arm.yml` |
| AC-33 | Production runtime is `nginx:alpine` only | Final image stage is `nginx:alpine`; no Node.js binary in the production image | Container inspection (`docker inspect`) | `Dockerfile` |
| AC-34 | nginx routes 9 services | `nginx.conf` declares `/api/admin/`, `/api/flights/`, `/api/annotations/`, `/api/detect/`, `/api/loader/`, `/api/gps-denied-desktop/`, `/api/gps-denied-onboard/`, `/api/autopilot/`, `/api/resource/` — each strips its `/api/<service>/` prefix | Config assertion test | `nginx.conf`; `ADR-006` |
| AC-35 | Manual bbox draw on `CanvasEditor` | A mousedown → mousemove → mouseup gesture on the canvas creates one new local detection with `classNum = selectedClassNum + photoModeOffset` (per AC-38) and `x,y,w,h` (normalised) matching the dragged rectangle within ±1 normalised px-equivalent; the new detection is appended to local state and is rendered immediately | Component test on `CanvasEditor` with synthetic pointer events; verify local-state shape | `components/06_annotations/description.md`; `system-flows.md` Flow F5; `solution.md:165,224` |
| AC-36 | 8-handle bbox resize + canvas modifier interactions | (a) Dragging any of the 8 resize handles (4 corners + 4 edge midpoints) of a selected bbox updates only the corresponding edges; (b) `Ctrl+click` on a bbox **adds it to the selection set** (multi-select); (c) `Ctrl+wheel` over the canvas zooms in/out around the cursor; (d) `Ctrl+drag` on empty canvas pans the view. Bboxes have a minimum normalised size > 0 so handle-drag past zero clamps instead of inverting. | Component tests on `CanvasEditor` with synthetic events (one per modifier path); assert resulting bbox / selection set / viewport state | `components/06_annotations/description.md`; `glossary.md:45` (CanvasEditor); `01_legacy_coverage_gaps.md:29-30`; `solution.md:224` |
| AC-37 | Class picker (`DetectionClasses` widget) | Widget loads class list from `GET /api/annotations/classes`; **number-key 19** (window `keydown`) selects `classes[(num-1) + photoMode]` and emits `onSelect(class.id)`; clicking a class entry emits the same; the rendered visible label index `i+1` matches the hotkey number for that class **within the currently active PhotoMode** (per AC-38). Fallback list is used when the API returns empty or errors. Backend class ordering MUST be `[0..N-1] (Regular), [20..20+N-1] (Winter), [40..40+N-1] (Night)` — when it is not, this AC fails (Step 4 verification candidate). | Component test on `DetectionClasses` with mocked API + simulated keypresses + clicks; contract test asserting backend response ordering on a fixture | `components/03_shared-ui/description.md:37`; `modules/src__components__DetectionClasses.md`; `data_model.md:158`; `_docs/legacy/wpf-era.md` §10 |
| AC-38 | PhotoMode switcher (Regular / Winter / Night) | PhotoMode buttons emit values from the set `{0, 20, 40}` (Regular=0, Winter=+20, Night=+40). Switching mode: (a) re-filters the class list to entries whose `photoMode` equals the new mode; (b) if the previously-selected `classNum` is not in the new filtered set, auto-selects the first class of the new mode and emits `onSelect`. On annotation save, the wire `Detection.classNum` (a.k.a. *yoloId*) equals `classId + photoModeOffset`. | Component test on the mode-switch effect + integration test on the save payload | `modules/src__components__DetectionClasses.md` §22, §31-43; `data_model.md:84`; `components/11_class-colors/description.md:31-35`; `ui_design/README.md:127-128`; `ui_design/annotations.html:84-93` |
| AC-39 | Tile-splitting endpoint + wire shape | `POST /api/annotations/dataset/{id}/split` exists and is callable from the dataset surface; success response is JSON with HTTP 200. `AnnotationListItem.isSplit: boolean` and `AnnotationListItem.splitTile: string \| null` (YOLO label `<class> <cx> <cy> <w> <h>`) are honored on read. When `isSplit === true` and `splitTile` is non-null, the client parses the 5-token YOLO label without throwing; malformed `splitTile` surfaces a user-visible error (no silent swallow). `DatasetItem.isSplit?: boolean` is read on the dataset list path (parent-suite-doc fix applied — see `_docs/_process_leftovers/2026-05-10_parent-suite-doc-fixes.md`). | Integration test against a fixture response; unit test on the YOLO-label parser with valid + malformed inputs | `components/07_dataset/description.md:28`; `data_model.md:104-105,130,164`; `modules/src__features__annotations.md:31,75`; `modules/src__types__index.md:24-28` |
| AC-40 | Tile-zoom auto-zoom on split-image annotation open | When the user opens a `splitTile`-bearing annotation (double-click in `AnnotationsSidebar` or seek via the annotation list), `CanvasEditor` auto-zooms to the tile region encoded by `splitTile` (parsed per AC-39). The visible viewport rectangle equals the tile rectangle within ±1 px on each edge. A small visual tile-zoom indicator (icon / badge) is rendered while the tile zoom is active so the operator knows the view is constrained. **Currently MISSING** — finding #24 in `modules/src__features__annotations.md`; Step 4 / Phase B fix. | Component test on `CanvasEditor` with a `splitTile`-bearing annotation; assert viewport rect + presence of the tile-zoom indicator | `components/06_annotations/description.md:62, 103`; `modules/src__features__annotations.md:75` finding #24; `legacy/wpf-era.md` (OpenAnnotationResult seek + ZoomTo) |
| AC-41 | Map tiles served by self-hosted `satellite-provider` via cookie auth | (a) `<TileLayer>` `url` prop equals `import.meta.env.VITE_SATELLITE_TILE_URL` (or the dev default `http://localhost:5100/tiles/{z}/{x}/{y}` when unset). (b) Every `<TileLayer>` the SPA renders carries `crossOrigin="use-credentials"` so the browser attaches the satellite-provider auth cookie on same-origin requests. (c) The classic/satellite map-type toggle, the `mapType` state, and the `MiniMap.Props.mapType` prop are absent. (d) A 401 / 503 from the tile endpoint MUST NOT crash the map; broken-tile placeholder is rendered for the failing cell. | Fast component tests (`src/features/flights/__tests__/satellite_tile.test.tsx`) + e2e infrastructure check (`e2e/tests/infrastructure.e2e.ts` AC-2) + STC-T1 typecheck + STC-FP22 i18n parity (post-key removal). Cycle-2 spec rows: FT-P-56, FT-P-57, FT-P-58, FT-P-59, NFT-RES-11. | Closed cycle 2 / 2026-05-12 by AZ-498 (epic AZ-497). `_docs/02_document/contracts/satellite-provider/tiles.md` v1.0.0 owns the wire shape. Cross-workspace prereq for production deploy: satellite-provider cookie-auth on `GET /tiles/{z}/{x}/{y}` (gated at autodev Step 16). |
| AC-42 | mission-planner OpenWeatherMap config externalized; fail-soft on missing key | (a) `mission-planner/src/services/WeatherService.ts::getWeatherData(lat, lon)` builds the outbound URL from `import.meta.env.VITE_OWM_API_KEY` + `VITE_OWM_BASE_URL` (falls back to `https://api.openweathermap.org/data/2.5` when the base URL is unset; trailing slash on the base URL is stripped). (b) When `VITE_OWM_API_KEY` is unset/empty, `getWeatherData` returns `null` and issues NO outbound `fetch`. (c) Static check `STC-SEC1C` (`scripts/check-banned-deps.mjs --kind=owm_key_in_source`) FAILS on any future re-introduction of the previously-committed literal under `src/` or `mission-planner/`. (d) The previously-committed key MUST be revoked at the OpenWeatherMap dashboard (manual deliverable — defense-in-depth). | Fast tests (`tests/mission_planner_weather.test.ts`) + STC-SEC1C static check + STC-T1 typecheck. Cycle-2 spec rows: FT-P-60, FT-N-16, NFT-SEC-09 step 3. | Closed cycle 2 / 2026-05-12 by AZ-499 (epic AZ-497). Closes the AZ-482 source-scan gap (which previously only checked `src/` for the regex shape and `dist/` for the literal — `mission-planner/` stays out of `dist/` per AC-31, so the dist scan alone could not catch it). |
| AC-43 | mission-planner Google Geocode config externalized; fail-soft on missing key | (a) The previously-hardcoded Google Geocode API key has been EXTRACTED from `mission-planner/src/config.ts` to a new `mission-planner/src/services/GeocodeService.ts` module that builds the outbound URL from `import.meta.env.VITE_GOOGLE_GEOCODE_KEY`. (b) When the env var is unset/empty, `geocodeAddress(address)` returns `null`, issues NO outbound `fetch`, and emits exactly one `console.warn` mentioning `VITE_GOOGLE_GEOCODE_KEY`. (c) Static check `STC-SEC1D` (`scripts/check-banned-deps.mjs --kind=google_key_in_source`) FAILS on any future re-introduction of the previously-committed literal under `src/` or `mission-planner/`. (d) The previously-committed key MUST be revoked at the Google Cloud Console (manual deliverable — defense-in-depth). (e) `LeftBoard.tsx` imports `geocodeAddress` from the service module; the inline geocode function and the `GOOGLE_GEOCODE_KEY` import are removed. | Fast tests (`tests/mission_planner_geocode.test.ts`) + STC-SEC1D static check + STC-T1 typecheck. Cycle-2 spec rows: FT-P-61, FT-N-17, NFT-SEC-09b. | Closed cycle 2 / 2026-05-12 by AZ-501 (filed during the security audit, `_docs/05_security/`). Mirrors the AZ-499 pattern (env var + fail-soft + literal-scan static gate + manual revocation). Manual deliverable AZ-501 AC-6 (key revocation at Google Cloud Console) PENDING USER. |
| AC-44 | Vite + PostCSS supply chain past published CVEs | `bun audit` in BOTH `ui/` and `mission-planner/` reports zero advisories. Achieved by `bun update vite` plus `package.json` `overrides` flooring `vite >= 6.4.2` and `postcss >= 8.5.10` in both roots — required because `vitest@3.2.4` nests its own `vite` copy that the direct upgrade alone does not lift past the `<= 6.4.1` advisory range. | `bun audit` exit code 0 in both roots after `bun install` from a clean `node_modules`. CI gate (`bun audit --severity high` in `.woodpecker/build-arm.yml`) is a Phase B follow-up tracked at `_docs/05_security/infrastructure_review.md` F-INF-1. | Closed cycle 2 / 2026-05-12 by AZ-502 (filed during the security audit). Affected advisories: GHSA-p9ff-h696-f583 (HIGH — Vite WebSocket file-read), GHSA-4w7w-66w2-5vf9 (MODERATE — Vite path traversal), GHSA-qx2v-qp2m-jg93 (MODERATE — PostCSS XSS). Production-bundle exposure was NONE before the upgrade (Vite is dev-server-only); the upgrade closes the developer-machine exposure and the audit-tool noise. |
## Anti-criteria — explicit non-goals
| AC# | Statement | Source |
|-----|-----------|--------|
| AC-N1 | The UI does NOT support real-time multi-user collaborative annotation. | F14 caveat: server pushes status events, the UI consumes; no concurrent edit semantics |
| AC-N2 | The UI does NOT host any in-browser ML model. All inference is server-side. | `package.json` has no ML libs |
| AC-N3 | The UI does NOT support offline mode. (Tile cache for field deployments is a separate, future concern.) | `architecture.md` § 2 |
| AC-N4 | The UI does NOT enforce a server-side response signature / checksum on REST replies. (Server is trusted within the suite network.) | absence of any signature library in `package.json` |
| AC-N5 | The UI does NOT port WPF Sound Detections or Drone Maintenance — both **dropped** per Step 4.5 decision. | `01_legacy_coverage_gaps.md` Step 4.5 update |
## Coverage status
- **Currently met & enforced**: AC-02 (no token storage), AC-05 (annotation save URL — body shape pending), AC-06, AC-07, AC-08, AC-09, AC-10 (server cap; UI surface is a finding), AC-20 (OWM key — closed cycle 2 by AZ-448 + AZ-499; STC-SEC1/SEC1B/SEC1C all green), AC-25 (sync path; async path is target-only), AC-31, AC-33, AC-34, AC-41 (self-hosted satellite tiles + cookie auth — closed cycle 2 by AZ-498; production deploy still gated on cross-workspace satellite-provider cookie-auth ticket), AC-42 (mission-planner OWM env-var hardening — closed cycle 2 by AZ-499; manual key revocation pending), AC-43 (mission-planner Google Geocode env-var hardening — closed cycle 2 by AZ-501; manual key revocation pending), AC-44 (Vite + PostCSS supply chain — closed cycle 2 by AZ-502; CI audit gate is a Phase B follow-up).
- **Currently met but not enforced by CI**: AC-04 (enum values), AC-12 (i18n parity), AC-29 (typed `mediaType`), AC-35 (manual bbox draw), AC-37 (class picker — pending Step 4 backend-ordering verification), AC-38 (PhotoMode switcher).
- **Currently violated — Step 4 fix candidates**: AC-01 (bootstrap refresh), AC-13 (i18n detector), AC-14 / AC-30 (class-delete dialog; `alert()` use), AC-15AC-17 (a11y), AC-21 (panel widths), AC-22 (route role-gate), AC-23 (refresh re-render — code-path correct, but bootstrap-refresh fix needed), AC-26 (numeric input hygiene), AC-27 (save error surfacing), AC-28 (overlay window), AC-36 (Ctrl-multi-select / Ctrl-wheel zoom / Ctrl-drag pan flagged "Partially missing"), AC-40 (tile-zoom auto-zoom — finding #24, no consumer of `splitTile` today).
- **Phase B targets (not currently in scope of `/document` Step 6)**: AC-11 (bundle gate), AC-18 (browser-list), AC-19 (mobile floor), AC-24 (SSE refresh re-subscribe), AC-25 async path, AC-32 (CI label assertions), AC-39 (tile-split endpoint — parent-suite-doc fix applied for `isSplit`; the YOLO-label parser hardening lands when the splitTile consumer is wired in Phase B).