# Deploy Seed Catalog, Planning, and Dispatch Contract

## Status and scope

This document defines the current single deploy Seed entrypoint and its
boundary with release-owned lifecycle work.

The current contract consists of:

1. one versioned Catalog of deploy-time data/configuration modules;
2. one directory-only, read-only Planner;
3. deterministic `seed-plan.json` and `deploy-seed-inventory.json` artifacts;
4. mechanical validation that the Catalog still matches the current callers;
5. one phase-scoped Dispatcher with reviewed adapters for all eleven
   Seed-owned modules.

The Planner must not connect to a database or API, read secrets, execute an
adapter, or modify the source directory.

`app_shell` and `hubspot_owner_directory_lifecycle_sync` remain owned by the
deploy pipeline because they are release configuration and a pack lifecycle
job, respectively. They are inventoried and callsite-checked by the Catalog but
cannot be executed by the Seed Dispatcher.

The Dispatcher preserves the existing per-module implementation engines:
full-coverage bulk apply for baselines, governed mysql-CLI reference data, reviewed
scripted adapters for approval and region data, and the partitioned EXP owner
binding adapter. Credential-bearing password alignment, secret provisioning,
repair, reset, and control-plane migration work remain explicitly outside Seed.
In particular, the `HubSpotPrivateApp` Named Credential definition is owned by
the official app-pack provisioning runbook, while its token is owned by secret
provisioning; neither belongs in the deploy baseline Seed.

## Responsibility boundary

Release admission and Seed planning answer different questions.

Release admission owns:

- release tag, lineage, ref, and expected-commit authority;
- checkout and clean isolated worktree preparation;
- associating the resolved downstream release commit with later evidence;
- environment-specific permission to mutate or publish.

The Seed Planner owns:

- reading one caller-prepared directory;
- validating the Catalog, data-source, adapter, and callsite contracts;
- selecting the invocations declared for one environment profile;
- identifying the exact source bytes used to build the artifacts.

The Seed Dispatcher owns:

- regenerating and validating the plan from the caller-prepared directory;
- selecting one profile phase and evaluating its typed conditions;
- executing only `seed_dispatcher` modules through a reviewed adapter;
- emitting one machine-readable apply-evidence artifact;
- proving that the planned source fingerprint did not drift before or after
  adapter execution.

The Planner accepts only `--source-root`. It has no source-ref mode, does not
inspect repository metadata, and does not run external commands. A complete
directory snapshot without repository metadata is a valid input.

Staging resolves release authority before preparing `downstream_build_root`,
then invokes the Dispatcher from that prepared directory. Release orchestration
retains the resolved downstream commit, while Dispatcher evidence records the
plan's content fingerprint. The content fingerprint is content identity, not
release-origin proof. Ordinary Production has no Seed-owned Catalog invocation
and does not invoke the Dispatcher. Its deploy-pipeline app-shell invocation is
executed by the common stopped-upgrade controller from the exact BOM-selected
L2 configuration.

## Catalog

The Catalog is
`deployments/offline/runtime/seeds/deploy-seed-catalog.json`. Its top-level
fields are fixed:

```text
schema_version
catalog_version
modules
profiles
expected_callsites
excluded_mutations
```

Unknown or missing fields fail closed. Catalog version and artifact/source
format versions are independent. Changing the Catalog's meaning requires a
`catalog_version` update; changing its structure requires a `schema_version`
update.

Catalog schema version 3 records execution ownership once on the module as
`execution_owner`. A module is owned by either `seed_dispatcher` or
`deploy_pipeline`. Every `seed_dispatcher` module must have a canonical
`dispatcher_adapter`; deploy-pipeline modules use `null` because the Dispatcher
must not execute them. Invocation ownership is derived from the referenced
module instead of being repeated on every profile row.

Schema version 3 also removes the redundant invocation `behavior` field. Every
Catalog invocation is required: a selected adapter failure, invalid evidence,
or missing required context fails the phase. The generated plan and inventory
artifact `schema_version` is also 3 because module and invocation rows use this
post-migration shape.

### Current inventory

