Taak 2.5 — Provisioning van tenant-sites (plan)
A ledger row in provisioning becomes a Postgres database, a Coolify app, runtime env, and a triggered deploy. The operator watches that on the tenant detail page. The visitor still has no preview hostname. That wait is task 2.6. The program keeps ciphertext out of every browser JSON body and retries to the same end state. Planned PRs in order are T25-1, T25-2, T25-3, T25-4, and T25-5.
How to read this
Section titled “How to read this”One box is one unit of work. Every box names the evidence that checks it. A nested box is a sub-step of the box above it. Check a box only when its evidence exists, a file, a log line, a screenshot, a test run, or a SHA. The body is a how-to. The appendices explain and record.
The program runs pstack/skills/poteto-mode/playbooks/autopilot-stack.md. The operator lands T25-1 through T25-5 bottom-up. Owners stop at merge-ready.
Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
Program checklist
Section titled “Program checklist”Arm the program
Section titled “Arm the program”- State the protocol and this plan to the operator, then stop. Start execution only on her explicit go.
- On her go, arm a
/goalwith this exact text. “Taakplan 2.5, T25-1 T25-2 T25-3 T25-4 T25-5, a PR is verified only when its unit, live, and perf boxes are all checked, the operator lands the stack, done when aprovisioningrow without Coolify config still gets a database and migrations, and with Coolify config also gets an app uuid, env, and adeploymentsrow.” - Read these from trunk at program start. Re-read them at every tick.
-
git show origin/main:pstack/skills/poteto-mode/playbooks/autopilot-stack.md -
git show origin/main:pstack/skills/swarm/SKILL.md -
git show origin/main:pstack/skills/control-ui/SKILL.md -
git show origin/main:pstack/skills/control-cli/SKILL.md -
git show origin/main:pstack/skills/poteto-mode/playbooks/opening-a-pr.md -
git show origin/main:pstack/skills/principle-make-operations-idempotent/SKILL.md -
git show origin/main:pstack/skills/principle-model-the-domain/SKILL.md
-
- Arm the 30-minute audit tick. In a local session, a real terminal
/loop. In a cloud root, a cloud-sleeper wake chain. Never leave the cadence to memory. - Use this tick prompt, verbatim. “Re-read the execution playbook from trunk and the armed /goal. Audit the operation against both and fix drift in this tick. Probe every active lane and judge progress by side effects only. Stand down a stuck lane and dispatch its replacement now. Then send the operator a status message, whether or not anything changed, with the queue table of PR, owner, state, and head SHA, the verdicts since the last tick, what merged, open operator gates, and blockers.”
- On the operator’s hold or stand-down, send every owner a zero-writes order at once.
Spawn owners
Section titled “Spawn owners”- Spawn one owner per PR with the full lifecycle the execution playbook names.
- Follow this dependency graph. Start dependent work only after its parent merges, or base it on the parent branch when the execution playbook stacks.
- T25-1 is first. It branches from
main. - T25-2 after T25-1.
- T25-3 after T25-2.
- T25-4 after T25-3.
- T25-5 after T25-4.
- T25-1 is first. It branches from
- Hold the file boundaries. T25-1 touches only
packages/ops/**,packages/db/scripts/**,packages/db/src/migrate-tenant.ts, andscripts/provision-tenant-db.sh. T25-2 touches master schema, contract docs, and webhook verify. T25-3 touches only the Coolify client and its tests. T25-4 touches Nitro provision routes,provision-tenant.ts, andprovision-tenant-migrate.ts. T25-5 touchesapps/master-dashboard/app/pages/tenants/**and copy in Taken. - Hold the review gate. T25-5 changes an interaction. It waits for the operator’s review in chat with screenshots and a video before merge.
PR mechanics, for every PR
Section titled “PR mechanics, for every PR”- Resolve the forge once. Default to
gh; ifcommand -v originsucceeds and Origin can resolve the repository, useorigin prfor every PR operation. Record any fallback togh. Never requiregt. - Open the PR ready, never draft, with
origin pr create --status open --base <base-branch>orgh pr create --base <base-branch>according to the resolved forge. A stack child targets its parent branch. - Run the repo’s lint and typecheck once before the PR-facing push. Push with hooks on.
- Run
/deslopbefore each commit and/no-commentsbefore review. - Triage every Bugbot and security-reviewer comment per
../references/bugbot-triage.md. - Rebase onto current trunk before babysit and again before the merge-ready report.
Verdict and merge, for every PR
Section titled “Verdict and merge, for every PR”- At the merge-ready head SHA, run the swarm per
pstack/skills/swarm/SKILL.md. One gates lane. The ten live lanes from the PR’s Verify, live block. The perf lane from its Verify, perf block. One audit lane that reads the diff and the receipts and distrusts the PR body. - Clean only when every lane is
PASS. Findings go back to the owner. A new head gets a fresh swarm and a fresh verdict. - The root appends the PR to the base-branch stack. The operator lands it bottom-up. Compare
git patch-idafter rebase perplaybooks/shipping.md.
Boot recipe, for every live lane
Section titled “Boot recipe, for every live lane”Each live lane runs on its own cloud VM at the PR head. Drive through control-ui or control-cli from cursor-team-kit.
-
git fetch origin <head-branch> && git checkout <head SHA>. - Start Compose Postgres on port 5432 if it is down. For T25-4 and T25-5 start
pnpm --filter master-dashboard run devand wait untilGET http://localhost:3000/loginreturns 200. For T25-1 runpnpm --filter @platform/ops run typecheckbefore the probe. - Deliver input only through the control skill’s commands. Name the read-only diagnostics.
- Save every screenshot to
/tmp/swarm-<pr-id>/worker-<n>/<slug>.pngand return the paths with the report.
Apply tenant database create and migrate (T25-1)
Section titled “Apply tenant database create and migrate (T25-1)”Depends on. None.
Files.
- Create
packages/ops/src/provision-tenant-db.ts. - Create
packages/ops/src/provision-tenant-db.test.ts. - Edit
packages/ops/src/index.ts. - Edit
packages/ops/package.json. - Create
packages/db/src/migrate-tenant.ts. - Edit
packages/db/scripts/migrate.mjs. - Edit
scripts/provision-tenant-db.sh. - Edit Operations.
Build.
- Add
provisionTenantDatabase({ adminUrl, tenantId, password })inpackages/ops/src/provision-tenant-db.ts. The SQL matchesscripts/provision-tenant-db.sh(CREATE ROLEandCREATE DATABASEonly when missing, thenALTER SCHEMA public OWNERandREVOKE ALL ON DATABASE … FROM PUBLIC). If the role already exists, leave its password unchanged. A retry must reuse the stored DSN, not mint a second password. - Return a percent-encoded
DATABASE_URL. Generated passwords contain+,/, and=. Encode the userinfo or latergetDbfails with an auth error that looks unrelated. - Add
applyTenantMigrations({ databaseUrl, migrationsFolder })inpackages/db/src/migrate-tenant.ts. Pointmigrate.mjsat that function so Coolify and the library share one path. - Make
scripts/provision-tenant-db.shcall the ops CLI. Do not keep a second SQL copy.
You see.
-
DATABASE_URL=… pnpm --filter @platform/db run migratestill printsMigrations applied successfullyontenant_demo. - A second migrate on the same database prints the same line and leaves
drizzle.__drizzle_migrationsat count 3. -
provisionTenantDatabaseagainst a role withCREATEROLEcreatestenant_<slug>twice with exit 0. This VM’splatformrole cannot create roles. Record that skip with the SQL error text if the lane has no superuser.
Verify, unit. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
-
packages/ops/src/provision-tenant-db.test.tscovers missing-role create, existing-role skip withoutALTER PASSWORD, percent-encoding of+in the DSN, and invalid slug. Runpnpm --filter @platform/ops exec node --test src/provision-tenant-db.test.ts.
Verify, live. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. Ten lanes on grok-4.6-fast-xhigh at the PR head, per the boot recipe.
- Lane 1. Regression lane against trunk. Run
DATABASE_URL=postgresql://platform:platform@localhost:5432/tenant_demo pnpm --filter @platform/db run migrateat trunk and head. If trunk lacks the feature, record that and gate the same migrate success plus the log lineMigrations applied successfully. Savet25-1-migrate-regression.png. Pass when both sides print that line andto_regclass('public.site')issite. - Lane 2. Second migrate on
tenant_demo. Savet25-1-migrate-idempotent.png. Pass when the command exits 0 and the migrations count stays 3. - Lane 3. Invalid slug
1badthroughprovisionTenantDatabase. Savet25-1-bad-slug.png. Pass when the function throws the contract slug message and noCREATEruns. - Lane 4. Shell wrapper
./scripts/provision-tenant-db.shwithPGDATABASE=postgres. Savet25-1-shell.png. Pass when the wrapper reaches the TS entry and does not embed a secondCREATE ROLEstring. - Lane 5.
pnpm --filter @platform/ops run typecheck. Savet25-1-ops-typecheck.png. Pass when exit is 0. - Lane 6.
pnpm --filter @platform/db run typecheck. Savet25-1-db-typecheck.png. Pass when exit is 0. - Lane 7.
pnpm turbo run typecheck. Savet25-1-turbo-typecheck.png. Pass when the pipeline is green. - Lane 8. Read
packages/db/scripts/migrate.mjs. Savet25-1-migrate-mjs.png. Pass when the file callsapplyTenantMigrationsand does not inlinemigrate()from drizzle. - Lane 9. Attempt
provisionTenantDatabaseasplatform. Savet25-1-createrole-denied.png. Pass when the error ispermission denied to create roleor a createdtenant_*database exists. Do not leave a stray database. Drop it with the admin user if one was created. - Lane 10. Confirm
tenant_demostill serves the demositerow after the probes. Savet25-1-demo-intact.png. Pass whenSELECT name FROM sitestill returns the demo name.
Verify, perf. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
- Metric. Wall time of
pnpm --filter @platform/db run migrateon already-migratedtenant_demoat trunk and head. The diff-added work is the extra function call inmigrate.mjs. The user waits for the success log line. - Probe.
/usr/bin/time -f %earound that migrate, trunk then head, interleaved once each. - Baseline. Record the trunk seconds first.
- Rule. Head must stay under 5 seconds absolute. Head must not exceed trunk by more than 1.0 second. Fail if either bound breaks.
Review gate. None. T25-1 is not review-gated.
Merge.
- Root’s clean verdict at the exact head SHA.
- Bugbot triage done.
- Rebased onto current trunk after the verdict, patch-id unchanged.
- The root appends T25-1 to the stack. The operator squash-merges it onto
main.
Store per-tenant secrets encrypted (T25-2)
Section titled “Store per-tenant secrets encrypted (T25-2)”Depends on. T25-1.
Files.
- Edit
packages/db/src/master/schema.ts. - Create the generated files under
packages/db/drizzle-master/frompnpm --filter @platform/db run generate:master. - Edit
apps/master-dashboard/server/utils/secrets.tsonly if the format must name more thandatabase_url_encryptedin the comment. - Edit
apps/master-dashboard/server/api/webhooks/site.post.ts. - Edit
apps/master-dashboard/server/utils/receive-site-webhook.ts. - Edit
apps/master-dashboard/server/utils/webhooks.test.ts. - Edit
apps/master-dashboard/server/utils/tenant-store.ts. - Edit Datamodel.
- Edit Contract.
Build.
- Add nullable
textcolumnswebhook_secret_encrypted,platform_api_key_encrypted, andbetter_auth_secret_encryptedontenants. KeepencryptSecretanddecryptSecretas the only codec. - Resolve the HMAC secret in
site.post.tsbyX-Platform-Tenantslug. Use the decrypted tenant secret when the column is set. Fall back toNUXT_PLATFORM_WEBHOOK_SECRETwhen the column is null so existing sites keep working. - Keep
platformTenantSchemawithout those columns.getTenantListItemmust not select them.
You see.
-
pnpm --filter @platform/db exec drizzle-kit check --config drizzle.master.config.tsis green. - A webhook with a per-tenant secret returns 200. The same body with only the shared env secret returns 401 once the column is set.
-
GET /api/tenantsJSON has noencryptedsubstring.
Verify, unit. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
-
apps/master-dashboard/server/utils/webhooks.test.tsplus a receive helper test for tenant-secret lookup and shared-secret fallback. Runpnpm --filter master-dashboard exec node --test server/utils/webhooks.test.ts server/utils/receive-site-webhook.integration.ts.
Verify, live. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. Ten lanes on grok-4.6-fast-xhigh at the PR head, per the boot recipe.
- Lane 1. Regression lane against trunk. POST a valid HMAC to
/api/webhooks/sitewithNUXT_PLATFORM_WEBHOOK_SECRETat trunk and head. If trunk lacks the feature, record that and gate HTTP 200 plus oneactivityrow. Savet25-2-webhook-shared.png. Pass when status is 200 on both and the payload type is unchanged. - Lane 2. Insert ciphertext with
encryptSecretintowebhook_secret_encryptedfor a test slug. POST with that plaintext. Savet25-2-webhook-tenant.png. Pass when status is 200. - Lane 3. Same row, POST with only the shared env secret. Save
t25-2-webhook-tenant-reject.png. Pass when status is 401. - Lane 4. GET
/api/tenantsas a session. Savet25-2-list-no-secrets.png. Pass when the body has nodatabaseUrlEncrypted,webhook_secret, orencrypted. - Lane 5. GET
/api/tenants/:slugas a session. Savet25-2-detail-no-secrets.png. Pass when the same strings are absent. - Lane 6. Duplicate
eventIdstill 200 without a secondactivityrow. Savet25-2-idempotent.png. Pass whenwebhook_receiptshas one row for that id. - Lane 7.
pnpm --filter @platform/db exec drizzle-kit check --config drizzle.master.config.ts. Savet25-2-drizzle-check.png. Pass when exit is 0. - Lane 8.
pnpm turbo run typecheck. Savet25-2-typecheck.png. Pass when exit is 0. - Lane 9. Tamper the ciphertext tag then POST. Save
t25-2-tamper.png. Pass when decrypt throws and the handler returns 500 or 401 without writingactivity. - Lane 10. Null secret column still accepts the shared env secret. Save
t25-2-fallback.png. Pass when status is 200.
Verify, perf. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
- Metric. Time from webhook POST to HTTP status at trunk (shared secret) and head (shared secret, then tenant secret). The diff-added work is one decrypt plus one extra SELECT. The user waits for the 200.
- Probe.
curl -o /dev/null -s -w %{time_total}on/api/webhooks/site, trunk then head, interleaved. - Baseline. Record the trunk
time_totalfirst. - Rule. Head shared-secret path must stay under 200 ms. Tenant-secret path must stay under 250 ms. Fail if either bound breaks.
Review gate. None. T25-2 is not review-gated.
Merge.
- Root’s clean verdict at the exact head SHA.
- Bugbot triage done.
- Rebased onto current trunk after the verdict, patch-id unchanged.
- The root appends T25-2. The operator squash-merges it.
Add a Coolify API client (T25-3)
Section titled “Add a Coolify API client (T25-3)”Depends on. T25-2.
Files.
- Create
apps/master-dashboard/server/utils/coolify.ts. - Create
apps/master-dashboard/server/utils/coolify.test.ts. - Edit
apps/master-dashboard/nuxt.config.ts. - Edit
apps/master-dashboard/.env.example.
Build.
- Add a typed client that talks to
/api/v1with a bearer token. Methods arecreateDockerfileGitApp,bulkSetEnvs,startApplication, andgetApplication. Parse JSON at the boundary. Trust the return type inside. - Create the app with git plus
build_pack=dockerfile,ports_exposes=4321,base_directory=/, health check path/robots.txt. Do not usePOST /applications/dockerfilewith inline file content. That endpoint stores Dockerfile text, not this repo. - When
coolifyTokenis empty, the client throws a namedCoolifyDisabledError. The orchestrator in T25-4 catches that.
You see.
-
node --testoncoolify.test.tspasses with a fakefetch. -
.env.examplelistsNUXT_COOLIFY_BASE_URL,NUXT_COOLIFY_TOKEN,NUXT_COOLIFY_PROJECT_UUID,NUXT_COOLIFY_SERVER_UUID,NUXT_COOLIFY_ENVIRONMENT_UUID,NUXT_COOLIFY_PRIVATE_KEY_UUID,NUXT_COOLIFY_GIT_REPOSITORY, andNUXT_COOLIFY_GIT_BRANCH.
Verify, unit. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
-
apps/master-dashboard/server/utils/coolify.test.tsstubsfetchfor create, bulk env, start, 401, and disabled token. Runpnpm --filter master-dashboard exec node --test server/utils/coolify.test.ts.
Verify, live. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. Ten lanes on grok-4.6-fast-xhigh at the PR head, per the boot recipe.
- Lane 1. Regression lane against trunk.
pnpm --filter master-dashboard exec node --test server/utils/webhooks.test.tsat trunk and head. If trunk lacks the feature, record that and gate the existing webhook tests plus the new file existing on head. Savet25-3-webhook-regression.png. Pass when webhook tests stay green. - Lane 2. Create-app mock returns a uuid. Save
t25-3-create-app.png. Pass when the client returns that uuid. - Lane 3. Bulk env mock sends runtime flags for
DATABASE_URLand build flags forS3_PUBLIC_URL. Savet25-3-bulk-env.png. Pass when the request body marks those keys correctly. - Lane 4. Start mock hits
POST /applications/{uuid}/start. Savet25-3-start.png. Pass when the method is POST. - Lane 5. Empty token throws
CoolifyDisabledErrorbeforefetch. Savet25-3-disabled.png. Pass when fetch call count is 0. - Lane 6. HTTP 401 from Coolify becomes a typed error. Save
t25-3-401.png. Pass when the message includes 401. - Lane 7.
nuxt typecheck. Savet25-3-typecheck.png. Pass when exit is 0. - Lane 8. Confirm no call to a real Coolify host in tests. Save
t25-3-no-network.png. Pass when the stub never used an http URL other than the fake base. - Lane 9. Health check path in the create payload is
/robots.txt. Savet25-3-health.png. Pass when that string is in the body. - Lane 10. Port
4321is inports_exposes. Savet25-3-port.png. Pass when that string is in the body.
Verify, perf. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
- Metric. Duration of the
coolify.test.tsfile at trunk (webhook file as stand-in if the client is missing) and head. The diff-added work is the mocked client suite. The user waits for the test process to exit 0. - Probe.
node --test server/utils/coolify.test.tstimed at head. Timewebhooks.test.tsat trunk first. - Baseline. Record the trunk webhook-test seconds first.
- Rule. Head client tests must finish under 2 seconds. Fail above that. Do not ratio unlike suites.
Review gate. None. T25-3 is not review-gated.
Merge.
- Root’s clean verdict at the exact head SHA.
- Bugbot triage done.
- Rebased onto current trunk after the verdict, patch-id unchanged.
- The root appends T25-3. The operator squash-merges it.
Run the provisioner from a session API (T25-4)
Section titled “Run the provisioner from a session API (T25-4)”Depends on. T25-3.
Files.
- Create
apps/master-dashboard/server/utils/provision-tenant.ts. - Create
apps/master-dashboard/server/utils/provision-tenant-migrate.ts. - Create
apps/master-dashboard/server/utils/provision-tenant.test.ts. - Create
apps/master-dashboard/server/api/tenants/[slug]/provision.post.ts. - Edit
apps/master-dashboard/nuxt.config.ts. - Edit
apps/master-dashboard/.env.example. - Edit Contract.
- Edit Deploy.
Build.
- Model the run as a list of named steps in one function. Steps are
database,migrate,secrets,coolifyApp,coolifyEnv,coolifyStart. Each step reads current columns, skips if the outcome already exists, then writes. Crash in the middle and the next POST continues. -
databasecallsprovisionTenantDatabasewithNUXT_POSTGRES_ADMIN_URL, encrypts the tenant DSN, stores it indatabase_url_encrypted. If that column is already set, skip create and keep the stored password. -
migratelives inprovision-tenant-migrate.ts. That file imports@platform/dbonly.provision-tenant.tsimports@platform/db/masteronly. Auth table names collide if one module loads both packages. -
migratedecrypts that DSN and callsapplyTenantMigrations. Do not wait for a Coolify container. That is how the first-deploy skip in Deploy stops being a blocker. -
secretsgeneratesBETTER_AUTH_SECRET,PLATFORM_API_KEY, andPLATFORM_WEBHOOK_SECRETwithopenssl-class randomness, encrypts them, and stores the three new columns. Generate each only when its column is null. -
coolifyAppcreates the app whencoolify_app_uuidis null. OnCoolifyDisabledError, stop after secrets, leavestatus=provisioning, writeactivitytypetenant.provision_partial. -
coolifyEnvbulk-sets runtime and build vars from Deploy.SITE_DOMAINis{slug}.okhema.studioeven without DNS.PLATFORM_WEBHOOK_URLis the master/api/webhooks/site. ThencoolifyStartand insertdeploymentsasrunning. - POST
/api/tenants/:slug/provisionrequiresrequireAdminSession. The 2.4 queue isstatus=provisioningandcoolify_app_uuidnull. Also acceptfailedso the detail button can retry. Return 409 ifstatusisactiveorsuspended. Return 202 with the detail payload. Do not put ciphertext in the body. - Do not import
tenants/*.json. Do not callbootstrapTenant. Do not callcreateCmsUser. Do not writedomains. Do not PATCH Coolify backup lists in this PR. Note that skip in Deploy.
You see.
- POST provision on a local row without Coolify token returns 202, fills
database_url_encrypted, and leavescoolifyAppUuidnull. - A second POST does not create a second database and still returns 202.
- GET detail never includes encrypted fields.
Verify, unit. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
-
provision-tenant.test.tscovers skip-when-present for each step, disabled Coolify, and reject ofactive. Runpnpm --filter master-dashboard exec node --test server/utils/provision-tenant.test.ts.
Verify, live. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. Ten lanes on grok-4.6-fast-xhigh at the PR head, per the boot recipe.
- Lane 1. Regression lane against trunk. POST
/api/tenantsstill creates a ledger row at trunk and head. If trunk lacks the feature, record that and gate 201 plusstatus=provisioning. Savet25-4-create-regression.png. Pass when the list contains the new slug. - Lane 2. POST
/api/tenants/:slug/provisionwithout session. Savet25-4-unauth.png. Pass when status is 401. - Lane 3. POST provision with Coolify disabled and a superuser admin URL if the lane has one. Save
t25-4-partial.png. Pass when 202,database_url_encryptedis non-null in SQL, and JSON has no encrypted keys. If the lane has noCREATEROLE, pass when the handler returns 500 withpermission denied to create roleand status staysprovisioning. - Lane 4. Second POST provision. Save
t25-4-retry.png. Pass when 202 andpg_databasestill has onetenant_<slug>. - Lane 5. POST provision on
status=active. Savet25-4-active-conflict.png. Pass when status is 409. - Lane 6.
activitycontainstenant.provision_partialortenant.provision_started. Savet25-4-activity.png. Pass when GET detail shows that type. - Lane 7.
pnpm turbo run typecheck. Savet25-4-typecheck.png. Pass when exit is 0. - Lane 8. Confirm
migrateran byto_regclass('public.site')on the new database when create succeeded. Savet25-4-schema.png. Pass when the value issite. Drop the probe database after the shot. - Lane 9. GET
/api/tenants/:slugstill lists emptydomains. Savet25-4-no-domains.png. Pass whendomainsis[]. - Lane 10. POST
/api/webhooks/sitewith the new tenant secret succeeds after secrets exist. Savet25-4-webhook-after.png. Pass when status is 200. If secrets were not written, pass when the handler still uses the shared secret and the lane log says so.
Verify, perf. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
- Metric. Time of POST
/api/tenants/:slug/provisionuntil 202 at head. Trunk has no route, so also time POST/api/tenantson trunk as the user-visible create they already wait for. The diff-added work is database plus migrate plus encrypt. - Probe.
curl -w %{time_total}for trunk create and head provision, interleaved. - Baseline. Record the trunk create
time_totalfirst. - Rule. Head provision without Coolify must finish under 30 seconds when
CREATEROLEworks. When it cannot create a role, the 500 must return under 2 seconds. Fail outside those bounds.
Review gate. None. T25-4 is not review-gated.
Merge.
- Root’s clean verdict at the exact head SHA.
- Bugbot triage done.
- Rebased onto current trunk after the verdict, patch-id unchanged.
- The root appends T25-4. The operator squash-merges it.
Show provision progress on tenant detail (T25-5)
Section titled “Show provision progress on tenant detail (T25-5)”Depends on. T25-4.
Files.
- Edit
apps/master-dashboard/app/pages/tenants/[slug].vue. - Edit Taken.
- Edit Contract.
- Edit Operations.
- Edit Deploy.
Build.
- Add a Provision button on the detail page when
statusisprovisioningorfailed. The click POSTs/api/tenants/:slug/provisionand refreshes detail. - Show
deployments[]that T25-4 inserts. Keep the empty copy when the list is empty. - Write
tenant.provision_failedintoactivitywhen the orchestrator throws after the handler started. Settenants.status=failedonly after a Coolify start error, not afterCoolifyDisabledError. - Tick 2.5 in Taken with what shipped and what stayed out (preview DNS, bootstrap JSON, CMS user, backup list, master Coolify deploy).
You see.
- An operator with a session clicks Provision and sees a 202 then an activity row without a reload of login.
- Ciphertext never appears in the rendered HTML.
- Taken 2.5 describes the partial local path.
Verify, unit. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
- No new test runner. Re-run
pnpm --filter master-dashboard exec node --test server/utils/provision-tenant.test.tsandpnpm --filter master-dashboard exec nuxt typecheck.
Verify, live. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. Ten lanes on grok-4.6-fast-xhigh at the PR head, per the boot recipe.
- Lane 1. Regression lane against trunk. Open
/tenants/[slug]for an existing fixture at trunk and head. If trunk lacks the feature, record that and gate the detail heading plus activity list plus the new Provision button on head. Savet25-5-detail-regression.png. Pass when the slug heading matches and login is not required twice. - Lane 2. Click Provision on a
provisioningtenant. Savet25-5-click.png. Pass when the button fires POST and the page still shows the slug. - Lane 3. After 202, activity mentions provision. Save
t25-5-activity.png. Pass when that row is visible. - Lane 4. Deployments table empty or one
runningorqueuedrow. Savet25-5-deployments.png. Pass when no invented hostname appears. - Lane 5. View source or network response of GET detail. Save
t25-5-no-ciphertext.png. Pass whenv1.blobs are absent. - Lane 6. Logged-out GET
/tenants/[slug]redirects to login. Savet25-5-auth.png. Pass when the login form is shown. - Lane 7. Mobile width 390px on the detail page. Save
t25-5-mobile.png. Pass when the Provision button stays inside the layout and does not overflow. - Lane 8.
failedtenant still shows Provision. Savet25-5-retry-failed.png. Pass when the button is present. - Lane 9.
activetenant has no Provision button. Savet25-5-active-hidden.png. Pass when the control is absent. - Lane 10. Logout still returns
/to login. Savet25-5-logout.png. Pass when the login page is shown.
Verify, perf. Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.
- Metric. Time to first byte of GET
/tenants/[slug]at trunk and head. The diff-added work is the extra button markup. The user waits for the slug heading. - Probe.
curl -o /dev/null -s -w %{time_starttransfer}with the session cookie, trunk then head, interleaved. - Baseline. Record the trunk
time_starttransferfirst. - Rule. Head must stay under 1.5 seconds and must not exceed trunk by more than 200 ms. Fail if either bound breaks.
Review gate. The operator reviews before merge.
- Copy lane 2 screenshots into
/tmp/swarm-T25-5/review/t25-5-review-click.png. - Record a 30 to 60 second video of the change on a lane VM. Save it as
/tmp/swarm-T25-5/review/t25-5-review.mp4. - Post the screenshots and the video in chat. Stop at merge-ready. Wait for the operator’s click.
Merge.
- Root’s clean verdict at the exact head SHA.
- Bugbot triage done.
- Rebased onto current trunk after the verdict, patch-id unchanged.
- The root appends T25-5. The operator squash-merges it after the review click.
Close the program
Section titled “Close the program”- Every box above is checked with its evidence.
- Reply to the operator with the report the execution playbook names.
Appendix A. Prototype evidence
Section titled “Appendix A. Prototype evidence”Idempotent migrate is proven on this VM. DATABASE_URL=postgresql://platform:platform@localhost:5432/tenant_demo pnpm --filter @platform/db run migrate printed Migrations applied successfully twice. drizzle.__drizzle_migrations stayed at 3. to_regclass('public.site') stayed site. No branch and no SHA. The command ran on the plan branch before this file existed.
CREATE ROLE is unproven here. platform has rolcreaterole=f. postgres peer auth failed. scripts/provision-tenant-db.sh without PGDATABASE=postgres also failed because psql used database platform, which does not exist. T25-1 and T25-4 lanes must bring a superuser DSN or accept the permission-denied pass.
Coolify HTTP is unproven. This VM has no Coolify token. Public docs list POST /api/v1/applications/private-github-app (and siblings), PATCH /api/v1/applications/{uuid}/envs/bulk, and POST /api/v1/applications/{uuid}/start. State-changing routes require POST. T25-3 tests stay on a fake fetch. A real create stays operator-gated until a token exists.
First Coolify deploy skip is already proven in Freeze-check and Deploy. The plan does not re-run that. T25-4 migrates through the tenant DSN instead of docker exec.
Appendix B. Alternatives rejected
Section titled “Appendix B. Alternatives rejected”A fifth tenant_status value for each step. The ledger already has provisioning and failed. Extra enum values fight 2.4’s PATCH rules. Columns plus deployments already say how far the run got.
docker exec of migrate.mjs after the first start. That is the freeze-check workaround. The provisioner already has the DSN. Migrate before the container exists.
POST /applications/dockerfile with file contents. Deploy builds from repo root and apps/site-template/Dockerfile. Git create matches that.
Run Coolify from @platform/ops. Ops stays free of HTTP clients except S3. Coolify is a Nitro adapter. Database SQL stays in ops because the shell script already lives there.
Call bootstrapTenant from JSON under tenants/. Architecture already says those files are content, not the ledger. 2.6 still needs a hostname. An empty migrated schema is enough for 2.5.
Block create until Coolify is configured. Task 2.5 may run locally for a while (Taken). Disabled Coolify is a first-class stop after secrets.
Update the Coolify per-database backup list in 2.5. Freeze-check asked for it. The public API for that list is not in the OpenAPI slice we fetched. Leave it as a documented operator step rather than a guessed PATCH.
Delete a tenant. 2.4 deferred it. Cascade vs a live database is a product call. This program does not delete.
Import @platform/db and @platform/db/master in one file. Table names users and sessions collide. Split migrate into its own module.
Auto-mark active when Coolify start returns. /robots.txt is healthy with an empty schema. Status stays provisioning or failed until an operator (or 2.6) decides the site is live.
Backfill coolify_app_uuid for pilot and rb-media inside 2.5. Those apps already exist. Recreating them is the wrong retry. Leave attach-existing as a later operator action, not a T25-4 step.
Appendix C. Risks
Section titled “Appendix C. Risks”T25-1 and T25-4 need CREATEROLE. Without it, live proof stops at the denied error. The owner must not weaken SQL to run as platform.
T25-3 may guess git fields (private_key_uuid, project_uuid). A wrong field fails only when a token appears. Keep the client strict. Map fields from one live GET of an existing app (pilot) during operator go, not during this plan.
T25-4 SITE_DOMAIN={slug}.okhema.studio without DNS. The container can be healthy on /robots.txt and still fail public TLS. Do not mark active in 2.5.
Master dashboard is not on Coolify yet. The provisioner is a local Nitro process until that loose Taken item ships. Long Coolify waits must not block the HTTP worker beyond start-accepted.
This repo has no pstack/ on main. The arm step’s git show origin/main:pstack/... will fail. Owners read the same skills from the Cursor plugin cache and record that fallback in the tick.
Shared NUXT_PLATFORM_WEBHOOK_SECRET fallback in T25-2 means a tenant with a new secret still accepts the old shared secret if the column is null. Once the column is set, shared-secret POSTs must 401. Do not leave mixed secrets on a live slug without a cutover note.
A second CREATE ROLE does not rotate the password. If database_url_encrypted is lost while the role remains, the next POST cannot guess the old password. Treat a missing ciphertext plus an existing role as failed with a clear activity payload, not a silent new password.
tenants/README.md and the tenants/ line in AGENTS.md still describe the pre-2.4 JSON ledger. Do not treat that directory as the customer register. Fix those two sentences in T25-5 docs if they still lie.
Appendix D. Links and reading list
Section titled “Appendix D. Links and reading list”Read Taken (2.5 and the local-master note), Deploy, Operations, Contract, Datamodel, Architectuur, Freeze-check section D, Taakplan 2.4 (ledger is not provision), packages/ops/README.md, scripts/provision-tenant-db.sh, packages/db/scripts/migrate.mjs, packages/db/src/master/schema.ts, apps/master-dashboard/server/utils/secrets.ts, apps/master-dashboard/server/api/tenants/index.post.ts, apps/master-dashboard/server/api/webhooks/site.post.ts.
T25-3 and T25-4 get pstack/skills/how/SKILL.md on the Coolify adapter and the step list. T25-4 gets pstack/skills/interrogate/SKILL.md if someone wants a queue table instead of the step list. Trail per pstack/skills/show-me-your-work/SKILL.md for the execution owners.