Primer
Yukta is built as many modules (engine, REST layer, MCP server, UI, plugins). Someone has to put them together into one program you can run, choose its settings, serve the web screens and ship it, either as a normal Java program or as a native binary. That someone is boot. It has one Java class that starts the app, one class that serves the web UI, three configuration files, a build script, and four test classes.Where boot sits
The vocabulary in eight cards
One example, end to end
A cold start of the native binary, up to a user pasting the link /sessions/x into a browser. Click a step to see what happens, why, and what can go wrong.Run the binary
Pick a profile
Build context
Listen on :8080
GET /sessions/x
SPA fallback
Hashed assets
UI calls /api
The module in plain words
Choose
Start
Configure
Assemble
Serve
Stay up
Components
Launcher
main(), profile guess Starts the application and chooses the profile in the one case where it must not be left to chance: a native image started without a profile.- main() first calls Hooks.enableAutomaticContextPropagation(). This makes Reactor copy context values (request id, tracing) from one thread to the next. Reason: Yukta is reactive, and without this the request id in log lines would vanish whenever work moves to another thread.
- If the process is a native image and no argument starts with —spring.profiles.active= or -Dspring.profiles.active=, main() sets the system property to prod. Example: ./yukta runs as prod; ./yukta —spring.profiles.active=dev runs as dev.
- In a normal JVM the launcher does nothing about profiles. The default profile in the base YAML, dev, applies. Example: java -jar boot.jar with no flags gives debug logging.
- Only command-line arguments are checked. If a native image is started with only the SPRING_PROFILES_ACTIVE environment variable, main() still sets the system property to prod; which of the two Spring then prefers was not tested.
- Guarantees. Context propagation is on before any Spring code runs. An explicit profile argument is never overwritten.
- State. No state. The profile choice lives in a JVM system property for the rest of the process.
- Ceiling. One class, no scaling concern.
- On failure. If Spring fails to start (bad config, port in use) the process exits with the Spring error. There is no custom start-up diagnostics.
Hooks.enableAutomaticContextPropagation(), --spring.profiles.active=<name> (also -D form), SPRING_PROFILES_ACTIVE is not checked by main()
Layered Configuration
base + dev or prod Provides every setting as a base file overlaid by the active profile’s file.- application.yaml is always read. It sets the loopback address, virtual threads, the MCP server (name yukta, version 0.0.1, stateless, async, endpoint /mcp, no stdio), strict JSON (unknown fields are rejected), the console log pattern with a request id, and the management exposure.
- application-dev.yaml adds more management endpoints and verbose logging. application-prod.yaml quiets logging, turns the banner off and switches on gzip compression for responses over 1 KB.
- Profile files are merged over the base, key by key. Prod does not set management exposure, so prod inherits the small base list (health, info, metrics). Dev replaces it with a longer list.
- ProfileConfigTest reads the real files and asserts that prod has no TRACE for the stream package, INFO for com.infenia.yukta, the small actuator list and no lazy initialisation, and that dev has TRACE and the longer list. This is what stops a casual edit from leaking payload logs into prod.
- Strict JSON (fail-on-unknown-properties: true) means a client that sends an extra field gets an error rather than silent acceptance. Reason: a misspelt option should not be ignored.
- Guarantees. A fresh install without any flags is loopback-only, and prod never has TRACE payload logging or debug management endpoints (guarded by a test).
- State. Files are read at start. Changing them needs a restart. Nothing is stored.
- Ceiling. Settings are plain keys with no validation of the YAML itself; a typo in a key name is silently ignored by Spring except for typed properties.
- On failure. A bad value (for instance unreadable type) stops start-up. A misspelt key does not fail anything; it is just ignored.
server.address = ${YUKTA_SERVER_ADDRESS:127.0.0.1}, spring.profiles.default = dev, spring.jackson.deserialization.fail-on-unknown-properties = true, spring.ai.mcp.server.* (see map)
Assembled Application
component scan, one process Turns every module’s classes into running beans inside one web server.- The application class carries @SpringBootApplication in com.infenia.yukta, so Spring scans that whole package tree: core, web, mcp, messaging and every plugin on the classpath. Boot lists no beans itself.
- Dependencies are fixed in build.gradle.kts: core, web, mcp, ui, the internal-core processors, the API/constant/schedule triggers, notify and console terminals, the rule supervisor and three control planes (rule, model, rules-first). They are always in. The nine optional plugins join depending on the build flag.
- Boot also brings the WebFlux, validation and actuator starters. It has no Spring Security: access control is done by plain web filters in web and mcp (roles, session-limited keys, host and origin checks), and the start-up guard ExposureGuard lives in boot. PostgreSQL stores and remote workers are optional modules switched on at build time.
- Defaults are in-memory: session store type in-memory, plugin log store memory. A restart starts from nothing unless you configure files.
- Startup is eager: a missing bean or a bad property stops the process immediately.
- Guarantees. If the context starts, every module’s beans are present and consistent. Eager creation means a defect appears at boot.
- State. All engine state is in this one process (see core). Boot itself stores nothing.
- Ceiling. One process, one port, one JVM. Scaling means running more copies, but nothing shares state between them.
- On failure. A failed bean stops the whole app. There is no partial start where only the UI works.
yukta.session.store-type = in-memory, yukta.logs.store.backend = memory, scan base package com.infenia.yukta
SPA Host
assets, fallback, CSP Serves the React app from the ui jar and makes client-side routes work.- Files under /assets/ (Vite writes hashed file names) and flat top-level files ending in .js, .css, .woff, .woff2, .ttf, .svg, .png, .jpg, .webp, .ico, .json or .webmanifest are served with a 365-day cache. A hashed name changes when the content changes, so a long cache is safe for /assets/.
- Every other GET is answered with index.html, except /api, /actuator, /mcp, /sse, /v3, /swagger-ui, /swagger-ui.html and /webjars, and except static asset paths. Reason: the router is looked at before the resource handler, so without these exclusions it would hide real files and real endpoints.
- index.html is sent with Cache-Control no-cache, must-revalidate, and a Content-Security-Policy: default-src ‘self’; style-src ‘self’ ‘unsafe-inline’; img-src and font-src also allow data:; object-src ‘none’; base-uri ‘self’; frame-ancestors ‘none’. In plain words: scripts and connections only from the same origin, no plugins or embedding in frames; inline styles are allowed because React Flow positions nodes with style attributes.
- The CSP header exists only on this fallback response. API responses and static assets do not get it; they do not need it, since the CSP matters for the document the browser renders.
- Exclusions include /sse/**, a leftover alias. The MCP filter still maps the exact path /sse to /mcp for old clients, so the exclusion is harmless but keeps a historic name in the code.
- Guarantees. A refresh or pasted deep link always returns the app; a new release is picked up on the next load because index.html is never cached.
- State. Stateless. The files come from the ui jar on the classpath.
- Ceiling. The index file is read per request from the classpath resource. For heavy traffic a CDN in front would take the load; boot has no ETag or compression for assets in dev (gzip is on in prod).
- On failure. If ui was not built, static/index.html is missing and the fallback fails at request time, not at start.
Cache-Control: max-age 365 days (assets), Cache-Control: no-cache, must-revalidate (index.html), CONTENT_SECURITY_POLICY constant
Engine Tuning Defaults
yukta.* and control.bus.* Writes down a handful of engine settings so they are visible and changeable without code.- control.bus.batch.size 100 and control.bus.batch.timeout.ms 50: the control bus collects up to 100 commands, or waits at most 50 ms, before processing a batch. control.bus.buffer.size 256 is its internal buffer. The code uses the same numbers as its built-in defaults.
- yukta.tracker.cleanup-ttl 10m: how long a finished run’s data stays in the tracker. yukta.streaming.status-history-ttl-minutes 5: how long recent progress is kept for late subscribers (the code accepts 1 to 30).
- yukta.session.execution-timeout-seconds 3600, yukta.session.store-type in-memory, and the two sub-directory names file-log-sub-dir (modified-files) and result-log-sub-dir (results).
- yukta.logs.store.backend memory with a 30-minute default retention (the comment says the maximum is 1440 minutes, 24 hours). Only the memory store is registered today, although the comment says memory | file.
- Everything else the engine reads (limits, queue, restart directories) has no entry in boot. Its default lives in core code, so boot’s YAML is not the whole list of settings.
- Guarantees. These values are what a fresh start uses. Unset settings fall back to the defaults written in core.
- State. None here. Values are read at start.
- Ceiling. Because they are only defaults, a deployment that needs limits, a durable log or file stores must add keys of its own; boot does not provide a production profile for state.
- On failure. A wrongly typed value stops start-up. A key that nothing reads, such as one left over after a rename, is silently ignored.
control.bus.batch.size / batch.timeout.ms / buffer.size, yukta.tracker.cleanup-ttl, yukta.streaming.status-history-ttl-minutes, yukta.session.*, yukta.logs.store.*
Management Surface
actuator health, metrics Gives operators health, info and metrics, and developers a few more diagnostics, on the same port as the API.- Base and prod expose health, info and metrics. Dev adds loggers, mappings and threaddump. Not exposed in either profile: heapdump, env, beans, shutdown.
- Health details use show-details: when-authorized. Boot contains no Spring Security, so in our view nobody counts as authorised and detail fields are most likely never shown; this was not run to confirm.
- Management shares port 8080; there is no separate management port. The SPA router excludes /actuator/**, so these paths are never answered with index.html.
- The API key filter in web now also covers /actuator (except /actuator/health): with a key set, the rest needs an ADMIN key. With no key on a loopback bind, the actuator is open to local callers; in the dev profile loggers can be read and changed (POST to /actuator/loggers). Swagger and /v3 are still outside any key check.
- Guarantees. Prod exposure is exactly health, info, metrics (asserted by ProfileConfigTest). No endpoint that dumps memory is exposed anywhere.
- State. None. Metrics are in memory in the process.
- Ceiling. Same port and thread pool as the API, so a scrape storm competes with real requests.
- On failure. If the management endpoint is not reachable, probes fail and an orchestrator may restart a healthy app.
management.endpoints.web.exposure.include, management.endpoint.health.show-details = when-authorized
Plugin Selection
build-time -Pyukta.plugins Decides at build time which optional plugins are linked into the app.- -Pyukta.plugins accepts default (all except script-js, the default), all, none, or a comma list. The nine valid names are process-executor, http-request, file-io, sql-query, llm-call, session-memory, decision, mcp-tool-call and script-js. Two more modules are switched on by their own flags: -Pyukta.persistence=postgres (PostgreSQL stores) and -Pyukta.workers=true (remote workers).
- An unknown name fails the Gradle configuration with a message listing the valid names. Example: -Pyukta.plugins=http-requst stops before compiling anything.
- Triggers (api, constant source, schedule), terminals (notify, console), the internal-core processors, the rule supervisor and three control planes are always included. Only nine processors are optional.
- Reason: a pure data-pipeline deployment may not want a shell-command plugin or a JavaScript engine in the image. Fewer plugins means less code to secure and a smaller native image.
- The choice is baked into the jar or binary. A running server cannot gain or lose a plugin. A workflow that uses a plugin that was left out fails when it is validated or run.
- Guarantees. The build refuses typos. What was chosen is exactly what is on the classpath.
- State. No runtime state. Build input only.
- Ceiling. Eleven or more modules are built each time. The more plugins, the longer the native compile.
- On failure. A workflow using a missing node type is rejected by core; boot does not warn at start which plugins are missing.
./gradlew bootRun -Pyukta.plugins=none|all|a,b, ./gradlew :boot:bootJar -Pyukta.plugins=...
Native Image
GraalVM build and flags Compiles the app into a standalone executable with fast start and low memory.- nativeCompile builds a binary named yukta from the main class, with —no-fallback (fail rather than build a JVM-needing fallback). The build JVM gets -J-Xmx16g. The size flags are -H:+RemoveUnusedSymbols, -H:+StripDebugInfo and -H:-AddAllCharsets.
- Build arguments -Dspring.profiles.active=prod and -Dspring.aot.enabled=true are also set. Spring AOT precomputes the bean wiring at build time, which is what makes native start-up fast.
- Whether the -D profile argument reaches the running binary was not verified: it is passed to the native-image builder. The launcher’s own prod rule in main() is the part that is covered by a test.
- The Dockerfile builds the image in a GraalVM stage, optionally compresses the binary with UPX, then copies it into a distroless nonroot image that exposes 8080 and sets YUKTA_SERVER_ADDRESS=0.0.0.0. There is no shell in the final image.
- script-js (GraalJS) is no longer in the default set: it is the largest part of the native image, so ask for it with -Pyukta.plugins=all or by name.
- Guarantees. A native image either builds with all reflection and resources known, or the build fails (no fallback).
- State. None. The binary is immutable; data directories must be mounted if you use file stores.
- Ceiling. The 16 GB build heap is not measured; a small CI machine may run out. Native builds are slow, so most developers use the JVM.
- On failure. Reflection or resource gaps appear only at run time in the native binary, since tests run on the JVM.
./gradlew :boot:nativeCompile, buildArgs: --no-fallback, -J-Xmx16g, -H:+RemoveUnusedSymbols, -H:+StripDebugInfo, -H:-AddAllCharsets, -Dspring.aot.enabled=true
Acceptance Harness
real app, real plugins Boots the whole assembled app and proves the wiring, profiles and web hosting behave.- WorkflowAcceptanceTest runs a trigger, a PROCESS_EXECUTOR step and a console terminal on the real engine: a normal run ends SUCCESS and is logged; a stop leaves no node running and records the command; a flaky step is retried; a failing step fails the run; a finished run can be restarted; a step silent for over 30 seconds is not cut off by the default idle timeout.
- SupervisorAcceptanceTest attaches a real rule supervisor: a rule pauses a workflow when its node starts running, and a deny rule refuses a KILL stop.
- ProfileConfigTest checks dev vs prod config values. YuktaApplicationTest starts main() and checks the native profile rule and the profile-argument parsing. SpaWebFluxConfigTest checks the 365-day cache, the no-cache index, the CSP and which paths the router leaves alone.
- These tests use the PROCESS_EXECUTOR plugin, which is optional. Building with -Pyukta.plugins=none would leave :boot:test with workflows it cannot run.
- Guarantees. A wiring or profile regression fails a test instead of reaching a release.
- State. Tests use real files and shell commands such as echo and sh in a temp setup; they need a Unix-like environment.
- Ceiling. Acceptance tests wait up to 30 seconds per scenario, so they are the slowest part of the module.
- On failure. A test depends on plugins that a build flag can remove; the failure then looks like a workflow bug rather than a build choice.
./gradlew :boot:test, ./gradlew :boot:test --tests com.infenia.yukta.ProfileConfigTest
Scenarios
Developer: ./gradlew bootRun
What a developer’s local run does from build to browser.Pick plugins
main() runs
Default profile is dev
Context starts
Browse the UI
Production: native container
How the production container is built, started and what it exposes.Build the binary
Package
Start
Serve
Deep link refresh
How a refresh or shared link on a client-side route reaches the right page.Browser asks for /sessions/x
Router checks exclusions
index.html with CSP
A new release
Exposed by mistake
A realistic misconfiguration: remote access with a key but no profile.Operator binds to 0.0.0.0
Profile was never set
API is protected, the rest is not
Payloads reach the log
Bad plugin build flag
What a wrong or minimal plugin choice does at build time and in tests.A typo in -Pyukta.plugins
Fail fast
A valid but incomplete list
Tests break too
Restart under defaults
What a restart loses when only boot’s defaults are used.Process stops
Process starts again
Optional state is on you
Clients reconnect
Capabilities
Boot’s capabilities are about packaging, configuration and hosting, not workflow features. Those live in core and the plugins.Packaging
Configuration
Web hosting
Operations
Verification
What is enforced where
Pick a profile and see what holds. Rows marked part or lost are the reasons for the risks on the board.What each profile enforces
What survives a restart (defaults)
Boot’s own defaults keep everything in memory.Static serving rules
Reference
Profile and config matrix
The effective value after merging the base file with each profile.Run commands
JVM vs native.Plugin catalogue
Endpoints served by this app
Architecture decisions
One executable for everything
One executable for everything
Loopback default, opt-in for remote
Loopback default, opt-in for remote
dev as the default profile
dev as the default profile
Optional plugins by build flag
Optional plugins by build flag
Native image as the shipping format
Native image as the shipping format
Hosting the SPA inside the server
Hosting the SPA inside the server
Keys by filter, not Spring Security
Keys by filter, not Spring Security