The Catalog records 13 modules, 6 profiles, 31 current invocations, 29
expected callsite assertions, and 10 explicit exclusions.

| Module | Current declared data | Intended owner |
|---|---:|---|
| `app_shell` | 1 app shell | deploy pipeline |
| `deploy_baseline` | 23 records across 5 objects | Seed Dispatcher |
| `exp_baseline` | 18 duplicate-free records across 6 objects + 1 lookup + 1 app-shell reference | Seed Dispatcher |
| `pack_reference_data` | 204 effective records across 3 objects | Seed Dispatcher |
| `pingcap_region_owner_rules` | 6 records + 6 lookups | Seed Dispatcher |
| `connected_apps` | 2 connected apps + 2 run-as users (staging + EXP) | Seed Dispatcher |
| `credits_approval_seed` | 2 definitions, 6 nodes, 18 actions, 4 templates | Seed Dispatcher |
| `credits_permissions_seed` | 5 permission sets, 5 object permissions, 29 field permissions | Seed Dispatcher |
| `account_approval_notify` | 5 process actions | Seed Dispatcher |
| `exp_hubspot_owner_bindings` | 52 mappings | Seed Dispatcher |
| `identity_sync_policy` | 1 `IdentitySyncSetting(Name=default)` singleton + 1 zero-access onboarding Profile + 1 least-privilege service Profile + 1 dedicated service User | Seed Dispatcher |
| `hubspot_owner_directory_lifecycle_sync` | 1 post-activation job | deploy pipeline |

The `connected_apps` module keeps `User.ProfileId` environment-agnostic. Its
reviewed seed stores the platform profile key (`system_admin`) for fresh-EXP
creation and a semantic `match.ExistingProfileName` for the real User
restored into Staging. The adapter resolves the create key through the unique
`Profile(Name="System Administrator")` row; an existing User must instead be
active and match the reviewed semantic Profile name. Physical Salesforce
Profile IDs are never tracked, and an unreviewed Profile remains a fail-closed
mismatch. The deployment never patches the real User.

The governed reference-data module owns all 114 `Industry_Vertical__c` records,
plus 11 `ReportFolder` and 79 `_System_EmailTemplate` records. The former
duplicate Industry copy has been removed from the shared deploy baseline.
Test and EXP consume their reviewed reduced selections through the Dispatcher;
staging consumes the full governed selection through its
`post_backend_health` Dispatcher phase before applying the residual shared
baseline. Full local and remote D3 initialization or stopped upgrade applies
the app shell through the sealed L2 release-configuration mechanism. The
common D3 controller re-proves the exact BOM-selected L2 checkout immediately
before reading configuration, executes code from the reviewed controller
checkout, and converges it before Catalog publication. This invocation is not
a Seed Dispatcher module; process-only Test image replacements do not replay
it. A legacy `runtime-applied` checkpoint with no complete config-bound receipt
can recover only after the controller proves no D3 containers, re-quiesces the
legacy runtime, and re-verifies Catalog and Worker ownership authority.

App-shell installation stops the target slot; Prod predecessors can still read the
shared database. Before any shell SQL, the selected Controlplane runs
`application-navigation -validate-only` on the same loaded template, using the
initialization decoder and semantic validator without opening a database. Invalid
navigation input therefore leaves the shell unchanged. Its application,
menu, and profile SQL runs in one short database transaction; a failed statement
rolls back the batch, including permission deletions. Controlplane then initializes
navigation in its existing transaction and preserves initialized current values.
A navigation failure or lost receipt leaves the install incomplete; retry replays
the shell and reads/preserves navigation. Catalog publication and service start
remain gated on successful completion. There is no cross-process transaction.

The script validates its execution selection before SQL. Published images must
use the canonical Controlplane repository and an already-present immutable digest;
publication verification remains owned by D3 admission. Local image IDs require
the explicit existing unsigned candidate manifest and its image verification.
Legacy local Catalog overrides carry their exact source revision into the same
app-shell callsite. The host-only override is checked by the existing local-role
image verifier and resolved to one immutable image ID before preflight and apply.
Image execution never implicitly pulls. The explicit local executable option is
for development verification.

