Skip to content

fix(watch): a cluster-wide selection must not collapse named-namespace streams - #255

Merged
sunib merged 18 commits into
mainfrom
feat/watchrule-src-ns-pr2-stream-scope-collapse
Jul 20, 2026
Merged

fix(watch): a cluster-wide selection must not collapse named-namespace streams#255
sunib merged 18 commits into
mainfrom
feat/watchrule-src-ns-pr2-stream-scope-collapse

Conversation

@sunib

@sunib sunib commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

PR 2 of the WatchRule source-namespace workstream. Stacked on #254 (PR 1) — base is docs-validation-placement-and-source-namespace, not main.

The defect

SnapshotNamespaces() returned nil whenever a WatchedType carried the "" namespace key, and nil meant all namespaces at every read site. A WatchRule scoped to one namespace and a ClusterWatchRule scoped cluster-wide, on the same GVR and the same GitTarget, folded into a single all-namespaces stream. The named scope survived only in the plan hash.

The operation sets collapsed the same way: targetWatchSpecs substituted NamespaceOps[""] for the named scope's set, so a CREATE-only named rule co-resident with an UPDATE-only cluster-wide rule lost its filter too.

Under PR 4 this is a gate bypass, not merely over-watching. A WatchRule authorized only for repo-config would receive events from every namespace the source credential can read, with its allowedSourceNamespaces check having passed before the data plane widened it.

The fix

SnapshotNamespacesWatchedType.WatchScopes, returning every scope including the cluster-wide "" instead of collapsing to it, with both read sites projecting one stream per scope. Fixing one path only would be worse than no fix — the plan hash and the running streams would then describe different mirrors.

  • targetWatchSpecs keys a watch per scope, each carrying NamespaceOps[ns], so the per-namespace operation sets survive.
  • snapshotGVRsFromTable and resolveSnapshotGVRForType project through one shared snapshotGVRScopes helper, so the whole-target and per-type paths cannot drift apart. snapshotGVR.namespaces []string became a single namespace string, matching targetWatchKey and matching how a dynamic client List actually takes a namespace ("" = all).

Why distinct streams rather than subtracting the named scope from the cluster-wide one: "all namespaces except team-a" is not expressible as a watch, and the subtraction would need a sweep scope shaped as a complement, which git.ResyncScope cannot represent. Each stream's replay now pairs a desired set with a sweep scoped identically — which is exactly why this had to land after PR 1.

Cluster-scoped types are unaffected. collectWatchRuleSelections hardcodes ResourceScopeNamespaced, so only a namespaced record can ever acquire a named namespace key; a cluster-scoped type still yields exactly one "" scope and one cluster-wide watch.

Tests

TestBuildWatchedTypeTable_ClusterWideOverridesNamedNamespaces asserted the collapse as intended ("matching the historic gvrSnapshotEntry collapse"). It is deleted, not skipped — leaving it green would have meant the fix did not land.

Verified by restoring the pre-fix collapse: all three replacements fail, each on its own assertion.

Test Read site
TestBuildWatchedTypeTable_ClusterWideDoesNotCollapseNamedNamespaces table
TestTargetWatchSpecs_NamedAndClusterWideScopesStayDistinctStreams targetWatchSpecs (streams + op sets)
TestSnapshotGVRsFromTable_NamedAndClusterWideScopesStayDistinctEntries snapshotGVRsFromTable

Validation

  • task lint — clean
  • task test — green, coverage 76.6% (baseline unmoved)
  • task test-e2e — full suite in flight at time of opening; will report

Also re-ran the unexplained PR 1 e2e failure (Commit Request Bundle UC2, commit_request_e2e_test.go:377) under E2E_LABEL_FILTER='!image-refresh && !bi-directional && !source-cluster && commit-request': all 4 specs passed, so it was a flake, not a regression from PR 1's renderReconcileCommitMessage signature change.

🤖 Generated with Claude Code

sunib and others added 18 commits July 19, 2026 21:29
…ad to 1.0

The quick start pinned cert-manager v1.20.3, a version that appeared nowhere
else in the repo and was never exercised. It also omitted the rollout wait (the
controller cannot start until cert-manager issues the admission certificate) and
told users to check `Ready` on the GitTarget, which is held at Unknown until
first source discovery — the e2e asserts `Validated` for exactly this reason.

Also:

- Document the Kustomize support that already ships. The write path runs
  kustomize itself as a pre-commit oracle; the README previously claimed it
  "cannot infer the original structure of arbitrary templates or overlays",
  which the code contradicts.
- Stop over-hedging delivery. While the watch is connected every change is seen
  and committed; collapsing to current state only happens across a gap.
- Reframe HA from a standalone caveat into a road-to-1.0 list alongside the
  configuration surface (six CRDs still v1alpha3, no conversion path) and docs.
- Move the attribution mode tables into the attribution setup guide, which
  already opened with the same concept.
- Warn that `helm uninstall` removes the cluster-scoped default ClusterProvider,
  and that the default RBAC grants cluster-wide Secret reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…vate

Dependabot covers gomod, github-actions, and Dockerfile FROM lines, and it is
healthy — every direct Go dependency is current. The pins it cannot see had
drifted, including a cert-manager version in the README that existed nowhere
else in the repo.

Cluster charts (validated by the e2e suite):

- cert-manager 1.19.4 -> 1.21.0. Its three breaking changes (tokenrequest RBAC,
  cert-manager-edit ACME permissions, metrics Helm values) do not apply: we set
  no cert-manager values beyond crds.enabled, use a self-signed Issuer, and have
  no serviceAccountRef.
- valkey 0.9.3 -> 0.10.0, kcp-operator 0.7.7 -> 0.8.3.
- gitea 12.5.1 -> 12.5.3, deliberately NOT the latest 12.7.0. Gitea 1.26 removed
  the _csrf form input and cookie from /user/login, which the giteaclient
  WebSession scrapes to verify SSH signing keys, so 12.6.0+ fails the signing
  e2e. 12.5.3 carries the same Gitea 1.25.5, so this is a chart-only patch.

