Runbook: Migrate Legacy Document Blobs to Org-Scoped Paths¶
One-time data migration that accompanies per-organization blob path scoping (PR #689 /
RA-4). Source document blobs (and the markdown derived from
them) uploaded before that change sit at a bare {file_name} in the documents container, invisible
to the app — which only ever looks under {ORGANIZATION_ID}/{file_name} — even though the document's
DB row is untouched.
Run this once per environment. Until it runs, chat and agentic search return no results for documents whose source blob predates org-scoping.
Background¶
| Old layout (pre-org-scoping) | New layout (org-scoped) | |
|---|---|---|
| Source document | {file_name} |
{organization_id}/{file_name} |
| Per-document markdown | {category_id}/{subcategory_id}/{doc}.md |
{organization_id}/{category_id}/{subcategory_id}/{doc}.md |
| Per-subcategory corpus | {category_id}/{subcategory_id}/corpus.txt |
{organization_id}/{category_id}/{subcategory_id}/corpus.txt |
Azure Blob Storage cannot rename folders or move files in place, so the source blob is copied to its org-scoped path, then the document is reprocessed so the derived markdown is regenerated directly under its org-scoped path — no separate markdown copy step exists or is needed. Once a document has been reprocessed, its legacy source blob and legacy markdown files are orphans and can be deleted.
The copy and reprocess steps can run together (the migrate phase, steps 1–2 below) or
separately (the copy phase, then the reprocess phase — step 2a below). Split them when the
copy must finish before a software upgrade: copying is safe at any time and can run during working
hours, but reprocessing only helps once the upgraded app reads org-scoped paths (reprocessing
beforehand would regenerate markdown right back under the legacy paths). Front-loading the slow bulk
copy this way shortens the post-upgrade maintenance window.
Reprocessing normally means chunking + embedding + markdown extraction, but migrate also copies
each document's .chunks.json/.embeddings.json sidecar blobs alongside the source blob (they match
the same bare-filename legacy check). The rag_pipeline's checkpoint logic
(get_pipeline_checkpoint) resumes from indexing or embedding when those already exist at the
org-scoped path, so most documents skip the expensive chunking step (and often embedding too) —
only markdown extraction and indexing typically re-run. Still treat the worst case (a full
chunk+embed+index pass, for documents with no prior checkpoint) as the basis for scheduling the
maintenance window.
Prerequisites¶
- An admin bearer token for the target environment (see token step below). Used by
migrate,reprocess,verify-status, andverify-markdown; not needed bycopyor the cleanup phases. - The frontend URL for the target environment including
/api(e.g.https://<frontend>.azurecontainerapps.io/api). Needed by the same phases as the token above. - The target environment's organization id (UUID) — passed explicitly via
--org-idon every phase (not read from the environment), since a deployment can serve more than one organization. scripts/.envpopulated withAZURE_STORAGE_CONTAINER_NAMEandAZURE_STORAGE_*for the target environment — required for every phase, includingmigrate's dry run, since it lists the documents container's blobs up front regardless of--apply.MARKDOWN_CONTAINER_NAMEis additionally required forcleanup-legacy-markdownonly.- Storage Blob Data Contributor access to the target environment's storage account (requested per-migration the same way it was granted for the previous agentic-blob-path migration).
- A maintenance window for the reprocess step. Reprocessing can re-run chunking + embedding +
markdown extraction for every migrated document (see Background above for when this is skipped) —
run it outside working hours, whether via
migrate --apply(combined) orreprocess --apply(split). Thecopyphase does no reprocessing and is safe to run during working hours.
The migration script is scripts/migrate_documents_to_org_scoped_blobs.py. migrate, copy,
reprocess, cleanup-legacy, and cleanup-legacy-markdown are dry-run by default; add
--apply to perform writes/deletes. verify-status and verify-markdown are read-only reports with
no --apply.
0. Get an admin token and the frontend URL¶
Using altaforge-ra-cli with a profile already configured for the target
environment:
export ADMIN_TOKEN=$(altaforge-ra-cli --profile <profile> auth ms get-token)
export FRONTEND_URL=$(altaforge-ra-cli config profile show <profile> | jq -r '.api_base_uri')
export ORG_ID=<organization-uuid>
<profile> is uat or prod (see cli-setup.md for configuring profiles). This
device-code-signs you in the first time and silently refreshes the cached token on subsequent runs.
ORG_ID is the target environment's organization id — the same value that previously lived in
that environment's ORGANIZATION_ID setting.
1. Dry-run the migrate phase¶
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
migrate --frontend-url "$FRONTEND_URL" --token "$ADMIN_TOKEN" --org-id "$ORG_ID"
This lists the legacy (non-org-scoped) blobs it would copy to {ORGANIZATION_ID}/{file_name}. It
still lists the documents container's blobs to build that list, so --env-file scripts/.env (or the
equivalent storage env vars in your shell) is required even for this dry run.
2. Run the migrate phase (maintenance window)¶
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
migrate --frontend-url "$FRONTEND_URL" --token "$ADMIN_TOKEN" --org-id "$ORG_ID" --apply
Each legacy blob is copied to its org-scoped path, then documents whose file name was freshly copied
on any version (not just the current one) are sent to POST /admin/documents/reprocess (tune throughput with
--max-concurrent, default 3, and --delay). Copying is additive — the legacy blob is left in
place — so this phase is safe to re-run. A blob already copied by a previous run is skipped on
retry, and its document is not reprocessed again by default (assumed already reprocessed); pass
--force-reprocess to reprocess those too, e.g. to retry after a prior run's reprocessing step
failed or was interrupted after copying succeeded. If some blobs fail to copy, the run still
reprocesses everything that did copy successfully and exits non-zero — re-run migrate --apply to
retry the ones that failed (idempotent).
There's no pipeline dashboard to watch — reprocessing runs as durable orchestrations in the background, so give it a few minutes, then move on to step 3.
2a. Alternative: split copy and reprocess across a software upgrade¶
Use this instead of steps 1–2 when the copy must finish before a software upgrade (e.g. the
deployment that first turns on org-scoped paths). copy does no reprocessing and touches no
org-scoped read path the running app uses, so it runs safely during working hours; reprocess then
runs after the upgrade, in the maintenance window. Steps 3–5 are unchanged.
Copy — before the upgrade, safe during working hours:
# Dry run — lists the legacy blobs it would copy
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
copy --org-id "$ORG_ID"
# Copy them
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
copy --org-id "$ORG_ID" --apply
copy needs only storage access and --org-id — no admin token or frontend URL. Copies are additive
and already-copied blobs are skipped, so it's safe to re-run.
Re-run copy --apply as the last step before the upgrade cutover. Until the environment is
upgraded, the old app keeps writing new uploads and edits to the legacy (non-org-scoped) paths, so a
document that changed after the initial copy would otherwise be missed. A final copy right before the
cutover picks up that delta cheaply — it only copies blobs that don't already have an org-scoped copy.
Reprocess — after the upgrade, in the maintenance window:
# Dry run — lists the documents it would reprocess
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
reprocess --frontend-url "$FRONTEND_URL" --token "$ADMIN_TOKEN" --org-id "$ORG_ID"
# Reprocess them
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
reprocess --frontend-url "$FRONTEND_URL" --token "$ADMIN_TOKEN" --org-id "$ORG_ID" --apply
reprocess targets every document whose legacy blob has a confirmed org-scoped copy — the same
set the verify phases report, not just one run's copies — so it's safe to re-run until step 3 is clean
(tune throughput with --max-concurrent, default 3, and --delay). As with migrate, reprocessing
runs as background orchestrations, so give it a few minutes, then move on to step 3.
3. Verify¶
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
verify-status --frontend-url "$FRONTEND_URL" --token "$ADMIN_TOKEN" --org-id "$ORG_ID"
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py \
verify-markdown --frontend-url "$FRONTEND_URL" --token "$ADMIN_TOKEN" --org-id "$ORG_ID"
Both are read-only and scoped to the same set of documents migrate would have triggered
reprocessing for (a legacy blob with a confirmed org-scoped copy). verify-status reports any of
them not yet back to available status; verify-markdown reports any missing their expected
markdown blob at {organization_id}/{category_id}/{subcategory_id}/{doc}.md (documents with no
category/subcategory assigned are reported separately since no path can be built for them). Both
exit non-zero if anything is outstanding — re-run either on its own to re-check as orchestrations
finish, no need to re-run migrate or reprocess. As a spot check, also run a chat query and an agentic search and
confirm previously-invisible documents are now returned.
Only proceed to cleanup once both commands report clean — the cleanup phases below only confirm that a blob was copied, not that reprocessing subsequently succeeded.
4. Clean up legacy document blobs¶
# Dry run — lists legacy document blobs that would be deleted
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py cleanup-legacy --org-id "$ORG_ID"
# Delete them
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py cleanup-legacy --org-id "$ORG_ID" --apply
Deletes a legacy top-level document blob only after confirming the corresponding
{organization_id}/{file_name} blob exists in the same container — i.e. only blobs migrate already
copied. Legacy blobs with no matching org-scoped copy are left alone and reported.
5. Clean up legacy markdown blobs¶
# Dry run — lists legacy markdown blobs that would be deleted
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py cleanup-legacy-markdown --org-id "$ORG_ID"
# Delete them
uv run --project scripts --env-file scripts/.env python scripts/migrate_documents_to_org_scoped_blobs.py cleanup-legacy-markdown --org-id "$ORG_ID" --apply
Same confirm-before-delete safety as step 4, run against the markdown container
(MARKDOWN_CONTAINER_NAME) instead of the documents container. No copy step exists for markdown —
reprocessing already writes fresh markdown straight to the org-scoped path, so once a document has
been reprocessed its legacy markdown files are pure orphans.
Rollback¶
There is no in-place rollback — the migration copies rather than moves data, and reprocessing regenerates rather than moves markdown. If a migrated document regresses, its legacy source blob remains available at the pre-migration path until step 4 is applied, so deferring cleanup keeps the previous layout available as a safety net. Legacy markdown similarly remains available until step 5 is applied.
Scope note¶
This runbook covers running the migration and reprocessing against one already-deployed environment. It does not cover the deployment itself — database migrations, Terraform adjustments, or other per-environment rollout work required to get an environment onto the org-scoped blob layout in the first place. See RA-95's scope note for the same distinction.