### Module contract

Each module declares:

- `dataset_class`: what kind of data/configuration it represents;
- `execution_owner`: `seed_dispatcher` or `deploy_pipeline`;
- `dispatcher_adapter`: reviewed adapter identifier for `seed_dispatcher`, or
  `null` for `deploy_pipeline`;
- `data_sources`: tracked data inputs;
- `adapter_paths`: current apply/verify implementations, inventoried but never
  executed by the Planner;
- `authoritative_paths`: manifests or configuration that select the module's
  effective inputs;
- `selection_schema`: typed, allowlisted per-invocation selection;
- `declared_stats`: reviewed counts used to make current divergence visible.

All paths are canonical source-root-relative POSIX paths. Empty paths,
absolute paths, `.`, `..`, repeated or trailing slashes, backslashes, colons,
and control characters are rejected. A symlink is accepted only when its
resolved regular-file target remains inside `source-root`.

`expected_count` counts static direct calls at a callsite. One shared deploy
controller call may map multiple environment-profile invocations when each
distinct profile reaches that exact call. Multiple invocation IDs from one
profile cannot satisfy this exception. Every invocation still maps to exactly
one callsite. All other module-owned callsites map exactly one invocation per
expected direct call.

The Dispatcher currently recognizes `deploy_baseline_v1`, `exp_baseline_v1`,
`pack_reference_data_v1`, the generic `scripted_seed_v1` protocol, and the
generic `scripted_seed_partition_v1` protocol. The versioned module adapters
keep their commands and reviewed invocation scopes in the Dispatcher. The
scripted protocols instead use the module's first `adapter_paths` entry as their
Python entrypoint and pass all Catalog-declared data sources through a fixed CLI
contract. The Planner's source fingerprint therefore covers both the script and
its inputs before mutation.

A scripted adapter receives `--source-root`, `--module-id`,
`--invocation-id`, `--output-dir`, `--selection-json`, one or more
`--data-source` arguments, and optionally `--dry-run`. The entrypoint must be a
tracked `.py` file under `scripts/` or `deployments/offline/runtime/`.
`scripted_seed_v1` must emit an exact `deploy-seed-script-v1` summary containing
the module and execution mode, canonical apply engine, planned and applied
counts, complete coverage, verification status, and non-negative duration.
Planned counts must equal the Catalog's `declared_stats`; dry-run applied counts
must be zero, and apply counts must equal the plan. Unknown summary fields fail
closed so adapter output cannot copy arbitrary values or secrets into deploy
evidence.

`scripted_seed_partition_v1` uses the same CLI contract, emits
`deploy-seed-script-partition-v1`, and adds exact `outcome_counts` to the
summary. It is for modules where every declared input is processed but valid
runtime outcomes are data-dependent. Apply counts still equal the Catalog plan,
while the non-negative outcome counts must partition the single applied count
exactly. Dry-run outcome counts are zero.

The current scripted modules are `pingcap_region_owner_rules`,
`credits_approval_seed`, `credits_permissions_seed`,
`account_approval_notify`, and `identity_sync_policy`.
The formal D3 staging workflow currently persists
`APPLY_IDENTITY_SYNC_POLICY=false` as an emergency recovery boundary while the
reviewed Seed invariant is reconciled with Core's setup-object read baseline.
The D3 controller must skip both the identity-policy Dispatcher phase and its
candidate-state receipt while that flag is false; native SCIM remains disabled
and this state is not eligible for a Staging D3 release Tag or Prod promotion.
When the flag is restored to true, the controller invokes the policy through
the canonical Seed Dispatcher after local service readiness and before nginx
publication, requires exact applied counts for the singleton and onboarding
Profile, service Profile, and service User, and stores a v2 candidate-state
receipt. The service Profile grants no User DELETE and materializes an explicit
deny for every runtime field outside the reviewed SCIM field set; the service
User must have no additive Profile, Permission Set, Permission Set Group,
record-type, custom-permission, or group grant. The independently published
sfcrm AppRelease owns the native `/scim/v2` surface; native-route acceptance
still requires the separate reviewed D3 BOM selection and Staging deployment.
The adapters accept only the reviewed module data source and an empty selection.
The Credits adapter reuses the existing
transactional direct-SQL applier and preserves the shared `post_quote_approval`
phase across test, EXP, and staging; production has no Credits
invocation. The staging-only Account notification adapter reuses the existing
fail-closed direct-SQL binder after Credits and reference-data, without enabling
a production invocation. `exp_hubspot_owner_bindings` uses the partitioned
protocol so evidence distinguishes resolved bindings from tolerated unresolved
mappings without failing a valid EXP fallback. Additional procedural Seed
modules may adopt either scripted protocol by adding a conforming module-specific
script and Catalog configuration; they do not require another hardcoded
dispatcher branch.

