Plugin Version Upgrade Migration (Trinity Asset 1.1.0)

Applies to 26.08.1.1.0. Companion to the 1.1.0 release notes.

ap.http, Reels datasource and case service calls return 403 at runtime when a project is still
pinned to an older build of those three plugin modules. The 1.1.0 release notes require every project
to be upgraded by hand from Studio > Project > Config > Plugins. This migration does the same work
tenant-wide in one authenticated call.


It also covers places the Studio screen does not reach.

It moves every pin onto these versions:

Package Set to Why
@neutrinos/alpha-module-http 1.1.20 CSRF-aware build
@neutrinos/alpha-module-reels-datasource 1.1.19 CSRF-aware build
@neutrinos/alpha-mdoule-case-servcie 1.2.1 Case-data versioning

No other package is touched.

[!IMPORTANT]
The migration sets these versions rather than comparing them. A project deliberately running a newer build of these three packages is moved back to the versions above. Run the dry run first and check affectedProjectIds.

Three endpoints, all on alpha-config-service, all under https://<alpha-host>/configservice/migration-module/:

# Endpoint Method Purpose
1 V1.1.0_plugin_version_upgrade_up POST Preview with dryRun, then apply. Start here.
2 V1.1.0_plugin_version_upgrade_status GET Poll a run, or see where the tenant stands.
3 V1.1.0_plugin_version_upgrade_down POST Roll it back.

up and down are asynchronous. They return 202 with a runId and do the work in the background. Poll that runId with endpoint 2 to see the outcome.

Neither takes a tenantId or a projectId. The tenant comes from the session, and both halves of the migration are tenant-wide.

[!IMPORTANT]
These routes are session-authenticated. A Service JWT is not accepted. Use a browser session cookie from an account signed into the tenant, and send x-csrf-token from GET https://<alpha-host>/configservice/csrf/token on every POST. GET does not need it.

What the Migration Touches

Every response reports two halves separately, so it helps to know what they are.

Half 1, Global Custom Code. Every saved version of every Global Custom Code records which plugin version it was written against. Runs in one transaction. Reported as rowsUpdated and rowsBackedUp.

Half 2, plugin lists. Every project has a list of plugins it uses, and every tag of that project keeps its own copy. These live in the config engine. The responses call this half the ledger. Runs after half 1 commits, project by project. Reported under ledger.

The tag copies matter. A tag’s copy is what project export emits, so a tag left behind carries old versions into whatever imports it.

1. POST V1.1.0_plugin_version_upgrade_up

Applies both halves above. It runs once per tenant. Once applied, every later call is refused. Nothing in the body overrides that, because the guard reads the tenant’s own state rather than the request. To apply it a second time, roll back first with endpoint 3.

POST https://<alpha-host>/configservice/migration-module/V1.1.0_plugin_version_upgrade_up
Content-Type: application/json
x-csrf-token: <token>

{ "dryRun": true }
Body field Default Meaning
dryRun false Report what would change, write nothing. Exempt from the once-only guard.

That is the entire body. There is no projectId, no tenantId, no version override, no force flag.

Response A: accepted, 202. The work has not happened yet. accepted: true means the migration claimed a run and started it in the background.

{
  "migration": "V1.1.0_plugin_version_upgrade",
  "runId": "2301b62c-0e3c-4f11-ab76-f2d97a5a2eeb",
  "direction": "up",
  "dryRun": false,
  "accepted": true,
  "resumed": false,
  "reason": null,
  "state": "running",
  "phase": "queued",
  "attempts": 1,
  "startedAt": "2026-09-02T05:44:10.112Z",
  "progress": {},
  "result": null,
  "error": null,
  "statusUrl": "/migration-module/V1.1.0_plugin_version_upgrade_status?runId=2301b62c-...",
  "message": "Run 2301b62c-... started. Poll statusUrl for progress."
}
Field Meaning
runId Poll this.
accepted true means this call started or resumed the run.
resumed true means it continued a previously failed run instead of starting new.
reason Why no work was done. null when accepted is true.
statusUrl Ready-made path for endpoint 2, with the runId already in it.

Response B: resumed, 202. Same runId as the failed attempt, with attempts incremented. This is deliberate. The backup rows that make the run rollbackable are keyed by runId, so a resumed run must keep the original. Never work around a failure by trying to get a fresh runId.

