Task Breakdown: App runtime on Kubernetes
Ordered tasks derived from
plan.md. Each is small enough to land in one PR and ships with tests per Constitution VI. Schema tasks ship their migration in the same PR per Constitution V.
Epic ID: APW-06-app-runtime
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-17
How to use
- Tasks are sequential by default.
(parallel)means it may run alongside its predecessor. - Every task names the exact files to create or modify;
_(new)_files do not exist yet; every other Modify path exists ondevelop@ee45946e5. - Every task has a Test line (a file and what it asserts, or the command that is the test) and a Done when
line that is checkable without reading the diff.
(ACC-06-nn)tags name the spec §8 criteria a Test line proves. - Add new tasks at the bottom rather than renumbering. T47 and T48 were moved from P2 into P1.8 by Resolution R-16 and keep their numbers.
- Phase boundaries are ship boundaries:
developmust be green and deployable at the end of each phase. - Commands run from the monorepo root; migrations are authored from
apps/api/. Package filters:@ever-works/contracts,@ever-works/plugin,@ever-works/k8s-plugin,@ever-works/agent,ever-works-api,ever-works-web,@ever-works/trigger-tasks. - API behaviour is tested under
apps/api/src/**orapps/web/e2e/— neverapps/api/test/(Resolution R-22). - Additive guard for every task: the existing suites
packages/plugins/k8s/src/__tests__/*.spec.ts,apps/api/src/plugins-capabilities/deploy/*.spec.tsandapps/web/e2e/flow-work-deploy-*.spec.tspass with no edits to existing assertions. - Resolutions that shape these tasks (CONTRACTS §0):
R-2 (Activity families), R-3 (eligibility read from APW-03, no attestation stored here), R-5 (Ever Works Apps only
through
AppsTierPolicyand theapps-tierdeployment plugin), R-10 (verification targets), R-12 (target None), R-15 (deleting an App Work), R-16 (managed subdomain on Your cluster in Wave 1), R-24 (sandboxed runtime from Wave 2).
Phase P1 — Your cluster (Wave 1)
Delivers spec FR-1…FR-6, FR-9…FR-21, FR-23…FR-51 (managed subdomain on Your cluster included), FR-54…FR-62.
P1.1 — Contracts and ports
-
T1. Runtime constants. Create
packages/contracts/src/apps/app-runtime.ts(new) with every constant in plan §5.3 plusAPP_DEPLOYMENT_STATES,APP_RUNTIME_HEALTH,APP_DEPLOY_TARGETS(none,your-cluster,ever-works-apps— no "not yet" value, R-12),APP_PRECONDITION_CODES(plan §5.1, incl.managed_ineligible,managed_sandbox_unavailable,app_work_deleting) andAPP_FAILURE_CODES(plan §5.4, §11). Modifypackages/contracts/src/apps/index.ts(APW-03's barrel — appendexport * from './app-runtime.js'; create it and Modifypackages/contracts/src/index.tsonly if APW-03 has not landed — R-1). Test:packages/contracts/src/apps/__tests__/app-runtime.spec.tspins every numeric value and every union;APP_DEPLOY_TARGETSis exactly the three values. Done when:pnpm --filter @ever-works/contracts testis green andimport { APP_ROLLOUT_MAX_S } from '@ever-works/contracts'resolves fromapps/api. -
T2 (parallel with T1). Plugin App contract. Create
packages/plugin/src/contracts/capabilities/app-deployment.types.ts(new) with the types of plan §3 and §3.1 (the normative field reference) (AppDeployTarget,AppDeployPhase,AppTargetRef,AppRenderInputincl.purposeandttlMinutes,AppComponentInput,AppJobInputincl.http.authScheme,AppCronInput,AppSmokeInput,AppDeployHooks,AppDeployResultincl.cancelReasonandimage,AppScaleResult,AppStatusSnapshot,AppStatusSpec,AppJobRunRequestincl. therunner: 'smoke'variant,AppJobResult,AppLogRequest,AppLogTail,AppLogRef,AppClusterCheckRequest,AppClusterCheck,AppDestroyResultincl.kept,AppQuotaInput,AppLimitRangeInput,CheckResult,AppSmokeRun,AppSmokeResult,AppComponentStatus,AppFailureCode,AppRuntimeState). Modifypackages/plugin/src/contracts/capabilities/deployment.interface.ts— add the ten optional members (supportsApps,deployApp,getAppStatus,runAppJob,destroyApp,scaleApp,getAppLogs,checkAppCluster,prepareAppNamespace,publishAppHosts) andisAppDeploymentPlugin(plugin)guard (supportsApps === true && typeof deployApp === 'function');scaleAppreturnsPromise<AppScaleResult>and takes the optionalresumeChecksargument. Modifypackages/plugin/src/contracts/capabilities/index.ts— export the new file. Test:packages/plugin/src/contracts/__tests__/app-deployment.types.spec.ts(new) — a type-level test that a plugin implementing only the pre-existing members still satisfiesIDeploymentPlugin(and that a plugin implementing only the eight earlier App members still satisfies it, so the two new members stay optional);isAppDeploymentPlugintrue/false cases. Done when:pnpm --filter @ever-works/plugin testis green andpackages/plugins/vercelbuilds unchanged. -
T3. Ports. Create
packages/agent/src/app-runtime/ports.ts(new) exactly as plan §9.6 (incl.AppsTierPolicy.eligibility,AppRuntimeEnvSource.resolveEphemeral,target: AppDeployTargetin both resolve contexts,AppRuntimeTargetPort/APP_RUNTIME_TARGET,AppRuntimeEventSink/APP_RUNTIME_EVENT_SINK,AppVerificationSink/AppVerificationSinkandAppVerificationUpdate), andpackages/agent/src/app-runtime/default-ports.ts(new):DisabledAppsTierPolicy(isOpen() = false,eligibility→{ eligible: false, reasons: ['managedTierDisabled'] }),UnavailablePullCredentialSource,UnavailableRuntimeEnvSourceandUnavailableRuntimeTargetthat throwAppPortUnavailableError(code), and anUnavailableVerificationSinkthat throwsverification_sink_unavailable. Createpackages/agent/src/app-runtime/index.ts(new) barrel. Modifypackages/agent/package.json— add the./app-runtimesubpath export next to./deployment-context. Test:packages/agent/src/app-runtime/__tests__/default-ports.spec.ts— disabled policy never reports open; unavailable sources throw with codespull_credential_unavailable/env_source_unavailable;UnavailableRuntimeTarget.prepareDependencyTargetresolves{ unavailable: 'target_none' }rather than throwing, so a caller reports a precondition; the verification sink refuses before any namespace exists; a grep assertion finds noEVER_WORKS_APPS_MANAGED_ENABLEDinpackages/agent/src/app-runtime/(R-5). Done when:pnpm --filter @ever-works/agent test -- default-portsis green and the symbols resolve from@ever-works/agent/app-runtime.
P1.2 — The App renderer (k8s plugin)
-
T4. Names and labels. Create
packages/plugins/k8s/src/app/app-names.ts(new) per plan §4.1:appNamespaceName(slug, workId),previewNamespaceName(ns, pr),verificationNamespaceName(ns, provisioningId, attempt), object name helpers,appLabels(...),componentSelector(name),internalUrl(component, namespace). Test:packages/plugins/k8s/src/app/__tests__/app-names.spec.ts— 63-char cap, slug truncation at 30, deterministic 8-hex suffix, the verification suffix-v<first 6 hex of provisioningId>-<attempt ≤ 9>stays ≤ 63 characters and ≤ 52 in practice, is deterministic and gives two provisionings different names (APW06-G09); labels never containever-works.io/managedorapp.kubernetes.io/name, Job name ≤ 45 and CronJob name ≤ 37 for a 32-charName. Done when:pnpm --filter @ever-works/k8s-plugin test -- app-namesis green and the helpers are pure and 100 % branch-covered. -
T5. Security context. Create
packages/plugins/k8s/src/app/app-security.ts(new) —podSecurityContext(input, component),containerSecurityContext(...),tmpVolume(...),namespacePodSecurityLabels(policy)per plan §4.4. Test:packages/plugins/k8s/src/app/__tests__/app-security.spec.ts— oneitper cell of the plan §4.4 table: no privilege escalation,drop: [ALL],RuntimeDefaultseccomp,runAsNonRoot, read-only root unless declared (ACC-06-07);runAsNonRoot: falseonly withallowRootonyour-clusterand never forever-works-apps(ACC-06-08);privileged_portrefusal onever-works-apps;NET_BIND_SERVICEonly withallowRootand port < 1024. Done when:pnpm --filter @ever-works/k8s-plugin test -- app-securityis green and no rendered container lacksallowPrivilegeEscalation: falseandcapabilities.drop: [ALL]. -
T6. Workloads, services, volumes, secrets, ingress. Create
packages/plugins/k8s/src/app/app-manifest.renderer.ts(new) —renderNamespace,renderServiceAccount,renderLimitRange,renderResourceQuota,renderEnvSecret(immutable, keys = env names),renderPlatformConfigMap,renderPullSecret(reusebuildImagePullSecretshape without editing it),renderPvc(emptyDirsubstitute forpurpose: 'verification'),renderComponentDeployment,renderComponentService,renderIngress(reuseIngressStrategyRegistry; TLS only forcert-manager; none for verification),componentDeadlineSeconds(component)(plan §5.3 formula), andvalidateRenderInputreturningvolume_replicas,volume_shrink,privileged_port; export the pure entry points as a library (R-5). Test:packages/plugins/k8s/src/app/__tests__/app-manifest.renderer.spec.tswith golden JSON fixtures inpackages/plugins/k8s/src/app/__tests__/fixtures/built from APW-03schema.md§24 examples: digest image;Recreatewith volumes;envFromnot optional; no env value in any pod spec and no pull credential other than the render input's (ACC-06-16);automountServiceAccountToken: false;enableServiceLinks: false(ACC-06-07); the pod template'sever-works.io/env-checksumchanges when one env value changes and is byte-identical otherwise (ACC-06-15); rendering twice with differentdeploymentId/deploymentShortbut the same Build, env and spec yields byte-identical componentspec.templates and identicalenvFromobject names, putsever-works.io/deployment-idonly under the Deployment'smetadata.annotations, and leaves noEVER_WORKS_DEPLOYMENT_IDkey in the platform ConfigMap (ACC-06-58, APW06-G07); Ingress only for the primary web component; strict host validation; deadline clamp at 300 and 2400; a component with a volume andreplicas: 2→volume_replicas(ACC-06-18); theprepare-namespacesubset renders the namespace, the ServiceAccount and theLimitRangeand — withisolation: true— exactlyew-default-deny,ew-allow-same-namespaceandew-allow-egress, neverew-allow-ingress/ew-allow-deps(ACC-06-54, plan §4.2). Done when:pnpm --filter @ever-works/k8s-plugin test -- app-manifest.renderer manifest.rendereris green andmanifest.renderer.spec.ts(existing) is untouched. -
T7 (parallel with T6). Network policies. Create
packages/plugins/k8s/src/app/app-network-policy.renderer.ts(new) per plan §4.10. Test:packages/plugins/k8s/src/app/__tests__/app-network-policy.spec.ts— the five default policies render and every excepted IPv4/IPv6 CIDR is present (ACC-06-17); controller-namespace and fallback variants;extraEgressand hairpin rules;isolation: falserenders zero policies and returns the five names to delete — and never adep-<kind>name (APW07-G01, ACC-06-54). Done when:pnpm --filter @ever-works/k8s-plugin test -- app-network-policyis green and the fixture diff is reviewed against the plan table. -
T8. Jobs, CronJobs and the runner. Create
packages/plugins/k8s/src/app/app-runner.script.ts(new) (the runner source as a string constant +APP_RUNNER_IMAGEdigest constant),packages/plugins/k8s/src/app/app-jobs.renderer.ts(new) —renderCommandJob,renderRunnerJob(kind: 'http-job'|'smoke'|'hairpin'|'isolation-probe'),renderRunnerConfigMap,renderCronJob; requests withredirect: 'manual',authSchemebearer/raw,{{env.NAME}}fromsecretKeyRef,foundcapped at 200 chars and secret-scrubbed;cron_too_frequentcheck. Test:packages/plugins/k8s/src/app/__tests__/app-jobs.renderer.spec.ts— backoff/deadline/TTL/Never; the ConfigMap contains paths with$(, backticks and quotes verbatim as JSON data and no command string contains them; cron auth viasecretKeyRef;concurrencyPolicymapping;suspend: truewhen paused (ACC-06-35).packages/plugins/k8s/src/app/__tests__/app-runner.script.spec.tsruns the script in-process against a local HTTP server: status,bodyContains,bodyNotContainsfailure quoting the found string (ACC-06-12) with the 1 MiB cap, latency, 307 not followed, bearer vs raw header. Done when:pnpm --filter @ever-works/k8s-plugin test -- app-jobs.renderer app-runner.scriptis green and no renderedcommand/argscontains a value read from the App spec'shttpblock. -
T9. Rollout predicate and classifier. Create
packages/plugins/k8s/src/app/app-rollout.ts(new) per plan §5.4 (isComponentRolledOut,classifyPodFailure,workerStable). Do not changepackages/plugins/k8s/src/status.mapper.ts. Test:packages/plugins/k8s/src/app/__tests__/app-rollout.spec.ts—metadata.generationvsobservedGeneration; old ReplicaSet with ready pods blocks success; 3 restarts;ImagePullBackOfffor 179 s vs 180 s;OOMKilled; the two root-user kubelet messages classifyimage_runs_as_root/image_user_unverifiablewithin 180 s (ACC-06-08). Done when:pnpm --filter @ever-works/k8s-plugin test -- app-rolloutis green and eachAppFailureCodein plan §5.4 has at least one test. -
T10. API wrapper additions. Modify
packages/plugins/k8s/src/k8s-api.service.ts— addapplyObject,readObject,listObjects(apiVersion, kind, namespace, labelSelector),deleteObject(…, propagationPolicy),readPodLog(ns, pod, container, { tailLines, limitBytes, previous }),createSelfSubjectAccessReview, andauthorizationV1ApionKubernetesClientFactory+defaultClientFactory. Existing methods unchanged. Test: extendpackages/plugins/k8s/src/__tests__/k8s-api.service.spec.tswith mocked-factory cases for each new method, including 404 →nullon reads. Done when:pnpm --filter @ever-works/k8s-plugin test -- k8s-api.serviceis green with existing assertions unchanged. -
T11. Kubeconfig guard. Create
packages/plugins/k8s/src/app/app-kubeconfig.guard.ts(new) per plan §6.1:assertSupportedKubeconfig,isPublicAddress(ip, allowlist),pinKubeconfigServer(yaml, resolver)→ rewritten YAML withtls-server-name. Modifypackages/plugins/k8s/src/errors.ts— add codeKUBECONFIG_UNSUPPORTEDandCLUSTER_ADDRESS_NOT_PUBLIC. Test:packages/plugins/k8s/src/app/__tests__/app-kubeconfig.guard.spec.ts—exec,auth-provider,tokenFile, file certificate paths,proxy-url,insecure-skip-tls-verifyand missing CA data each refused before any resolver or client call (ACC-06-02); every deny CIDR incl.::ffff:10.0.0.1,64:ff9b::a00:1; a hostname resolving to one public + one private address is refused; an operator allow-list range is accepted (ACC-06-03); DNS timeout 10 s; resulting YAML has the IP server and originaltls-server-name; a mocked 307 from/versionis not followed. Done when:pnpm --filter @ever-works/k8s-plugin test -- app-kubeconfig.guardis green and a spec spying on the factory proves no code path insrc/app/callsKubeConfig.loadFromStringwithout the guard. -
T12. Deployer: phase machine and rollback. Create
packages/plugins/k8s/src/app/app-deployer.ts(new) per plan §5.5: capture → prepare → pre-deploy jobs → rollout → first-deploy jobs → in-cluster smoke → isolation probe → publish →hooks.verifyPublic→ hairpin (T61) → post-deploy jobs → CronJobs → GC (env Secrets/ConfigMaps beyond 3, Jobs beyond 3 per name); rollback from capture; first-Deployment failure handling (scaleFailedFirstDeployToZero); cancellation between phases and polls; 2-hour overall deadline;purpose: 'verification'path (T60). Test:packages/plugins/k8s/src/app/__tests__/app-deployer.spec.tswith a fake API — phase order; a failing pre-deploy job endsfailedwith zero Deployment writes (ACC-06-09); a crash-looping component re-applies captured templates and hosts →rolled-back(ACC-06-10); the Ingress apply happens after the first-deploy job completes andisFirstDeploymentOnCluster: falserenders no first-deploy Job (ACC-06-11); in-clusterbodyNotContainsfailure →rolled-backwith the found string (ACC-06-12); publicdns_not_pointing→succeeded-with-warningsand no rollback (ACC-06-13); rollback restores the previousenvFromsecret name (ACC-06-15); cancel before change →cancelled, after change →rolled-backwithcancelled(ACC-06-22);skipPreDeployJobson a manual rollback input renders no pre-deploy Job and applies the captured build's image (ACC-06-23); rollback that does not become ready →rollback-failed(ACC-06-24); publish failure rolls back. Done when:pnpm --filter @ever-works/k8s-plugin test -- app-deployeris green and every row of spec FR-26's table has a test named after it. -
T13. Status, scale, logs, destroy, cluster check. Create
packages/plugins/k8s/src/app/app-status.reader.ts,app-lifecycle.ts,app-cluster-check.ts(new):getAppStatus(components, restarts, lastOOMKilledwithin 24 h, jobs, cron last schedule/success),scaleApp(replicas + CronJobsuspend; onresumeit takes the optionalresumeChecks{ smoke, deadlines }, waits withisComponentRolledOut/componentDeadlineSecondsfor the phase-3 rollout and then runs the phase-5 in-cluster smoke, resolvingAppScaleResult— APW06-G03),getAppLogs(≤ 500 lines, 262 144 bytes, secret redaction by value ≥ 8 chars),runAppJob(live Deployment's image andenvFrom; refuses while a Job of the same name is active; therunner: 'smoke'variant runs the App spec's checks through the runner Job),prepareAppNamespace(plan §4.2 subset: namespace + pod-security labels + ownership check, ServiceAccount, LimitRange with thelimitrange_forbiddenwarning, and the three baseline policies whenisolationis true; idempotent; never drawsew-allow-ingress,ew-allow-depsor adep-*policy — GAP-06),publishAppHosts(re-applies only theIngressby reusingrenderIngressand returns the observedingressAddress; zero other applies — APW06-G03),destroyApp(never PVCs,ever-works.io/dependencyobjects or — while such objects remain —ew-default-denyunlessdeleteVolumes; namespace deleted only whendeleteVolumes; verification namespaces deleted whole),checkAppCluster(plan §6.3 permission list, ingress classes, controller namespace, issuers, storage classes, the ingress controller Service's address asingressAddress, and the fingerprint inside the result — GAP-09 / APW06-G03). Test:packages/plugins/k8s/src/app/__tests__/app-status.reader.spec.ts— every FR-46 field is filled from a fake cluster (ACC-06-31).packages/plugins/k8s/src/app/__tests__/app-lifecycle.spec.ts—scaleApp('pause')sets replicas 0 andsuspend: true(ACC-06-35);scaleApp('resume', …)resolves anAppScaleResultcarrying components and smoke, and reportsfailure.codewithout rolling back;destroyAppwithdeleteVolumes: falseissues zero PVC deletes, zero dependency deletes, keepsew-default-denyand zero namespace deletes (ACC-06-18, ACC-06-36);getAppLogsreplaces a secret value appearing mid-line with its name and returns no value (ACC-06-34);runAppJobuses the live image digest and a second call while active is refused (ACC-06-37);prepareAppNamespacepersists nothing itself but renders the §4.2 subset and is idempotent on a second call;publishAppHostsapplies oneIngressand zero Deployments (ACC-06-25).packages/plugins/k8s/src/app/__tests__/app-cluster-check.spec.ts— each missing required permission is named, withrequired: trueblocking Save (ACC-06-05); optional permissions listed as optional; the result carriesfingerprintandingressAddressand never writes the runtime-stateclusterFingerprint. Done when:pnpm --filter @ever-works/k8s-plugin test -- app-status.reader app-lifecycle app-cluster-checkis green and results contain no secret values (asserted with a sentinel value). -
T14. Plugin wiring. Modify
packages/plugins/k8s/src/k8s.plugin.ts—readonly supportsApps = trueand ten methods delegating tosrc/app/*(deployApp,getAppStatus,runAppJob,destroyApp,scaleApp,getAppLogs,checkAppCluster,prepareAppNamespace,publishAppHosts), each runningassertSupportedKubeconfig+pinKubeconfigServeron every credential (thek8splugin never servesever-works-apps; R-5).deploy()and every existing method are untouched. Modifypackages/plugins/k8s/src/index.ts— export the App renderer (pure functions, for APW-10's in-zone controller) and guard entry points. Test: extendpackages/plugins/k8s/src/__tests__/k8s.plugin.spec.ts—supportsApps, delegation, the guard runs on every App method, anAppTargetRefwith targetever-works-appsis refused, and a snapshot provingdeploy()renders the same manifests as before for the existing fixture (ACC-06-43). Done when:pnpm --filter @ever-works/k8s-plugin testis green. -
T15. Kind e2e. Create
packages/plugins/k8s/src/__tests__/e2e/app-runtime.e2e.spec.ts(new) per plan §12.1 using a non-root nginx image asweb, a busyboxworker, amigratecommand job, a first-deployhttpjob, anhttpcron every minute, a 100Mi PVC, smoke checks; second Deployment with a crashing command → rolled back; isolation reported not enforced; App Work deletion without data; a verification namespace. Allow-list127.0.0.1/32viaEVER_WORKS_APPS_CLUSTER_PRIVATE_ALLOWLISTfor kind. Modify.github/workflows/k8s-e2e.ymlonly if the new spec needs a longer job timeout (≤ 30 min). Test:pnpm --filter @ever-works/k8s-plugin test:e2eon kind — first Deployment Live with web, worker, migrate, first-deploy job, cron and volume (ACC-06-06); the loopback allow-list admits the kind API (ACC-06-03); the crashing Deployment rolls back and the Service still answers with the previous version (ACC-06-10); first-deploy Job completion precedes the Ingress creation and no first-deploy Job exists after the second Deployment (ACC-06-11); a changed env value restarts pods and an unchanged one does not (ACC-06-15); policies exist and isolation readsfalseon kindnet (ACC-06-17); the PVC survives redeploy, pause and remove-without-data (ACC-06-18); pause scales to 0 within 120 s (ACC-06-35); remove keeps the PVC (ACC-06-36); App Work deletion keeps the PVC andew-default-denyand removes every workload (ACC-06-45); the verification namespace has no Ingress or PVC and is gone after destroy (ACC-06-48). Done when: thek8s-e2e.ymlworkflow is green andcluster.e2e.spec.tsis unchanged.
P1.3 — Data model
-
T16.
WorkDeploymentcolumns and states. Modifypackages/agent/src/entities/work-deployment.entity.ts— appendbuildId,componentStatuses,smokeResult,appTarget,appRender(plan §7.1); extendisTerminal()withROLLED_BACK,SUPERSEDED. Test:packages/agent/src/entities/__tests__/work-deployment.entity.spec.ts(create if absent) — columns nullable, no pre-existing column changed,isTerminaltruth table. Done when:pnpm --filter @ever-works/agent test -- work-deployment.entityis green andpnpm --filter @ever-works/agent buildis clean. -
T17.
WorkAppRuntimeStateentity and repository. Createpackages/agent/src/entities/work-app-runtime-state.entity.ts(new) (plan §7.2 — nolicenseAttestationcolumn, R-3; deletion columns for R-15; scope columns without relation decorators) andpackages/agent/src/database/repositories/work-app-runtime-state.repository.ts(new):getOrCreate(workId),claimDeployLock(workId, deploymentId, staleAfterS),releaseDeployLock(workId, deploymentId),setQueued(...),selectForHealthPoll(limit),recordHealth(...),saveSnapshot(...),claimDeletion(workId, opts),recordDeletionAttempt(workId). Added by the 2026-09-17 fix pass (plan §7.2). The entity also carriescancelRequestedAt/cancelRequestedByUserId(the flaghooks.isCancelled()reads, cleared byreleaseDeployLockin the same UPDATE),pendingDomainRebuildBuildId(with an atomicclearPendingDomainRebuild(workId, buildId)) andupstreamSyncJudgedToSha.claimDeployLockrequirespaused = falseanddeletionRequestedAt IS NULLin addition to the null lock.prepare-namespace(T69) and §5.6 both persistnamespace/clusterFingerprint; acluster-checknever writes them. FR-63 — derive the target on first read (this closes APW-01's recorded cross-epic requirement; without it a Work created for Your cluster staysnoneand refuses to deploy).getOrCreate(workId)must settargetfrom the Work's creation-time choice:your-clusterwhen the Work's persisteddeployProvidernames a deployment plugin withsupportsApps === true, the managed target when it isever-works-apps, elsenone. Never leave the column's default in place for a Work that was created with a target, and never overwrite a target the owner has since changed. Modifypackages/agent/src/entities/index.ts,packages/agent/src/database/_entity-names.ts,packages/agent/src/database/_entities-inventory.ts— register the entity next toWorkDeployment. Test:packages/agent/src/database/repositories/__tests__/work-app-runtime-state.repository.spec.ts(new) — lock claim is atomic under two concurrent claims (one wins), stale lock reclaimed after 7 260 s, release only by the holder;claimDeletionrefuses while a deploy lock is held and succeeds once; the entity metadata has no column namedlicenseAttestation(ACC-06-39); andgetOrCreatederivesyour-clusterfor a Work whosedeployProvideris asupportsAppsplugin,ever-works-appsfor the managed provider,noneotherwise, and does not clobber a target that was already set. Done when:pnpm --filter @ever-works/agent test -- work-app-runtime-state.repositoryis green and the database drift specs pass without editing their counts by hand beyond the new entity. -
T18. Migrations. Create
apps/api/src/migrations/1792060000000-ExtendWorkDeploymentsForApps.tsandapps/api/src/migrations/1792060100000-CreateWorkAppRuntimeStates.ts(new) (plan §7.3), generated withcd apps/api && pnpm typeorm migration:generate …and reviewed by hand. Test:apps/api/src/migrations/__tests__/ExtendWorkDeploymentsForApps.spec.tsandapps/api/src/migrations/__tests__/CreateWorkAppRuntimeStates.spec.ts—up()has noDROP/rename of pre-existing columns;down()drops only whatup()created; existing rows readbuildId = null; the runtime-state table carriescancelRequestedAt,cancelRequestedByUserId,pendingDomainRebuildBuildIdandupstreamSyncJudgedToSha, and rows written before the column existed readnullfor all four. Done when:pnpm --filter ever-works-api test -- ExtendWorkDeploymentsForApps CreateWorkAppRuntimeStatesis green and a fresh database and one with existingwork_deploymentsrows both migrate.
P1.4 — Agent services
-
T19. Config. Modify
packages/agent/src/config/index.ts— addeverWorks.apps(getDomain,getMaxPerUserdefault 3,getDnsZoneId,getDnsApiToken,isClusterWorkerIsolated,getClusterPrivateAllowlist) with the apps-domain relation validation of plan §8.3 (P1 per R-16). Test: extendpackages/agent/src/config/config.spec.ts— apps domain equal to / under / parent ofEVER_WORKS_DOMAIN→getDomain() === null(ACC-06-27); invalid CIDR entries dropped with a warning and a valid one returned (ACC-06-03). Done when:pnpm --filter @ever-works/agent test -- config.specis green and unset env keeps every getter at its documented default. -
T20.
AppRuntimeFacadeService. Createpackages/agent/src/facades/app-runtime.facade.ts(new) — resolves, throughPluginRegistryServiceand by capability only (R-5): foryour-clusterthe deployment plugin for the Work'sdeployProviderwithisAppDeploymentPluginand withoutapps-tier; forever-works-appsthe enabled deployment plugin withisAppDeploymentPluginandapps-tier, only whileAppsTierPolicy.isOpen(). Resolves the credential per plan §5.6 step 3 (custom-kubeconfigonly foryour-cluster;AppsTierPolicy.resolveClusterCredentialonly forever-works-apps). Added (APW06-G02). It is constructed in every process that importsFacadesModule, so it cannot refuse at construction: every method call throwsAPP_CLUSTER_IO_IN_APIunlessisAppClusterWorkerContext()is true. Createpackages/agent/src/app-runtime/worker-context.ts(new) exportingmarkAppClusterWorkerContext()andisAppClusterWorkerContext()— a process-level flag, not an env var. Only theTriggerAppRuntimeModulebootstrap provider (T71) calls the marker, inonModuleInit. Modifypackages/agent/src/facades/facades.module.tsandpackages/agent/src/facades/index.tsto provide it. Test:packages/agent/src/facades/__tests__/app-runtime.facade.spec.ts(new) — constructing it outside the worker is allowed and the first call throwsAPP_CLUSTER_IO_IN_API(ACC-06-04); the API module graph never imports the marker provider (a static import scan overapps/api/src); never readsEVER_WORKS_K8S_WORKS_KUBECONFIG,EVER_WORKS_K8S_WORKS_SHARED_KUBECONFIGorEVER_WORKS_APPS_MANAGED_ENABLED(env spies); refusesk8s-works-sharedsettings for kindapp;validateClusterSourceForOwnercalled with the data-repository owner; forever-works-appstheapps-tierplugin receives the tier credential and thek8splugin spy receives nothing (ACC-06-49). Done when:pnpm --filter @ever-works/agent test -- app-runtime.facadeis green and no'k8s'string literal is added outsidepackages/plugins/k8s/. -
T21. Preconditions and license gate. Status 2026-09-26: Status notes. Create
packages/agent/src/app-runtime/app-deploy-preconditions.service.tsandpackages/agent/src/app-runtime/app-license-gate.ts(new) per plan §5.1–§5.2 — the license gate readsAppLicenseService.getHostingEligibility(workId)(APW-03; typed fake until it lands) and stores nothing (R-3). Test:packages/agent/src/app-runtime/__tests__/app-deploy-preconditions.service.spec.ts— one test per precondition code; two unset required values and a provisioning dependency produce three named entries and no dispatch; no green Build for the head returnsno_green_build_for_headwithlatestGreenBuildId(ACC-06-19); the App spec is read at the Build's commit, not the latest applied commit (ACC-06-20); targetnone→target_none(ACC-06-01); a pending dependency gives exactly onedependency_not_readyentry naming it and exactly oneAppDependenciesService.ensureReadyForDeploycall, which is what dispatches provisioning (GAP-05, ACC-06-54); strategydockerfile/autorequires a green Build while strategyimagedoes not and never yieldsno_green_build*,nothing_to_deployis returned for strategynone, andimage_not_pinnedis returned only onever-works-appsfor a tag-only reference (FR-64, ACC-06-52/-53); an entry sourced fromdomains.primary.*with no primary host yieldsprimary_domain_missingnaming it, with theprimary_url_inclusterwarning rather than a refusal (GAP-09); a dispatcher that resolvesnullor lacksdispatchApp*yieldsworker_not_isolatedwith noWorkDeploymentrow (APW06-G02, ACC-06-55).packages/agent/src/app-runtime/__tests__/app-license-gate.spec.ts—yourCluster: 'attestationRequired'→license_attestation_missing;managedreason (amber without agreement, red) →license_blocks_target; a changed eligibility after a license change re-requires attestation; the gate never writes to any repository (ACC-06-39). Done when:pnpm --filter @ever-works/agent test -- app-deploy-preconditions.service app-license-gateis green and the service never throws for an unmet precondition. -
T22. Render input builder. Create
packages/agent/src/app-runtime/app-render-input.builder.ts(new) — spec at Build commit, env viaAppRuntimeEnvSource(withbuildCommitSha,internalUrls, primary URL/host), pull credential viaAppImagePullCredentialSource, dependency egress resolved to/32or/128CIDRs, hosts from T26, policy. Added (APW06-G08 / APW06-G04). It passestarget: AppDeployTargetintoAppRuntimeEnvSource.resolve; forbuild.strategy: imageit takes the image from the spec atspecCommitSha, passesbuildCommitSha: nulland never callsAppImagePullCredentialSource; for every other strategy it passes the Build's commit and resolves the pull credential. Test:packages/agent/src/app-runtime/__tests__/app-render-input.builder.spec.ts(new) — never includesGH_TOKEN,PLATFORM_API_SECRET_TOKEN,PLATFORM_SYNC_SECRETor any key the env source did not return; the image pull block equals exactly the port's credential and the owner's Git token sentinel appears nowhere in the input (ACC-06-16); image reference andspecCommitShacome from the same Build (ACC-06-20);targetreaches the env source for every target; under strategyimagethe pull-credential port records zero calls,buildCommitShaisnulland the reference is the spec's own (ACC-06-52);DeployService.collectServerSideRuntimeEnvandresolveGhcrReadTokenare not called (spies). Done when:pnpm --filter @ever-works/agent test -- app-render-input.builderis green and the builder has no dependency onapps/api. -
T23. Public smoke service. Create
packages/agent/src/app-runtime/app-public-smoke.service.ts(new) — requests with manual redirects, 1 MiB body cap, classificationdns_not_pointing/tls_not_ready/unreachable/check_failed, windows 600 s / 180 s, retry every 10 s. Test:packages/agent/src/app-runtime/__tests__/app-public-smoke.service.spec.ts(new) — local HTTPS server with a mismatched certificate →tls_not_ready; resolver returning another address →dns_not_pointing, both reported as warnings not failures (ACC-06-13); body mismatch →check_failedwith the found string ≤ 200 chars. Done when:pnpm --filter @ever-works/agent test -- app-public-smoke.serviceis green and no response body beyond 200 characters leaves the service. -
T24. Deploy request service. Create
packages/agent/src/app-runtime/app-deploy-request.service.ts(new) — preconditions, lock claim, latest-wins queue (SUPERSEDED),WorkDeploymentcreation, dispatch; manual vs Build-triggered vsspec-appliedvs domain-change vs rollback (skipPreDeployJobsdefault true); cluster-change confirmation; refuses whiledeletionRequestedAtis set. Added (APW06-G02, APW06-G04). The request body accepts{ buildId?, specCommitSha?, confirmClusterChange? }; sendingbuildIdfor strategyimagereturns400 build_not_applicable; a strategy-imagerequest creates the row withbuildId: nulland the spec commit, and its queue entry keepsqueuedBuildIdnull; a queued request from the other strategy isSUPERSEDED. The dispatcher is checked before the lock claim: when it resolvesnull,isEnabled()is false or the active runtime lacksdispatchApp*, the request returns422 worker_not_isolatedand creates no row (APW06-G02). Test:packages/agent/src/app-runtime/__tests__/app-deploy-request.service.spec.ts(new) — second manual request →APP_DEPLOY_IN_PROGRESS; three Build-triggered requests during one run → one queued, oneSUPERSEDED(ACC-06-21); a rollback request carries the old Build (or the recorded digest and spec commit under strategyimage) and its commit andskipPreDeployJobs: true(ACC-06-23); request budget ≤ 2 s with a slow dispatcher mocked at 5 s; deleting App Work →app_work_deleting; a missing dispatcher →worker_not_isolated, zero rows, zero cache entries (ACC-06-55). Done when:pnpm --filter @ever-works/agent test -- app-deploy-request.serviceis green and the service never calls the plugin. -
T25.
app-deployorchestrator. Createpackages/agent/src/app-runtime/app-deploy.orchestrator.ts(new) per plan §5.6 (states, hooks, snapshot, first-deploy bookkeeping per cluster fingerprint, lock release, dequeue, event emission order). Added by the 2026-09-17 fix pass. It resolves its credential throughAppRuntimeTargetResolver(T69), emits throughAppRuntimeEventSink(T70) rather thanEventEmitter2, resolves animage-strategy Deployment throughAppImageReferenceResolver(T72) beforeprepare, calls APW-07'sonAppRemovedon both removal paths (plan §5.6 step 8, T70'sremoveop), and runs the upstream-sync verdict of plan §5.6 step 9 (APW06-G10) after the terminal andapp.smoke.*events:APP_PROVISION_EVENTS_PORT@Optional(),upstreamSyncJudgedToShaset whether the Deployment passed or failed,smokeFailedAfterUpstreamSynccalled only when the Deployment emittedapp.smoke.failed(in-cluster failure endingROLLED_BACK/ERROR, or a publiccheck_failed— neverdns_not_pointing,tls_not_readyorunreachable), rollback Deployments skipped, and a throwing port never delaying lock release. Test:packages/agent/src/app-runtime/__tests__/app-deploy.orchestrator.spec.ts(new) — outcome → state mapping table (rolled-back→ROLLED_BACK,succeeded-with-warnings→READY+ warnings); lock released on every outcome including thrown errors; queued Build requested after release; event orderstarted → job.* → terminal → smoke.*;rollback-failedtriggers the urgent notification producer (ACC-06-24); a thrown plugin error endsERROR (worker_failed); a cancelled or quarantined result is storedCANCELEDwithappRender.cancelledBy: 'quarantined'(APW06-G05, ACC-06-55); the upstream verdict's eight cases (fast-forward range plus failing smoke → one call; the sametoShatwice → one call; pass-then-fail → none; publiccheck_failedon aREADY+ warnings Deployment → one;tls_not_readyalone → none; rollback → none;isAncestorCommitnull and commit ≠toSha→ none; an unbound or throwing port leaves state and lock release unchanged) (APW06-G10); under strategyimagethe resolver runs once beforeprepareand a 404/401/timeout endsERRORwith its own code (ACC-06-52). Done when:pnpm --filter @ever-works/agent test -- app-deploy.orchestratoris green and no outcome leaves a held lock. -
T26. Hosts and domains. Create
packages/agent/src/app-runtime/app-hosts.service.tsandpackages/agent/src/app-runtime/app-domains.service.ts(new) per plan §8.1, §8.2, §8.4 (custom domains, primary order custom-then-managed, URL scheme per TLS mode). Modifypackages/agent/src/facades/deploy.facade.ts— early kind-appbranch ingetDomains,addDomain,removeDomain,verifyDomaindelegating toAppDomainsService. Test:packages/agent/src/app-runtime/__tests__/app-hosts.service.spec.ts(new) — unverified domain never inhosts; verify →ingress-reconciledispatched with no Deployment requested (ACC-06-25); primary change withrestartrequests a Deployment of the current Build and withrebuildcallsAppBuildsService.requestRebuildand stores the returned id inpendingDomainRebuildBuildId, while a rate-limited or blocked request stores nothing and requests no Deployment (ACC-06-26, APW06-G11); under strategyimagerebuildbehaves asrestartwith therebuild_not_applicablewarning (ACC-06-52). The custom-domain verify path usesruntimeState.ingressAddress, whichcheckAppClusternow records before the first Deployment (GAP-09).packages/agent/src/app-runtime/__tests__/app-domains.service.spec.ts(new) — DNS guidanceAfor an IP andCNAMEfor a hostname.packages/agent/src/facades/__tests__/deploy.facade.spec.tsstays green unchanged. Done when:pnpm --filter @ever-works/agent test -- app-hosts.service app-domains.service deploy.facadeis green andmergeCustomDomainHostsis untouched. -
T27. Health service. Create
packages/agent/src/app-runtime/app-health.service.ts(new) per plan §9.3. Test:packages/agent/src/app-runtime/__tests__/app-health.service.spec.ts(new) — 4 failing polls → no notification, 5th → one; failures for 7 h → 2 notifications; 3 passes → recovery only after a failure notification (ACC-06-32); 10 unreachable →unreachablenotdownwith one notification (ACC-06-33); paused and deleting App Works skipped; per-cluster concurrency 5. Done when:pnpm --filter @ever-works/agent test -- app-health.serviceis green and a single poll never exceeds 20 s (fake timers). -
T28. Events and Activity. Create
packages/agent/src/events/app-runtime.events.ts(new); Modifypackages/agent/src/events/index.ts. Createapps/api/src/app-runtime/app-runtime-event-relay.service.ts(new) and its worker-facing proxy (APW06-G02):emit(name, payload)accepts only names in theapp.*catalogue, runs the ACC-06-41 forbidden-key check and re-emits throughEventEmitter2, so the existing listener writes Activity unchanged; an unknown name is refused. Register it inapps/api/src/trigger/trigger-internal.controller.tsand inpackages/tasks/src/trigger/worker/modules/trigger-internal.module.ts(createRemoteProxy). Modifypackages/agent/src/entities/activity-log.types.ts—APP_DEPLOY = 'app_deploy',APP_JOB = 'app_job',APP_SMOKE = 'app_smoke',APP_HEALTH = 'app_health'(R-2). Modifyapps/api/src/activity-log/activity-log.listener.ts— one@OnEventper event in plan §9.4 withaction= the dotted name andactionType= the family. Test:packages/agent/src/app-runtime/__tests__/app-runtime.events.spec.ts(new) — payload types have novalue/env/log/kubeconfig/tokenfields (compile-time + runtime key check with sentinel values) (ACC-06-41); the relay refuses an unknown name and writes one Activity row for a known one (APW06-G02); extendapps/api/src/activity-log/activity-log.listener.spec.ts— each event writes its familyactionType, neverdeployment; switching isolation off records a warning row (ACC-06-17). Done when:pnpm --filter @ever-works/agent test -- app-runtime.eventsandpnpm --filter ever-works-api test -- activity-log.listenerare green; Activity summaries name components/jobs/checks only. -
T29. Notifications. Modify
packages/agent/src/notifications/core-event-catalogue.ts—app_deploy_failed,app_unhealthy,app_recovered,app_cluster_unreachable(plan §9.4). Modifypackages/agent/src/notifications/notification.service.ts— the four producers withdeduplicationKeyapp-health:<workId>/app-deploy:<deploymentId>andactionUrlto the Deploy tab. Test:packages/agent/src/notifications/__tests__/event-registry-coverage.spec.tsgreen;packages/agent/src/notifications/__tests__/app-runtime-notifications.spec.ts(new) — a rollback-failed producer call creates an urgent notification with the Deploy tab link (ACC-06-24); dedupe keys as stated. Done when:pnpm --filter @ever-works/agent test -- event-registry-coverage app-runtime-notificationsis green and urgent rows ship in-app + email by the catalogue's defaults rule. -
T30 (parallel with T29). Source offer. Create
packages/agent/src/app-runtime/app-source-offer.ts(new) —requiredand the URL from APW-03'sgetHostingEligibility(workId).sourceOffer(the shared C3 condition: obligation and (link or ahead > 0)), the deployed commit substituted into the URL,license.sourceOfferUrlwhen private;privateWithoutUrlwarning. Test:packages/agent/src/app-runtime/__tests__/app-source-offer.spec.ts(new) — truth table of obligation × link × ahead × private × url; the URL targets the deployed commit, not the branch head (ACC-06-30). Done when:pnpm --filter @ever-works/agent test -- app-source-offeris green andEVER_WORKS_SOURCE_URLis set only when the offer applies.