Skip to content

fix(cli): config set resolves keys against the generator's schema instead of guessing them - #974

Merged
rickylabs merged 3 commits into
mainfrom
fix/config-set-schema-aware-keys
Jul 31, 2026
Merged

fix(cli): config set resolves keys against the generator's schema instead of guessing them#974
rickylabs merged 3 commits into
mainfrom
fix/config-set-schema-aware-keys

Conversation

@rickylabs

Copy link
Copy Markdown
Owner

Summary

netscript config set NetScript.Databases.postgres.Persistent false exited 0 and printed Set …
while writing NetScript.NetScript.Databases.Postgres.Persistent — a key nothing reads. The
setting silently did nothing.

The root cause is not the doubled prefix. appsettingsPath() was a blind text transform — one
hardcoded alias, then an unconditional NetScript prefix and capitalize-first-letter on every
segment — applied with no knowledge of the appsettings schema, and setProjectConfigValue() created
whatever intermediate objects that transform implied. So it also corrupted record keys: the
documented camelCase spelling databases.postgres.persistent landed on
NetScript.Databases.Postgres.Persistent (right prefix, wrong record key) and was equally dead.
There was no spelling of that setting that worked, and every one of them exited 0.

Paths now resolve against the JSON Schema @netscript/aspire generates from the same Zod
definitions parseAppSettings() validates with. "The generator knows this key" is true by
construction, not by a hand-kept table that can drift.

Scope

What changed

Resolution is schema-derived. read-appsettings-schema.ts reads
generateAppSettingsJsonSchema() and exposes it as a walkable tree. The JSON Schema distinguishes
the two node kinds that matter, and the resolver honours the distinction:

Node kind Example Rule
closed object (properties + additionalProperties:false) NetScript.Databases schema field — matched case-insensitively, rewritten to canonical casing
record (additionalProperties: <schema>) …Databases.postgres user data — matched against keys already in the document, else kept verbatim

Capitalizing record keys was the half of the bug the issue did not report.

Three spellings, one canonical key. The full path, the section-relative shorthand
(Databases.postgres.Persistent), and any casing of either all resolve to
NetScript.Databases.postgres.Persistent. The shorthand is only accepted when the section it names
actually resolves, so a full path is never re-prefixed and Parameters.x is not silently re-homed
as NetScript.Parameters.x.

Unknown paths fail; they do not succeed quietly.

$ netscript config set NetScript.Databases.postgres.Persistant false
Error: Unknown configuration path "NetScript.Databases.postgres.Persistant" — "Persistant" is not a
key of NetScript.Databases.postgres. The Aspire generator would never read it, so nothing was
written. Did you mean: NetScript.Databases.postgres.Persistent, …? Run 'netscript config list' for
the canonical paths, or pass --force to write it anyway.
exit=101

Suggestions are ranked by edit distance (levenshteinDistance from @std/text). --force writes
an off-schema key with a warning — kept because the scaffold legitimately emits a top-level
Parameters block that AppSettingsSchema does not model (see Drift D3).

Values are validated too, scoped to the written path, so an appsettings.json that is already
invalid elsewhere does not block an unrelated correct write.

config list (new) prints the canonical case-sensitive paths, expanding records over the
project's own keys and templating unpopulated ones as NetScript.Databases.<key>.Engine. It also
lists keys present in the document that the schema does not model, marked
(not read by the generator) — which is how a developer discovers that an earlier config set
changed nothing:

$ netscript config list Databases
NetScript.Databases.postgres.DatabaseName   "my-app-db"
NetScript.Databases.postgres.Engine         "Postgres"
NetScript.Databases.postgres.Persistent     true
…

config get moves onto the same resolution, so the read and write paths can no longer disagree.

What I deliberately did not change

  • config override / config runtime — a different (KV-backed) store with a different key space.
  • The @netscript/config (netscript.config.ts) surface config inspect reads.
  • AppSettingsSchema itself. The scaffold's top-level Parameters block is genuinely off-schema;
    modelling it is a cross-package change and belongs in its own issue (Drift D3).
  • The packages/cli Restructure verdict in doctrine file 10. No new oversized file, no new flat
    folder: config/project/ is at 8 children (cap 12), the command file is 111 LOC (cap 150), the
    largest new module is 236 LOC (cap 250).

The regression guard

This is the part that matters more than the fix. The shipped test asserted one string mapping
the single hardcoded telemetry.otlpEndpoint alias — and never a nested or record path. That is
exactly why the defect reached 0.0.1-beta.11.

The guard now asserts the property that actually failed, not a transform:

after set, the generator's own parseAppSettings() reads the value back at the requested
setting.

A future refactor of the key mapping cannot pass that test while writing a key the generator
ignores. 30 tests across three files cover: the reported path, the doubled prefix, record-key
preservation, hyphenated service keys, misspellings, scalar descent, the alias, off-schema --force
writes, value rejection, pre-existing-damage tolerance, and config list canonicality.

