From 7c2d2b16aa5a0295de038a9c4c9795e5ae1866fa Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 14 Jul 2026 10:52:28 +0000 Subject: [PATCH] docs: restore the guard's own section, and tighten two pages saving.md had a structural bug I introduced when I added the 204 section: the "The guard rejects:" list and everything after it describes ValidateMergePatch, but my two new sections were inserted between the guard and its own description. It read as though the bullets described gateway.Project. The guard's rules are back next to the guard, under a heading of their own, and the 204 and adoptSaved sections follow. Also trimmed: the em-dashes, "this is the recommended shape and it is the one to reach for unless you have a specific reason not to" (which says nothing the heading does not), and four paragraphs in why-a-gateway.md that took a run-up to every point. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/saving.md | 55 ++++++++++++++++++++----------------------- docs/why-a-gateway.md | 23 +++++++++--------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/docs/saving.md b/docs/saving.md index 3784933..00b3185 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -35,25 +35,38 @@ func (s *server) saveConfigMap(w http.ResponseWriter, r *http.Request) { } ``` -## Answer 204 and let the watch echo it +## What the guard rejects + +- a value declared in `redacted`, including deletion of a parent map such as `data: null`; +- `metadata.managedFields` and the last-applied-configuration annotation, which every projection + removes; +- `status` under `krm-spec/v1`; +- a non-object or malformed JSON merge patch. + +It does not grant write permission, choose a projection, fetch the object, issue a PATCH, or implement +optimistic concurrency. Those stay with the host. Do not use whole-object `PUT`: projected objects are +intentionally incomplete, and a `PUT` can delete fields the browser never saw. + +`metadata.resourceVersion` may be stale when `krm-spec/v1` suppresses invisible status churn. Do not +use the streamed value as a write precondition. The client-side three-way merge surfaces conflicts in +the fields the user can see; send only the user's explicit merge-patch changes. -This is the recommended shape, and it is the one to reach for unless you have a specific reason not -to. +## Answer 204 and let the watch echo it -The object returned by a Kubernetes write is a *raw* object: `managedFields`, the last-applied -annotation, `status`, and the Secret values your projection withholds. Writing it to the response -hands the browser, through your save endpoint, precisely what the stream spent its whole design -refusing to send. The save endpoint is not covered by the projection unless you cover it. +The object a Kubernetes write returns is a *raw* object: `managedFields`, the last-applied annotation, +`status`, and the Secret values your projection withholds. Writing it to the response hands the +browser, through your own save endpoint, exactly what the stream is designed to refuse. The save +endpoint is not covered by the projection unless you cover it. -You do not need to. The write goes to the API server, the watch sees it, and it arrives back down the -stream as an ordinary `modified` event — projected, redacted, three-way merged into the draft the user -is still holding. The store converges on its own. Dirty state is derived from `draft` versus `server`, -so there is nothing to clear and nothing to adopt: the echo settles it. +You do not need to. The write reaches the API server, the watch sees it, and it comes back down the +stream as an ordinary `modified` event: projected, redacted, and three-way merged into the draft the +user is still holding. Dirty state is derived from `draft` versus `server`, so there is nothing to +clear and nothing to adopt. The echo settles it. ## If you must answer with the object -`store.adoptSaved(object)` exists for a host that already holds a **projected** object — a host doing -its own optimistic update, or one that cannot wait a round-trip for the echo. Project it first: +`store.adoptSaved(object)` is for a host that already holds a **projected** object, such as one doing +its own optimistic update. Project it first: ```go projected, redacted := gateway.Project(gateway.ProjectionFull, result) @@ -64,22 +77,6 @@ writeJSON(w, projected) `gateway.Project` applies the same projection the stream applies. Never hand `adoptSaved` an object straight from the Kubernetes client. -The guard rejects: - -- a value declared in `redacted`, including deletion of a parent map such as `data: null`; -- `metadata.managedFields` and the last-applied-configuration annotation, which every projection - removes; -- `status` under `krm-spec/v1`; -- a non-object or malformed JSON merge patch. - -It does not grant write permission, choose a projection, fetch the object, issue a PATCH, or implement -optimistic concurrency. Those stay with the host. Do not use whole-object `PUT`: projected objects are -intentionally incomplete, and a `PUT` can delete fields the browser never saw. - -`metadata.resourceVersion` may be stale when `krm-spec/v1` suppresses invisible status churn. Do not -use the streamed value as a write precondition. The client-side three-way merge surfaces conflicts in -the fields the user can see; send only the user's explicit merge-patch changes. - ## Creating and deleting whole objects A create and a delete are host writes exactly as a save is, and they stay host-side for the same diff --git a/docs/why-a-gateway.md b/docs/why-a-gateway.md index 678415e..5d598f2 100644 --- a/docs/why-a-gateway.md +++ b/docs/why-a-gateway.md @@ -44,21 +44,20 @@ and no reconnection logic in your application. ## Why watches are shared A watch is not free upstream. Each one is a connection and a registered watcher on the API server, -and it delivers every event in its scope. Ten tabs on the same namespace, watching directly, are ten -watches, ten snapshots and ten copies of the same object graph. Close a floor of laptop lids and -reopen them and the reconnect storm arrives at the API server multiplied by the number of tabs. +delivering every event in its scope. Ten tabs on the same namespace, watching directly, are ten +watches, ten snapshots and ten copies of the same object graph. Reopen a floor of laptops at once and +that reconnect storm hits the API server multiplied by the number of tabs. [`gateway.SharedBackend`](../gateway/shared.go) opens one upstream watch per scope rather than per tab, and serves every subscriber from its cache. A tab joining a scope that is already open gets its `reset`…`synced` snapshot from that warm cache without reaching the API server at all. -Sharing is opt-in, and the reason is a real trade. A shared watch can be opened only once, so it can -be opened as only one identity: your service account. Without sharing, the client acts as the caller -and Kubernetes RBAC is the enforcement, so no bug in this library can hand a caller an object they -may not see. With sharing, your `Authorizer` becomes the only thing between a caller and the cache. +Sharing is opt-in, because the trade is real. A shared watch can be opened only once, so it runs as +one identity: your service account. Without sharing, the client acts as the caller and Kubernetes RBAC +enforces the boundary, so no bug in this library can hand someone an object they may not see. With +sharing, your `Authorizer` is the only thing between a caller and the cache. -There is a way to take the fan-out without giving up the boundary. Pair `SharedBackend` with -[`kube.SSARAuthorizer`](../gateway/kube/authz.go), which asks the API server, through a -SubjectAccessReview, whether this user may list and watch this resource here, before the subscriber -is served from the shared cache. Kubernetes decides again, per user, per snapshot cycle, and the -sharing costs one round-trip. Read [auth.md](auth.md) before wiring it. +You can have both. Pair `SharedBackend` with [`kube.SSARAuthorizer`](../gateway/kube/authz.go), which +asks the API server through a SubjectAccessReview whether this user may list and watch this resource +here, before serving them from the shared cache. Kubernetes decides again, per user, per snapshot +cycle, at the cost of one round-trip. Read [auth.md](auth.md) before wiring it.