Skip to content

feat: add KRM resource streaming library - #1

Merged
sunib merged 44 commits into
mainfrom
feat/live-resource-store
Jul 14, 2026
Merged

feat: add KRM resource streaming library#1
sunib merged 44 commits into
mainfrom
feat/live-resource-store

Conversation

@sunib

@sunib sunib commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces the first pre-1.0 krm-stream library: a written KRM resource-stream protocol, a Go read gateway, and a dependency-free TypeScript browser store sharing one conformance corpus.

Highlights

  • Adds the v1 stream protocol, fixtures, SSE goldens, and cross-language conformance coverage.
  • Adds the transport-neutral Go gateway and opt-in gateway/kube adapter, including streaming-list and list-then-watch fallback paths.
  • Adds a headless TypeScript store with three-way reconciliation, redaction-safe merge patches, sequence-gap handling, and OpenAPI associative-list support.
  • Adds projection-aware patch validation while keeping writes, credentials, and authorization policy owned by the host application.
  • Makes resource scope policy explicit: namespaced, cluster-scoped, all-namespaces, and label-selector access are deliberate host decisions.
  • Adds operational controls and observer hooks for heartbeats, shared-consumer queues, resyncs, suppression, overflows, and terminal failures.

Adopter Documentation

  • End-to-end same-origin cookie, bearer-token, and shared-backend/SSAR recipes.
  • Save-path responsibilities and projection-aware patch rejection.
  • Scope semantics, readiness matrix, and an operational runbook.

Validation

  • task fixtures-check
  • task test (including Go race tests)
  • task lint
  • task build-client

Boundaries

The library intentionally remains a read gateway and headless client. It does not provide a browser token flow, Kubernetes API proxy, authorization system, or write endpoint.

sunib and others added 12 commits July 11, 2026 19:10
…e corpus

The client fixtures asserted nothing until now: test/ only checked that the
corpus was well-formed. It now replays each fixture's `events` into a store,
applies `client.edits` at their `after:` positions, and asserts `expect` —
dirty, conflicts, draftSubset, absentPaths, readOnlyPaths, flashed, patch — plus
the checkpoints in conflict-and-converge. All 14 client fixtures pass.

The store is the two layers of spec §4.1, kept apart:

  - applyServerEvent REPLACES the authoritative object (a deep-merge cannot
    express a removal, so it resurrects the field the server just deleted), then
    three-way reconciles the editable regions against the PREVIOUS server object
    as base (R-THREEWAY).
  - read-only regions follow the server live and flash what moved. They never
    grow a draft, a dirty flag or a conflict, and never enter a patch — and a
    write to one is refused, not ignored.
  - dirtiness is derived on every ask (R-DERIVED); paths are segment arrays
    (R-ID); deepEqual is a structural compare, not JSON.stringify.
  - patch() is RFC 7386 over the editable changes only; arrays go whole.

Each of the three bugs the inline original shipped is now caught by a mutation:
dropping the base term fails 6 fixtures, keeping a pruned key fails 2, pruning
on reset instead of synced fails 1. Two rules no fixture pins — a REFUSED edit
to status, and key-order-independent equality — are pinned in
test/invariants.test.ts, which is where docs §7 says the property tests live.