### Profile and invocation contract

The exact profile set is:

```text
local
ci
test
exp
staging
production
```

Each invocation declares:

```text
invocation_id
module_id
phase
order
condition
selection
```

Conditions are typed. Version 1 supports:

- `always`;
- `env_flag` with required boolean `default` and `equals` values;
- `required_env_flag` with the same typed values, but a mismatch fails the
  dispatch instead of recording a skipped invocation;
- `argument_flag` with required boolean `default` and `equals` values;
- `selected_stage`, validated against the selected profile's deploy driver
  stage contract (`TEST_STAGES` or `STAGING_STAGES`);
- `runtime_mode`, allowlisted separately for non-stage entrypoints;
- `all` containing at least two typed conditions.

An invocation's execution owner is derived from its module. Selection fields
must exactly match the module's schema, contain no duplicates, remain inside
the allowlist, and use allowlist order.

Test, EXP, and staging use `APPLY_SHARED_DEPLOY_BASELINE` to control the shared
`deploy_baseline` module.

Production has exactly zero `seed_dispatcher` invocations. Ordinary Prod never
applies reference data or identity-sync policy. Exact-candidate pre-start setup
may converge schema, metadata, `InstalledPack` state, and the deploy-pipeline
app-shell release configuration, but it does not invoke deploy seed modules.
Adding a Production `seed_dispatcher` invocation would violate the Prod
promotion contract and requires a separate architecture decision, not a
Catalog-only change.

Exactly two non-production invocations use the dedicated
`governed_reference_data` phase:

```text
test.pack_reference_data.governed_reference_data
exp.pack_reference_data.governed_reference_data
```

Each is independently pinned in the Dispatcher to its reviewed Catalog object
selection. Catalog selection cannot broaden or narrow those scopes.
`DEPLOY_PACK_REFERENCE_DATA_APPLY=false` produces explicit skipped evidence
without running the adapter.

### Callsite closure

Every invocation maps to exactly one expected callsite entry. Each callsite
provides a source path, a direct-execution marker, and an expected count. A
count change fails planning, including when a direct call is removed or a new
duplicate call is introduced.

For Python call markers, the Planner also parses the source AST and counts
executable calls to the marker's terminal symbol. Direct calls, imported
aliases, simple assignment aliases, and calls inside newly added wrappers are
counted; comments and string literals are not. Both the exact marker count and
the executable-call count must match. `expected_python_calls` may be declared
when a file intentionally has additional calls to the same symbol that are not
separate profile invocations; any further call still fails closed. Non-call
markers, such as a referenced reconcile script path, retain exact text-count
validation.

Shell callsites declare a complete `shell_command` token locator and receive a
second token-aware simple-command check without executing the script. The
parser recognizes commands across conditionals, pipelines, `;`, `&&`, `||`,
subshells, and line continuations while excluding comments, quoted marker
strings, heredoc bodies, function declarations, and array assignments. The
locator is matched to one executable command and its ordered required
arguments; the exact marker count and executable-command count must both match.

Known deploy-time mutations that must not enter the Seed dispatcher are
recorded in `excluded_mutations`. These include database/branch reset,
control-plane migrations and backfills, `RepairLegacyUniqueDuplicates`, patch
replay, secret provisioning, APAC convergence, targeted pack-source repair,
the opt-in Order layout operational reconcile, and the deterministic D3
inspection metadata written before Catalog publication. The latter is
case-specific Catalog authority shared by every Test slot and Formal Staging,
not a general environment baseline or a post-deploy Seed phase.
An exclusion documents ownership; it never authorizes execution.