Devcontainer toolchain (needs an image rebuild to take effect): flux2 2.9.2,
flux-operator 0.55.0, helm 4.2.3, task 3.52.0, cosign 3.1.2. cosign is pinned
twice and both places move together.

Renovate is scoped to only what Dependabot cannot see, so the two never open
competing PRs: Flux HelmRelease charts, the devcontainer ENV pins, the
cert-manager URL in the README, and the cosign-installer input. It holds gitea
below 12.6.0 until the web session helper is reworked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the investigation behind unpinning Gitea rather than migrating to
Forgejo, read from the upstream sources in external-sources/.

The premise for migrating was that we were stuck on chart 12.5.x because Gitea
1.26 removed the `_csrf` form input from /user/login, which our WebSession
scrapes. That premise does not survive contact with the source: Gitea and
Forgejo made the *same* change, both replacing form-token CSRF with Go's stdlib
http.CrossOriginProtection, which admits any request carrying neither
Sec-Fetch-Site nor Origin. The fix is a deletion, and it works on either server —
so migration and unpinning are not the same piece of work.

That leaves a cost comparison Gitea wins: it keeps the literal "gitea" signing
namespace (Forgejo switched to the ROOT_URL hostname, the one hazardous change),
keeps the session cookie name, keeps an HTTP chart repo, and has the better
trusted-key lever — TRUSTED_SSH_KEYS reports the commit's own committer email
where Forgejo's instance key reports a fixed configured address, which is what
our signing assertion checks.

Also records why no Go SDK is adopted on either server: there is no official
Forgejo SDK, no SDK can cover the web-UI verification flow, the Gitea SDK's
version gating is meaningless against Forgejo, and it would add five dependencies
to test-only code that is currently stdlib-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gitea 1.26 replaced form-token CSRF with Go's stdlib
http.CrossOriginProtection (routers/web/web.go). That check returns nil when a
request carries neither Sec-Fetch-Site nor Origin — every non-browser Go client —
and POST /user/login is exempt besides, being SignOutRequired. So the token our
WebSession scraped is not merely absent, it is unnecessary.

Delete fetchCSRF, extractCSRFToken, csrfInputRE and both form.Set("_csrf", ...)
calls, and move the chart from 12.5.3 (Gitea 1.25.5) to 12.7.0 (Gitea 1.27.0) —
the version whose signing spec failed earlier today. HTML-scraping a hidden input
was the most brittle part of the client, so this is a net reduction in fragility
as well as ~45 fewer lines.

The Renovate guard holding gitea below 12.6.0 goes with it; it existed only to
describe this problem.

Validated on Gitea 1.27.0: task lint, task test (coverage 76.6%, baseline
unchanged), and task test-e2e — 58 passed, 0 failed, including the signing specs
that drive the web session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validation climbs a ladder: OpenAPI schema, then CEL XValidation, then the
reconciler for anything cross-object. A validating admission webhook is not a
rung — it is correct only when the information exists solely at admission time
and nowhere on the persisted object.

Writing it down once so it stops being re-argued per feature. The reasoning was
already settled in practice by the config-plane split, which enforces
ClusterProvider.spec.allowedNamespaces on every reconcile rather than at
admission: reconcile-time re-evaluates continuously (so it catches a policy
tightened after the object was created), the data-plane ordering is what
provides the security property, and admission buys only feedback latency in
exchange for cert wiring in the chart.

Also records why /validate-operator-types is not a counterexample: it captures
submitter identity and always allows, so it is not a validation gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sions

The record had grown to 474 lines carrying a build order, a test plan, an API
dump and open questions that are all either shipped or now owned elsewhere.
Cut to the decisions and the reversals, which is the part a finished doc is
actually for.

Corrects one claim that read as current but never shipped: an earlier draft
reserved the name "default" for the in-cluster cluster via CEL. What shipped
derives locality from spec.kubeConfig alone, for every name — a provider named
"default" may carry a kubeConfig and mirror a remote cluster. Records why the
reservation was rejected (it pins a name to a physical cluster, the same
silent-retarget hazard the immutability rules exist to prevent).

Also drops the "enforced in two places" admission-webhook wording for
allowedNamespaces, which shipped as a single reconcile-time gate, and points at
the new spec/where-validation-lives.md for the general rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revises the WatchRule source-namespace design. The previous revision gated the
new field on kubeConfig presence (remote-only, in-cluster forbidden), which is
wrong twice over: it derives an addressing capability from a connectivity
setting, so setting a kubeconfig to reach a cluster silently grants namespace
addressing; and it welds the in-cluster case shut forever, conflating "unsafe
by default" with "impossible".

Replaced by an explicit deny-by-default allow-list on the platform-owned
object, ClusterProvider.spec.allowedSourceNamespaces, mirroring the existing
allowedNamespaces exactly. One mechanism instead of two: an empty allow-list
already is a gate, so no separate boolean is needed, and the only capability a
boolean uniquely adds — "any namespace" — is the one the doc argues must not
ship upstream.

Names: sourceNamespace on WatchRule (the longer sourceClusterNamespace was
justified only by the remote-only rule, which is gone), and the allowed*
prefix retained on the provider because the allow-list IS the gate and the
name must foreclose a fail-open reading. Records allowedGitTargetNamespaces +
allowedSourceNamespaces as the symmetric pair to adopt at the next API
version rather than breaking v1alpha3.

Keeps the two code findings that make the upstream ask small: the Git path
already uses the source object's namespace, so the write side needs no change,
and ClusterWatchRule already watches the source cluster across all namespaces
but carries two pre-existing defects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment claimed AllowsNamespace was "the single authorization predicate
shared by the admission webhook and the reconcile-time refusal, so the two can
never diverge". There is no such webhook — the proposal was dropped, and
checkSourceAuthorization in gittarget_source_cluster.go is the only non-test
caller.

Replaces it with what actually holds: enforcement is reconcile-time and nowhere
else, it returns before DeclareForGitTarget, and that is deliberate rather than
incidental because continuous re-evaluation also covers a policy tightened after
the GitTarget was created — something admission could not see.

