feat(monitoring): register a monitored kind as data, not as four hardcodes - #9546
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A behavior-preserving consolidation that closes a real persistence hole; alternatives were measured (import edges, shared enums, the probes registry deferral), not assumed. The derived allowlists are pinned equal to the old hardcodes, the allowlist/capability distinction in [DESIGN-REVIEWED] 2c085ed |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Registry covers only the four allowlists: arming still hardcodes GitHub at What this change shipsInventory (8 items) — 8 justifiedIntent: make the monitored-kind vocabulary one data table so the four per-kind gates stop being four places to forget — an ADDITION (consolidation plus boundary hardening), honestly framed.
[FIRST-PRINCIPLES-REVIEWED] 2c085ed |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
eef1d4f to
c744555
Compare
c744555 to
1391723
Compare
95c4fd0 to
c65ac3c
Compare
1391723 to
03ea19e
Compare
c65ac3c to
866bbbf
Compare
03ea19e to
8e6552d
Compare
…codes
A kind was spelled out wherever it mattered. Four boundaries, and they are not the
same shape, which is why the count is worth stating exactly:
THREE are entry allowlists -- they decide what a caller may ask for:
1. mcp_tools/control.py, a JSON-Schema `enum` (two tools: monitor_watch and
monitor_update)
2. validation.py, a FieldSpec `allowed=frozenset` (two schemas)
3. dashboard/handlers/autonudge.py::_monitor_config, an `if` guard
The FOURTH is not an allowlist at all: monitoring/shadow.py refused anything but
one kind because the persistence-only path is only IMPLEMENTED for that kind. That
is a statement about what code exists, not about what a caller may request.
Introduce monitoring/registry.py holding one MonitorKind entry per kind. Each entry
declares the objectives THAT kind supports -- there is deliberately no shared
objective enum, because `review_ready` is not a sentence about a calendar or a
ticket, and a shared list would make every new kind edit a common set, which is a
dispatch branch moved to a different file. The three entry allowlists now derive
from the registry, and shadow.py asks whether the kind DECLARES the capability.
Modelled as an allowlist the registry polices, a kind added later would silently
inherit a claim about a path it has never run.
Two kinds are registered, because MonitorState.kind already carries both:
github_pull_request (armed by monitor_watch, publicly armable, shadow implemented)
and gh-pr (armed internally by infer_monitor from a loop's message text via
probes.targets.infer, so registered but NOT publicly armable). Keeping that
distinction is what lets the entry allowlists stay exactly as narrow as they were
while the registry still tells the truth about what exists.
`gh-pr` is spelled here as data rather than imported from probes/__init__.py, which
owns the same string. The two packages have no import edge in either direction, and
adding one for a constant would couple them ahead of the porting work that unifies
the vocabularies -- while the reverse direction would drag the irq subsystem behind
validation.py, which is imported almost everywhere. The drift that duplication
risks is closed by a test that reads the kind targets.infer actually produces and
asserts this registry knows it.
MonitorState itself accepts any non-empty kind -- it takes "calendar" today -- so
both mutation boundaries now refuse a kind/objective pair no kind declares.
add_monitor is the one place a caller-supplied kind reaches persistence, and
update_monitor is the one place an objective can be repointed afterwards; the flat
objective allowlist those sit behind is a union across every publicly armable kind,
so it admits an objective a DIFFERENT kind declares and cannot express the pairing.
Both boundaries know the kind, so both check. Deserialization is deliberately
untouched: refusing there would quarantine existing records rather than reject new
ones.
The registry's own entries are checked by tests iterating the table rather than by
runtime validation, because nothing constructs a MonitorKind outside this module's
static literal -- a guard there would only ever validate a literal, while the tests
also cover an entry added later.
Behaviour-preserving. The registry-derived allowlists resolve to exactly the values
the hardcodes carried -- kinds {github_pull_request}, objectives {review_ready} --
asserted directly. Two refusal messages changed wording: the dashboard guard's, and
shadow.py's, where one message became two. The substrate premise this change tests
also holds: decision.py contains zero references to kind or objective, so
registering a kind as data forces no change to the decision engine, and MonitorProbe
and MonitorProbeResult are unchanged. The registry is data only, with no probe
factories and no imports of any kind's implementation.
866bbbf to
e74f840
Compare
8e6552d to
2c085ed
Compare
…codes (#9546) A kind was spelled out wherever it mattered. Four boundaries, and they are not the same shape, which is why the count is worth stating exactly: THREE are entry allowlists -- they decide what a caller may ask for: 1. mcp_tools/control.py, a JSON-Schema `enum` (two tools: monitor_watch and monitor_update) 2. validation.py, a FieldSpec `allowed=frozenset` (two schemas) 3. dashboard/handlers/autonudge.py::_monitor_config, an `if` guard The FOURTH is not an allowlist at all: monitoring/shadow.py refused anything but one kind because the persistence-only path is only IMPLEMENTED for that kind. That is a statement about what code exists, not about what a caller may request. Introduce monitoring/registry.py holding one MonitorKind entry per kind. Each entry declares the objectives THAT kind supports -- there is deliberately no shared objective enum, because `review_ready` is not a sentence about a calendar or a ticket, and a shared list would make every new kind edit a common set, which is a dispatch branch moved to a different file. The three entry allowlists now derive from the registry, and shadow.py asks whether the kind DECLARES the capability. Modelled as an allowlist the registry polices, a kind added later would silently inherit a claim about a path it has never run. Two kinds are registered, because MonitorState.kind already carries both: github_pull_request (armed by monitor_watch, publicly armable, shadow implemented) and gh-pr (armed internally by infer_monitor from a loop's message text via probes.targets.infer, so registered but NOT publicly armable). Keeping that distinction is what lets the entry allowlists stay exactly as narrow as they were while the registry still tells the truth about what exists. `gh-pr` is spelled here as data rather than imported from probes/__init__.py, which owns the same string. The two packages have no import edge in either direction, and adding one for a constant would couple them ahead of the porting work that unifies the vocabularies -- while the reverse direction would drag the irq subsystem behind validation.py, which is imported almost everywhere. The drift that duplication risks is closed by a test that reads the kind targets.infer actually produces and asserts this registry knows it. MonitorState itself accepts any non-empty kind -- it takes "calendar" today -- so both mutation boundaries now refuse a kind/objective pair no kind declares. add_monitor is the one place a caller-supplied kind reaches persistence, and update_monitor is the one place an objective can be repointed afterwards; the flat objective allowlist those sit behind is a union across every publicly armable kind, so it admits an objective a DIFFERENT kind declares and cannot express the pairing. Both boundaries know the kind, so both check. Deserialization is deliberately untouched: refusing there would quarantine existing records rather than reject new ones. The registry's own entries are checked by tests iterating the table rather than by runtime validation, because nothing constructs a MonitorKind outside this module's static literal -- a guard there would only ever validate a literal, while the tests also cover an entry added later. Behaviour-preserving. The registry-derived allowlists resolve to exactly the values the hardcodes carried -- kinds {github_pull_request}, objectives {review_ready} -- asserted directly. Two refusal messages changed wording: the dashboard guard's, and shadow.py's, where one message became two. The substrate premise this change tests also holds: decision.py contains zero references to kind or objective, so registering a kind as data forces no change to the decision engine, and MonitorProbe and MonitorProbeResult are unchanged. The registry is data only, with no probe factories and no imports of any kind's implementation.
Problem / Motivation
A monitored kind was spelled out wherever it mattered, and adding a kind meant
finding every place. There are four such places, and they are not the same
shape. The distinction matters, because the next person will otherwise find the
fourth one and think it was missed.
Three are entry allowlists -- they decide what a caller may ask for:
mcp_tools/control.py, a JSON-Schemaenum, in two tools (monitor_watchandmonitor_update)validation.py, aFieldSpec(allowed=frozenset(...)), in two schemasdashboard/handlers/autonudge.py::_monitor_config, anifguardThe fourth is not an allowlist.
monitoring/shadow.pyrefused anything but onekind because the persistence-only probe path is only implemented for that kind.
That is a statement about what code exists, not about what a caller may request, and
the two must not be modelled the same way.
There is also a hole underneath all four:
MonitorStateitself validates nothingabout
kind. It accepts"gh-pr","calendar", any non-empty string. Theallowlists are the only thing constraining it, so a path that bypasses them
persists a monitor under a kind nothing implements.
Why it matters
Being precise about provenance, because the weaker version of this argument is the
one worth refusing:
docs/request-for-change/rfc-consolidated-monitor.md(status:draft) does NOT contain the word registry, or kind. What its item 5 specifies is
exposing the probe contract as an SDK -- implement
identity()andobserve(),inherit scheduling, dedupe, coalescing, epoch reset and failure handling. An SDK
with pluggable implementations needs some answer to which implementations exist,
but the RFC does not name a registry, and the module spec that does is in flight in
#9368 rather than on
main.So the direct case is the smaller one, and it stands on its own: four hardcodes in
four files is a dispatch branch spread across a codebase, and consolidating them
removes four places to forget. A shared objective vocabulary would have the same
defect one level down --
review_readyis not a sentence about a calendar or aticket, so a shared list would make every new kind edit a common set.
This is also the change that tests whether the substrate premise is true, so the
finding is worth stating plainly: it holds.
monitoring/decision.pycontainszero references to
kindorobjective, so registering a kind as data forces nochange to the decision engine.
MonitorProbeandMonitorProbeResultare unchanged.Nothing here adds a branch, a special case, or a per-kind hook to a shared layer.
What changed
monitoring/registry.pyholds oneMonitorKindentry per kind: its name, theobjectives that kind declares, whether a caller may name it, and whether the
persistence-only path is implemented for it.
The three entry allowlists now derive from the registry.
shadow.pyasks whetherthe kind declares the capability rather than matching a literal -- modelled as an
allowlist the registry polices, a kind added later would silently inherit a claim
about a path it has never run.
Two kinds are registered, because
MonitorState.kindalready carries twovocabularies.
github_pull_requestis armed throughmonitor_watch, is publiclyarmable, and has the shadow path implemented.
gh-pris armed only internally, byinfer_monitorfrom a loop's own message text viaprobes.targets.infer, so it isregistered without being requestable. Keeping
publicly_armableseparate fromsupports_shadowis what lets the entry allowlists stay exactly as narrow as theywere while the registry still tells the truth about what exists.
Both mutation boundaries now refuse a kind/objective pair no kind declares.
add_monitoris the one place a caller-supplied kind reaches persistence;update_monitoris the one place an objective can be repointed afterwards. The flatobjective allowlist both sit behind is a UNION across every publicly armable kind,
so it admits an objective a different kind declares and structurally cannot
express the pairing -- it is a first filter, never the whole check. Both boundaries
know the kind, so both check.
Deserialization is deliberately untouched: refusing there would quarantine existing
records rather than reject new ones, which reaches further than this change should.
Two refusal messages changed wording: the dashboard guard's, and
shadow.py's --where one message became two, separating a kind whose shadow path is unimplemented
from an objective the kind does not declare. Those are the caller-visible
differences in the diff.
probes/__init__.pyis deliberately left alone. Itsifkeys on thegh-prvocabulary, which has no objective concept, and it is the irq subsystem's own
deferral -- a future consumer of this registry rather than its subject.
Tests
test/test_monitor_kind_registry.pyis new. The load-bearing ones are the teststhat would fail if the design collapsed back:
{github_pull_request}and objectives{review_ready}, asserted against both theregistry and
MONITOR_WATCH_SCHEMAobjective one kind declares is not thereby legal for another -- this is the test
that fails if objectives ever move into one shared enum
supports_shadowis a declared capability: the kind that implements it says so, aregistered kind without it says false, and an unregistered kind says false rather
than inheriting a claim
publicly_armableandsupports_shadoware independent, since they answerdifferent questions
supports its objective, so registered-not-requestable does not mean unusable
update_monitorrefuses an objective the monitor's own kind does not declare, andthe refusal does not half-apply
the missing capability rather than an unknown kind
showing that
MonitorStateitself still accepts itobjective, a non-boolean flag
Review rounds
Both design lanes are advisory, and neither has ever produced a structured finding,
so no span exists to claim and no disposition comment is warranted. Recording the
outcomes here instead. Design Review is now PASS.
Round 1, accepted.
update_monitorpersisted an objective with only the flatunion as a filter, behind a schema enum that now derives from the cross-kind union.
Today the union is
{review_ready}so nothing changes, but the body already claimedpairing was enforced at the boundary that knows both, which was false for exactly
that one. Fixed with a test driving
update_monitordirectly. Also:is_registered_kindhad zero consumers and is deleted; the
isinstanceguards duplicated the mypy gate;and the provenance overclaim was corrected.
Round 2, accepted.
MonitorKind.__post_init__is gone. Nothing constructs aMonitorKindoutside this module's own static literal, so runtime validation onlyever checked a literal -- the tests moved to iterate
_KINDSinstead, which coversthe same ground and also covers an entry added later, with no shipped code. The
docstring claim is scoped to the four converted boundaries, since other spellings do
survive elsewhere. And
infer_monitornow usesREVIEW_READYrather than repeatingthe literal, since that file already imports the registry.
Round 2, one finding answered a different way than proposed.
GH_PR = "gh-pr"isgenuinely defined twice -- here and at
probes/__init__.py:25, whosetargets.inferproduces the spelling this registry must recognise. The proposed fix was to have
probes/import the registry's constant. Measured before deciding: the two packageshave no import edge in either direction today, so that would create the first one
between them, in the subsystem whose registry shape is deliberately deferred. The
reverse is worse --
validation.pyimports this registry and is imported almosteverywhere, so pulling
probesin would drag the irq subsystem behind everyvalidator.
So the duplication stays and the drift is closed instead: a test reads the kind
targets.inferactually produces and asserts this registry knows it, plus a directequality on the two constants. Verified non-vacuous by perturbing this module's
spelling, which fails with "the two spellings have drifted apart". That turns "must
silently stay in sync" into "cannot drift undetected" without coupling two packages
ahead of the work that unifies them.
Held.
monitoring/github_pull_request.py:688returns"review_ready"as areason code in an observation, not as an objective. It is the same string for a
different concept, and replacing it with
REVIEW_READYwould assert a coupling thatdoes not exist.
On the repo's own stance.
probes/__init__.py:35-38records that "the shape of aregistry is best decided by the second probe's real needs rather than guessed before
it exists," which is the opposite stance for the adjacent layer, and it is right. It
is about the shape of a probe registry -- what an implementation must provide --
and this module deliberately holds none of that: no probe factories, no
identity()or
observe(), no scheduling contract. It consolidates four allowlists that alreadyexist and already all say the same thing. That is why
probes/__init__.pyisuntouched here and remains free to decide its own shape when its second probe lands.
A green board here does NOT mean the code is verified
This is stacked on
feat/monitor-plural-probe, so it runs the smaller check set: 18checks against the 63 that run on a
main-based pull request. The absent ones areevery test job and every deterministic gate -- Backend, Frontend, Gateway and
Electron tests, the builds, E2E, Coverage, Backend Lint & Type Check, CodeQL, Docs
Lint, Brand Name, Feature Map, Focus Cue, Harness Parity, Testpaths Coverage,
Vendored Tree Integrity. A green board here means the review lanes are satisfied and
says nothing about whether the code works, and
PR Readinesshere aggregates a setthat excludes the tests.
Merge order. This is the third of three stacked changes. Each base merges before
the one above it retargets to
main, and the full 63 checks run on this only afterboth bases have landed. Do not merge on the stacked green.
Manual verification
CI runs no tests on a stacked PR, so this is the only test evidence, and its gaps are
stated rather than implied.
Run and passing after the latest change:
test_monitor_kind_registry.py(25),test_autonudge.py+test_monitor_persistence.py(213), andtest_monitor_behaviour_golden.py+test_github_pull_request_monitor.py+test_monitor_controller.py+test_monitor_decision.py(240).GOLDEN_DIGESTandall 28 per-group digests are unmoved.
Run and passing on the previous revision but NOT re-run on this one:
test_monitor_mcp.py,test_validation.py,test_monitor_directive_apply.py,test_monitor_start_ack.py,test_autonudge_handlers_cov80.py(240 together). Alocal sandbox policy began refusing those invocations partway through the round. The
reason they are low risk is specific rather than general: this revision's only
source changes outside the registry module are a comment, the removal of validation
whose sole subject was this module's static literal, and one literal replaced by the
constant of identical value. None of the five suites touches any of that. It is still
a gap, and the deterministic gates below are unaffected by it.
Gates run locally, all passing: black (the repository's own diff-scoped gate), isort,
flake8,
mypyovermonitoring/,autonudge.py,validation.py,mcp_tools/control.pyand the dashboard handler in one invocation, pluscomment-history, sync-IO, subprocess-encoding, brand-name, feature-map, focus-cue,
changelog-history, harness-parity, loop-bound-locks, builtin-skill-scope,
testpaths-coverage, vendored-manifest and docs-lint. NOT run locally: the
repository-wide
mypy src/kiro_crew/, the frontend gates (no frontend changes) andcfn-lint (no templates changed).
Import direction was measured, not assumed:
validation.pyalready importedmonitoring.models; this registry imports nothing fromkiro_crew; nothing inmonitoring/orprobes/importsvalidation; andmonitoring/andprobes/donot import each other in either direction. No cycle, and no new edge between
packages.
Related Issues
no linked issue: this is the third of three sequenced foundation changes for the
monitor substrate, and it closes no tracked defect on its own. The omission is
deliberate.
Checklist