{
  "accepted": true,
  "resumed": true,
  "runId": "2301b62c-...",
  "attempts": 2,
  "message": "Resumed run 2301b62c-..., which had failed at phase ledger. Poll statusUrl for progress."
}

Response C: already applied, 202. Nothing happened. runId is the run the tenant already stands applied under, not a new one. This is the expected answer on any tenant where the migration has already been done.

{
  "runId": "060ec865-ac8d-44ad-a7a8-88cab1ec5b99",
  "accepted": false,
  "resumed": false,
  "reason": "already-applied",
  "state": "completed",
  "result": { "...the original run's outcome, if that build recorded it..." },
  "message": "Already applied for this tenant as run 060ec865-.... This migration runs once; roll it back with V1.1.0_plugin_version_upgrade_down before applying it again."
}

Response D: already running, 202. Nothing happened, and the response hands back the run that is happening. Poll it. Do not retry.

{
  "runId": "2301b62c-...",
  "accepted": false,
  "reason": "already-running",
  "state": "running",
  "phase": "ledger",
  "progress": { "...where the real run has got to..." },
  "message": "Run 2301b62c-... (up) is already in progress for this tenant."
}
Code Meaning
401 No session, or the session has no tenant.
500 The run could not be started, usually database access. A failure during the run shows up on the run, not here.

Reading result: dry run. Poll until state is completed, then read run.result.

{
  "migration": "V1.1.0_plugin_version_upgrade",
  "runId": "...",
  "dryRun": true,
  "applied": false,
  "targets": ["...the three packages and versions..."],
  "customCodeVersionsSwept": 4381,
  "snapshotsFound": 4381,
  "affectedProjectIds": ["04d181b1-...", "063a7ec1-...", "..."],
  "rowsToUpdate": 6690,
  "ledger": {
    "featureEnabled": true,
    "projectsScanned": 265,
    "tagsScanned": 152,
    "alreadyOnTarget": 72,
    "configsMoved": 849,
    "projectListConfigsMoved": 565,
    "tagListConfigsMoved": 284,
    "projectsMoved": 193
  },
  "previousRuns": 0
}
Look at It tells you
affectedProjectIds Which projects this will change. Check your project is in the list, and that nothing unexpected is.
rowsToUpdate How much it will move. These are Global Custom Code pins that need a new version.
You see It means
rowsToUpdate > 0 and your project listed Expected. Go ahead and apply it.
ledger.configsMoved > 0 Plugin lists need moving too. Expected.
everything 0 Nothing for this migration to do. Applying it changes nothing.
your project not in affectedProjectIds This migration will not touch it, so applying it will not fix it.
ledger.featureEnabled: false The PROJECT_PLUGINS flag is off, so half 2 is skipped.

A dry run writes nothing and is exempt from the once-only guard, so it can run at any time. It does take the tenant’s single run slot while it works.

Reading result: the real run. Repeat the call with an empty body {}, poll, then read run.result.

{
  "runId": "2301b62c-...",
  "dryRun": false,
  "applied": true,
  "customCodeVersionsSwept": 4400,
  "snapshotsFound": 4400,
  "affectedProjectIds": ["21fa5a46-b5a4-4f43-947b-2cfcf0ddb3d2"],
  "rowsBackedUp": 13,
  "rowsUpdated": 13,
  "previousRuns": 1,
  "ledger": {
    "featureEnabled": true,
    "projectsScanned": 266,
    "tagsScanned": 155,
    "alreadyOnTarget": 933,
    "configsMoved": 0,
    "projectListConfigsMoved": 0,
    "tagListConfigsMoved": 0,
    "projectsMoved": 0
  }
}
Field Meaning
customCodeVersionsSwept Every Global Custom Code version the tenant holds, published or not, current or not.
snapshotsFound The same number as customCodeVersionsSwept. Two names for one count.
affectedProjectIds Projects pinning Global Custom Code the sweep moved.
rowsBackedUp Rows whose old version was saved so a rollback can restore them.
rowsUpdated Rows moved. Must equal rowsBackedUp. If not, stop and raise it.
previousRuns Runs before this one, counted before this run’s own backup insert. So the first run reports 0, and status a second later says 1. Both are right.
ledger.featureEnabled false means half 2 was skipped because PROJECT_PLUGINS is off.
ledger.projectsScanned Projects walked. Not the same as projects changed.
ledger.tagsScanned Tag copies walked.
ledger.alreadyOnTarget Entries that needed nothing. On a second run this is high and configsMoved is 0.
ledger.configsMoved Entries changed, split into projectListConfigsMoved and tagListConfigsMoved.
ledger.projectsMoved Distinct projects whose lists changed.