Comment-only: no generated CRD drift (it documents a method, not a field), and
`task lint` passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dep refresh read v0.8.3 (the appVersion kcp-operator 0.7.7 ships) as the
chart version. No such chart exists — kcp-dev/helm-charts publishes nothing
above 0.7.x — so the HelmRelease never left SourceNotReady and the e2e
source-cluster leg died in setup with "no 'kcp-operator' chart with version
matching '0.8.3' found".

The earlier fix on this branch was invisible to the merge: it restored the
merge-base value, so the three-way merge saw a change only on main's side and
took 0.8.3. Merging main in first makes this pin an explicit change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…R pages

The single 783-line design had grown past the point where it could be picked up
and implemented. Split it into a README that carries the decision and the
sequencing, plus one page per shippable PR:

- PR 1: collapse the ClusterWatchRule stream scope
- PR 2: admit ClusterWatchRule targets
- PR 3: the sourceNamespace field and its GitTarget allow-list gate
- PR 4: the ClusterProvider source ceiling that binds ClusterWatchRule too

PR 4 is not optional cleanup: a multi-tenant deployment runs a ClusterWatchRule
per tenant from day one to capture CRDs, so an allow-list that binds only
WatchRule would leave the wider path unguarded.

Repoint the INDEX entry and the multi-cluster-attribution cross-reference at the
new directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A per-namespace replay produced a `desired` set covering one namespace, but the
resulting mark-and-sweep was scoped by (group, resource) only. Every managed
document of that type in every other namespace was therefore absent from
`desired` — and a sweep deletes what is absent.

The scope was present at both ends and discarded in the middle: `targetWatchSpecs`
already built per-namespace watch keys, `enqueueReplayResync` passed only
`key.GVR` onward, and `resyncPlan`'s predicate matched group and resource while
`types.ResourceIdentifier` carried the namespace all along.

Replace `ScopeGVR *schema.GroupVersionResource` with `Scope *ResyncScope`
(GVR + Namespace) across ResyncRequest and PendingWrite, so the two halves of a
scope travel together and a call site cannot pass one without the other. An empty
Namespace keeps today's all-namespaces meaning, which is what a ClusterWatchRule's
cluster-wide stream gathers. `resyncHealKey` now separates namespaces too, so a
parked heal for one namespace no longer replaces another's.

The defect is latent today: a GitTarget can only watch one namespace per GVR,
because WatchRule.targetRef is namespace-local and a ClusterWatchRule's "" key
collapses the type to all-namespaces. It goes live with the first change that
fans a GitTarget out across namespaces — which is every remaining PR in
docs/design/watchrule-source-namespace/. Hence this lands first.

Verified: reverting the namespace half of ResyncScope.Matches fails
TestResync_NamespaceScopedSweepLeavesSiblingNamespacesAlone and the sibling-namespace
row of TestResyncScope_MatchesRespectsTypeAndNamespace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concurrent design-doc pass over the WatchRule source-namespace workstream,
committed so it is not carried alongside the PR 2 code change:

- README: PR 1 marked landed, PR 2 marked landed, and the compatibility table's
  PR-number column corrected (it was off by one against the phase split).
- PR 1 page: rewritten past-tense as the record of what shipped, with a "what the
  tree looks like now" section naming ResyncScope, resyncScopeForWatchKey, and the
  scope.Matches predicate.
- PR 2 page: status block describing the landed shape (WatchScopes, one stream per
  scope at both read sites), and a sharper PR 5 interaction — the ceiling drops the
  "" key only for namespaced selections, so cluster-scoped rules still emit it.
- PR 4 / PR 5 pages: consistency with the above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ce pages

Follow-up to 9dc9df8, covering the remaining PR #254 review findings.

- PR 5: drop the stale "own namespace, plus what the policy admits" row. The
  overview and PR 4 both settled on no implicit self-namespace exception; PR 5
  was the only page still carrying the superseded carve-out, which made the
  authorization boundary read two ways.
- PR 5: attribute the source Namespace informer and the
  SourceNamespaceAuthorized condition to PR 4, not PR 3. PR 3 is the
  ClusterWatchRule target-admission fix and introduces neither.
- PR 4 / PR 5: settle the unevaluatable-policy contract that the two pages
  stated in opposite terms. They are one contract with two instances, split on
  whether a scope is being *established* or *maintained* — failing closed while
  establishing means never starting a stream, while failing closed on a
  maintained scope means narrowing to nothing, and a narrowed set is the input
  to a sweep. Hence a permanent Forbidden is Stalled in the first case and a
  retained Unknown in the second. Recorded as a table in PR 4, referenced from
  PR 5, and pinned by a test pair so an implementation cannot satisfy one page
  while contradicting the other.
- README: header said PR 1 landed while the phase table said 1 and 2.
- multi-cluster-author-attribution: language on the ASCII fence (MD040).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e streams

`SnapshotNamespaces()` returned nil whenever a WatchedType carried the ""
namespace key, and nil meant all-namespaces at every read site. A WatchRule
scoped to one namespace and a ClusterWatchRule scoped cluster-wide, on the same
GVR and the same GitTarget, therefore folded into a single all-namespaces
stream: the named scope survived only in the plan hash, and `targetWatchSpecs`
substituted `NamespaceOps[""]` for its operation set, so a CREATE-only named
rule co-resident with an UPDATE-only cluster-wide rule lost its filter too.

Under PR 4 that is a gate bypass, not just over-watching. A WatchRule
authorized only for `repo-config` would receive events from every namespace the
credential can read, with its allowedSourceNamespaces check having passed
before the data plane widened it.

Replace `SnapshotNamespaces` with `WatchedType.WatchScopes`, which returns every
scope including the cluster-wide "" rather than collapsing to it, and project
one stream per scope at BOTH read sites — a fix in one path only would leave the
plan hash and the running streams describing different mirrors:

- `targetWatchSpecs` keys a watch per scope, each carrying NamespaceOps[ns], so
  the per-namespace operation sets survive.
