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.