affectedProjectIds and ledger.projectsMoved count different things. One comes from Global Custom Code usage, the other from plugin lists. They are not meant to match, and one being bigger tells you nothing.

After it completes. In the result, rowsBackedUp should equal rowsUpdated, and the numbers should sit in the range the dry run predicted. Then open an affected project and load a page that makes an ap.http, Reels datasource or case service call, and confirm the 403 is gone. The response numbers report what the database holds. Only loading the page confirms the runtime agrees.

If it still returns 403, the old code bundle is probably still cached. A project whose plugin list moved needs its pages re-served, so purge the CDN and try again.

2. GET V1.1.0_plugin_version_upgrade_status

Poll a run that was started, or check where the tenant stands. It never writes anything.

One specific run. Use this while waiting on an up or down.

GET https://<alpha-host>/configservice/migration-module/V1.1.0_plugin_version_upgrade_status?runId=<runId>
{
  "migration": "V1.1.0_plugin_version_upgrade",
  "run": {
    "runId": "2301b62c-0e3c-4f11-ab76-f2d97a5a2eeb",
    "direction": "up",
    "dryRun": false,
    "state": "running",
    "phase": "ledger",
    "attempts": 1,
    "progress": {
      "snapshotsFound": 4400,
      "rowsBackedUp": 13,
      "rowsUpdated": 13,
      "affectedProjects": 1,
      "ledger": {
        "projectsScanned": 141,
        "projectsTotal": 266,
        "tagsScanned": 78,
        "configsMoved": 0,
        "alreadyOnTarget": 480
      }
    },
    "result": null,
    "error": null,
    "startedAt": "2026-09-02T05:44:10.112Z",
    "updatedAt": "2026-09-02T05:46:02.887Z",
    "finishedAt": null
  }
}

A 404 here means that runId does not belong to the calling tenant.

Field Meaning
direction up or down.
dryRun true if this run wrote nothing.
state running, completed or failed.
phase Which part it is working on. See below.
attempts 1 normally. 2 or more means it failed and was resumed.
progress Live counters. Grows as it works.
result null until it finishes. Then the full outcome, the payload documented under endpoint 1.
error null unless state is failed.
startedAt When the run was claimed.
updatedAt The heartbeat. If this stops moving, the run is stuck or its service instance died.
finishedAt null while running.
Phase What it’s doing
queued Claimed, background task not picked it up yet. Lasts a moment.
planning Working out what it would touch. Nothing written yet.
dependency_sweep Half 1, moving Global Custom Code pins in one transaction.
ledger Half 2, moving project and tag plugin lists, project by project.
ledger_restore On a down, putting plugin lists back.
dependency_restore On a down, putting Global Custom Code pins back.
done Finished.
progress key Appears during Meaning
snapshotsFound after planning Global Custom Code versions being swept.
rowsToUpdate dry run Rows a real run would move.
rowsBackedUp and rowsUpdated after half 1 Rows moved. These two must be equal.
affectedProjects after half 1 Projects pinning code the sweep moved.
ledger.projectsScanned and projectsTotal half 2 Progress through the tenant. This is your ETA.
ledger.tagsScanned half 2 Tag copies walked so far.
ledger.configsMoved half 2 Plugin list entries changed so far.
ledger.alreadyOnTarget half 2 Entries that needed nothing.
targetRunId down Which up run this rollback is unwinding.
ledgerConfigsRestored and ledgerConfigsToRestore down Rollback progress.
state Do
running Wait. Poll every 30s. A real tenant takes minutes.
completed Read result.
failed Read error. Fix the cause, then call up again. It resumes this same run.

A run whose service instance dies stops updating updatedAt. After 5 minutes the migration marks it failed, which frees the tenant. Nothing is lost. Call up again and it continues that run.

