NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Independent services and site gateway
Developer reference

Independent services and site gateway

Independent plugin service

Declare exactly "service": { "entry": "dist/worker/main.js" }. The relative POSIX entry must be .js or .mjs, with no absolute/traversal path, empty segments, whitespace, quotes, backslash, percent/dollar expansion or control characters. Unknown service keys are rejected. Core validates the installed real path and invokes it as the recorded nonroot service user, never as root. The worker entry supports --check: import its complete graph, validate current persisted serving state, exit zero without listening or mutating state. A failing preflight leaves the running service untouched. The process protocol reserves Unix EX_TEMPFAIL exit 75 only when --check can explicitly prove that the first runtime has not been prepared. A plugin data folder that does not exist yet (the plugin never ran on this instance) is the same unprepared first runtime; root classifies it after validating the declaration, units and build, without running --check. Independent workers remain core-free; the root consumer uses the shared unit-policy constant. Root bulk reconcile reports the plugin in deferred without creating units or state only if no prior ownership record, unit or active process exists. It does not claim service readiness. Existing, partial or malformed runtime state must fail; explicit ensure/restart never defer. The authoritative plugin registration must then prepare its data and call the normal service restart before publishing.

In the daemon, ctx.service is either null or { socketPath, trustProxy, status(), restart() }. status() resolves to { active: boolean } and restart() resolves to nothing. trustProxy is a getter that reads the live core security.trustProxy setting on each access (src/privileged/pluginServices.ts, PluginServiceManager.context; wired in src/daemon/brainCore.ts). Nothing in this path writes a snapshot. Independent HTTP services resolve addresses through the existing exported elowen/dist/api/clientIp.js module, using clientOrigin(c, trustProxy) from src/api/clientIp.ts; they do not invent another header-trust rule. It accepts no plugin name or path input. Read LISTEN_FDS and LISTEN_PID and listen on inherited fd 3. Use ELOWEN_PLUGIN_DATA for the plugin's existing data directory, not daemon configuration, host node_modules or a second database. The socket has mode 0660 and the root-recorded proxy group. All service policy is fixed: no extra executable arguments, one environment variable, no capabilities, read-only system and home except this plugin's data directory, private temporary devices, bounded memory and tasks. Core stamps identity and owner proof through the plugin-service privileged domain. Concurrent restart() callers share one root operation. A failed start remains a failure: further calls reject with the reported error without another root attempt until the retry deadline. The delay starts at 30 seconds, doubles after each failed retry and caps at five minutes; status reads remain fresh and are never delayed. Core logs each distinct start error once per continuous failure episode and raises the existing admin-only core service alert. Lifecycle and interval callers recognize the typed, already-reported error rather than logging it every tick. A successful restart, complete root reconciliation or removal clears the failure state and standing alert.

During executable updates the coordinator stops affected plugin sockets and services before swapping or restoring folders; proof waits for their settled startup results. Ordinary daemon restarts retain the independent-service behaviour below.

Core ensures all installed user declarations on boot after daemon-side background writers start, during install and privileged refresh, and removes proven orphans on refresh. A changed JavaScript build or unit is preflighted and restarts only the service, never its socket. Plugin disable, failed registration, daemon stop and crash leave it alone. Uninstall orders external offline, service removal, then marketplace deletion. Core never imports the independent entry. Bundled plugins and runner contexts receive null. Sites must persist its snapshot and secrets before its first restart; adding the declaration alone does not complete cutover.

Each web.settings entry accepts id, label, optional icon and optional placement. Placement defaults to page; pluginDetail offers the section in the selected plugin's settings. Unknown section fields are rejected, including the retired layout field. The independent web.layout document/workbench contract remains available.

brain.turn.contextBuilt and HookPatch.appendContext are removed. Plugins contributing turn context use registerTurnContext. Existing retained context records remain readable. Persona, tool veto and run-boundary hooks keep their capability gates, bounded dispatch and audit behavior.

Shared runtime helpers

The additive /ask, /projectExecution, /subagentName and /imageRuntime package entries and adjacent declarations ship with shared API 8. Registry consumers adopt them with the paired core artifact; no contract number changes. Their input, absent behavior, limits and costs are documented in packages/plugin-shared/README.md.

