Skip to content

C6 — Repo-correct PR risk, warm review modules, and the daemon fork guard (PLN-2027 PR 6 + FR11) - #21

Merged
wongk merged 7 commits into
mainfrom
campaign-prd-739-20260915-c6
Sep 16, 2026
Merged

wongk merged 7 commits into
mainfrom
campaign-prd-739-20260915-c6

Conversation

@wongk

@wongk wongk commented Sep 16, 2026

Copy link
Copy Markdown

C6 — Repo-correct PR risk, warm review modules, and the daemon's fork guard

Lands PLN-2027 PR 6 (FR12, FR13) plus one operator-directed fix that belongs to no plan unit: closing the FR11 gap the previous chunk carried forward. Base origin/main; not stacked. The two are in separate commits.

What it does

PR 6. PR risk takes blast radius and affected tests from the repository’s own import graph instead of a machine-wide index spanning other checkouts, and five review modules join the start-up warm list.

Operator-directed (FR11). The background auto-updater refuses to install an upstream release over a fork build, logging the command that updates the fork instead. The refusal is checked before the auto-update opt-in, so that flag cannot read as consent, and no new flag or environment variable was added — a daemon has nobody to prompt, so refusing is the only behaviour that satisfies "the operator is told and confirms".

Acceptance ledger

18 criteria: 17 met, 1 met differently, 0 unmet.

The one met differently is the honest one: this PR does not make pr_risk read-only, and it stays refused through the read-only broker. PR 6’s own text claims complexity "reads the file’s own content, not other repositories". That is false: _file_complexity calls cap.get_cached(...) or cap.summarize_file(...), and both write the machine-wide index. Moving blast radius does not change that. An unresolved plan comment said so and was never folded in. broker_policy.py is untouched by this diff.

Review

Deep review: NEEDS_ATTENTION, 7 verified findings, 0 rejected, 0 pending. All 7 fixed, 0 pushed back, 1 declined. Re-review: APPROVED, no blocking or high findings.

The two HIGHs were both real:

  1. PR risk claimed exhaustive zeroes it had not earned. A changed file simply absent from the index came back impacted_files: 0, missing_tests: true, objective: "exhaustive" with no reason — indistinguishable from a file that genuinely has no importers and no tests. New files in a PR are exactly this tool’s workload, so it was the common case, and it reintroduced at file granularity the silent-zero failure the completeness contract exists to prevent. Fixed: the index’s indexed flag is forwarded, and an unindexed file reports partial with a reason. An indexed sibling in the same call still answers exhaustively.
  2. The fork predicate and the update hint were duplicated between the CLI and the daemon. Both now come from the fork-only distribution module, each importer keeping its own fallback where the module’s absence means an upstream build. The daemon does not import the CLI module — that would drag click and the whole CLI import graph into a long-running service.

The five MEDIUMs were all fixed: the MCP seam now resolves a relative root workspace-relatively like its sibling kinds; a rebuilding index is distinguished from an absent one in the reason; a no-data result no longer scores as a test gap (it was adding a penalty for ignorance while reporting "low"); and one redundant unguarded index write was removed — its failure aborted the entire report instead of degrading one factor.

FR13’s race is closed, not deferred. The three review-tool handlers imported the review group without taking the import lock while warming ran on background threads under a serving daemon. It turned out to be the existing lock taken in three places, so the criterion this chunk claims is genuinely met rather than met on paper.

Declined (1): _blast_factor’s curve. Its docstring claimed a saturating shape (1 importer → 0.2, 3 → ~0.5) while the body has been min(1.0, impact_total / 10.0) — linear — since the function’s first commit, so the prose never described shipped behaviour. The docstring is corrected here; the formula is deliberately not touched, because changing it moves every score across the low/medium/high tier boundaries. The reviewer judged the saturating curve was the real intent, and that is a product call, recorded as an open question rather than closed quietly by a doc edit.

One fix made after the re-review gate

The re-review approved but confirmed a MEDIUM one level up from the first HIGH: per-file honesty had landed, the envelope had not. pr_risk over a single brand-new file still headlined overall_tier: "low" with no objective — a verdict over files it had just admitted, row by row, that it could not read. The earlier fix made that quieter, because dropping the unearned test-gap penalty lowered the very score the headline is computed from.

Fixed in the final commit: the envelope carries objective, and overall_tier is "unknown" when every scored file is partial. Pinned by two tests, each verified to fail under a targeted mutation.

Verification

