---
title: "Config Import/Export"
slug: config-import-export
section: Governance & Operations
status: stable
description: "Portable configuration transfer between Globus environments — export, diff, transactional apply, config-as-code review, and the automation built around all three"
last-updated: 2026-10-07
---

# Config Import/Export

Portable configuration transfer between Globus environments — export, diff, transactional apply, config-as-code review, and the automation built around all three

## Overview

Config Import/Export ("Data I/E") lets an operator move a defined slice of Globus's configuration — brands, programs, seasons, workflow definitions, queue definitions, SLA policies, rule bundles, rule definitions, program rule coverage bindings, step library entries, reference templates, agreements, and brand legal policies — between environments as a single portable JSON file. It exists so that configuration built and reviewed in development can be promoted to QA and production deliberately, with a field-level diff reviewed before anything is written, rather than by hand-editing each environment's database independently.

The feature has three layers, all covered on this page: (1) the export/diff/apply pipeline inside BackOfficePrototype (BOP) itself, reachable from Admin Settings → Data Management; (2) a config-as-code Git repository, Alliance-Strategies/globus-config, where entity files are reviewed as pull requests before they ever reach an environment; and (3) the automation wrapped around both — a daily drift-detection cron, CI validation on every PR, and a machine-authorized (but human-attributed) import-from-repo path.

## Core Principle: Import Stays Human

> **Export can be a machine. Import cannot be, except by proxy for a real admin.**
>
> A machine credential can read configuration (config.export) and can trigger an apply from the reviewed repo (config.import), but that second permission grants no authority by itself — every API-key-triggered apply independently re-verifies, via a live database lookup at apply time, that a named human currently holds system.admin in that environment. Uploading a file and applying it interactively remains a session-only, human-only action with no API-key path at all. Nothing in this feature lets a bot originate a change with its own authority.

## The v2 Envelope Format

Every export, upload, snapshot, and repo-built file is the same shape: a "v2 envelope." v1 envelopes (an earlier, unversioned format) are rejected outright on import. POST /api/admin/config-import answers a v1 file with 400 ENVELOPE_V1_UNSUPPORTED (re-export from the source environment to get a v2 file with metadata and checksum) and a v2 file that fails the schema with 400 ENVELOPE_INVALID (the message lists up to the first five schema issues). The contract lives in shared/config-transfer/envelope.ts and is enforced identically wherever an envelope is parsed — the BOP import endpoint, the repo-build script, and CI validation all call the same Zod schema.

| Field | Purpose |
| --- | --- |
| version | Literal 2 — anything else is rejected before any content is read. |
| exportedAt | Export timestamp. Not part of the checksum. |
| metadata | sourceEnv, exportedBy, appVersion, and checksum (64-char hex). Not part of the checksum itself. |
| categories | One optional array per category. This is the only thing the checksum covers. |
| manifest | Optional — present only on selective exports. . Deliberately kept outside categories so it never affects the checksum. |

The checksum is SHA-256 over a canonical JSON serialization of categories: object keys are recursively sorted, arrays are kept in their original order, and undefined members are dropped. Timestamps, actor identifiers, and the manifest all live outside this canonicalized block on purpose — two envelopes containing the same entities produce the same checksum regardless of who exported them or when, which is what lets the drift-detection cron compare a repo file against a live export byte-for-byte.

## What Travels Per Category

| Category | Export scope & notable exclusions |
| --- | --- |
| brands | Full brand row. status travels but is never written on update — lifecycle transitions stay in the brand PATCH route. |
| programs | Full program row with brand/category resolved to natural keys (brandSlug, categoryCode). |
| seasons | Full season plus workflow bindings. usedCapacity is deliberately excluded — a live counter, never overwritten by import. |
| workflowDefinitions | Only the meaningful heads per (program, workflowType): at most one published version and the single latest draft. Older/archived versions are environment-local and never exported. |
| queueDefinitions | Full queue row + rules, with parent/brand/program resolved to natural keys. |
| slaPolicies | Full SLA fields + escalation rules, each escalation scope resolved to natural keys. |
| ruleBundles | Bundle catalog + scopes + rules. Pinned rule versions travel as (ruleDefinitionKey, versionNumber), not raw IDs. scopeHash is recomputed on import, never exported. |
| ruleDefinitions | Every definition plus only its currently published version (scopes/actions/parameter defaults for that version), plus all test cases. Drafts and archived versions never travel. |
| programRuleCoverageBindings | Portable coverage wiring between a rule version and the workflow step or system process that exercises it. Every reference is a natural key (ruleDefinitionKey + ruleVersionNumber, programCode, sourceType workflow_step \| system_process, sourceId, factKey); database UUIDs never cross the boundary. Workflow-step coverage must be scoped to a program, and a system-process binding must name the unique authoritative producer of its factKey. |
| stepLibrary | Full entry including status — which, like brands, is exported but never applied on update. |
| referenceTemplates | Questions and config only. The GCS-backed blank-PDF version history is excluded — the importer re-renders a fresh PDF after commit. |
| agreements | Catalog fields plus only the currently published version's content — draft/under-review legal text is never promotable via export. |
| brandLegalPolicies | Per-brand legal content (privacy_policy, disclaimer): each policy's brandSlug and policyType plus only the body of its currently published version — draft/archived text never travels. Imported content always lands as a draft version for review (publishing stays an operator decision in the target); an identical-to-published body is a no-op, and a policy with no published version in the source is imported as metadata only, with a warning. |