/ask supplies parseQuestionReply(text, question) and collectQuestionAnswers(questions, selected, other) to Discord/Telegram/WhatsApp without owning presentation or question authorization. /display adds frozen DISPLAY_AXES for config validation and display menus. /modelIdentity adds resolveActiveModel(models, chosen), modelReasoningLevels(active) and supportsReasoningChoice(active, value); use a fresh active descriptor at reasoning confirmation. An absent descriptor advertises no choices. Teams translates its empty reset to 'default' at this boundary.

/projectExecution owns RESERVED_GUEST_ROOTS, isReservedProjectSlug, managedGuestRoot and ELOWEN_ARTIFACT_ROOT. Sandbox mount validation and runtime paths share them with core's typed Project parser facade. They derive names only and do not authorize host or guest operations. /subagentName is the shared owner for derived and explicit delegated-child labels in the coordinated store/migration/Subagent cutover, preserving stored names and empty-task behavior.

Image-gen and Image-edit call createImageRuntime(ctx) from /imageRuntime and pass their own generate/edit request. The helper uses the existing authorized ctx.images and ctx.projectImageFiles() seams, validates path/overwrite before rendering, then writes the provider's actual format and returns the unchanged result text. Missing usable provider returns null with the existing warning; failures propagate. It registers no tool, adds no retry and performs one render/write.

The browser runtime also exposes the existing hooks.useNow(periodMs?, enabled?) function itself. Todo's live cards and rail share the host clock, visibility behavior and timer teardown. This staged member retains UI API 54 and requires the existing published-member assertion in unreleased consumers, without a local timer fallback. See docs/WEB.md, Shared clock.

elowen-plugin-shared/processTokens exports DIRECT_PROCESS_TOKEN_ENV and tokenPids(tokens). Terminal stamps a fresh token into each direct-host launch; descendants inherit it even after setsid. Use tokenPids([token]) for cleanup, or pass all dead-runner tokens together. It synchronously scans Linux /proc once per call, skips the scanning process and unreadable or exited processes, and returns matching pids in descending order. Empty tokens, no readable match or unavailable /proc return []. The helper registers nothing and sends no signals. Work scales with readable process environments and the token set, without per-token rescans, provider calls or persisted state. Terminal uses it for confirmed lease cleanup and retains its ESRCH/error distinction; core's killTokenProcesses uses it for best-effort runner-exit cleanup. The adjacent .d.mts is shipped with the additive subpath; shared API remains 8.

Shared helpers are opt-in imports; importing them registers no service or hook. The coordinated release requires shared API 8 and the paired core artifact. elowen-plugin-shared/errors exports errorText(error), preserving Error.message or String(error), and FinalTextDeliveryError, the marker used by chat bindings when final-answer transport fails. For example, Discord wraps a rejected final REST send with its diagnostic and original cause; Telegram and Teams mark a null send result, and WhatsApp marks rejected text sends. Each adapter's existing /turnRunner errorText callback maps this marker to /messages SHARED_MESSAGES[lang].deliveryFailed in en/cs/sk, while other errors retain their detail. The marker allocates one Error and the message table does no I/O. Without a marked send failure, normal turn handling is unchanged. The four registry adapters require core 0.29.64 or newer, the paired artifact shipping this marker. elowen-plugin-shared/toolResult exports textResult(text, details = {}) and errorResult(error, details = {}). Both return one text content block and caller-owned details; the latter prefixes Error: . They never infer ok/isError, serialize objects, catch conversion failures or classify transport errors. Code Mode, core memory tools, ExitPlanMode and local ToolSearch use the same envelope; ElowenApi serializes its response before passing the text to it. Files retains tool/status metadata above the primitive; Web rethrows transport faults before rendering domain errors. These helpers perform no I/O and allocate only the result envelope and converted text. With no importer, nothing happens.

elowen-plugin-shared/runtimeContext exports runtimeClock(timezone, now?), runtimeIdentity(identity, displayName?) and createTimezoneResolver(readTimezone, warn). The bundled Runtime Context turn providers and core voice instructions use the same clock and identity formatters; the plugin host and voice use the same live operator timezone resolver. For example, runtimeClock(ctx.timezone()) renders the current local date, time and daypart. Identity accepts the display-only fields of a host-minted turn identity, returns an empty string for null, and bounds/sanitizes names; the optional account display name supplements the login name. These helpers do not grant authority, read storage, execute tools or register hooks. Formatting is bounded by the supplied identity fields. The resolver calls its supplied getter when used, falls back to the host timezone for absent settings and warns once per invalid legacy value or getter failure. With no importer, nothing runs. The new package entry ships with the paired core artifact.

