think
16px
820px

Plan D — Deferred Backlog Design (2026-07-02)

Closes out the four unblocked deferrals from Plan C
(2026-07-02-monorepo-split-plan-c-completion-notes.md). The fifth deferral —
public DB replica flip — stays deferred: no replica exists to flip to.

Scope decisions (user-confirmed)

# Item Decision
D1 Staff server-side auth DB-managed users + admin UI now; session layer designed so SSO can replace the credential check later. Session = JWT cookie + token-version revocation (Approach A).
D2 sql-guard runtime wiring Enforcement lives in the Python public agent, pre-execution. The Plan C idea of wiring the TS validator into DataTool is superseded — DataTool never sees SQL or rows (it only accumulates RunResponseContent text; verified in apps/public-web/src/lib/orchestrator/tools/data.ts).
D3 Durable ahu-net attach Fix the ai-ahu-rag stack's own compose on Server 2. This repo's compose files already declare ahu-net: external.
D4 Local embeddings Reuse the existing tei-qwen3-embed container (one-shared-container-per-capability pattern, same as ahu-vllm / paddle-ocr / classifier). No new container.
Public DB replica Out of scope (blocked on infra that does not exist).
SSO Out of scope; D1 keeps the swap surface to a single login route.

Deploy order: D3 → D4 → D2 → D1 (infra first; auth last because it changes
edge behavior).

D1 — Staff server-side auth