### Selection Keys

Every entity has a stable, human-readable "selection key" independent of its database UUID — this is what selective export, the diff engine, and the config-as-code repo's filenames all key on.

| Category | Selection key |
| --- | --- |
| brands | slug |
| programs | code |
| seasons | programCode::seasonKey (or ::name::year) |
| workflowDefinitions | programCode::workflowType |
| queueDefinitions | slug |
| slaPolicies | queueSlug::name |
| ruleBundles | bundleKey::vN |
| ruleDefinitions | key |
| programRuleCoverageBindings | ruleDefinitionKey::vN::programCode (or *)::sourceType::sourceId::factKey |
| stepLibrary | stepKey |
| referenceTemplates | templateKey |
| agreements | code |
| brandLegalPolicies | brandSlug::policyType |

A selection key is turned into a filesystem-safe filename stem for the config-as-code repo by replacing :: with __ and any other unsafe character with - — e.g. SWT-CAMP::application becomes SWT-CAMP__application.json.

## Export

### Whole-Category Export

The simplest export: pick one or more of the 13 categories and download every entity in them as a v2 envelope. This is what backs the drift-check cron's read.

### Selective Export & Dependency Closure

Selecting individual entities (rather than whole categories) triggers a dependency-closure walk: a breadth-first traversal over "requires" edges between categories (a season requires its program and the workflow versions its bindings point at; a workflow requires its program and every step-library entry its steps reference; a queue requires its program/brand/parent; a brand legal policy always requires its owner brand; and so on). The walk dedupes diamond dependencies (a shared dependency reached two ways is included once) and never expands a node twice. Anything the walk can't resolve inside the source environment is still recorded — as a requires entry with missing: true — so the operator can see, before exporting, exactly what the target environment will need to already have.

The resulting envelope carries a manifest: what was requested, what actually got included (selections plus everything the closure pulled in), and every dependency requires edge with its reason — this manifest is what the config-as-code repo's own manifest files ( envs/ .manifest.json) are structurally modeled on.

## The Diff Engine

### Diff Classes

Before anything is written, every entity in an incoming envelope is classified against the current state of the target environment:

create — no matching entity exists in the target. noop — the incoming and target entities are byte-identical once IDs and never-applied fields are stripped, so nothing would change. update — they differ; for single-entity keys the diff lists the exact top-level field names that changed. blocked is not a raw comparison result — it is a deliberate reclassification applied to agreements when an open under_review or approved version already exists in the target, since applying over it would corrupt an in-flight legal review, and to step library entries whose content edit the apply would refuse with STEP_EDIT_BLOCKED_BY_ACTIVE_APPLICANTS (409) because a published workflow reaching that step has active applicants in flight. A blocked step-library entry carries the structured impact (affected programs and the active applicant count) so the review can name what stands in the way.

Two comparison rules are worth knowing. A rule bundle's manifest key is versioned (bundleKey::vN), but an incoming bundle is compared against the target's effective logical head for that bundleKey — the highest-numbered published, non-retired version, or else the highest-numbered non-retired version — not against whatever version shares the source's counter. And workflow definitions whose content is unchanged remain a no-op even when the target's copy is a legacy one (for example a phase-less head): the import reports the plan as unchanged rather than attempting a repair, and any write to a legacy workflow must still pass today's integrity checks.

### Fields That Never Apply on Update