elowen-plugin-shared/zonedTime exports zonedParts(ms, timezone), zonedTimeToMs(timezone, year, month, day, hour, minute) and Sunday-first WEEKDAYS. Cronjob and Meeting Confirm share local wall-clock conversion, for example zonedTimeToMs('Europe/Prague', 2026, 10, 8, 9, 0). Cached formatters and two offset corrections cost constant work per conversion. Invalid zones retain cronjob's machine-zone fallback; validate new settings at their boundary. The converter does not reject DST gaps or select repeated-hour variants. It performs no I/O or registration, and without a caller nothing runs. The exported entry and adjacent .d.mts declarations ship with the paired core artifact; this is additive to shared API 8.

elowen-plugin-shared/contentHash exports hashText(text) for SHA-256 of exact UTF-8 text. elowen-plugin-shared/vectors exports packVector(vector), unpackVector(buffer) and cosine(a, b). MemoryStore, DocsSearch and Codebase share these bytes and scores. Packing respects a Float32 view's byte offset/length and passes an existing Buffer through unchanged; unpacking copies the selected bytes. No endian conversion or stored-data migration occurs. Empty, mismatched and zero-norm vectors score zero; callers still validate embedding-space identity. Hashing and scoring are linear, unpacking allocates one copy, and the helpers perform no I/O or registration. They do not normalize text or repair bad vectors.

elowen-plugin-shared/subagentProgress exports foldProgressEvent(state, event) plus tool-detail, usage and nested-wait helpers. Delegate, workflow nodes and daemon recovery share sticky authored notes, valid token aggregates and the turn/model fence for effective speed. The boolean says when to rewrite the progress row; callers own session lifecycle signals and persistence. Each fold costs constant work. elowen-plugin-shared/toolLists exports listCovers(iterable, name) for exact or trailing-star prefix matching. Core grants and Subagent policies consume it over arrays or sets; callers own absent lists. It scans entries once and an empty iterable covers nothing.

elowen-plugin-shared/modelIdentity exports splitModelIdentity(spec) for one complete provider/model pair, splitting on the first slash. The helper neither trims nor decodes; core exec parsing owns legacy syntax and program prefixes, while Subagent validates and trims stored pins. The existing /lifecycle entry owns both the daemon's English fallback table and adapter translations. /chatImageNames owns the lowercase generated UUID/SHA-256 filename pattern and validator used by stored image reads and /format reference parsing. It is browser-safe, accepts only png/jpg/gif/webp, and does not grant file access. elowen-plugin-shared/imageSniff exports sniffImageMime(bytes), the one magic-number check for raw image bytes (png, jpeg, gif, webp, otherwise null; never the file name). ShareImage, visitor conversation uploads, the CLI clipboard paste and code-mode's image() all use it, so no reader can accept a signature another rejects. These opt-in pure helpers perform no I/O or registration; they leave storage and authorization unchanged.

The shared elowen-plugin-shared/record module owns isRecord(value) and optional record(value) traversal. The guard accepts non-null, non-array objects without requiring a particular prototype; traversal returns the original object or undefined. Core imports the same implementation through src/shared/record.ts for nested Code Mode arguments, Project and ElowenApi input checks, site-search replies and configuration/voice settings. Subagent, Code Mode's schema renderer, MCP OAuth credential validation and Sandbox Desktop argument messages use the package entry directly. It performs no I/O and registers nothing when unused.

The existing shared elowen-plugin-shared/atomicJson helper accepts writeJsonAtomic(file, value, { durable: true, mode: 0o600, maxBytes: 16777216 }). Durable mode syncs staged file bytes before atomic rename and the parent directory afterwards, throwing on either failure. Mode applies from the staging file's creation; maxBytes bounds the exact formatted bytes before any filesystem effect. Chatbot uses this for its private policy and intake files; ordinary callers retain atomic replacement without the extra sync cost. compact:true omits indentation, newline:true adds the existing licence-record trailing newline, and forceMode:true applies the requested mode before syncing and rename regardless of umask. These options default to false. readJsonStrict(file, parse?) throws for missing, unreadable or corrupt JSON; an optional domain parser validates the shape and returns its typed value without catches or defaults. UpdateJournal uses strict transport reads with its domain validator and durable compact writes; update report and schedule retain writes without fsync. The licence record, boot report and core swap/inventory also use the same writer. writeTextAtomic(file, text, { durable, mode, forceMode, maxBytes }) is the same writer for text that is not JSON: writeJsonAtomic only serializes and hands its bytes to it, so both share the exclusive no-follow staging file, the optional fsync, the mode handling and the removal of a staging file a failed write left behind. The marketplace catalog cache (registry.json and catalog.etag) and the plugin install receipts, pending-install records and parked-uninstall markers are written through it, with the bytes and mode they had before. These additive exports and options ship in the coordinated shared API 8 release and require the paired core artifact that ships them; writeTextAtomic is additive and needs a core that ships it.