The whole tenant. Drop the runId to ask where the tenant stands, for example when someone else started the run.

GET https://<alpha-host>/configservice/migration-module/V1.1.0_plugin_version_upgrade_status
{
  "migration": "V1.1.0_plugin_version_upgrade",
  "applied": {
    "runId": "060ec865-ac8d-44ad-a7a8-88cab1ec5b99",
    "source": "backup-table"
  },
  "running": false,
  "currentRun": null,
  "lastRun": {
    "runId": "2301b62c-...",
    "direction": "up",
    "state": "completed",
    "...": "..."
  },
  "recentRuns": ["...up to 5 runs, newest first..."],
  "previousRuns": 1,
  "lastRunAt": "2026-09-02T08:39:57.837Z",
  "rowsInBackup": 6690
}
Field Meaning
applied null means not applied. An object means applied, under that runId. This is what makes up return already-applied.
applied.source How that was known. run-log means applied by this build. backup-table means applied by an older build that had no run log.
running true if a run is in flight. Only one per tenant at a time.
currentRun The run in flight, or null.
lastRun The most recent finished run, with its result or error.
recentRuns Up to 5, newest first, including dry runs.
previousRuns Distinct runs with rows in the backup table.
lastRunAt When the newest backup row was written.
rowsInBackup Rows a rollback can restore.

3. POST V1.1.0_plugin_version_upgrade_down

Unwinds the run the tenant stands applied under: plugin lists first, then Global Custom Code pins, then that run’s backup rows. It is asynchronous like up, and shares the same single run slot. A rollback cannot start while a sweep is still moving the versions it would restore.

Running this is also how a tenant becomes eligible for up again.

POST https://<alpha-host>/configservice/migration-module/V1.1.0_plugin_version_upgrade_down
Content-Type: application/json
x-csrf-token: <token>

No body. No projectId. The 202 response has the same shape as up, with "direction": "down". Poll statusUrl the same way.

Reading result: something was rolled back.

{
  "migration": "V1.1.0_plugin_version_upgrade",
  "rolledBack": true,
  "runId": "060ec865-ac8d-44ad-a7a8-88cab1ec5b99",
  "rowsRestored": 6690,
  "ledger": { "featureEnabled": true, "restored": 849, "tagConfigs": 284 },
  "previousRuns": 0
}
Field Meaning
rolledBack true means it unwound something.
runId The up run it unwound, not this rollback’s own id.
rowsRestored Global Custom Code pins put back. Should equal that up run’s rowsUpdated.
ledger.restored Plugin list entries put back, of which tagConfigs were tag copies.
previousRuns Runs still left in the backup table afterwards.

Reading result: nothing to roll back. Harmless. It means the migration was never applied, or was already rolled back.

{
  "rolledBack": false,
  "runId": null,
  "rowsRestored": 0,
  "ledger": null,
  "previousRuns": 0
}

Step by Step

# Do this Confirm before moving on
1 POST _up with {"dryRun": true}. The response is immediate. accepted: true. Keep the runId.
2 GET _status?runId=<runId> until state is completed, then read result. Your projects are in affectedProjectIds, and rowsToUpdate is what you expected.
3 POST _up with {}. This is the once-per-tenant call. accepted: true. Keep the new runId.
4 GET _status?runId=<runId> until state is completed, then read result. rowsBackedUp equals rowsUpdated.
5 Open the project and use it. A page that calls ap.http, Reels datasource or case service no longer returns 403.

Steps 2 and 4 are the same call. A run takes minutes on a real tenant, so poll every 30 seconds.

If step 2 shows nothing to do, meaning no projects listed and rowsToUpdate and ledger.configsMoved both 0, then applying it changes nothing. Stop. Find out why the project is failing before running anything.

If step 3 comes back already-applied, the migration has already been run on this tenant. Nothing happened, and nothing needs to. Go to step 5 and verify.

If step 4 shows the two numbers differ, stop and raise it. Some rows moved without a backup, and a rollback would not put them all back.

[!IMPORTANT]
The migration runs once per tenant. Global Custom Code published after the run keeps the versions it was cloned from and is not swept until the migration is rolled back and applied again, which unwinds everything rather than only the new rows. Run this once projects have settled.