Monorepo Public/Internal Split — Design Spec
Date: 2026-06-30
Status: Approved, ready for implementation planning
Supersedes: the 3-repo topology described in 2026-05-21-orchestrator-and-public-data-design.md (the orchestrator design itself stays; only its hosting changes)
Goal
Collapse the current 3-repo system (ai-ahu-chatbot + ahu-chatbot-orchestrator + ai-ahu-data-dash) into a single monorepo (ahu-ai-chatbot), structured around the real architectural boundary — public vs internal — with physical isolation between the two so that internal data, prompts, and code paths cannot leak to public traffic.
Drivers
- The 3-repo split is a historical accident, not a real architectural boundary. The real boundary is public vs internal.
- Specs already live in the chatbot repo but reference code in sibling repos — constant drift.
- Cross-repo dev requires coordinating 3 PRs, 3 deploys, 3 git histories. Mistakes cause version skew.
- Single deploy artifact + one runbook is easier to operate.
Constraints
- Model deployment (dual-model, transitional): planning runs on the local GPU server via vLLM (Qwen3.6-35B-A3B-FP8); synthesis runs on Alibaba Cloud (Qwen3.5-397B-A17B) as a transition state. Synthesis will move to the local GPU server once we have the 16× B200 GPUs (shared with other AHU products). No OpenAI/Anthropic/etc. in production paths. Both endpoints are OpenAI-compatible and reached through
MODEL_GATEWAY_URL— flipping synthesis from Alibaba → local later is a config change, not a code change. - Physical isolation between public and internal: internal code must not compile into the public artifact. Different containers, different env, different networks, different data sources, different prompts.
- Working system must keep working:
chatbot-neo.val.idis live. Cutover plan must preserve service. - Indonesian-language UX: existing surface conventions (Tanya / Tanya Data / Konsol Admin) preserved.
Current state (3-repo)
- ai-ahu-chatbot — Next.js 15 frontend, hosted as
ahu-chatbot-devcontainer on Server 1 (0-GPU, 192.168.62.155). Routes:/tanya(public),/app/data(staff),/admin/*(staff). Talks to orchestrator + internal Dash directly. - ahu-chatbot-orchestrator — Python FastAPI, Server 2 (GPU, 192.168.83.20), port 8104. Plan→Execute→Judge→Compose loop. Routes public Tanya traffic to RAG (8110) + public Dash (8102).
- ai-ahu-data-dash — Python FastAPI Agno SQL agent, deployed twice on Server 2 (
dash-api-public:8102,dash-api-staff:8101) with different knowledge scopes.
Target topology
Single monorepo, 4 deployable apps, all hosted on Server 2 (co-located with vLLM, Milvus, embedding model).
ahu-ai-chatbot/
├── apps/
│ ├── public-web/ # Next.js — Tanya + orchestrator API routes + SSE
│ ├── internal-web/ # Next.js — Tanya Data + Konsol Admin
│ ├── public-agent/ # Python — restricted Dash variant
│ └── internal-agent/ # Python — full Dash variant
├── packages/
│ ├── ui/ # shared React components (chat, tables, charts, admin chrome)
│ ├── streams/ # shared SSE/native-provider TS code
│ ├── orchestrator-types/ # Plan/Tool/Event types shared FE ↔ API routes
│ ├── queue/ # BullMQ workers (background jobs)
│ ├── agno-base/ # shared Python: tools, prompt scaffolding, vLLM client
│ └── eval/ # eval harness
├── infra/
│ ├── compose.public.yaml
│ ├── compose.internal.yaml
│ ├── compose.shared.yaml # Redis, future model-gateway placeholder
│ └── knowledge/
│ ├── public/ # public Dash knowledge (restricted)
│ └── internal/ # internal Dash knowledge (full)
└── docs/
Why 4 apps and not 5 or 3
- The public orchestrator does pure HTTP coordination (vLLM via OpenAI-compatible HTTP, SSE encoding, tool routing to RAG/Dash, SQLite policy CRUD). No Python-specific dependency. Folded into Next.js API routes — shared types with the FE automatic, single SSE pipeline, no proxy hop.
- Public and internal Next.js stay separate apps so internal routes never compile into the public bundle. Approach C (one Next.js, two configs) was rejected because it lets internal code paths exist inside the public binary.
- Approach B (orchestrator + agent in one Python process) was rejected because it couples them and requires an up-front rewrite. Keeping them separate keeps the rewrite optional.
Isolation contract
The contract is enforced at deploy time. The orchestration framework here is "what code is in which image, what env each image gets".
| What | Public artifact | Internal artifact |
|---|---|---|
| Docker image | ahu-ai-chatbot-public (+ ahu-ai-agent-public) |
ahu-ai-chatbot-internal (+ ahu-ai-agent-internal) |
| Bundled routes | /tanya/*, /api/orchestrate, /api/health |
/app/*, /admin/*, /api/data, /api/admin/* |
| Env: DB creds | Phase 1: same internal DB + full creds (no public DB yet). Phase 2: PUBLIC_DB_URL with restricted creds. |
INTERNAL_DB_URL, full creds |
| Env: agent URL | PUBLIC_AGENT_URL=http://public-agent:8000 |
INTERNAL_AGENT_URL=http://internal-agent:8000 |
| Knowledge dir (read-only mount) | infra/knowledge/public/ |
infra/knowledge/internal/ |
| Docker network | public-net + gateway-net + ingress |
internal-net + gateway-net |
| Internet ingress | yes (via reverse proxy → chatbot-neo.val.id) |
no (LAN only) |
| Prompts | packages/agno-base/prompts/public.py |
packages/agno-base/prompts/internal.py |
Three isolations satisfied:
- Data source — public-web has no DB env vars at all; public-agent has restricted-scope creds (Phase 2) + public-only knowledge mount.
- Instructions — separate prompt modules per agent. Never co-loaded in the same process.
- Flow — public goes through orchestrator (Plan/Execute/Judge/Compose); internal calls agent directly. Different code paths, different binaries.
Phase 1 honest caveat: because no public DB exists yet, the public agent reads from the internal DB in Phase 1. Protection that public users can't see internal data comes from (a) the restricted prompt in prompts/public.py + (b) the restricted tool surface loaded into public-agent — same posture as today's dash-api-public. The env-var shape is pre-wired for a PUBLIC_DB_URL flip with zero code changes when the public DB lands.
Data flow
Public turn (chatbot-neo.val.id/tanya):
Browser
↓ POST /api/orchestrate (SSE)
public-web (Next.js)
├─ lib/orchestrator/plan → MODEL_GATEWAY_URL (planning model = local vLLM 3.6-35B, headers: X-Surface=public, X-Priority=planning)
├─ lib/orchestrator/execute → public-rag-service:8110/search
│ → public-agent:8000/agents/data-agent/runs
├─ lib/orchestrator/judge → MODEL_GATEWAY_URL (planning model)
└─ lib/orchestrator/compose → MODEL_GATEWAY_URL (synthesis model = Alibaba 3.5-397B today, local later) → SSE chunks back
Internal turn (internal.local/app/data):
Browser
↓ POST /api/data (SSE)
internal-web (Next.js)
└─ direct → internal-agent:8000/agents/data-agent/runs → MODEL_GATEWAY_URL
Forward-compat for shared GPU infrastructure
The chatbot monorepo does NOT build the GPU/model gateway — that's a separate platform service shared across AHU products (chatbot, doc-classifier, ocr-akta-notaris, doc-forensic). This monorepo just doesn't paint itself into a corner.
Reserved hooks (config slots today, drop-in tomorrow):
MODEL_GATEWAY_URL(planning) +SYNTHESIS_GATEWAY_URLenv vars — both OpenAI-compatible. Today: planning → localhttp://ahu-vllm:8000(Qwen3.6-35B-A3B-FP8); synthesis → Alibaba Cloud Model Studio endpoint (Qwen3.5-397B-A17B). When synthesis moves on-prem,SYNTHESIS_GATEWAY_URLflips to the local gateway. When the shared model gateway lands, both flip to it. Zero code change either time.- Standard request headers from orchestrator/agent → vLLM:
-X-Tenant-Id: ahu-chatbot
-X-Surface: public|internal
-X-User-Id: <anon-bucket-or-staff-id>
-X-Priority: planning|synthesis|eval
vLLM ignores them today; gateway uses them for fairness + scheduling tomorrow. packages/queue/(BullMQ on Redis) — day-1 use: nightly eval runs + knowledge re-ingestion workers. NOT user-facing GPU-queue UI (that belongs in the future gateway).- SSE resume primitive — orchestrator writes
Last-Event-IDcheckpoints into Redis so the browser can reconnect mid-stream. Useful both for flaky networks today and for queue-position UI later.
Admin UI/UX
Admin lives only in internal-web. The public app ships zero admin routes — they don't exist in the public bundle.
Nav structure
Konsol Admin
├─ 🌐 Publik (config that affects public-agent / public surface)
│ ├─ Knowledge Base — tables, business rules, query patterns (public scope)
│ ├─ Prompt & Persona — public agent system prompt
│ ├─ Public Policy — rate limits, anon override, redirect rules
│ └─ RAG Corpus — public document ingestion (Tanya RAG)
│
├─ 🔒 Internal (config that affects internal-agent / staff surface)
│ ├─ Knowledge Base — tables, business rules, query patterns (full scope)
│ ├─ Prompt & Persona — internal agent system prompt
│ └─ Schema Sync — reload-schema, DB introspection
│
├─ ⚙️ Bersama (Shared)
│ ├─ Model Gateway — MODEL_GATEWAY_URL (planning, local), SYNTHESIS_GATEWAY_URL (Alibaba today, local later), preset switcher
│ ├─ Provider Config — vLLM endpoint, fallback, headers
│ └─ Background Jobs — BullMQ queues (evals, ingestion runs)
│
└─ 📊 Observasi
├─ Threads & Sessions — per-surface filter
├─ Eval Runs — kick off + view history
└─ Audit Log — config changes, who/when
Visual cues (inherit existing .admin-shell aesthetic)
- Page header has a tinted scope badge: 🌐
public(cool blue), 🔒internal(warm amber), ⚙️shared(neutral). Tinted left-border on cards reinforces scope. - Color is decorative, never the only signal — the badge text + icon are always visible.
- Top-right of every admin page: a scope pill showing the current scope. Cmd+K palette has "Switch to Public Admin" / "Switch to Internal Admin".
- Drift detection on Knowledge Base pages: when a table/business-rule exists in both scopes, show a small "drift detected" badge if their content differs.
- Cross-scope copy actions (e.g. "promote this rule from internal → public") use the existing
DiffConfirmDialogand explicitly show what becomes visible to public.
Auth posture
- Internal-web has NextAuth + staff role gate (current setup).
- New role:
admin-public— can edit Public scope only. - Existing role:
admin-internal— can edit Internal + Shared. - Audit log records
{scope, who, what, when, before, after}for every mutation.
Route layout
apps/internal-web/app/admin/
├── public/
│ ├── knowledge/
│ ├── persona/
│ ├── policy/ (current public-policy redesign lands here)
│ └── rag/
├── internal/
│ ├── knowledge/
│ ├── persona/
│ └── schema/
├── shared/
│ ├── model-gateway/
│ ├── provider/
│ └── jobs/
└── observe/
├── threads/
├── evals/
└── audit/
Shared admin chrome (cards, dialogs, chips) lives in packages/ui/admin/.
Future public auth (out of scope, but architecture-aware)
Public will eventually have its own auth + accounts. Not built now. Architectural slots reserved:
apps/public-web/lib/auth/directory ready for a future NextAuth config.- Public user tables: in Phase 1 would co-locate in the internal DB if needed; in Phase 2 they belong in
PUBLIC_DB_URL. - Public account UI lives entirely in
public-web— internal admins manage public user accounts via a future Bersama → Public Users page (data sourced from public DB). - Cookie domain isolation: public auth cookies must scope to public host only; internal auth cookies to internal host only.
Migration plan
Phase 0 Repo setup (~1 day)
Phase 1 Code relocation (~2-3 days)
Phase 2 Build + deploy parity (~2 days)
Phase 3 Traffic cutover (~1 day)
Phase 4 Hot-spot rewrites (rolling, post-cutover)
Phase 0 — Repo setup
- Rename
ai-ahu-chatbot→ahu-ai-chatbot(or create new repo and pull existing into it; either way keep current git history intact). git subtree add --prefix=apps/internal-agent ../ai-ahu-data-dash maingit subtree add --prefix=tmp/orchestrator ../ahu-chatbot-orchestrator main(Python lands in temp dir; gets translated to TS in Phase 1, thentmp/is deleted)- Set up tooling:
pnpm-workspace.yaml+ rootpackage.jsonfor TS;uvworkspaces + rootpyproject.tomlfor Python.
Phase 1 — Code relocation
- Move
src/app/(public)/tanya/*+ relevant provider/streams code intoapps/public-web/. - Move
src/app/(staff)/*+src/app/(staff)/admin/*intoapps/internal-web/. - Extract shared React/TS into
packages/ui+packages/streams+packages/orchestrator-types. - Translate
tmp/orchestrator/src/orchestrator/*.py→apps/public-web/lib/orchestrator/*.ts(Plan/Execute/Judge/Compose, vLLM client, RAG/Dash tool clients, SSE encoder, policy store). Then deletetmp/orchestrator/. - Duplicate
apps/internal-agentintoapps/public-agent— same Python code, mounts public knowledge dir, loads public prompt module. - Knowledge files moved to
infra/knowledge/{public,internal}/.
Phase 2 — Build + deploy parity
infra/compose.public.yaml,infra/compose.internal.yaml,infra/compose.shared.yaml(Redis, model-gateway placeholder).- Build images:
ahu-ai-chatbot-public,ahu-ai-chatbot-internal,ahu-ai-agent-public,ahu-ai-agent-internal. - Smoke runs against staging URLs (
chatbot-neo-next.val.idor similar) — public stack + internal stack standing side-by-side with current production.
Phase 3 — Traffic cutover
- DNS / reverse-proxy flip on
chatbot-neo.val.idfrom the oldahu-chatbot-devcontainer to the new public stack. - Internal users move to the internal stack URL.
- Old
ai-ahu-chatbot,ahu-chatbot-orchestrator,ai-ahu-data-dashrepos archived (kept read-only for history reference). - Sibling project specs that reference paths inside
ai-ahu-chatbot/docs/keep working because the chatbot repo IS the new monorepo (just renamed).
Phase 4 — Hot-spot rewrites (rolling, post-cutover)
| # | Hot spot | Why rewrite | Effort | Ship with refactor? |
|---|---|---|---|---|
| 1 | lib/streams/native-provider.ts |
DualMode detection patched on; redesign as event-pipeline | 2 days | post-cutover |
| 2 | Orchestrator policy store | SQLite-file → shared store (probably Postgres alongside agents); enables per-tenant policy later | 2 days | post-cutover |
| 3 | Dash dual-model wiring | output_model wiring bolted on; promote to first-class agent config |
1-2 days | post-cutover |
| 4 | Admin scope-switcher + audit log | Brand-new for the split admin UI | 3 days | with refactor |
| 5 | SSE resume primitive | Last-Event-ID + Redis checkpoint for reconnect | 1-2 days | with refactor |
| 6 | Standard gateway request headers | Drop-in additions to vLLM HTTP client | 0.5 day | with refactor |
Items 4-6 ship with the refactor (small effort, payoff in v1). Items 1-3 are done post-cutover.
Testing strategy
- Unit/integration tests stay co-located with each app (
apps/*/tests/) and run via per-app commands. - Cross-app integration tests in
tests/e2e/exercise the public stack and internal stack via real HTTP (no mocks of agents or vLLM — use staging vLLM endpoint). - Isolation tests specifically verify the boundary: a test suite called
tests/isolation/runs against the public image and asserts (a)/app/*returns 404, (b) admin routes return 404, (c)INTERNAL_DB_URLis unset, (d) internal prompts cannot be imported from the public Python image. - Eval harness (
packages/eval/) runs both per-app smoke evals and the existing 28-case AHU eval against both agents to verify post-refactor parity.
Risks and mitigations
| Risk | Mitigation |
|---|---|
| Translating Python orchestrator → TS introduces behavioral drift | Per-feature parity tests against the Python orchestrator before deleting it; A/B traffic during Phase 2 staging |
| Public-agent and internal-agent diverge over time despite shared base | packages/agno-base/ carries all shared code; agents are thin entrypoints. Drift-detection on knowledge in admin UI surfaces accidental divergence. |
| Public DB Phase 1 reality (public reads internal DB) is forgotten and becomes permanent | Documented here explicitly; tracked as a Phase 2 follow-up; PUBLIC_DB_URL env stub present from day 1 as a visible reminder |
Cutover breaks chatbot-neo.val.id mid-traffic |
Staging URL parallel to prod; DNS TTL lowered before cutover; rollback = flip DNS/reverse proxy back |
Sibling repos (ahu-doc-classifier, ahu-ocr-akta-notaris) still call old service URLs |
Old containers stay up read-only during Phase 3; URLs proxied to new stack until siblings cut over |
Open questions
None blocking. Items deferred to implementation:
- Exact tooling choice between Turborepo / Nx / plain pnpm-workspaces (will pick simplest that works during Phase 0).
- Whether
packages/agno-base/becomes a published wheel or stays a workspace-only package (workspace-only is fine for now). - Where audit-log storage lives (initial: Postgres alongside agents; could move to dedicated audit DB later).