## Directory source and fingerprint

All Catalog-declared input files are read through one cache. A file is:

1. path-validated;
2. resolved and checked for root containment and regular-file type;
3. read exactly once as raw bytes;
4. parsed or searched from those cached bytes;
5. hashed from the same cached bytes.

This prevents the plan from being based on one read while the fingerprint is
based on a later read.

Source evidence uses this versioned shape:

```json
{
  "format": "deploy-seed-source-v1",
  "entries": [
    {
      "declared_path": "deployments/offline/runtime/seeds/deploy-seed-catalog.json",
      "resolved_path": "deployments/offline/runtime/seeds/deploy-seed-catalog.json",
      "content_sha256": "<lowercase SHA-256>"
    }
  ],
  "source_fingerprint_sha256": "<lowercase SHA-256>"
}
```

Every entry always has all three fields. For a non-symlink,
`resolved_path == declared_path`. If any path component is a permitted in-root
symlink, `resolved_path` is its final source-root-relative target. Different
declared paths that resolve to the same target remain different entries. A
declared path read more than once produces one entry.

Entries are sorted by `declared_path`. The overall fingerprint is SHA-256 over
UTF-8 encoded canonical JSON of this object without the fingerprint field:

```text
{
  "entries": [...],
  "format": "deploy-seed-source-v1"
}
```

Canonical JSON uses sorted object keys, no insignificant whitespace,
`ensure_ascii=false`, and separators `,` and `:`. File hashes are lowercase
SHA-256 hexadecimal strings. The fingerprint never includes itself or an
absolute filesystem path.

Coverage is normative, not incidental. Version 1 fingerprints:

- the Catalog itself;
- every module data source, adapter, and authoritative path;
- every expected-callsite source file;
- every explicit exclusion source file.

The Planner does not scan the whole directory. Unrelated file changes do not
alter the fingerprint.

## Artifacts and invocation

Example:

```bash
python3 scripts/plan_deploy_seed.py \
  --source-root /path/to/prepared/downstream \
  --profile staging \
  --catalog-relpath deployments/offline/runtime/seeds/deploy-seed-catalog.json \
  --plan-output /path/to/deploy-artifacts/seed-plan.json \
  --inventory-output /path/to/deploy-artifacts/deploy-seed-inventory.json
```

The two output paths must:

- be different regular-file paths;
- use the same parent directory;
- be outside `source-root`;
- never overwrite a Catalog/source/adapter/callsite input.

Both artifacts are rendered without timestamps and written through temporary
files. A normal write failure restores the previous pair rather than leaving a
new plan with an old or missing inventory.

`seed-plan.json` contains the selected profile, ordered invocations, selected
module definitions, summary counts, and source evidence.
`deploy-seed-inventory.json` contains all modules, profile counts, callsite
contracts, explicit exclusions, and the same source evidence.

## Mutation Dispatcher

The Dispatcher is `scripts/apply_deploy_seed.py`. It always regenerates the
plan itself; it does not accept a caller-edited plan as mutation authority.
Every call names exactly one profile phase and must explicitly choose
`dry-run` or `apply`:

```bash
python3 scripts/apply_deploy_seed.py \
  --source-root /path/to/prepared/downstream \
  --profile test \
  --phase governed_reference_data \
  --execution-mode dry-run \
  --output-dir /path/to/deploy-artifacts/seed-dispatch
```

The output directory must be empty and outside `source-root`. The Dispatcher
writes:

```text
seed-plan.json
deploy-seed-inventory.json
seed-apply-evidence.json
adapters/<invocation-id>/runtime-state.json
adapters/<invocation-id>/summary.json
```

`seed-apply-evidence.json` uses format `deploy-seed-apply-v1` and records the
Catalog version, profile, phase, explicit execution mode, source fingerprint,
condition decisions, adapter identifier, selection, per-invocation status,
and sanitized adapter summary. Secrets are inherited only through the process
environment and are not serialized into the evidence.