Shared helper subpaths are shipped inside the core artifact together with their adjacent .d.mts declarations. Import each helper from its explicit elowen-plugin-shared subpath; the package root does not re-export helper bodies. NodeNext resolves the adjacent declaration for the .mjs export. Each declaration has an exact analysis entry. These additions retain shared API 8, but consumers must require the first verified core release containing the helper and its export metadata. API 8 alone does not establish availability. Unused helpers perform no work; a missing target or unsupported subpath fails module loading. Validate the packed artifact as well as the source tree.

elowen-plugin-shared/xml exports sanitizeXmlText(value), xmlEscapeText(value) and xmlEscape(value). Each first converts its input with String. The sanitizer replaces XML 1.0-forbidden C0 controls with U+FFFD while retaining tab, LF and CR. The text helper additionally escapes ampersand and angle brackets; the attribute helper also escapes both quote characters. Non-ASCII characters are preserved and literal entities are escaped as data.

Use import { xmlEscape } from 'elowen-plugin-shared/xml'; and xmlEscape(proc.command) when rendering a Subagent collect reminder. Core prompt producers import the same implementation through src/shared/xml.ts. The helpers run synchronously only when called, perform no I/O, register no hooks and incur linear string-processing work. Without an importer nothing runs. They do not parse XML, preserve pre-escaped entities, validate element names, bound input length or perform Unicode validation beyond the stated C0 replacement. Callers own framing and size limits. The public subpath and adjacent xml.d.mts ship with the paired core artifact; shared API remains 8.

/configTokenList exports parseConfigTokenList(value) for messaging configuration. Arrays are already tokenized: stringify and trim elements, then remove empty entries while preserving embedded commas and newlines. String values split on commas and newlines. Missing values produce an empty list; order and duplicates remain intact. Discord threadIds, Telegram allowedChatIds and WhatsApp groupIds use it when reading adapter configuration. For example, parseConfigTokenList(['123', '456,789']) preserves two tokens. Work scales with input size. It neither validates configuration writes nor parses Codebase globs.

/operationInitiator exports operationInitiatorFromCredentialScope(scope). Call it with the host-verified req.auth.credentialScope at authenticated HTTP operation creation. full and impersonation map to ui, agent maps to agent, and api, advisor or an absent origin map to system. Sandbox environment operations, core Project routes and Cronjob manual runs share this classifier. It performs constant-time classification without authorization or persistence. Request bodies and account roles do not determine origin; non-HTTP callers retain their existing defaults.

/withTimeout exports withTimeout(work, ms, failure), where work is a Promise or a callback returning one and failure is a string or Error. For example, await withTimeout(() => cdp.send('Tracing.end'), 1000, new BrowserDeadlineError('Trace end timed out')). Callback work starts once after the timer is armed. Work results and failures, including synchronous throws, preserve identity. Expiry creates an Error from a string or rejects with the supplied Error unchanged. The timer is unref'd and cleared on settlement. This helper never cancels work: callers own late effects and cancellation. Each call uses one timer with Node event-loop timing; without a call it schedules nothing. Browser tracing after migration and core platform startup through src/shared/withTimeout.ts are real consumers. Resolve-on-expiry waits keep their own semantics.

/ask also supplies formatQuestionOptionLabel(option, messages, maxLabelLength?) and formatQuestionPrompt(question, messages). Pass the adapter's existing localized message table, which includes SHARED_MESSAGES for en/cs/sk. For example, Discord buttons call formatQuestionOptionLabel(option, adapter.msg, option.recommended ? 60 : 80). The cap applies to the display label before the localized recommendation suffix; the original option label remains the answer identity. Without recommended, no suffix is added. Numbered prompts retain option order and include a custom-answer hint unless custom is false. Callers own markup, description placement, selection prefixes and platform clipping. Formatting is pure, performs no delivery or authorization, and allocates in proportion to the supplied labels/options. Discord and Telegram text-reply flows consume the same prompt formatter.