tests/core/test_code_health_pr_risk.py (14) · tests/gateway/test_completeness_contract.py, test_pro_import_warm.py, tests/infra/test_servicectl_autoupdate.py (97 together) · test_update_command.py (14) · test_cap_tools_list_gate.py (51) · test_mypyc_compile_safety.py (3) · test_mirror_denylist.py (9) · mypy --strict clean on every changed source file · ruff check --no-cache src benchmarks tests scripts integrations clean · black --check src tests clean. The repo-wide lint was re-run after the final commit on a cold cache.

Beyond the suite, three invariants were probed directly rather than through the tests that cover them: disarming the fork guard fails exactly one test and no other; with the fork-only module absent, both the CLI and the daemon import cleanly and report an upstream build; and the mirror’s own is_public() still denies exactly that one file while both importers stay public.

The plan’s §0 full-suite-in-a-scratch-worktree row is left to CI, which runs that lane sharded.

Deviations

  • PR 6’s stated read-only premise is false; built as specified anyway, and pr_risk stays broker-refused (recorded in the commit body, the module docstring and the plan thread).
  • The plan’s line citations had drifted; every target was located by symbol.
  • PR 6 carries no Exit line, so one was written from FR12 and FR13.
  • The degrade behaviour for a rebuilding or unavailable index was forced: catch and degrade, never propagate out of a risk report.
  • Four pre-existing tests needed fixture updates, assertions unchanged.
  • The FR11 fix is not a plan unit; it carries its own ledger rows marked operator-directed.

What is still owed

  • _blast_factor’s curve shape is an open product question. Shipped: linear to a cap at 10, so one importer contributes 0.10. The prose asked for 0.20. Changing it shifts every tier.
  • A known unfixed crack, deliberately out of scope: the install script writes its dev-install marker to the home directory unconditionally while the reader checks the active root, so an install using a non-default root has a gate that fails open. The FR11 fix above no longer depends on that marker.
  • Pre-existing, same shape as the FR13 fix: two other handlers in the MCP server import warm-listed modules on request threads without warming first.
  • The completeness-contract suite has no handler-level row for graph kind="pr_risk" yet.

Artifacts

  • Plan: PLN-2027 (v4), unit PR 6
  • PRD: PRD-739 — FR12, FR13, and FR11 for the folded-in fix

FR12: the blast factor and affected tests now come from
open_file_graph(repo_root).blast_radius(), which reads the repository's own
imports table, instead of SemanticFileMemoryCapability.change_impact, which
reads a machine-wide index spanning every repository summarize_file has
touched. A sibling checkout importing this one's module counted as an
importer, and one of its tests could clear this file's test gap.

open_file_graph fails loud on an absent or mid-rebuild index (PR 1). pr_risk
catches that and degrades the blast factor rather than propagating it: its
contract is fail-open per delegated factor. The per-file result gains
`objective` and `truncated` beside the existing keys, additively.

This does NOT make pr_risk read-only, and it stays refused through the broker.
The plan's premise for this change is false: _file_complexity reaches
summarize_file, and pr_risk summarizes the changed file outright, so both
paths still write the machine-wide semantic index. Removing those writes is
not in PR 6's change list.

FR13: _PRO_CODE_PATH_MODULES gains the five review modules the review_* tools
import on a request thread, so a release build finishes building that mypyc
group before serving instead of racing it.
… fork build

Operator-directed added scope, not part of any PLN-2027 unit: the FR11 gap PR 5
left open. The background auto-updater carried its own release path around the
guard PR 5 shipped -- a second "lemoncrow-lab/lemoncrow" literal under a
"keep in lockstep with update.py" comment that PR 5 made false, and an
_update_via_release() that downloaded upstream's install.sh and ran it detached
with no fork check.

The repo now comes from lemoncrow._distribution, with the same
ModuleNotFoundError fallback update.py uses: the module's absence is the
upstream build, because the public mirror denies the file. The release arm
refuses on a fork build and logs the fork update command.

Refusal is unconditional and adds no new environment variable. FR11 asks that
no update path replace a fork build with an upstream release unless the
operator is told and confirms; a daemon has nobody to prompt, so refusing and
naming `lc update`'s alternative is the only behaviour that satisfies it.
LEMONCROW_AUTO_UPDATE_RELEASE is not consent: it says "auto-update from
releases", not "replace my fork with upstream".

Known, deliberately not fixed here: scripts/local.sh writes .dev_mode to
${HOME}/.lemoncrow unconditionally while _is_dev_install reads it under the
active root, so a non-default --root install has a dev gate that fails open.
With the fork check in place .dev_mode is no longer the FR11 defence, and
changing what the installer writes is a separate blast radius.
…ves its root (PLN-2027 PR 6)