Dispatch is phase-scoped. `deploy_pipeline` invocations are never executed by
this tool. All enabled invocations are adapter-validated before the first
mutation. An adapter exit, missing or mismatched summary, unsupported record,
incomplete coverage, selection mismatch, or source drift fails the phase. A
non-zero adapter exit also emits failure evidence with an exit code, but never
records database credentials.

Conditions keep their Catalog defaults. Environment and argument flags accept
only explicit boolean spellings. Stage- and runtime-mode-dependent phases
require `--selected-stage` or `--runtime-mode`; omitted context fails rather
than silently skipping a required invocation. `--argument-flag name=true|false`
provides typed deploy-driver argument state without granting arbitrary CLI
forwarding to an adapter.

The governed reference-data adapter resolves dependency layers and emits its
204-record staging plan in `dry-run` without database access. In `apply`, it
continues to use the existing mysql-CLI implementation and existing TiDB
environment contract. The Region Owner scripted adapter likewise reuses the
existing REST upsert and verification functions rather than introducing a
second data-writing implementation.

The EXP and shared baseline adapters use the same bulk-only apply helper. Bulk
coverage is mandatory; unsupported or uncovered records fail the phase because
the retired REST Seed runner is no longer a fallback. The EXP adapter's reviewed
summary reports 18 configured and physical records with no collapsed aliases or
duplicate match groups. The one app-shell declaration is reported as a source
reference and is not applied a second time.

The release-owned EXP product catalog is intentionally small: one active
`Pricebook2`, seven active TiDB Cloud `Product2` rows (`C001` through `C007`),
and seven USD `PricebookEntry` rows. Every entry includes `CurrencyIsoCode` in
both `match` and `fields`; scenario-specific products, currencies, or prices
belong in run-scoped Seed data. This replaces the lossy original-staging
snapshot rather than guessing the omitted currency of its historical prices.
The former ten-Account, ten-Contact, ten-Lead, and ten-Campaign staging
snapshot is not an environment prerequisite and has been removed; journey
fixtures belong in run-scoped Seed data.
The original-staging identity/security snapshot has also been removed: five roles,
eleven permission sets, six users, and ten permission-set assignments were not
EXP runtime prerequisites. Removing them also retires the EXP-only seeded-user
count gate and password-hash alignment; reviewed shared actors in
`deploy-baseline.seed.yaml` remain unchanged.
Seed apply remains non-destructive: a clean or reset deployment receives only
the reviewed catalog, while reapplying to an existing database does not prune
historical rows.

The 114-record Industry-only non-production scope uses the same governed
reference-data adapter. Remote deploy evidence is written under the run's
`DEPLOY_ARTIFACT_DIR/seed-dispatch/`, with a fresh attempt directory for every
dispatch so a failed stage can be retried without overwriting its prior
evidence. Dispatcher output always uses a fresh directory outside the source
tree because it must never mutate or overlap its source directory.

## Boundary and change conditions

The Planner remains read-only and stops rather than expanding scope if its
implementation requires:

- release tag/ref/commit or source-directory cleanliness authority;
- database or API access;
- a secret;
- adapter execution;
- an existing deployment callsite change;
- Seed/reference-data content changes;
- a mutation dispatcher.

Adding a new Seed module requires reviewed data ownership, a Catalog module and
profile invocation, a conforming adapter, a callsite closure entry, and focused
dry-run/apply evidence. Ordinary Production must retain zero `seed_dispatcher`
invocations; shared-state changes are governed migrations rather than
production Seed selection changes.

Database reset policy, credential alignment, secret provisioning, repair,
backfill, and pack lifecycle work must not be moved into Seed as incidental
cleanup. Each such ownership change requires a separate design and review.

Quote approval definitions, nodes, and actions are existing organization configuration.
The former EXP Quote seed and its SQL installation adapter are retired. Ordinary
deployment must not recreate or overwrite these rows. The four sanitized XML
references under `deployments/offline/runtime/reconcile/` belong only to the
existing guarded action-wrapper repair; they are not installable approval data.