No runtime dependencies. The emitted ESM imports nothing but its own relative
./*.js, and a browser can import it with no bundler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TS side was two tools short of the Go side, and one of the gaps was a
correctness hole rather than a nicety:

  tsconfig.json includes only src/, so `tsc --noEmit` saw ZERO files under
  test/ — and `node --test` on Node 22 STRIPS types, it does not check them. The
  conformance suite, whose entire job is to be the contract check, was the one
  piece of unverified TypeScript in the repo.

tsconfig.test.json (noEmit, src + test) closes that; it needs @types/node,
because the tests import node:test. Biome supplies what gofmt + go vet +
golangci-lint supply on the other side, in one binary. Both run in `task lint`
and in CI; `task fmt-client` fixes what it can.

noNonNullAssertion is off, deliberately: with `noUncheckedIndexedAccess` on,
`path[i]!` inside a loop bounded by `path.length` is the honest expression of a
fact the bound already guarantees, and the alternative is a runtime guard for a
branch that cannot happen. Turning OFF noUncheckedIndexedAccess to satisfy the
lint rule would have traded a real check for a cosmetic one.

CONTRIBUTING now says what the dependency rule actually means: no RUNTIME
dependencies, ever — that one is absolute, the bundle is plain ESM a browser
imports with no bundler. devDependencies ship nothing, and the three we have
each pay for themselves.

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

Rung 1 of the e2e ladder: each fixture's `watch:` ops are played through a fake
Kubernetes watch and what the gateway puts on the wire is compared to the
fixture's `events:` — byte for byte, because `events:` is exactly what the
TypeScript store on the other side is fed. All 11 gateway fixtures pass. No
cluster: every rule in the corpus is about framing and projection, and a fake
watch drives those deterministically in microseconds.

The seams (seams.go) are CONTRIBUTING's one rule expressed as Go. The gateway
does not know what a tenant is or where a kubeconfig lives; it asks the host,
through Authorizer and ClientFor(target, principal), and the host answers. The
upstream is behind an interface for the same reason — and that buys the test
suite as a side effect: the corpus drives a ScriptedBackend, a real cluster will
drive a client-go one, and the loop cannot tell them apart.

Watcher is PULL-based, deliberately. "The gateway was handed the last event" and
"the gateway finished with it" are the same moment under a pull, and different
moments under a channel — so the conformance replay is deterministic with no
sleeps. A channel-based client-go watch adapts in ten lines; the reverse does not
adapt at all.

Two spec rules turned out to be UNTESTABLE by the corpus, and I proved it by
mutation: emitting `synced` on every bookmark, and forwarding a partial object
with no uid, both leave all 11 fixtures green. A fixture's `watch:` script has no
op for a bare BOOKMARK or a degenerate tombstone, so it cannot say those words.
They are MUST NOTs (spec §2, §4.2) and rows 7/8 of the gateway's own matrix, so
they are pinned in stream_test.go instead — and the fixture format needs three
new ops, which is a change to the shared contract and therefore not mine to make
unilaterally.

Six mutations, six caught: bookmark-as-synced, partial-object-forwarded,
monotonicity-removed, tombstone-uid-guessed, secret-policy-off, and
empty-named-scope-emits-nothing.

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

Rung 2 of the e2e ladder, and the one that turns "end to end" from a claim into
a fact.

Until now the two implementations shared a DOCUMENT. Go asserted "given this
watch I would emit these events"; TypeScript asserted "given these events I hold
this state"; and nothing, anywhere, checked that the events one side emits are
bytes the other side can read. Two implementations agreeing to disagree is
exactly what that gap permits — and it was not hypothetical, see below.

Three things land:

  - gateway/sse.go — the transport. Content-Type, flush-per-frame, a heartbeat
    COMMENT every 20s, no `id:` lines ever, and a terminal error as the last
    event before the close.

  - conformance/gen/sse/*.sse — golden transcripts: the bytes the gateway really
    writes, through its real sink, committed and regenerated by `task fixtures`.
    They are produced BY the gateway rather than hand-written beside it, because
    a transcript written next to the implementation agrees with it by
    construction and proves nothing.

  - src/sse.ts — the consumer half that never got built: an incremental
    SSEDecoder, connectResourceStream (fetch, for bearer tokens) and
    connectWithEventSource (cookies, the v1 baseline). The TS suite now replays
    the golden bytes ONE BYTE AT A TIME — a frame split down the middle is what a
    real network does under exactly the load where you least want to debug it.

And `task e2e-wire`: a real Go gateway on a real socket, read by the real client
over real fetch, 11 fixtures, no cluster. cmd/replay serves the corpus as SSE, so
it is also a cluster you can point a browser at.

The seam caught a genuine divergence the moment it was closed: `terminal` was
omitted from an error event when false (Go's omitempty), so a consumer had to
INFER "do I give up?" from a missing field. That is the same reasoning that made
redactedPaths mandatory, and the failure it prevents is worse — EventSource
reconnects on its own, so a misread terminal error means every open tab hammering
a forbidden scope forever. The field is now always present. Both sides had been
"passing" their tests for a week.

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

Proposal 0001 (docs/proposals/0001-watch-ops.md), grounded in a full read of the
Kubernetes API concepts page, summarized with citations in
docs/facts/kubernetes-api-concepts.md. That file exists because spec/v1.md and
gateway/README.md made claims about the watch API that were written from memory,
and two of them were wrong.

The corpus could not express three of the gateway's own MUST NOTs — proved by
mutation: "emit synced on every bookmark" and "forward a partial object" both
left all 11 fixtures green. So `watch:` grows three ops, each a documented
Kubernetes behaviour:

  bookmark   a routine BOOKMARK. Kubernetes, verbatim: its object "only includes
             a .metadata.resourceVersion field". An object with no uid, no name,
             no spec and no status is not an edge case — it is on EVERY stream
             that asked for bookmarks, and we must ask, because that is how the
             snapshot boundary arrives at all.
  partial    PartialObjectMetadata delivered as an upsert.
  tombstone  a DELETED that lost its identity (DeletedFinalStateUnknown).

Two real bugs fell out:

1. resourceVersion OVERFLOW. Kubernetes: "compared as arbitrary bitsize decimal
   integers... the bitsize must not be assumed"; its own example is 40 digits.
   We compared with strconv.ParseInt — int64, 19 digits. On such a cluster the
   parse fails, the monotonicity check gives up, and live updates are SILENTLY
   DROPPED — a symptom indistinguishable from "Kubernetes is slow", which is how
   a bug like this survives for years. Now compared as the docs prescribe:
   longer-is-greater, then lexicographic. A plain lexicographic compare is also
   wrong ("9" > "10"), and an extension API server's non-decimal resourceVersion
   cannot be ordered at all — so it now drops nothing rather than guessing.

2. The partial-object guard checked the WRONG FIELD. It asked "does it have a
   uid?" — but a PartialObjectMetadata has a complete metadata block, uid
   included; what it lacks is spec and status. The guard waved it through, and a
   consumer whose model is REPLACE would have swapped a live Deployment for a
   husk: status blank, the user's spec gone. The honest test is the kind.

spec §6's non-normative note has been corrected — it said "monotonically
increasing integer", which invites exactly the ParseInt that bit us. The
normative rule (resourceVersion is opaque to consumers) is unchanged, and no wire
change: all three ops describe things the gateway must absorb or refuse, so the
correct behaviour emits FEWER events, never different ones.

Four new fixtures. All four catch their mutation; the corpus can now go red for
every rule it claims to defend.

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

`task e2e-browser` (and `task demo`, which is the same thing at human speed).

This is the only test of the constraint the whole library is designed around:
that the published ESM imports in a browser with NO BUNDLER. Node importing
dist/index.js proved nothing — Node is not a browser. Here Chromium fetches
index.js, which imports ./store.js, which imports ./merge.js, and one bare
specifier or one missing extension would leave the page blank.

Three more things only a browser can prove, and all three are now asserted:

  - native EventSource works at all. It is the same-origin session-cookie path
    and the v1 baseline (spec §7); fetch cannot test it, being a different
    transport.
  - a read-only region actually FLASHES. "Read-only is not ignored" is the
    product thesis, and it is a DOM fact, not an array of paths.
  - a user can type into a field while the server changes the object underneath
    them, and keep what they typed — the three-way merge, from the only end that
    matters.

Six specs: the bundler-free import, status-flashes-while-you-edit, a conflict
raised and then clearing on convergence, an unrelated change not disturbing an
edit, a masked Secret with no input to type into, and an absent named object
rendering empty rather than as a ghost. Mutation-checked: deleting the flash from
applyServerEvent turns the status test red.

cmd/replay now serves the example and the built ESM from the SAME ORIGIN as the
stream. Not for convenience — same-origin is the deployment the protocol is
specified around, and a demo on a second port would need CORS and would then be
proving something we do not ship.

Playwright lives in the example's own package, so packages/krm-stream keeps its
three devDependencies and its zero-dependency promise intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each rung replays the same fixtures with one more thing really true. Also states
the rule that actually earns the suite its keep: a green test proves nothing
until you have watched it go red.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… say so when the upstream lies

You were right that I under-read the page. That "may not parse as a decimal"
caveat is the PRE-1.35 world, and it is scoped to extension/aggregated API
servers. The sentence that actually governs the design is:

  "Starting with Kubernetes 1.35, orderability of resource versions for all
   Kubernetes types is included in Certified Kubernetes requirements. Base API
   objects AND custom resources MUST be orderable as a monotonically increasing
   integer for any 1.35+ APIServer implementation in order to pass conformance
   tests."

So on a supported cluster an unorderable resourceVersion cannot occur — and my
"return 0, drop nothing" fallback silently degraded on EVERY cluster to
accommodate a case a conformant one cannot produce. Per-object monotonicity is a
promise the protocol makes to consumers (§6); quietly ceasing to keep it is worse
than refusing.

Gateway.Ordering (ResourceVersionOrdering):

  OrderingStrict (default, zero value) — this library targets 1.35+. Every
    resourceVersion must be an orderable decimal. One that is not means the
    upstream is not what we were told it is, so: a TERMINAL INTERNAL error that
    NAMES THE ESCAPE HATCH, rather than a silent loss of the guarantee.

  OrderingLenient — for the two cases the docs do carve out, and both are real: a
    cluster older than 1.35, and an aggregated/extension API server, which is a
    third-party implementation the conformance test does not cover. There ordering
    is genuinely undefined, so we do not pretend to it and we DROP NOTHING: a
    duplicate is harmless (apply is idempotent by construction), a wrongly-dropped
    update is data loss.

New fixture resourceversion-unorderable pins the refusal — note it lands AFTER
`reset`, because the gateway only learns what the upstream's resource versions
look like when the first object arrives. That exposed a gap in both framing
tests, which assumed a stream may only end mid-cycle by dying: a TERMINAL error is
by definition the last event on the connection (spec §4.3) and may perfectly well
arrive mid-snapshot. Both loaders now say so, and both now also assert that
nothing follows a terminal error.

Also: five mermaid diagrams — the system as a whole and the snapshot cycle
(README), the test ladder (CONTRIBUTING), and the two that are genuinely hard to
hold in your head as prose (docs/client-state-model): the two layers of
applyServerEvent, and the per-path merge decision, where every branch turns on the
one question the naive implementations never ask — did the SERVER move this? All
five validated against the real mermaid parser in Chromium, which caught one that
GitHub would have rendered as a red error box (semicolons are statement separators
in a sequenceDiagram).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on a real aggregated API

Two corrections, and the second was a real honesty bug in the corpus.

1. The README now REQUIRES Kubernetes 1.35+, and says why. From 1.35,
   resourceVersion orderability is a Certified Kubernetes conformance requirement
   — for base objects AND custom resources — and that is what lets the gateway
   promise a browser something no naive watch relay can: per-object monotonicity.
   It is what makes coalescing safe (a controller rewriting status at 200Hz NEEDS
   coalescing) and what lets a stale replay after a relist be dropped instead of
   flickering the UI backwards. Ordering is a real dependency, so Gateway.Ordering
   is a documented setting rather than an implementation detail.

2. The resourceVersion fixtures used a ConfigMap, and that was teaching a lie.
   They now use a Flunder — wardle.example.com/v1alpha1, Kubernetes' own
   sample-apiserver, the same aggregated API that gitops-reverser's e2e already
   stands up.

   Because on a conformant 1.35 cluster:

     - a ConfigMap, a Deployment or a CRD CANNOT serve an unorderable
       resourceVersion. Orderability is a conformance requirement. A fixture using
       one was defending against a scenario that cannot happen.
     - kube-apiserver's resourceVersion is an etcd revision: an int64, 19 digits.
       You will never meet the docs' 40-digit example there either. A different
       backing store is where such a value actually comes from.
     - an AGGREGATED / extension API server is a third-party implementation the
       conformance test does not cover, and is the only case the docs' equality-only
       carve-out is written for. It is the honest home for both fixtures — and
       therefore the honest home of OrderingLenient.

   A fixture that teaches a real rule with an impossible example is worse than no
   fixture: it makes the reader trust a mental model that will mislead them the
   next time they meet the real thing.

Both fixtures mutation-checked against a Flunder body: a plain lexicographic
compare (the naive fix once you learn int64 overflows — it reads 41 digits as
OLDER than 40) fails resourceversion-bignum, and a strict mode that degrades
silently instead of refusing fails resourceversion-unorderable.

external/ is gitignored: it is a sibling repo checked out for reference, not ours.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Facts first, backend second. Everything the gateway believes about Kubernetes is
currently unverified — most importantly that a streaming list's terminating
bookmark really carries k8s.io/initial-events-end, which is the snapshot boundary
and appears nowhere in the API docs.

So the first deliverable is a fact-finder that writes its answers into
docs/facts/, not a test suite. Finding out that assumption is false AFTER building
gateway/kube around it would be the expensive way round.

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

Phase B of proposal 0002. `task cluster-up` (k3d, k3s v1.36.2, --cluster-init so
the store is real etcd) and `task cluster-facts` — a witness, not a test suite. It
opens real watches, records what actually arrived, and writes the answers into
docs/facts/observed-<version>.md, stamped with a cluster version.

client-go lives in its own module (gateway/kube), so `go get .../gateway` is
still zero-dependency: the protocol core stays clean and the Kubernetes adapter is
opt-in. Verified — the core go.mod has no require block at all.

WHAT THE CLUSTER SAID

F1 ✅ CONFIRMED, and it was the single load-bearing assumption in the gateway: a
   streaming list really does end with a BOOKMARK carrying
   k8s.io/initial-events-end="true". That annotation appears NOWHERE in the API
   documentation. If it had been wrong, `synced` would fire at the wrong moment —
   or never, and the browser would never paint. It was faith; now it is a fact.

   The docs are also slightly wrong in a way we only learned by looking: they say a
   bookmark's object "only includes a .metadata.resourceVersion field", but the real
   one also carries metadata.annotations (it must — that is where the marker lives).
   What it does NOT carry is a uid, which is what the partial-object guard actually
   keys on. The guard is right, for a reason one shade more precise than the
   sentence it was written from.

F3 ✅ a 410 arrives on an open watch as watch.Error, code=410 reason="Expired",
   message "too old resource version: 1 (2)".
F4 ✅ resourceVersions are orderable decimals (4 digits here — an int64 parse would
   have worked, which is exactly the trap: it works until it doesn't).
F5 ✅ a real Deployment controller emits status-only MODIFIEDs — 5 of them, spec
   byte-identical every time, readyReplicas climbing to the requested count. The
   product's headline is real traffic.
F7 ✅ a kube-apiserver DELETED is complete and carries a uid. The degenerate
   tombstone we guard against comes from client-go's INFORMER, not the API server —
   which is why that fixture can only ever be a fake-watch one.

F6 ❗ AND THIS ONE CHANGES THE DESIGN. Pointed at a real aggregated API (Kubernetes'
   own sample-apiserver — the wardle Flunder our resourceVersion fixtures already
   model), the streaming list of gateway/README §3a is REFUSED outright:

     sendInitialEvents is forbidden for watch unless the WatchList feature gate is
     enabled

   An aggregated API server is a separate binary with its own feature gates.
   WatchList being on in kube-apiserver says nothing about it. So §3b's
   list-then-watch is NOT "a fallback for old clusters" — it is the only way to
   serve an aggregated API on a current one, and a gateway implementing only §3a
   cannot open a stream for a Flunder at all. §3a and §3b are corrected to say so.

   Its resourceVersions also live in their own space, starting at ~1, in an
   ephemeral sidecar etcd — so they can go BACKWARDS across a restart. Per-object
   monotonicity survives that only because the high-water map is per-CYCLE rather
   than per-stream. That decision was made for a different reason; this is the
   cluster confirming it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase A of proposal 0002. `gateway/kube` is the only code here that knows what an API server is,
and it stays a separate module so `go get .../krm-stream/gateway` remains genuinely
zero-dependency: the protocol is the product, and client-go is opt-in.

It ships BOTH paths, because the cluster proved that is not a luxury. F6 pointed the §3a streaming
list at a real aggregated API — Kubernetes' own sample-apiserver — and it was refused outright
("sendInitialEvents is forbidden ... unless the WatchList feature gate is enabled"). An aggregated
API server is a separate binary with its own feature gates, so §3b's list-then-watch is not a
fallback for old clusters: it is the ONLY way to serve an aggregated API on a current one. A gateway
implementing only §3a could not open a stream for a Flunder at all — a bug a user would have found,
in their cluster, and we would not.

So the backend DETECTS rather than configures: nobody should have to know which of their APIs is
aggregated in order to watch it. The refusal is remembered per GroupVersion (it is a fact about a
server binary, not about a request), and it is strictly the sendInitialEvents refusal that triggers
the fallback — a Forbidden on Secrets must stay a Forbidden on Secrets, not become a second,
differently-worded denial.

What both paths have in common is the only thing the stream loop cares about: added* terminated by a
bookmark whose InitialEventsEnd is set. On §3a the API server hands us that boundary; on §3b we
synthesize it. That is exactly why the protocol names the boundary and not the mechanism.

Tested three ways, because a stub is a thing we wrote and it will agree with us:

  - 8 unit tests against a stubbed dynamic client — chosen over client-go's fake, whose watch reactor
    cannot see SendInitialEvents at all, and the exact ListOptions ARE the thing under test;
  - both load-bearing behaviours mutation-checked (drop the refusal detection, or open the fallback
    watch at the freshest rv instead of the list's, and the suite goes red);
  - `task test-cluster`: the REAL stream loop over the REAL backend against a REAL API server, on
    both paths. The aggregated test first asserts the API still REFUSES §3a, so a cluster that
    quietly gains WatchList cannot make it pass for the wrong reason, and then asserts the gateway
    serves the Flunder anyway.

Two things fell out on the way:

  - the kube module was in NO ci job and no task — a separate module is exactly what `go test ./...`
    does not cross, so the adapter would have shipped untested. Now gated, with the e2e tag vetted
    per-PR even though the suite itself runs outside CI: a test suite that has quietly stopped
    compiling is one nobody notices has stopped running.
  - gosec found the fact-finder building a file path out of a string the SERVER chose (GitVersion).
    A cluster we do not own should not get to pick where we write. Sanitized.

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

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change adds the Go gateway, Kubernetes adapter, TypeScript live store and SSE client, conformance fixtures, replay and browser demos, authorization seams, real-cluster checks, and reusable CI/release workflows.

Changes

Live stream and conformance stack

Layer / File(s) Summary
Gateway contracts, scope handling, and protocol rules
gateway/*.go, spec/*, conformance/*
Defines gateway seams, HTTP scope validation, terminal and recoverable errors, projections, redaction metadata, resourceVersion ordering, and watch-operation fixtures.
Gateway streaming, projection, replay, and sharing
gateway/stream.go, gateway/project.go, gateway/sse.go, gateway/scripted.go, gateway/shared.go, gateway/cmd/replay/*
Implements snapshot cycles, authorization refresh, projection/redaction, monotonic event handling, SSE output, scripted replay, pacing, reconnect segmentation, and shared upstream watches.
Kubernetes adapter and cluster validation
gateway/kube/*, test/cluster/*, Taskfile.yml
Adds streaming-list and list-then-watch support, Kubernetes RBAC authorization, fact collection, k3d cluster tasks, and real API integration tests.
TypeScript client state and transport
packages/krm-stream/src/*, packages/krm-stream-compat/*
Adds deep/path utilities, editability policies, three-way reconciliation, keyed-list support, live resource state, SSE decoding/connectors, patch generation, URL/version helpers, and package exports.
Conformance, browser, and delivery validation
packages/krm-stream/test/*, packages/krm-stream/e2e/*, examples/vanilla-browser/*, .github/workflows/*
Adds client, wire, browser, packaging, release, security-analysis, replay, lint, and end-to-end validation workflows.
Documentation and development configuration
README.md, CONTRIBUTING.md, docs/*, .gitignore, .devcontainer/*, .github/dependabot.yml, release-please-config.json
Documents the test ladder, authentication, Kubernetes facts, client state model, release process, and repository development configuration.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing a KRM resource streaming library.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/live-resource-store

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.

sunib and others added 3 commits July 13, 2026 07:41
A server could ACCEPT sendInitialEvents and then quietly ignore it — no synthetic ADDEDs, no
terminating bookmark, so `synced` never fires and a browser never paints. There is no honest guard:
the only one available is a timeout, and "no bookmark in N seconds" makes N a guess that turns a slow
cluster into a corrupt one.

What we have instead is a stated environment (Kubernetes 1.35+), where the option is not silently
droppable. A server that accepts an option and ignores it is broken in a way that is not ours to
paper over — and the right answer to a broken upstream is to be diagnosable, not to guess. Say so in
the package doc rather than leave the next reader to wonder whether we forgot.

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

The browser rung failed on its first-ever CI run, and the reason is worse than a broken build.

Playwright's webServer rebuilds the library before serving it (a stale dist is a test of last week's
library). It did that with a bare `npx tsc`, from a directory that on a CLEAN CHECKOUT has no
node_modules. Bare npx does not fail there. It goes to the registry, downloads whatever is published
under the name `tsc` — a squatter package, not TypeScript — and RUNS it:

    [WebServer] npm warn exec The following package was not found and will be installed: tsc@2.0.4
    [WebServer]     This is not the tsc command you are looking for

So the job did not merely fail to build; it executed an unrelated package off the internet and then
failed to build. It announced itself only because the squatter happens to exit non-zero. A useful one
would have been silent.

This survived because on a developer's machine packages/krm-stream/node_modules is already there —
`task test` puts it there — and because CI ran on main only, where the browser rung does not exist
yet. The first clean checkout to run it found it, which is the argument for opening the PR early.

Two fixes, and the first is the one that matters:

  - `npx --no-install` everywhere a build shells out to a tool, so a missing dependency is LOUD
    rather than resolved from the network. The repo already used --no-install in lint-client; the
    playwright config and build-client are simply where the convention had a hole.
  - `task e2e-browser` now depends on build-client, so the library's devDependencies are actually
    installed before the webServer tries to compile it.

Verified by reproducing CI's state rather than trusting the diff: rm -rf packages/krm-stream/
{node_modules,dist} && task e2e-browser → 6 passed.

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

Caught in review, reproduced, and it is the worst kind of bug: every test in this repo passed while
`go get github.com/ConfigButler/krm-stream/gateway/kube` was broken for literally everyone else.

gateway/kube/go.mod carried `require .../gateway v0.0.0` plus `replace ... => ../`. A replace applies
ONLY to the main module — a consumer ignores it, resolves the core module at the literal version in
the require line, and gets:

    reading github.com/ConfigButler/krm-stream/gateway/go.mod
      at revision gateway/v0.0.0: unknown revision gateway/v0.0.0

The core module resolves fine. It is precisely the Kubernetes adapter — the one thing an adopter with
a real cluster actually needs — that could not be fetched. Reproduced from a clean throwaway module
before changing anything, because a bug you cannot reproduce is a bug you cannot claim to have fixed.

The fix is the standard one:

  - the require now names a REAL, resolvable version of the core module (a pseudo-version today; it
    becomes gateway/v0.1.0 the moment we tag);
  - go.work at the root does locally what the replace was doing — and consumers never see a go.work,
    which is the entire difference;
  - a CI job, `a stranger can go get this`, builds from a clean module in a temp directory OUTSIDE
    the workspace, resolving over the network exactly as an adopter would.

That last part is the real repair. Nothing INSIDE this repository could ever have caught this: the
replace hid it from every test we have, by construction. A test that lives in the same tree as the
bug it is meant to catch is not a test. The same job also guards the other half of the promise — that
the core module keeps an empty require block, so `go get .../gateway` stays the zero-dependency
protocol and does not quietly start costing what client-go costs.

Also, per review: the sequence diagram now reads left-to-right from the BROWSER, not from Kubernetes.
The reader of that diagram is a frontend developer deciding whether this library is for them, and
starting at the API server tells the story backwards from where they stand.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (12)
packages/krm-stream/src/store.ts-174-183 (1)

174-183: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reject renaming onto an existing key.

If newKey already exists, object reconstruction silently overwrites either the renamed value or the destination value depending on insertion order.

Proposed guard
     const map = get(res.draft, path);
     if (!isPlainObject(map)) throw new Error(`krm-stream: ${pathKey(path)} is not a map`);
+    if (!Object.hasOwn(map, oldKey)) throw new Error(`krm-stream: key ${JSON.stringify(oldKey)} does not exist`);
+    if (oldKey !== newKey && Object.hasOwn(map, newKey)) {
+      throw new Error(`krm-stream: key ${JSON.stringify(newKey)} already exists`);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/krm-stream/src/store.ts` around lines 174 - 183, Update renameKey to
validate that newKey does not already exist in the map before reconstructing or
mutating it; reject the operation with an error when the destination key exists,
while preserving the current rename behavior for absent destinations.
docs/facts/observed-v1.36.2+k3s1.md-15-15 (1)

15-15: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the generated Flunder resourceVersion contradiction.

The table and F6 result report "4", while Line 56 says "2". Update the facts generator so this witness consistently records the observed value.

Also applies to: 46-46, 56-57

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/facts/observed-v1.36.2`+k3s1.md at line 15, Update the facts generator
for the F6 Flunder resourceVersion witness so every generated reference
consistently records the observed value “4”, including the table entry and the
lines currently reporting “2”. Use the existing Flunder witness-generation
symbols and preserve the surrounding streaming-list result unchanged.
gateway/kube/e2e_test.go-72-85 (1)

72-85: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Create a genuinely unique scratch namespace.

The deterministic name can survive an interrupted run or still be terminating. Accepting AlreadyExists then reuses stale state and recreates the teardown race this helper intends to prevent.

Proposed fix
 func scratchNamespace(t *testing.T, cs kubernetes.Interface) string {
 	t.Helper()
-	ns := "krm-stream-e2e-" + strings.ToLower(strings.NewReplacer("/", "-", "_", "-").Replace(t.Name()))
+	prefix := "krm-stream-e2e-" + strings.ToLower(strings.NewReplacer("/", "-", "_", "-").Replace(t.Name())) + "-"
 	ctx := context.Background()
-	_, err := cs.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{
-		ObjectMeta: metav1.ObjectMeta{Name: ns},
+	created, err := cs.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{GenerateName: prefix},
 	}, metav1.CreateOptions{})
-	if err != nil && !apierrors.IsAlreadyExists(err) {
-		t.Fatalf("create namespace %s: %v", ns, err)
+	if err != nil {
+		t.Fatalf("create namespace with prefix %s: %v", prefix, err)
 	}
+	ns := created.Name
 	t.Cleanup(func() {
 		_ = cs.CoreV1().Namespaces().Delete(context.Background(), ns, metav1.DeleteOptions{})
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/kube/e2e_test.go` around lines 72 - 85, Update scratchNamespace to
generate a genuinely unique namespace name for each test run, rather than
deriving it solely from t.Name(). Do not accept AlreadyExists as success: create
the generated namespace and fail on any creation error, while preserving the
existing cleanup behavior.
gateway/kube/go.mod-11-17 (1)

11-17: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Bump the Go toolchain pin go 1.26.0 is already behind the 1.26.x patch line; use the latest 1.26.x release so the build picks up the known security fixes. k8s.io/client-go v0.36.0 matches Kubernetes v1.36.0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/kube/go.mod` around lines 11 - 17, Update the Go version directive in
go.mod from 1.26.0 to the latest available 1.26.x patch release, leaving the
Kubernetes dependency versions unchanged.
packages/krm-stream/src/index.ts-7-7 (1)

7-7: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stale “not built yet” note.

Line 7 contradicts the implemented export at Line 22.

Proposed fix
-//   connectResourceStream  a conforming SSE consumer that feeds a store.  (not built yet)
+//   connectResourceStream  a conforming SSE consumer that feeds a store.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/krm-stream/src/index.ts` at line 7, Remove the stale “not built yet”
wording from the connectResourceStream comment, keeping the existing description
aligned with the implemented export.
conformance/README.md-100-100 (1)

100-100: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Routine bookmarks are permitted, not guaranteed.

Kubernetes does not promise a bookmark on every watch that requests them. The object may also contain type metadata, so “only metadata.resourceVersion” is too strict. Document that routine bookmarks may arrive and must be absorbed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@conformance/README.md` at line 100, Update the bookmark row in the
conformance documentation to state that routine bookmarks may arrive but are not
guaranteed, and that bookmark objects can include type metadata alongside
metadata.resourceVersion. Preserve the requirement to absorb bookmarks without
forwarding them or treating them as synced.
docs/proposals/0002-real-cluster.md-45-82 (1)

45-82: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the proposal body to match its landed status.

The table still marks F1–F6 as unverified, and Phase A says client-go and the backend do not exist, contradicting Lines 3–27. Mark the observed results and rewrite this section as the landed design—or label the remainder explicitly as historical.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/proposals/0002-real-cluster.md` around lines 45 - 82, Update
docs/proposals/0002-real-cluster.md to reflect the landed implementation:
replace the unverified F1–F6 statuses with the observed results, and revise
Phase A to describe the existing client-go KubeBackend and Watch adapter rather
than proposing them as missing work. Preserve historical proposal context only
if clearly labeled as historical.
docs/client-state-model.md-223-224 (1)

223-224: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the array description with the algorithm below.

Arrays are not always atomic here: Lines 236–240 recurse positionally when clean and equal-length, then fall back to atomic handling on dirtiness or length changes. Either qualify this sentence or change the algorithm to be fully atomic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/client-state-model.md` around lines 223 - 224, The array description in
section 4.1 conflicts with the positional recursion described in the algorithm.
Update the sentence near the array/scalar branch to qualify that arrays are
atomic only when dirty or their lengths differ, while clean equal-length arrays
recurse positionally; keep the existing algorithm unchanged.
conformance/README.md-87-102 (1)

87-102: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not characterize every fixture operation as API-server behavior.

disconnect is an SSE/browser condition, while DeletedFinalStateUnknown is informer-cache behavior rather than an API-server watch event. Reword the introduction to say these operations model conditions handled across the gateway pipeline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@conformance/README.md` around lines 87 - 102, The introduction before the
operation table overstates that every fixture operation is direct API-server
behavior. Reword the paragraph to describe the operations as modeling conditions
handled across the gateway pipeline, while retaining the existing references to
Kubernetes API concepts and the distinction from assumptions based on memory.
packages/krm-stream/src/merge.ts-69-74 (1)

69-74: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

mergeContainer mishandles non-object theirs when base or ours is an object.

When theirs is not a plain object (e.g., null, a string, or a number) but base or ours is, the condition on line 70 evaluates false and the function recurses. recurse then iterates only keys from base/ours (since unionKeys skips non-objects), producing {} or a partial object instead of the server's actual value. On save, the patch would send {} where the server has null, potentially creating an unwanted empty object.

The fix is to follow the server whenever theirs is not a plain object — a container with no object from the server has no editable children to recurse into.

🐛 Proposed fix for mergeContainer
 function mergeContainer(s: MergeState, path: Path, base: unknown, ours: unknown, theirs: unknown): unknown {
-  if (!isPlainObject(base) && !isPlainObject(ours) && !isPlainObject(theirs)) {
-    return follow(s, path, base, theirs);
-  }
+  if (!isPlainObject(theirs)) return follow(s, path, base, theirs);
   return recurse(s, path, base, ours, theirs);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/krm-stream/src/merge.ts` around lines 69 - 74, Update mergeContainer
so it immediately follows the server value via follow when theirs is not a plain
object, regardless of whether base or ours are objects; only recurse when theirs
is a plain object. Preserve the existing follow arguments and recursive merge
behavior for object-valued server containers.
docs/facts/kubernetes-api-concepts.md-268-268 (1)

268-268: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Distinguish routine bookmarks from the snapshot-boundary bookmark.

This row says nothing may depend on bookmark arrival, while Lines 142–157 make the requested initial-events bookmark the snapshot boundary. Qualify this as applying to routine bookmarks; otherwise the summary contradicts the protocol design.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/facts/kubernetes-api-concepts.md` at line 268, Update the row describing
bookmark arrival in the Kubernetes API concepts table to explicitly scope the
restriction to routine bookmarks, while preserving the terminating
initial-events bookmark as the snapshot boundary defined in the surrounding
protocol discussion.
.github/workflows/ci.yml-193-210 (1)

193-210: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Set persist-credentials: false on checkouts that don't push (esp. the artifact-uploading browser job).

This job persists the GITHUB_TOKEN in .git/config and later uploads test-results/ as an artifact on failure, which is the exfiltration pattern zizmor flags (artipacked). None of the new jobs push, so credential persistence is unnecessary here and on the other checkouts (Lines 59, 88, 171).

🔒 Suggested change
       - uses: actions/checkout@v6
+        with:
+          persist-credentials: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 193 - 210, Update every checkout step
in the CI workflow, including the browser job checkout near setup-go/setup-node
and the other checkouts referenced at lines 59, 88, and 171, to set
actions/checkout’s persist-credentials option to false. Keep the existing
checkout behavior otherwise unchanged.

Source: Linters/SAST tools

🧹 Nitpick comments (1)
packages/krm-stream/e2e/wire.ts (1)

70-99: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Make the socket test observe incremental delivery.

A server or proxy buffering every frame until EOF would still pass these assertions. Add a gated second frame and assert the first callback occurs before release/closure; also fragment a frame across writes to exercise chunk boundaries.

Also applies to: 108-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/krm-stream/e2e/wire.ts` around lines 70 - 99, Add
incremental-delivery coverage to the socket test around connectResourceStream
and handle.closed: make the stream emit a second frame behind a controllable
gate, assert the first callback occurs before the gate is released or the
connection closes, and release it only afterward. Fragment at least one frame
across multiple writes so parsing is exercised across chunk boundaries, while
preserving the existing fixture assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 104-133: Update the clean-module go get gate to avoid resolving
fork-only head SHAs from the canonical module path: skip this step for pull
requests whose head repository differs from the base repository, or use the pull
request’s merge commit instead. Preserve the existing validation for
same-repository pull requests and push events.

In `@examples/vanilla-browser/index.html`:
- Around line 143-147: Update the onInput handlers in the editable-value flow to
preserve the original JSON type for arrays, booleans, null, and numbers instead
of storing non-numeric input as strings. Use the existing value and parsing
logic around store.setValue(id, path, next) to convert valid edits back to the
corresponding original type, while preserving the current behavior for text
values and empty or invalid input.

In `@gateway/kube/cmd/facts/main.go`:
- Line 61: Update the namespace lifecycle around the namespace flag and its
creation/cleanup logic: do not accept an AlreadyExists result for the configured
namespace, instead fail on collision or generate a unique namespace name.
Register deferred deletion only after this process successfully creates the
namespace, ensuring pre-existing namespaces and their contents are never
deleted.
- Around line 271-280: Update the watch.Error handling in the facts check to set
f.OK=true only when status.Code is 410 or status.Reason is Expired or Gone. Keep
the existing nil-status response and diagnostic details, but mark unrelated
authorization or server errors as not OK while still returning the finding.
- Around line 489-512: Update the F6 detail construction in the facts command to
derive its narrative from the observed werr, rv, and bookmark values instead of
using fixed claims. Ensure acceptance or rejection of streaming lists, the
returned resourceVersion, and bookmark/reset observations are described
accurately and remain consistent with f.OK and f.Answer.
- Around line 348-353: Update the deployment creation and watch flow around
Deployments(ns).Create and the subsequent Watch call to retain the created
Deployment object. Use its resourceVersion in metav1.ListOptions when starting
the watch, and use its spec as the baseline for validation; preserve existing
AlreadyExists handling while ensuring the watch begins at the creation version.
- Around line 194-207: Update the annotation validation in the finding logic
around InitialEventsAnnotationKey so it only succeeds when the annotation is
present and its value is exactly "true". Treat missing or differently valued
annotations as failure, preserving the existing diagnostic response and bookmark
shape reporting for that path.

In `@gateway/seams.go`:
- Around line 53-59: Update the ClientFor type to accept a context.Context
parameter and propagate that context through every implementation and call site
involved in target resolution or token exchange, allowing cancellation and
deadlines to stop backend acquisition. Preserve the existing target and
principal arguments and Backend/error return contract.

In `@gateway/sse.go`:
- Around line 121-128: Ensure the handler waits for the goroutine started by
Heartbeat before returning: add synchronization around sink.Heartbeat, cancel
its context, and wait for completion after g.Stream returns. Update the defer
flow around stopHeartbeat so the ResponseWriter is never accessed after the
handler exits.

In `@gateway/stream_conformance_test.go`:
- Around line 78-99: Update the stream verification around gw.Stream and
backend.Exhausted so the final connection of a terminal fixture does not pass
merely because Exhausted closes. Track whether this is the last split connection
and fail when it reaches Exhausted, while preserving cancellation and normal
completion checks for earlier connections.

In `@gateway/stream.go`:
- Around line 208-222: Update the WatchDeleted handling around identityOf and
emitted so deletion events pass through isStale using the tombstone
resourceVersion, ignoring stale deletes without emitting or removing state. For
accepted deletions, retain the deletion resourceVersion as the high-water mark
for the rest of the cycle instead of unconditionally deleting emitted[id.UID],
preventing older replays from being accepted.

In `@packages/krm-stream/src/sse.ts`:
- Around line 125-128: Update connectResourceStream and the other stream
connection paths around the referenced signal handling to check
opts.signal.aborted before invoking fetch or constructing EventSource. Abort the
internal controller and prevent stream establishment when the caller’s signal is
already aborted, while preserving the existing one-time abort listener for
signals that abort later.
- Around line 30-39: Update the buffer normalization in the SSE stream
processing loop before frame separator detection so a trailing bare \r at the
end of the current chunk remains buffered until the next chunk arrives, while
still normalizing complete \r\n and standalone \r line endings. Preserve correct
\n\n frame detection and multi-line data event handling in the logic around the
buffer and parseFrame.

In `@packages/krm-stream/src/store.ts`:
- Around line 243-248: Update isEditable and the related editability handling
around the code at lines 278–295 so paths that are ancestors of any redacted
path are also treated as non-editable. Ensure checking `/data` is blocked when
`/data/token` is redacted, preventing edits or generated patches from
overwriting hidden descendants while preserving editability for unrelated paths.
- Around line 134-147: Update adoptSaved to preserve edits made after the save
request was submitted: compare the current draft with the submitted snapshot and
only adopt server values for fields unchanged since submission, retaining newer
local edits and recalculating conflicts accordingly. Use the existing
save-submission state or snapshot mechanism rather than overwriting
existing.draft unconditionally.

---

Minor comments:
In @.github/workflows/ci.yml:
- Around line 193-210: Update every checkout step in the CI workflow, including
the browser job checkout near setup-go/setup-node and the other checkouts
referenced at lines 59, 88, and 171, to set actions/checkout’s
persist-credentials option to false. Keep the existing checkout behavior
otherwise unchanged.

In `@conformance/README.md`:
- Line 100: Update the bookmark row in the conformance documentation to state
that routine bookmarks may arrive but are not guaranteed, and that bookmark
objects can include type metadata alongside metadata.resourceVersion. Preserve
the requirement to absorb bookmarks without forwarding them or treating them as
synced.
- Around line 87-102: The introduction before the operation table overstates
that every fixture operation is direct API-server behavior. Reword the paragraph
to describe the operations as modeling conditions handled across the gateway
pipeline, while retaining the existing references to Kubernetes API concepts and
the distinction from assumptions based on memory.

In `@docs/client-state-model.md`:
- Around line 223-224: The array description in section 4.1 conflicts with the
positional recursion described in the algorithm. Update the sentence near the
array/scalar branch to qualify that arrays are atomic only when dirty or their
lengths differ, while clean equal-length arrays recurse positionally; keep the
existing algorithm unchanged.

In `@docs/facts/kubernetes-api-concepts.md`:
- Line 268: Update the row describing bookmark arrival in the Kubernetes API
concepts table to explicitly scope the restriction to routine bookmarks, while
preserving the terminating initial-events bookmark as the snapshot boundary
defined in the surrounding protocol discussion.

In `@docs/facts/observed-v1.36.2`+k3s1.md:
- Line 15: Update the facts generator for the F6 Flunder resourceVersion witness
so every generated reference consistently records the observed value “4”,
including the table entry and the lines currently reporting “2”. Use the
existing Flunder witness-generation symbols and preserve the surrounding
streaming-list result unchanged.

In `@docs/proposals/0002-real-cluster.md`:
- Around line 45-82: Update docs/proposals/0002-real-cluster.md to reflect the
landed implementation: replace the unverified F1–F6 statuses with the observed
results, and revise Phase A to describe the existing client-go KubeBackend and
Watch adapter rather than proposing them as missing work. Preserve historical
proposal context only if clearly labeled as historical.

In `@gateway/kube/e2e_test.go`:
- Around line 72-85: Update scratchNamespace to generate a genuinely unique
namespace name for each test run, rather than deriving it solely from t.Name().
Do not accept AlreadyExists as success: create the generated namespace and fail
on any creation error, while preserving the existing cleanup behavior.

In `@gateway/kube/go.mod`:
- Around line 11-17: Update the Go version directive in go.mod from 1.26.0 to
the latest available 1.26.x patch release, leaving the Kubernetes dependency
versions unchanged.

In `@packages/krm-stream/src/index.ts`:
- Line 7: Remove the stale “not built yet” wording from the
connectResourceStream comment, keeping the existing description aligned with the
implemented export.

In `@packages/krm-stream/src/merge.ts`:
- Around line 69-74: Update mergeContainer so it immediately follows the server
value via follow when theirs is not a plain object, regardless of whether base
or ours are objects; only recurse when theirs is a plain object. Preserve the
existing follow arguments and recursive merge behavior for object-valued server
containers.

In `@packages/krm-stream/src/store.ts`:
- Around line 174-183: Update renameKey to validate that newKey does not already
exist in the map before reconstructing or mutating it; reject the operation with
an error when the destination key exists, while preserving the current rename
behavior for absent destinations.

---

Nitpick comments:
In `@packages/krm-stream/e2e/wire.ts`:
- Around line 70-99: Add incremental-delivery coverage to the socket test around
connectResourceStream and handle.closed: make the stream emit a second frame
behind a controllable gate, assert the first callback occurs before the gate is
released or the connection closes, and release it only afterward. Fragment at
least one frame across multiple writes so parsing is exercised across chunk
boundaries, while preserving the existing fixture assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ac113ca2-436b-4dc2-88d2-56968b108ad4

📥 Commits

Reviewing files that changed from the base of the PR and between 58f703e and 5299075.

⛔ Files ignored due to path filters (22)
  • conformance/gen/bodies.json is excluded by !**/gen/**
  • conformance/gen/fixtures.json is excluded by !**/gen/**
  • conformance/gen/sse/bookmark-absorbed.sse is excluded by !**/gen/**
  • conformance/gen/sse/conflict-and-converge.sse is excluded by !**/gen/**
  • conformance/gen/sse/delete-recreate-uid.sse is excluded by !**/gen/**
  • conformance/gen/sse/edit-vs-unrelated-change.sse is excluded by !**/gen/**
  • conformance/gen/sse/key-removed-upstream.sse is excluded by !**/gen/**
  • conformance/gen/sse/named-object-absent.sse is excluded by !**/gen/**
  • conformance/gen/sse/nested-field-removed.sse is excluded by !**/gen/**
  • conformance/gen/sse/partial-object-refused.sse is excluded by !**/gen/**
  • conformance/gen/sse/reconnect-prune.sse is excluded by !**/gen/**
  • conformance/gen/sse/resourceversion-bignum.sse is excluded by !**/gen/**
  • conformance/gen/sse/resourceversion-unorderable.sse is excluded by !**/gen/**
  • conformance/gen/sse/resync-midstream.sse is excluded by !**/gen/**
  • conformance/gen/sse/secret-redaction.sse is excluded by !**/gen/**
  • conformance/gen/sse/snapshot-then-deltas.sse is excluded by !**/gen/**
  • conformance/gen/sse/status-only-churn.sse is excluded by !**/gen/**
  • conformance/gen/sse/tombstone-without-uid.sse is excluded by !**/gen/**
  • examples/vanilla-browser/package-lock.json is excluded by !**/package-lock.json
  • gateway/kube/go.sum is excluded by !**/*.sum
  • go.work is excluded by !**/*.work
  • packages/krm-stream/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (63)
  • .github/workflows/ci.yml
  • .gitignore
  • CONTRIBUTING.md
  • README.md
  • Taskfile.yml
  • conformance/README.md
  • conformance/bodies/flunder.v1-bignum.yaml
  • conformance/bodies/flunder.v2-bignum-newer.yaml
  • conformance/bodies/flunder.v3-opaque.yaml
  • conformance/fixtures/bookmark-absorbed.yaml
  • conformance/fixtures/partial-object-refused.yaml
  • conformance/fixtures/resourceversion-bignum.yaml
  • conformance/fixtures/resourceversion-unorderable.yaml
  • conformance/fixtures/tombstone-without-uid.yaml
  • docs/client-state-model.md
  • docs/facts/kubernetes-api-concepts.md
  • docs/facts/observed-v1.36.2+k3s1.md
  • docs/proposals/0001-watch-ops.md
  • docs/proposals/0002-real-cluster.md
  • examples/vanilla-browser/README.md
  • examples/vanilla-browser/index.html
  • examples/vanilla-browser/package.json
  • examples/vanilla-browser/playwright.config.ts
  • examples/vanilla-browser/tests/live-krm.spec.ts
  • gateway/README.md
  • gateway/cmd/replay/main.go
  • gateway/conformance.go
  • gateway/conformance_test.go
  • gateway/event.go
  • gateway/golden_test.go
  • gateway/kube/backend.go
  • gateway/kube/backend_test.go
  • gateway/kube/cmd/facts/main.go
  • gateway/kube/e2e_test.go
  • gateway/kube/go.mod
  • gateway/project.go
  • gateway/scripted.go
  • gateway/seams.go
  • gateway/sse.go
  • gateway/stream.go
  • gateway/stream_conformance_test.go
  • gateway/stream_test.go
  • packages/krm-stream/biome.jsonc
  • packages/krm-stream/e2e/wire.ts
  • packages/krm-stream/package.json
  • packages/krm-stream/src/deep.ts
  • packages/krm-stream/src/index.ts
  • packages/krm-stream/src/merge.ts
  • packages/krm-stream/src/path.ts
  • packages/krm-stream/src/policy.ts
  • packages/krm-stream/src/sse.ts
  • packages/krm-stream/src/store.ts
  • packages/krm-stream/src/types.ts
  • packages/krm-stream/test/conformance.test.ts
  • packages/krm-stream/test/conformance.ts
  • packages/krm-stream/test/expect.ts
  • packages/krm-stream/test/invariants.test.ts
  • packages/krm-stream/test/store.test.ts
  • packages/krm-stream/test/wire.test.ts
  • packages/krm-stream/tsconfig.test.json
  • spec/v1.md
  • test/cluster/sample-apiserver/README.md
  • test/cluster/sample-apiserver/sample-apiserver.yaml