A short, explicit list of fields travel in every envelope (so they're available on create) but are stripped before both diffing and applying on update, because the decision they represent belongs to an operator acting in the target environment, not to a config file:

| Category | Field | Why |
| --- | --- | --- |
| brands | status | Brand lifecycle is a one-way state machine with cascading side effects; transitions belong to the brand PATCH route. |
| stepLibrary | status | Activating/deactivating a live step is an operator decision. |
| agreements | status | Archiving a live agreement is an operator decision. |
| seasons | usedCapacity | A live counter — never overwritten by import. |

### diffToken and Staleness Protection

Reviewing a diff and applying it are two separate calls, so the target could change in between. To catch that, the diff step computes a diffToken — a hash binding the envelope's checksum to a fingerprint of exactly the slice of the target state this import touches. At apply time, that fingerprint is recomputed inside the transaction and compared again; a mismatch means something the operator reviewed has since moved, and the apply is rejected with rather than silently applying against state the operator never actually saw.

### Dry-Run Parity and Step-Edit Acknowledgement

A dry run is more than the field-level diff. Besides the diff, the dry-run response carries validationErrors — produced by running the real apply pipeline inside a transaction that is always rolled back, so FK resolution and apply-time guards are checked with the same code an apply would use, and every entity-level error the apply would hit is reported up front rather than one at a time. It also carries stepContentEdits: one entry per step-library entry whose content genuinely differs from the target, with its stepKey, whether it is blocked, the active applicant count, the affected program names, and its servingSource (staff_library, or portal_mirror when the portal mirror row is what applicants actually see).

To proceed past a blocked step edit, an apply request may send acknowledgeStepLibraryEdits, a list of stepKeys. The server clamps that list to the steps its own pre-flight found blocked for this envelope at that moment and passes the result to the apply as stepLibraryPolicy.acknowledgedContentEdits, so the list can only narrow the blocked set, never widen it. Both the file-upload route and the from-repo route accept it.

## Import & Apply

### Transactional, All-or-Nothing

A pre-import snapshot, every category's upserts, the import-run row, and the audit write all happen inside one database transaction. Any error rolls back everything already written in that call — there are no partial applies and no silent skips. Each entity is applied inside its own savepoint, so when one entity fails the apply rolls back to that savepoint, records the error, and carries on to the next entity; a single run therefore reports every entity-level error (as the errors list on the 422 response) instead of stopping at the first, and the overall transaction is still rolled back. The one exception is reference-template PDF re-rendering, a post-commit side effect explicitly allowed to fail without rolling back already-committed config; a failure there is recorded as a warning on the run instead.

### Publish-Safety: Never Mutate a Published Version

Workflow definitions, rule versions, and rule bundles share one safety pattern: if the matching target-environment version is already published, the importer never overwrites it in place. It instead clones the incoming content into a new draft at the next version number, leaving the published version — and anything already relying on it — untouched. If the target version is still a draft, it's safe to overwrite directly. New entities arriving with a "published" status are always forced to draft on insert — nothing is ever imported as published. Agreements follow the analogous rule at the content level: a published version's content is never overwritten; a matching draft is overwritten in place; and an open under_review/approved version blocks the import entirely rather than risk corrupting a review in progress. Brand legal policies follow the same idea with a simpler lifecycle (draft, published, archived only): content identical to the published or draft version is skipped, an existing draft is overwritten in place, and otherwise a new draft version is created — the importer never writes a published policy body directly.

### Selective Apply & SELECTIVE_GAPS

An apply can be restricted to a subset of an uploaded envelope's entities. The same closure logic used for selective export runs again, this time against the envelope itself: dependencies missing from the envelope are then checked against the target's actual current state. Only what's missing from both becomes a gap. Any gap fails the apply before a single write happens, with and one entry per missing dependency.

### Noop Filtering

Before the snapshot is taken, every entity classified noop is dropped from what actually gets applied — byte-identical entities are never rewritten. Because a noop-dropped entity can still be referenced by something else in the same import (a program that didn't change, but whose code a new season needs to resolve), the apply engine always seeds its foreign-key lookup maps from the full existing database state first, then overlays whatever the apply itself just wrote — never the other way around. One exception applies to the noop drop itself: workflowDefinitions and ruleBundles are never dropped by it, because their writers compare against the head inside the apply transaction and the head can change between review and apply.

## Snapshots, Runs & Restore

Every apply takes a pre-import snapshot of the affected categories before writing anything, so it can always be undone. Snapshots (config_snapshots) come in three kinds — pre_import_backup, pre_restore_backup, and manual. Every attempted apply also leaves a run row ( config_import_runs) recording its status (applied / failed / restored) and source (file / snapshot_restore / repo) — this is how a from-repo apply is distinguishable in history from a manual file upload. Restoring a snapshot re-runs it through the identical apply pipeline: it takes its own fresh backup first, is upsert-based, and never deletes entities created after the snapshot was taken.

## Permissions & Security Model

### config.export vs config.import vs system.admin

| Permission | Grantable to | What it actually authorizes |
| --- | --- | --- |
| config.export | API keys (e.g. the drift-check cron) | Read-only export — the full and selective export endpoints. Nothing else. |
| config.import | API keys only — must never be granted to a human role | Unlocks the API-key path of POST /config-import/from-repo only. Grants no authority by itself — see below. |
| config.publish | API keys only (machine-only, restricted) — must never be granted to a human role | Unlocks only the internal config-repo retire and publish-entity routes the automated manifest-sync consumer calls (see Automation: Manifest Sync on Delete/Archive). Writes to the repo; never reads from it and never imports into BOP. |
| system.admin | Human roles | The only permission that actually authorizes an apply. Required for every session-based route, and independently re-checked live for every API-key apply. |

### The Dual-Path Gate

POST /api/admin/config-import/from-repo is the only route with two entry paths, gated independently: a human session still goes through the exact requireSystemAdmin() check every other import route uses, untouched. An API key instead needs config.import — but that only proves the credential is allowed to attempt the route. Every plain file-upload import route (POST /api/admin/config-import) and the publish route have no API-key path at all.

### Live Re-Verification, Not a Signed Claim

An API-key call to the from-repo route must include a requestedByEmail. The handler looks that user up fresh — not from a cached or signed claim — and checks, at the moment of the call, that they exist, are active, and currently hold system.admin in that specific environment. If the named human's access was revoked between when a publish PR was opened and when this apply actually runs, this catches it; a point-in-time signed token would not. Every failure mode — no such user, inactive, or lacking the permission — returns the identical on the wire; the real reason is only logged server-side, so the response never reveals whether an email corresponds to a real account. On this path, backup is also always forced to true regardless of what the caller sent.

The publish route runs the same idea in reverse: it never trusts a client-supplied requester at all. The email attached to a publish PR is always the session user's own verified email — there is no field a client can set to publish "as" someone else.

Cross-environment guard. Every dry-run response of the file-upload import (POST /api/admin/config-import) carries an environment object with sourceEnv (from the file metadata), targetEnv (this deployment CONFIG_ENV, defaulting to development) and crossEnvironment. Applying a file whose source differs from the target is refused with (details carry the environment object) unless the request sends acknowledgeCrossEnvironment: true, and a cross-environment apply always takes the pre-apply snapshot whatever backup was sent. On the API-key path of POST /api/admin/config-import/from-repo, an env that does not equal this deployment CONFIG_ENV is refused with before the requester is checked; session callers are not constrained this way.

## API Reference

| Route | Auth | Purpose / notable errors |
| --- | --- | --- |
| GET /api/admin/config-export | session or config.export key | Full export of the requested categories. |
| POST /api/admin/config-export/selective | session or config.export key | Selective export + dependency closure. 422 UNKNOWN_SELECTION. |
| POST /api/admin/config-import | session only, system.admin | File-upload dry-run/apply. 409 STALE_DIFF, 422 SELECTIVE_GAPS. Dry run also returns validationErrors and stepContentEdits; apply accepts acknowledgeStepLibraryEdits. |
| POST /api/admin/config-import/from-repo | session (system.admin) or config.import key + live requestedByEmail re-check | Builds the envelope from globus-config at a pinned commit sha, then dry-runs/applies (same validationErrors / stepContentEdits / acknowledgeStepLibraryEdits behaviour as file import). 503 CONFIG_REPO_NOT_CONFIGURED, 422 CONFIG_REPO_CONTENT, 403 REQUESTED_BY_NOT_AUTHORIZED. |
| POST /api/admin/config-export/publish | session only, system.admin | Opens a PR against globus-config. Requester is always the session user's own email. 503 CONFIG_REPO_WRITE_NOT_CONFIGURED, 422 CONFIG_REPO_WRITE_FAILED. |
| POST /api/internal/config-repo/retire | config.publish key only (machine-only) | Body . Removes the entity's file and manifest entry from the development globus-config manifest and auto-merges the PR. 503 CONFIG_REPO_WRITE_NOT_CONFIGURED, 422 CONFIG_REPO_WRITE_FAILED. |
| POST /api/internal/config-repo/publish-entity | config.publish key only (machine-only) | Body . Re-publishes one entity's current content (no dependency closure) to the development manifest and auto-merges the PR. 422 UNKNOWN_SELECTION, 503 CONFIG_REPO_WRITE_NOT_CONFIGURED, 422 CONFIG_REPO_WRITE_FAILED. |
| GET /api/admin/config-snapshots | session only, system.admin | Snapshot metadata, newest-first, capped at 100. |
| GET /api/admin/config-snapshots/:id | session only, system.admin | Downloads the stored envelope. 404 if missing. |
| POST /api/admin/environment-sync/development-to-qa | session only, system.admin | Development to QA rule and bundle convergence, built from the globus-config development manifest. Body: dryRun (required), planToken, acknowledgeHybridPublication. Apply needs the planToken from the dry-run plan (409 STALE_DIFF if missing or QA has changed). Apply errors: 422 CONVERGENCE_MANIFEST_INVALID (the plan carries validation errors), 400 HYBRID_PUBLICATION_ACKNOWLEDGEMENT_REQUIRED (automatic publication of eligible Development rules not confirmed; in practice the request schema rejects an apply without acknowledgeHybridPublication: true first, as 400 VALIDATION_ERROR, so the service-level code is a backstop), 422 CONVERGENCE_ACTIVATION_NOT_ATOMIC (an effective QA bundle has an unpinned entry resolving through the rule's current published version, so auto-publishing an imported rule could change that bundle outside its reviewed activation; nothing is committed), 503 CONFIG_REPO_NOT_CONFIGURED, 422 CONFIG_REPO_CONTENT. |
| POST /api/admin/environment-sync/finalize | session only, system.admin | Body planToken. Finalizes the reviewed convergence, including retirement of QA-only rules and bundles. 409 STALE_DIFF when the token no longer matches the locked source and target state; 422 CONVERGENCE_NOT_EFFECTIVE when any rule or bundle entry still differs between Development and QA (publish every staged business or regulatory bundle, activate the staged operational-gate set, then create a fresh plan). |
| POST /api/admin/config-snapshots/:id/restore | session only, system.admin | Re-applies a snapshot, taking its own pre-restore backup first. |
| GET /api/admin/config-import/runs/:runId/publish-preview | session only, system.admin | Non-mutating preview of publishing the drafts an import run landed: per draft its blockers, warnings, superseded versions and bundle reach; each finding carries a gate and code. Blockers: BUNDLE_PIN_UNRESOLVED (a bundle pins a rule to a version that will not be published), BUNDLE_RULE_NO_PUBLISHED_HEAD (a bundle includes a rule with no published version and none in this import), SCOPE_ALREADY_PUBLISHED (a future-dated rule version cannot supersede an overlapping published version; archive the published one first). SLA_READINESS warnings, reported only for new or changed queue destinations: QUEUE_HAS_NO_ACTIVE_SLA (work routed there will not be timed) and QUEUE_SLA_PRECEDENCE_AMBIGUOUS (more than one active SLA policy at the same priority; the name tie-break picks the winner). |
| GET /api/admin/config-import/runs | session only, system.admin | Import run history, newest-first, capped at 100. |

> **A separate, older route exists with a similar name**
>
> GET /api/admin/data-export is a distinct, earlier v1-envelope endpoint covering a smaller category set, unrelated to this feature — don't confuse the two.

## Admin UI — Data Management Tab

Admin Settings → Data Management is gated on the raw system.admin permission string — more strictly than the Settings, Feature Flags, or Change History tabs beside it, and it is never the tab opened by default. It has three tabs — Export Configuration, Import, and Rules Environment Sync. The Export Configuration tab holds the Export/Publish panel with the Snapshots panel beneath it, and import-run history is on the separate Change History tab. The panels are:

- Export — a single searchable entity picker (grouped by category, with per-category and global select-all) and a Download JSON button that builds the selective-export envelope with its dependency closure.

- Publish — in the same panel and sharing the same selection, plus a target-environment checklist and a confirmation dialog showing the (server-verified) requester email before opening a PR.

- Import — file upload, envelope-metadata preview, a "Review Changes" dry-run that opens a field-level diff dialog (per-entity create/update/noop/blocked, selectable for a partial apply), and an "Apply" step that auto-retries on a stale diff and surfaces gaps inline on a 422 without closing the dialog.

- History — import runs (Change History tab) and snapshots (beneath the Export panel), each snapshot offering download and a type-to-confirm ("type RESTORE") destructive restore action.

## Config-as-Code: the globus-config Repository

Alliance-Strategies/globus-config stores configuration as one reviewable JSON file per entity, validated against the same schemas BOP's import endpoint enforces. Layout:

`config/ / .json one entity per file envs/ .manifest.json which entities each environment receives scripts/validate.ts schema + filename + manifest validation (CI gate) scripts/build-envelope.ts manifest -> importable v2 envelope (build artifact) scripts/drift-check.ts live export vs repo comparison (daily cron)`

Built envelopes (dist/envelope- .json) are CI artifacts only — never committed, since the checksum is meant to bind them to content at build time, not to a stale committed copy. The repo's own stated three-step workflow: author (export from BOP or hand-edit a file), review (PR triggers CI validation and an envelope build), apply — and merging is explicitly a no-op for any live environment. An operator always imports the built envelope through BOP's Data Management screen, or a machine applies it through the from-repo route with a re-verified human attached. Merges never auto-apply.

BOP reaches the repo through three environment variables. GITHUB_CONFIG_REPO names the repository as owner/name and defaults to Alliance-Strategies/globus-config. Reads (building an envelope from the repo) use GITHUB_CONFIG_REPO_TOKEN, falling back to GITHUB_PAT_WORKFLOW; with neither set the read side raises CONFIG_REPO_NOT_CONFIGURED (503). Publishing PRs uses a separate GITHUB_CONFIG_REPO_WRITE_TOKEN; when it is unset the write side raises CONFIG_REPO_WRITE_NOT_CONFIGURED (503).

### CI Validation (6 Checks)

scripts/validate.ts runs on every PR and on push to main, reusing the same per-category schemas BOP's import endpoint enforces:

- Unknown category directories — any folder under config/ that isn't one of the 13 export categories fails.

- Schema validation — every entity file is parsed against its category's schema; the first five Zod issues per file are reported.

- Filename matches selection key — a file's name must equal fileStemForKey(key) for the entity it contains.

- One selection key per file — a file whose entities resolve to more than one distinct key fails.

- No duplicate keys across files — the same category::key can't appear in two different files.

- Manifests resolve — every selection in every envs/*.manifest.json must reference an entity file that actually exists.

Any finding exits the CI job with status 1 and a per-file list of failures; a clean run logs a single summary line (entity/category/manifest counts) and the CI workflow proceeds to build the development and qa envelopes and upload them as a 30-day workflow artifact.

## Automation: Drift Detection Cron

A GitHub Actions workflow in globus-config runs daily at 06:17 UTC (plus manual dispatch) using an export-only API key — the drift-check script authenticates with an API key that carries config.export and deliberately nothing else, since a cron job should never be able to write. For every entity a given environment's manifest tracks, it fetches the live export from BOP and canonicalizes both sides with the same canonical-JSON function the checksum uses, so ordering differences never register as drift, then compares them byte-for-byte. The workflow's matrix covers development and qa; production is present only as a commented-out entry.

| Exit code | Meaning | GitHub issue behavior |
| --- | --- | --- |
| 0 | Clean — every tracked entity matches live | Only on Mondays: comment/create on the drift-heartbeat-labeled issue, so weekday silence provably means "no drift," not "dead cron." |
| 1 | Drift — at least one entity differs or is missing live | Upserts an open issue labeled config-drift with a per-entity table (category, key, repo file, status), fails the job. |
| 2 | Check itself broke — bad usage, unreachable API, auth failure, malformed response | Upserts an open issue labeled drift-check-error with the tail of the run log — "a failing check must never masquerade as no drift," fails the job. |

Per-selection statuses inside a drift finding are match, drift (content differs), missing_in_live (tracked but absent from the live environment), and missing_in_repo (manifest references a file that doesn't exist). Alerting is GitHub-issue-based rather than a Teams post — no workflow in the org actually consumes a Teams webhook secret, so an issue is the only verified channel.

## Automation: Publish to Repo

The Publish panel calls a route that resolves the same entity closure as selective export, then writes the result to globus-config as a single atomic commit via the GitHub Git Data API (blob → tree → commit → branch → PR). Only files that actually changed — compared byte-for-byte against the current repo content — are written; each target environment's manifest is merged additively, so a publish can never silently drop an existing selection. The commit carries three plain-text trailers:

`Requested-by: Target-envs: Publish-checksum:`

These trailers live only on the original commit object, not in the tracked file tree — deliberately, so that a later commit added during PR review (which can still edit the config files) can never retroactively change who is on record as having requested the change. An annotated tag pointing at the same commit is also created, so the record of "what was requested and by whom" survives even if the PR is later closed without merging.

## Automation: Manifest Sync on Delete/Archive

Publishing only ever adds or updates, so on its own a hard-deleted or archived entity would leave the globus-config manifest silently drifted from BOP. To close that gap, BOP emits two events when a config-transfer-tracked entity (brand, program, season, workflow definition, queue definition, SLA policy, rule bundle, rule definition, reference template, or agreement) changes lifecycle: config.entity.deleted (hard delete — the row is gone, so the selection key is captured before the delete) and config.entity.changed (archived or status flipped). Each payload carries the category, the selectionKey (the same natural key the per-category selection-key functions compute), the action, and the acting staff user.

An automated manifest-sync consumer (an n8n handler workflow, never a human) reacts by calling the machine-only internal routes: config.entity.deleted → POST /api/internal/config-repo/retire, which removes the entity's file and its manifest entry; config.entity.changed → POST /api/internal/config-repo/publish-entity, which re-publishes that single entity's current content (no dependency closure). Both routes are gated by the machine-only config.publish permission, are restricted to the development target environment, attribute the commit to the automation identity (config-manifest-sync@allianceabroad.com) rather than to whichever staff member triggered the underlying delete, and auto-merge the resulting PR immediately — unlike the human Publish panel, there is no reviewer in this loop, and waiting for one would leave the manifest drifted in the meantime. Each call is audited (config.retire.applied / config.publishEntity.applied).

## Automation: Import from Repo (Machine Path)

Once a publish PR merges, applying it to an environment reads the merged repo state at a specific, pinned commit — the ref (branch, tag, or sha) is resolved to an exact commit sha up front, and every subsequent read (the manifest, every entity file) uses that pinned sha, so the run's history records exactly what was read even if the branch moves later. This is the same mechanism whether the caller is a human session or a config.import-scoped API key — the only difference is which authorization path validates the caller (see The Dual-Path Gate above). The rest of the pipeline — dry-run diff, staleness token, transactional apply, snapshot, run row tagged source: "repo" — is identical to a file upload.

> **The sync-apply workflow is the automatic consumer — and it stays human-attributed**
>
> globus-config has a Sync Apply GitHub Actions workflow (sync-apply.yml, running scripts/sync-apply.ts) that runs on every push to main. It scans the commits the push introduced for the publish trailers (Requested-by / Target-envs / Publish-checksum) — a scan that works for squash, merge-commit and rebase merges alike, since the trailers are carried into the merged commit — and, for each target environment listed, calls that environment's POST /api/admin/config-import/from-repo at the pinned commit sha with a config.import-scoped API key (CONFIG_IMPORT_API_KEY_DEVELOPMENT, _QA or _PRODUCTION) and the original requester's email. The BOP-side live re-verification that the named human currently holds system.admin in that environment still applies to every call. A failed (ref, env) pair opens or updates a sync-apply-failure issue; a retry is a manual workflow_dispatch with the config-publish/* tag and an optional only_env, and re-applying an already-applied ref is safe because every entity re-diffs to noop. A merge that carries no publish trailers applies nothing, and plain merges without trailers (hand-edited files) remain a no-op until an operator imports them through BOP.

## End-to-End Flow

## Test Plan

This development-only procedure verifies the v2 envelope, diff, apply, selective-export, snapshot, and recovery guarantees documented on this page. Every write-path test must use an explicitly labelled disposable fixture or an existing dedicated test fixture; business configuration is never an ad hoc test target.

> **Safety rule: back up, dry-run, apply one controlled change, and prove recovery**
>
> Before any write-capable action, export all 13 categories, verify the selected-category count and v2 checksum, and preserve the artifact. Run a dry-run before every apply. Create the restore artifact before the first apply, then independently export the changed category after cleanup to prove that the temporary marker is absent.

| Control | Required procedure | Evidence to retain |
| --- | --- | --- |
| Environment | Use development only. Do not run ad hoc import/apply tests in QA or production. | Base URL, authenticated role, run timestamp. |
| Backup | Select all 13 categories, confirm the selected count, download the v2 envelope, and record its SHA-256 checksum. | Filename, checksum, metadata, selected-category count. |
| Fixture | Use a dedicated fixture such as referenceTemplates:test; change one reversible, non-behavioural field only. | Selection key, original value, temporary value, restore checksum. |
| Diff review | Confirm the review proposes only intended entities and fields before enabling apply. | Diff counts, selected entities, field-level change list. |
| Recovery | Apply the prepared restore envelope or an appropriate snapshot, then make an independent read-only export check. | Restore run ID, snapshot ID, post-restore result. |
| Audit | Confirm import runs and snapshots are recorded newest-first in History. | Run IDs, snapshot IDs, source, status, timestamps. |

### Positive Test Cases

Repeat the following positive-path checks for every release candidate. The executed development baseline completed on 2026-07-24 is included as a reference, not as a substitute for a fresh run.

| ID | Procedure | Expected result | Baseline |
| --- | --- | --- | --- |
| POS-01 | Export all categories after confirming all 13 selections. | A portable v2 envelope contains the requested categories and valid metadata/checksum. | Passed after manual selection of all 11 categories. |
| POS-02 | Re-upload an unchanged authentic v2 backup and choose Review Changes only. | Review creates no write; identical entities are noops and Apply is disabled when zero changes exist. | Passed: 0 changes, 547 noops. |
| POS-03 | Prepare a valid envelope that changes one non-behavioural field on a dedicated fixture. | Review identifies exactly one update and its changed field. | Passed: referenceTemplates:test / description. |
| POS-04 | Apply the reviewed one-entity change. | One applied run and a pre_import_backup snapshot are created. | Passed: one reference-template update with automatic snapshot. |
| POS-05 | Apply the prepared restore envelope for the same fixture. | The original value is restored through the guarded pipeline; a second recovery point and run row are recorded. | Passed: one-entity restoration completed. |
| POS-06 | Export the changed category after restore and search for the temporary marker. | The original value is present and the temporary marker is absent. | Passed: empty description restored; marker absent. |
| POS-07 | Selectively export one known entity with dependency closure enabled. | A checksum-valid v2 envelope identifies requested, included, and required entries in its manifest. | Passed: referenceTemplates:test with no unresolved dependencies. |
| POS-08 | Read snapshots and import-run history after update and restore. | Both applies are auditable, newest-first, and linked to their recovery points. | Passed: two file runs and matching snapshots were present. |

### Negative Test Cases

Every negative case must prove that invalid input or insufficient authorization fails before configuration is modified. An expected failure must not create an applied run, snapshot, or partial configuration change.

| ID | Procedure | Expected result | Baseline |
| --- | --- | --- | --- |
| NEG-01 | Upload a valid-shaped v1 envelope. | Reject before diff/apply and direct the operator to re-export a v2 file. | Passed. |
| NEG-02 | Upload malformed JSON. | Show actionable parse context; review/apply is unavailable. | Passed. |
| NEG-03 | Upload a v2 envelope whose categories do not match its checksum. | Reject as modified/corrupted before diff/apply. | Passed. |
| NEG-04 | Request selective export with a non-existent key. | Return 422 UNKNOWN_SELECTION and no partial envelope. | Passed. |
| NEG-05 | Call a session-protected POST without the CSRF header. | Return 403 CSRF_TOKEN_INVALID with no configuration action. | Passed. |
| NEG-06 | Use an API key without config.export for full export. | Reject without returning configuration content. | Passed. |
| NEG-07 | Use an API key for session-only file import or snapshot history. | Reject before file processing or snapshot disclosure. | Passed; improve session-required messaging. |
| NEG-08 | Mutate the reviewed target in a second controlled session before applying the old diff. | Return 409 STALE_DIFF; require refreshed review. | Required regression test with two controlled sessions. |
| NEG-09 | Apply a selection with a dependency absent from both envelope and target. | Fail atomically with 422 SELECTIVE_GAPS. | Required regression test with seeded dependency fixtures. |
| NEG-10 | Import against an agreement with active under-review or approved content. | Classify the item as blocked and prevent it from applying. | Required regression test with disposable agreement fixture. |

### UI and Release Acceptance

| Check | Acceptance criterion | Baseline result |
| --- | --- | --- |
| Whole-category Select all | All 13 checkboxes select and the action reads Download JSON (13 categories). | Failed: baseline selected only Brands. This is a release gate. |
| No-op review messaging | Publish-safety warnings do not imply drafts will be created when zero entities change. | Improvement required. |
| History traceability | Runs, snapshots, exports, and fixtures carry a visible correlation/test label. | Improvement required. |
| Admin navigation | System administrators can distinguish personal /settings from platform /admin/settings. | Improvement required. |
| Release closure | Temporary fixture is restored, post-restore export proves the marker absent, and all run/snapshot evidence is retained. | Required for every write-path test. |

> **Minimum regression gate before broader operational use**
>
> Do not approve broad operational use until Select all reliably selects all 13 categories and has automated coverage. The stale-diff, selective-gap, blocked-agreement, published-version, forced-rollback, and type-to-confirm snapshot-restore paths also require seeded development fixtures and dedicated regression coverage.

## Known Limitations

- Publish targets are development, qa and production. The valid set (PUBLISH_TARGET_ENVS) is a single source of truth shared by server validation and the UI's picker — adding another environment is a deliberate, reviewed one-line change, not something a request body can smuggle in as an arbitrary string. The automated manifest-sync routes (retire / publish-entity) are still limited to development.

- The scheduled drift-check covers development and qa; production is not yet scheduled (its workflow matrix entry is commented out in drift-check.yml).

- Applying a merged publish PR is automatic only through the sync-apply workflow, and only for merges that carry publish trailers; every from-repo apply still carries a real admin's re-verified email, and a hand-edited merge without trailers is applied only by an operator.