Replaces the nginx Basic Auth stopgap on
x056.ahu-demo.chatbot-neo-staff.val.id. Today /api/admin/* has zero
server-side auth (client-only localStorage JWT, mock HS256 signer, hardcoded
accounts in src/lib/auth/mock-staff.ts; middleware.ts only does legacy
redirects).

User store

  • SQLite at /data/staff.sqlite (same volume/pattern as audit.sqlite),
    table staff_users:
    id INTEGER PK, email TEXT UNIQUE, name TEXT, role TEXT CHECK(role IN ('admin','staf')), pass_hash TEXT (bcrypt), token_version INTEGER DEFAULT 1, active INTEGER DEFAULT 1, created_at TEXT, updated_at TEXT.
  • First boot with an empty table seeds one admin from
    STAFF_ADMIN_EMAIL / STAFF_ADMIN_PASSWORD env vars.
  • mock-staff.ts hardcoded accounts are deleted.

Session layer (Approach A)

  • Login route (POST /api/auth/login): bcrypt-verify against staff_users;
    on success issue an HS256 JWT via the existing jose signer
    (src/lib/auth/jwt.ts, secret NEXTAUTH_SECRET, 8h expiry, claims
    sub = email, role, tv = token_version) set as an httpOnly, Secure,
    SameSite=Lax cookie
    (ahu_staff_session, path /). No localStorage token.
  • GET /api/auth/me returns the session user for the client store;
    POST /api/auth/logout clears the cookie.
  • Failed logins: constant small delay + audit row (auth.login_failed). No
    lockout machinery at POC stage.

Enforcement

  • middleware.ts matcher expands to /admin/:path* and
    /api/admin/:path*: verify JWT signature + expiry with jose (Edge-safe,
    no store lookup). Pages redirect to /login; API requests get 401 JSON.
    Existing legacy redirects are kept.
  • Mutation handlers (POST/PUT/PATCH/DELETE under /api/admin/*): load the
    user row from SQLite and require active && tv === token_version.
    Disabling a user or resetting their password bumps token_version, which
    revokes existing sessions for mutations immediately. Read-only pages remain
    valid until cookie expiry (accepted trade-off, 8h max).

Admin UI

  • /admin/shared/users CRUD page: create (bcrypt hash server-side), edit
    name/role, reset password, disable/enable. Backed by /api/admin/users.
  • Every mutation writes an audit row (user.create, user.update,
    user.disable, user.reset_password) to the existing audit SQLite
    (/data/audit.sqlite, table audit).

Cutover

  1. Deploy internal-web with server-side auth.
  2. Verify from the internet: /api/admin/* without cookie → 401; login →
    cookie → 200.
  3. Remove Basic Auth from the staff vhost (keep the htpasswd file and a vhost
    backup for rollback).

SSO later = replace the login route only; cookie, middleware, and
version-check logic are unchanged.

D2 — sql-guard pre-execution enforcement (Python public agent)

SQL is generated and executed inside the Python agent
(apps/public-agent/dash/tools/sql.py); today _validate_query checks only
BLOCKED_KEYWORDS and statement start. The TS validator
(apps/public-web/src/lib/orchestrator/sql-guard/validator.ts, 13 vitest
cases) stays as the tested reference implementation for the TS side.

  • New apps/public-agent/dash/tools/sql_guard.py using sqlglot (already a
    dependency). Ports the six spec §5 rules:
    1. single statement, SELECT-only
    2. table allowlist
    3. no SELECT *
    4. disallowed-column list
    5. no LIMIT 1
    6. aggregate-or-minimum-rows rule
  • run_sql_query calls the guard before execution when SURFACE=public.
    Violations return a structured refusal message to the model (not an
    exception), so the agent can rephrase or refuse.
  • Row-level k-anonymity filtering applies to the result set Python-side,
    before rows reach the model (mirrors
    sql-guard/result-filter.ts semantics).
  • Internal agent behavior is unchanged (SURFACE unset/internal → current
    validator only).
  • Tests: pytest port of the 13 TS cases + a SURFACE=internal passthrough
    case.

D3 — Durable ahu-net attach for ai-ahu-rag

Current state: ai-ahu-rag was attached with docker network connect, which
does not survive container recreate. This repo's four compose files
(infra/compose.{shared,public,internal,workers}.yaml) already declare
ahu-net as external — the gap is in the RAG stack's own compose (sibling
repo checked out on Server 2).

  • Edit the ai-ahu-rag compose on Server 2: declare
    networks: ahu-net: external: true and attach the ai-ahu-rag service to
    it (keeping its existing networks).
  • docker compose up -d the RAG stack; then verify the attach survives
    --force-recreate
    and the orchestrator still resolves
    http://ai-ahu-rag:8110.
  • Live doc query on the public surface as the end-to-end check.

D4 — Local embeddings via tei-qwen3-embed

Both agents hardcode OpenAIEmbedder(id="text-embedding-3-small")
(apps/{public,internal}-agent/dash/agents.py), which violates the on-prem
constraint and returns 0-dim vectors without an OpenAI key. tei-qwen3-embed
(TEI 1.9, Qwen/Qwen3-Embedding-4B fp16, GPU 1, host port 8100) already
serves the RAG stack and exposes an OpenAI-compatible /v1/embeddings.

  • Env (both agent env files + examples):
    EMBEDDER_BASE_URL=http://192.168.83.20:8100/v1,
    EMBEDDER_MODEL=Qwen/Qwen3-Embedding-4B, EMBEDDER_DIM=2560,
    EMBEDDER_API_KEY=EMPTY. LAN IP is deliberate: TEI sits on
    ai-ahu-rag-net, agents on ahu-net/ahu-gateway-net, so container-name
    routing is not available, and the host-published port is durable.
  • agents.py: construct OpenAIEmbedder(id=EMBEDDER_MODEL, base_url=EMBEDDER_BASE_URL, api_key=EMBEDDER_API_KEY, dimensions=EMBEDDER_DIM) when EMBEDDER_BASE_URL is set; otherwise keep
    current behavior (dev fallback).
  • Vector dim changes 1536 → 2560: drop dash_knowledge / dash_learnings
    vector tables and re-ingest via the existing knowledge-reingest BullMQ
    queue. No in-place migration.
  • Verify: hybrid search returns non-zero hybrid_score; no
    OPENAI_API_KEY warnings in agent logs; no external API calls.

Success criteria

  1. Internet curl to /api/admin/* without cookie → 401; after login → 200.
  2. A disabled user's existing session cannot mutate (token_version check
    verified live).
  3. /admin/shared/users CRUD works and writes audit rows; Basic Auth removed
    from the staff vhost.
  4. sql-guard pytest green (13 ported cases + passthrough); live public query
    with a forbidden pattern is refused; an allowed aggregate query passes.
  5. RAG stack --force-recreate preserves the ahu-net attach; public doc
    query green afterwards.
  6. Knowledge hybrid search returns non-zero scores via TEI; re-ingest job
    completes via the BullMQ queue.
  7. All existing suites stay green (public-web vitest incl. sql-guard, streams
    8/8, isolation pytest 3/3, typecheck); shipped via
    ./infra/deploy/build-and-ship.sh + per-stack deploy.

Rollback notes

  • D1: restore Basic Auth include on the staff vhost (htpasswd retained);
    internal-web image rollback via previous tag.
  • D2: guard is env-gated by SURFACE=public; emergency disable = redeploy
    public-agent with previous image.
  • D3: docker network connect remains available as the manual fallback.
  • D4: unset EMBEDDER_* env → agents fall back to current behavior;
    knowledge tables re-ingest from source either way.