fix(config): validate the parameters section, not the whole config body (#587) - #589
Conversation
A component's `configurationSchema` from the AI Service describes the
CONTENTS of the configuration's `parameters` key, not the whole
configuration object. `_validate_config_body` validated the whole body
against it, so it was off by one level -- which inverted every outcome:
{"parameters": {"db": {...}}} correct config -> rejected
"<root>: 'db' is a required property"
{"db": {...}} malformed config -> accepted
Both confirmed live against the AI Service (keboola.ex-db-mysql) before
and after the fix.
The damage was second-order. Faced with a bogus error on a body they
knew was correct, the reporter of #587 passed --no-validate to get past
it, and thereby also switched off the checking that did work. What
slipped through was a dropped `runtime.parallelism`: Keboola treats a
missing `runtime` as parallelism 1, so a 65-row Snowflake writer ran
strictly sequentially -- 140 minutes instead of the expected 60-90 --
with nothing in any tool output pointing at it.
Validation now targets `body["parameters"]` and prefixes error paths
with `parameters.` so they name the section to fix. Sibling keys
(`storage`, `runtime`, `authorization`), which the schema does not
describe, no longer fail validation. Unwrapping affects validation only;
the POSTed body still carries every sibling key -- covered by its own
regression test, since dropping them is the very failure this issue is
about.
A body with no `parameters` key is still validated whole: that is the
keboola.flow shape, where `phases` / `tasks` ARE the configuration root.
Known limit of that fallback: a parameters-level body posted by mistake
as the whole configuration is indistinguishable from a flow config and
still validates ok.
The mock schema in the tests was itself why this shipped -- it wrapped
everything in a `parameters` property, a shape no real component
returns, so the off-by-one-level validation matched it and CI stayed
green. It is now the real parameters-level shape, plus six regression
tests for the contract.
Closes #587
padak
left a comment
There was a problem hiding this comment.
Review of #589 — fix(config): validate the parameters section, not the whole config body (#587)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Summary
This PR fixes a real off-by-one-level bug in ConfigService._validate_config_body (src/keboola_agent_cli/services/config_service.py): a component's AI Service configurationSchema describes the contents of a configuration's parameters key, not the whole config body, so the body is now unwrapped (body["parameters"]) before Draft7 validation and error paths are prefixed parameters.. A body with no parameters key (the keboola.flow shape) is still validated whole. The mock schema in the test suite — the actual reason this shipped broken — was rewritten to match the real AI Service shape, and 6 new regression tests were added. I live-verified the fix against a real project (kbagent config new --push --dry-run against keboola.ex-db-mysql) and it reproduces exactly the before/after table in the PR description, including the documented "known limit". No layer violations, no security issues, make check is green (5495 passed), and the changelog/version/plugin-sync files are consistent. Verdict: COMMENT — no blocking findings, a few small non-blocking follow-ups.
Verdict
- Verdict: COMMENT
- Blocking findings: 0
- Non-blocking findings: 4
- Nits: 1
Blocking findings
(none)
Non-blocking findings
[NB-1] plugins/kbagent/skills/kbagent/references/gotchas.md:1180 — new heading has no (since vX.Y.Z) tag
The new section ## Cloning a config by hand: copy the WHOLE object, not just parameters is the only topical gotcha heading added in this diff that carries no version tag, while its sibling bullets two paragraphs above (under the config new --push heading) correctly say (since v0.84.1). Per CONTRIBUTING.md "every new gotcha MUST be tagged with (since vX.Y.Z)", and per the review rubric this is BLOCKING for behavior changes — but this heading documents a persistent fact (there has never been a config clone command, config examples has always shown only parameters) rather than a version-gated behavior change, so I'm not treating it as blocking. Still, tagging it (e.g. (documented since v0.84.1, issue #587)) would keep it consistent with the rest of the file's convention and let an agent anchor "when was this written down" the same way every other heading does.
[NB-2] src/keboola_agent_cli/services/component_service.py:130-146 — _resolve_parameters still carries the old (wrong) off-by-one mental model as dead code
Focus item (3) asked whether any other configurationSchema consumer shares the fixed off-by-one assumption. I checked all three other call sites:
component_service.py:403(get_component_detailschema summary) only countsproperties/required— schema-shape-agnostic, no bug.flow_service.py:293/flow_validation.py:33validate the whole{"phases", "tasks"}document directly against the live CF schema — correct, becausekeboola.flowgenuinely has noparameterswrapper (confirmed viaflow_service.py:506,configuration = {"phases": phases, "tasks": tasks}).component_service.py:140-146(_resolve_parameters, used byconfig new's scaffold generator) has a first branch that looks forschema["properties"]["parameters"]["properties"](the OLD wrong nested-schema model) before falling back to_generate_from_schema(schema)(the correct flat model). Since real AI Service schemas never nest underparameters(confirmed live and viatests/test_component_service.py's fixtures, which already use the flat shape), the first branch is dead in production and untested — it just happens to be harmless because the fallback saves it. Not a live bug, out of scope for this diff, but worth a follow-up cleanup so the stale mental model doesn't confuse the next reader or get copy-pasted into a new call site.
[NB-3] tests/test_e2e.py:2084 (_test_config_new_push) — no E2E test exercises schema validation with an explicit --configuration body against a real component
The existing E2E test for config new --push only exercises the empty-shell path (configuration == {}, validation_status ends up "skipped"/untested for "ok"/"failed"). Issue #587 was specifically about validation being wrong when a real body is provided — the exact scenario the author manually reproduced live against keboola.ex-db-mysql in the PR description's before/after table — but that repro was never codified into test_e2e.py. Per CONTRIBUTING.md this is not strictly mandatory (the E2E requirement in the checklist targets new commands, and this is a fix to existing command behavior), so this is non-blocking, but given the regression already escaped once because the mock schema was unrealistic, an E2E case posting a real --configuration body against a real writer component (dry-run is enough) would close the loop that unit-test mocking alone cannot.
[NB-4] tests/test_config_create_service.py:415 — test_empty_parameters_still_fails_a_schema_that_requires_fields does not discriminate old vs. new code
Per focus item (4) I ran the new TestParametersLevelSchemaValidation class against the pre-fix _validate_config_body (checked out from main, same new fixtures) in an isolated worktree. 3 of 6 tests genuinely fail against the old code (test_sibling_keys_next_to_parameters_do_not_fail_validation, test_whole_body_is_posted_even_though_only_parameters_is_validated, test_validation_error_path_points_inside_parameters), confirming they are honest regression tests, not tests that merely re-describe the new implementation. The other 3 pass against both old and new code by design (the flow-fallback pair behaves identically before/after since the old code always validated whole-body; the test_empty_parameters_... case also raises ConfigError under both implementations, just for a different reported reason). This isn't a defect — it's still a legitimate edge-case test for the new code path — but the PR description's "six regression tests covering the contract" reads as if all six catch the regression, which isn't quite accurate; worth a one-word tweak in the PR body (not the test file) if the author wants precision.
Nits
[NIT-1]plugins/kbagent/skills/kbagent/references/gotchas.md:1180— the new## Cloning a config by hand...heading has no blank line before it (runs directly off the previous bullet list). Pre-existing style drift exists elsewhere in this file (e.g. the## data-app JSON outputheading a few lines below has the same issue onmain), so this isn't a new pattern, but since it's new content it would be an easy one-line fix while in the area.
Verification log
- Read
CONTRIBUTING.md(Checklist: Adding a New CLI Command, Plugin synchronization map, Releasing a new version) andCLAUDE.md(convention #17, All CLI Commands) in full. - Read
plugins/kbagent/agents/keboola-expert.md§1 (non-negotiable rules) and §3 (inline gotchas). gh auth status→ authenticated aspadak,repo/workflowscopes ✓gh pr view 589 --json title,body,files,additions,deletions,baseRefName,headRefName,labels,state→ OPEN,fix(config): ...conventional prefix matches (bug fix) ✓, +245/-16 across 8 filesgit rev-parse --abbrev-ref HEAD→fix/config-new-parameters-schema-587, matches<branch>✓ (no checkout needed)gh pr diff 589→ 368 lines;git diff main...HEAD --statconfirms onlyconfig_service.py+ tests + docs/version-sync files changed; no command surface touched (git diff main...HEAD -- src/keboola_agent_cli/commands/ src/keboola_agent_cli/permissions.py src/keboola_agent_cli/server/routers/→ empty), socontext.py/CLAUDE.md/permissions.py/commands-reference.md/keboola-expert.md§2 matrix updates are correctly not required- Layer-violation greps (typer/click/formatter in services; httpx/requests in commands) → all empty ✓
- Convention greps (magic numbers, raw error-code strings, bare
except:,print(), token leaks, newtuple[...]returns) → all empty ✓ - Simulated
jsonschema.Draft7Validator.iter_errors()againstNone/[]/'a string'/{"db": None}targets → all produce clean, non-crashing type errors with sensible messages ("parameters: None is not of type 'object'"after prefixing) — confirms focus item (2), no crash on non-dictparameters - Traced all 4
configuration_schemaconsumers (config_service.py,component_service.pyx2,flow_service.py/flow_validation.py) — confirms focus item (3), only_validate_config_bodyhad the live bug; see NB-2 for the one dead-code loose end - Created an isolated detached
git worktreeatmerge-base(HEAD, main)(does not touch this working tree), copied the PR's newtests/test_config_create_service.pyonto pre-fixconfig_service.py, ranuv run pytest -k ParametersLevelSchemaValidation→ 3 of 6 tests FAIL against the old code (confirms focus item (4), test honesty) — worktree removed after make check(freshuv sync --extra serverfirst) → exit 0, 5495 passed, 12 skipped, lint/format/typecheck/skill-freshness/version-sync/command-sync/changelog-check/error-codes/sentinel-guards all greenpyproject.toml/changelog.py/.claude-plugin/marketplace.json/plugins/kbagent/.claude-plugin/plugin.json/uv.lockall bumped to0.84.1consistently ✓- Live behavior verification (
--config-dir /Users/padak/kbagent/demo/.kbagent, projectshop,config new --push --no-files --dry-runagainst realkeboola.ex-db-mysql, no API calls made):- Valid wrapped body (
{"parameters": {"db": {...}}, "runtime": {...}}) →validation_status: "ok"✓ matches PR's claimed fix - Invalid parameters (
{"parameters": {"nonsense": 1}}) →validation_status: "failed",["parameters: 'db' is a required property"]✓ exact match to PR's claimed error text - Malformed / no wrapper (
{"db": {...}}) →validation_status: "ok"✓ reproduces the documented "Known limit" - Dry-run envelope's
configurationfield kept["parameters", "runtime"]— siblings survive validation-only unwrapping ✓ - Human-mode output (no
--json) on the invalid-parameters case correctly printsparameters: 'db' is a required property— both JSON and human surfaces agree ✓
- Valid wrapped body (
Open questions for the author
(none)
Review follow-ups on #589. E2E (NB-3): _test_config_new_push only covers the empty-shell path, where validation auto-skips -- so nothing exercised a real component schema against a real body. That is the gap #587 fell through, and the same gap the unit-test mock could not close, since the mock was written in the wrong shape itself. New step 19c posts a valid wrapped body against keboola.ex-db-mysql (dry-run only, nothing created) and asserts the one-directional invariant that holds on any stack: a valid configuration must never come back "failed". When the stack does serve a schema it also asserts junk parameters are still rejected with `parameters.`-prefixed paths, proving the unwrap did not turn validation into a no-op. gotchas.md (NB-1, NIT-1): tag the new "Cloning a config by hand" heading with its version per CONTRIBUTING.md, and add the missing blank line before it.
|
Review addressed in 6384242. NB-1 / NIT-1 — fixed. The NB-3 — fixed. NB-4 — correct, and the PR description was the inaccurate part, not the tests. Rewritten: three tests are true regression tests (verified to fail against pre-fix code), the other three are guards that pass against both implementations by design. Your isolated-worktree check matches what I saw in the RED run. NB-2 — confirmed and not fixed here. |
…tion Devin review on #589. Keying the unwrap on the body alone (`"parameters" in body`) regressed one case: a component whose configurationSchema declares a top-level `parameters` property describes the WHOLE configuration object, and such a component worked before this PR precisely because every body used to be validated whole. Unwrapping it validates the wrong level and rejects a correct configuration -- the exact failure mode #587 is about, just moved to a different component. The unwrap now requires both signals: the body carries `parameters` AND the schema does not declare it. The mirror case -- a parameters-level schema whose own fields include one named `parameters` -- is misvalidated today as well, so preferring the schema signal never trades a working case for a broken one. Two regression tests: a whole-body schema validates a wrapped body as ok, and its errors keep whole-body paths rather than gaining an invented `parameters.` prefix. Live re-verified against keboola.ex-db-mysql: valid body ok, junk parameters still failed with `parameters: 'db' is a required property`.
#591 added a test asserting the newest version's release notes are not shown truncated. #589 was written and CI'd before that test existed and was never re-run against the newer main, so both PRs were green and merging them in order produced a red main. Its Tests note opened with a 161-character sentence, one over CHANGELOG_HEADLINE_MAX_CHARS, so the summary broke off at '... and CI stayed green …'. One dash becomes a period: same words, 161 -> 99 chars. This is the failure mode issue #585 describes -- CI checks the merge commit but only recomputes it when the PR is updated, so a branch that sits while main moves reports green for a merge that no longer exists. Second occurrence today; #560 hit it earlier at 33 commits stale and was caught only because the merge was simulated by hand.
…docs Opens 0.84.3: v0.84.2 is tagged and published at main's HEAD, so there is no in-progress key to append to, and the repo's convention is that the substantive PR carries the bump (0.84.2 <- #594/#597, 0.84.1 <- #589, 0.84.0 <- auth login-password, ...). Neither `changelog-check` (audits that released versions have entries) nor `version-check` (plugin.json / marketplace.json / uv.lock vs pyproject) would have caught the omission -- the silent drift convention #17 warns about. The behaviour change is user-visible, so it also lands in gotchas.md tagged (since v0.84.3). Tests: the PR claimed poll counts are unchanged for every budget but nothing pinned it. test_timeout_raises_storage_job_timeout now records sleeps and asserts none happened -- verified that moving the deadline check after the sleep makes it fail (assert [1.0] == []) where before it merely ran a second slower, since the break still precedes the fetch. Adds test_budget_below_one_interval_still_polls_once for the other half of the claim (0.5s budget -> exactly one poll, overshoot preserved), and test_polled_success_returns_the_polled_body: the happy path was covered only incidentally, by a fixture that returns a terminal body and never enters the loop. Docstring: the "same shape as the sibling pollers" line read as a parity claim. Narrowed -- the check-then-fetch shape matches, the behaviour does not: this poller knows only success/error (so any other terminal status would exhaust the budget and surface as STORAGE_JOB_TIMEOUT, where the queue poller keys off isFinished), and its sleep is not capped to the remaining budget. Both predate this branch. _mk_client is now one module-level helper instead of two byte-identical methods 62 lines apart (the only two in the suite). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…605) (#614) A component's configurationSchema describes the CONTENTS of configuration.parameters, so a flattened body -- the component's own fields at the configuration root -- matched it, validated ok, and was POSTed verbatim, creating a live configuration with no parameters key while --push reported success. A body with no parameters key is now validated as an EMPTY parameters section, and the whole-body exemption is keyed on the component (keboola.flow / keboola.orchestrator) rather than on the body's shape. Completes the half of #587 that #589 left standing. Live-verified against a real stack; Devin's carve-out concern checked against the AI Service and refuted.
Closes #587.
The bug
A component's
configurationSchema(AI Service) describes the contents of theparameterskey, not the whole configuration object._validate_config_bodyvalidated the whole body against it, so it was off by one level — which inverted every outcome.Confirmed live against the AI Service (
keboola.ex-db-mysql,--dry-run, nothing created):{"parameters": {"db": {…}}, "runtime": {…}}failed—<root>: 'db' is a required propertyok{"parameters": {"nonsense": 1}}parametersfailedfailed—parameters: 'db' is a required property{"db": {…}}ok(wrong)Not platform-specific despite the Windows environment in the report — reproduced on macOS.
Why it mattered more than a bad error message
Faced with a bogus failure on a body they knew was correct, the reporter passed
--no-validateto get past it, and thereby also switched off the checking that did work. What slipped through was a droppedruntime.parallelism. Keboola treats a missingruntimeasparallelism: 1, so a 65-row Snowflake writer ran strictly sequentially — 140 minutes instead of the expected 60–90 — with nothing in any tool output pointing at it. It was caught by hand-comparing per-row job timestamps after the fact.The fix
services/config_service.py— validation targetsbody["parameters"], and error paths are prefixedparameters.so they name the section to fix. Sibling keys (storage,runtime,authorization), which the schema does not describe, no longer fail validation.Unwrapping affects validation only — the POSTed body still carries every sibling key. That has its own regression test, since dropping siblings is the very failure this issue is about.
A body with no
parameterskey is still validated whole: that is thekeboola.flowshape, wherephases/tasksare the configuration root (cf.resources/flow/conditional-flow-schema.json, whoserequiredis["phases","tasks"]).Known limit: a parameters-level body posted by mistake as the whole configuration is indistinguishable from a flow-style config and still validates
ok. That is unchanged from today's behavior, not a new gap — the two shapes cannot be told apart from the body alone.Scope: validation runs only in
create_config.config updateandconfig row-createdo not validate against a schema and are untouched.Why CI never caught it
The mock schema in
tests/test_config_create_service.pywas itself the reason this shipped: it wrapped everything in aparametersproperty — a shape no real component returns — so the off-by-one-level validation matched it and the suite stayed green. It is now the real parameters-level shape.Six tests cover the new contract, of which three are true regression tests — verified to fail against the pre-fix code:
parametersdo not fail validationparameters.The other three pass against both old and new code by design, and are guards rather than regression proofs: the two flow-style fallback cases (which must keep behaving exactly as before) and the empty-
parameterscase (which raises under both implementations, for different reported reasons).Because unit-test mocking is what failed here in the first place,
tests/test_e2e.pygains step 19c: a valid wrapped body posted against a realkeboola.ex-db-mysqlschema (dry-run only, nothing created), asserting the invariant that holds on any stack — a valid configuration must never come backfailed— plus, when the stack does serve a schema, that junkparametersare still rejected.make check: 5495 passed, 12 skipped.Not addressed here
Issue #587 raises two further asks, both worth their own issues:
config clonecommand — copy an entire existing configuration and override only named fields, removing the "forgot a sibling key" class of bug at the source.config examples/config new --helpshould say that a configuration may carryruntime/storage/authorizationnext toparameters. Documented ingotchas.mdin this PR, but not yet surfaced in the CLI itself.Review also flagged a third, in
component_service._resolve_parameters(the scaffold generator): it still probesschema["properties"]["parameters"]["properties"]— the same wrong mental model — before falling back to the correct flat read. Harmless today because the fallback catches it, but it is a different code path with its own tests, so it is a follow-up rather than scope creep here.