15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 356 | Harden config pipeline against silently-dropped fields (follow-up to #332) |
Harden the config pipeline against silently-dropped fields
Problem Statement
Issue #332 fixed a specific bug — toolInputPreviewMaxLength / toolTextSummaryMaxLength were declared on PermissionSystemExtensionConfig and read by ToolPreviewFormatter, but silently dropped by the UnifiedPermissionConfig intermediate in config-loader.ts.
The fix wired those two fields through the loader, but it did not close the class of bug that let the omission ship undetected.
Two structural gaps remain:
normalizePermissionSystemConfigacceptsunknownand re-parses its argument throughtoRecord(raw). Its sole production caller (ConfigStore.refresh) feeds itmergeResult.merged, aUnifiedPermissionConfig. Because the parameter isunknown, TypeScript never objected that the merged object lacked the two fields. A field declared on the runtime type but missing from the merge intermediate is a silent runtime drop, not a compile error.- Every existing test injects config at a single layer in isolation.
Nothing exercises the full seam — temp
config.json→loadAndMergeConfigs→normalizePermissionSystemConfig— so a field dropped in the middle is never observed end to end.
This plan attacks the class of bug rather than the single instance.
Goals
- Retype
normalizePermissionSystemConfig's parameter fromunknowntoUnifiedPermissionConfigand read fields directly, so a future field declared on the runtime type but absent from the merge intermediate becomes a compile error. - Drop the now-redundant defensive coercion (
toRecord,normalizeOptionalPositiveInt,normalizeOptionalStringArray) fromnormalizePermissionSystemConfig— that work already happens at the JSON boundary innormalizeUnifiedConfig. - Add one full-pipeline seam test in a new
test/config-pipeline.test.tsthat writes a tempconfig.json, runs it throughloadAndMergeConfigs→normalizePermissionSystemConfig, and asserts a runtime knob and a preview-length field both survive end to end.
This change is not breaking: observable runtime behavior is unchanged.
The two production call sites already feed typed objects through normalizeUnifiedConfig first, so the removed coercion is dead code for production; only test-only garbage injection is affected.
Non-Goals
- The secondary
saveExtensionConfigbehavior from #332 (length fields not written back) is out of scope —ConfigStore.savealready preserves existing file fields via...existing.config, and the modal only edits the three booleans.config-store.tsis not modified by this plan. - No changes to
normalizeUnifiedConfig,mergeUnifiedConfigs, or the boundary parsing — they already do the defensive work correctly. - No new config fields, schema entries, or example-config changes.
- No narrowing of the parameter to
Omit<UnifiedPermissionConfig, "permission">(see Design Overview — accepted minor ISP slack).
Background
Relevant modules:
src/extension-config.ts— declaresPermissionSystemExtensionConfig,DEFAULT_EXTENSION_CONFIG, andnormalizePermissionSystemConfig. The function currently doestoRecord(raw)then reads each field defensively (record.debugLog === true,normalizeOptionalPositiveInt(record.toolInputPreviewMaxLength), etc.).src/config-loader.ts— declaresUnifiedPermissionConfig(all-optional intermediate) andnormalizeUnifiedConfig, which already does the full defensive parse at the JSON boundary:normalizeOptionalBooleanfor the three booleans,normalizeOptionalPositiveIntfor the two length fields,normalizeOptionalStringArrayforpiInfrastructureReadPaths.src/config-store.ts— the two production call sites.refreshpassesmergeResult.merged(aUnifiedPermissionConfig);savepassesnext(aPermissionSystemExtensionConfig).src/common.ts— exportstoRecord,normalizeOptionalPositiveInt,normalizeOptionalStringArray.
Applicable constraint from the package skill (package-pi-permission-system): "Treat any declared config field not read at runtime as a maintenance trap" and "A field on the runtime type but not the merge intermediate is silently dropped before runtime (the #332 / #347 bug class)."
This plan makes that trap a compile error.
Existing boundary coverage in test/config-loader.test.ts already exercises the defensive parse the retype removes from normalizePermissionSystemConfig:
- Lines 188–199 — non-boolean values (
debugLog: "yes",permissionReviewLog: 1,yoloMode: null) are dropped toundefined. - Lines 156–172 —
debugLogpresent/false/missing. - Lines 262–325 — both length fields: valid positive integer parsed, absent omitted, and invalid values (
0,-1, fractional, string, boolean) dropped.
This is why the redundant cases in test/extension-config.test.ts can be deleted rather than relocated.
Design Overview
Type model
After the change, normalizePermissionSystemConfig converts a typed UnifiedPermissionConfig into a PermissionSystemExtensionConfig by applying defaults to the three required booleans and passing through the optional fields:
export function normalizePermissionSystemConfig(
raw: UnifiedPermissionConfig,
): PermissionSystemExtensionConfig {
const result: PermissionSystemExtensionConfig = {
debugLog: raw.debugLog === true,
permissionReviewLog: raw.permissionReviewLog !== false,
yoloMode: raw.yoloMode === true,
};
if (raw.piInfrastructureReadPaths !== undefined) {
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
}
if (raw.toolInputPreviewMaxLength !== undefined) {
result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
}
if (raw.toolTextSummaryMaxLength !== undefined) {
result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
}
return result;
}
Behavior is preserved exactly: undefined booleans default the same way (=== true → false, !== false → true), and the optional fields are passed through unchanged.
The function no longer imports toRecord, normalizeOptionalPositiveInt, or normalizeOptionalStringArray — those imports become unused and are removed.
Why this achieves the safety goal
The point of the retype is the omission-becomes-compile-error property.
Keeping toRecord would defeat it: toRecord returns Record<string, unknown>, so record.toolInputPreviewMaxLength is unknown and the type checker cannot see a missing field on the input.
Reading raw.toolInputPreviewMaxLength directly against a typed UnifiedPermissionConfig means that if a future field is declared on PermissionSystemExtensionConfig, read here, but never added to UnifiedPermissionConfig, the field access is a compile error.
Call-site compatibility
Both production call sites already feed assignable types — no config-store.ts change is needed:
// ConfigStore.refresh — mergeResult.merged is already UnifiedPermissionConfig
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
// ConfigStore.save — next is PermissionSystemExtensionConfig, structurally
// assignable to UnifiedPermissionConfig (every field present, all optional there)
const normalized = normalizePermissionSystemConfig(next);
PermissionSystemExtensionConfig (required booleans + optional extras) is assignable to UnifiedPermissionConfig (all-optional superset including permission?), so save compiles unchanged.
ISP note (accepted slack)
normalizePermissionSystemConfig reads 6 of UnifiedPermissionConfig's 7 fields — it never reads permission.
A strict ISP reading would narrow the parameter to Omit<UnifiedPermissionConfig, "permission">.
We accept the single unused optional field instead: UnifiedPermissionConfig is the natural merged-config domain object and the exact type of mergeResult.merged, the issue prescribes typing the parameter as UnifiedPermissionConfig, and the compile-error safety property holds either way.
Adding a narrowing alias would be speculative surface for negligible benefit.
Design-review checklist result
| Check | Finding |
|---|---|
| Dependency width | Param reads 6/7 fields; one unused optional (permission) accepted (see ISP note) |
| Law of Demeter | No chained access introduced |
| Output arguments | None — function returns a fresh object |
| Scattered resets | N/A |
| Parameter relay | N/A |
| Test mock depth | Removes test-only as unknown casts in config-modal.test.ts (improvement) |
No structural smells introduced; the change removes redundant code and a test-only cast.
Module-Level Changes
src/extension-config.ts- Change
normalizePermissionSystemConfig(raw: unknown)to(raw: UnifiedPermissionConfig); read fields directly; drop thetoRecordcall and thenormalizeOptionalPositiveInt/normalizeOptionalStringArraycoercion. - Add
import type { UnifiedPermissionConfig } from "./config-loader";. - Remove the now-unused imports
normalizeOptionalPositiveInt,normalizeOptionalStringArray,toRecordfrom the./commonimport (verifytoRecordhas no other use in the file —detectMisplacedPermissionKeystakes aRecorddirectly, so it does not).
- Change
test/extension-config.test.ts- Delete the four redundant garbage-input cases (now uncompilable against the typed parameter and already covered at the boundary in
config-loader.test.ts): "coerces non-boolean values to their defaults", "handles null/undefined input gracefully", "omits toolInputPreviewMaxLength for invalid values", "omits toolTextSummaryMaxLength for invalid values". - Keep the valid-input cases (valid config, the three boolean defaults via
{}, includes/omits for both length fields) — they pass typed objects and still compile.
- Delete the four redundant garbage-input cases (now uncompilable against the typed parameter and already covered at the boundary in
test/config-modal.test.ts- Fix the two call sites that pass
JSON.parse(readFileSync(configPath, "utf-8")) as unknowntonormalizePermissionSystemConfig(lines ~147 and ~156): route them throughloadUnifiedConfig(configPath).config(reads + normalizes the file into aUnifiedPermissionConfig, mirroring production) and importloadUnifiedConfigfrom#src/config-loader. - The other two call sites pass typed values (
config,next) and need no change.
- Fix the two call sites that pass
test/config-pipeline.test.ts(new)- Add the full-pipeline seam test (change 2).
No docs/architecture/, schema, example-config, README, or docs/configuration.md updates — no config surface changes.
The package skill's bug-class note already describes this trap; no skill edit required.
Test Impact Analysis
What the change enables and affects:
- New coverage enabled — the seam test in
test/config-pipeline.test.tsis the first test that runs config through the realloadAndMergeConfigs→normalizePermissionSystemConfigpath on disk, catching any future mid-pipeline drop (not just the two length fields). - Tests that become redundant — the four garbage-input cases in
test/extension-config.test.tsduplicate boundary coverage already present intest/config-loader.test.ts(lines 188–199 for booleans, 296–325 for invalid length values). They are deleted, not relocated, because the boundary already tests the identical behavior. - Tests that must stay as-is — the valid-input cases in
test/extension-config.test.ts(default application, length-field passthrough) genuinely exercisenormalizePermissionSystemConfig's remaining responsibility (defaults + passthrough) and are kept. All oftest/config-loader.test.tsstays — it owns the boundary defensive-parse contract.
TDD Order
- Add the full-pipeline seam test (change 2).
Surface: new
test/config-pipeline.test.ts. Covers: write a tempconfig.jsonwith a runtime knob (e.g.debugLog: true) and a preview-length field (e.g.toolInputPreviewMaxLength: 1000); runloadAndMergeConfigs(agentDir, cwd, extensionRoot); pass.mergedtonormalizePermissionSystemConfig; assert both values survive end to end. This passes immediately (the #332 loader fix is already in place) — it is a regression guard documenting the seam, established before the refactor as a safety net. Commit:test: add full-pipeline config seam regression test (#356). - Retype
normalizePermissionSystemConfigand migrate its tests (change 1). Surface:src/extension-config.ts,test/extension-config.test.ts,test/config-modal.test.ts. This is a single atomic commit: changing the parameter type breaks the four garbage-input test cases and the twoas unknowncall sites at the type level, so the production change, the test deletions, and theconfig-modal.test.tscall-site updates must land together. Runpnpm run checkimmediately after — the retype is the one interface-shape change in this plan, andpnpm --filter @gotgenes/pi-permission-system exec vitest runto confirm the seam test (step 1) and the surviving unit tests stay green. Commit:refactor: type normalizePermissionSystemConfig parameter as UnifiedPermissionConfig (#356).
Risks and Mitigations
- Risk: a hidden production caller passes genuinely untrusted
unknowntonormalizePermissionSystemConfig, relying on the defensive coercion. Mitigation: confirmed via grep — only two production call sites (ConfigStore.refresh,ConfigStore.save), both feeding typed objects already normalized upstream; no untrusted path exists. - Risk: deleting the garbage-input tests loses coverage of invalid-value handling.
Mitigation: that behavior is fully covered at the JSON boundary in
test/config-loader.test.ts(booleans lines 188–199, length fields 296–325); the deletions are duplicates, not unique coverage. - Risk:
config-modal.test.tscall-site rewrite changes what the test asserts. Mitigation:loadUnifiedConfig(configPath).configreads and normalizes the same file the test wrote, mirroring the production load path more faithfully than the previousJSON.parse(...) as unknowncast — the asserted round-trip is preserved.
Open Questions
None blocking.
A future follow-up could narrow the parameter to Omit<UnifiedPermissionConfig, "permission"> if more unused fields accumulate, but that is not warranted now.