No new deno task was needed — the checks are unit tests, already wired into deno task test.

Slices

  • S1 Failing regression guard (reported path, camelCase path, record key) — d2bf826, 2ad29e3
  • S2 Schema-derived resolver + scoped value validation — 2ad29e3
  • S3 set/get onto the resolver; unknown path errors, --force warns — 2ad29e3
  • S4 netscript config listedabab7
  • S5 Gate sweep + run artifacts — d2bf826, edabab7

Validation

Reproduced first, on 8e0bcef39, with the real command:

BASELINE: {"NetScript":{"Databases":{"postgres":{"Persistent":true}},
                        "NetScript":{"Databases":{"Postgres":{"Persistent":false}}}}}

— original value untouched, dead key created, exit 0. After the fix the same command sets
Persistent = false in place and no NetScript.NetScript key exists.

Gate Result
deno task fmt:check PASSfindings: 0 over 1869 files
deno task lint PASStotalOccurrences: 0 over 1724 files
deno task check PASStotalOccurrences: 0 over 2462 files, 21 batches
deno task test PASS — 2254 passed (507 steps), 0 failed, 12 ignored (3m52s)
deno task arch:check PASS — exit 0, no FAIL= rows, no new findings
deno test packages/cli/src/ PASS — 386 passed (426 steps), 0 failed
packages/cli deno task check PASS — all six entry points
quality:scan (config feature) PASSfindings: []
run-deno-lint / run-deno-fmt scoped to the config feature PASS — 0 findings (root lint/fmt:check exclude packages/cli)

Note on the scoped wrappers: root deno task lint and fmt:check exclude packages/cli, so
their green is not evidence for this change. Both wrappers were re-run scoped to
packages/cli/src/public/features/config and are clean; that is the evidence for the changed files.

e2e:cli scaffold.runtime was not run: this change touches no scaffold output, template, or
generated artifact — only CLI-side key resolution. Called out rather than silently skipped.

Harness

  • Run dir: .llm/runs/fix-config-set-schema-aware-keys--955/
  • Archetype: 6 (CLI / Tooling); gates per .llm/harness/gates/archetype-gate-matrix.md
  • Phase: IMPL — see the phase comments below.

Drift / Debt

  • D1 — no separate evaluator sessions. The harness requires PLAN-EVAL and IMPL-EVAL in sessions
    distinct from the generator. This was dispatched as a one-shot non-interactive fix with no second
    session available, so plan-eval.md and evaluate.md do not exist and the PR carries
    status:impl, not status:ready-merge. An independent IMPL-EVAL is still owed before merge.
  • D2 — the issue understates the defect. Both spellings were broken, not one. Fixed at the root
    cause; recorded in drift.md.
  • D3 — Parameters is off-schema. generateAppsettings() emits a top-level Parameters block
    (MSSQL SA password) that AppSettingsZod does not model, so parseAppSettings() strips it. This
    is why --force exists. Deserves its own issue: model the section, or document it as host-side
    .NET configuration outside the NetScript schema.
  • D4 — pre-existing, untouched. config get calls loadConfig() before its appsettings
    fallback, so it needs netscript.config.ts to exist even for an appsettings-only path. Noted so
    it is not read as a regression from this PR.
  • Debt: none created, none closed.

rickylabs and others added 3 commits July 31, 2026 15:35
… transform

Run dir for the #955 fix. Research re-derives the issue against main and finds
the defect is wider than filed: the documented camelCase spelling is broken
too, because the key mapper capitalizes record keys as well as schema fields.
Plan locks the fix at the contract level — resolve every path against the JSON
Schema the Aspire generator's own parser is built from — so the CLI and the
generator cannot disagree about which keys exist.

Refs #955

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing why

`netscript config set NetScript.Databases.postgres.Persistent false` exited 0
and reported success while writing
`NetScript.NetScript.Databases.Postgres.Persistent` — a key nothing reads. The
mapper prefixed `NetScript` unconditionally and capitalized the first letter of
every segment, so it also corrupted record keys: the documented camelCase
spelling `databases.postgres.persistent` landed on `Databases.Postgres` and was
equally dead. No spelling of that setting worked.

Paths now resolve against the JSON Schema `@netscript/aspire` generates from
the same Zod definitions `parseAppSettings()` validates with, so "the generator
knows this key" is true by construction rather than by a hand-kept table.
Closed objects canonicalize case-insensitively; records keep the developer's
own key. An unresolvable path fails with suggestions and writes nothing;
`--force` writes it with a warning, for host-side keys like `Parameters.*` that
sit outside the NetScript schema.

The regression guard no longer asserts a string mapping — it asserts the
property that actually failed: after `set`, `parseAppSettings()` reads the value
back. The shipped test only covered the one hardcoded alias, which is why this
reached a release.

