Gateway P2.7 — Native Inbound Façades Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Spec:
docs/superpowers/specs/2026-07-07-gateway-p2.7-native-facades-design.md(read it; on conflict the spec wins). Ledger:.superpowers/sdd/p27-progress.md.
Goal: Serve the OCR engine's Azure-DI, PaddleOCR, and doc-classifier egress natively on the gateway (pool-admitted + audited), so flipping each seam is a base-URL env swap.
Architecture: Generalize the P2.6 SyncForward into a config-driven per-upstream sync façade (adapter: sync-facade, sync_paths), and add an async passthrough façade for Azure DI (adapter: azuredi-facade, facade_prefixes) that relays :analyze submits + Operation-Location polls verbatim, auditing submit and terminal poll via an in-memory correlation map.
Tech stack: Go 1.25, existing internal packages (config, server, jobs, pool, audit, registry). No new dependencies.
Global Constraints (from the spec + CONVENTIONS v1.0 — every task inherits these)
- Dormancy: with no
sync-facade/azuredi-facadeupstream configured, the binary behaves byte-identically to P2.6. Zero new routes mounted. - Engine hands-off: nothing in
ahu-ocr-tidyupchanges; the façades mirror the engine's existing wire shapes byte-for-byte (multipart bodies forwarded verbatim with original Content-Type boundary; query strings forwarded verbatim;Ocp-Apim-Subscription-Keyrelayed; response headers relayed minus hop-by-hop). - Auth + on-prem: every new inbound path applies the same tenant resolution (header-trust / token mode via
TokenTenants) andexternal_devrefusal (403 EXTERNAL_UPSTREAM_FORBIDDEN, nested error shape) asFacade.SyncForward. - Pool admission: sync paths and azure submits admit on the upstream's pool with the class hold budget; past budget →
429+Retry-After+X-Queue-Depth, slot always released (the P2.6releaseOnce/abandonpattern). - Audit: schema-v1 events via the shared emitter; bodies zstd-compressed and capped at
BodyCapByteswith sha256 on truncation (theFacade.compresspattern). Operation comes fromUpstream.OperationValue(ocr|classify) — never hardcoded. - Reserved-path collision guard: config validation rejects any
sync_paths/facade_prefixesentry equal to or prefixing/v1,/jobs,/metrics,/health,/summarize,/cek-bukti, or any gpu-server façade path (jobs.FacadePaths()+jobs.FacadeSyncPaths()). - Commit after every green test cycle. All work on branch
feat/gateway-p2.7-native-facadesoff master.
File Structure
- Modify:
internal/config/config.go—SyncPathtype,SyncPaths,SyncTimeoutMs,FacadePrefixesfields + validation + adapter allowlist. - Modify:
internal/jobs/facade.go— per-upstreamSyncForwardtimeout + operation from upstream (keep gpu-server behavior identical). - Create:
internal/jobs/facade_azuredi.go—AzureDIFacade(submit + poll passthrough + correlation map). - Create:
internal/jobs/facade_azuredi_test.go. - Modify:
internal/jobs/manager.go—newAdaptercasessync-facade→SyncHTTPAdapter,azuredi-facade→AzureDIAdapter(job-API fallback so a genericPOST /jobs?model=against these upstreams still works sanely). - Modify:
internal/server/server.go—EnableNativeFacades()mounting sync paths + azure prefixes per upstream. - Modify:
cmd/gateway/main.go— callEnableNativeFacadeswhen any such upstream exists. - Modify:
deploy/gateway.example.yaml— documented example upstream blocks. - Modify:
internal/server/facade_sync_test.go+ createinternal/server/native_facade_test.go— mount/behavior tests.
Task 1: Config surface (sync-facade / azuredi-facade upstreams)
Files: internal/config/config.go, internal/config/config_test.go
Interfaces produced:
type SyncPath struct {
Path string `yaml:"path"`
Model string `yaml:"model"`
}
// on Upstream:
SyncPaths []SyncPath `yaml:"sync_paths"`
SyncTimeoutMs int `yaml:"sync_timeout_ms"` // 0 → default 120000
FacadePrefixes []string `yaml:"facade_prefixes"`
Steps:
- [ ] Failing tests: (a) a valid sync-facade upstream (type ocr-http, sync_paths [{/layout, paddle-layout}]) loads; (b) a valid azuredi-facade upstream (facade_prefixes 4 entries) loads; (c) sync-facade with empty sync_paths → error; (d) azuredi-facade with empty facade_prefixes → error; (e) sync_path /v1/chat → collision error; (f) facade_prefix /jobs → collision error; (g) sync_path colliding with a gpu-server façade path (/summarize) → error; (h) type classify + adapter sync-facade valid with operation defaulting to classify.
- [ ] Implement: extend the adapter allowlist in the ocr-http|classify validation branch to sync-http|azure-di|classify|gpuserver-job|sync-facade|azuredi-facade; add requiredness + collision validation (reserved set: /v1, /jobs, /metrics, /health + jobs.FacadePaths()/FacadeSyncPaths() paths — import cycle note: jobs imports config, so config cannot import jobs; hardcode the reserved literals in config with a comment pointing at facade.go, and add a jobs-side test (Task 3) asserting the two lists stay in sync).
- [ ] go test ./internal/config/ green; commit.
Task 2: Generalized sync façade (PaddleOCR + doc-classifier)
Files: internal/jobs/facade.go, internal/server/server.go, internal/server/native_facade_test.go, cmd/gateway/main.go
Interfaces consumed: Task 1 config fields. Produced: Server.EnableNativeFacades(); Facade.SyncForward honoring per-upstream SyncTimeoutMs + OperationValue.
Steps:
- [ ] Modify Facade minimally: syncTimeout() helper (Upstream.SyncTimeoutMs > 0 → that, else 120s const) replacing the syncForwardTimeout const usage; emitSync takes operation from f.Upstream.OperationValue (default "ocr" when empty — gpu-server unchanged since its OperationValue is already "ocr").
- [ ] EnableNativeFacades() on Server: for each upstream with Adapter == "sync-facade", build a per-upstream jobs.Facade{Store:nil-ok, Reg, Cfg, Pools, Stats, TokenTenants, UpstreamID: u.ID, Upstream: u, Emit: s.emit} and mount each sync_paths entry as POST {path} → f.SyncForward(path, sp.Model). Guard double-mount (like facadeMounted). NOTE: SyncForward must not require the job Store (it doesn't — verify by test).
- [ ] cmd/gateway/main.go: call srv.EnableNativeFacades() unconditionally after the jobs/façade block (it no-ops with no matching upstreams).
- [ ] Failing tests first, then implement. Tests in native_facade_test.go (httptest backend standing in for paddle + classifier):
- multipart POST /layout forwarded byte-verbatim (body + Content-Type boundary + query), response JSON + headers relayed, audit event operation=ocr, model=paddle-layout, engine=<tenant>;
- POST /api/classifier/classify on a type: classify upstream → audit operation=classify, model=doc-classifier; interactive default class (classFromPriority classify rule);
- 429 past hold budget with slot released after (mirror TestFacadeSyncReleasesSlotAfter429);
- per-upstream timeout honored (SyncTimeoutMs=50 against a slow backend → 502/504-style upstream error, audit error);
- dormancy: no sync-facade upstreams → the routes 404;
- gpu-server façade regression: existing facade_sync_test.go suite still green untouched.
- [ ] go test ./internal/... green; commit.
Task 3: Azure DI async passthrough façade
Files: internal/jobs/facade_azuredi.go, internal/jobs/facade_azuredi_test.go, internal/server/server.go (mount in EnableNativeFacades), internal/jobs/manager.go (newAdapter fallback cases)
Interfaces produced:
type AzureDIFacade struct {
Reg *registry.Registry; Cfg *config.Config; Pools map[string]pool.Pool
TokenTenants map[string]string; UpstreamID string; Upstream *config.Upstream
Emit func(audit.Event); Client *http.Client
// corr: in-memory map[resultID]submitInfo{start time, tenant, model, trace, surface, user}; TTL 1h, swept lazily.
}
func (a *AzureDIFacade) Analyze() http.HandlerFunc // POST {prefix}/.../documentModels/{model}:analyze
func (a *AzureDIFacade) Poll() http.HandlerFunc // GET {prefix}/... passthrough
Steps:
- [ ] Failing tests (httptest backend emulating Azure DI on-prem):
- submit: POST /api/custom-template/formrecognizer/documentModels/bukti_setor_v2:analyze?api-version=2023-07-31 with binary body + Ocp-Apim-Subscription-Key → forwarded verbatim (path+query+body+key header), 202 relayed including Operation-Location header unchanged, audit event (status ok, model=bukti_setor_v2, operation=ocr, doc body captured);
- model extraction: documentModels/{model}:analyze parsed from BOTH dialects; un-parseable path → model falls back to the last path segment (never an error);
- poll passthrough: GET {prefix}/formrecognizer/documentModels/x/analyzeResults/{id}?api-version=... forwarded, non-terminal ("status":"running") → NO audit event; terminal succeeded → one completion event with upstream_ms from the correlation entry (submit seeded it via the same {id} extracted from Operation-Location); terminal failed → completion event status=error;
- correlation miss (poll for unknown id, e.g. after gateway restart) → passthrough still works, completion event emitted with queue_ms=0 and no start-derived duration (documented degradation, never a 5xx);
- pool admission on submit only: hold-budget 429 mirrors SyncForward; polls never touch the pool;
- auth + on-prem enforcement on both handlers;
- dormancy: no azuredi-facade upstream → prefixes 404.
- [ ] Implement AzureDIFacade (share writeErr, classFromPriority, compress/emit helpers — extract small shared helpers in facade.go rather than duplicating). Mount in EnableNativeFacades: for each facade_prefixes entry P, mux.Handle("POST "+P+"/", analyze) routing: path ends :analyze → Analyze, else 405; mux.Handle("GET "+P+"/", poll).
- [ ] newAdapter: case "sync-facade" → &SyncHTTPAdapter{Upstream: u} (SubmitPath = first SyncPaths entry); case "azuredi-facade" → &AzureDIAdapter{Upstream: u}. Test: manager with a sync-facade upstream runs a generic job through it.
- [ ] Reserved-path sync test: assert config's hardcoded reserved list ⊇ jobs.FacadePaths()+FacadeSyncPaths() paths (lives in jobs package, no cycle).
- [ ] go test ./internal/... green; commit.
Task 4: Wiring polish + example config + docs
Files: deploy/gateway.example.yaml, docs/integration/prompt-ahu-ocr-tidyup.md (P2.7 update note), README/CONVENTIONS untouched.
Steps:
- [ ] Example blocks for the three upstreams exactly as the spec's YAML (azure endpoints = nginx base, paddle = host-published :8108, classifier = nginx base + full sync path), each with a comment naming the engine env var that flips it.
- [ ] Append a "P2.7 update" note to the OCR integration prompt: the three flips are now base-URL swaps; paddle deferred until enabled.
- [ ] GOFLAGS=-mod=mod go build ./... && go vet ./... + full suite green; commit.
Task 5: Merge + final review + tag
- [ ] Whole-branch review (superpowers final-review pattern; fix Critical/Important, log Minor), merge
feat/gateway-p2.7-native-facades→ master, tagp2.7.
Task 6: Deploy to ai-ahu + live config + verification (controller-run, not subagent)
- [ ] Backup live
gateway.yaml; append the three upstreams (azurehttps://x056.ahu-azure.val.id+ 4 prefixes; classifierhttps://x056.ahu-azure.val.id+/api/classifier/classify; paddlehttp://192.168.83.20:8108+/layout). - [ ] tar-sync source →
docker compose build gateway→up -d; startup log shows native façades enabled;/health200. - [ ] Chatbot regression:
/v1completion 200 + façade pollJOB_UNKNOWNshape + existing OCR job path still green (submit aktp-cleanupsmoke). - [ ] Gateway-loopback smokes BEFORE any engine flip: classifier classify via gateway (multipart, expect
{classification, confidence}+ auditoperation=classify); azure analyze+poll via gateway against a real custom model with a tiny PDF/image (expect Operation-Location relay + analyzeResult + 2 audit events); paddle/layoutvia gateway (container is up even if engine doesn't use it — expect words JSON + audit).
Task 7: Engine env flips, one seam at a time (controller-run)
- [ ] Backup
shared.env. FlipCLASSIFIER_URL=http://192.168.83.20:8200/api/classifier/classify→ recreate web (-p ahu-ocr-staging, pinned IMAGE_TAG,--no-deps) → real-document classification smoke → audit event check (engine=ahu-ocr, operation=classify). - [ ] Flip
AZURE_ON_PREM_BASE_URL=http://192.168.83.20:8200→ recreate web → real-document Azure extraction smoke (a bukti-setor or KTP flow) → audit events (submit + terminal) → confirm Operation-Location rewrite path works live. - [ ] Leave
PADDLE_OCR_URLunset (dormant); note in ledger. - [ ] Final: chatbot
/v1+ gpu-server job path + summarize regression sweep; metrics show ahu-ocr on all three upstream dimensions (gpu-server, azure-di-onprem, doc-classifier). - [ ] Upload changed .md files per CLAUDE.md; final report.