Re-scoped 2026-08-03 by operator direction. This ticket was originally "make MigrationPath.md truthful". It is now the executable delta. Rationale in the first comment: Neo itself has no migration to perform, so a hand-written guide serves an audience of zero and documents a manual runbook instead of removing it. The written path is produced from this tool, for the deployment that needs it, after the capability exists.
Context
A deployment several hundred commits behind cannot find out what an upgrade will break before attempting it. Discovering it during a --no-cache rebuild means discovering it as a container that never starts — no state directory, no PID file, no log.
The contract is already machine-readable. Nothing reads it on a deployment's behalf.
The Problem
The delta is fully derivable today and is derived by hand, or not at all.
ai/scripts/lint/config-leaf-parity.json partitions every NEO_* / MCP_* key the reference Compose profiles carry:
- 13
requiredDeploymentInputs — must resolve to a value.
- 22
forbiddenEnv — retired or derived, each already carrying a written reason (NEO_MESSAGE_WAL_DIR → "derived from NEO_MEMORY_WAL_DIR"; the NEO_ORCHESTRATOR_*_ENABLED family → "disabled by the cloud/container config posture").
- 2
secrets — supplied out of band.
lint-config-template-ssot.mjs enforces that census against our Compose profiles in our CI. An external deployment has no way to run the same comparison against its own env, and that is the whole gap.
Two hazards make the manual path worse than merely tedious:
NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILE refuses boot. Its default is deliberately empty — #16229 removed the default so requiredFor (which evaluates the resolved value) could arm. A deployment that "changes nothing" gets a refused orchestrator launch, and the missing log is the symptom.
- Staleness is invisible. The reference
Dockerfile acquires source by NEO_REF (default dev), and Docker does not invalidate a RUN layer when remote content changes. An unpinned deployment is stale by cache, reports success on every rebuild, and crosses every intervening contract generation at once when someone finally passes --no-cache.
Adjacent, and deliberately not the same thing: ai/scripts/setup/migrateConfigOverlay.mjs reconciles config.mjs overlay structure and states explicitly that it handles "never env-resolved values". The env half is covered by nothing.
The Architectural Reality
ai/scripts/lint/config-leaf-parity.json — $composeDefaultParity.census (requiredDeploymentInputs / optionalOverrides / secrets) + $composeDefaultParity.forbiddenEnv as a key→reason map. The authority.
ai/scripts/lint/lint-config-template-ssot.mjs — proves the census tracks the Compose profiles. Our-side only.
ai/configBase.mjs — requiredFor metadata; authorityProfile carries the one human-readable reason among the required inputs.
ai/scripts/setup/migrateConfigOverlay.mjs — the overlay half; explicitly not env.
ai/scripts/maintenance/ — sibling precedent for operator-runnable deployment drivers (redeployPreflight.mjs, backup.mjs, restore.mjs). Placement anchor.
/app/.neo-revision — measured running revision; "always populated, always true" (PipelineWiring.md).
The Fix
One operator-runnable driver that reads a deployment's actual env and reports the ordered delta. Derived from the census on every run, so it cannot rot the way a transcribed list does.
It answers, for a given deployment:
- Which required inputs are missing, with the reason text where one exists, and
NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILE called out as boot-blocking rather than listed flat.
- Which forbidden/retired keys are still set, each with the census's own reason and what replaced it.
- Which secrets are unsupplied.
- Revision staleness — what the deployment reports running versus the intended target.
- An ordered plan: env delta → overlay reconcile → pinned rebuild → verify. Ordered because applying the env delta after the rebuild is how you get an unreadable failure.
Exit non-zero when the deployment would not boot, so the same command works as a pre-rebuild gate and not only as a report.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback / Error Semantics |
Docs |
Evidence |
delta driver (ai/scripts/maintenance/) |
config-leaf-parity.json census + forbiddenEnv reasons |
Reads a deployment's env, emits missing-required / present-forbidden / missing-secret / staleness + an ordered plan |
Env source unreadable ⇒ fail loudly; never report an empty delta, which reads as "you are fine" |
the driver's own --help + JSDoc |
a fixture env missing NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILE exits non-zero and names the boot refusal |
| machine-readable output |
this ticket |
--json for a caller to consume as the plan step of a bootstrap |
Malformed request ⇒ non-zero with the parse error, never partial JSON |
same |
the JSON shape is asserted by spec |
| census freshness |
config-leaf-parity.json |
The delta is computed per run; adding a required input changes the output with no edit here |
Census missing/unparseable ⇒ hard failure |
same |
adding a key to the census fixture appears in the report without touching the driver |
Decision Record impact
none — an operator driver over an existing machine-readable census. No config semantics change; no new container privilege.
Acceptance Criteria
Out of Scope
- Executing the migration. #16454 owns plan/apply bootstrap and the pipeline invocation; this produces the plan input it consumes.
- Changing any config default or requiredness. The contract is correct; discoverability is what is missing.
- The written migration guide. It is produced from this tool's output for the deployment that needs it, and it is not a Neo deliverable — Neo performs no migration. This is the correction that re-scoped the ticket.
#16040's onboarding rewrite — first-run ergonomics, blocked on #15798 + #16039, unrelated to upgrade discovery.
- Overlay structure reconciliation.
migrateConfigOverlay.mjs owns it; this reports that it needs running.
- Any specific deployment's configuration. Third-party specifics never live in this repo.
Avoided Traps
- Writing the guide first. A guide describing a five-step manual runbook documents the pain instead of removing it, and Neo has no migration to perform — so it would be maintained by people who never run it, for an audience that is not here.
- Hand-transcribing the inventory. That is exactly how the current
MigrationPath.md rotted into "no breaking changes". A second hand-list with a newer date is the same artifact.
- Reporting an empty delta on an unreadable input. Silence reads as safety; this is the same false-confidence class as a green check on an unmeasured axis.
- Folding this into #16454. Its plan step consumes this output; bundling them makes one ticket no single PR can cleanly resolve.
Related
- #16454 — plan/apply bootstrap + safe pipeline invocation; the consumer of this output.
- #16229 — removed the
authorityProfile default, arming its requiredness.
- #16039 — the config-default consolidation that produced the census.
learn/agentos/cloud-deployment/PipelineWiring.md — revision provenance and the pinned-rebuild contract.
Origin Session ID: 9f05cd72-5457-4ec2-926c-ef1406041f19
Retrieval Hint: query_raw_memories("deployment contract delta driver required forbidden env census discoverable before rebuild boot refusal")
Context
A deployment several hundred commits behind cannot find out what an upgrade will break before attempting it. Discovering it during a
--no-cacherebuild means discovering it as a container that never starts — no state directory, no PID file, no log.The contract is already machine-readable. Nothing reads it on a deployment's behalf.
The Problem
The delta is fully derivable today and is derived by hand, or not at all.
ai/scripts/lint/config-leaf-parity.jsonpartitions everyNEO_*/MCP_*key the reference Compose profiles carry:requiredDeploymentInputs— must resolve to a value.forbiddenEnv— retired or derived, each already carrying a written reason (NEO_MESSAGE_WAL_DIR→ "derived fromNEO_MEMORY_WAL_DIR"; theNEO_ORCHESTRATOR_*_ENABLEDfamily → "disabled by the cloud/container config posture").secrets— supplied out of band.lint-config-template-ssot.mjsenforces that census against our Compose profiles in our CI. An external deployment has no way to run the same comparison against its own env, and that is the whole gap.Two hazards make the manual path worse than merely tedious:
NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILErefuses boot. Its default is deliberately empty —#16229removed the default sorequiredFor(which evaluates the resolved value) could arm. A deployment that "changes nothing" gets a refused orchestrator launch, and the missing log is the symptom.Dockerfileacquires source byNEO_REF(defaultdev), and Docker does not invalidate aRUNlayer when remote content changes. An unpinned deployment is stale by cache, reports success on every rebuild, and crosses every intervening contract generation at once when someone finally passes--no-cache.Adjacent, and deliberately not the same thing:
ai/scripts/setup/migrateConfigOverlay.mjsreconcilesconfig.mjsoverlay structure and states explicitly that it handles "never env-resolved values". The env half is covered by nothing.The Architectural Reality
ai/scripts/lint/config-leaf-parity.json—$composeDefaultParity.census(requiredDeploymentInputs/optionalOverrides/secrets) +$composeDefaultParity.forbiddenEnvas a key→reason map. The authority.ai/scripts/lint/lint-config-template-ssot.mjs— proves the census tracks the Compose profiles. Our-side only.ai/configBase.mjs—requiredFormetadata;authorityProfilecarries the one human-readablereasonamong the required inputs.ai/scripts/setup/migrateConfigOverlay.mjs— the overlay half; explicitly not env.ai/scripts/maintenance/— sibling precedent for operator-runnable deployment drivers (redeployPreflight.mjs,backup.mjs,restore.mjs). Placement anchor./app/.neo-revision— measured running revision; "always populated, always true" (PipelineWiring.md).The Fix
One operator-runnable driver that reads a deployment's actual env and reports the ordered delta. Derived from the census on every run, so it cannot rot the way a transcribed list does.
It answers, for a given deployment:
NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILEcalled out as boot-blocking rather than listed flat.Exit non-zero when the deployment would not boot, so the same command works as a pre-rebuild gate and not only as a report.
Contract Ledger Matrix
ai/scripts/maintenance/)config-leaf-parity.jsoncensus +forbiddenEnvreasons--help+ JSDocNEO_AI_ORCHESTRATOR_AUTHORITY_PROFILEexits non-zero and names the boot refusal--jsonfor a caller to consume as the plan step of a bootstrapconfig-leaf-parity.jsonDecision Record impact
none— an operator driver over an existing machine-readable census. No config semantics change; no new container privilege.Acceptance Criteria
ai/scripts/maintenance/reads a target deployment's env and reports required-missing, forbidden-present, and secret-missing keys.NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILEis reported as boot-blocking, distinct from ordinary missing inputs, with itscontainer-plane/host-edgevalues.--jsonemits the same findings machine-readably for a bootstrap caller (#16454) to consume.Out of Scope
#16040's onboarding rewrite — first-run ergonomics, blocked on#15798+#16039, unrelated to upgrade discovery.migrateConfigOverlay.mjsowns it; this reports that it needs running.Avoided Traps
MigrationPath.mdrotted into "no breaking changes". A second hand-list with a newer date is the same artifact.Related
authorityProfiledefault, arming its requiredness.learn/agentos/cloud-deployment/PipelineWiring.md— revision provenance and the pinned-rebuild contract.Origin Session ID: 9f05cd72-5457-4ec2-926c-ef1406041f19
Retrieval Hint:
query_raw_memories("deployment contract delta driver required forbidden env census discoverable before rebuild boot refusal")