feat(notification): audit Flow Notifications-tab subscriptions (#600) - #615
Conversation
8b931d6 to
e6f12b5
Compare
padak
left a comment
There was a problem hiding this comment.
Review of #615 — feat(notification): audit Flow Notifications-tab subscriptions (#600)
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 adds a new read-only kbagent notification command group (list/detail) across all three layers plus the kbagent serve REST mirror, closing a real audit gap: Flow Builder Notifications-tab recipients live in a separate platform service and were previously invisible to flow detail/config detail. Layering, permission registration, and the entire hand-maintained plugin-sync surface (CLAUDE.md, commands/context.py, keboola-expert.md §2 matrix row, gotchas.md, commands-reference.md, SKILL.md description/table) are all present and consistent, make check is green (5633 passed), and the diff shows a healthy iteration history already responding to a prior automated review round (Devin) that fixed an over-counting bug in project_wide_excluded. My own pass found one residual edge case in that same counter that the prior fix didn't close — see NB-1 — plus a couple of small cosmetic notes. Verdict: COMMENT (no blocking findings).
Verdict
- Verdict: COMMENT
- Blocking findings: 0
- Non-blocking findings: 2
- Nits: 1
Blocking findings
(none)
Non-blocking findings
[NB-1] src/keboola_agent_cli/services/notification_service.py:116 + :306-313 — project_wide_excluded still over-counts when --component-id is filtered against a subscription scoped to a different, specific component
scope is derived purely from whether a subscription has a job.configuration.id filter (services/notification_service.py:116: SCOPE_CONFIG if config_id else SCOPE_PROJECT_WIDE), ignoring whether it does carry a job.component.id filter for some other component. The exclusion-counting loop (:306-313) then counts any unmatched row whose scope == SCOPE_PROJECT_WIDE as "would also fire for the audited config/component."
Concretely: a project with subscription A = {filters: [{field: "job.component.id", value: "keboola.ex-db-snowflake"}]} (no config filter) is unambiguously scoped to a different component and can never fire for a keboola.flow job. Running notification list --component-id keboola.flow still counts subscription A in project_wide_excluded and the human-mode warning at commands/notification.py:178 ("They have no configuration filter, so they also fire for this one") is factually wrong for this row — it will never fire "for this one."
This is a narrower residual of the exact bug class the prior Devin-review fix (commit e6f12b5) addressed for matched rows; that fix correctly stopped counting kept rows as excluded, but didn't add the extra check needed for unmatched-but-provably-irrelevant rows (an explicit, non-matching component_id filter). tests/test_notification_service.py::test_component_filter_is_applied_client_side (line ~329) exercises exactly this scenario but only asserts the kept-row list, not project_wide_excluded, so the gap is untested. Not blocking because the bias is still toward over- rather than under-reporting (the PR's stated safety invariant holds), but it will produce misleading "who else gets paged" noise for the exact incident-response workflow this feature targets. Suggested fix: exclude a row from the project_wide_excluded count when it carries its own non-matching, present component_id filter (i.e., only count rows where the missing dimension is genuinely ambiguous, not ones whose explicit filter proves them irrelevant).
[NB-2] src/keboola_agent_cli/services/notification_service.py:120-122 — new tuple[...] return is a candidate for a small dataclass
_build_config_indexes(...) -> tuple[dict[tuple[str, str], str], dict[str, list[str]]] is a newly-added, heterogeneous two-element tuple (by_pair vs names_by_config_id) — exactly the case CONTRIBUTING.md's "Return values" section calls out ("even two-element tuples should use a dataclass when the values are semantically distinct"). A tiny @dataclass(frozen=True) with by_pair / names_by_config_id fields would make the two call sites (_stamp_config_names) self-documenting instead of relying on unpacking order. Low priority — the function is _-private with two call sites and both are in the same file.
Nits
[NIT-1]src/keboola_agent_cli/services/notification_service.py:284—_fetch_project_subscriptionsreturns a 4-tuple(alias, rows_or_error_dict, excluded, ok)on success but a 2-tuple(alias, error_dict)on failure (see theexceptbranches around line ~360). Both shapes are consumed positionally inlist_subscriptions(result[1],result[2]), which works only because_run_parallelsorts successes/errors into separate buckets first. This is grandfathered under theBaseServiceparallel-result convention (tuple[str, ...] | tuple[str, dict]) so it's not a Code-Quality-Patterns violation, but the differing arity between the two tuple shapes in the same function is easy to trip over on a future edit — worth a one-line comment at the tworeturnsites noting the arity contract if this file is touched again.
Verification log
git rev-parse --abbrev-ref HEAD→claude/issue-600-3d974b(matches<branch>), working tree clean,gh pr view 615 --json state→OPEN✓gh auth status→ authenticated aspadak✓- Read
CONTRIBUTING.md(Checklist: Adding a New CLI Command, Plugin synchronization map, Releasing a new version),CLAUDE.mdconvention #17 +## All CLI Commands,plugins/kbagent/agents/keboola-expert.md§1/§3 (and confirmed §2 already carries the new matrix row) ✓ gh pr view 615 --json title,body,files,additions,deletions,...→ 26 files, +2213/-7,feat(notification): ...conventional prefix matches (new read-only feature) ✓gh pr diff 615→ 2576 lines, reviewed in full ✓git diff main...HEAD -- pyproject.toml changelog.py plugin.json marketplace.json→ version bumped 0.85.1 → 0.86.0 consistently across all four files; changelog entry present and well-formed (3 bullets, each starting with a recognized style); survived the rebase onto#614(d6c6ef8) intact ✓- Layer-violation greps (typer/formatter in services, httpx in commands, formatter/typer in clients) on the diff → all empty ✓
- Magic-number / raw-error-code-string / bare-except / stray-
print()/ token-leakage greps on the diff → empty except two rawerror_code="..."string literals, both confirmed to be in test files (tests/test_notification_cli.py:295,tests/test_notification_service.py:429,466,507) constructing mockKeboolaApiErrorobjects, not production code —make check-error-codes(part ofmake check) passed, confirming these are not flagged ✓ - New
-> tuple[...]return added in this diff → exactly one hit,_build_config_indexes(see NB-2); the twotuple[Any, ...]/tuple[str, ...] | tuple[str, dict]worker-callback shapes match the grandfatheredBaseServiceparallel-result convention and are correctly skipped ✓ make check(background, ~124s) →5633 passed, 12 skipped, 158 deselected, exit 0 — lint, format, typecheck, skill freshness, version sync, command-sync (permissions/CLAUDE.md/context.py/commands-reference.md drift gate), changelog-check, error-code enum, sentinel-guards, full test suite all green ✓uv run python scripts/check_sentinel_guards.py --list→OK: ... all 10 guards covered by SESSION_UNSUPPORTED_FEATURES (8 entries)— the new_notification_clientsub-client +_notification_requestplumbing did not trip the session-sentinel guard (bearer-capable likebilling/queue, correctly classified) ✓- Read
services/notification_service.pyin full (406 lines) and traced_matches_scope/ scope classification / exclusion-counting logic line by line against the commit history (git show e6f12b5, the prior Devin-review fix) to confirm what was already fixed vs. what remains — see NB-1 ✓ - Read
_stamp_config_namesjoin-path selection logic (per-componentlist_component_configsvs whole-projectlist_components_with_configs) and cross-checked againsttests/test_notification_service.py(test_component_scoped_join_uses_the_cheap_per_component_listing,test_component_less_row_falls_back_to_the_whole_project_listing,test_each_component_is_listed_once_for_many_subscriptions,test_no_join_call_when_nothing_is_config_scoped) → logic and tests both correct: whole-project listing is used only when at least one config-scoped row lacks a component filter, otherwise one call per distinct component ✓ - Read
commands/notification.py(254 lines) → thin, both JSON and human-mode paths present, permission callback matches theschedule/branchprecedent exactly (check_cli_permission(ctx, "notification")→notification.list/notification.detailkeys, both registered as"read"inpermissions.py) ✓ server/routers/notifications.py,test_server_router_calls.py,test_server_smoke.pydiffs → both CLI commands have a 1:1 REST mirror (GET /notifications,GET /notifications/{project}/{subscription_id}), registered inserver/app.py(router include + OpenAPI tag) andserver/dependencies.py(ServiceRegistry.notification), with request-forwarding tests and a smoke-test path entry ✓- Attempted live reproduction against a real Keboola project: no credentialed
config.json/ registered project was available in this sandbox environment (no~/.kbagent/config-dir with a live token was present or accessible without handling a token myself, which is out of scope per policy) — could not independently hit a livenotification.{stack}host. The claimed wire-format corrections (kebab-case event names, dotted filter paths,addressvsurlrecipient discrimination) are stated in the PR description as verified against the service's public swagger with a link to the issue thread; I did not independently re-verify against the swagger. Flagging as unverified rather than asserting correctness.
Open questions for the author
- Was
[NB-1]'s scenario (a component-scoped-but-config-less catch-all for a different component, audited via--component-idfor another component) considered and deliberately left as an acceptable over-report, or is it worth a follow-up fix? Given the PR already iterated once on this exact counter with the automated reviewer, a quick disposition either way (fix now vs. tracked as a known limitation ingotchas.md) would close the loop cleanly.
|
All three findings addressed in NB-1 — fixed, not accepted as a known limitationNot deliberate. The prior round fixed the kept-row half of this counter and I stopped there without walking the dropped side, so this is the same bug class left half-closed — a fair catch.
What still over-counts, deliberately: a subscription filtering on a component but not a config, dropped by
NB-2 — fixedChecked CONTRIBUTING.md before acting: line 127 ("even two-element tuples should use a dataclass when the values are semantically distinct") and the PR checklist item "No new NIT-1 — fixedDocumented the arity contract in the docstring and at both return sites, naming the On the unverified wire-format claimsReasonable flag given you had no credentialed project. For the record, both halves were verified earlier in the session that produced this branch: the swagger was fetched from The gap that remains, and which the PR description states: those projects have zero subscriptions, so row shaping is exercised only against swagger-shaped fixtures, and it is still unconfirmed whether the Flow Builder UI actually writes a
🤖 Addressed by Claude Code |
Adds a read-only `kbagent notification` command group over the Notification
Service, closing the last unauditable notification surface: the Flow Builder's
Notifications tab (bell icon -- Success / Error / Processing-delay / Warning
cards). Those recipients live in a separate platform service, not in the flow's
`configuration` JSON, so `flow detail` / `config detail` never showed them. The
in-flow `type: "notification"` TASK is a different mechanism and stays visible
there.
kbagent notification list [--project ALIAS ...] [--event NAME]
[--component-id ID] [--config-id ID]
kbagent notification detail --project ALIAS --subscription-id ID
Three layers, following the queue/schedule precedents:
- L3 `client/notifications.py`: `_NotificationsMixin` over a
`notification.{stack}` sibling host derived by `_derive_service_url`, plus
the `_notification_request` / sub-client / `close()` plumbing in `_core.py`.
Authenticates with the plain project Storage token -- no elevated scope.
- L2 `services/notification_service.py`: parallel fan-out with per-project
error accumulation, plus a config-name join (exact component+config match,
unique-config-id fallback, blank on ambiguity) that is skipped entirely when
no row is config-scoped.
- L1 `commands/notification.py`, registered under the Flows help panel and as
read-only operations in the permission registry.
Wire-format details verified against the service's public swagger, since the
issue's draft had them wrong:
- Event names are kebab-case (`job-failed`, ...), and `EventName` is an open
string with no enum -- so `--event` is forwarded verbatim and NOT validated
against a client-side allowlist that would go stale.
- Filter fields are dotted paths into the event payload (`job.component.id`,
`job.configuration.id`, `branch.id`, `phase.id`), not flat keys. The
`--component-id` / `--config-id` filters match those client-side; the API's
only server-side filter is `?event=`.
- Webhook recipients carry `url`, email recipients carry `address`; both
normalize into one `address` column.
`filters` is optional, so a subscription with none is project-wide and fires
for every job. Those rows are excluded by `--component-id`/`--config-id` but
counted in `project_wide_excluded` (with a warning in human mode), so "who
gets paged when this flow breaks" is never silently under-reported.
Docs synced per convention #17: CLAUDE.md, context.py, commands-reference.md,
SKILL.md, gotchas.md, keboola-expert.md. Changelog + version bump to 0.86.0.
…their limits The changelog headline is truncated at 160 chars in `kbagent changelog` and the "What's new" banner, and Claude Desktop rejects a skill whose description exceeds 1024 characters. Lead each 0.86.0 note with a short self-contained sentence, and keep the SKILL.md addition to trigger words only -- dropping the duplicate "browser login" trigger, which "login" / "sign in" / "auth" already cover.
…ty, join cost
Four findings from the automated review, all verified against the code before
acting.
1. `project_wide_excluded` over-reported. `scope` is derived from the config
filter alone, so a subscription filtering only on `job.component.id` is
labelled project-wide -- but `--component-id` KEEPS it. The counter summed
every project-wide row instead of only the dropped ones, so a kept row was
also warned about as hidden, contradicting the table on screen. Now counted
in the same pass that partitions the rows.
2. The Component column rendered literal `[dim]any[/dim]`. The fallback markup
was inside `escape()`, which turned `[dim]` into `\[dim]`. Moved the markup
outside, matching what `_config_cell` already did.
3. No REST routes. CONTRIBUTING.md mandates 1:1 CLI/HTTP parity for every
command group, with a skip allowed only for terminal-only commands -- these
are plain JSON reads, so no skip applies. Adds
`server/routers/notifications.py` (`GET /notifications`,
`GET /notifications/{project}/{subscription_id}`) plus registry, app and
OpenAPI-tag wiring.
4. The config-name join fetched the whole project. `list_components_with_configs`
sends `include=configuration,rows` -- every config body and row -- to map an
ID to a display name, once per project in the fan-out. When every
config-scoped row names its component (the common case), one
`list_component_configs` per distinct component answers the same question;
the whole-project listing is now used only when a row filters on a bare
config ID, where resolving it does require searching every component.
Note the review's supporting claim was wrong: `ScheduleService` uses the same
`list_components_with_configs` call (schedule_service.py:521, 585) and
documents the trade-off -- it needs the config BODIES. The stale mention of
`list_component_configs` in its module docstring is what the comparison
picked up. The optimization stands on its own for this service, which only
needs names.
Regression tests for each: kept-row exclusion counting, markup rendering, both
join paths and per-component call de-duplication, and the two REST routes. The
E2E exclusion assertion is updated to the dropped-rows-only contract.
… the arity
Three findings from the kbagent-pr-reviewer pass, all verified before acting.
NB-1: `project_wide_excluded` still over-counted one case. The prior fix
stopped counting rows the filter KEPT, but a dropped row carrying its own
explicit `job.component.id` for a DIFFERENT component was still counted --
and that row can never fire for the audited component, so the warning
("they also fire for this one") was factually wrong for it. `_could_also_fire`
now counts a dropped row only when the mismatch comes from an ABSENT
constraint, i.e. the genuinely ambiguous case. `test_component_filter_is_
applied_client_side` exercised this scenario but asserted only the kept list,
which is why the gap was untested; it now asserts the counter too, alongside
a dedicated regression test.
NB-2: `_build_config_indexes` returned a heterogeneous two-element tuple.
CONTRIBUTING.md "Return values" calls this out explicitly ("even two-element
tuples should use a dataclass when the values are semantically distinct") and
the PR checklist carries "No new `tuple[...]` returns". Replaced with a
`ConfigNameIndex` dataclass, which also documents why the second index exists
at all -- ambiguity detection on the component-less fallback path.
NIT-1: `_fetch_project_subscriptions` returns a 4-tuple on success and a
2-tuple on failure, and that arity difference IS how `_run_parallel`
discriminates. Documented in the docstring and at both return sites so a
future edit cannot quietly break the contract by growing one shape.
The gotchas entry now states the full counter rule: kept rows are never
counted, and neither is a dropped row whose own explicit filter proves it
irrelevant -- only rows missing the constraint you filtered on.
…dget `main` grew `keboola-expert.md` from 60656 to 61622 bytes while this branch was open (#611, #616). The branch's own copy was fine at 61178, but CI builds the PR MERGE commit -- 61622 + this branch's 522-byte matrix row = 62144, i.e. 144 over the hard 62000 ceiling. All three test jobs failed on it; the branch in isolation passed, which is why it only showed up on the PR. The budget test's own comment rules out raising the ceiling ("split keboola-expert into per-domain specialists rather than raising the ceiling again"), so the row is trimmed to 377 bytes instead: command, version gate, the one fact that changes a decision (recipients live in a separate service, not the flow config), the fallback, and both ways to get it wrong. The flag list and the longer gloss are dropped -- `--help` and `gotchas.md` carry them. The file now sits at 61999 bytes, one under the ceiling. That is not headroom; the next PR touching this file hits the same wall regardless of what it adds. The structural fix the test comment asks for is out of scope here.
6a2aac6 to
b9de465
Compare
…85.1 The version bump to 0.86.0 already landed on main (#615), but the release notes it produces were incomplete in two ways. Missing entries. PR #616 (`token list`, plus the retry-policy and exceptionId changes) carried no changelog note at all -- its commit message says "No version bump: this lands in a stack of PRs released as one version. The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm that", and the bump PR did not. `make changelog-check` cannot catch this: it verifies every published GitHub release has an entry, not that every merged PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610 (winget job disabled) were likewise unannounced. All four are added. Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615) without a tag in between, so 0.85.1 exists only as a changelog bucket -- no release, no artifact, nobody running it. `format_whats_new` shows the notes of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would have silently missed those four fixes (Azure ciphertext prefix, the `parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs). The bucket is folded into 0.86.0 verbatim. The same phantom leaked into the agent-facing version gates, which is the worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can install. Retagged to 0.86.0, along with two source comments. Three of the new notes had to lead with a shorter sentence to satisfy `test_newest_release_notes_are_not_truncated` (the headline is the note's first sentence, capped at 160 chars). No behaviour change; documentation and release metadata only.
…recognised prefix
Two findings from Devin's review of this PR, plus one they could not see.
The serve route for `token list` was cited as `GET /tokens/{project}`. Both
halves are wrong: the router carries `prefix="/token"` (singular) and the
operation is registered at `/{project}/list`, so the real path is
`GET /token/{project}/list` -- confirmed against the runtime OpenAPI schema,
not the source, because that is what a caller actually hits. Worth noting the
review's proposed correction (`/tokens/{project}/list`) is itself wrong on the
prefix; taking it verbatim would have swapped one 404 for another.
`CI:` is not a recognised note prefix. `_PREFIX_STYLES` / `_PREFIX_RE` in
commands/changelog.py define the set, the module docstring states the contract,
and an unrecognised label renders unhighlighted. Retitled to `Note:`, which
also reads better: the winget job being disabled has a user-facing consequence
(WinGet users stay on the last published version), so burying it under a dim
`Internal:` would understate it.
The finding Devin could not report: the four notification notes carried by
#615/#618 have no prefix at all. They were outside this PR's diff, so no
reviewer looking at the diff would flag them -- but they ship in the same
release block and break the same contract, leaving half of v0.86.0 rendering
flat. Prefixed `New:` / `Note:` with no change of meaning. Every 0.86.0 note
now matches `_PREFIX_RE`, verified by asserting over the live CHANGELOG rather
than by reading.
Each replacement is written to disk on its own. Running several in one script
means a later failed assert discards the earlier successful writes, which is
precisely how #618's stale "server-side ?event=" claim survived its own fix
pass.
…85.1 (#619) * chore(release): complete the 0.86.0 changelog and drop the phantom 0.85.1 The version bump to 0.86.0 already landed on main (#615), but the release notes it produces were incomplete in two ways. Missing entries. PR #616 (`token list`, plus the retry-policy and exceptionId changes) carried no changelog note at all -- its commit message says "No version bump: this lands in a stack of PRs released as one version. The (since v0.86.0) doc tags assume 0.86.0 and the bump PR must confirm that", and the bump PR did not. `make changelog-check` cannot catch this: it verifies every published GitHub release has an entry, not that every merged PR has a note. #556/#606 (merge-request endpoints, Layer 3) and #610 (winget job disabled) were likewise unannounced. All four are added. Phantom 0.85.1. pyproject went 0.85.0 -> 0.85.1 (#614) -> 0.86.0 (#615) without a tag in between, so 0.85.1 exists only as a changelog bucket -- no release, no artifact, nobody running it. `format_whats_new` shows the notes of the *target* version only, so every user upgrading 0.85.0 -> 0.86.0 would have silently missed those four fixes (Azure ciphertext prefix, the `parameters` wrapper, GCP/Azure sync ciphertext, the encrypt-values docs). The bucket is folded into 0.86.0 verbatim. The same phantom leaked into the agent-facing version gates, which is the worse half: `keboola-expert.md` told users to "upgrade to 0.85.1+" and four gotchas.md entries were tagged `(since v0.85.1)` -- a version nobody can install. Retagged to 0.86.0, along with two source comments. Three of the new notes had to lead with a shorter sentence to satisfy `test_newest_release_notes_are_not_truncated` (the headline is the note's first sentence, capped at 160 chars). No behaviour change; documentation and release metadata only. * fix(changelog): correct the serve route and give every 0.86.0 note a recognised prefix Two findings from Devin's review of this PR, plus one they could not see. The serve route for `token list` was cited as `GET /tokens/{project}`. Both halves are wrong: the router carries `prefix="/token"` (singular) and the operation is registered at `/{project}/list`, so the real path is `GET /token/{project}/list` -- confirmed against the runtime OpenAPI schema, not the source, because that is what a caller actually hits. Worth noting the review's proposed correction (`/tokens/{project}/list`) is itself wrong on the prefix; taking it verbatim would have swapped one 404 for another. `CI:` is not a recognised note prefix. `_PREFIX_STYLES` / `_PREFIX_RE` in commands/changelog.py define the set, the module docstring states the contract, and an unrecognised label renders unhighlighted. Retitled to `Note:`, which also reads better: the winget job being disabled has a user-facing consequence (WinGet users stay on the last published version), so burying it under a dim `Internal:` would understate it. The finding Devin could not report: the four notification notes carried by #615/#618 have no prefix at all. They were outside this PR's diff, so no reviewer looking at the diff would flag them -- but they ship in the same release block and break the same contract, leaving half of v0.86.0 rendering flat. Prefixed `New:` / `Note:` with no change of meaning. Every 0.86.0 note now matches `_PREFIX_RE`, verified by asserting over the live CHANGELOG rather than by reading. Each replacement is written to disk on its own. Running several in one script means a later failed assert discards the earlier successful writes, which is precisely how #618's stale "server-side ?event=" claim survived its own fix pass.
Closes #600.
Adds a read-only
kbagent notificationcommand group over the Notification Service, closing the last unauditable notification surface: the Flow Builder's Notifications tab (bell icon — Success / Error / Processing-delay / Warning cards).Those recipients live in a separate platform service, not in the flow's
configurationJSON, which is whyflow detail/config detailnever showed them. The in-flowtype: "notification"task is a different mechanism and stays visible there.Layers
Follows the
queue/scheduleprecedents exactly — no new abstraction.client/notifications.py+client/_core.py_NotificationsMixinover anotification.{stack}sibling host derived by_derive_service_url, plus the_notification_request/ sub-client /close()plumbingservices/notification_service.pycommands/notification.pyserver/routers/notifications.pyGET /notificationsandGET /notifications/{project}/{subscription_id}— the 1:1kbagent servemirror CONTRIBUTING.md requiresAuthenticates with the plain project Storage token every registered alias already holds — no elevated scope, no manage token.
Wire-format corrections
The issue's draft got three details wrong; all were verified against the service's public swagger and are written up in the issue thread.
job-failed,job-succeeded,job-succeeded-with-warning,job-processing-longand thephase-job-*variants, notjobFailed.EventNameis also an open string with no enum, so--eventis forwarded verbatim and deliberately not validated against a client-side allowlist that would go stale the moment the platform adds an event.job.component.id,job.configuration.id,branch.id,phase.id,durationOvertimePercentage— not flatconfigurationId/componentkeys. This was the one that mattered: with the flat keys the resolvedflow_namecolumn would have been silently empty on every row.url, email recipients carryaddress. Both normalize into oneaddresscolumn.Both of the issue's open questions are answered:
branch.idis a supported filter (so subscriptions can be branch-scoped even though the endpoint has no branch parameter), andfiltersis optional — a filter-less project-wide subscription is legal and common.One design decision beyond the issue
--component-id/--config-idexclude project-wide subscriptions (the ones with no filters) — but those also page for the config being audited. Silently dropping them would answer "who gets paged when this flow breaks" wrongly, which is the exact failure mode the issue was opened about. They are therefore counted inproject_wide_excludedand surfaced as a warning in human mode.Two smaller ones:
list_components_with_configspayload is proportional to the whole project, and a project full of catch-alls would otherwise pay for a join that can only produce blanks.config_nameresolves by exact(component_id, config_id)match, falling back to a config-ID lookup only when unambiguous. Two components sharing a config ID, a deleted parent, or a failed lookup all yield""rather than a guess — a wrong flow name in an alert audit is worse than a blank one.Review round 1
Four findings from the automated review, all verified before acting — fixed in
8b931d6:project_wide_excludedover-reported.scopekeys off the config filter alone, so a subscription filtering only onjob.component.idis labelled project-wide — but--component-idkeeps it. The counter summed every project-wide row rather than only the dropped ones, so a visible row was also warned about as hidden. Now counted in the same pass that partitions the rows.Component column rendered literal
[dim]any[/dim]. The fallback markup was insideescape(). Moved out, matching_config_cell.No REST routes. Genuine CONTRIBUTING.md violation — no skip applies to plain JSON reads. Router added (see the table above).
Config-name join fetched the whole project.
list_components_with_configssendsinclude=configuration,rowsto map an ID to a display name. Now onelist_component_configsper distinct component when every config-scoped row names its component; the whole-project listing is used only for a bare-config-ID row, where resolving it does require searching every component.One detail in that finding's reasoning was wrong and is worth recording:
ScheduleServiceuses the samelist_components_with_configscall (schedule_service.py:521,:585) and documents the trade-off — it needs the config bodies. The stalelist_component_configsmention in its module docstring is what the comparison picked up. The optimization stands on its own here regardless, since this service only needs names.Testing
make checkgreen: 5620 passed.tests/test_notification_client.py(10) — host derivation, query params, path quoting, sub-client lifecycletests/test_notification_service.py(27) — field extraction, fan-out, client-side filters, join fallbacks, exclusion counter, error accumulationtests/test_notification_cli.py(12) — JSON envelope, human rendering, warnings, exit codes,--deny-writestests/test_e2e.py::TestE2ENotificationSubscriptions(6) — envelope contract,--eventround-trip, unknown-event-returns-empty, exclusion counter, detail round-tripLive verification:
GET https://notification.europe-west3.gcp.keboola.com/project-subscriptions→ HTTP 200 with a plain project Storage token, fanned out across 4 real projects on a GCP stack, no errors.Not verified live: row shaping against real data — the projects available to me have zero subscriptions, so the E2E class asserts the envelope contract unconditionally and the per-row shape only when rows exist. It starts covering the row path the day a subscription exists in an E2E fixture, without needing a rewrite. The same gap means it is still unconfirmed whether the Flow Builder UI actually writes a
branch.idfilter when a subscription is created inside a dev branch — the schema supports it, the producer is unverified.Scope
Read-only, per the issue's stated non-goals.
POST/DELETE /project-subscriptionsare a natural follow-up.POST /notifications(send a direct notification) is deliberately out — it needs a Manage API application token with thenotifications:send-notificationscope, so it belongs with the rest of the default-deny manage-token surface, not here.Docs
Synced per convention #17:
CLAUDE.md,context.py,commands-reference.md,SKILL.md,gotchas.md,keboola-expert.md. Changelog entry + version bump to 0.86.0.Two limits bit during the doc sync and are fixed in the second commit: the changelog headline truncates at 160 chars, and Claude Desktop rejects a skill description over 1024 characters (the description was already at ~997).
Note for a follow-up
CLAUDE.mdconvention #17 listscontext.pyand the## All CLI Commandssection as silent-drift risks "not CI-enforced". They are enforced today byscripts/check_command_sync.py— it failed on both while I was building this. Left alone here to keep the diff on-topic; happy to fix in a separate PR.