The supported /imageRuntime entry is createImageRuntime(ctx), called once during Image-gen or Image-edit registration. It returns null and logs the existing warning before acquiring file authority when the configured provider is unusable; otherwise its model property contains the trimmed configured id or the existing provider default. Rendering and file authorization remain host-owned. providerUsable and resolveModel are private implementation functions, not package exports.

The supported /subagentName entry is resolveSubagentName(explicit, task). Pass an empty explicit value to derive a label from the first five task words, clipped to forty characters with whitespace and trailing punctuation normalized. Explicit names retain the same forty-character budget. Empty tasks stay empty. The Subagent plugin and durable store readers use this pure helper; it performs no I/O and does not rewrite persisted names. deriveSubagentName is private.

/imageSniff owns sniffImageMime(bytes) and the ordered INLINE_IMAGE_TYPES vocabulary: PNG, JPEG, GIF, WebP. The sniffer classifies Buffer signatures, including bounded probes, and returns null for other signatures. It does not validate a complete image or promise decoder eligibility. ShareImage uses signature classification at the storage boundary; Files first probes with this classifier, then retains its full-byte PNG/IHDR and APNG/JPEG-LS eligibility checks plus BMP validation. BMP is not an inline MIME and must be converted. With no caller, these helpers perform no work or I/O.

The /inlineImage entry owns prepareInlineImage(raw, mime, policy) for Read, rendered PDF pages and MCP image results. For example, ordinary Read passes { maxRawBytes: IMAGE_MAX_BYTES, fallbackOnUnsupportedResize: false }. The helper calls PI resizeImage with maxWidth/maxHeight 2000 and returns { image, resized, mimeType }. A supported resized result becomes the image block; a null or failed resize uses original supported bytes only within maxRawBytes. PDF sets fallbackOnUnsupportedResize true with the same 3_750_000-byte raw cap. MCP sets it true with Infinity and rawData: part.data, preserving its existing original encoded fallback with no new Files cap. Without an eligible resized or fallback block, image is null. resized retains coordinate metadata even when its MIME cannot be embedded, and mimeType describes the emitted or omitted result. Decoding and encoding cost scale with the input; this does not add an input allocation limit. Filesystem access, signature validation, model support, text, successful-read hashes and authorization stay with each caller. Importing the module performs no resize or registration.

Hook prefixes on the instance vhost

The existing core nginx generator accepts explicit pluginServiceHooks such as ['chatbot/v2']. Operators use elowen proxy print|apply --domain example.com --plugin-service-hook chatbot/v2. The selected exact mount and slash-prefixed subtree retain the original URI and proxy directly to the fixed /run/elowen-plugin-services/chatbot.sock. Root rendering overwrites X-Real-IP, removes the forwarded-address and private control headers, disables request/response buffering and retains long stream reads. No plugin-provided upstream path, directive or executable is accepted. Other hooks retain daemon routing. Apache has no independent-hook cutover.

Before any vhost write, apply checks the protected socket parent, nonroot socket ownership and closed world mode, then performs a bounded Unix HTTP probe of GET /_elowen/service/health. The JSON response must name the selected plugin, contain ready: true and include the relative mount in hooks. This endpoint must check actual service state and assets; a declaration alone is not readiness. It is outside every public hook prefix and nginx does not forward it. Failed proof leaves nginx unchanged. The regular nginx validation/backup/reload path remains the only writer. Omit the option to roll routing back to the retained daemon endpoint, after draining durable intake; root service removal is a separate lifecycle.

Published Sites gateway control

The daemon-only publishedSitesGateway control publishes a bounded set of canonical hostname/slug/class bindings. syncBindings, ensureBinding, and removeBinding accept optional typed global proxySettings. Every desired binding selects the fixed Sites service socket; omitted worker normalizes to true and explicit false is refused. There is no daemon public Sites handler. No caller can provide an upstream path, header fragment, command, or nginx directive. The root helper independently validates every setting before effects. Installed binding status reports the actual stored worker boolean alongside servedGeneration, so a plugin can refuse a one-use access exchange until the worker is actually selected. Persisted legacy false or omitted routing must never be reported as a successful cutover. Desired bindings or cached activity do not establish that routing authority.

Core, not plugin code, stamps a private per-instance ownership proof on mutations. An absent or different proof is refused before any lock file or disk is touched. status() remains read-only and needs no proof. Without an injected privileged transport, the control does not throw: each call resolves to an unavailable status whose detail is no privileged transport in this process (src/daemon/brainCore.ts, src/privileged/publishedSitesGateway.ts).

offline() replaces the retired deny() operation. It reads persisted bindings and renders HTTP ACME challenges plus HTTPS maintenance using existing certificates, with 503, no-store, and Retry-After. No certificate is issued or deleted. Missing or malformed persisted state refuses without replacing the active configuration. Plugin removal invokes this host control before scheduling file/data deletion, even when the consuming plugin is disabled or failed to load. Absence, disable, daemon shutdown, and registry load failures never invoke offline. Sites is the only approved plugin consumer.

removeBinding is explicit removal authority: Sites first persists its deleting/hostname-removal marker, and the authenticated root helper durably records a per-hostname removal intent before probes or effects. It withdraws that hostname's active/maintenance blocks and deletes only certificates proven in its issued/adopted ledger. syncBindings and ensureBinding retry outstanding explicit intents, never infer deletion from omission, and preserve omitted stored bindings as maintenance with their existing certificates. Historical owned orphans without intent are retained. Completed removal tombstones reject stale binding lists; only explicit, probed ensureBinding recreates a removed hostname after cleanup completes. The root state bounds ownership and removal ledgers to 4096 entries each; exhaustion refuses. Unchanged configuration avoids reloading nginx. The minute recovery sweep is the periodic caller, and explicit sync uses the same path. No plugin-supplied ownership claim or certificate path is accepted. Legacy adoption requires both exact renewal-configuration proof and non-redirected physical root-owned paths as documented in Security. Host-owned certificates and uninstall maintenance inventory are never deleted by offline. Failed explicit deletion remains retryable through the root intent, ownership ledger and Sites' durable cleanup markers; worker/proxy proof failure postpones physical effects, not persistence of removal intent.

The worker-only renderer is not automatic cutover. The Sites plugin must persist and validate its serving state and directly probe every binding through the worker before supplying worker flags. Root repeats signed, application-backed proofs through /run/elowen-plugin-services/sites.sock before any gateway effect; local HTTPS through nginx is not a cutover prerequisite. Direct transport errors identify the hostname, socket and underlying network failure; invalid replies report the validation reason without response bodies or authentication material. The operation worker's bounded error is preserved as the control's unavailable detail, rather than replaced by a generic refusal. Project previews use durable projectPublicationBinding transports, keyed by preview id, without file copying or execution leases. Initial creation needs the current authorized writable account; reattachment of an existing unchanged record is account-independent. Before recording a new preview, Sites invokes the same writable-account preflight as a conversion. Background migration may use a currently eligible account only for preview rows marked by the upgrade migration and without an ambient actor; Sandbox still performs its full account, grant, scope and Project checks. Preview removal first persists a deletion marker and withdraws access, then retries gateway/certificate cleanup and projectPublicationRelease. No preview row is deleted before transport cleanup succeeds. Retired file Sites have no endpoint and return signed worker maintenance. SQLite ticket/grant stores and projectPreviewBinding are removed; bounded worker controls own all one-use exchanges.

Publication identities are globally unique in Sandbox. Mutation locks and the database owner check use that same identity, never a Project-local key. projectPublicationAuthorize({project, accountUserId}) checks writable account/grant/turn/Project authority without runtime or transport mutation. Sites invokes it before recording a conversion intent. Each conversion has a fresh publication id in a durable Sites journal; committing that exact uncancelled attempt atomically adopts its Project/port/id. Superseded or crashed attempts remain cleanup-owned until projectPublicationRelease({project, publicationId, retire: true}) succeeds. Retirement is persisted before asynchronous cleanup, uses the recorded Project owner, and retains a minimal non-serving fence against late creation across daemon replacement. Ordinary release without retirement permits deliberate recreation. Boot cancels outstanding conversion attempts; normal sweeps cancel expired attempts after five minutes and retry cancelled cleanup. Sandbox reconciliation retries persisted retirement independently, and every forwarder start rechecks retirement after its asynchronous privileged operation before returning a usable socket. Missing authorization support refuses a conversion or preview, never bypasses permissions. Publication bindings inspect an already-running Project environment and never provision or implicitly start it; the manager must start the Project and its HTTP service explicitly. Each intent costs one journal row and one retained retirement fence after cancellation; no execution lease or file copy is involved.