refactor(monitoring): type the probe boundary to a subject, not to GitHub - #9505
refactor(monitoring): type the probe boundary to a subject, not to GitHub#9505chenmingwei23 wants to merge 2 commits into
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth construction sites use keyword arguments (so the inherited field reorder is harmless), and No findings. [OPUS-REVIEWED] 4f3adb8 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) — 🟡 CONCERNSPremise-level review of All evidence gathered. The counts I ran: First-Principles-Verdict: CONCERNS Half this diff is the kind registry the description itself defers to "the next [change]", and the plural probe arity has zero multi-subject callers. Not justified as shipped
What this change shipsInventory (10 items) — 5 justifiedIntent: make the external probe boundary host-neutral so a second monitored kind can exist — a FIX (of an abstraction typed to its one implementation).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 4f3adb8 |
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: |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, well-argued substrate work; the risk is procedural — none of the CI that exercises this code runs on this stacked base. Watch
Suggestions
[DESIGN-REVIEWED] 4f3adb8 |
2cff81c to
571a73c
Compare
|
Dispositioning the First Principles advisory. Two findings accepted and fixed in Accepted -- guard placement (items 4 and 5). This was a real asymmetry I introduced and the review located it precisely: I created the subset/wrong-shape hazard by making the boundary plural, then guarded only one of its two consumers. Both consumers now resolve through one shared Accepted -- the Rebutted -- plural arity with no plural caller today (item 3). The finding is accurate on the facts and the review concedes the widening-cost argument is real. I am keeping the plural signature, because arity is the one property of this contract that cannot be widened later without touching every implementation and every caller: a mapping return and a sequence parameter change the call shape at both ends, unlike a defaulted field on the result record, which is why the aggregate discussed below is deferred while this is not. The asymmetry is the whole argument. If arity were as cheap to change later as a field, deferring it would be right on the same reasoning that removed On the aggregate the record leaves room for. No aggregate field is added, and that is deliberate for two independent reasons. Nothing would read it. And it cannot be populated here at all: this probe enumerates The behaviour claim is unchanged by all of the above: the golden digest and all 28 per-group digests, captured on pristine |
f795d2a to
042fb55
Compare
571a73c to
95c4fd0
Compare
95c4fd0 to
c65ac3c
Compare
c65ac3c to
866bbbf
Compare
866bbbf to
e74f840
Compare
UX Review (Fable 5) — ⏭️ skippedRevision |
…tHub Both Protocols at the external probe boundary -- `_Provider` in monitoring/controller.py and `GitHubShadowProvider` in monitoring/shadow.py -- annotate their `probe` return as `GitHubPullRequestProbeResult`. That return type is the defect. An abstraction typed to its one concrete implementation is not an abstraction: a second monitored kind cannot satisfy either boundary whatever it returns, because satisfying it requires producing a pull request's result type. The two declarations being near-identical is a symptom of that, not the problem. Introduce MonitorProbeResult in monitoring/models.py, naming no host: the subject's canonical facts plus the generic observation the engine classifies. GitHubPullRequestProbeResult becomes an implementation of it, keeping `response` -- its own typed detail -- on itself rather than on the shared type. The two Protocols collapse into one public MonitorProbe, and the service boundary (`_Service.apply_monitor_probe`, AutoNudgeService.apply_monitor_probe) is retyped to the shared record so no path names GitHub to describe a probe. The signature is PLURAL from the start: it takes a sequence of subjects and returns a mapping keyed by the subject string AS PASSED IN, not by any identity a host derives from it -- a caller can only look up what it asked for. GitHub answers for one pull request per call so its implementation loops, but arity is the one thing that cannot be changed later without touching every implementation and every caller, so it is settled now. Keying by the caller's own string is what lets a host normalize a subject without reshaping the mapping its caller reads. MonitorProbeResult is a RECORD rather than a bare sequence of per-check rows. A host that publishes its own overall verdict, distinct from the rows a probe enumerates, then has somewhere to put it as a defaulted field that reaches every caller without changing this type or any signature naming it. No such field is added here: nothing would read it, and this probe fetches no published aggregate today, so populating one would mean a new request and a behaviour change. A plural boundary lets a provider answer for a SUBSET of what it was asked, and lets it answer with the wrong shape. Neither is a verdict: an absent subject leaves no observation to decide from, and the decision engine reads attributes off whatever it is handed, so an untyped value fails deep inside it rather than at the boundary. Both consumers -- MonitorController.tick and run_shadow_probe -- resolve through one shared `resolve_probe_result`, so the two cannot disagree about what an unusable answer means; a guard in one and a bare KeyError in the other would be the same hazard handled two ways. Behaviour-preserving for a provider that answers its contract. test_monitor_behaviour_golden.py's digest and all 28 of its per-group digests -- captured on kirocrew/main at 53987e7 before any edit in this stack -- are unchanged across the boundary change.
…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.
4cf659d to
4f3adb8
Compare
Problem / Motivation
The external probe boundary is declared twice --
_Providerinmonitoring/controller.pyandGitHubShadowProviderinmonitoring/shadow.py--and both annotate
probe's return asGitHubPullRequestProbeResult.The return type is the defect, not the repetition. An abstraction typed to its
one concrete implementation is not an abstraction: satisfying either boundary
requires producing a pull request's result type, so a second monitored kind cannot
satisfy either one whatever it returns. The two declarations being near-identical
(they differ by class name, visibility and a docstring) is a symptom worth
tidying, but collapsing them while both still named a GitHub type would leave the
boundary exactly as closed as it is now.
The arity is the second half.
probetakes one subject and returns one result, soa host that answers for many subjects in one request -- which most review and CI
hosts do -- cannot express that without changing a signature every implementation
and every caller depends on.
Why it matters
docs/request-for-change/rfc-consolidated-monitor.mddescribes a substrate wherea kind is registered as data and each kind implements a probe. That is unreachable
while the probe contract names one host in its return type: the registry would
have nothing to register a second kind as. This is the change that makes a
second kind possible at all, and the next one (a registry where a kind declares
its own objectives) has no seam to attach to without it.
What changed
MonitorProbeResultinmonitoring/models.pynames no host: the subject'scanonical facts plus the generic
MonitorObservationthe engine classifies.GitHubPullRequestProbeResultbecomes an implementation of it and keepsresponse-- its own typed detail -- on itself rather than on the shared type,which is the line every future kind gets to draw for itself. The two Protocols
collapse into one public
MonitorProbe, and the service boundary(
_Service.apply_monitor_probeandAutoNudgeService.apply_monitor_probe) isretyped to the shared record, so no path names GitHub in order to describe a probe.
Plural from the start.
probetakes a sequence of subjects and returns amapping. GitHub answers for one pull request per call, so its implementation loops
and the per-subject logic is untouched in
_probe_one. Arity is the one propertythat cannot be widened later without touching every implementation and every
caller, so it is settled now; a caller wanting one subject passes a one-element
sequence.
Keyed by the subject as passed, not by an identity the host derives. GitHub's
canonical facts carry
github.com/owner/repo#123, which is not the URL the callerhanded over -- keying by the derived form would make the mapping unreadable to the
caller that built the request, and it lets a host normalize a subject for its own
use without that reshaping what its caller has to read. A test asserts the two
differ so this cannot regress into keying by whichever one happens to be handy.
A record, not a bare list of rows. This is the part that keeps a later fix
cheap. The status tool treats a published aggregate verdict as authoritative over
the individual rows, while a provider has no aggregate notion and computes from
row states alone; those two can disagree. Closing that gap is another change's
work, but a probe result shaped as a record leaves room for a host's own
aggregate as a defaulted field that reaches every caller without changing this
type or any signature naming it. A protocol returning a bare sequence of rows
would have to change its return type instead -- the same trap this change is
undoing one layer up.
No aggregate field is added here, deliberately. Nothing would read it, and this
probe fetches no published aggregate today -- it enumerates
statusCheckRolluprows and never reads a rollup state -- so populating one would mean an additional
request, which is a behaviour change this stack's golden table exists to forbid.
An unread field is unverifiable surface. The structural room is the deliverable;
the field belongs to the change that reads it.
Two failure modes fail closed, in BOTH consumers. A plural boundary makes it
possible for a provider to answer for a subset of what it was asked, or to answer
with the wrong shape. Neither is a verdict: an absent subject leaves no observation
to decide from, and the decision engine reads attributes off whatever it is handed,
so an untyped value fails deep inside it rather than at the boundary.
MonitorController.tickandrun_shadow_probeboth resolve through one sharedresolve_probe_result, so the two cannot disagree about what an unusable answermeans. Guarding one consumer and letting the other raise a bare
KeyErroron thesame input would make the hazard's meaning depend on which path observed it, which
is the asymmetry a shared boundary exists to remove.
GitHubPullRequestProbeResult.responsecarries no default. The shared base'sfields have none either, so nothing forced one, and a default would only let a
future caller silently omit the typed response.
Tests
test/test_monitor_behaviour_golden.pyis the behaviour proof and it is unchangedexcept for its own call into the new signature. Its digest and all 28 per-group
digests were captured on pristine
kirocrew/mainat53987e756, before any editin this stack, and do not move.
New
TestPluralProbeBoundaryintest/test_github_pull_request_monitor.pycoversthe contract that would be expensive to change later:
canonical identity being different
against the wrong subject and fake a changed head
change actually makes
record
In
test/test_monitor_controller.py: a provider that omits the requested subjectfails closed, a provider returning an untyped value fails closed, and the
controller asks for exactly its own subject rather than widening the request. The
shadow path has the matching pair, so the shared resolver is asserted at both
consumers rather than only where it was first needed, and one test pins that the
GitHub result cannot be constructed without its response.
The ~60 existing provider tests call the boundary through one named helper rather
than each constructing a one-element sequence and indexing the mapping. Those
tests are about what the probe derives from a response, not about arity, and their
assertions are untouched.
A green board here does NOT mean the code is verified
Read this before merging. Because this pull request is based on a branch rather
than
main, it runs a much smaller check set: 18 checks against the 63 that runon its
main-based base. The 18 are the review and policy lanes -- Design,First Principles, GPT, Opus, UX, SAST, Inclusive Language, PR Hygiene, PR Scope,
Dependency License, Cross-Platform Portability, Automated Rule Check, Screenshot
Evidence and PR Readiness.
All 45 absent checks were enumerated by diffing the two rollups. They include
every test job and every deterministic gate: Backend Tests (all four shards, the
Windows shards and the namespace sandbox), Frontend Tests, Gateway Tests, Electron
Shell Tests, E2E, Build Wheel, Build Desktop, Build Windows Installer, Linux
Packaging, Coverage Gate, Frontend Coverage Merge, Backend and Frontend Lint &
Type Check, CodeQL, the internal-content scan, and the Brand Name, Changelog
History, Docs Lint, Feature Map, Focus Cue, Harness Parity, Loop-Bound Locks,
Builtin Skill Scope, Testpaths Coverage, Bundle Size and Vendored Tree Integrity
gates.
So a green board here means the review lanes are satisfied and says nothing about
whether the code works.
PR Readinesspassing is not equivalent to its passing ona
main-based pull request either: it aggregates a set that excludes the tests.Merge order, so this is not merged early on a green that does not cover the
code. The base merges first; GitHub then retargets this to
mainautomatically; the full 63 checks run for the first time at that point; only then
is a green here meaningful.
The stack is still the right shape -- basing this on
mainwould recreate themonitoring/models.pyandmonitoring/controller.pyconflict the sequencingexists to prevent, and that conflict is a certainty where this is a deferral. But
it is a deferral, and the deferred half is the tests.
Manual verification
No user-visible surface, and the behaviour claim rests on the golden digests
captured before the change rather than on inspection. Because CI is not covering
the code here, the local run is doing more work than it normally would, so here is
exactly what it did and did not cover.
Test files run locally, all passing:
test_monitor_behaviour_golden.py,test_github_pull_request_monitor.py,test_monitor_controller.py,test_monitor_decision.py,test_monitor_persistence.py,test_autonudge.py.Those are the files this diff touches plus the suites that exercise the changed
boundary. NOT run locally: the rest of the backend suite, all frontend tests, the
builds, E2E, Gateway and Electron tests, CodeQL, and the Windows and
namespace-sandbox shards. Those are the gap, and they close when this retargets to
main.Gates run locally, all passing: the repository's own black gate (diff-scoped),
isort, flake8,
mypyovermonitoring/andautonudge.pyin one invocation, andthe comment-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 scans
(the last with its own self-test). NOT run locally: the repository-wide
mypy src/kiro_crew/, the frontend gates (this diff has no frontend changes) andcfn-lint (no templates changed).
Review rounds
Both design lanes are advisory CONCERNS with no structured findings, so no span
exists and no disposition comment is warranted. Recording the outcomes here.
Accepted.
controller.pyloggedERROR: structured monitor provider gave no usable resultwhenever the resolved result carriedPROVIDER_ERROR. That status iscarried by BOTH the synthesized fallback and a transient the provider classified
correctly -- and both carry the
provider_transientreason code -- so a status testcannot separate them, and every ordinary rate limit was reported at ERROR level as an
absent answer, on every tick. The log moved into
resolve_probe_result, the onlyplace that knows which branch it took. No shared type or signature changed, and
shadow.py, which logged nothing on that path, now gets the correct log too. Theguard is a controller-level test driving a real tick; it was verified to fail against
the previous behaviour rather than merely to pass against the new one.
Also declared, previously omitted.
MonitorProbeResult.__post_init__refuses anon-
MonitorObservationobservation and a non-dictcanonical at construction.Held: the spec's monitor section is not stale. The same-commit rule applies when a
change alters what a spec documents. Measured against
docs/system-specs/modules/learn-cron-dashboard.md: it contains ZERO occurrences ofevery symbol this PR changes --
GitHubShadowProvider,MonitorProbe,MonitorProbeResult,resolve_probe_result,GitHubPullRequestProvider,run_shadow_probe. The probe boundary was never documented at that level, so there isno single-subject picture in it to correct. What the spec does document is behaviour:
the adapter's URL rules, that
statusCheckRollupis read in a separate request pairedwith
headRefOid, the ten-page thread cap, and thatMonitorControlleraccepts only apublic GitHub pull request with the
review_readyobjective. Every one of thosesentences remains true, which the 16,800-row golden table captured on pristine
mainis the evidence for.
Held:
MonitorProbeResult.__post_init__is not subsumed byresolve_probe_result.The two guard different things at different points.
resolve_probe_resulttests theobject's TYPE (
isinstance(result, MonitorProbeResult));__post_init__tests itsCONTENTS. Constructing
MonitorProbeResult(canonical="not a dict", observation=None)is refused today; without
__post_init__that same object constructs, satisfies thetype guard, and reaches the decision engine, which reads attributes off
None.Verified by running it. The distinction from the sibling change in this stack, which
DID delete a
__post_init__on the same reasoning, is who constructs the type: there,a static in-module literal with no external constructor; here, every probe
implementation, which is the extension point this PR exists to create.
Held: plural arity. Unchanged from the earlier round. Arity cannot widen later
without touching every implementation and every caller, which is not true of a
defaulted field, so the cost of deferring is not symmetric with the cost of shipping
it now.
Two commits, and which evidence belongs to which
This PR carries two changes, and the file list flattens a distinction that matters
for reading the second one's evidence. Stating it here because a squash erases it.
Commit 1 --
refactor(monitoring): type the probe boundary to a subject, not to GitHub. Ownsmonitoring/models.py,monitoring/controller.py,monitoring/github_pull_request.py,monitoring/shadow.py, and their tests. This isthe change that is supposed to touch shared code: it introduces the shared
MonitorProbeResultand the one publicMonitorProbe, and retypes both consumersonto them.
Commit 2 --
feat(monitoring): register a monitored kind as data, not as four hardcodes(#9546). Owns exactly seven files:monitoring/registry.py(new),monitoring/shadow.py(the capability guard only),validation.py,mcp_tools/control.py,dashboard/handlers/autonudge.py,autonudge.py, andtest/test_monitor_kind_registry.py.Why that split is the acceptance test. The registry change was accepted on the
condition that registering a kind as data must NOT force a change to the decision
engine, a shared result type, or a shared protocol -- and that if it did, the right
response was to stop rather than to add a branch to a shared layer. It did not.
Commit 2 touches
monitoring/decision.pyzero times,monitoring/models.pyzerotimes,
monitoring/controller.pyzero times, andmonitoring/github_pull_request.pyzero times. Its only edit to
monitoring/shadow.pyis the capability guard; theshared protocol in that file was already replaced by commit 1, so commit 2 had no
reason to touch it. The single
classit adds anywhere is the registry's owndataclass.
A reader looking at the merged 13-file list sees
models.pyandcontroller.pyandcould reasonably conclude the registry reached into shared code. It did not -- those
are commit 1's work.
git showon either commit separates them.Base drift, not authored here
The GPT lane returned
[BLOCK-MERGE]namingwebsite/src/apps/aws-control/DrivePage.tsxand
website/src/components/FileRenderers.tsx. This PR touches neither, and touchesnothing under
website/at all -- all 13 files are undersrc/kiro_crew/ortest/.The branch was 50 commits behind main, so the reviewed patch included commits already
merged to main, which is what the First Principles lane independently identified as
base drift. Rebasing onto current main drops that from the reviewed surface. The file
list is byte-identical before and after the rebase, verified by diffing it.
The named finding looks legitimate on its own merits and has been passed to a human as
a candidate issue. It is deliberately NOT fixed here: it is outside this PR's scope,
and an unrelated security change should not ride a refactor.
Related Issues
no linked issue: this is the second of three sequenced foundation changes for the
monitor substrate, and it closes no tracked defect on its own. The omission is
deliberate.
Based on
feat/monitor-verdict-payloadrather thanmain, because that change isopen and both touch
monitoring/models.pyandmonitoring/controller.py. Thediff shown here is this change alone; GitHub retargets it to
mainwhen the basemerges.
Checklist