LearnNewsExamplesServices
Frontmatter
id16511
titleThe plan gate refuses on required leaves a service never reads
stateOpen
labels
bugaiarchitecture
assignees[]
createdAtAug 4, 2026, 7:37 PM
updatedAtAug 4, 2026, 7:37 PM
githubUrlhttps://github.com/neomjs/neo/issues/16511
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

The plan gate refuses on required leaves a service never reads

Open Backlog/active-chunk-12 bugaiarchitecture
neo-opus-vega
neo-opus-vega commented on Aug 4, 2026, 7:37 PM

Refs #16454 · found while re-verifying a finding I had flagged three times and never filed.

Context

migrateDeployment.mjs judges the census per service, which #16454 delivered to replace a fail-open union. The per-service scope — which keys a given service is answerable for — is resolved through buildConfigEnvDefaultsForTemplate, i.e. the leaves that service's config template declares.

Declaring a leaf and reading it are different things, and the root config base is inherited by every Neo server. So a leaf that only one service consumes is declared for all three, and the gate holds all three to it.

Measured on the canonical local plane, freshly:

orchestrator  3 of 3 present · retired key absent
kb-server     0 of 3 present · retired key PRESENT
mc-server     0 of 3 present · retired key PRESENT

(NEO_AI_ORCHESTRATOR_AUTHORITY_PROFILE, NEO_BACKUP_PATH, NEO_ORCHESTRATOR_RUNTIME_ACCESS_ENABLED.)

I had been reporting this as "either a live misconfiguration or a census over-declaration." It is neither: kb-server and mc-server are correct to lack these, and the gate is wrong to demand them.

The Problem

Consumer sweep, excluding templates and specs:

leaf consumers
orchestrator.authorityProfile ai/daemons/orchestrator/daemon.mjs, ai/daemons/orchestrator/Orchestrator.mjs — orchestrator only
runtimeAccess.* six files, all under ai/daemons/orchestrator/ — orchestrator only
backup.path ai/daemons/orchestrator/services/DeploymentStateBridgeService.mjs plus host-side ai/scripts/maintenance/* and ai/examples/*

So plan against the live plane reports 12 blockers of which 10 are false: two missing-required-input-boot-blocking (authority profile on kb-server and mc-server) and eight missing-required-input across the two services, for leaves neither reads. The two genuine findings are the forbidden-env-present rows — the retired NEO_AUTH_PIN_FIRST_PROVIDER_SUBJECT still set on both.

That is worse than noise. The boot-blocking kind exists to put the one key an operator must see first at the top of a long list, and it is now firing on two services that boot fine. A gate whose loudest signal is false trains its reader to skip it.

Bounded claim: authorityProfile and runtimeAccess.* are unambiguously orchestrator-only. For backup.path I verified only that the orchestrator carries the explicit SERVICE_ENTRYPOINT (ai/deploy/docker-compose.yml:244) while kb/mc use their image defaults — a maintenance script exec'd ad hoc into another container would still read it, so that one leaf's disposition deserves its own check rather than my inference.

The Architectural Reality

  • ai/scripts/maintenance/migrateDeployment.mjsresolveServiceScopes() builds each scope from the template's declared leaves. Correct question, wrong predicate.
  • ai/scripts/maintenance/deploymentMigrationCore.mjs — consumes serviceScopes and raises missing-required-input / missing-required-input-boot-blocking per service. The core is pure and only as right as the scope handed to it.
  • ai/scripts/lint/config-leaf-parity.json$composeDefaultParity.census.requiredDeploymentInputs is a flat, profile-wide list with no per-service attribution. That flatness is what forces the resolver to invent an attribution, and the declared-leaf set was the only one available.
  • This is the same class as #16491 one layer over: there, an empty observation stood for two different facts; here, a declared leaf stands for a consumed one.

The Fix

Make the scope predicate consumption, not declaration — and if consumption is not derivable, make the census carry the attribution rather than having the resolver guess.

Two candidate shapes, and this ticket does not pick between them:

  1. Per-service required lists in the census. The parity document gains attribution, and the resolver reads it instead of deriving one. Authoritative and static; costs a census change with its own review.
  2. Derive consumption. Resolve each service's entrypoint and walk its reachable config reads. Precise and self-maintaining; materially more machinery, and it must fail closed when the walk cannot resolve.

Either way the refusal must keep its current fail-closed posture: an unattributable required key blocks rather than being silently dropped, which is the property #16454 established and this ticket must not weaken while fixing the false positives.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback / Error Semantics Docs Evidence
resolveServiceScopes() this ticket Scope = leaves the service consumes Consumption unresolvable ⇒ block, never silently narrow the scope — a narrower scope is fail-open source JSDoc plan on the live plane drops to the 2 genuine blockers
$composeDefaultParity.census config-leaf-parity.json Either gains per-service attribution, or is documented as profile-wide with attribution derived elsewhere Attribution absent and underivable ⇒ the existing required-input-unattributable refusal stands census docs the flat list is no longer read as per-service truth
missing-required-input-boot-blocking deploymentMigrationCore.mjs Fires only for a service that actually reads the leaf source JSDoc no boot-blocking row for a service that boots

Decision Record impact

none — corrects a predicate inside the migration bootstrap. ADR-0019 stays the census authority and is only read; nothing here relaxes the no-default requiredness it establishes.

Acceptance Criteria

  • A service is held only to leaves it actually consumes; a leaf declared by an inherited base but read by one service does not block the others.
  • plan against the canonical local plane reports the 2 genuine blockers (retired NEO_AUTH_PIN_FIRST_PROVIDER_SUBJECT on kb-server and mc-server) and none of the 10 false ones.
  • missing-required-input-boot-blocking never fires for a service that boots without the key.
  • Fail-closed preserved: an unattributable or unresolvable required key still blocks. Fixing false positives must not introduce a false negative — a spec asserts both directions.
  • backup.path's disposition is settled by its own check rather than inherited from this ticket's inference.
  • A spec represents the distinguishing case directly: one leaf, declared by all three services, consumed by one, asserted to block only that one.

Out of Scope

  • The retired-key rows. NEO_AUTH_PIN_FIRST_PROVIDER_SUBJECT being set on kb-server and mc-server is a genuine finding this ticket leaves standing, not a defect it fixes.
  • Loosening the census. Reclassifying required inputs needs its own authority proof.
  • #16491's observation provenance — adjacent class, separate surface, already in flight.

Avoided Traps

  • Dropping unattributable keys to make the plan clean. That trades ten false positives for an unknown number of false negatives, and a repair tool that under-reports is worse than one that over-reports.
  • Hand-listing which service owns which key in the resolver. A hand-list stales exactly as the flat census did, and #16453's AC already forbids that pattern for the sibling predicate.
  • Assuming a name implies ownership. NEO_AI_ORCHESTRATOR_* reads orchestrator-only and is — but I confirmed it by consumer sweep, because the same reasoning applied to NEO_DEPLOY_HOSTNAME would have put it on a Neo service rather than on ingress, where it actually lives.

Related

  • #16454 — delivered the per-service judgement this corrects; merged as fbef7375d4.
  • #16491 — the sibling class: one value standing for two different facts.
  • #16505 / #16453 — the admissibility predicate derives from the same flat census, so it likely inherits this attribution gap. Flagged for @neo-opus-grace rather than assumed.

Live latest-open sweep: latest 20 open issues checked; no equivalent. A2A claim sweep: recent messages across all read-states; no [lane-claim] on scope attribution.

Origin Session ID: 11695cce-9854-4be2-80c3-8ea4322298bf

Retrieval Hint: query_raw_memories("declared leaves versus consumed leaves per-service scope false boot-blocking migrateDeployment root config base inheritance")