Review feedback on PR 6.

FR13: adding the five review modules to _PRO_CODE_PATH_MODULES only moves the
build to start-up for whoever calls _warm_pro_code_modules(). The review_* tools
never did -- they import lemoncrow.pro.capabilities.review.* straight from the
request thread. The stdio path is safe because serve() warms before the reader
starts; the HTTP daemon is not, because it starts serving while _warm_daemon's
thread is still inside the same mypyc group. Listing the modules without warming
at the entry points gave that request thread a concurrent importer it did not
have before, which is the race rather than the fix. All three handlers now warm
before their lazy import, the way _code_context_engine already did.

FR12: _op_graph_code_health resolved a relative repo_root against the process
cwd while every sibling file-graph kind goes through _code_repo_root, which
takes it against the active workspace. That did not show while the blast radius
came from the machine-wide index keyed by absolute path; now that pr_risk opens
<repo_root>/.lemoncrow/workspace, a relative root against a daemon standing
elsewhere finds no index and silently degrades every file.
…arn (PLN-2027 PR 6)

Review feedback on PR 6.

_blast_radius_for copied FileGraph.blast_radius's envelope-level `objective`
and dropped its per-file `indexed` flag. blast_radius answers for a path it has
never seen -- zero importers, zero tests -- and the envelope's objective is
computed from whether the imports TABLE has rows, not from whether THIS file
was examined. A changed file absent from the index therefore came back as
impacted_files 0, missing_tests true, objective "exhaustive", truncated false,
with no reason: indistinguishable from a file that genuinely has no importers
and no tests. A PR that adds a file is exactly pr_risk's workload, so that was
the common case, and it reintroduced at file granularity the failure the
completeness contract exists to prevent. `indexed` now decides the objective
per file.

No data is also not a known test gap. The degraded shape asserted
missing_tests: true for every file, adding 0.20 to every score while reporting
risk_level "low" -- a penalty charged for ignorance, and a verdict no closure
was walked to reach. Both are now None / "unknown", so an unknown gap costs
nothing, the way churn and complexity already degrade.

IndexRebuilding and CodeIntelUnavailable are caught separately. They are
deliberately not in a subclass relationship because they are different
conditions -- one answers the same call properly in a moment, the other needs
an index built -- and a single reason string put that back out of reach.

The redundant cap.summarize_file(abs_path) pre-seed is gone: its consumer
(change_impact) left with FR12, _file_complexity already does the same write
under a guard, and this was the one unguarded index write, so its failure
aborted the whole report instead of degrading one factor. pr_risk still writes
through _file_complexity and stays refused through the read-only broker.

Not changed: _blast_factor's curve. The docstring PR 6 corrected describes what
ships; whether the saturating shape the original prose asked for is what is
wanted is an open question for the operator, since it moves every score across
the low/medium/high tier boundaries.
…ribution

Review feedback on the FR11 added scope.

The servicectl commit removed the duplicated repo literal but left the two
pieces derived from it as independent copies: _is_fork_build() was byte-identical
to update.py's, and the remediation hint was hand-typed inline instead of built
by update.py's _fork_update_command(). Rename the install script and only `lc
update` picks it up -- the daemon keeps logging the stale command, and the drift
test caught none of it because it pinned the constants only.

is_fork_build() and fork_update_command() move next to the constants they read,
in lemoncrow._distribution, and both modules import them. servicectl does NOT
import update.py: that would drag click and the whole CLI import graph into a
long-running daemon and point runtime infrastructure at the CLI layer. Each
importer keeps its own ModuleNotFoundError fallback, where the module's absence
is the upstream build -- the predicate is False and no hint is ever needed.

The drift test now pins the predicate and the hint by identity, which only one
shared implementation satisfies, and rebinding DISTRIBUTION_REPO has to move the
hint in both modules.

The fork guard's ORDERING is pinned too. It sits above the
LEMONCROW_AUTO_UPDATE_RELEASE opt-in, and the existing test set that flag, so it
passed whichever side of the check the guard sat on. Below the opt-in, clearing
the flag turns the refusal into the opt-out's quiet return, and setting it makes
an "auto-update from releases" flag read as consent to replace the fork with
upstream. The refusal is now asserted at every value of the flag, and asserted
to be the fork refusal rather than a no-op.
@wongk
wongk merged commit 4f910fb into main Sep 16, 2026
9 checks passed
@wongk
wongk deleted the campaign-prd-739-20260915-c6 branch September 16, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant