One artifact, several runtime shapes
P6X4 ran web, worker, outbox, and indexer processes from one versioned artifact without pretending they needed identical credentials or resources.
Consolidating P6X4 did not produce one process. It produced one version.
The distinction became important as soon as I described the target as a modular monolith. “Monolith” suggested a large web server performing every task. P6X4 still had interactive requests, CPU-heavy document rendering, an outbox relay, projection indexing, scheduled reconciliation, and external delivery adapters. Running them all in one process would have combined unrelated resource and failure profiles.
Keeping a separate repository and release for each profile had created the coordination problem I was trying to remove.
I built one application artifact that could start in several runtime shapes. They shared domain contracts and a release identity. They did not share every credential, dependency, concurrency limit, or lifecycle.
Version unity was the primary promise
Before consolidation, a P6X4 scenario could involve document service version 18, renderer version 12, delivery version 9, and search consumer version 14. A change to a document field required compatibility across several combinations during rollout.
Some compatibility was real. Durable messages might remain in a queue across a deployment. External consumers could not be changed atomically. But many version combinations existed only because parts of one personal project were packaged independently.
The unified artifact made a narrower promise:
All runtime roles in one release understand the same current domain model and published internal contracts.
The artifact had a content-addressed identifier and a release manifest containing source revision, schema migration set, public contract versions, build environment, and asset digests. Web and worker processes reported that same identity.
I could now ask whether every active role had converged on release harbor-2022.05.10.3, instead of reasoning about a matrix of loosely coordinated versions.
This did not permit instant incompatible change. Queued work created before the release still carried an older workflow definition or payload version. The current worker had to interpret those durable contracts or route them to a compatible handler. Version unity reduced accidental combinations; it did not erase time.
One build had multiple entry points
The TypeScript repository contained explicit application entry points:
apps/
web.ts
render-worker.ts
outbox-relay.ts
search-indexer.ts
reconciler.ts
packages/
documents/
rendering/
delivery/
search/
platform/The build compiled each entry point and included the package graph it was allowed to reach. A runtime role was selected by its command, not by an environment flag buried inside the web process.
This mattered operationally. Starting render-worker could not accidentally open an HTTP listener. Starting web did not begin consuming render jobs. Health and readiness checks were role-specific. Shutdown behavior was role-specific. Each process had a small, named contract despite coming from one artifact.
I considered one universal executable with ROLE=worker. It was convenient for deployment configuration but made invalid combinations easy: a misspelled role could fall back to web, and role-specific dependencies still appeared universally reachable. Explicit entry points gave the build and tests something concrete to enforce.
The container image could remain shared while its command selected a known entry point. The artifact was one; the processes were not ambiguous.
Shared code did not imply shared authority
Putting domain packages in one repository made it easy for every entry point to import everything. That would have turned deployment consolidation into privilege consolidation.
I declared a capability manifest for each role:
export const renderWorker = defineRuntime({
entry: "apps/render-worker",
packages: ["rendering", "documents/contracts", "platform/jobs"],
database: ["render_jobs", "render_receipts"],
outbound: ["asset_store"],
})Architecture tests checked imports against the manifest. Database roles enforced table and operation access at runtime. Network policy restricted outbound destinations. Configuration loading failed if a process received an undeclared secret.
The renderer could read immutable render inputs through its contract and write job receipts. It could not modify document authority, query delivery credentials, or send notifications. The web role could record a render request transactionally but could not perform the render effect.
These constraints were stronger than the old assumption that an “internal” service could trust any request from the private network.
One artifact meant shared provenance and compatible code. Least privilege remained per execution identity.
The web role optimized for bounded latency and moderate memory. The render worker consumed CPU and memory in bursts. The indexer preferred batched I/O. The outbox relay needed little CPU but demanded steady progress and careful backoff.
Running them in separate processes let me assign:
- Different CPU and memory limits.
- Different replica or concurrency counts.
- Role-specific autoscaling signals in the lab harness.
- Independent restart and termination grace periods.
- Separate database connection-pool budgets.
The last item corrected a failure I had already explored in W93H. A burst of background work should not exhaust connections required for interactive requests. Each role received an explicit pool allocation, and a startup check compared configured maxima with the database budget.
Scaling a role did not require extracting its domain into a separately versioned service. More render workers could execute the same durable job contract. The authoritative document module still belonged to the common application model.
I had previously treated “needs different scale” as proof of service independence. P6X4 showed that execution scale and change independence can be separated.
Deployment order became a compatibility test
One artifact still reached processes at different moments. A rolling deployment could briefly leave an old worker beside a new web process.
I made the order explicit:
- Apply backward-compatible schema expansion.
- Start new background roles capable of reading old and new durable contracts.
- Start new web roles that may emit the new contract.
- Drain old web roles.
- Drain old workers after their leases or hand off safely.
- Verify no retained work requires the old contract.
- Remove compatibility and schema only in a later release.
The release manifest described minimum readable and writable contract versions per role. A pre-deployment check rejected an order that could emit work no active consumer understood.
For a personal laboratory this may seem elaborate. The scenarios were designed to expose assumptions that a fast local restart would hide. I could pause a worker on the old version, deploy the web role, enqueue work, and observe whether the contract survived the overlap.
“One release” meant coordinated intent, not simultaneous process replacement.
Durable work carried definition, not code location
A queued job stored a workflow type and version, stable identity, input reference, policy revision, and creation context. It did not store a JavaScript function name.
The current worker resolved that definition through a registry:
const handler = workflows.resolve(job.type, job.version)Handlers for retained versions remained available until all durable work using them reached a terminal state or migrated through an explicit procedure. The artifact build failed if a referenced version was removed from fixtures or migration inventory.
This allowed packages to move during consolidation. A job created when rendering lived in a separate service still meant render-document@2; it did not care whether the handler now ran from apps/render-worker inside P6X4.
The durable contract outlived the deployment topology. That made architectural subtraction possible without rewriting history.
Startup was a declaration, not exploration
The early universal process discovered capabilities by importing modules and inspecting environment variables. Missing a queue URL silently disabled a consumer. Extra credentials silently enabled one.
I changed startup to validate a complete role declaration:
- Required configuration was present and parseable.
- No undeclared sensitive configuration was injected.
- Database identity matched the role.
- Expected migrations were applied.
- Required contract versions were registered.
- External adapter clients matched an allowlist.
- The role reported its release and capability manifest before readiness.
A web process without its document tables failed readiness. A render worker without the asset fixture failed before leasing work. An outbox relay with delivery credentials was rejected because it had no reason to possess them.
This made environment differences visible. I did not want a role to discover its meaning from whatever happened to be available.
Shutdown followed the work contract
Each runtime shape needed a different graceful shutdown.
The web server stopped accepting new requests, allowed bounded in-flight requests to finish, and relied on durable intent records for accepted background work. The outbox relay stopped claiming rows, completed or released current leases, and left unsent rows authoritative. The renderer stopped leasing jobs and either completed its current atomic output or abandoned the temporary artifact before releasing the lease.
I tested termination at every step. A process received the same signal used by the deployment harness, not a friendly test callback. After its grace period, the harness killed it and restarted another instance. The workflow had to converge without duplicate external effects or permanent running states.
Readiness changed before termination so new work moved elsewhere. Liveness did not remain green merely because the process loop was alive; each role reported whether it could make its promised kind of progress.
One artifact gave the roles shared shutdown libraries. Their actual promises remained distinct.
Logs shared a release and preserved roles
Consolidation risked producing one undifferentiated log stream. Every record included release_id, runtime_role, instance_id, and the stable workflow or request identity where relevant.
This let W93H-like incident reconstruction answer two questions:
- Were different processes running the same release?
- Which role observed or changed this workflow state?
Metrics used the runtime role as a bounded label but avoided instance IDs and document identities that would create unbounded cardinality. Traces crossed process boundaries through explicit context in durable messages. A message's correlation identity was not inferred from the current process.
The shared artifact made source lookup simpler. A stack frame and release identity resolved to one manifest. Role labelling prevented that convenience from erasing execution context.
Separate images were considered and declined
I built a role-specific image experiment. Each image contained only its compiled entry point and dependencies. The renderer image was larger because of rendering binaries; the outbox image was small.
The tighter file surface was attractive. It also created several build artifacts that needed provenance, scanning, promotion, and coordinated versioning. For P6X4's scale, one base image plus a role-specific renderer variant captured most of the benefit without recreating a release matrix.
The generic roles used one image and different commands. The renderer used an image derived from the same source manifest with its native dependency layer. Both carried the same application release identity and contract inventory.
This was not a universal answer. If image size, platform, or security isolation justified distinct artifacts, I would separate them. The experiment clarified that artifact unity was a means of reducing accidental version combinations, not an aesthetic rule.
The first design attached periodic reconciliation to the web process. Every web replica registered the timer, and a database lock selected one. It worked, but deployment restarts and scale changes made scheduling behavior unnecessarily implicit.
I gave reconciliation its own entry point. A scheduler created durable, uniquely identified intents for time windows. A reconciler worker leased and processed them. Missed intervals were visible, and overlapping execution followed an explicit policy.
The runtime did not assume one immortal process with a reliable clock. It could stop, restart, and catch up within retention limits. A manual run used a distinct request identity and recorded who or what initiated it in the lab.
Again, this did not require a separate repository. It required a separate lifecycle and work contract.
The deployment model became legible
At the end, P6X4's deployment view listed one release across several shapes:
| Role | Primary responsibility | Scales by | Privilege boundary |
|---|---|---|---|
| Web | Validate and record interactive intent | Request latency and concurrency | Authoritative application writes |
| Render worker | Produce immutable artifacts | Queue depth and CPU | Render jobs and asset storage |
| Outbox relay | Publish committed records | Oldest unsent age | Outbox read and publish only |
| Search indexer | Maintain rebuildable projections | Projection lag | Index write, source read |
| Reconciler | Query uncertain or overdue work | Due work and adapter limits | Status-query capabilities |
The table was smaller than the old service map and more precise about what could fail independently.
Deploying one version did not mean pretending all code had one job. It meant the jobs shared a coherent model, provenance, and release decision. Running several processes did not mean inventing several products. It meant execution respected resource, privilege, and recovery boundaries.
P6X4 became a modular monolith in the dimensions where unity reduced coordination. It remained plural in the dimensions where isolation protected the work.
One artifact was not one giant process. It was one answer to the question, “Which version of this system are we running?”
Database migrations belonged to the release, not a role
With several processes starting from one artifact, each entry point could not race to apply schema migrations. I separated migration execution from ordinary startup.
A release task acquired a database advisory lock, verified the current schema lineage, and applied only forward-compatible expansion migrations. Runtime roles checked the expected schema range and refused readiness if the database was too old or unexpectedly new. They never modified schema as a side effect of receiving traffic.
The manifest declared three versions:
- The schema version the release could read.
- The minimum schema version it could write safely.
- The expansion version its migration task would install.
This permitted a rolling overlap. Old processes could continue against the expanded schema while new processes arrived. A later cleanup release removed old fields only after every runtime role and retained workflow definition stopped reading them.
I tested a delayed render worker from the previous release against the expanded database. It completed an old job without seeing the new optional fields. I also started a new web process before migration and expected it to remain unready with a useful diagnosis rather than fail during the first request.
The shared artifact simplified coordination because one manifest described code and schema expectations. Explicit migration ownership prevented that unity from becoming a startup stampede.
Feature activation was separate from binary presence
Deploying a handler did not immediately make it authoritative.
P6X4 used checked-in feature definitions with activation stored as data. A release could contain both old and new document paths, while a deterministic cohort selected the new one only after migrations, workers, and recovery tooling were ready. Activation records included release compatibility, cohort, decision owner—the project itself in this personal lab—start time, and a removal condition.
I avoided environment variables that could differ by process. Web and workers read the same durable activation revision and included it in relevant workflow records. A job therefore did not change behavior midway because a flag had flipped; it retained the definition accepted at creation unless a versioned migration changed it.
Feature definitions were not an excuse to preserve both paths forever. The release checklist included three end states: adopt and remove the old path, revert and remove the new path, or extend the experiment with a dated reason. The build reported code references to expired definitions.
This mattered more in a multi-role artifact than it had in isolated services. Binary consistency did not guarantee behavioral consistency if every process interpreted a mutable flag at a different moment.
Dependency failure stayed role-specific
The shared build initially initialized every client in a common bootstrap module. If search was unavailable, even the outbox relay failed to start. If one optional adapter lacked configuration, the web role became unready despite never calling it.
I moved dependency construction to the role composition root. Packages received narrow ports rather than importing global clients. The search indexer constructed index and source readers. The renderer constructed asset storage and its rendering engine. The web process constructed only the authoritative repositories and command ports it required.
Readiness reported required dependencies for that role. Optional product regions could expose local unavailability without marking the entire application dead. A document read did not depend on recommendation search merely because both packages existed in the same repository.
Scenario tests removed one dependency at a time and asserted the promised blast radius. This was the runtime equivalent of package-boundary tests: code colocation was not permission for failure to spread.
The exercise also reduced configuration. Each process received fewer variables, opened fewer connections, and could explain exactly why every external capability existed.
Promotion reused the exact build
I did not rebuild the artifact for each environment. The test harness, preview, and final private environment promoted the same digests with different validated configuration and synthetic data.
The release manifest was signed by the local build identity and stored beside test results. Startup printed the manifest digest rather than a mutable tag. If the renderer variant added native binaries, its derived manifest linked to the same source release and recorded the extra layer digest.
A promotion check verified that all requested runtime shapes existed, contract fixtures passed, migration inventory matched, and no role manifest requested undeclared privileges. The deploy specification referenced digests, so a tag update could not quietly change one role beneath the others.
This was intentionally more rigorous than P6X4's audience required. The personal project existed to investigate what “one release” should mean after years of independently versioned experiments.
The answer was not that every byte or process was identical. It was that source, domain contracts, migration expectations, workflow handlers, and provenance formed one reviewable unit.