- `snapshotGVRsFromTable` and `resolveSnapshotGVRForType` both project through
  one shared `snapshotGVRScopes` helper, so the whole-target and per-type paths
  cannot drift apart. `snapshotGVR.namespaces []string` became a single
  `namespace string`, matching targetWatchKey and matching how a dynamic client
  List actually takes a namespace ("" = all).

Distinct streams rather than subtracting the named scope from the cluster-wide
one: "all namespaces except team-a" is not expressible as a watch, and the
subtraction would need a sweep scope shaped as a complement, which
git.ResyncScope cannot represent. Each stream's replay now pairs a desired set
with a sweep scoped identically — which is exactly why this had to land after
PR 1.

A cluster-scoped GVR is unaffected: collectWatchRuleSelections hardcodes
ResourceScopeNamespaced, so only a namespaced record can ever acquire a named
namespace key, and a cluster-scoped type still yields exactly one "" scope.

TestBuildWatchedTypeTable_ClusterWideOverridesNamedNamespaces asserted the
collapse as intended ("matching the historic gvrSnapshotEntry collapse") and is
deleted, not skipped. Verified: restoring the collapse fails all three
replacements — the table-level scope test, the targetWatchSpecs stream/op-set
test, and the snapshotGVRsFromTable test — each on its own assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d9889f27-8713-43ef-90e8-ce6dcd35732e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/watchrule-src-ns-pr2-stream-scope-collapse

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from docs-validation-placement-and-source-namespace to main July 20, 2026 10:05
@sunib
sunib merged commit 10a530a into main Jul 20, 2026
2 checks passed
@sunib
sunib deleted the feat/watchrule-src-ns-pr2-stream-scope-collapse branch July 20, 2026 10:23
sunib added a commit that referenced this pull request Jul 20, 2026
* ci: validate every pull request, not only those based on main

`ci.yml` triggered on `pull_request: branches: [main]`, so a stacked PR — one
based on the previous PR's branch rather than on main — never matched and ran
no validation at all. PR #255 (PR 2 of the WatchRule source-namespace stack)
merged having run only CodeRabbit and GitGuardian: no lint, no unit tests, no
e2e.

Retargeting does not rescue it. When the base branch merges, GitHub retargets
the still-open PR onto main and emits `pull_request` with action `edited`,
which is not in the default trigger set (opened/synchronize/reopened), so
nothing re-runs. The PR is then mergeable into main having never been
validated by this pipeline.

Drop the `branches` filter so validation follows the pull request rather than
its base. This costs some runner time on stacked PRs and is what makes a stack
reviewable one PR at a time — the workstream in
docs/design/watchrule-source-namespace/ is five stacked PRs, so without it four
of the five would merge unvalidated.

The trust model is unchanged: `pull_request` still grants no secrets and no
registry writes, and GitHub independently forces a read-only token on fork PRs.
docs/ci-overview.md already documents the trigger as `pull_request`, so this
makes the workflow match the documented design.

Verified with actionlint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): read the author mode from the Deployment, not from a log grep

`configuredAuthorModeEnabled` decided which commit-author assertion applies by
shelling out to `kubectl logs deployment/gitops-reverser --since=30m` and
grepping for a startup banner, returning false on any kubectl error. That is a
probe that fails OPEN on the exact question the assertion turns on, in at least
three ways:

- a controller up longer than 30 minutes has its banner age out of the window;
- `kubectl logs deployment/...` can error or pick the wrong pod mid-rollout;
- a spec that redeploys the controller in configured-author mode (the
  quickstart specs do) leaves the banner in the shared log window, so a later
  spec reads a mode that is no longer current.

It matters more than a normal flaky helper because both author values are
ambiguous in isolation: "GitOps Reverser" is BOTH the configured-author identity
AND the fallback used when attribution finds no matching audit fact
(git.DefaultCommitterName). A wrong probe therefore silently swaps the
assertion for a weaker one that passes, instead of failing.

Read the Deployment's container args, which are the actual input to the mode
switch in cmd/main.go, and fail loudly when they cannot be read. The decision is
factored into a pure `configuredAuthorModeFromArgs` with unit coverage, so the
default-handling is pinned: cmd/main.go defaults BOTH `--author-attribution`
(true) and `--redis-addr` ("valkey:6379") to enabled, so an absent flag means
attribution mode and only an explicit opt-out selects configured-author mode.

Found while investigating an attribution failure on PR 2; see
docs/design/watchrule-source-namespace/pr2-e2e-attribution-investigation.md.
This is NOT the cause of that failure — the deployment there was genuinely in
attribution mode and the probe genuinely returned the right answer — but it made
the failure take far longer to interpret than it should have.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(dev): make the e2e task surface hard to misuse

Two ergonomics fixes for footguns that each cost a real debugging cycle in the
session that landed PR 2.

Rename test/e2e/Taskfile.yml to Taskfile-e2e.yml. Every path in that file is
relative to the REPO ROOT, because the root Taskfile includes it with `dir: .`.
While it was named Taskfile.yml, running `task` from inside test/e2e/ stopped
Task's upward search there and executed with test/e2e/ as the working directory,
so every one of those paths silently resolved somewhere that does not exist. The
observed damage: `task clean-cluster` deleted the k3d cluster (cwd-independent)
while its `rm -rf .stamps/cluster/<ctx>` hit a nonexistent relative path, leaving
a stale `ready` stamp that made the next `prepare-e2e` skip cluster creation and
fail with "No nodes found for given cluster". With no Taskfile.yml in test/e2e/,
the search always reaches the repo root and the working directory is right no
matter where the command is typed. docs/tasks-overview.md records why, so it does
not get renamed back.

