Skip to content

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, and verify-markdown; not needed by copy or 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-id on every phase (not read from the environment), since a deployment can serve more than one organization.
  • scripts/.env populated with AZURE_STORAGE_CONTAINER_NAME and AZURE_STORAGE_* for the target environment — required for every phase, including migrate's dry run, since it lists the documents container's blobs up front regardless of --apply. MARKDOWN_CONTAINER_NAME is additionally required for cleanup-legacy-markdown only.
  • 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) or reprocess --apply (split). The copy phase 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.