Comment thread .github/workflows/ci.yml
Comment thread examples/vanilla-browser/index.html Outdated
Comment thread gateway/kube/cmd/facts/main.go Outdated
Comment thread gateway/kube/cmd/facts/main.go
Comment thread gateway/kube/cmd/facts/main.go
Comment thread gateway/stream.go
Comment thread packages/krm-stream/src/sse.ts Outdated
Comment thread packages/krm-stream/src/sse.ts Outdated
Comment thread packages/krm-stream/src/store.ts
Comment thread packages/krm-stream/src/store.ts Outdated
sunib and others added 13 commits July 13, 2026 11:28
…write it never had

Owner's call, and the repo was quietly disagreeing with itself about it.

The corpus declared `gatewayRejects:` in secret-redaction.yaml. conformance.go parsed it into a
Reject struct. NOTHING read it. spec §10 made it gateway conformance rule 10 — "it rejects a save
that would write a redacted or projection-removed path" — and the README advertised that the library
"applies saves as a guarded patch (it will refuse one that touches a redacted path)".

None of that was true. There is no write path in this library, and there is not going to be one: a
change reaches the cluster through the Kubernetes API from the host's own server, as it always did.
krm-stream makes the current state streamable and mergeable; it is not a second, thinner API server.

So: the Reject struct, the fixture block, the gateway conformance rule and the README claim are gone.
A conformance rule the gateway cannot execute is not a rule, and a struct tag is not an
implementation.

