One who reads and announces markings.
Herald is a Go web service with an embedded Lit web client for classifying approximately 1,000,000 Department of Defense PDF documents' security markings using Azure AI Foundry GPT models. It reads security classification markings from document pages, interprets them through vision-capable LLMs, and produces structured classification records suitable for database association and downstream system integration.
Organizations managing large corpora of classified documents face a persistent bottleneck: manually reading and recording security markings across millions of pages. Herald eliminates this bottleneck by applying vision-capable LLMs to interpret markings at scale, producing structured classification records that humans validate and downstream systems consume. The service is designed for rapid delivery, drawing heavily from proven patterns established during R&D in agent-lab and the tau agentic-units ecosystem.
Herald occupies a deliberate middle ground between two existing implementations:
-
classify-docs (
go-agents/tools/classify-docs): A CLI tool using pure sequential page-by-page processing with context accumulation. Accurate (96.3%) but no web interface, no persistence, no batch processing. -
agent-lab: A full workflow experimentation platform with 5-node classification graphs, parallel detection, observer infrastructure, profile management, and image caching. Powerful but far more infrastructure than Herald needs.
Herald takes the per-page vision analysis approach from classify-docs, wraps it in a 4-node state graph from go-agents-orchestration (init, classify, conditional enhance, finalize), and hosts it in a streamlined Go web service adapted from agent-lab's infrastructure patterns. Pages are classified in parallel with bounded concurrency, and a dedicated finalize node synthesizes the document-level classification from all page findings. Documents flow in through the service API to Azure Blob Storage, classification results flow out through PostgreSQL, and humans validate results through an embedded Lit web client.
The architecture deliberately excludes: image caching (images are ephemeral during classification), observer/checkpoint infrastructure (results self-contain context), multi-workflow registries (single classification workflow), provider/agent CRUD (single externally-configured agent), and OpenAPI/Scalar documentation (deferred for velocity).
| Phase | Focus Area | Version Target | Status |
|---|---|---|---|
| Phase 1 - Service Foundation | Go project scaffolding, Azure PostgreSQL schema/migrations, Azure Blob Storage integration, configuration system, module/routing infrastructure, document domain (upload single + batch, registration, metadata), storage abstraction, lifecycle coordination | v0.1.0 | Complete |
| Phase 2 - Classification Engine | Agent configuration from external config, classification workflow state graph (init -> classify -> enhance? -> finalize), parallel per-page analysis with bounded concurrency, named prompt overrides (DB + API), single document classification endpoint, classification result schema with flattened workflow metadata | v0.2.0 | Complete |
| Phase 3 - Web Client | Lit 3.x SPA with native Bun builds embedded in Go binary, Air hot reload for development, document management UI (upload single + batch, browse, search, filter, bulk classify), SSE streaming observer for classification progress, classification result viewing/validation/manual adjustment with PDF viewer, prompt modification management | v0.3.0 | Complete |
| Phase 4 - Security and Deployment | Azure Entra authentication (opt-in JWT middleware + MSAL.js web client), managed identity for Azure services (Storage, PostgreSQL, AI Foundry), Docker containerization with ImageMagick 7.0+, Azure Container Apps deployment configuration, IL4/IL6 environment configuration | v0.4.0 | Complete |
| Phase 5 - Post-Deployment Polish | Post-IL6-deployment quality-of-life adjustments: document-upload scroll container, configurable page size selector, URL query-parameter state persistence across view transitions, migration from go-agents/go-agents-orchestration to tau/agent and tau/orchestrate, MSAL stale credential refresh hardening, native top-layer overlay primitives (dialog/popover), toast service for command feedback, format-aware document processing (PDF + raw PNG/JPEG/WEBP) with per-format handler registry replacing the document-context dependency | v0.5.0 | Complete |
herald/
├── cmd/
│ ├── server/ # HTTP server entry point and composition
│ └── migrate/ # Database migration CLI
├── internal/
│ ├── api/ # API module: Runtime, Domain, route registration
│ ├── config/ # Configuration management (JSON + env overlays)
│ ├── infrastructure/ # Infrastructure assembly (lifecycle, logger, database, storage, agent)
│ ├── state/ # Shared classification types (ClassificationState, ClassificationPage, EnhanceSettings, Confidence, state keys)
│ ├── format/ # Document-format registry + PDF and image handlers + shared magick helper
│ ├── documents/ # Document domain (upload, registration, metadata, blob management, content-type validation)
│ ├── classifications/ # Classification result domain (store, query, validate, adjust)
│ ├── prompts/ # Named prompt override domain (CRUD, per-stage loading)
│ └── workflow/ # Classification workflow definition
│ ├── workflow.go # State graph assembly: init -> classify -> enhance? -> finalize
│ ├── init.go # Init node: dispatch to format handler for extraction
│ ├── classify.go # Classify node: parallel per-page analysis with bounded concurrency
│ ├── enhance.go # Enhance node: dispatch to format handler for conditional re-render
│ ├── finalize.go # Finalize node: document-level classification synthesis
│ ├── types.go # WorkflowResult (execution metadata wrapping state.ClassificationState)
│ └── prompts.go # Prompt composition with instructions, specs, and running state
├── pkg/
│ ├── auth/ # Auth config, User context helpers, JWT middleware (opt-in)
│ ├── core/ # Stateless cross-cutting primitives (byte formatting, JSON extraction, bounded concurrency)
│ ├── database/ # PostgreSQL connection management (pgx driver)
│ ├── handlers/ # HTTP response utilities (RespondJSON, RespondError)
│ ├── lifecycle/ # Startup/shutdown coordination
│ ├── middleware/ # HTTP middleware (CORS, logging)
│ ├── module/ # Modular HTTP routing (prefix-based modules)
│ ├── pagination/ # PageRequest/PageResult pattern
│ ├── query/ # SQL query builder with projections and sorting
│ ├── repository/ # Database helpers (QueryOne, QueryMany, WithTx)
│ ├── routes/ # Route registration types
│ ├── storage/ # Blob storage interface (Azure Blob Storage implementation)
│ └── web/ # Template set, router, embedded asset serving
├── app/ # Lit 3.x SPA
│ ├── app.go # Go module: embed dist/*, shell template, NewModule()
│ ├── package.json # Bun project (no Vite)
│ ├── scripts/ # Bun build scripts (build.ts, watch.ts)
│ ├── client/ # TypeScript source (native Bun build)
│ ├── dist/ # Build output (app.js, app.css)
│ └── server/ # Go HTML templates (layouts/app.html)
├── deploy/ # Bicep IaC deployment manifests for Azure Container Apps
├── config.json # Base configuration
├── .air.toml # Air hot reload configuration
├── Dockerfile
└── _project/
└── README.md # This document
Herald follows the Layered Composition Architecture (LCA) established in agent-lab:
Cold Start (initialization, no connections):
config.Load()-- Read JSON base + environment overlay + env var overridesinfrastructure.New(cfg)-- Create lifecycle coordinator, logger, database system, storage system, validate agent configNewModules(infra, cfg)-- Create API module (runtime + domain assembly) and web app module
Hot Start (connections established, ready to serve):
infra.Start()-- Database connect + pool, storage container initializationhttp.Start(lifecycle)-- Begin listening, register shutdown hooklifecycle.WaitForStartup()-- All subsystems report ready
Shutdown (coordinated teardown):
- Signal received (SIGINT/SIGTERM)
lifecycle.Shutdown(timeout)-- Reverse-order hook execution within deadline
type Infrastructure struct {
Lifecycle *lifecycle.Coordinator
Logger *slog.Logger
Database database.System
Storage storage.System // Azure Blob Storage implementation
Agent tauconfig.AgentConfig // tau protocol/config for per-request agent creation
Credential azcore.TokenCredential // Azure managed identity (nil when auth disabled)
NewAgent func(ctx context.Context) (agent.Agent, error) // Auth-mode-aware agent factory
}type Runtime struct {
*infrastructure.Infrastructure
Pagination pagination.Config
}
type Domain struct {
Documents documents.System
Classifications classifications.System
Prompts prompts.System
}Each domain follows the handler pattern: a System interface, repository with query builder, Handler struct with Routes() method.
documents -- Upload (single + batch), registration, metadata, blob lifecycle. Documents are immutable after upload.
classifications -- 1:1 relationship with documents. Stores the classification result with flattened workflow metadata columns. Supports manual validation and adjustment.
prompts -- Named prompt instruction overrides stored in PostgreSQL with CRUD API endpoints. Each override targets a specific workflow stage and provides tunable instructions. The final system prompt is composed of two layers: instructions (tunable, from DB or hard-coded defaults) + output format (hard-coded, never exposed). The workflow assembles both layers at execution time.
A 4-node state graph using tau/orchestrate:
init --> classify --> [needs enhancement?] --> enhance --> finalize --> exit
|
v (no enhancement needed)
finalize --> exit
init node: Dispatches to the format handler registered for the document's content type. PDF handlers render each page to PNG via ImageMagick (using magick's native source.pdf[N] selector syntax, bounded concurrency via errgroup); image handlers copy PNG sources verbatim and normalize JPEG/WEBP sources to PNG. Output images land in a request-scoped temp directory that the workflow owns (created with os.MkdirTemp, removed via deferred RemoveAll).
classify node: Parallel per-page analysis using bounded errgroup concurrency. Each page is sent to the vision-capable GPT model independently (no accumulated context between pages). The model populates per-page ClassificationPage data (markings found, rationale, enhancement flags) but does not produce document-level classification — that is deferred to the finalize node. Pages flagged with Enhance: true include an Enhancements description of what adjustments are needed.
enhance node (conditional): Triggered when any page's Enhance flag is true (evaluated via ClassificationState.NeedsEnhance()). Re-renders flagged pages in parallel with bounded errgroup concurrency by delegating to the same format handler used during init, applying EnhanceSettings filters (brightness, contrast, saturation) and reclassifying via vision. Trigger conditions TBD through experimentation during Phase 2; initially, classify never sets Enhance: true.
finalize node: Always runs as the terminal node. Performs a single inference that reviews all per-page analysis results (including any enhanced pages) and produces the authoritative document-level ClassificationState fields: classification, confidence, and rationale. Sees all evidence holistically rather than incrementally.
This collapses agent-lab's 5-node graph (init, detect, enhance, classify, score) into 4 nodes by merging detection into the classify node, separating document-level synthesis into a dedicated finalize node, and keeping enhance as optional remediation. The classify node runs one LLM call per page; finalize runs one LLM call total.
Single agent definition from external configuration (not CRUD-managed). Uses tau's protocol/config.AgentConfig type directly — no Herald-specific agent config structs:
{
"agent": {
"name": "herald-classifier",
"provider": {
"name": "azure",
"base_url": "https://...",
"options": {
"deployment": "gpt-5-mini",
"api_version": "2024-12-01-preview",
"auth_type": "api_key"
}
},
"model": {
"name": "gpt-5-mini",
"capabilities": {
"vision": {
"max_tokens": 4096,
"temperature": 0.1
}
}
}
}
}Configured at startup from JSON config + env var overrides. Token injected via HERALD_AGENT_TOKEN env var. Provider, model, and provider options are also overridable via HERALD_AGENT_* env vars.
Azure Blob Storage with flat layout:
documents/{document-id}/{filename}
Documents are immutable after upload. Processing status is tracked exclusively in the PostgreSQL database. All documents enter through the Go service API (single or batch upload) to ensure database record + blob atomicity.
Azure PostgreSQL with golang-migrate. Core tables:
documents -- Registration records with metadata and blob reference. Status tracks a document's position in the classification lifecycle, not operational concerns. pending means inference hasn't completed; review means inference produced a result awaiting human validation; complete means the classification has been validated or adjusted. Errors during inference leave the document in pending — error handling and observability are separate concerns.
- id, external_id, external_platform, filename, content_type, size_bytes, page_count, storage_key, status (pending/review/complete), uploaded_at, updated_at
classifications -- 1:1 with documents, overwritten on re-classification. Workflow metadata is stored as flattened columns rather than JSONB. Human review is either validate (agree with AI) or update (overwrite classification and rationale). Both transition the document to complete.
- id, document_id (unique FK), classification, confidence, markings_found (JSONB), rationale, classified_at, model_name, provider_name
- validated_by, validated_at
prompts -- Named instruction overrides per workflow stage. Stores the tunable instruction layer; the output format layer is hard-coded in workflow/prompts.go and composed at execution time.
- id, name (unique), stage (classify/enhance/finalize), instructions, description, active
Lit 3.x SPA following agent-lab patterns:
- Build: Native Bun builds with TypeScript (
Bun.build()API, no Vite). Component CSS via{ type: 'css' }import attribute — Bun plugin emitsCSSStyleSheet(Lit 3+ accepts directly). Global CSS extracted automatically. - Development: Air hot reload for Go + Bun watch for TypeScript/CSS. Two-terminal workflow:
bun run watchrebuilds assets,airrebuilds and restarts the Go server on dist/ changes. - Embedding:
go:embed dist/*, shell template pattern - Routing: Client-side History API router, component prefix
hd- - Styling: CSS cascade layers with design tokens
- State: Lit
@state()decorators for component-local reactivity - Services: Stateless PascalCase service objects with
basepath constants
Key views: document management (upload, browse, classify, bulk operations), prompt management (CRUD), document review (PDF viewer alongside classification for validation/adjustment).
| Decision | Choice | Rationale |
|---|---|---|
| Workflow topology | 4-node (init -> classify -> enhance? -> finalize) | Classify handles per-page analysis (one LLM call per page), finalize synthesizes document-level classification from all page findings (one LLM call total). Separating per-page analysis from document-level synthesis eliminates incremental anchoring bias and ensures finalize sees all evidence (including enhanced pages) holistically. |
| Classification approach | Parallel per-page analysis, finalize synthesis | Classify processes all pages concurrently with bounded errgroup concurrency (no accumulated context). Finalize produces the authoritative document classification from complete page data. |
| Image lifecycle | Ephemeral (render, encode, discard) | 1M documents would generate enormous image storage. Images serve only as Vision API input. |
| Classification result model | 1:1 with document, overwritten on re-classification | Simpler than run/stage/decision tracking. Flattened workflow metadata columns preserve provenance. |
| Agent configuration | Single externally-configured agent | Herald serves one purpose with one or two GPT models. External config matches deployment patterns. |
| Prompt management | Two-layer composition: tunable instructions (DB overrides or hard-coded defaults) + hard-coded output format | Lighter than agent-lab's profile + profile_stages system. Instructions are tunable without risking broken output formats. |
| Blob storage layout | Flat with DB-driven status | Avoids expensive blob-move operations across 1M documents. DB is single source of truth. |
| Database | Azure PostgreSQL | Maximizes code reuse from agent-lab (pgx, query builder, repository patterns). |
| Observability | SSE streaming observer (Phase 3) | Classification progress uses Server-Sent Events — unidirectional server-push, works behind standard load balancers, auto-reconnects natively. tau/orchestrate supports observer injection (cfg.Observer); implementation deferred to Phase 3 alongside the web client that drives its design. WebSockets rejected — upgrade negotiation, sticky sessions, and message broker requirements for cluster deployments are unjustified for unidirectional progress streaming. |
| Document format handling | Per-format handler registry (PDF, raw image) | A single format.Handler interface with content-type dispatch decouples init/enhance/upload-validation from any one format's rasterizer. PDF uses pdfcpu (page count) + magick (native source.pdf[N] selector). Image normalizes JPEG/WEBP to PNG. New formats (DOCX, PPTX, TIFF) land as additional handlers without threading branches through the workflow or client. |
| Batch classification | Client-orchestrated parallel single-document classifications | Same pattern as document uploads — deterministic per-document behavior. Clients coordinate via Promise.allSettled. |
| Bulk upload | Sequential single-file uploads (no batch endpoint) | ParseMultipartForm(maxMemory) caps total request memory, making a batch endpoint's per-file size limit unpredictable. Single-file uploads give deterministic per-file size limits. The web client coordinates multi-file uploads via <input multiple> with Promise.allSettled, providing per-file progress, retry, and error handling. |
| Web client scope | Full management MVP | Upload, browse, classify, validate, monitor, manage prompts. Complete operational interface. |
- tau/agent: LLM abstraction. Agent interface, Vision/Chat/Tools/Embed protocol methods, typed request/response. Pulls in sibling modules
tau/protocol(messages, config),tau/format(wire format registry, openai format),tau/provider(provider registry, azure/ollama factories). - tau/orchestrate: State graph workflow engine. StateGraph, StateNode, conditional edges, observability events.
- pgx: PostgreSQL driver with connection pooling
- golang-migrate: Database migration management
- google/uuid: UUID generation
- azure-sdk-for-go: Azure Blob Storage client, Azure Identity (Entra auth)
- pdfcpu: PDF page count extraction (upload + format/pdf handler)
- go-oidc: OIDC discovery, JWKS management, and JWT token verification (coreos/go-oidc)
External dependencies are acceptable when they meet all of the following:
- Purpose-built — solves a specific, well-scoped problem (not a kitchen-sink framework)
- Authoritative source — maintained by a reputable organization or established in the ecosystem
- Actively maintained — recent releases, responsive to issues, not abandoned
- Production-ready — stable API, used in production by major projects
- Not a supply chain risk — minimal transitive dependencies, auditable codebase
- Lit 3.x (lit)
- Bun: JavaScript runtime, package manager, and bundler (build-time)
- Air: Go live reload for development (github.com/air-verse/air)
- ImageMagick 7.0+ with Ghostscript PDF delegate: PDF rasterization and raw-image (JPEG/WEBP) normalization to PNG (required in deployment containers;
apk add imagemagick ghostscriptinDockerfile) - Azure PostgreSQL: Managed database service
- Azure Blob Storage: Document persistence
- Azure AI Foundry: GPT-5-mini and GPT-5.2 deployments (both confirmed available on IL6)
- Single configurable agent targeting GPT-5-mini or GPT-5.2 per deployment
- Vision API for page classification (base64 data URI encoded images)
- Authentication via API key (Azure Key Vault) or access token
- Configuration: tau
protocol/config.AgentConfigin config.json + HERALD_AGENT_* env var overrides
- Document upload via Go service API (ensures DB + blob atomicity)
- Flat key structure:
documents/{document-id}/{filename}.pdf - Azure SDK azblob client with managed identity or connection string auth
- Immutable blobs (upload once, delete on removal, never modify)
- pgx driver with connection pooling
- golang-migrate for schema management
- Query builder pattern for dynamic filtering and sorting
- Service authentication: user-assigned managed identity for Blob Storage, PostgreSQL, AI Foundry access
- API authentication: opt-in JWT bearer middleware via go-oidc OIDC discovery and token verification
- Web client authentication: MSAL.js with redirect login flow, token injection into API requests
- User identity: JWT claims stored in
validated_byfor audit trail (no OBO — managed identity for service-to-service) - Token management: Azure Identity SDK credential chain, pgx BeforeConnect hook for database token refresh
- Documents enter Herald exclusively through the upload API (single or batch)
- External systems push documents to Herald; Herald does not pull
- Document metadata fields (
external_id,external_platform) for external system linkage
- Enhance stage trigger conditions: What quality thresholds trigger conditional enhancement? The classify node must report whether image quality was a limiting factor.
- GPT-5-mini vs GPT-5.2 benchmarking: Which model performs better at acceptable cost? Both confirmed available on IL6.
- Bulk ingestion strategy: Loading 1M documents requires a bulk ingestion approach feeding the upload API. Detailed strategy TBD.
- External system identifier fields:
external_idandexternal_platformcolumns on the documents table provide linkage back to originating systems. Resolved in Phase 1. - Confidence scoring approach: Categorical (HIGH/MEDIUM/LOW). Aligns with classify-docs' proven approach and is more interpretable for human validators.
- Worker pool sizing: Not applicable — no batch classification endpoint. Clients orchestrate parallel single-document classifications.
- SSE vs WebSockets for classification progress: SSE. Classification progress is unidirectional (server → client). SSE works over HTTP/1.1, requires no upgrade negotiation, operates behind standard load balancers and reverse proxies, and auto-reconnects natively. WebSockets would be justified only if the client needed bidirectional communication during classification. Cancel can be a separate REST endpoint. go-agents-orchestration already supports observer injection — the implementation point is
workflow.go'scfg.Observer = "noop", swappable to aStreamingObserverported from agent-lab. Full SSE implementation path documented in the Phase 2 review plan. - Azure deployment target: Azure Container Apps. Single-service deployment, no cluster management needed. Bicep IaC with modular templates. Resolved in Phase 4.
- Remove
PageRequestfrom core: Each domain defines its ownSearchRequestwith domain-specific filters.PageRequestremoved from@core,toQueryStringretained as a generic utility. Resolved in Phase 4.