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 checkaffectedProjectIds.
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 sendx-csrf-tokenfromGET https://<alpha-host>/configservice/csrf/tokenon everyPOST.GETdoes 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.