Add a PreToolUse hook refusing `task … | …` when pipefail is unset. A pipeline
reports the LAST command's status, so `task prepare-e2e | tail -15 && task
test-e2e` reported success for a failed prepare and ran the suite against a
cluster that was never created — surfacing much later as an unrelated-looking
SynchronizedBeforeSuite failure. The root Taskfile sets pipefail for commands
inside tasks, which does nothing for a pipeline typed at the shell.

.gitignore un-ignores .claude/hooks/ because the tracked settings.json points at
the hook: ignoring it would leave everyone else with a hook reference to a file
they do not have, failing every Bash call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* diag(watch): name declared streams, and record the attribution investigation

An e2e failure while landing PR 2 — a commit that should have been authored
jane@acme.com was authored "GitOps Reverser" — took far too long to diagnose, and
was wrongly blamed on PR 2 three separate times. This adds the instrumentation
that settled it and writes down the answer.

Findings, measured rather than argued:

- Attribution silently loses ~10% of actors under parallel load. The resolver
  waits DefaultAttributionGraceWindow (3s) for a matching audit fact and then
  commits as the configured committer. Measured fact-delivery lag on an IDLE
  cluster is 835-1101ms, bimodal — the signature of the apiserver's
  --audit-webhook-batch-max-wait=1s. So a 3s budget runs against a ~1s fixed
  batching delay with ~2s of headroom, and under load the tail crosses it.
  Resolver wait by result: weak 0.001s, exact_user 0.533s, absent 2.268s.

- PR 2 is NOT the cause. Attribution loss rates over ~85 resolutions per arm are
  8.0% with PR 2 and 9.6% without; configmaps alone lose 10.0% on baseline. The
  spec is ~10%-flaky on its own, which no three-sample comparison could separate
  from an effect.

- The loss is invisible by construction: git.DefaultCommitterName ("GitOps
  Reverser") is ALSO the configured-author identity, so a lost-actor commit is
  indistinguishable from a correct configured-author commit by inspection.

targetWatchSpecs now names every declared stream as
"<gvr>@<namespace|*cluster-wide*>=<ops>" instead of logging a bare count. A GVR
appearing twice for one GitTarget means the same object is delivered on two
streams — legitimate scoping after PR 2, but invisible in a count. This is how
the real case-C fan-out was found (unsupported-folder-dest carries both a
cluster-wide and a named configmaps stream).

hack/attribution-diagnostics.sh answers the question that splits the diagnosis:
is the fact ABSENT from Redis (never delivered) or PRESENT but written too late
for the grace window? Fact keys carry a 10-minute TTL, so the remaining TTL
back-computes when each landed.

docs/design/watchrule-source-namespace/ gains the resolution, the full session
log including the reversals, and a watch-construction overview answering how
watches are built and at what cardinality (one raw watch per
(GitTarget, GVR, namespace) — no reuse across GitTargets).

The grace-window fix itself is deliberately NOT in this commit: it is a product
behaviour change (a write with no audit fact would wait longer before
committing) and deserves its own review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(debug): consolidate the attribution investigation into docs/debug/

Three sprawling working documents in design/watchrule-source-namespace/ replaced
by one condensed page, plus the watch-construction reference moved alongside it.
The originals grew by accretion during the investigation and carried several
conclusions that were later overturned; keeping them would mean keeping three
versions of the same story with no signal about which is current.

docs/debug/ is a new lifecycle folder, registered in INDEX.md: "this is wrong
now" — open investigations into live defects, retired to finished/ once fixed.
It sits between design/ (still deciding) and finished/ (already happened), which
had no home for a known-broken behaviour someone must not re-investigate from
scratch.

attribution-loss.md states the defect, how far the 7-10% figure can be trusted
and why, and then two tables that are the point of the exercise: what was RULED
OUT with the evidence that killed each hypothesis (PR 2, late fact arrival,
watermark gating, key shape, webhook routing, audit policy, replay events, load,
"just flaky"), and what was FIXED along the way (CI trigger, author-mode probe,
Taskfile rename, task-pipe hook, per-stream logging, the after-suite report, the
histogram le-label bug).

The ruled-out table is the part worth keeping. Every row cost a cycle, and
several were things I asserted confidently before measuring.

Also corrects the grace-window comment in config/deployment.yaml: raising 3s to
10s left the loss rate unchanged, so it buys headroom and observability, NOT a
fix. And fixes the after-suite report's bucket queries — Prometheus renders le as
"1.0"/"3.0"/"10.0", so %g matched nothing above 0.5 and produced a plausible but
entirely wrong distribution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(design): propose an explicit author for unattributed commits

When attribution is enabled but no audit fact matches, the commit is authored by
the configured committer — the same "GitOps Reverser" identity that every commit
carries in configured-author mode. So a silent attribution failure is
byte-identical in Git history to correct configured-author behaviour, and the
only way to see the ~7-10% loss is a Prometheus counter nobody looks at. That
ambiguity is what made the loss so slow to diagnose (docs/debug/
attribution-loss.md).

Proposes authoring those commits as `unattributed
<unattributed@gitops-reverser.invalid>` while leaving the committer as the
operator — using git's author/committer split for exactly what it is for: the
operator really did make the commit, and the honest answer to "on whose behalf"
is "we do not know". Configured-author mode is untouched, since there the
committer genuinely is the author.

Checked the interaction most likely to make this risky: commit-window grouping
matches on UserInfo.Username, and today unattributed events share Username == ""
so they already group with each other and not with attributed ones. A sentinel
renames that equivalence class without splitting or merging any window.

Does NOT fix the underlying loss — makes it legible, and worth doing first
because it turns an invisible correctness failure into one visible in `git log`,
and gives the open investigation a labelled instance tied to an object and time
rather than a counter increment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(design): drop the flag, and say "attribution failed" rather than "unattributed"

Two revisions after review.

No flag. The earlier draft proposed --author-attribution-placeholder defaulting
on. The only thing such a flag can do is restore silently mislabelled
authorship, which is not a preference to preserve behind an opt-out — it is the
bug. Attribution is already gated by --author-attribution: an operator who does
not want actor names turns that off and gets configured-author mode, where the
committer genuinely is the author. A second flag would only add an incoherent
third state: attribution on, but lie about it when it fails.

"unattributed" was too passive — it reads as "this operator does not do
attribution", which is exactly the confusion with configured-author mode the
change exists to remove. The reader must be able to tell the operator TRIED and
failed, because that is a defect to investigate rather than a configuration they
chose.

Settled on three distinct strings, because UserInfo feeds three places and one
string does not serve them:

  Username     attribution-failed                    (window grouping key AND
                                                      the grouped commit MESSAGE
                                                      body — must stand alone
                                                      there, where a bare
                                                      "unknown" says nothing)
  DisplayName  unknown (attribution failed)          (the git author Name a
                                                      human reads; leads with
                                                      what they care about,
                                                      follows with why)
  Email        attribution-failed@gitops-reverser.invalid

Verified parentheses and spaces pass isSafeSignatureField, so the display form
is valid in a commit object and in a signed payload.

With no opt-out, adopters WILL see these commits appear, so the release note is
now mandatory rather than advisable, and must point at the metric and at
docs/debug/attribution-loss.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(design): carry attribution outcome explicitly, not in the author string

Revised after review. Every point raised was verified against the code and every
one held; two would have shipped silent regressions.

The structural change: stop encoding attribution outcome in the author string.
Add an explicit AttributionOutcome (not_attempted | resolved | unresolved) on the
event and pending write, and let the git author identity, the author_kind metric,
and CommitRequest window matching all READ it. The first draft inferred outcome
from UserInfo.Username, which is load-bearing in three places it did not account
for.

HIGH — CommitRequest attachment. The draft claimed this was untouched; it is not.
A fallback CommitRequest attaches with Author == "" and matchesWindow compares
the author string exactly, so today it coincides with an unattributed window's
empty author and attaches. A naive sentinel breaks that coincidence and the
request silently lands as its own commit. Now matched on outcome, which also
turns today's accidental match-on-empty-string into stated behaviour. Integration
test required, not just a unit test.

HIGH — author_kind. authorKind() classifies by string: not empty and not a
serviceaccount prefix means "user". A sentinel username would therefore be
counted as a real named actor, so failed attribution would render in dashboards
as IMPROVED user attribution — actively masking the defect rather than merely
staying silent. Adds `unresolved` as a fourth kind, driven by the outcome, with
metric docs and dashboards updated in the same change.

"failed" -> "unresolved". AttributionAbsent collapses no-fact-existed (correct
behaviour), cancelled waits, Redis read errors and malformed values —
matchFactKey discards the error deliberately. "Failed" asserts a fault we cannot
prove. "Unresolved" says we tried and did not arrive at an actor, which is all
the evidence supports; a genuine `failed` state can be split out later if the
index grows typed outcomes.

Resolver contract. ResolveAuthor documents ok=false as "commit as the configured
committer". Returning a placeholder instead would overturn that contract while
leaving its documentation intact — the exact failure mode this investigation was
caused by. The signature returns the outcome explicitly instead, which also
removes the unsound "non-nil resolver means attribution is configured" inference.

Documentation inventory. architecture.md ("the author identity is never
invented"), configuration.md, attribution-setup-guide.md, interpreting-metrics.md
and UPGRADING.md all promise the committer fallback and must move together. Also
notes Username reaches user-authored per-event templates, not only grouped
messages, so that surface needs compatibility coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(attribution): carry the outcome explicitly, and name unresolved authors in Git

Attribution failed silently. When it ran and found no fact, the commit was
authored by the configured committer — the same "GitOps Reverser" identity used
in configured-author mode — so a lost actor was byte-identical in Git history to
a deployment that simply does not do attribution. The only way to see the ~7-10%
loss was a Prometheus counter nobody reads. Implements
docs/design/attribution-outcome-and-author.md.

The change is NOT just a sentinel string. Attribution outcome is now an explicit
value carried on Event and PendingWrite:

  not_attempted  configured-author mode; the committer legitimately IS the author
  resolved       a fact named the actor
  unresolved     attribution ran and did not arrive at an actor

Three consumers read the outcome instead of inferring it from the author string,
which is load-bearing in places a naive sentinel would have broken:

- Commit author. `unresolved` renders
  `unknown (attribution unresolved) <attribution-unresolved@gitops-reverser.invalid>`
  while the committer stays the operator — git's author/committer split used for
  exactly what it is for. RFC 2606 reserves .invalid, so it can never collide
  with a real address.
- author_kind gains `unresolved`. Classifying it as "user" would have made a lost
  actor indistinguishable from a named one, so a DEGRADING attribution path would
  have read in dashboards as an improving one.
- CommitRequest attachment matches on outcome. A fallback CommitRequest carries an
  empty author, which today coincides with an unattributed window's empty author
  and attaches by accident; once the window carries the sentinel those strings
  differ and the request would have silently landed as its own commit. Matching on
  the outcome keeps them together and makes the old accident explicit.

ResolveAuthor returns the outcome rather than an ok bool whose documented meaning
("commit as the configured committer") the placeholder would have overturned
while leaving the doc intact — the exact class of mismatch that made this
investigation slow. It also removes the unsound "non-nil resolver means
attribution is configured" inference.

Two defects found while implementing, neither in the review:

- Adding attributionNotAttempted left setCommitRequestAttributed's switch
  unhandled, so the AuthorAttributed condition stopped being set at all.
- The existing attributionCommitter message claimed "the validate-operator-types
  webhook is not configured", which is now only true for not-attempted. A
  configured-but-missing webhook would have sent operators to the wrong place.
  Both messages are now accurate.

Docs moved together, as they must: architecture.md ("the author identity is never
invented" -> never fabricated as a PERSON, with both no-actor cases spelled out),
configuration.md, attribution-setup-guide.md, interpreting-metrics.md.

Verified: e2e attribution report shows the split working — 3 resolutions
succeeded after waiting longer than the 3s default grace, and absent waits now
separate fast returns (a fact with no author) from grace-ceiling ones (no fact
ever arrived), which the old combined view could not distinguish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(giteaclient): follow pagination when listing a user's keys

ListUserKeys issued GET /users/{login}/keys with no pagination, so it saw at
most one page. Verified against both sides:

- Server: GetListOptions does PageSize: ToCorrectPageSize(limit), and
  ToCorrectPageSize clamps `size <= 0` back to DefaultPagingNum — 30 by default,
  MaxResponseItems 50. There is NO "return everything" mode for this endpoint.
  (gitea modules/setting/api.go, services/convert/utils.go,
  routers/api/v1/utils/page.go)
- SDK, for the record: code.gitea.io/sdk documents ListOptions.Page = -1 as
  "disables pagination", but it merely sends page=0&limit=0, which the server
  clamps straight back to 30. So adopting the SDK would NOT have avoided this;
  its escape hatch is ineffective on this endpoint and actively misleading.

The consequence is not a truncated listing but a wedged workflow: this listing
backs FindUserKeyByTitle, which EnsureUserKeyAsAdmin uses to decide whether a
title is taken. Past one page, "ensure" cannot see the key it needs to replace,
so it tries to create and fails `422 Key title has been used` for a key that
demonstrably exists.

The e2e suite hits this deterministically. It registers ~24 seeded transport
keys per full run against a Gitea that outlives the run, so from the second run
onward the admin user holds more than one page. The long-lived, stable-titled
`playground` key then becomes unreachable and the playground spec fails at
BeforeAll — which is why it only ever appeared after several runs on one cluster,
and why `task clean-cluster` "fixed" it: that resets Gitea to zero keys rather
than fixing anything.

Requests limit=50 (MaxResponseItems) to keep round trips low, and stops on the
first short page. An empty page also terminates, guarding against a server that
ignores the limit.

Verified: both new tests fail against the unpaginated implementation — one for a
title on the last of three pages, one for a listing that exactly fills a page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(watch): drop the dead snapshot-resolution path

resolveSnapshotGVRs, resolveSnapshotGVRForType, snapshotGVRsFromTable,
snapshotGVRScopes and the snapshotGVR type had no production callers — only each
other and their tests. The desired set is no longer gathered through this path;
it is the spliced materialization, so scope_resolve.go kept a parallel,
never-executed projection of the same watched-type table.

Found while implementing PR 2, where it caused real cost: the change had to be
applied to BOTH read sites and verified at both, and one of PR 2's three
regression tests asserted this projection specifically. That test goes with the
code, which is the honest outcome — with the path gone there is exactly ONE
consumer of WatchedType.WatchScopes (targetWatchSpecs), so the "both read sites
must agree" invariant PR 2 was written to defend no longer has two sides to
disagree.

Kept: retainedWatchedTypes, typeWobbling, gvkListSummary and desiredFromObject,
which are all live from target_watch.go and the splice.

243 lines removed. No behaviour change — nothing called any of it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(attribution): let a CommitRequest attach when the two sides disagree about why nobody was named

In the DEFAULT deployment no CommitRequest could attach to any commit window, so
the user's message was silently dropped and the change landed under the
generated one. Two independent causes, both introduced with the explicit
outcome.

1. AttributionNotAttempted was "not_attempted", so it was NOT the zero value of
   AttributionOutcome. Most producers of an Event never assign the field —
   reconcile, resync, bootstrap, and configured-author mode's early return in
   attachAuthor (the only assignment site in the non-test tree) — and all of
   them mean "no actor was sought". The zero value was therefore a silent fourth
   state equal to none of the three named outcomes. Three comments asserted it
   already WAS AttributionNotAttempted and were false (types.go, target_watch.go,
   author_resolver.go). The constant is now "" so that it genuinely is, which
   makes all three true at once instead of patching one site. Nothing serializes
   it: it reaches no CRD field and no metric label, and authorKind branches on
   the typed value.

2. matchesWindow compared the two outcomes for exact equality. They are produced
   by DIFFERENT, INDEPENDENTLY CONFIGURED subsystems — the window's by
   mirrored-resource attribution (--author-attribution), the request's by
   command authorship (--admission-webhook), which cmd/main.go documents as
   independent — so equality couples the two flags:

     watch attr | webhook   | window        | CommitRequest | equality
     off        | off       | not_attempted | not_attempted | match
     off        | on, miss  | not_attempted | unresolved    | NO MATCH
     on, unres. | off       | unresolved    | not_attempted | NO MATCH
     on, unres. | on, miss  | unresolved    | unresolved    | match

   The two middle rows are real deployments that attach on main. Matching now
   goes through AttributionOutcome.NamesActor: an outcome either produced an
   actor to compare or it did not, and WHY it did not is a property of the
   producer's configuration, not of the commit.

   The author comparison stays UNCONDITIONAL, with the outcome class as an
   additional guard rather than a replacement. Skipping it when neither side
   names an actor looks equivalent — both authors are "" — but makes
   cross-author attachment depend on the outcome fields being right;
   TestAttach_ForeignWindowIsNotStolen caught exactly that, with bob's request
   claiming alice's window.

   Note this is the opposite call from openWindow.canAppend, which compares the
   enums directly and should: both of its sides come from the same watch
   pipeline. Intra-subsystem versus cross-subsystem.

Also settles what the unresolved sentinel is scoped to. commitOptionsFor already
DERIVES UnresolvedAuthor() at the write path from the carried outcome rather
than storing it on the event, so the sentinel is the git author header and
nothing else — not window grouping, not the grouped message body, not
{{.Username}}. types.go documented the opposite and was the stated rationale for
the three-string design. Stamping it onto event.UserInfo would change the commit
text of every deployment that has attribution misses and force user templates to
special-case a magic token; an unresolved event now renders {{.Username}} empty
exactly as configured-author mode always has, while git log names the gap.

Tests. The gap that let this ship is that every existing case in
commit_request_attach_test.go left BOTH sides at the zero value, so "" == ""
passed and the production combination was never exercised.

- TestCommitRequestAndWindowOutcomesAgree drives each side from its REAL
  producer: the controller's gitOutcome() projection, and the watch package's
  actual resolver / an actual zero git.Event. It lives in internal/controller
  because that is the only package that imports both.
- TestMatchesWindow_AcrossIndependentlyConfiguredSubsystems walks every pair
  either side can produce and names the deployment that produces it, including
  the different-authors-with-unset-outcomes case that pins the author check as
  unconditional.
- TestAttributionZeroValueIsNotAttempted pins the coupling everything rests on.
- The two tests that hand-injected a state production never produces
  (window.Author = sentinel; the sentinel in a {{.Username}} template) now
  assert production reality instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(giteaclient): terminate pagination on an empty page, not a short one, and cap the loop

The keys pagination added in 2f29c39 terminated on `len(keys) < keyPageSize`,
which assumes the server's page size equals the one requested. It does not:
MAX_RESPONSE_ITEMS is operator-configurable and ToCorrectPageSize clamps every
request down to it, so against a server whose cap is below 50 EVERY page is
short and the loop stops after page 1 with no error.

Measured against a server clamping to 30 with a user holding 100 keys:
`keys=30 (of 100)`, FindUserKeyByTitle found=false — byte-for-byte the original
failure (`422 Key title has been used` for a key that demonstrably exists). It
passed only because it happened to match an unpinned server default; the e2e lab
sets no [api] block in gitea-values.yaml, so nothing held that default in place.

Only an empty page terminates now. Correct for any server page size, at one
extra round trip. (Gitea does emit X-Total-Count and Link, but Client.Do returns
(int, []byte, error) and discards headers, so that route needs a signature
change.)

Separately the loop had no cap. Against a server that ignores `page` and returns
a full page forever it issued 7193 requests in 1.5s before the context deadline,
growing `all` by 50 structs each time. All callers pass deadlines, so it
degraded to an undiagnosable "context deadline exceeded" rather than a hang. A
maxKeyPages guard names the cause instead.

The comment at the old termination was wrong on both halves and is rewritten
with the fix: a short page is not necessarily the last page, and the server that
loops forever is one that ignores `page`, not one that ignores the limit. The
stranded "// ListUserKeys returns every public key…" doc comment, left above
`const keyPageSize` when the new block was inserted, now documents the const it
actually sits on.

ListRepoHooks had the identical unpaginated bug — /repos/{owner}/{repo}/hooks
paginates the same way. Latent only because e2e creates a fresh repo per spec
and never accumulates a page of hooks on one repo. Both now go through one
listPaginated helper, so the termination rule is stated once.

Tests: a server that clamps below the requested limit (the regression above), a
server that ignores `page` (the cap), and the exact-page-multiple case. The
fakes now honour `page` rather than serving the same body forever — three of
them would have tripped the new cap, which is the correct client behaviour
against an unrealistic fake.

Also fixes the handler-goroutine t.Fatal in this file's fakes: Fatal from an
httptest handler runs runtime.Goexit on that goroutine, killing the connection
mid-response, so the client reports a spurious EOF on top of the real message —
two reported errors for one fault, misleading one first. Failures are now
recorded and asserted on the test goroutine (or reported with t.Errorf, which
does not Goexit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(e2e): initialize the Prometheus client before the attribution report

Four e2e legs failed on run 29745528349 — quickstart-install, source-cluster,
image-refresh and bi-directional — with every selected spec PASSING and the
suite then panicking in SynchronizedAfterSuite:

  Test Panicked: invalid memory address or nil pointer dereference
    test/e2e.queryPrometheus            helpers.go:70
    test/e2e.reportAttributionStats     e2e_suite_test.go:153
    test/e2e.init.func18                e2e_suite_test.go:80

reportAttributionStats, added in c55c33b, queries Prometheus without calling
ensurePrometheusClient() — unlike assertNoAnomalousAuditOutcomes on the very
next line. promAPI is only ever set by that lazy initializer, so on any leg
whose selected specs never touched a metric helper it was still nil.

The four failing legs are exactly the small ones (1, 3, 6 and 11 specs);
full-manager (43) and full-core (17) passed only because their specs had
already initialized the client. A pure function of leg size and spec selection,
not of what the legs test — which is why it read as unrelated flakiness.

Worth noting the blast radius: the panic aborted the AfterSuite node, so the
GATING assertNoAnomalousAuditOutcomes never ran on those four legs at all.

queryPrometheus now reports an uninitialized client as a named error rather
than nil-dereferencing. A panic there aborts whatever node is running, and from
an AfterSuite that fails an entire leg with every spec green, which is
unreadable from the console log.

Also parses the author-attribution flag with strconv.ParseBool, as Go's flag
package does. configuredAuthorModeFromArgs matched only the literal
`--author-attribution=false`, so a `--set attribution.enabled=0` (or f, F,
False, FALSE) would flip the probe to ATTRIBUTION mode while the controller ran
configured-author — precisely the silent assertion swap a678dc7 exists to
prevent. The `["--redis-addr="]` row is dropped: with attribution defaulting
true that arg set is fatal at startup ("redis-addr is required when
author-attribution is enabled"), so it documented a mode that cannot exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(renovate): track the second devcontainer ENV block

TILT_VERSION, DLV_VERSION, GOPLS_VERSION and STATICCHECK_VERSION
(.devcontainer/Dockerfile) had no custom manager and are invisible to Dependabot
too, so those four pins sat in exactly the "silently rotted" class the config's
own description says it covers. The first ENV block is fully covered by 13
working regexes; this one was simply missed.

Datasource per how each is actually installed, not by habit:

- tilt downloads a GitHub release asset  -> github-releases tilt-dev/tilt
- the other three are `go install <module>@$VERSION`, so the `go` datasource
  resolves the same module-proxy versions the build uses:
    github.com/go-delve/delve
    golang.org/x/tools/gopls   (its own module, versioned apart from x/tools)
    honnef.co/go/tools         (staticcheck's module path, not dominikh/go-tools
                                releases, whose tags use a different scheme)

NODE_MAJOR is left alone: it is a deliberate major-only pin tracking an LTS
line, not a rotting version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: clarify attribution outcomes and prune stale notes

* fix(attribution): address review feedback

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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