Refs #955

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reads

`config list` enumerates the case-sensitive appsettings paths the generator
reads, expanding records over the project's own keys and templating the ones it
has not populated yet. It also lists keys the document contains but the schema
does not model, marked "(not read by the generator)" — which is how a developer
discovers that an earlier `config set` changed nothing.

`config get` moves onto the same resolution, so the read and write paths can no
longer disagree.

Refs #955

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickylabs

Copy link
Copy Markdown
Owner Author

[PHASE: RESEARCH]

The reported doubled prefix is one symptom of two; the root cause is that the key mapper never consulted a schema.

Findings

  1. appsettingsPath() is a blind text transformpackages/cli/src/public/features/config/project/project-config-ops.ts:41-45 (pre-fix). One hardcoded alias, then an unconditional NetScript prefix and capitalize-first-letter on every segment. No schema is consulted at any point.

  2. It corrupts record keys, not just field names. Databases is z.record(string, DatabaseEntry) (packages/aspire/config.ts:535); postgres is a name the developer chose. Capitalizing it produced Databases.Postgres, so the documented camelCase spelling was dead too:

    "NetScript.Databases.postgres.Persistent" -> NetScript.NetScript.Databases.Postgres.Persistent
    "databases.postgres.persistent"           -> NetScript.Databases.Postgres.Persistent
    

    Neither is a key the generator reads. There was no spelling of that setting that worked.

  3. Nothing could ever fail. setProjectConfigValue() created every missing intermediate object, so a wrong path always succeeded structurally; nothing read the result back. Hence exit 0 + Set ….

  4. The schema authority already existed. @netscript/aspire/config holds the Zod AppSettingsSchema that parseAppSettings() validates with, and @netscript/aspire/schema exposes generateAppSettingsJsonSchema() — draft-7 JSON Schema derived from the same Zod. packages/cli resolves @netscript/aspire to the workspace member (deno info confirms file:///…/packages/aspire/config.ts), so a schema change lands here immediately.

  5. The JSON Schema distinguishes the two node kinds the fix needs: closed objects emit properties + additionalProperties: false; records emit additionalProperties: <schema>.

  6. The file target was never wrong. The AppHost reads ../appsettings.json (render-ts-apphost.ts:244) — the same project-root file config set writes. Only the key was wrong.

  7. config get shares the broken mapper, so the read path was wrong in the same way (loudly rather than silently).

  8. The existing guard tested one string mapping — the single telemetry.otlpEndpoint alias — and never a nested or record path. That is why this shipped in 0.0.1-beta.11.

  9. netscript config list did not exist. The group was inspect/get/set/override/runtime.

  10. One legitimate off-schema key exists: generateAppsettings() emits a top-level Parameters block (MSSQL SA password) that AppSettingsZod does not model. A blanket "unknown key → hard error" would have rejected it — which is why --force is part of the design.

Full detail: .llm/runs/fix-config-set-schema-aware-keys--955/research.md.

@rickylabs

Copy link
Copy Markdown
Owner Author

[PHASE: PLAN]

Archetype 6 (CLI / Tooling). Fix the contract, not the string transform.

Locked decisions

ID Decision Rationale
D1 Derive the canonical path space at runtime from generateAppSettingsJsonSchema() A hand-maintained path table is exactly the drift that produced this bug. Deriving it makes "the generator knows this key" true by construction.
D2 Closed objects canonicalize case-insensitively; records keep the developer's key Record keys are user data. Capitalizing them was the unreported half of the defect.
D3 Unknown path → error, exit non-zero, with suggestions; --force warns and writes The issue's own framing: never a silent success. --force exists because research finding 10 shows a legitimate off-schema key.
D4 config list as its own subcommand The issue names it; inspect has an existing InspectionReport JSON contract that must not change shape.
D5 telemetry.otlpEndpoint stays an explicit alias constant, walked through the schema Explicit table > implicit heuristic, and a stale alias then fails resolution instead of writing a dead key.
D6 Value validation reports only issues at or under the resolved path An appsettings.json already invalid elsewhere must not block an unrelated correct set.
D7 New modules in the feature folder, not kernel/domain/ kernel/domain/ sits at exactly 12 children — the R-A6-N1 cardinality cap.

Open-decision sweep

All decisions that would force rework were resolved before implementation. Deferred and marked safe: modelling Parameters in AppSettingsSchema (cross-package; --force covers it), and the packages/cli Restructure verdict (out of scope; this run adds no new debt).

Risk register (top 3)

  • Erroring on unknown paths breaks a workflow relying on an off-schema key--force, named in the error text.
  • z.toJSONSchema() output shape changes → the walker handles the two documented node kinds and treats anything else as "cannot descend", i.e. it fails closed, not open.
  • The resolver becomes a second source of truth → it holds no schema facts, only traversal rules; every key name comes from the generated schema at call time.

Design checkpoint

Recorded in .llm/runs/fix-config-set-schema-aware-keys--955/worklog.md § Design: public surface, domain vocabulary (ResolvedAppsettingsPath, SchemaChildren, AppsettingsPathEntry, SetProjectConfigResult), constants (APPSETTINGS_PATH_ALIASES, ROOT_SECTION, RECORD_KEY_PLACEHOLDER, MAX_SUGGESTIONS), 5 commit slices, deferred scope, and the contributor path.

Contributor path: adding a settable key requires no CLI change at all — add the field to the Zod schema in packages/aspire/config.ts and set/get/list pick it up on the next run.

Plan-Gate

FAIL on process, not on content: PLAN-EVAL requires a separate session and this run had one. Recorded as drift D1 rather than claimed as passed.

@rickylabs

Copy link
Copy Markdown
Owner Author

[PHASE: IMPL]

Five slices landed; 30 tests across three files; every named gate run.

Slices

# Slice Commit Gate
S1 Failing regression guard — reported path, camelCase path, record key d2bf826 / 2ad29e3 red on 8e0bcef39, green after S2–S3
S2 Schema-derived resolver + scoped value validation 2ad29e3 resolve-appsettings-path_test.ts — 13 tests
S3 set/get onto the resolver; unknown path errors, --force warns 2ad29e3 project-config-ops_test.ts — 9 tests
S4 netscript config list edabab7 list-appsettings-paths_test.ts — 5 tests
S5 Gate sweep + run artifacts d2bf826 / edabab7 see below

Reproduction, then proof

Before (8e0bcef39, real command through bin/netscript.ts):

Set NetScript.Databases.postgres.Persistent.     # exit 0
{"NetScript":{"Databases":{"postgres":{"Persistent":true}},
              "NetScript":{"Databases":{"Postgres":{"Persistent":false}}}}}

After — the same command sets Persistent=false in place, and NetScript.NetScript does not exist. The camelCase spelling reports the resolution it performed:

Set NetScript.Databases.postgres.Persistent (resolved from databases.postgres.persistent).

A misspelling now costs one line instead of an afternoon:

Error: Unknown configuration path "…postgres.Persistant" — "Persistant" is not a key of
NetScript.Databases.postgres. The Aspire generator would never read it, so nothing was written.
Did you mean: NetScript.Databases.postgres.Persistent, …?     # exit 101

Gate results

Gate Result
deno task fmt:check PASS — findings: 0 / 1869 files
deno task lint PASS — totalOccurrences: 0 / 1724 files
deno task check PASS — totalOccurrences: 0 / 2462 files, 21 batches
deno task test PASS — 2254 passed (507 steps), 0 failed, 12 ignored, 3m52s
deno task arch:check PASS — exit 0, no FAIL= rows; all output is pre-existing WARN/INFO (npm catalog, export default in plugins)
deno test packages/cli/src/ PASS — 386 passed (426 steps), 0 failed
packages/cli deno task check PASS — six entry points
quality:scan (config feature) PASS — findings: []
scoped run-deno-lint / run-deno-fmt on features/config PASS — 0 findings each

Root lint and fmt:check exclude packages/cli, so their green says nothing about this change — the scoped wrapper runs are the evidence for the changed files. Called out because a green wrapper that skipped your files is how false-green lands.

Structural gates (Archetype 6)

Rule Measured
F-CLI-1 presentation ≤ 150 LOC project-config-command.ts = 111
F-CLI-1 use cases ≤ 250 LOC resolve-appsettings-path.ts = 236; list = 127; ops = 130; read-appsettings-schema = 72
F-CLI-25 / R-A6-N1 ≤ 12 children features/config/project/ = 8
F-CLI-16 Deno.* outside adapters none in the new modules
F-CLI-26 console.* none added; output via outputText/outputWarning

F-CLI-2…31 remain PENDING_SCRIPT per the archetype profile (no dedicated script since S9), backed by arch:check.

Not run, and why

e2e:cli scaffold.runtime — this change touches no scaffold output, template, or generated artifact; only CLI-side key resolution. Stated rather than skipped silently.

Next

  • An independent IMPL-EVAL in a separate session is still owed (drift D1). The PR carries status:impl, not status:ready-merge.
  • Follow-up issue for the off-schema top-level Parameters block (drift D3).

@rickylabs rickylabs added this to the 0.0.1-beta.12 milestone Jul 31, 2026
@rickylabs
rickylabs merged commit 54e8b31 into main Jul 31, 2026
17 of 21 checks passed
@rickylabs
rickylabs deleted the fix/config-set-schema-aware-keys branch July 31, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(cli): 'config set' writes a doubled key the generator never reads, and reports success

1 participant