…ntrol
The group and campaign titles three of these fetchers matched on were hardcoded
to one tenant. Pointed anywhere else they emitted completion_rate: 0 and exited
0 — byte-identical to a tenant where the campaign resolved and genuinely nobody
had trained:
typo'd config, users DID pass -> {"completed_training": 0, "completion_rate": 0}
config OK, nobody passed -> {"completed_training": 0, "completion_rate": 0}
Two very different states, one output, so no test could tell them apart.
The names now come from config_schema. A name that matches nothing in the tenant
is NOT a fetcher failure — one typo must not turn a whole nightly run red. The
fetcher exits 0 and reports every metric it could not measure as null, never 0,
with a results.config_resolution block naming what was requested, what matched,
and what the tenant actually has. null means "not measured"; 0 still means
"measured, and it is zero", so a genuine 0% is preserved as the real finding it
is. A key that is never wired at all is caught pre-flight by paramify validate,
since these are required.
Also in this pass, because each was a way to get wrong numbers from valid input:
- Exact matching, not substring: a group configured as "IT" was also sweeping in
"AUDIT" and "Legal-IT".
- Config reaches jq as data (--args/$ARGS.positional) instead of being spliced
into the filter text, where a title containing a quote or backslash produced a
compile error. Making titles customer-supplied would have made that routine.
- One jq pass per fetcher instead of re-running jq over the growing output file
per record, which was quadratic: 3000 enrollments went from >120s (over the
runner's 600s cap for a mid-size tenant) to ~3s.
- A non-array response (an error body returned with HTTP 200) is recorded as a
failure rather than treated as a page; pagination looped forever on one before.
tests/test_knowbe4_config_resolution.py drives the scripts the way the runner
does — stub curl on PATH, config as env — and test_c_unmeasured_and_genuine_zero
_are_distinguishable is the pair that pins the null-vs-0 distinction. It fails
on the old code, which is the point: that bug was previously inexpressible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bug
The group and campaign titles three of these fetchers matched on were hardcoded to one tenant. Pointed anywhere else they emitted
completion_rate: 0and exited 0 — byte-identical to a tenant where the campaign resolved and genuinely nobody had trained:Two very different states, one output,
status: successon both. An auditor reads the first as a failing control. No test could tell them apart, which is why this went unnoticed.The fix
Names come from
config_schemanow (high_risk_groups,role_specific_campaigns,developer_groups,developer_campaigns,security_awareness_campaigns,retraining_interval_days).A name that matches nothing is not a fetcher failure — one typo must not turn a whole nightly run red. The fetcher exits 0 and reports every metric it could not measure as
null, never0, with aresults.config_resolutionblock naming what was requested, what matched, and what the tenant actually has:nullmeans "not measured".0still means "measured, and it is zero" — so a genuine 0% survives as the real finding it is. A key that is never wired at all is caught pre-flight byparamify validate, since these arerequired.Also fixed — each a way to get wrong numbers from valid input
ITwas also sweeping inAUDITandLegal-IT.--args/$ARGS.positional) instead of being spliced into the filter text, where a title containing a quote or backslash produced a compile error. Making titles customer-supplied would have made that routine.training_module_summaryon an empty tenant is now{}rather thannull.printf '%s'overechoso a backslash in a title survives a non-bash shell.Verification
Against the real Paramify KnowBe4 tenant (463 enrollments, 8 groups, 5 campaigns), all four fetchers exit 0 and resolve cleanly. Computed completion rates match KnowBe4's own
completion_percentageexactly:Old vs new on that same real data: 62s → 7s, byte-identical module numbers.
Tests:
tests/test_knowbe4_config_resolution.py(28 tests) drives the scripts the way the runner does — stubcurlon PATH, config as env, no network.test_c_unmeasured_and_genuine_zero_are_distinguishableis the pair that pins the null-vs-0 distinction; it fails on the old code, which is the point — that bug was previously inexpressible. Suite: 325 passed (297 baseline + 28 new), contract gate 142 passed.Notes for review
completion_ratecan now benullwhere it was always an integer. Nothing in this repo constrains it (payload: {}in the envelope schema,comparators/is a template), but Paramify-side ingestion or any regex validator needs to toleratenull. This is the only change altering an existing field's type.Cloud Ops Manual Add (FedRAMP Boundary Access)), so the old hardcoded"Cloud Ops"only ever worked because of substring matching. Configs must name the full title —examples/knowbe4_run.yamland the category README show how to list them.WARNnaming unmatched values, but the runner drops stderr on a zero exit (framework/api.pyrecordsstderr_tailonly whenexit_code != 0;executor.pydoes not forward stderr toon_line). So the evidence artifact is the only channel that reaches an operator today. One-line fix inapi.py, left alone to avoid colliding with in-flight work on that file.framework/runner/retry.pyis still empty; a 429 fails the fetcher cleanly rather than producing partial evidence.🤖 Generated with Claude Code