The RULE did not disappear — it moved to where it can actually be enforced, and it is now stated far
more plainly than it was when it was pretending to be ours (spec §3):

  The endpoint that accepts a save MUST reject any patch touching a redacted or projection-removed
  path. The consumer never saw the real value of a masked field; it saw a mask. A patch carrying that
  mask back writes `••••••` OVER the real Secret, and the object is destroyed by a save that looked,
  from the browser, entirely ordinary.

And the part that is easy to get wrong, so it is now written down: a conforming client will not
produce such a patch — `patch()` never emits a redacted path, and the browser suite proves it — but
that is a NECESSARY condition, not a sufficient one. The client is not the security boundary. A
hostile client is just a client. The check belongs on the server that performs the write, and that
server is yours.

What the corpus can prove, it still proves: the mask is not editable and never reaches the patch.
What it cannot prove is someone else's endpoint, and it no longer pretends to.

Also per review: the README's architecture diagram drew the browser PATCHing the stream loop, which
is precisely the thing that is not true. The save handler is now drawn where it lives — in YOUR
server, dashed, going straight to the API server, labelled with the duty it carries.

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

Two review items, and they are one problem: the first ten minutes of an adopter's life.

**#4 — the README called an API that did not exist.** The first Go block in the project README was
`gateway.Handler(gateway.Options{...})`. Neither identifier was real. What existed was
ServeStream(w, r, principal, scope) — a fine seam, but it left every host to write the same four
things by hand: parse the scope out of the query, decide what a principal is, check the scope
against an allowlist, wire the three together. Four chances to get security-relevant glue subtly
wrong, once per adopter. So the library now ships the glue and the README compiles.

Handler's one non-obvious decision, now pinned by a test: every refusal — malformed scope,
disallowed scope, unidentifiable caller — is a TERMINAL SSE error event over a 200, not an HTTP
status code. A 403 is the obvious thing and it is wrong: EventSource cannot read the body of a
non-200, so it reaches the page as an `onerror` with no code, no message and no reason. `terminal`
is also what stops the browser reconnecting to a scope that can never become valid.

ScopePolicy is deny-by-default. The zero value streams NOTHING, so a host that forgets to configure
it fails closed — rather than serving Secrets from every namespace in every cluster it can reach,
which is what the friendlier default does the first time someone copy-pastes a README. Missing
seams PANIC at mount time, on the first line of main(), not on a request from a real user hours
later.

**#3 — the request half of the wire was never specified.** §8 named the scope's FIELDS and never
their ENCODING, so the replay server invented `?group=&version=&resource=`, the client's README
invented a URL string, and nothing compared them. Two ends of one repository, free to disagree, with
a green suite — which is the exact failure the corpus exists to make impossible for the RESPONSE
half.

So the request half gets the same treatment: conformance/scopes.yaml is read by BOTH suites. The
client asserts resourceStreamURL() produces `canonical`; the gateway asserts ScopeFromQuery()
accepts that same `canonical` and yields `scope`. The client's output is fed byte-for-byte into the
server's parser, and neither can drift without the other's suite going red. (Mutation-checked: swap
two fields in the client's builder — a plausible, harmless-looking reorder — and the TS suite goes
red.)

