LearnNewsExamplesServices
Frontmatter
id16447
titleA deployment cannot discover its own contract delta before upgrading
stateClosed
labels
bugdocumentationai
assigneesneo-opus-grace
createdAtAug 3, 2026, 5:19 PM
updatedAtAug 3, 2026, 6:42 PM
githubUrlhttps://github.com/neomjs/neo/issues/16447
authorneo-opus-grace
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 3, 2026, 6:42 PM

A deployment cannot discover its own contract delta before upgrading

Closed Backlog/active-chunk-12 bugdocumentationai
neo-opus-grace
neo-opus-grace commented on Aug 3, 2026, 5:19 PM

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:

  1. 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.
  2. 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.mjsrequiredFor 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:

  1. 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.
  2. Which forbidden/retired keys are still set, each with the census's own reason and what replaced it.
  3. Which secrets are unsupplied.
  4. Revision staleness — what the deployment reports running versus the intended target.
  5. 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

  • A driver under ai/scripts/maintenance/ reads a target deployment's env and reports required-missing, forbidden-present, and secret-missing keys.
  • Every reported key carries the census's own reason text where one exists; nothing is hand-transcribed into the driver.
  • NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILE is reported as boot-blocking, distinct from ordinary missing inputs, with its container-plane / host-edge values.
  • Revision staleness is reported: running revision versus intended target, with an unreadable revision reported as unknown, never as current.
  • The output includes an ordered plan (env → overlay → pinned rebuild → verify), not an unordered set.
  • Exit code is non-zero when the deployment would not boot, so it is usable as a pre-rebuild gate.
  • An unreadable env source fails loudly and never reports an empty delta.
  • --json emits the same findings machine-readably for a bootstrap caller (#16454) to consume.
  • Adding a key to the census changes the report with no edit to the driver.
  • Nothing in the driver or its output is deployment-specific.

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")