docling-studio/docs/design/210-feature-flag-mode-gating.md
Pier-Jean Malandrino 621a9e3fce docs(design): E2 design docs for 0.6.0 navigation refactor
Adds technical design docs for the navigation epic of the doc-centric
pivot:

- 207 — Document-centric routing (/docs, /docs/:id?mode=, /index/:store, /runs)
- 208 — Doc workspace breadcrumb (Studio > <doc> > <mode>)
- 209 — Sidebar nav rework (Home / Docs / Stores / Runs / Settings)
- 210 — Feature flag mode gating (deep-link redirect + flag exposure)

Status: Accepted on all four. Each doc spells out the contract,
alternatives considered, risks per audit dimension, and testing
strategy. Backwards-compatibility is preserved throughout: legacy
routes and pages keep working until E3/E4/E5 explicitly replace them.

Refs #207 #208 #209 #210
2026-04-29 17:31:41 +02:00

10 KiB

Design: Feature flags hide entire mode tabs (and redirect deep links)

  • Issue: #210
  • Title on issue: [ENHANCEMENT] Feature flags hide entire mode tabs instead of routing to 404
  • Author: Pier-Jean Malandrino
  • Date: 2026-04-29
  • Status: Accepted
  • Target milestone: 0.6.0 — Doc-centric ingest
  • Impacted layers: frontend: features/feature-flags · app/router · pages · shared · backend: api/health
  • Audit dimensions likely touched: Clean Code · Tests · Security · Documentation
  • ADR spawned?: no

1. Problem

Studio gates Inspect / Chunks / Ask per tenant via feature flags. Without explicit handling, a disabled mode is still reachable by URL — a deep link like /docs/abc?mode=chunks lands on a half-broken page or 404 when chunks are disabled. The user experience is "the link my colleague sent me is broken".