Two rules in there do real work:

  - `resource` and `version` are REQUIRED, never defaulted. A defaulted version streams objects of a
    shape the caller did not ask for; a defaulted resource streams the wrong objects entirely.
  - An API-server address is REFUSED, not ignored. It would be easy to satisfy "the browser never
    supplies an API-server URL" by simply having no parameter for it — but then `?server=https://
    10.0.0.1:6443` is silently dropped and an SSRF probe is indistinguishable from an ordinary
    request. Refusing loudly turns a security property into an observable one. Ditto kubeconfig,
    token, endpoint. And `../../kube-system` is not a namespace: every field is validated as the
    DNS name it actually is, at the edge, before anything downstream is entitled to assume it was.

One thing the tests taught me, recorded where the next person will hit it: a stub Watcher that
returns ErrWatchClosed immediately makes the gateway spin fresh snapshot cycles forever (a closed
watch means "reopen"). It ran for 164 seconds before the test binary was killed. The stream loop was
behaving exactly as designed; the stub was the liar. A real API server idles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review item #5. Ten tabs on the same namespace were ten watches on the API server, ten snapshots and
ten copies of the same object graph — and a reconnect storm (a floor of laptops closing) multiplied
it. SharedBackend makes them one watch and one warm cache: a joining subscriber gets its entire
reset…synced from memory, and the API server never hears about it.

It is a Backend like any other, so it wraps whichever of §3a/§3b is underneath and the stream loop
cannot tell the difference. That is what "the protocol is backend-agnostic" has to mean if it means
anything — and it is why this could be slid in behind the existing seams rather than through them,
exactly as the reviewer said it could be.

**It is opt-in, and the reason is not performance.** A shared watch is opened once, so it is opened
as ONE identity. Without sharing, ClientFor hands the gateway a client acting AS the caller and
Kubernetes' own RBAC is the enforcement — no bug in this library can show someone objects they may
not see. With sharing, the upstream runs as your service account and every subscriber reads from its
cache, so YOUR Authorizer becomes the only thing between a caller and the objects. A bug there is not
a bug, it is a disclosure. That is a choice about someone's threat model, and the library does not
get to make it — it only refuses to make it silently. The package comment says so in those words.

Three things this had to get right, each one tested and each one a real failure mode:

  - **A slow subscriber must not stall the pump**, and therefore everyone else. The queue is bounded;
    overflow resnapshots that consumer from the warm cache (costing the API server nothing) instead
    of blocking, and instead of the unbounded queue that turns one paused tab into unbounded memory
    for every other tenant. Slowness degrades into a resnapshot, never into a leak and never a lie.
  - **A partial object is refused AT THE CACHE**, not only at the wire. The stream loop already
    guards its own output — but this cache is REPLAYED to every future joiner. A husk forwarded once
    blanks one consumer's object; a husk cached is served to everybody who arrives later, for as
    long as the scope lives. Same for a uid-less tombstone: guessing would evict the wrong object
    from a cache that is then handed to everyone.
  - **N subscribers resyncing at once must not stampede** the API server into N new watches. They
    get one.

The tests taught me one thing I had wrong, and the fix is now in a comment where the next person will
need it: the overflow error was being pushed INTO the subscriber's channel — which cannot work,
because the case where we need to send it is exactly the case where the channel is full. The reason
was silently dropped and the consumer saw a bare close. It still RECOVERED (a closed watch means
resnapshot), so every test passed — but nothing anywhere could say why, which is the difference
between a system you can operate and one you can only restart. The reason now lives beside the queue.

-race is now a permanent gate, in the Taskfile and in CI. The gateway has a pump goroutine, a
per-scope cache and N bounded queues; a data race in that is a browser being shown another tenant's
object, not a flaky test.

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

Review item #6, both halves.

**The version stamp.** This library is publishable to npm, but it is also VENDORED — copied, as built
ESM, into a host that serves it to a browser (that is exactly what the replay server's --dist flag
does). A vendored asset drifts, and it drifts SILENTLY: an old client and a new gateway agree about
every event they both still understand, right up until the wire changes, and then the failure lands
in a user's browser rather than in anybody's test suite.

So the bytes now carry their provenance: `VERSION` and `PROTOCOL_VERSION` are exported, and a host
can assert the copy it vendored against the gateway it is running.

Neither number is hand-maintained in the way that rots. `task test` fails if VERSION drifts from
package.json, and PROTOCOL_VERSION is checked against the GO CONSTANT THAT DEFINES IT: the gateway
writes it into conformance/gen/protocol.json (published by the side that owns the protocol, not
copied by hand into the side that consumes it), and the TypeScript suite reads it back. Neither half
of this repo can bump the protocol alone. Both guards mutation-checked: bump either number and the
suite goes red.

**The Kubernetes floor.** k8s.io/* stays on v0.36.0 — the lowest minor we support — and that is now
written down as a rule rather than left as an accident. MVS makes the require line a FLOOR, so a
consumer already on v0.36.2 keeps it and nothing drags them backwards; but the asymmetry matters: if
we chased patch releases, every adopter would be forced up to whatever we last happened to build
against. An adapter has no business dictating which Kubernetes libraries its host runs. We sit on the
floor; the consumer picks the ceiling.

And one trap, fixed where I fell into it: `task fixtures` did not list Taskfile.yml among its
sources, so changing WHICH golden tests the generator runs left it cheerfully reporting "up to date"
while the new artifact was never written. The command line of a generator is one of its inputs.

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

The reviewer asked for an auth stance doc. Writing it found a bug, because a doc that has to be TRUE
is a test of the code.

**The bug.** Authorize() and ClientFor() were each called exactly ONCE, at stream open — and then the
stream ran for as long as the browser stayed connected, reopening upstream watches internally, for
hours. Two consequences, both real:

  - **Revocation was never noticed.** Take away a user's access and their open stream kept delivering
    objects, from a watch that had been authorized once, possibly hours earlier.
  - **A long stream outlived its short token.** An OIDC access token is good for 5–60 minutes; a
    dashboard tab is open all afternoon. The credential captured at open was the credential used
    forever, and the host had nowhere to hand us a fresh one.

Both seams are now re-consulted on EVERY SNAPSHOT CYCLE. A cycle is the natural checkpoint — it is
where continuity is re-established anyway, so it is where entitlement should be too. A revoked user's
stream now ends with a TERMINAL Forbidden; terminal because EventSource reconnects on its own, so a
non-terminal refusal would leave a revoked user hammering a forbidden scope forever. And ClientFor is
re-invoked there, which is the seam a host hangs credential refresh on.

Mutation-checked, and the mutant is instructive: restore the old "authorize once at open" and the
revoked-user test does not merely fail, it HANGS — the stream never ends, which is exactly the bug
stated as a symptom.

**The doc** (docs/auth.md) is short and takes one route, as asked: OIDC via Dex.

Everything in it falls out of one physical constraint, stated first because nothing else makes sense
without it: a browser's EventSource CANNOT SEND AN AUTHORIZATION HEADER. So a browser-held token
cannot ride on a native SSE request at all. Therefore the browser logs in to YOUR server, your server
custodies the token, and the SSE request carries nothing but a same-origin HttpOnly cookie — nothing
an XSS can read. ClientFor then opens the watch AS that user, so their own RBAC enforces.

The stance, in one line: krm-stream never holds a credential and is not an authorization boundary —
Kubernetes is. The gateway holds no privileged client of its own, so it cannot bypass RBAC even if a
bug wanted it to. Authorization is not something this library DOES; it is something it structurally
cannot avoid delegating.

Also written down because they are the things people get wrong:

  - the Authorizer is fail-fast defence in depth, NOT the boundary;
  - a projection is NOT authorization — redaction is a tighter disclosure layer ON TOP OF RBAC, and
    must never be relied on to hide what the caller could have read anyway;
  - sharing a watch MOVES the boundary onto your Authorizer, which is why it is opt-in;
  - bearer token vs impersonation is a real choice, with the blast radius named.

README gets a paragraph and the login flow in the picture — the reader deciding whether this library
is for them should be able to see where the token lives without opening a second file.

Two gaps are listed as gaps rather than quietly omitted: kube.SSARAuthorizer (which would let you
share a watch AND keep Kubernetes as the boundary), and ValidatePatch (the redaction guard for the
host's save endpoint, as a function rather than a paragraph).

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

The reviewer's best idea, and it closes the one place this library's own principle broke.

The stance is that krm-stream does not authorize: ClientFor opens the watch AS the caller, so the API
server's RBAC decides and no bug in here can talk it round. SharedBackend violated that by
construction — a shared watch is opened ONCE, so it is opened as ONE identity (your service account),
and every subscriber then reads from its cache. At that moment the Authorizer stops being defence in
depth and becomes the only thing between a caller and the objects.

We shipped that with a warning label. A warning label is not a boundary.

kube.SSARAuthorizer gives the boundary back: before a subscriber is served from the shared cache, ASK
THE API SERVER — with a SubjectAccessReview — "may this user list and watch this resource, in this
namespace?" — and let it answer. RBAC decides again, and sharing costs one round-trip per snapshot
cycle instead of the security property. Because the gateway now re-authorizes every cycle, this is
also how a revocation reaches an already-open stream.

`subjectOf` is the host's: Principal is opaque on purpose, so mapping it to the Kubernetes user and
groups RBAC binds against (the OIDC username/groups claims) is a thing only the host can do.

Three ways this could have been accidentally permissive, all now tested, and two of them
mutation-checked:

  - **It asks about BOTH `list` and `watch`.** A snapshot cycle is a list THEN a watch — literally so
    on the §3b path, where the gateway issues a real LIST — so a caller who may watch but not list
    would otherwise be handed, in the snapshot, exactly the objects RBAC refused to let them
    enumerate. Checking only `watch` authorizes half of what we are about to do.
  - **A review we could not COMPLETE is not an allow.** If the API server cannot tell us whether this
    caller may look, the answer is no. The other way round is a disclosure with an excuse.
  - **An explicit Denied beats an Allowed** (a webhook authorizer saying "no" is not "no opinion").

It needs `create` on subjectaccessreviews (system:auth-delegator). It does NOT need impersonate
rights — it asks a question ABOUT a user, it does not act AS one, and impersonate is a privilege
whose compromise is total.

docs/auth.md and SharedBackend's own comment now point at it, so the code and the doc say the same
thing. The remaining known gap is ValidatePatch, and it stays listed as undecided rather than
quietly dropped: the hazard is one this library CREATES (the mask exists because we redacted the
field), and it is currently guarded by a paragraph.

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

Asked to explain the ValidatePatch question properly, and writing it down found a lie in our own
code, which is its own small argument for the proposal.

project.go still told the reader that the mask "can never be written back over the truth: the write
path refuses any patch touching a path in redactedPaths." That became FALSE the moment the write path
was removed. There is no write path here; nothing in this library refuses anything. The comment was
describing a guarantee that had been deleted out from under it — which is exactly what happens to a
security property whose only enforcement is prose.

The proposal, stated plainly:

  - The projection INVENTS `**REDACTED**`. The browser never saw the real Secret value; it saw a mask
    this library made up. A patch carrying that mask back writes the literal string `**REDACTED**`
    OVER the real secret, and the token is destroyed by a save that looked, from the browser, like a
    green tick. **This failure cannot happen without us.**
  - The client already refuses to produce such a patch, and it is tested in a real Chromium. That is a
    NECESSARY condition, not a sufficient one — the save endpoint is an HTTP endpoint, and it accepts
    whatever bytes are posted to it. The fixture says it in its own words: the client is not the
    security boundary; a hostile client is just a client.
  - So ship the check as a PURE FUNCTION — ValidatePatch(projection, obj, patch) — that the host calls
    inside its own save handler. No client, no connection, no API server, no write. The read-path
    stance survives intact; the deleted gatewayRejects: fixtures come back as hostRejects: and drive a
    real test of a real function. The fixtures were right; only their placement was wrong.

And the alternative is stated fairly, because it is a real one: if the answer is no, the coherent move
is to STOP MASKING — a projection that removes Secret values entirely creates no overwrite hazard at
all. What is not coherent is inventing the placeholder and leaving the guard to a blockquote.

Nothing is built. The decision is the owner's, and it should be made against the hazard rather than a
summary of it.

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

"In the end, starting a watch on a Secret is a decision you make yourself." That is right, and
following it honestly turned the proposal inside out.

The premise holds. Streaming a Secret is decided TWICE before this library sees it: the host
allowlists `secrets` in a deny-by-default ScopePolicy, and Kubernetes RBAC grants that user list/watch.
A user who clears both can already run `kubectl get secret -o yaml`. So the mask grants no security
against the user — we say so ourselves in docs/auth.md: a projection is not authorization.

What the mask actually buys is narrower, and worth naming rather than assuming: a browser is an
AMBIENT environment in a way a terminal is not. `kubectl get secret` is one deliberate act by one
person. A dashboard tab is left open — on a second monitor, in a screen share, in a screenshot pasted
into a ticket, in an error reporter that serializes the DOM, on a projector at standup. Same value,
same eyes, different exposure profile. It is why Lens, Headlamp and the OpenShift console all hide
Secret values behind a reveal, though every one of their users could kubectl the value in five
seconds.

But that argues for REDACTION — not for the PLACEHOLDER. And that distinction is the finding:

  - `redactedPaths` is authoritative, mandatory on every added/modified, and already carries the fact.
    The client marks paths read-only from THAT, not from the value (store.ts).
  - spec §3 ALREADY says a consumer must not treat an absent path as deleted if it appears in
    `redactedPaths`. Omitting the value is already legal, today, with no spec change.
  - So the placeholder is REDUNDANT — and it is the sole source of the hazard. It is the only poisoned
    value in the system, and we invented it.

Under the sanctioned write (an RFC 7386 merge patch, which is what patch() builds): with the
placeholder, a consumer sends `**REDACTED**` and the real token is destroyed. With the value omitted,
there is no such value to send — the draft never had one — and the token is untouched.

The hazard does not need a guard. It needs to not be created.

So the proposal now lays out three options and recommends B:

  A. keep the placeholder, add ValidatePatch — a guard for a hazard we did not have to create;
  B. keep redaction, DROP the placeholder — the hazard cannot arise, keys-only disclosure survives via
     redactedPaths, and no spec change is needed;
  C. delete redaction entirely — coherent, simpler, and the premise is right; I argue against it only
     on the exposure asymmetry, because safe-by-default is worth one field of the wire and the cost of
     being wrong is a token in a screenshot you never find out about.

I opened this document recommending A. The change of mind is left in the document rather than tidied
away, because how the answer moved is part of the argument.

Nothing is built. Still the owner's call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proposal 0003, option B. The projection no longer invents `**REDACTED**`.

The mask was the only poisoned value in the system, and we made it. A browser holding
`{"token": "**REDACTED**"}` can save it back, and a merge patch carrying that value writes the literal
string OVER the real Secret. The token is destroyed by a save that looked, from the browser, like a
green tick. Guarding that (ValidatePatch, on every adopter's save endpoint, forever, correctly) is a
guard for a hazard we did not have to create. So we stopped creating it.

Now: the value is gone, `data` is left in place but empty, and `redactedPaths` names the keys. Nothing
is lost, because redactedPaths was always authoritative and mandatory — it already carried the one
thing the mask carried, which is that the key EXISTS and is withheld. **The mask is something a UI
draws. It is not something the wire carries.** The demo renders `token ••••••` from
store.redactedPaths(uid), which is the new public accessor and the only place that fact lives.

Spec §3.1 is new and normative: a redacted value MUST NOT appear on the wire, not even as a
placeholder — and it says why, at the point where the next person would otherwise reinvent the mask.
The gateway README's "masked" disclosure policy is deleted for the same reason: it was an option that
created the landmine.

The corpus turned out to be hiding something, and fixing this exposed it. secret-token.v1 was used as
BOTH the gateway's input and its expected output — which only worked because masking an already-masked
value yields the same masked value. The corpus therefore never watched a Secret with a real value in
it, and the projection was never asked to remove anything. There are now two bodies: secret-token.v1
(a real Secret, real base64) and secret-token.v1-wire (data: {}). The difference between the files IS
the projection, and it is a real difference.

The browser test is now the strong one, and it is the assertion that matters: the real value is
nowhere in the page. Not in the DOM, not in the store, not in a screenshot of it — and not even as a
placeholder.

BREAKING CHANGE: `gateway.RedactedPlaceholder` is removed, and a redacted value is no longer present
on the wire in any form. A consumer that rendered the placeholder from the object must render it from
`redactedPaths` instead (the TS client exposes `store.redactedPaths(uid)`). No back-compat shim: there
are no users yet, and keeping the landmine around to be polite to nobody would be the whole mistake
repeated.

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

The three cache failures had one cause, and it was not a misconfiguration: gateway/go.sum does not
exist and never will, because the core module has ZERO dependencies — the exact invariant the
`consumable` job fails the build over. Asking to cache a file that by design cannot exist warned on
every run. `cache: false` now says so, and the line documents the promise instead of quietly failing.

Same for `consumable` itself, for the opposite reason: it must resolve from the NETWORK, the way an
adopter does, and a warm module cache there is a place for the bug to hide. (Its missing root go.mod
— the repo root holds a go.work — was the fourth warning.)

setup-task v2 ran on a deprecated Node 20; v3 does not.

Beyond the warnings, two changes that the release workflow needs:

  - workflow_call, so release.yml can call this pipeline whole. The release tail then runs only
    after the exact same jobs a PR gets are green IN THE SAME RUN, which is what keeps a write token
    away from code this pipeline has not already validated.
  - the `client` job now packs the npm tarball it just tested and hands it over as an artifact, so
    what lands on the registry is what this pipeline PROVED — not a second build of the same source
    that merely ought to match.

And a new, cheap check: no module may carry a `replace`. The `go get` below it would also catch that,
but only while the version gateway/kube requires already exists as a tag — and inside a release PR it
does not. The grep has no such blind spot, so the mistake that once shipped an unconsumable module
cannot be re-introduced silently.

Actions are pinned by SHA (npm ci, not npm install, for the same reason: the lockfile is the input).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…AG is the release

Nobody types a version number and nobody pushes a tag. release-please reads the conventional commits
since the last release, keeps a release PR open, and merging it IS the act of releasing.

The npm client, the gateway and the adapter release in LOCKSTEP on one number (linked-versions). That
is the same claim this repo makes everywhere else: they are one contract and they move in one commit.
A client that speaks protocol N and a gateway that speaks protocol N have no business carrying
version numbers you need a compatibility table to compare.

The two halves publish in genuinely different ways, and only one of them involves a registry:

  npm   the tarball ci.yml already packed and tested, pushed over OIDC trusted publishing. There is
        NO NPM_TOKEN: npm trades the workflow's short-lived GitHub identity for a publish credential,
        so the repo holds no long-lived secret that could leak, and provenance is attached
        automatically. (One-time bootstrap on npmjs.com — docs/releasing.md.)

  Go    nothing is pushed anywhere. A Go module is published by the mere EXISTENCE of a tag named
        <module-dir>/vX.Y.Z, which the GitHub release is. So the release ends by resolving that fresh
        tag from a clean module, over the network, with no checkout — `a stranger can go get the
        release`. Until something outside this repository does that, "published" is a hope. It is the
        same check ci.yml runs per-PR, aimed at what an adopter actually types (@v0.1.0, not @<sha>),
        and it is the only run of it that CAN exist, because the tag does not exist until now.

version.ts is bumped by release-please rather than by hand, because a vendored build states its own
provenance and version.test.ts already fails if it drifts from package.json.

gateway/kube still requires the core at a pseudo-version, deliberately: pointing it at a tag before
any tag exists would mean release-please rewriting a pseudo-version, and that is not a result worth
guessing at. It is a one-line follow-up once v0.1.0 lands, and docs/releasing.md says exactly what it
is — `consumable` already understands both worlds and defers to the post-tag check when the version
it needs is not tagged yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Grouped, so a week of upstream patch releases is one PR to review and not eleven, and `chore(deps):`
so routine bumps stay out of a changelog that a consumer of this library reads. Every ecosystem is
listed: one that is not listed is one nobody is watching. The core module is absent for the reason it
is absent everywhere — it has no dependencies to update.

k8s.io/* is explicitly ignored. That requirement is a supported-version FLOOR, not a version to keep
current: minimal version selection means an adopter already on a newer client-go keeps it, while
chasing patch releases here would force every adopter up to whatever we last built against. Raising
it raises the supported minor, which is a deliberate act and not Dependabot's to make.

Scorecard is not decoration. A library whose pitch is "import this into your browser and trust what
it tells you about your cluster" is asking for a great deal of trust, and it should be able to show
its working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
package.json has always listed README.md in `files`, and there has never been one — so the first
publish would have put a package on the registry with nothing on its page.

It is deliberately short. This client is one half of a pair, and the documentation belongs in one
place rather than being half-told in a second copy that drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
.github/workflows/ci.yml (1)

148-196: 🩺 Stability & Availability | 🟠 Major

Fork PRs still break the go get gate (previously flagged).

Line 175 sets sha="${{ github.event.pull_request.head.sha || github.sha }}". For fork PRs, head.sha is a commit in the fork that doesn't exist in the canonical repo, so go get github.com/ConfigButler/krm-stream/gateway/kube@${sha} at line 194 fails. This was flagged in a prior review and remains unaddressed.

Either skip the go get step when github.event.pull_request.head.repo.full_name != github.repository, or use github.sha (the merge commit, which exists in the canonical repo) for all pull_request events.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 148 - 196, Update the SHA selection in
the “go get the ADAPTER from a clean module” workflow step so fork pull requests
do not pass an unresolvable fork-only commit to go get. Use github.sha for all
pull_request events, or explicitly skip this step when
github.event.pull_request.head.repo.full_name differs from github.repository;
preserve the existing SHA behavior for non-fork runs.
🧹 Nitpick comments (1)
.github/workflows/release.yml (1)

92-93: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider pinning the npm upgrade version. In a publish pipeline that leans on trusted-publishing/provenance for supply-chain integrity, npm install -g npm@latest pulls an unpinned version at each release. Pinning to a known-good floor (e.g. npm@11.5.1, the documented minimum) keeps the release toolchain reproducible and auditable.

♻️ Suggested change
-      # Trusted publishing needs npm >= 11.5.1; Node 22 ships an older npm.
-      - run: npm install -g npm@latest
+      # Trusted publishing needs npm >= 11.5.1; Node 22 ships an older npm.
+      - run: npm install -g npm@11.5.1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 92 - 93, Pin the npm upgrade
command in the release workflow to the documented known-good version npm@11.5.1
instead of npm@latest, preserving the existing global installation step and
trusted-publishing setup.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Line 33: Update every actions/checkout step in the CI workflow, including the
checkout steps at the referenced locations, to set persist-credentials to false.
Preserve the existing pinned action versions and all other checkout
configuration.

In `@gateway/kube/authz_test.go`:
- Around line 158-169: Update TestANamedScopeAsksAboutThatName to validate
ResourceAttributes.Name on both SubjectAccessReviews in asked, ensuring each
authorization request uses "app-config" rather than checking only asked[0].
Preserve the existing authorization assertion and expected name.

In `@spec/v1.md`:
- Line 425: Update the normative validation rule in §8 so DNS-style name
validation applies to eligible fields but explicitly excludes labelSelector.
Ensure valid selectors containing operators and separators such as =, commas,
and parentheses remain accepted, consistent with §8’s selector support.

---

Duplicate comments:
In @.github/workflows/ci.yml:
- Around line 148-196: Update the SHA selection in the “go get the ADAPTER from
a clean module” workflow step so fork pull requests do not pass an unresolvable
fork-only commit to go get. Use github.sha for all pull_request events, or
explicitly skip this step when github.event.pull_request.head.repo.full_name
differs from github.repository; preserve the existing SHA behavior for non-fork
runs.

---

Nitpick comments:
In @.github/workflows/release.yml:
- Around line 92-93: Pin the npm upgrade command in the release workflow to the
documented known-good version npm@11.5.1 instead of npm@latest, preserving the
existing global installation step and trusted-publishing setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5a64bee7-4e16-4c62-9620-c1616567cc58

📥 Commits

Reviewing files that changed from the base of the PR and between 5299075 and 8f330e1.

⛔ Files ignored due to path filters (5)
  • conformance/gen/bodies.json is excluded by !**/gen/**
  • conformance/gen/fixtures.json is excluded by !**/gen/**
  • conformance/gen/protocol.json is excluded by !**/gen/**
  • conformance/gen/scopes.json is excluded by !**/gen/**
  • conformance/gen/sse/secret-redaction.sse is excluded by !**/gen/**
📒 Files selected for processing (52)
  • .devcontainer/Dockerfile
  • .devcontainer/devcontainer-lock.json
  • .github/dependabot.yml
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .github/workflows/scorecard.yml
  • .release-please-manifest.json
  • README.md
  • Taskfile.yml
  • conformance/README.md
  • conformance/bodies/secret-token.v1-wire.yaml
  • conformance/bodies/secret-token.v1.yaml
  • conformance/fixtures/secret-redaction.yaml
  • conformance/generate.sh
  • conformance/scopes.yaml
  • docs/auth.md
  • docs/client-state-model.md
  • docs/facts/kubernetes-api-concepts.md
  • docs/naming.md
  • docs/proposals/0001-watch-ops.md
  • docs/proposals/0002-real-cluster.md
  • docs/proposals/0003-validate-patch.md
  • docs/releasing.md
  • examples/vanilla-browser/index.html
  • examples/vanilla-browser/tests/live-krm.spec.ts
  • gateway/README.md
  • gateway/auth_test.go
  • gateway/conformance.go
  • gateway/golden_test.go
  • gateway/handler.go
  • gateway/handler_test.go
  • gateway/kube/authz.go
  • gateway/kube/authz_test.go
  • gateway/kube/go.mod
  • gateway/project.go
  • gateway/scope.go
  • gateway/scope_conformance_test.go
  • gateway/shared.go
  • gateway/shared_test.go
  • gateway/stream.go
  • gateway/stream_test.go
  • packages/krm-stream/README.md
  • packages/krm-stream/src/index.ts
  • packages/krm-stream/src/store.ts
  • packages/krm-stream/src/url.ts
  • packages/krm-stream/src/version.ts
  • packages/krm-stream/test/conformance.ts
  • packages/krm-stream/test/invariants.test.ts
  • packages/krm-stream/test/scopes.test.ts
  • packages/krm-stream/test/version.test.ts
  • release-please-config.json
  • spec/v1.md
💤 Files with no reviewable changes (1)
  • packages/krm-stream/test/conformance.ts
🚧 Files skipped from review as they are similar to previous changes (14)
  • examples/vanilla-browser/index.html
  • docs/client-state-model.md
  • gateway/kube/go.mod
  • docs/proposals/0001-watch-ops.md
  • examples/vanilla-browser/tests/live-krm.spec.ts
  • docs/proposals/0002-real-cluster.md
  • README.md
  • conformance/README.md
  • docs/facts/kubernetes-api-concepts.md
  • gateway/stream.go
  • Taskfile.yml
  • packages/krm-stream/test/invariants.test.ts
  • gateway/stream_test.go
  • packages/krm-stream/src/store.ts

Comment thread .github/workflows/ci.yml
Comment thread gateway/kube/authz_test.go
Comment thread spec/v1.md Outdated
sunib and others added 9 commits July 13, 2026 17:11
The ask: a controller rewrites `status` constantly, and a consumer that only edits `spec` does not
care. Today it receives every one of those events in full. The proposal was: hash the interesting
parts, put the hashes in metadata, let the consumer choose.

Two of those three are right. The third inverts the goal, and it is the finding that reshapes the
design:

  A `status` digest and event-suppression are MUTUALLY EXCLUSIVE. If the gateway emits a digest of
  status, then every status change changes the digest — so every status change still produces an
  event. You save the BYTES of the status block and keep every wakeup, every frame, every re-render.
  For "I don't care about status", the correct traffic is ZERO EVENTS, not a small event per change.
  The digest actively prevents the thing we want.

So the mechanism for "I don't want this" is a VIEW plus SUPPRESSION, and it needs almost no new
protocol: a view IS a projection (already named, already server-declared, already on every reset), and
suppression is ~20 lines — if the projected object is byte-identical to the last one we sent this
consumer, say nothing. Under a no-status view, a Deployment rollout produces N status-only MODIFIEDs
upstream and ZERO events downstream. Not smaller events. No events.

(One wrinkle, and the fix falls out of an existing rule: metadata.resourceVersion changes on every
write, so a naive digest suppresses nothing. Exclude it — a consumer may then hold a stale
resourceVersion, which is harmless precisely because spec §6 already forbids them from looking at it.)

The general rule behind proposal 0003 is also written down here, because both of this proposal's
temptations violate it:

  THE OBJECT IS A STRICT SUBSET OF WHAT THE API SERVER SENT. The gateway may remove a key. It may
  never add one, and never change a value. Everything it wants to say ABOUT what it removed lives in
  the ENVELOPE.

That settles `maskedData` (a synthesized field — a consumer round-trips it into a patch and now it is
written to your cluster) and, more sharply, a digest in `metadata.annotations` (an annotation IS part
of the object; a save persists our private bookkeeping onto the resource, in etcd, forever). It is the
mask landmine wearing a different hat. The instinct — be explicit, don't pretend a field is the field
— is right; the envelope is where to be explicit, because nothing can round-trip it into a cluster.

Digests do pay in one narrow place: "I may not see it, but I need to know it changed" — a rotated
Secret. Envelope-only, and it MUST be an HMAC with a per-process key, not a hash: sha256 of a Secret's
value is an offline-crackable oracle, and secrets are frequently low-entropy or structured. We would
have disclosed the secret we were protecting while believing we had hidden it. So: an opaque
`changeToken`, comparable only within one stream. No length, no size (the old README's "mask with
length" is exactly the sort of thing you do not publish about a password).

And the transport question, answered so it is not relitigated: the cheap wins are enormous and the
expensive ones are small. Suppression → views → coalescing → gzip (5–10x on repetitive KRM JSON,
invisible to the protocol) → and only then deltas or a new transport. Deltas would trade I-REPLACE —
the property the whole convergence proof rests on — for bytes gzip gives us free. WebSocket/gRPC would
cost us EventSource, and with it the entire auth model (docs/auth.md hangs off SSE's constraints).

The uncomfortable and probably correct summary: the answer to "we are sending too many bytes" is
mostly "stop sending events nobody wants", and after that "turn on gzip".

Nothing built. Design for discussion.

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

The constant itself went in d0e3140; this fixes the proposal that still claimed otherwise. Its
Recommendation read:

    (`RedactedPlaceholder` stays exported and deprecated, since a consumer may render it.)

That was wrong twice over, and leaving it would have been a document quietly disagreeing with the code
it describes — which is the exact failure mode this proposal was written about.

  - There are no consumers yet. Keeping a landmine exported to be polite to nobody would be the whole
    mistake repeated in miniature.
  - And a mask is not something the WIRE should carry at all. It is something a UI DRAWS, from
    `store.redactedPaths(uid)`. Exporting the string would have kept the idea alive that the value
    belongs in the object.

`RedactedPlaceholder` now appears in no source file in the repo — no alias, no shim, no deprecation.
What remains is prose explaining why not to reinvent it, and one test that puts the literal string in a
ConfigMap value on purpose, to prove redaction is decided by KIND and never by what a value looks like.

The historical section is retitled to the past tense: the argument only makes sense against the thing
it argued about, so it stays, honestly labelled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two corrections and one addition, all from the owner's question: "are you suggesting to remove
resourceVersion from what's sent to browsers then?"

**No — and §3 was ambiguous enough to be read that way.** The suppression digest excludes
resourceVersion from a comparison we make INTERNALLY. The field itself stays on the wire: it is part of
the object, the object is what the API server sent, and deleting a field to stop people misusing it is
how you end up with a protocol full of holes.

**But the question exposed a hazard I under-sold, and it has teeth.** Suppression means the consumer
keeps the object it already had — including its old resourceVersion. Its visible content is correct;
its resourceVersion now trails the cluster, and it trails it PRECISELY in the case that motivated the
proposal: the status-blind editor watching an object whose status churns invisibly.

A host that used that resourceVersion as a save precondition would get a 409 Conflict on every save,
forever, on exactly the objects that churn most — and it would be a FALSE conflict, since nothing the
user could see had changed. Maddening, and it would look like our bug. The protocol already forbids the
cause (§3: no whole-object PUT; saves are a merge patch, which carries no resourceVersion), so the fix
is to say it louder rather than invent anything: a consumer MUST NOT use resourceVersion as a save
precondition. A host that wants optimistic concurrency reads server-side at save time — it has an API
client; the browser does not. The browser's copy is a view, not a transaction handle.

**And a correction on what Kubernetes actually guarantees**, because it is both looser and stricter
than stated, and this repo has the receipts. On kube-apiserver 1.35+ it IS guaranteed orderable as a
monotonically increasing integer — a Certified Kubernetes conformance requirement, for base objects and
CRDs, and the whole basis of our OrderingStrict default. But the guarantee is per-storage: our own
cluster run (F6) found the aggregated API numbering its OWN resourceVersion space from ~1, in an
ephemeral etcd, where a restart sends them BACKWARDS. And the client contract says "opaque" regardless
— a guarantee the SERVER must uphold is not a licence for a CLIENT to depend on it.

**Which is the argument for the envelope `seq`, and it is a good idea — for a better reason than bytes.**
A uint64, per stream, assigned AT EMIT TIME so suppression and coalescing consume no numbers and the
stream is gapless by construction. Three things it buys, and the third is the real one:

  1. it gives a consumer an order that is legitimately theirs to use. Today the only number available
     is the one thing the spec forbids them to touch — a trap we set;
  2. it makes suppression legible: "I dropped 200 status events for you" stays invisible, as it should,
     rather than showing up as a suspicious hole;
  3. **a gap is proof of loss.** Today, if an intermediary truncates an SSE frame, the consumer applies
     what it got, converges WRONG, and never finds out. It looks fine. With a gapless seq it knows, and
     can do the one correct thing: reconnect and resnapshot. We currently cannot detect that at all.

It must NOT go in the SSE `id:` field (spec §7 bans id: lines, because EventSource would replay it as
Last-Event-ID and promise a delta resume v1 does not have). It lives in the JSON envelope, which makes
no such promise.

Cost: twenty bytes and a counter. It is free to add NOW and never again — no users, unreleased
protocol — which is the actual argument for doing it first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…edaction was never a feature

The owner asked whether we could drop the whole redaction apparatus by making the projection smarter,
and get "the secret changed" out of it. Yes — and chewing on it deleted the most dangerous thing in
the earlier draft.

**The realization: the gateway already knows.** §3's suppression makes it stateful per stream — it
must hold the last thing it told each consumer. So it can do the comparison ITSELF. It does not need
to hand the browser a token so the BROWSER can compare. It can simply say: `/data/token` changed.

That deletes the entire cryptographic section: the HMAC, the per-stream key, the key management, the
security review, and the offline-cracking risk (a sha256 of a Secret is a crackable oracle — secrets
are frequently low-entropy or structured, and we would have disclosed the thing we were protecting
while believing we had hidden it).

And it is not merely simpler, it is STRICTLY BETTER. Compared on the same footing, a perfectly-keyed
per-stream token buys:

  - "the token was rotated"                       → same as just saying "changed"
  - "it changed while I was disconnected"         → NEITHER can do this (new stream, new key)
  - "these two Secrets have the same password"    → only the token can — and that is a DISCLOSURE we
                                                    never wanted to grant

Same power for the legitimate use, plus one illegitimate one, plus a permanent footgun. There is no
version of the crypto that wins.

The shape is a small per-path counter (`withheld[].rev`), not a boolean: a boolean is fragile, because
a UI that coalesces renders can miss the single event carrying `changed: true`. A counter is state, so
a late re-render still sees that it moved.

**And the bigger answer: there is no "redaction feature". There are three verbs.** A projection decides,
per path, exactly one of:

  send      you get the value
  withhold  the value never leaves the gateway; you learn the path EXISTS and are woken when it CHANGES
  drop      the value never leaves the gateway; it is as if it did not exist, and it NEVER wakes you

Everything in the document collapses into that: managedFields is `drop`. A Secret's values are
`withhold` — which is exactly the owner's case. `status` for a status-blind editor is `drop` (zero
traffic); for a dashboard it is `send`. "Redaction" was just `withhold` with a bad name and its own
plumbing, and `redactedPaths` becomes `withheld[]`, carrying the rev for free. One concept, one
envelope field, one code path — instead of a Secret-shaped special case bolted to the side.

The vocabulary also makes the central trade EXPLICIT instead of hidden: `withhold` costs one event per
change (you asked to know), `drop` costs zero. That is the §0 finding, and it is now something a
projection author chooses on purpose rather than discovers in production.

The honest limit, stated: `rev` is scoped to one stream, so you cannot know whether a withheld value
changed while you were DISCONNECTED — and no design can tell you that without publishing a stable,
content-derived identifier, which is precisely what we must never publish. A consumer that cares treats
every `reset` as "this may have changed".

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

The test that found it came from outside, and it is a good one: 300 ConfigMaps is a SMALL namespace,
and sharedQueueDepth is 256. It failed immediately.

The bug, and it is worse than a truncated snapshot. subscribe() fills a joiner's queue from the warm
cache while nobody is draining it yet. The 257th object overflows, and the subscriber is told it "fell
too far behind" — so the stream loop does the one thing it knows: it resnapshots. Which fills the
queue from the warm cache again. Which overflows again, at exactly the same object.

**An infinite resync loop, on a 300-object namespace.** SharedBackend could not serve one at all. Both
of my own fan-out tests used two objects, so both were green.

The mistake was conflating two things that were never the same, and the fix is to stop:

  - the SNAPSHOT is the consumer's STARTING STATE. It is finite, known in advance, and dropping part
    of it is not backpressure — it is a WRONG ANSWER, and the recovery for a wrong answer is to send
    it again, forever. It now gets an unbounded slice, drained before any live event.
  - LIVE EVENTS are open-ended. A consumer that cannot keep up with THOSE genuinely is falling behind,
    and resnapshotting it from the warm cache is exactly right. They keep the bounded channel and the
    backpressure it exists for — a slow tab still cannot grow memory for everyone else.

Fixing it exposed a second bug immediately, and the tests caught that one too: with the snapshot no
longer travelling through the channel, a consumer that called Next() BEFORE the upstream finished its
first cycle parked on a channel that would never carry the thing it was waiting for. Every test that
subscribed before the boundary bookmark hung for exactly its timeout. Hence `ready` — a one-slot
wakeup that tells a parked reader the snapshot has landed in `pending`, with Next re-checking `pending`
before parking again, so a missed signal is impossible.

Green under -race, twice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner's review, and both points land.

**"Withhold is kind of redact, isn't it?"** Yes, and `redact` was already ours. A redacted document has
a BLACK BAR: you can see something was taken, you cannot see what. That is precisely the verb, and it
is exactly what `redactedPaths` has always meant. And the silent case already had a word too, in spec
§3's own text — a field the gateway REMOVED, "never shown, and never named".

So the vocabulary is **send / redact / remove**, all three of them words the spec already uses.
`withhold` was inventing a word for a concept that had one. The generalisation was real; the neologism
was not.

**Three projections, and the status-only one is dropped.** The owner's instinct was right, and for a
reason worth writing down: an object with its `spec` removed IS NOT A KRM OBJECT ANY MORE. It is a
fragment that looks like a resource and is not one — you cannot round-trip it, you cannot diff it, and
every consumer holding one has to know it is holding half a thing. This project's thesis is "the
payload is a Kubernetes object, not an abstracted document" (spec §0). A status-only projection quietly
abandons that.

And its byte argument does not survive §5 either. The case FOR it was: under I-REPLACE every status
event re-sends the whole object, including a `spec` that did not change, and a Deployment's
spec.template is large. But an SSE stream is ONE GZIP STREAM WITH ONE SLIDING WINDOW — the previous
event's spec is still in that window, so re-sending an identical spec costs almost nothing on the wire.
Compression eats that problem for free, without inventing a payload that is not a KRM object. So: pay
for `remove` when you want ZERO EVENTS (that is krm-spec/v1, a real win compression cannot give you),
not merely to shave repeated bytes.

  krm-raw/v1    send everything, Secret values INCLUDED ⚠️  — an operator console meant to disclose
  krm-full/v1   default. metadata + spec + status, Secrets redacted
  krm-spec/v1   full minus status — the status-blind editor, and zero traffic under churn

One naming note that is a safety property, and it is why I did not take "verbose": the projection that
puts Secret values in a browser must have a name that makes a reviewer flinch, because the name is what
shows up in the diff. "verbose" sounds like a log level. `raw` sounds like the loaded gun it is.

**Custom projections: yes, but only the HOST may define one.** A projection is a SECURITY POLICY — it
is the thing that redacts Secrets. If the browser can describe a projection, the browser can describe
one that does not redact, and it has just un-redacted your cluster. So: the consumer PICKS a projection
from a registered list; it never DESCRIBES one. Agreed it is not the first thing to build, but the seam
is reserved now, because retrofitting "the host may define one" onto a hard-coded switch is a refactor
and reserving it is free.

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

Proposed: send / inform / ignore. The two verbs turn out to be decided by different arguments, so the
answer is a split — and recording why is the point.

**`ignore` wins, and it is better than my `remove`.** `remove` describes what happens to the OBJECT —
the key is gone. But what this proposal is ABOUT is what happens to the EVENTS: a change there must
NEVER WAKE YOU. `remove` merely implies that, by way of suppression. `ignore` states it. It is the
stronger claim and the one a reader actually needs, and it took a moment to see.

**`inform` loses to `redact`, on two counts.** It is ambiguous — read `Inform: ["/data/token"]` cold
and ask "inform whom, of what?", where `Redact: ["/data/token"]` is unmistakable. And, the real reason:
`redact` is a SECURITY TERM OF ART, and that is a feature. The word is what catches a reviewer's eye
scanning a diff; it carries "this is secret" without anybody reading the documentation. `inform` sounds
like a notification preference.

It is the same argument that keeps the disclosing projection called `raw` rather than `verbose`: the
scary word does its work precisely on the person who is NOT reading carefully.

(`inform` does name one real thing `redact` does not — our redaction now tells you WHEN the value
changed, which plain redaction does not imply. But that gap closes for free in the envelope:
`redacted: [{path, rev}]`, counter right there, self-documenting. The security legibility that `inform`
would cost cannot be bought back that way, because its whole value is working on someone who is not
reading.)

So: send / redact / ignore. One verb chosen for what it does to the object's VALUE, one for what it
does to the STREAM, and both for saying so out loud.

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

The owner's reductio settles an open question in one line: under the same logic that keeps `data: {}`,
you would send `status: {}` when status is ignored — which is bullocks.

Correct. And it is sharper than that, because **the rule was already in our own code, four lines above
the block that broke it.** project() removes an `annotations` map it has emptied, with this comment:

    // An annotations map that is empty ONLY because we emptied it is our artifact, not the
    // server's state. Leaving `annotations: {}` behind would tell the consumer the object
    // has an empty annotation map, which is a different fact from having none.

And then the Secret path left `data: {}` behind. We wrote the rule and violated it in the same
function. I even defended it in the proposal ("slightly more informative"), which it is not: `data: {}`
asserts *"this Secret has an empty data map"*, and that is a DIFFERENT FACT from *"this Secret's data
is not yours to see"*. The second is what happened, `redactedPaths` is what says so, and it is the only
thing that does.

So a fully-redacted Secret now carries no `data` key at all. Spec §3 states the rule generally: a
container the projection empties is removed with its contents, never left behind as `{}`.

The other half of the rule, and the half that keeps it honest — now its own test: **we remove what WE
removed, and nothing else.** A Secret that genuinely arrived with an empty `data` map KEEPS it. That
emptiness is the server's fact, not our artifact, and deleting it would report a removal we never made.

The browser suite is the proof that this is safe, and it is worth stating: the demo still renders
`token ••••••` with NO `data` key in the object at all — drawn purely from `redactedPaths`. That is
redaction being genuinely authoritative rather than decorative. If the mask had been quietly depending
on the object's shape, this change would have blanked it, and the test would have caught that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ug I would have shipped

A user's review of 0004, and the first finding is real. I verified it with code rather than taking it
on faith, because it decides the build order:

    before: {"apiVersion":"v1","kind":"Secret","metadata":{…},"type":"Opaque"}  redactedPaths=[/data/token]
    after:  {"apiVersion":"v1","kind":"Secret","metadata":{…},"type":"Opaque"}  redactedPaths=[/data/token]

A Secret ROTATION is byte-identical after projection — the projection deletes the value, so there is
nothing left to differ. §3's suppression rule was stated over the projected OBJECT, so the event is
dropped, the `rev` bump from §4 never arrives, and "did my Secret rotate?" returns ZERO EVENTS,
FOREVER. On exactly the case that motivated §4. And it would have looked like the feature working.

The reviewer's fix is right; I have generalised it, because the instance is less interesting than the
class. Suppress on the EVENT, never on the OBJECT: the digest covers everything the consumer would
observe — object AND envelope (`redacted[]`, its rev vector, and whatever we add next) — minus only
what churns without informing (`resourceVersion`, and `seq` itself). Stated over the object, the next
envelope field re-introduces the same bug. Stated over the event, it cannot.

And the build order was wrong in a way that mattered: suppression at step 1, `rev` at step 5, four
steps apart. **They are one change and they ship together** — because the failure is invisible (zero
events on a Secret nobody rotates; zero events on one somebody does).

**§3.1's answer WAS a hand-wave, and the pushback lands.** "A host wanting optimistic concurrency reads
server-side at save time" — a server-side read returns the CURRENT resourceVersion by definition, so
the precondition always matches. It cannot detect that the USER'S VIEW was stale, which is the only
thing OCC is for. It is not OCC; it is a write with a ceremony.

But I push back on `metadata.generation` being THE answer, and the second hole matters more for this
product than the one the reviewer named:

  1. we already do better, and it is the product: the store raises a LIVE conflict the moment the
     server touches a field the user is editing — before they save. A save-time precondition is a
     worse version of a thing this library already does continuously;
  2. a merge patch is per-field last-write-wins, and that is INTENDED — two people editing different
     fields both succeed, which is why the write is a patch and not a PUT. Say it plainly;
  3. and only then: if you want a hard precondition it is `generation`, never `resourceVersion`.
     Reviewer's hole: absent on ConfigMap/Secret. **Mine: `generation` does not move on METADATA
     writes** — and editing a label is the first thing this library's own fixtures demonstrate. So it
     silently misses a concurrent label edit. It is a precondition on `spec`, and must be sold as
     exactly that.

**§2.2's Rules shape was kind-blind, and that is a security bug in a doc.** `Redact: ["/data"]` — but
ConfigMap has a `/data` too, and its whole purpose is to be readable. The built-in it replaces reads
`if p == ProjectionEditor && isSecret(out)`: a rule that cannot express `isSecret` cannot replace it.
Rules now match on `(group, kind)` first, then path — group included, because a CRD may define its own
`Secret` kind in its own group.

Also accepted: the gzip argument for killing krm-status/v1 is weaker than stated (32 KB window holds
for one object, degrades on a large scope with many interleaved — brotli's is larger). The CONCLUSION
stands on its own without compression at all: the payload would not be a KRM object. And nobody has
measured it, which in this repo means "go and measure it" (F1–F7).

Housekeeping the reviewer caught: a section duplicated verbatim, §8 sitting between §5 and §6 and
colliding with the gateway README's §8, and a JSON example still saying `redactedPaths` after §2
renamed it.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/proposals/0004-views-and-bytes.md`:
- Around line 434-444: Update the modified Secret envelope example to omit the
object’s data property entirely when all data keys are redacted, while
preserving the existing redacted entries and revision values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ad5c42c-df04-4678-82eb-3a6d65a5f521

