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 asaudit.sqlite),
tablestaff_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_PASSWORDenv vars. mock-staff.tshardcoded accounts are deleted.
Session layer (Approach A)
- Login route (
POST /api/auth/login): bcrypt-verify againststaff_users;
on success issue an HS256 JWT via the existingjosesigner
(src/lib/auth/jwt.ts, secretNEXTAUTH_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/mereturns the session user for the client store;
POST /api/auth/logoutclears the cookie.- Failed logins: constant small delay + audit row (
auth.login_failed). No
lockout machinery at POC stage.
Enforcement
middleware.tsmatcher expands to/admin/:path*and
/api/admin/:path*: verify JWT signature + expiry withjose(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 requireactive && tv === token_version.
Disabling a user or resetting their password bumpstoken_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/usersCRUD 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, tableaudit).
Cutover
- Deploy internal-web with server-side auth.
- Verify from the internet:
/api/admin/*without cookie → 401; login →
cookie → 200. - 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.pyusing sqlglot (already a
dependency). Ports the six spec §5 rules:
1. single statement, SELECT-only
2. table allowlist
3. noSELECT *
4. disallowed-column list
5. noLIMIT 1
6. aggregate-or-minimum-rows rule run_sql_querycalls the guard before execution whenSURFACE=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.tssemantics). - Internal agent behavior is unchanged (
SURFACEunset/internal → current
validator only). - Tests: pytest port of the 13 TS cases + a
SURFACE=internalpassthrough
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: trueand attach theai-ahu-ragservice to
it (keeping its existing networks). docker compose up -dthe RAG stack; then verify the attach survives
--force-recreateand 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 onahu-net/ahu-gateway-net, so container-name
routing is not available, and the host-published port is durable. agents.py: constructOpenAIEmbedder(id=EMBEDDER_MODEL, base_url=EMBEDDER_BASE_URL, api_key=EMBEDDER_API_KEY, dimensions=EMBEDDER_DIM)whenEMBEDDER_BASE_URLis set; otherwise keep
current behavior (dev fallback).- Vector dim changes 1536 → 2560: drop
dash_knowledge/dash_learnings
vector tables and re-ingest via the existingknowledge-reingestBullMQ
queue. No in-place migration. - Verify: hybrid search returns non-zero
hybrid_score; no
OPENAI_API_KEYwarnings in agent logs; no external API calls.
Success criteria
- Internet curl to
/api/admin/*without cookie → 401; after login → 200. - A disabled user's existing session cannot mutate (token_version check
verified live). /admin/shared/usersCRUD works and writes audit rows; Basic Auth removed
from the staff vhost.- sql-guard pytest green (13 ported cases + passthrough); live public query
with a forbidden pattern is refused; an allowed aggregate query passes. - RAG stack
--force-recreatepreserves the ahu-net attach; public doc
query green afterwards. - Knowledge hybrid search returns non-zero scores via TEI; re-ingest job
completes via the BullMQ queue. - 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 connectremains available as the manual fallback. - D4: unset
EMBEDDER_*env → agents fall back to current behavior;
knowledge tables re-ingest from source either way.