The fix has three pieces:

  1. Routing-level redirect: ?mode=<disabled> rewrites to the first enabled mode, preserving the doc id.
  2. Tab-level visibility: when E4 builds the workspace tabs (#216), disabled modes are hidden from the tab strip.
  3. Server-side guard: even if a client sends a request for a disabled mode, the API rejects writes (read-only is OK so the user can still view).

This issue ships piece 1 (routing redirect) plus the flag declarations on /api/health and frontend exposure that pieces 2 + 3 will consume. Tab visibility (piece 2) is wired in #216. Server-side write guard (piece 3) is wired by the chunks-edit endpoints from #205's follow-up.

2. Goals

  • /api/health exposes three new booleans: inspectModeEnabled, chunksModeEnabled, askModeEnabled.
  • useFeatureFlag('inspectMode' | 'chunksMode' | 'askMode') returns the live value.
  • Routing redirects /docs/:id?mode=<disabled> to the first enabled mode, in this priority: askchunksinspect. If none are enabled, redirect to /docs with a flash message.
  • No 404 on a disabled mode — only redirects.
  • Server-side: nothing yet (the actual write endpoints land elsewhere).
  • E2E (Karate UI): toggle a flag (via env var) → deep link redirects correctly.

3. Non-goals

  • Tab strip visibility — that is #216 (workspace).
  • Server-side write rejection — that lands on the chunks-edit endpoints (#205 follow-up).
  • Per-user flags (vs per-tenant) — out of scope; flags are global to the deployment.
  • A flag UI to toggle modes at runtime — operator-only via env vars.

4. Context & constraints

Existing code surface

  • document-parser/api/schemas.pyHealthResponse already exposes chunking, disclaimer, ingestion_available, reasoning_available.
  • document-parser/api/health.py — endpoint that builds HealthResponse.
  • document-parser/infra/settings.py — env-var driven settings.
  • frontend/src/features/feature-flags/store.ts — flag map + isEnabled(name).
  • frontend/src/features/feature-flags/useFeatureFlag.ts — composable.
  • frontend/src/app/router/index.ts — Vue Router (extended in #207).

Hexagonal Architecture constraints (backend)

The flag values are read from env vars in infra/settings.py (existing pattern). The API layer translates them into the HealthResponse DTO. No domain change.

Hard constraints

  • /api/health stays additive — three new fields, no breaking renames.
  • Frontend behaviour falls back gracefully when fields are absent (older backend version).
  • Default for all three flags = true (enabled). That preserves current behaviour for existing deployments.

5. Proposed design

5.1 Backend

document-parser/infra/settings.py adds three booleans:

INSPECT_MODE_ENABLED  = os.getenv("INSPECT_MODE_ENABLED",  "true").lower() == "true"
CHUNKS_MODE_ENABLED   = os.getenv("CHUNKS_MODE_ENABLED",   "true").lower() == "true"
ASK_MODE_ENABLED      = os.getenv("ASK_MODE_ENABLED",      "true").lower() == "true"

document-parser/api/schemas.pyHealthResponse gains:

inspect_mode_enabled: bool = True
chunks_mode_enabled:  bool = True
ask_mode_enabled:     bool = True

document-parser/api/health.py populates them from settings.

5.2 Frontend — flag store

frontend/src/features/feature-flags/store.ts adds three keys to the flag map:

inspectMode: this.health.inspectModeEnabled ?? true,
chunksMode:  this.health.chunksModeEnabled  ?? true,
askMode:     this.health.askModeEnabled     ?? true,

useFeatureFlag('inspectMode' | 'chunksMode' | 'askMode') returns a ComputedRef<boolean>. No new composable; the existing one works on these new keys.

5.3 Frontend — routing redirect

A pure helper frontend/src/shared/routing/resolveMode.ts:

export const MODE_PRIORITY: DocMode[] = ['ask', 'chunks', 'inspect']

export function resolveMode(
  requested: DocMode | undefined,
  enabled: Record<DocMode, boolean>,
): DocMode | null {
  if (requested && enabled[requested]) return requested
  return MODE_PRIORITY.find(m => enabled[m]) ?? null
}

Wired in the router's beforeEach guard for doc-workspace:

router.beforeEach((to) => {
  if (to.name !== ROUTES.DOC_WORKSPACE) return true
  const requested = parseMode(to.query.mode)
  const enabled = featureStore.modeFlags()  // returns Record<DocMode, boolean>
  const resolved = resolveMode(requested, enabled)
  if (resolved === null) {
    return { name: ROUTES.DOCS_LIBRARY, query: { reason: 'no-mode-enabled' } }
  }
  if (resolved !== requested) {
    return { ...to, query: { ...to.query, mode: resolved } }
  }
  return true
})

Pure helper is unit-testable; the beforeEach hook is integration-tested via router.test.ts.

5.4 i18n

A flash message displayed on /docs if ?reason=no-mode-enabled is present:

  • flags.allModesDisabled (fr + en).

The flash is rendered by DocsLibraryPage.vue (placeholder from #207) on mount; #211 will polish the rendering.

6. Alternatives considered

Alternative A — 404 on disabled mode

  • Summary: Show a "this mode is not available" 404.
  • Why not: A deep link sent by a colleague becomes a dead end. Redirect is more graceful.

Alternative B — Render the workspace and disable the tab

  • Summary: No redirect; the workspace renders with the disabled mode replaced by a "disabled" placeholder.
  • Why not: The URL still shows the disabled mode. A user copying it shares the same broken link.

7. API & data contract

Endpoints

Method Path Request Response Breaking?
GET /api/health now includes inspectModeEnabled, chunksModeEnabled, askModeEnabled No (additive)

Env vars

Name Default Allowed Notes
INSPECT_MODE_ENABLED true true / false gates the Inspect mode end-to-end
CHUNKS_MODE_ENABLED true true / false gates the Chunks mode
ASK_MODE_ENABLED true true / false gates the Ask mode

Breaking changes

None.

8. Risks & mitigations

Risk Audit dimension Likelihood Impact How we notice Mitigation / rollback
All three flags off → user lands on /docs with a confusing flash Clean Code Low Low Visual smoke The flash explicitly names the cause; admins should never set all three off
Frontend caches old flag values across navigation Decoupling Low Medium Stale UI The flag store loads on app boot and exposes a reload() for support flows; revisit if needed
Backend default change breaks existing deployments Security / Tests Low High E2E catches Defaults = true (enabled). Existing behaviour preserved unless operator opts out
Server-side write protection missing in 0.6.0 Security Medium Medium Manual / next audit Documented as a follow-up; client-side gating is enough until chunks-edit endpoints land

9. Testing strategy

Backend — pytest

  • tests/test_schemas.pyHealthResponse round-trip with the three new fields, defaults preserved.
  • tests/test_settings.py (or equivalent) — env-var parsing for the three flags.

Frontend — Vitest

  • shared/routing/resolveMode.test.ts — full table over (requested, enabled) combinations including all-disabled, all-enabled, requested disabled, requested enabled.
  • app/router/router.test.tsbeforeEach redirect scenarios via a mocked store.
  • features/feature-flags/store.test.ts — three new flags exposed; falls back to true when health response is missing them.

E2E — Karate UI

A focused smoke under e2e/api/: set CHUNKS_MODE_ENABLED=false in the test env, hit /docs/abc?mode=chunks, expect a redirect to /docs/abc?mode=ask (default).

Manual QA

  1. Default deploy → all three modes work.
  2. Set CHUNKS_MODE_ENABLED=false, restart, visit /docs/abc?mode=chunks → redirected to /docs/abc?mode=ask.
  3. Set all three to false/docs/abc?mode=ask redirects to /docs?reason=no-mode-enabled.

10. Rollout & observability

Release branch

release/0.6.0.

Feature flag

This issue is the feature flag work. Defaults preserve current behaviour.

Observability

  • A single INFO log line on the server at boot: feature_flags inspect=<true/false> chunks=<true/false> ask=<true/false>.
  • A WARN log when all three are off (operator misconfiguration smell).

Rollback plan

Set all three env vars to true (or unset them) → defaults restore the old behaviour.

11. Open questions

  • Per-tenant flags (multi-tenant deployments) — explicitly punted to post-0.6.0.
  • Should the flash message be a banner or a toast? Decision: banner, dismissible, persistent until the user navigates away.

12. References

  • Issue: https://github.com/scub-france/Docling-Studio/issues/210
  • Related issues: #207 (routing), #216 (workspace tabs), #205 follow-up (chunks-edit endpoints + server-side guard)
  • ADRs: none planned
  • Project docs: Architecture (docs/architecture.md), Frontend conventions (frontend/CLAUDE.md), Backend conventions (document-parser/CLAUDE.md)