📥 Commits

Reviewing files that changed from the base of the PR and between 4c8510d and e474d85.

⛔ Files ignored due to path filters (2)
  • conformance/gen/bodies.json is excluded by !**/gen/**
  • conformance/gen/sse/secret-redaction.sse is excluded by !**/gen/**
📒 Files selected for processing (8)
  • conformance/bodies/secret-token.v1-wire.yaml
  • docs/proposals/0004-views-and-bytes.md
  • gateway/project.go
  • gateway/shared.go
  • gateway/shared_bigcache_test.go
  • gateway/stream_test.go
  • packages/krm-stream/test/invariants.test.ts
  • spec/v1.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • gateway/project.go
  • gateway/shared.go
  • packages/krm-stream/test/invariants.test.ts
  • spec/v1.md
  • gateway/stream_test.go

Comment thread docs/proposals/0004-views-and-bytes.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
gateway/README.md (2)

394-394: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the JSON Merge Patch citation to RFC 7396. RFC 7386 is obsolete here; both references should point to the current spec.

✏️ Suggested fix
-   (RFC 7386, built by the consumer — the reconcile engine's `patch(id)`) and `PATCH` it with the
+   (RFC 7396, built by the consumer — the reconcile engine's `patch(id)`) and `PATCH` it with the
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/README.md` at line 394, Update the JSON Merge Patch citation in the
gateway documentation from RFC 7386 to RFC 7396, including both references
mentioned in the surrounding text.

138-138: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the package-shape diagram with the real repo layout.
gateway/README.md:101-117 describes api/, auth/, watcher/, project/, stream/, and write/ as subpackages, but the implementation is a flat gateway package plus gateway/kube. Either mark this as a conceptual split or update the diagram so readers don’t look for directories that don’t exist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/README.md` at line 138, Update the package-shape diagram in
gateway/README.md to match the repository’s actual layout: a flat gateway
package with the kube submodule containing kube.NewBackend. Either label api,
auth, watcher, project, stream, and write as conceptual areas or remove the
nonexistent subpackage directories, while preserving accurate package guidance.
packages/krm-stream/src/store.ts (1)

179-189: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

renameKey silently drops data on a key collision.

If newKey already exists as a distinct key in the map, the Object.entries(map) rewrite loop writes both the original newKey entry and the renamed oldKey entry to the same renamed[newKey] slot; whichever is processed last in iteration order silently overwrites the other, with no error and no signal to the caller.

🐛 Proposed fix: reject rename into an existing distinct key
   renameKey(id: string, path: Path, oldKey: string, newKey: string): void {
     const res = this.#editable(id, [...path, oldKey]);
     this.#editable(id, [...path, newKey]);
     const map = get(res.draft, path);
     if (!isPlainObject(map)) throw new Error(`krm-stream: ${pathKey(path)} is not a map`);
+    if (oldKey !== newKey && Object.hasOwn(map, newKey)) {
+      throw new Error(`krm-stream: ${pathKey([...path, newKey])} already exists`);
+    }
     const renamed: Record<string, unknown> = {};
     for (const [k, v] of Object.entries(map)) renamed[k === oldKey ? newKey : k] = v;
     setAt(res.draft, path, renamed);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/krm-stream/src/store.ts` around lines 179 - 189, Update renameKey to
detect when newKey already exists in the map as a distinct key from oldKey, and
reject the operation before rewriting or settling the map. Preserve the existing
rename behavior when no collision exists.
gateway/handler.go (1)

101-118: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Authenticate before scope validation. ScopePolicy.Validate returns distinct errors for allowlisted targets/resources and namespace semantics, so an unauthenticated caller can enumerate the allowlist by probing query params before o.Principal(r) runs. Move the principal check first so unauthenticated requests always get the same refusal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/handler.go` around lines 101 - 118, In the handler returned by the
visible HTTP HandlerFunc, move the o.Principal(r) authentication check before
ScopeFromQuery and o.Scopes.Validate. Ensure any principal error immediately
calls refuse with Forbidden("not authenticated") and returns, so unauthenticated
requests cannot reach scope validation; preserve the existing scope error
handling for authenticated requests.
gateway/stream.go (1)

226-236: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prune stale revisions entries at snapshot boundaries revisions is per connection, but only live WatchDeleted events remove keys. If an object disappears during a resync, its redaction state can stay resident for the rest of the stream and grow with churn. Clean out UIDs not present in emitted right after EventSynced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/stream.go` around lines 226 - 236, At the InitialEventsEnd branch in
the watch stream, immediately after emitting EventSynced, prune the
per-connection revisions map by removing every UID not present in emitted. Keep
revisions for currently emitted objects and preserve the existing EventSynced
observation and error flow.
♻️ Duplicate comments (2)
gateway/stream.go (1)

280-297: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Deletion events still bypass the monotonicity/staleness check — same issue flagged and marked resolved in a prior review.

WatchDeleted is emitted and prunes emitted/digests/revisions unconditionally, with no isStale check against the tombstone's resourceVersion (when available). A stale/out-of-order delete replayed by the upstream watch can therefore prune state for — and tell the consumer to remove — an object that has since been re-added/modified within the same cycle. This is the exact gap a previous review comment on this range proposed fixing, and that thread is marked "✅ Addressed in commits b3fac4c to dfcf5a0", but the fix is not present in this code.

Proposed fix
 		case WatchDeleted:
 			id := identityOf(ev.Object)
 			if id == nil {
 				return ResyncRequired("deletion tombstone carried no trustworthy uid")
 			}
+			stale, err := isStale(g.Ordering, emitted, id.UID, ev.Object)
+			if err != nil {
+				return err
+			}
+			if stale {
+				continue
+			}
 			if err := sink.Emit(ctx, Event{Type: EventDeleted, Identity: id}); err != nil {
 				return err
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@gateway/stream.go` around lines 280 - 297, Update the WatchDeleted branch to
apply the existing isStale monotonicity check using the tombstone’s
resourceVersion when available before emitting EventDeleted or pruning emitted,
digests, and revisions. Ignore stale/out-of-order tombstones while preserving
the current resync behavior for deletions without a trustworthy identity.
packages/krm-stream/src/sse.ts (1)

141-144: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle signals that are already aborted.

AbortSignal does not replay its abort event to newly registered listeners. Both clients therefore open a stream when passed an already-aborted signal; check signal.aborted before invoking fetch or constructing EventSource.

Proposed fix
   const controller = new AbortController();
   const fetchImpl = opts.fetch ?? globalThis.fetch;
-  if (opts.signal) opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
+  if (opts.signal?.aborted) controller.abort();
+  else if (opts.signal) opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
 export function connectWithEventSource(
   url: string,
   store: LiveResourceStore,
   opts: Omit<StreamOptions, "fetch" | "headers"> = {},
 ): StreamHandle {
+  if (opts.signal?.aborted) {
+    return { close: () => {}, closed: Promise.resolve() };
+  }
   const es = new EventSource(url, { withCredentials: true });

Also applies to: 188-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/krm-stream/src/sse.ts` around lines 141 - 144, Update
connectResourceStream and the corresponding EventSource client path around the
signal-listener setup to check opts.signal.aborted before invoking fetch or
constructing EventSource. Immediately abort or return the existing closed-stream
behavior for an already-aborted signal, while preserving the current listener
wiring for signals that abort later.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@gateway/README.md`:
- Around line 273-274: The README bullet conflates cluster-scoped resources with
all-namespaces watches. Split the content into separate bullets: one stating
that cluster-scoped resource kinds such as Node and PersistentVolume are
supported and inherently non-namespaced, and another explaining that a
namespaced resource with no namespace is an explicitly allowlisted
all-namespaces watch, not an accidental wildcard.

In `@packages/krm-stream/src/merge.ts`:
- Around line 109-190: Fix keyed-list conflict path indexing in
mergeAssociativeList so entries whose mergeAssociativeEntry result is undefined
do not cause subsequent conflicts to use incorrect positions. Assign final array
indices only after filtering omitted entries, or otherwise track conflicts by
key and remap them to the finalized output positions before returning; preserve
correct revert and takeTheirs behavior for surviving entries.

---

Outside diff comments:
In `@gateway/handler.go`:
- Around line 101-118: In the handler returned by the visible HTTP HandlerFunc,
move the o.Principal(r) authentication check before ScopeFromQuery and
o.Scopes.Validate. Ensure any principal error immediately calls refuse with
Forbidden("not authenticated") and returns, so unauthenticated requests cannot
reach scope validation; preserve the existing scope error handling for
authenticated requests.

In `@gateway/README.md`:
- Line 394: Update the JSON Merge Patch citation in the gateway documentation
from RFC 7386 to RFC 7396, including both references mentioned in the
surrounding text.
- Line 138: Update the package-shape diagram in gateway/README.md to match the
repository’s actual layout: a flat gateway package with the kube submodule
containing kube.NewBackend. Either label api, auth, watcher, project, stream,
and write as conceptual areas or remove the nonexistent subpackage directories,
while preserving accurate package guidance.

In `@gateway/stream.go`:
- Around line 226-236: At the InitialEventsEnd branch in the watch stream,
immediately after emitting EventSynced, prune the per-connection revisions map
by removing every UID not present in emitted. Keep revisions for currently
emitted objects and preserve the existing EventSynced observation and error
flow.

In `@packages/krm-stream/src/store.ts`:
- Around line 179-189: Update renameKey to detect when newKey already exists in
the map as a distinct key from oldKey, and reject the operation before rewriting
or settling the map. Preserve the existing rename behavior when no collision
exists.

---

Duplicate comments:
In `@gateway/stream.go`:
- Around line 280-297: Update the WatchDeleted branch to apply the existing
isStale monotonicity check using the tombstone’s resourceVersion when available
before emitting EventDeleted or pruning emitted, digests, and revisions. Ignore
stale/out-of-order tombstones while preserving the current resync behavior for
deletions without a trustworthy identity.

In `@packages/krm-stream/src/sse.ts`:
- Around line 141-144: Update connectResourceStream and the corresponding
EventSource client path around the signal-listener setup to check
opts.signal.aborted before invoking fetch or constructing EventSource.
Immediately abort or return the existing closed-stream behavior for an
already-aborted signal, while preserving the current listener wiring for signals
that abort later.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c77103a-0a4e-4cad-afe8-50cf5d410250

📥 Commits

Reviewing files that changed from the base of the PR and between e474d85 and e658ce3.

⛔ Files ignored due to path filters (20)
  • conformance/gen/bodies.json is excluded by !**/gen/**
  • conformance/gen/fixtures.json is excluded by !**/gen/**
  • conformance/gen/sse/bookmark-absorbed.sse is excluded by !**/gen/**
  • conformance/gen/sse/conflict-and-converge.sse is excluded by !**/gen/**
  • conformance/gen/sse/delete-recreate-uid.sse is excluded by !**/gen/**
  • conformance/gen/sse/edit-vs-unrelated-change.sse is excluded by !**/gen/**
  • conformance/gen/sse/key-removed-upstream.sse is excluded by !**/gen/**
  • conformance/gen/sse/named-object-absent.sse is excluded by !**/gen/**
  • conformance/gen/sse/nested-field-removed.sse is excluded by !**/gen/**
  • conformance/gen/sse/partial-object-refused.sse is excluded by !**/gen/**
  • conformance/gen/sse/reconnect-prune.sse is excluded by !**/gen/**
  • conformance/gen/sse/resourceversion-bignum.sse is excluded by !**/gen/**
  • conformance/gen/sse/resourceversion-unorderable.sse is excluded by !**/gen/**
  • conformance/gen/sse/resync-midstream.sse is excluded by !**/gen/**
  • conformance/gen/sse/secret-redaction.sse is excluded by !**/gen/**
  • conformance/gen/sse/snapshot-then-deltas.sse is excluded by !**/gen/**
  • conformance/gen/sse/status-follow-live.sse is excluded by !**/gen/**
  • conformance/gen/sse/status-only-churn.sse is excluded by !**/gen/**
  • conformance/gen/sse/tombstone-without-uid.sse is excluded by !**/gen/**
  • packages/krm-stream/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (84)
  • .devcontainer/Dockerfile
  • .devcontainer/devcontainer.json
  • .devcontainer/post-create.sh
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .release-please-manifest.json
  • README.md
  • Taskfile.yml
  • conformance/README.md
  • conformance/bodies/deploy-web.v1-spec.yaml
  • conformance/bodies/secret-token.v1-wire.yaml
  • conformance/bodies/secret-token.v1.yaml
  • conformance/fixtures/bookmark-absorbed.yaml
  • conformance/fixtures/conflict-and-converge.yaml
  • conformance/fixtures/delete-recreate-uid.yaml
  • conformance/fixtures/edit-vs-unrelated-change.yaml
  • conformance/fixtures/key-removed-upstream.yaml
  • conformance/fixtures/named-object-absent.yaml
  • conformance/fixtures/nested-field-removed.yaml
  • conformance/fixtures/partial-object-refused.yaml
  • conformance/fixtures/reconnect-prune.yaml
  • conformance/fixtures/resourceversion-bignum.yaml
  • conformance/fixtures/resourceversion-unorderable.yaml
  • conformance/fixtures/resync-midstream.yaml
  • conformance/fixtures/secret-redaction.yaml
  • conformance/fixtures/snapshot-then-deltas.yaml
  • conformance/fixtures/status-follow-live.yaml
  • conformance/fixtures/status-only-churn.yaml
  • conformance/fixtures/tombstone-without-uid.yaml
  • docs/adopting.md
  • docs/client-state-model.md
  • docs/extraction-plan.md
  • docs/facts/observed-v1.36.2+k3s1.md
  • docs/naming.md
  • docs/operations.md
  • docs/proposals/0002-real-cluster.md
  • docs/proposals/0004-views-and-bytes.md
  • docs/releasing.md
  • docs/saving.md
  • examples/README.md
  • examples/vanilla-browser/README.md
  • examples/vanilla-browser/index.html
  • examples/vanilla-browser/tests/live-krm.spec.ts
  • gateway/README.md
  • gateway/conformance.go
  • gateway/conformance_test.go
  • gateway/event.go
  • gateway/handler.go
  • gateway/handler_test.go
  • gateway/observe.go
  • gateway/patch.go
  • gateway/patch_test.go
  • gateway/project.go
  • gateway/projection_policy.go
  • gateway/scope.go
  • gateway/scope_policy_test.go
  • gateway/shared.go
  • gateway/shared_test.go
  • gateway/sse.go
  • gateway/stream.go
  • gateway/stream_conformance_test.go
  • gateway/stream_test.go
  • packages/krm-stream-compat/README.md
  • packages/krm-stream-compat/index.d.ts
  • packages/krm-stream-compat/index.js
  • packages/krm-stream-compat/package.json
  • packages/krm-stream/README.md
  • packages/krm-stream/package.json
  • packages/krm-stream/src/index.ts
  • packages/krm-stream/src/merge.ts
  • packages/krm-stream/src/path.ts
  • packages/krm-stream/src/schema.ts
  • packages/krm-stream/src/sse.ts
  • packages/krm-stream/src/store.ts
  • packages/krm-stream/src/types.ts
  • packages/krm-stream/src/url.ts
  • packages/krm-stream/src/version.ts
  • packages/krm-stream/test/conformance.test.ts
  • packages/krm-stream/test/conformance.ts
  • packages/krm-stream/test/invariants.test.ts
  • packages/krm-stream/test/wire.test.ts
  • release-please-config.json
  • spec/events.schema.json
  • spec/v1.md
🚧 Files skipped from review as they are similar to previous changes (35)
  • conformance/fixtures/resourceversion-bignum.yaml
  • conformance/bodies/secret-token.v1.yaml
  • conformance/fixtures/bookmark-absorbed.yaml
  • .release-please-manifest.json
  • packages/krm-stream/README.md
  • packages/krm-stream/src/version.ts
  • docs/facts/observed-v1.36.2+k3s1.md
  • packages/krm-stream/src/url.ts
  • conformance/bodies/secret-token.v1-wire.yaml
  • conformance/fixtures/resourceversion-unorderable.yaml
  • release-please-config.json
  • gateway/sse.go
  • conformance/fixtures/partial-object-refused.yaml
  • docs/releasing.md
  • packages/krm-stream/test/wire.test.ts
  • examples/vanilla-browser/index.html
  • conformance/fixtures/tombstone-without-uid.yaml
  • gateway/project.go
  • examples/vanilla-browser/tests/live-krm.spec.ts
  • conformance/README.md
  • packages/krm-stream/src/path.ts
  • gateway/conformance_test.go
  • packages/krm-stream/src/index.ts
  • gateway/stream_conformance_test.go
  • .github/workflows/release.yml
  • gateway/handler_test.go
  • packages/krm-stream/package.json
  • gateway/shared.go
  • .github/workflows/ci.yml
  • .devcontainer/Dockerfile
  • README.md
  • examples/vanilla-browser/README.md
  • conformance/fixtures/secret-redaction.yaml
  • docs/proposals/0002-real-cluster.md
  • Taskfile.yml

Comment thread gateway/README.md Outdated
Comment thread packages/krm-stream/src/merge.ts
@sunib sunib changed the title feat: krm-stream — the protocol, both implementations, and a real cluster to check them against feat: add KRM resource streaming library Jul 14, 2026
@sunib
sunib merged commit f415a97 into main Jul 14, 2026
8 checks passed
@github-actions github-actions Bot mentioned this pull request Jul 14, 2026
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