From b5cc77953bc58acf873fc7999c8c263af77be1d2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sun, 4 Oct 2026 21:21:26 +0000 Subject: [PATCH 1/3] docs: propose the upstream continuation design for review Priority 4 of proposal 0006 asks for the exhaustion transition, and the choice between client-go's utilities and a small loop, to be settled before implementation. This adds that design, and nothing else. - Continuation lives in gateway/kube's watcher, beneath the gateway and SharedBackend, so one reopen serves every subscriber and the wire is unchanged. - client-go's RetryWatcher is not used: it cannot own the streaming list's initial snapshot, retries most failures without a bound, and makes 401/403 terminal where a fresh cycle can refresh credentials. - The checkpoint is the last upstream event or bookmark resourceVersion, taken before projection. Continuation is armed only after the snapshot boundary. - 410 resyncs, 401/403 start a fresh cycle, and transient failures get at most three backoff reopens. A watch that dies before an event counts as a failure. Then ErrWatchClosed hands over to the gateway's existing quick-end rule and the client's bounded budget. - The authorization and credential-lifetime consequences are stated, with one open question (capping continuation age), and an evidence and measurement plan is given. Co-Authored-By: Claude Opus 5.5 --- ...006-stream-and-save-implementation-plan.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index c5f756d..cff7737 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -148,6 +148,89 @@ do not label by UID, username or opaque RV. One shared watch still incurs a snap browser reconciliation per subscriber; measure those costs rather than inferring capacity from upstream watch count. +### Proposed design (for review before implementation) + +Nothing below is implemented. It answers the questions this section requires settled first: the +client-go assessment, the checkpoint and boundary rules, the exhaustion transition and the +authorization consequences. + +**Where.** In `gateway/kube`, inside the watcher the backend returns. The gateway core, its snapshot +loop and `SharedBackend` do not change: to them a continued watch is one upstream watch that never +ended. One reopen therefore serves every subscriber of a shared scope, and nothing new reaches the +browser: no event, no reset, no replay protocol. + +**Why not client-go's `RetryWatcher` (v0.36.0).** It has the reopen mechanics but conflicts with +three requirements here. It needs a concrete initial resourceVersion, so it cannot own the streaming +list's initial snapshot and could only take over after the boundary. It retries every failure except +410 and authorization indefinitely, "leaving it up to the user to timeout", where this plan requires +bounded failure. And it turns 401/403 into terminal error events, where this design wants a fresh +cycle so the host's `Clients` can supply refreshed credentials. Wrapping it to impose those rules +would be larger than a small loop in `backend.go` that reuses `translate`, `classify` and the +existing bookmark handling. + +**Checkpoint.** The resourceVersion of the last event the API server delivered on this watch: added, +modified, deleted, a routine bookmark or the initial-events-end bookmark. It is recorded in the +backend, before the gateway projects or suppresses anything, so a suppressed update still advances +it. It is never derived from a browser object, an SSE `seq` or a projected or shared view. Bookmarks +only move it forward sooner; no bookmark cadence is assumed. + +**Boundary.** Continuation is armed only once the snapshot boundary is established: the +initial-events-end bookmark on the streaming-list path, the list's resourceVersion on +list-then-watch. A close before that returns `ErrWatchClosed` as today, so a partial snapshot is +never relabeled as live or complete. + +**Reopen.** When an armed watch closes cleanly (the API server's routine timeout, 30 to 60 minutes +by default), the watcher reopens `Watch` with the scope's selectors, `allowWatchBookmarks` and +`resourceVersion` set to the checkpoint, and without `sendInitialEvents`. Both initialization paths +reopen the same way. The reopened watch's events continue the stream, and the gateway never sees the +seam. + +| Reopen outcome | Result | +|---|---| +| 410 Gone or `Expired`, when opening or as a watch error event | `ResyncRequired`: history is gone, so a fresh snapshot follows, as today | +| 401 or 403 | `ErrWatchClosed`: a fresh cycle reauthorizes and asks `Clients` again, so expired credentials are replaced; a refusal there is terminal, exactly as at opening today | +| Network failure, 429, 5xx or timeout | retry within the bound below | +| Context cancelled, or `Stop` | return at once, abandoning a reopen in progress | + +**Exhaustion transition.** At most three consecutive failed reopens, with jittered backoff of +roughly 250 ms, 1 s and 4 s, so a dead upstream is noticed within about six seconds. A reopened +watch that closes again before delivering any event, bookmarks included, counts as a failure, so a +server that accepts and immediately drops watches cannot loop. The count resets when a reopened +watch delivers an event. At the bound the watcher returns `ErrWatchClosed`: the gateway starts a +fresh cycle, its existing rule turns a second early end into `UPSTREAM_UNAVAILABLE` and closes the +connection, and the client's own bounded retry budget takes over. Subscribers never report `live` +over a dead upstream for longer than that, and no public retry setting is added. + +**Authorization and credentials.** Each routine upstream close used to start a cycle, and with it a +cycle authorization check and a `Clients` call. With continuation those happen far less often. That +cadence was never a revocation bound: quiet streams already relied on `ReauthorizationInterval`, +whose timed checks run per subscriber and keep running through an upstream reopen, because the cycle +they belong to has not ended. What does change is credential lifetime: a per-user backend is now +used for the whole stream rather than for one routine watch period. The 401 fallback above refreshes +credentials instead of ending the stream, and the [authorization guide](../auth.md) must say that a +backend's credentials should refresh themselves (client-go token sources do) or be renewed through a +fresh cycle. Open question for review: whether to also cap continuation age, so that a fresh cycle +still happens periodically. The proposal is not to, because timed checks and the 401 fallback cover +the cases a cap would; measurements may say otherwise. + +**Shared watches.** `SharedBackend` wraps the upstream backend, so continuation happens beneath its +cache: one reopen per scope and no resnapshot for any subscriber, which also removes a +SubjectAccessReview per subscriber per routine close. Its single-subscriber overflow recovery and +early-end backoff are unchanged and still apply to the fallbacks. The last subscriber leaving stops +the upstream watcher, which cancels a reopen in progress. + +**Evidence plan.** Unit tests with a fake dynamic client cover each row above, the checkpoint on a +suppressed update and on a routine bookmark, deletion during the interruption, a close during the +initial snapshot, exhaustion, cancellation and subscriber departure during backoff, and two shared +subscribers seeing one reopen and no reset. Against the real API server, a package-internal test +sets a short watch `timeoutSeconds` so routine closes happen in seconds, on both the streaming-list +path and the aggregated API's list-then-watch path. Measurements use the same workload before and +after on the spike cluster, with counts from the gateway's Observer and a counting backend: upstream +reopens, downstream resets by cause, snapshot bytes and duration, the browser store's time to apply +a snapshot (the node driver times it), recovery latency, SubjectAccessReview rate and latency, and +the save 409 rate with the share of 409s that carry no field conflicts. Labels stay bounded: no UID, +user or resourceVersion. + **Acceptance:** retained-history recycling preserves downstream continuity, lost history still recovers safely, retries stop correctly, and authorization/credential lifecycle expectations remain explicit. No new SSE events or downstream replay protocol. From 1cdf1c4f6e2f3df65a5c8d6a55d08aac3ee9c55b Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 10:06:40 +0000 Subject: [PATCH 2/3] docs: re-center on watch streams with optional editing and a native watch direction Lead with live resource views and treat editing as an optional layer. Record native Kubernetes watch JSON through a host proxy as the preferred transport direction in a new field report, slim completed proposals and remove superseded field reports and the duplicate Vue guide. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 15 + README.md | 311 ++++----- SECURITY.md | 6 +- conformance/README.md | 4 + docs/adopting.md | 31 +- docs/alternatives.md | 8 +- docs/auth.md | 7 +- docs/client-state-model.md | 84 ++- docs/field-reports/first-fieldreport.md | 262 -------- docs/field-reports/second-fieldreport.md | 167 ----- docs/field-reports/third-our-identity.md | 137 ++++ docs/glossary.md | 183 +++--- docs/migrating.md | 4 +- docs/operations.md | 12 +- ...05-kubernetes-stream-and-save-semantics.md | 30 +- ...006-stream-and-save-implementation-plan.md | 272 ++++---- .../0007-shared-stream-host-integration.md | 261 +------- docs/proposals/0008-shared-watch-hardening.md | 237 +------ .../0009-stream-and-editor-separation.md | 622 ++++-------------- docs/proposals/0010-gateway-api-cleanup.md | 224 ++----- docs/saving.md | 25 +- docs/vue.md | 35 - docs/why-a-gateway.md | 120 +++- examples/README.md | 7 +- examples/conditional-save/README.md | 10 +- examples/vanilla-browser/README.md | 6 + examples/vue/README.md | 40 +- gateway/README.md | 22 +- packages/krm-stream/README.md | 38 +- spec/v1.md | 15 +- 30 files changed, 1101 insertions(+), 2094 deletions(-) delete mode 100644 docs/field-reports/first-fieldreport.md delete mode 100644 docs/field-reports/second-fieldreport.md create mode 100644 docs/field-reports/third-our-identity.md delete mode 100644 docs/vue.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1318539..3d31625 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,11 @@ Run `task fixtures-check`, `task test`, and `task lint` before opening a pull re ## Design rules +Start with watch streams and authoritative resource views; add editing as an optional layer. +The [README](README.md) explains current capabilities and the native-fetch direction. Current API +examples must match this branch; native transport, editor cleanup and save-progress evaluation +remain proposals until implemented. + - Keep the core gateway free of `client-go`; Kubernetes integration belongs in `gateway/kube`. - Keep the browser client framework-free and free of runtime dependencies. - Keep credentials, application identity, authorization policy, and writes in the host application. @@ -34,6 +39,16 @@ Run `task fixtures-check`, `task test`, and `task lint` before opening a pull re Protocol compatibility is explicit. Additive optional fields are allowed when consumers can ignore them; incompatible event semantics require a new protocol version rather than a silent reinterpretation. +## Documentation changes + +Keep viewing and transport guidance in [watching resources](docs/why-a-gateway.md), editor behavior in +[the editor model](docs/client-state-model.md) and writes in [saving](docs/saving.md). Examples own +their framework-specific guidance. Link to these contracts rather than repeating them in proposals. +Retain decisions and useful evidence when shortening completed plans; remove superseded request +inventories and duplicate guides. Preserve dated observations and generated release history. +Documentation-only changes need local link/anchor checks, snippet/API review and diagram review; +cluster and runtime suites are required when behavior changes, not for prose alone. + ## Fixtures Fixtures use source YAML in `conformance/bodies/` and `conformance/fixtures/`. Run `task fixtures` to diff --git a/README.md b/README.md index c6fb117..f7733da 100644 --- a/README.md +++ b/README.md @@ -10,215 +10,183 @@ # krm-stream -Live Kubernetes resource updates for browser apps. +Efficient live Kubernetes views for browser applications, with optional editing. -`krm-stream` turns a Kubernetes watch into a small, browser-safe stream. It ships a Go gateway, a -headless TypeScript store with zero runtime dependencies, and shared conformance fixtures, so your -product can show live cluster state while people are editing it. +`krm-stream` helps applications watch Kubernetes resources while controllers and other users keep +changing them. Its gateway delivers a defined resource view, withholds selected values, suppresses +irrelevant updates and optionally shares upstream watches. Its headless TypeScript client maintains +live state and reconciles incoming changes with local drafts when a page needs editing. -## Is this for you? +Your application supplies authentication, authorization policy, Kubernetes credentials, UI and writes. +The browser client has zero runtime dependencies and chooses no UI framework. -**Yes, if:** +## Start with the watch -- You want to consume a Kubernetes **watch from a browser**, without handing the browser a cluster - credential or turning on CORS across your API server. -- You want to **live-edit Kubernetes resources**, where a concurrent server change is merged into - what the user is typing rather than clobbering it, and a genuine conflict is surfaced instead of - silently resolved. That is the [three-way merge](docs/glossary.md). -- You want to **bound the number of real watches** on your API server. Ten tabs on one namespace - should be one upstream watch, not ten. -- You are building a **product**, not a Kubernetes dashboard. The store is headless and picks no UI - framework. +Kubernetes already provides a change feed. krm-stream adds a browser lifecycle and resource-view +contract, reducing work at different points: -**Probably not, if:** +| Capability | Benefit | Boundary | +|---|---|---| +| Scope | Watch a resource kind, namespace, name or allowed label selector | The host authorizes the scope; a selector does not grant access | +| Projection and redaction | Deliver the fields a view needs; full/spec views withhold Kubernetes Secret values | Secret key paths and change revisions remain visible; arbitrary sensitive CRD fields are not automatically redacted | +| Update suppression | Avoid downstream events when projected content and redaction records are unchanged | The gateway still receives upstream changes; fewer events do not guarantee a current write version | +| Optional watch sharing | Use one upstream watch for matching scopes on the same shared backend | Every subscriber is authorized separately and receives its own snapshot and updates | -- You just want a **generic three-way merge library**. This store includes KRM identity, projection - and redaction rules, plus optional schema-based keyed-list merging. -- You want a **ready-made Kubernetes dashboard**. Use [Headlamp](https://headlamp.dev/). See - [alternatives](docs/alternatives.md). -- You want to **write to the cluster from the browser**. krm-stream is the read-and-edit half: it - captures a merge patch and version together. Your application validates and performs the write. - See [saving edits safely](docs/saving.md). +Choose a view explicitly: -## What is KRM? +| View | Delivered content | Updates suppressed | +|---|---|---| +| `krm-full/v1` (default) | Resource including status, with Secret values withheld | Bookkeeping-only changes | +| `krm-spec/v1` | Full view with status omitted | Bookkeeping-only and status-only changes | +| `krm-raw/v1` | Secret values included when host policy permits | Bookkeeping-only changes | -**KRM** is the Kubernetes Resource Model: the shape every Kubernetes object has (`apiVersion`, -`kind`, `metadata`, and kind-specific fields such as `spec`, `status` or ConfigMap `data`). Custom -resources use the same conventions, -which is why this works for your product's own objects, a `Database`, a `FeatureFlag`, a `Tenant`, -and not only for cluster infrastructure. +All three remove `metadata.managedFields` and the last-applied-configuration annotation. +`krm-raw/v1` is still a projection. A hidden Secret rotation produces a redaction update in full/spec. -Never touched a cluster? The [glossary for frontend developers](docs/glossary.md). +Each browser connection starts with a complete snapshot, then follows visible changes. On reconnect, +a fresh snapshot repairs missed changes and deletes; resources are pruned only when it completes. +This is a live state feed: intermediate updates may be coalesced. See [watching resources](docs/why-a-gateway.md). -## Why a gateway +## Transport direction and current support -Kubernetes already has a good change feed: a watch, documented under -[efficient detection of changes](https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes). -Direct browser access requires exposing cluster credentials and arranging cross-origin access. -A raw watch also carries whole objects, including Secret values. The embedded gateway uses host-owned -credentials, enforces the selected disclosure policy and emits SSE. Hosts can opt into one shared -upstream watch per scope with per-subscriber authorization. +The preferred direction is to consume native Kubernetes watches through a host proxy using fetch, +with one frontend lifecycle for state, errors, cancellation and bounded recovery. Hosts that already +provide native API access should not need to translate it into SSE to reuse the client. +Keep gateway SSE as a compatibility path for applications choosing that protocol, and retain the +projected gateway for redaction, suppression and watch sharing. -[Why a gateway](docs/why-a-gateway.md) works through this in full. +**Today, the supported connector consumes gateway SSE through fetch.** The native watch connector +is [requested work](docs/field-reports/third-our-identity.md#native-watch-connector), not an available API. +SSE framing can carry errors; the current fetch connector handles HTTP refusals and in-stream errors, +including authentication expiry. The native direction simplifies framing and integration rather than +introducing error handling for the first time. No transport fallback may bypass a refused view. -## How it fits +## Watch a resource view today -```mermaid -flowchart LR - api["Kubernetes API"] - gateway["Embedded Go gateway
Enforces host authorization
Projection and redaction"] - store["Browser store
Delivered state, local draft and conflicts"] - ui["Your form or live view"] - save["Your Go save handler
Write authorization and patch validation"] - - api -->|"Snapshot and watch updates"| gateway - gateway -->|"One-way SSE"| store - store -->|"Render draft and live status"| ui - ui -->|"Local edits"| store - ui -->|"On Save: capture patch, UID and RV from store"| save - save -->|"Conditional PATCH"| api - - classDef library fill:#dff3ff,stroke:#1677a4,color:#062f45; - classDef application fill:#e4f7e8,stroke:#27834c,color:#113d23; - classDef upstream fill:#f8e0ef,stroke:#a83970,color:#4b1230; - class gateway,store library; - class save,ui application; - class api upstream; -``` +```ts +import { + LiveResourceStore, readOnlyPolicy, applyStreamEvent, + connectResourceStream, resourceStreamURL, +} from "@configbutler/krm-stream"; -The library owns the read stream and browser reconciliation. Your application owns identity, -authorization policy, Kubernetes credentials, and writes. The browser never receives a Kubernetes -credential or a raw API-server URL. The blue boxes are the library; the green boxes are your product. -Both Go components run inside your application: the host decides authorization policy, and the -embedded gateway enforces it. A successful write returns through the Kubernetes watch as another -live update. +const store = new LiveResourceStore(readOnlyPolicy); +const stopRendering = store.subscribe(() => renderResources(store)); +const connection = connectResourceStream( + resourceStreamURL("/resource-stream/v1", { + target: "production", version: "v1", resource: "configmaps", namespace: "app", + }), + event => applyStreamEvent(store, event), +); +renderConnection(connection.state.status); +const stopConnection = connection.subscribe(state => renderConnection(state.status)); +connection.closed.catch(reportApplicationError); + +// On view disposal: +stopRendering(); +stopConnection(); +connection.close(); +``` -Each connection starts with a complete projected snapshot, then follows visible changes. The store -keeps that delivered state separate from local edits. A quiet stream can still hold an older write -version: see [why a quiet stream can reject a save](docs/saving.md#why-a-quiet-stream-can-still-reject-a-save). +The current viewer uses `LiveResourceStore(readOnlyPolicy)`; a dedicated read-only store is deferred. +The connector delivers state events independently of editing. It uses same-origin cookies by default, +exposes connection state and bounded retries, and stops on terminal refusals. Apply each event +synchronously. The [client README](packages/krm-stream/README.md) explains lifecycle and errors. +For a browser without a bundler, the same API is available in one file through +`@configbutler/krm-stream/bundle`. -## Start here +## Add editing when the page needs it -**Want it running in minutes?** [krm-foyer](https://github.com/ConfigButler/krm-foyer) is a -ready-made backend for browser apps on Kubernetes: OIDC sign-in, server-side sessions, a `/k8s` API -proxy, and krm-stream's live resources, with each watch opened as the signed-in user so Kubernetes -RBAC decides what they see. Its `task demo` starts a test cluster and an example app. Start there if -you don't already have a Go backend to embed the gateway in. To build the integration yourself, read -on. +Use `new LiveResourceStore()` for an editor. It keeps the last delivered server object separate from +the person's draft. Incoming changes update untouched fields, preserve local edits and record +conflicts when both sides changed the same editable field differently. -There are two halves, and they are usually two different people. +For example, someone changes a Deployment's image while an autoscaler changes its replicas. The +replicas follow the server and the image edit stays. If another person changes that image to a +different value, the editor keeps the local value and exposes the disagreement for review. -### The browser half +```ts +const store = new LiveResourceStore(); // use this store in the connection setup for an editor +store.setValue(uid, ["data", "message"], "hello"); // ConfigMap field edit +store.conflicts(uid); // disagreements to resolve before Save +const intent = store.captureSave(uid); // detached { uid, resourceVersion, patch } +// Your save controller submits this intent after review while the connection is live. +``` -No bundler, no framework, no Kubernetes client. The connector uses fetch and hands each resource -event to a callback; the store is plain ESM: +The default policy allows `spec`, labels, annotations, `data` and `stringData`; status, immutable +metadata and redacted paths remain read-only. A host can narrow the policy for its form. -```ts -import { LiveResourceStore, applyStreamEvent, connectResourceStream, resourceStreamURL } from "@configbutler/krm-stream"; +The intended Save flow is explicit: capture the patch, UID and resource version together; have the +host authorize and validate it; apply a conditional merge PATCH; then observe the projected result +through the stream or a guarded projected read. Preserve typing made after Save. A version rejection +can occur without any field conflict, because suppressed updates still advance Kubernetes versions. +The current recovery is a guarded read, review and another deliberate Save. -const store = new LiveResourceStore(); +**A dirty draft, an accepted write and application progress are separate states.** A successful PATCH +can precede its watch observation, and neither proves a workload has finished rolling out. -const connection = connectResourceStream( - resourceStreamURL("/resource-stream/v1", { - target: "production", - version: "v1", - resource: "configmaps", - namespace: "app", - }), - (event) => { - const change = applyStreamEvent(store, event); - render(change.uid); // what moved, and which resource it moved on - }, -); -connection.subscribe((state) => renderConnection(state.status)); // connecting, syncing, live, retrying… -connection.closed.catch(reportApplicationError); // the callback threw, and the stream stopped +Use [the editor state model](docs/client-state-model.md) for reconciliation, conflict resolution and +arrays, and [saving edits safely](docs/saving.md) for the complete host-owned write contract. The +[conditional-save example](examples/conditional-save/README.md) executes that contract. -// The user edits. The server keeps changing underneath them. Neither wins by accident. -store.setValue(uid, ["spec", "replicas"], 3); -store.conflicts(uid); // paths where the server disagreed with an edit the user actually made -store.patch(uid); // an RFC 7386 merge patch of just their changes, or null +## How it fits today -// In your host/view teardown callback: -connection.close(); // stop the stream and pending retries +```mermaid +flowchart LR + api["Kubernetes API"] + gateway["Go gateway
Scopes, views and optional sharing"] + connector["Fetch connector
State events and recovery"] + store["Resource store
Live state and optional drafts"] + ui["Your list, viewer or form"] + save["Your save endpoint
Authorize, validate and conditionally PATCH"] + api -->|"Snapshot and watch"| gateway + gateway -->|"SSE"| connector + connector --> store + store --> ui + ui -->|"Local edits"| store + ui -->|"Captured save intent"| save + save --> api ``` -If you have no bundler at all and vendor the library by copying it, import -[`@configbutler/krm-stream/bundle`](packages/krm-stream/README.md): the same API in one file. - -### The server half - -Mount a scoped stream endpoint in an existing Go application: - -```go -mux.Handle("/resource-stream/v1", gateway.Handler(gateway.Options{ - Principal: func(r *http.Request) (gateway.Principal, error) { return userFromSession(r) }, - Scopes: gateway.ScopePolicy{ - Targets: []string{"production"}, - Resources: []gateway.GroupResource{ - {Resource: "configmaps", Scope: gateway.ResourceScopeNamespaced}, - }, - }, - StreamConfig: gateway.StreamConfig{ - Authorizer: authorizeScope, - Clients: func(_ context.Context, _ string, p gateway.Principal) (gateway.Backend, error) { - return kube.NewBackendForConfig(restConfigFor(p.(*User))) - }, - Projections: gateway.StaticProjection(gateway.ProjectionFull), - }, -})) -``` +The gateway runs inside your Go application. Per-user backends let Kubernetes authorize the caller's +reads; a shared backend uses a service identity and requires checks for each subscriber. Sharing +reduces duplicate upstream work; access controls and host limits govern who can consume it. +Gateway upstream continuation and improved save progress during suppressed churn are proposed work. +New browser connections receive a fresh snapshot under the current protocol. -The Go side owns identity, authorization, the Kubernetes credential, and the scope a caller is -allowed to ask for. It never lets the browser choose which cluster to talk to. +## Start here -`LiveResourceStore` keeps server truth and the local draft separate, reconciles live updates with a -three-way merge, records conflicts, and builds RFC 7386 merge patches. Your application applies any -patch through its own save endpoint, which is the one place a write can happen. +If you already have a Go host, follow [adopting krm-stream](docs/adopting.md). For a ready-made host, +[krm-foyer](https://github.com/ConfigButler/krm-foyer) integrates sign-in, sessions, native API proxying +and krm-stream gateway hosting. The host's dependency version determines which APIs are available. -## Packages +KRM means the Kubernetes Resource Model: `apiVersion`, `kind`, `metadata` and kind-specific fields +such as `spec`, `status` or ConfigMap `data`. Custom resources follow the same conventions. See the +[frontend glossary](docs/glossary.md) and [alternatives](docs/alternatives.md) for context. | Package | Purpose | |---|---| -| `github.com/ConfigButler/krm-stream/gateway` | Dependency-free Go stream gateway and SSE handler. | -| `github.com/ConfigButler/krm-stream/gateway/kube` | Optional `client-go` backend and SubjectAccessReview authorizer. | -| `@configbutler/krm-stream` | Official dependency-free ESM client store and transports. | -| [`spec/v1.md`](spec/v1.md) | Normative protocol contract. | -| [`conformance/`](conformance/) | Shared fixtures exercised by the Go gateway and TypeScript client. | - -## Boundaries - -- No browser token handling or raw API-server URLs. -- No authorization system: the host provides `Principal`, `Authorizer`, and `ClientFor`. -- No write endpoint: hosts validate and apply their own patches. -- No framework dependency in the browser client. -- No `client-go` dependency in the core gateway. - -The important safety rule is simple: a projected or redacted field must never be written back by a -browser. Use [`gateway.ValidateMergePatch`](gateway/patch.go) in the host save handler. +| `github.com/ConfigButler/krm-stream/gateway` | Dependency-free Go stream gateway and SSE handler | +| `github.com/ConfigButler/krm-stream/gateway/kube` | Optional client-go backend and SubjectAccessReview authorizer | +| `@configbutler/krm-stream` | Dependency-free ESM connector and resource/editor store | +| [spec/v1.md](spec/v1.md) and [conformance](conformance/README.md) | Shared normative contract and executable fixtures | ## Guides -- [Glossary for frontend developers](docs/glossary.md): the Kubernetes vocabulary you actually need, and where each word shows up in the library. -- [Why a gateway](docs/why-a-gateway.md): why the browser cannot watch the API server, and why watches are shared. -- [Adopting krm-stream](docs/adopting.md): same-origin cookie, bearer-token, and shared-watch setups. -- [Authentication and authorization](docs/auth.md): identity and RBAC boundaries. -- [Saving edits safely](docs/saving.md): patch validation and host write responsibilities. -- [Operating krm-stream](docs/operations.md): metrics, alerts, and runtime controls. -- [Client state model](docs/client-state-model.md): drafts, conflicts, redactions, and keyed lists. -- [Alternatives and prior art](docs/alternatives.md): how this differs from Kubernetes clients, browser dashboards, and config-as-data systems. -- [Releasing](docs/releasing.md): release workflow and publication prerequisites. -- [Upgrading from 0.7](docs/migrating.md): the one browser connector and the gateway's shared stream configuration. - -## Requirements and maturity - -The project is pre-1.0. Protocol and API changes may still be made before 1.0. +- [Watching resources](docs/why-a-gateway.md): scopes, views, suppression, sharing and recovery. +- [Adoption](docs/adopting.md): host and browser wiring. +- [Editor state model](docs/client-state-model.md): drafts, conflicts, redactions and arrays. +- [Saving](docs/saving.md): conditional writes, recovery and user-facing outcomes. +- [Authorization](docs/auth.md) and [operations](docs/operations.md): identity, revocation and runtime limits. +- [Examples](examples/README.md): browser, conditional editor, recovery recipes and Vue integration. +- [Delivery plan](docs/proposals/0006-stream-and-save-implementation-plan.md): completed work, open work, ordering and dependencies. +- [Upgrading from 0.7](docs/migrating.md) and [releasing](docs/releasing.md). -- Go 1.27.1 for the gateway. -- Node 24 for client development and tests. -- Kubernetes 1.35+ for strict resource-version ordering. `OrderingLenient` supports known - non-conformant or aggregated APIs at the cost of per-object monotonic ordering. +## Requirements and development -## Development +The project is pre-1.0; protocol and API changes may still be made before 1.0. +Go 1.27.1 and Node 24 are required for development. Kubernetes 1.35+ supports strict resource-version +ordering; `OrderingLenient` accommodates known non-conformant or aggregated APIs with a reduced +per-object monotonicity guarantee. ```bash task fixtures-check @@ -227,5 +195,4 @@ task lint task build-client ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for the fixture and test workflow. Licensed under -[Apache-2.0](LICENSE). +See [CONTRIBUTING.md](CONTRIBUTING.md). Licensed under [Apache-2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 6b52977..4cf3ffe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,8 @@ almost all *disclosure* failures. The things we would treat as security bugs: - **A projected or redacted value reaching the browser.** A `Secret` value, `managedFields`, or any path the effective projection withheld appearing in a stream event, an error message, or a save response. The projection is the boundary; a leak through it is the highest-severity bug this - codebase can have. + codebase can have. This applies to the effective projection: explicitly authorized raw/native + access can disclose Secret values, and built-in redaction does not classify arbitrary CRD fields. - **A caller receiving an object outside their authorized scope.** Particularly through [`SharedBackend`](gateway/shared.go), where one upstream watch is fanned out to many subscribers and the host's `Authorizer` is the only thing standing between a caller and the cache. A bug there is @@ -52,4 +53,5 @@ The boundaries this library claims to hold, and where they are enforced: - [docs/auth.md](docs/auth.md): identity, RBAC, and the `SharedBackend` trade. - [docs/saving.md](docs/saving.md): the write path, and why a save answers 204. -- [docs/why-a-gateway.md](docs/why-a-gateway.md): why the browser never holds a cluster credential. +- [docs/why-a-gateway.md](docs/why-a-gateway.md): watch views, Secret disclosure, host-owned credentials + and explicit native/SSE source selection. diff --git a/conformance/README.md b/conformance/README.md index 28782cf..1b01812 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,5 +1,9 @@ # conformance — the shared contract, executable +These fixtures verify the current gateway SSE contract and browser state application. Editing cases +add local drafts to that live-state contract; viewing does not require edit controls. Requested native +watch support will need recovery tests of its own, not an assumption that these SSE fixtures prove it. + **One YAML file describes one scenario end to end**: the Kubernetes watch input, the gateway's wire output, and the client's resulting state after applying events and local edits. The Go and TypeScript suites load the same files, so a contract change is checked on both sides in the same commit. diff --git a/docs/adopting.md b/docs/adopting.md index 8886148..e6fc17d 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -1,8 +1,14 @@ # Adopting krm-stream -This is the recommended route from an existing Go application to a live KRM view in the browser. The -library owns the read path. Your application owns identity, authorization policy, Kubernetes -credentials and writes. +Start with a live resource view, then add editing only where the page needs it. This guide wires the +currently supported gateway stream into an existing Go application. The library owns stream recovery +and browser reconciliation; your application owns identity, authorization policy, credentials and writes. + +Choose scope, projection and sharing separately using [watching resources](why-a-gateway.md). +The preferred transport direction is a native Kubernetes watch through a host proxy and fetch, with +SSE retained for compatibility and existing projected streams. The native connector is +[requested work](field-reports/third-our-identity.md#native-watch-connector); use the supported wiring +below until its lifecycle, recovery and editing contracts are implemented. ## 1. Mount the stream endpoint @@ -101,12 +107,13 @@ Use the `Authorizer` to pin a user to a namespace or target before any watch ope ```ts import { LiveResourceStore, + readOnlyPolicy, applyStreamEvent, connectResourceStream, resourceStreamURL, } from "@configbutler/krm-stream"; -const store = new LiveResourceStore(); +const store = new LiveResourceStore(readOnlyPolicy); // list or viewer const url = resourceStreamURL("/resource-stream/v1", { target: "production", version: "v1", @@ -115,17 +122,25 @@ const url = resourceStreamURL("/resource-stream/v1", { projection: "krm-full/v1", }); +const stopRendering = store.subscribe(() => render(store)); const connection = connectResourceStream(url, event => applyStreamEvent(store, event)); renderConnection(connection.state.status); -connection.subscribe(state => renderConnection(state.status)); // gaps recover with a fresh snapshot -connection.closed.catch(reportApplicationError); // the callback threw, and the stream stopped -store.subscribe(() => render(store)); +const stopConnection = connection.subscribe(state => renderConnection(state.status)); +connection.closed.catch(reportApplicationError); // host callback failures stop the stream + +// On view disposal: +stopRendering(); +stopConnection(); +connection.close(); ``` Fetch sends the same-origin session cookie, and the connection recovers on its own after network failures and sequence gaps. -Saving is the host's: see [saving](saving.md). A quiet stream can still hold an older write version; +For an editor, choose `new LiveResourceStore()` with the default policy or a form-specific policy. +Render the draft, edit through the store methods and resolve conflicts explicitly; see the +[editor state model](client-state-model.md). Saving is the host's: see [saving](saving.md). +A quiet stream can still hold an older write version; see [why a quiet stream can reject a save](saving.md#why-a-quiet-stream-can-still-reject-a-save). ## 4. Optional: recheck quiet streams on a timer diff --git a/docs/alternatives.md b/docs/alternatives.md index d2096e6..a3c74ce 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -1,10 +1,12 @@ # Alternatives -krm-stream combines a scoped KRM read stream with a browser store for live state, local drafts and -conflicts. Choose tools according to which part of that problem your application needs. +krm-stream starts with efficient live Kubernetes views for browser applications. Its gateway adds +projection, redaction, suppression and optional watch sharing; its client adds lifecycle recovery +and optional draft reconciliation. Choose tools according to the part your application needs. | Need | Relevant approach | Where krm-stream fits | |---|---|---| +| Native watch through an existing browser proxy | Host proxy plus a native fetch/watch consumer | A shared frontend lifecycle is the preferred direction; the native connector is requested, while gateway SSE is supported today. | | Kubernetes API access from a server | Kubernetes client libraries and raw watches | The Go adapter uses `client-go`; the gateway adds projections and browser snapshot framing. | | A complete Kubernetes UI | Dashboard applications and their plugin APIs | krm-stream supplies state and transport; the host builds the UI. | | Review and deliver configuration packages | KRM package and GitOps systems | The host can record accepted writes in its delivery workflow. | @@ -24,5 +26,5 @@ conflicts. Choose tools according to which part of that problem your application - [gitops-reverser](https://reversegitops.dev): a complementary write-and-record workflow. The host connects accepted Kubernetes writes to Git; krm-stream supplies the read and edit side. -The [architecture overview](../README.md#how-it-fits) shows the library/host split. The +The [architecture overview](../README.md#how-it-fits-today) shows the library/host split. The [saving guide](saving.md) describes the conditional-write boundary and its limitations. diff --git a/docs/auth.md b/docs/auth.md index 7eb8216..8a5e942 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -9,7 +9,12 @@ host's scope and projection decisions. How Kubernetes checks a caller depends on | Shared | One service identity | Authorize every subscriber before serving cached objects; use `kube.SubjectAccessReviewAuthorizer` for Kubernetes RBAC decisions. | Prefer a per-user backend: Kubernetes RBAC is then the boundary by construction. The wiring for both -is in [adopting](adopting.md). +is in [adopting](adopting.md). Watch sharing is an efficiency choice; it never grants a subscriber +the service identity's access. Full/spec Secret redaction also does not replace scope authorization. + +These settings describe the current gateway. The requested native fetch connector uses a host proxy +and the same session boundary, but needs its own recovery contract. SSE remains a compatibility path; +changing framing must not bypass refusal or extend session lifetime. ## Browser sessions diff --git a/docs/client-state-model.md b/docs/client-state-model.md index 08c3a21..ea434b9 100644 --- a/docs/client-state-model.md +++ b/docs/client-state-model.md @@ -1,8 +1,27 @@ -# Client state model - -`LiveResourceStore` is a headless TypeScript store for a KRM resource stream. It separates the -authoritative object received from Kubernetes from the user's local draft, so a live update never -silently overwrites an edit. +# Editor state model + +Editing is an optional layer on a live resource view. `LiveResourceStore` keeps the last delivered +server object separate from the person's local draft, incorporates incoming changes and exposes +conflicts for review. The host owns the form and every write; see [saving edits safely](saving.md). +For a list or viewer today, use `LiveResourceStore(readOnlyPolicy)` and render `server(uid)`. +The connector consumes and delivers state events independently of the editor. + +## Intended use + +1. Choose one authorized source, scope and view for the store. Choose the form's editable fields. +2. Apply authoritative state events synchronously with `applyStreamEvent(store, event)`. +3. Render detached store reads and make edits through `setValue`, `removeKey` and related methods. +4. Present incoming changes and resolve conflicts explicitly while preserving the draft. +5. On a deliberate Save, capture the patch, UID and resource version together. Let the host authorize, + validate and conditionally write that intent; preserve later typing during the request. +6. Observe the result through the watch or a guarded read under the same view. Track write acceptance + and workload progress separately from draft dirtiness. + +Do not mutate a `draft()` return value directly: reads are detached copies, and that bypasses edit +policy and notifications. Independent sources/scopes need separate stores. Switching identity or view +must not silently reuse drafts, redactions or snapshot state. Native watch integration is +[requested](field-reports/third-our-identity.md#native-watch-connector); the current guidance assumes +complete projected objects from the gateway. ## State per resource @@ -40,7 +59,14 @@ objects it has not yet reloaded. The default editable regions are `spec`, `metadata.labels`, `metadata.annotations`, `data`, and `stringData`. `status`, immutable metadata, and redacted paths are read-only. -When a new server object arrives, the store compares three values at each editable path: +Suppose a Deployment starts with image `v1` and replicas `3`. The person types image `v2`, then an +autoscaler changes replicas to `5`. The draft becomes image `v2`, replicas `5`: untouched fields follow +the server while local work stays. If another person changes the image to `v3`, the local image `v2` +stays and a conflict records `v3` for review. + +When a new server object arrives, the store compares three values at each editable path: **base** is +the previous server value, **ours** is the draft, and **theirs** is the incoming server value. The +incoming object then becomes the new server base. | Draft differs from base | Incoming server differs from base | Result | |---|---|---| @@ -50,7 +76,9 @@ When a new server object arrives, the store compares three values at each editab | yes | yes, differing from the draft | keep the draft and record a conflict | `isDirty` and `changes` are derived from `draft` versus `server`; neither is a cache that can drift -after a stream update. `revert` or `takeTheirs` restores the current server value. +after a stream update. `revert` restores the current server value. To keep a local value explicitly, +use the [tested keep-local recipe](../examples/editor-recipes/README.md#keep-the-local-value-in-a-conflict). +The UI owns the choice; the store does not silently choose a winner for differing concurrent edits. ## Patches and redactions @@ -88,27 +116,22 @@ const pendingDeletes = new Set(); // uids marked for removal ### Reflecting the result -The recommended shape is still 204 and let the watch echo it (see [saving edits safely](saving.md)): a -create arrives as an `added` event, a delete as a `deleted` event, and the store converges on its own. -Two primitives exist for a host that cannot wait for the echo, and both are idempotent with it -(`I-IDEMPOTENT`): - -- `adoptSaved(object)` — insert the created object once the save returns it. The echo that follows is - a no-op, not a second card. -- `removeResource(uid)` — drop a deleted object before its `deleted` event arrives. +Prefer 204 or a receipt and let the stream reflect creates and deletes. Keep accepted writes separate +from pending confirmation, so a delayed watch echo cannot cause a duplicate submission. A create +arrives with its server-assigned UID; a delete removes only the original UID. A completed snapshot +can confirm missed observations. Account for an echo arriving before the HTTP response as well as +for an echo arriving afterwards. -Either way, clear the page-local entry — the `pendingCreates` draft or the `pendingDeletes` uid — when -its write succeeds. Those primitives update the store, not your pending lists; a completed mutation -left staged reappears in the review list and can be submitted twice. +Current `adoptSaved` and `removeResource` APIs remain available, but the recommended flow uses the +stream. `adoptSaved` is unguarded and must not receive an asynchronous response that can race newer +watch state; a reconciliation guard for an existing UID does not insert a newly created resource. +Optimistic removal after a failed delete cannot recover until an authoritative event or snapshot +restores the object. Do not invent UIDs or fabricate authoritative stream events to represent intent. -Two caveats keep this honest: - -- **A create reflects only _after_ the server responds.** `adoptSaved` needs the server-assigned uid - and a **projected** object (never a raw Kubernetes object — see [saving edits safely](saving.md)). Do - not fabricate a uid for a pending draft; keep it page-local until the create returns the object. -- **An optimistic delete is not self-healing.** A delete that _fails_ server-side produces no watch - event, so a `removeResource`d object does not reappear until the next snapshot. Re-add it on failure, - or skip the optimism and let the `deleted` echo do it. +Deletion and snapshot pruning discard the resource's draft. If recovery matters, capture a detached +copy as edits change, before removal, using the [recovery-copy recipe](../examples/editor-recipes/README.md#recover-work-after-a-deletion). +Retain it under the original identity and UID for explicit copy-out; never transfer it automatically +to a same-name replacement. ## Arrays and associative lists @@ -131,8 +154,8 @@ retain the atomic behavior. Merge patches still send the final array as one RFC ## UI integration -The store has no rendering dependency. Subscribe once, then query `draft`, `status`, `changes`, -`conflicts`, and `redactions` during rendering: +The store has no rendering dependency. Subscribe once, then query `draft`, `server`, `changes`, +`conflicts` and `redactions` during rendering. Connection state belongs to the connector: ```ts const unsubscribe = store.subscribe(() => render(store)); @@ -146,3 +169,8 @@ if (intent) await hostSave(intent); Use the [conditional editor](../examples/conditional-save/README.md) for conflict checks, serialized saves and guarded asynchronous responses. `adoptSaved` is for synchronous adoption or newly created objects; a delayed response must use a reconciliation guard. See [saving](saving.md). + +A clean draft means it matches the delivered editable values. It does not prove that a captured write +was accepted or that a controller completed its work. A suppressed update can leave the displayed +view unchanged but its save version stale; see [the save-version explanation](saving.md#why-a-quiet-stream-can-still-reject-a-save). +For reactive integration and subscription ownership, see the [Vue example](../examples/vue/README.md). diff --git a/docs/field-reports/first-fieldreport.md b/docs/field-reports/first-fieldreport.md deleted file mode 100644 index ef494f5..0000000 --- a/docs/field-reports/first-fieldreport.md +++ /dev/null @@ -1,262 +0,0 @@ -# krm-stream feedback from krm-foyer - -krm-foyer is about to host the krm-stream gateway with user-authenticated watches -([roadmap step 5](https://github.com/ConfigButler/krm-foyer/blob/main/docs/roadmap.md#order-of-work)). This note asks for five changes, most -important first. None of them blocks us: for each we say what krm-foyer does meanwhile. -Everything was checked against krm-stream v0.4.0 (`gateway`, `gateway/kube` and -`@configbutler/krm-stream`) on 2026-10-02, by reading the source and, for the error -output below, by running the gateway. - -## Status (2026-10-02): asks 1 to 5 are in krm-stream 0.5.0 - -krm-stream 0.5.0 took all five asks below (krm-stream #40), and krm-foyer moved to it: -the gateway maps the API server's answers, keeps their text off the wire, sends it to a -`Diagnostics` hook, leaves retries to the browser's client with the server's hint, admits -any resource with `ScopePolicy.AnyResource`, and lets `Principal` choose its refusal. -krm-foyer's own mapping, status capture and backoff are gone. - -Reviewing krm-foyer's integration found three more, below the original asks as -[asks 6 to 8](#ask-6-refuse-redirects-in-the-kubernetes-backend). krm-foyer works around -each meanwhile. - -## The asks at a glance - -| # | Ask | Priority | What krm-foyer does meanwhile | -| --- | --- | --- | --- | -| 1 | Map Kubernetes errors to the spec's codes, and recover from the retryable ones with backoff | High | Wraps `kube.Backend` and maps errors itself | -| 2 | Send a generic message with an unexpected `INTERNAL`, and give the detail to a diagnostic hook | High | The same wrapper keeps raw errors from reaching the gateway | -| 3 | Let `Handler` serve a host that delegates resource admission to Kubernetes | Medium | Parses the scope itself and calls `Gateway.ServeStream` | -| 4 | Let `Principal` say why it failed, so a missing credential is `UNAUTHENTICATED` | Low | Answers before the gateway runs, so it never sees this | -| 5 | Honour retry hints at the layer that retries | Low | Nothing needed for now | - -## How krm-foyer uses krm-stream - -krm-foyer is a backend for frontend: it signs users in with OIDC, keeps their ID token -server-side behind a session cookie, and proxies `/k8s` to the API server with that -token. **Kubernetes alone decides what a user may do:** krm-foyer has no allowlist of -its own, and no service-account fallback. Its [design](https://github.com/ConfigButler/krm-foyer/blob/main/docs/design.md#access) promises -that for `/stream` as well as `/k8s`. - -So krm-foyer uses the gateway in per-user mode: - -- **Before the gateway,** krm-foyer checks the session and answers 401 itself when there - is none. It parses the scope with `ScopeFromQuery` and accepts only its one target. -- **`Clients`** returns `kube.NewBackend` over a `rest.Config` holding only the API - server's address, its CA and the user's token. Tests that try to get past this boundary - are krm-foyer's to write; no type check in krm-stream could prove it. -- **`Authorizer`** is `AllowAll`, because every watch is opened as the user, so - Kubernetes RBAC decides. -- **When the session ends,** krm-foyer cancels the request context. That aborts the - stream and cancels the upstream watch; the managed connector then gets a 401 and stops. - -Shared watches come later. Everything below is about per-user mode unless it says -otherwise. - -## Ask 1: map Kubernetes errors, and recover from them with backoff - -**The problem.** `kube.Backend.Watch` wraps any error from the API server (except the -`sendInitialEvents` refusal) in `fmt.Errorf`. The gateway turns any error that is not a -`*StreamError` into a terminal `INTERNAL`. So `FORBIDDEN`, `UNAUTHENTICATED` and -`UPSTREAM_UNAVAILABLE` never come from the Kubernetes backend. What a browser receives -from `Handler` with a backend that fails as `kube.Backend` does: - -```text -upstream 403: -data: {"seq":1,"type":"error","code":"INTERNAL","message":"krm-stream/kube: streaming list for https://10.43.0.1:443/apis/hello.krm-foyer.example/v1/namespaces/hello/notes: notes.hello.krm-foyer.example is forbidden: User \"oidc:carol@example.com\" cannot watch resource \"notes\"","terminal":true} - -API server unreachable: -data: {"seq":1,"type":"error","code":"INTERNAL","message":"krm-stream/kube: streaming list for https://10.43.0.1:443/api/v1/namespaces/x/configmaps: dial tcp 10.43.0.1:443: connect: connection refused","terminal":true} -``` - -A user without `watch` permission sees "INTERNAL" where Kubernetes said why. A restart of -the API server ends every open stream permanently, though the spec calls that case -retryable. - -**Mapping alone is not enough.** The stream loop answers every non-terminal -`*StreamError` by starting the next cycle at once (`StreamProjection` in -`gateway/stream.go`). It emits a generic `RESYNC_REQUIRED` in place of the error, so the -code and any `retryAfterMs` are lost, and it does not wait. Mapping an unreachable API -server to a non-terminal `UPSTREAM_UNAVAILABLE` would therefore turn an outage into a -tight loop of reconnect attempts. The mapping and the recovery have to change together. - -What we would expect, by the spec's own table (§4.3): - -| Upstream answer | Today | Expected | -| --- | --- | --- | -| 403 Forbidden | `INTERNAL`, terminal | `FORBIDDEN`, terminal, with the API server's `Status.message` | -| 401 Unauthorized (token expired or rejected) | `INTERNAL`, terminal | `UNAUTHENTICATED`, terminal | -| 404 (resource not served, wrong version) | `INTERNAL`, terminal | `SCOPE_INVALID`, terminal | -| Connection refused, timeout, 5xx | `INTERNAL`, terminal | `UPSTREAM_UNAVAILABLE`, not terminal, with backoff | -| 429 from Priority and Fairness | `INTERNAL`, terminal | `UPSTREAM_UNAVAILABLE`, not terminal, `retryAfterMs` from `Retry-After` | -| 410 Gone / `Expired` on an open watch | `RESYNC_REQUIRED` | Unchanged: a new snapshot cycle | -| Any other `ERROR` event on an open watch | `RESYNC_REQUIRED` | Classified as in the rows above; 401 and 403 terminal | - -The mapping is needed on every path an error can take: opening a streaming list, the -LIST and the WATCH of the list-then-watch fallback, and `ERROR` events on an established -watch (`watchError` in `gateway/kube/backend.go` turns all of these into -`RESYNC_REQUIRED` today). - -**Who retries.** The spec leaves open whether a retryable error is retried by the gateway, -on the same connection, or by the client, on a new one. We would prefer the client: the -gateway sends the non-terminal error, with its code and `retryAfterMs`, then closes the -connection. Our reasons: - -- **One retry policy, not two.** The managed connector already has a bounded budget, - exponential backoff and jitter. A second backoff in the gateway would multiply with - it. -- **Nothing is held during an outage.** A gateway that retries keeps the browser's - connection, a goroutine and the host's subscription slot for an outage of unknown - length. A closed connection costs nothing until the client returns. -- **No continuity is lost.** A retry in the gateway starts a new snapshot cycle, and a - reconnect asks for a fresh snapshot anyway, so the browser sees the same thing. - -If you prefer that the gateway retries, it needs a cancellable backoff that honours the -upstream's `Retry-After`, and it must keep the error's code and hint on the wire rather -than replacing them with `RESYNC_REQUIRED`. Either way, please say which in the spec. - -A shared backend's own upstream watch is a different matter: it has no browser to hand -the retry to, so it must retry with backoff itself. - -**Tests we would find convincing:** - -- An API server that keeps refusing connections: the number of upstream attempts per - minute is bounded, and the browser sees `UPSTREAM_UNAVAILABLE`, never `INTERNAL`. -- Cancellation during a backoff, or a closed connection during one, ends it at once. -- 401 and 403, at opening and as watch `ERROR` events, are terminal with the right code. -- 410 on an open watch still starts a new cycle. - -## Ask 2: keep unexpected error text off the wire - -`asStreamError` uses `err.Error()` as the message of any `INTERNAL` it generates, so -whatever an error says reaches the browser. In the output above that is the cluster's -internal address (`10.43.0.1:443`), and, with `NewBackendForConfig`, the configured host, -which `Backend.upstream` says is "never sent to a browser". `Handler` already declines to -pass on a `Principal` error for the same reason. - -We suggest: - -- **A fixed message for a generated `INTERNAL`,** such as "internal error". -- **Messages the host chose stay.** A `*StreamError` returned by an `Authorizer` or - `ClientFor` keeps its message, as does the API server's `Status.message` on a 403 - under ask 1. That message is Kubernetes telling the user about their own access, and - `/k8s` passes it on verbatim too. It is not the same problem as an internal error. -- **A separate diagnostic hook for the raw error,** rather than `Observer`, whose kinds - deliberately carry no error messages (`gateway/observe.go`). The host decides what to - log and what to redact. krm-foyer, for one, logs no token or session ID anywhere, and - "log the full error" would be too broad a default for it. - -A test: an error carrying a recognisable secret string, returned from a backend, appears -in the diagnostic hook and nowhere in the response. - -## Ask 3: let `Handler` serve a host that delegates resource admission - -`ScopePolicy` answers "may anyone stream this kind of thing here?", separately from -the `Authorizer`'s "may this caller see it". That is a legitimate host policy, and the -right default. krm-foyer has a different, equally deliberate one: what its endpoint -exposes is whatever Kubernetes lets the user read, exactly as `/k8s` does. It -promises its users no allowlist. - -`ServeStream` already serves that policy: krm-foyer parses the scope with -`ScopeFromQuery`, checks the target and calls the gateway. So this is not a blocker. We -ask for it because `Handler`'s own comment is right: every host that writes this glue by -hand gets four chances to get security-relevant code subtly wrong, and leaving -`ScopePolicy` out should not mean leaving `Handler` out. - -**A possible shape.** An explicit opt-in that stands out in review, as `AllowAll` does: - -```go -Scopes: gateway.ScopePolicy{ - Targets: []string{""}, - Resources: gateway.AnyResource{}, // admission is left to the upstream's own authorization -}, -``` - -With it, any scope that `ScopeFromQuery` accepts passes `Validate` if its target is -allowlisted. An omitted namespace means whatever the upstream makes of it: the list of a -cluster-scoped resource, or every namespace for a namespaced one, which RBAC allows only -to users with a cluster-wide grant. The `Authorizer` still runs, so a host can narrow it -there. The target allowlist and the refusal of `server`, `url`, `token` and the like stay -mandatory. - -**What it must not promise.** The option should not claim to prove that the stream uses -the caller's credential. A runtime check for `*SharedBackend` would not prove it: a -`kube.Backend` can carry a service account's client, and a wrapper can hide a shared -backend. Binding the stream to the caller's own credential stays the host's obligation, -and the docs should say so where the option is described. - -**The spec.** §8 says the server maps the request to "an allowlisted target + GVR", and -conformance item 10 says the scope is allowlisted. "An allowlisted target and a -host-approved GVR" would cover hosts that approve by delegating to the upstream's own -authorization. - -## Ask 4: let `Principal` say why it failed - -`Handler` answers every `Principal` error with `FORBIDDEN` "not authenticated". A page -treats the two codes differently: `UNAUTHENTICATED` offers sign-in, `FORBIDDEN` says no. -But not every failure means signing in again will help: when a host's session store is -down, the honest answer is "try later". - -We suggest honouring a `*StreamError` that `Principal` returns, as the gateway already -does for `Authorizer` and `ClientFor`, so a host can choose `UNAUTHENTICATED`, -`UPSTREAM_UNAVAILABLE` or `FORBIDDEN` with a message of its own. Any other error keeps -today's generic answer, with the code changed to `UNAUTHENTICATED`, which is what -"not authenticated" says. - -## Ask 5: honour retry hints at the layer that retries - -The managed connector ignores both hints a host can give: `Retry-After` on an HTTP 429, -and `retryAfterMs` on a non-terminal error event. It uses its own backoff for both. - -- **On reconnecting,** the connector should wait at least as long as the last hint - said, within its own cap. krm-foyer's rate bound sends `Retry-After` with the exact - time until a request would be let through, so retries before then are sure to fail. -- **A non-terminal event on an open connection** should not make the client reconnect: - under the gateway's current behaviour, recovery follows on the same connection. The hint - applies only if the connection then ends. -- **If the gateway retries itself** (ask 1), it should honour the upstream's - `Retry-After` in the same way. - -Smaller, and related: on a non-200 answer the connector reports only `stream: HTTP 403` -and discards the body. When the body is a Kubernetes `Status`, as krm-foyer's refusals -are, its `message` would be more useful to show. A bounded read of a JSON `Status`, or a -decoder the host supplies, with today's text as the fallback, would cover it. - -## Ask 6: refuse redirects in the Kubernetes backend - -**This one is about credentials.** client-go follows redirects, and its transport adds the -bearer token to the redirected request too. A backend built with -`kube.NewBackendForConfig(cfg)` from a `rest.Config` holding the caller's token therefore -sends that token wherever a redirect points: another host, or plain `http`. Pinning the -host and leaving `Proxy` unset does not prevent it. krm-foyer reproduced it against a -test API server that redirects to a recording server, which received the token. An -aggregated API's answer passes back through the API server, so a redirect is not only a -hostile-server case. - -krm-foyer builds its client with `rest.HTTPClientFor`, sets `CheckRedirect` to refuse, -and ends the stream with a terminal error. We suggest `NewBackendForConfig` do the same, -and that the gateway map a refused redirect to a terminal error, since a redirect will -not go away on retry. The docs that show `kube.NewBackend(dynamicClientFor(user))` -should say a host-built client must refuse redirects too. - -## Ask 7: treat a watch that ends before it was of use as a failure - -The stream loop reopens at once, on the same connection, after `ErrWatchClosed`, and -after a `RESYNC_REQUIRED`. That is right for the API server's routine timeout, which -comes after half an hour. But an upstream that ends every watch before its snapshot is -complete, or answers 410 right after it, gets a tight loop: krm-foyer measured about 100 -reopenings a second against a test server that does so, on one browser connection. - -krm-foyer's backend now answers such an end with a non-terminal `UPSTREAM_UNAVAILABLE`, -so the connection closes and the client waits: an end before the `synced` boundary, or -within a second of it. We suggest the gateway do this itself, for every backend. One -detail cost us a bug: `StreamError.Unwrap` returns `Cause`, so an `UPSTREAM_UNAVAILABLE` -whose cause is `ErrWatchClosed` is still read by the loop as a routine close. - -## Ask 8: say that client-go retries a 429 with Retry-After itself - -client-go asks again on its own, up to ten times, when a 429 or 5xx answer carries a -`Retry-After` header, before the backend sees the error. The API server sets the pace, so -this is not a tight loop, but the browser hears nothing meanwhile, and the -`retryAfterMs` mapping of 0.5.0 only applies once client-go has given up. We suggest -saying so in the operations guide, and considering a client with those retries off, -so the browser's client, which shows the wait, owns them as 0.5.0 intends. diff --git a/docs/field-reports/second-fieldreport.md b/docs/field-reports/second-fieldreport.md deleted file mode 100644 index 7678592..0000000 --- a/docs/field-reports/second-fieldreport.md +++ /dev/null @@ -1,167 +0,0 @@ -# krm-stream feedback from krm-foyer: shared watches - -krm-foyer now hosts `SharedBackend` with `kube.SubjectAccessReviewAuthorizer` -([roadmap step 5b](https://github.com/ConfigButler/krm-foyer/blob/main/docs/roadmap.md#order-of-work), [watches](https://github.com/ConfigButler/krm-foyer/blob/main/docs/watches.md)). This note asks -for five changes, numbered on from the [first feedback](first-fieldreport.md), whose -asks 1 to 8 krm-stream 0.5.0 and 0.6.0 took. Two (9 and 10) are about guarantees that -do not hold today in a failure case; three (11 to 13) would cut the authorization load -that sharing costs. For each we say what krm-foyer does meanwhile. Everything was -checked against krm-stream main at `5ca19ed` (gateway 0.6.0) on 2026-10-04, by reading -the source and by tests in krm-foyer that reproduce each problem. - -## Status (2026-10-04): asks 9, 10 and 13 are taken - -[Proposal 0008](../proposals/0008-shared-watch-hardening.md) selected ask 9, the "refuse the -combination" form of ask 10, and the documentation part of ask 13: - -- **Ask 9:** a shared scope now opens its upstream watch without the backend-wide lock, under a - context of its own that its waiting callers share; the last to leave cancels it. -- **Ask 10:** `Handler`, `ServeStream` and `ServeStreamProjection` refuse a positive - `ReauthorizationInterval` without a positive `WriteTimeout`, and the - [revocation budget](../auth.md#revocation-budget) is documented in one place. -- **Ask 13:** the [SubjectAccessReview request](../auth.md#what-the-subjectaccessreview-asks) is - documented on the adapter and in the guide, and tests pin it. The exported attribute builder is - deferred. - -Asks 11 and 12 are deferred; the proposal records what would reopen them. - -## The asks at a glance - -| # | Ask | Priority | What krm-foyer does meanwhile | -| --- | --- | --- | --- | -| 9 | Open a shared watch without holding the backend-wide lock, and cancel an opening nobody waits for | High | Its upstream backend returns at once and opens the watch on the first `Next` | -| 10 | Make timed rechecks independent of a blocked write, or require a write timeout with them | High | Sets `WriteTimeout` on every stream, and counts it in its revocation bound | -| 11 | Let a host trigger a recheck | Medium | Rechecks on the timer only | -| 12 | Recheck once per principal and scope, not once per subscriber | Medium | Reuses decisions for a short time in its own authorizer | -| 13 | Say which attributes the SubjectAccessReview asks about | Low | Keys its decisions on them, with a test that fails if they change | - -## Ask 9: open a shared watch without holding the backend-wide lock - -**The problem.** `SharedBackend.Watch` holds `b.mu`, the lock every scope takes, while -`startScope` calls `b.upstream.Watch`, a network round trip to the API server. The -context it passes is `context.WithCancel(context.Background())`, and nothing cancels it -until the scope exists. So one API server slow to answer one opening: - -- **holds up every other scope:** a stream of an unrelated namespace waits for the lock, - and never reaches the API server; -- **cannot be abandoned:** the browser that asked for it leaving cancels its own request, - but not the opening, so its stream slot stays taken, and so does every other waiting - stream's. - -krm-foyer reproduced both: an API server that never answers a watch of one namespace, -not even with headers, held up a stream of another namespace until the test timed out, -and the first stream's slot stayed taken after its browser left. A client-go watch has -no response-header timeout of its own, so the wait is unbounded. - -**What we suggest.** Open the upstream outside `b.mu`: record an in-flight entry for the -scope under the lock, so later subscribers of the same scope wait on it, then open -without the lock. Give the opening a context that its waiting subscribers can cancel -together: when the last one leaves before the watch opened, cancel the opening and -drop the entry. The backoff bookkeeping can stay under the lock. - -**What krm-foyer does meanwhile.** Its upstream backend's `Watch` returns a watcher at -once, and opens the real watch on the first `Next`, with the context `Watch` was given. -`pump` calls `Next` without the lock, and `leave` cancels that context when the last -subscriber goes, so the opening is the scope's alone and ends with it. An opening -failure arrives through `Next` and `die`, which already records an -`UPSTREAM_UNAVAILABLE` for the backoff. This works, but it relies on the order of calls -inside `SharedBackend`, which is not a contract. - -## Ask 10: keep timed rechecks from waiting on a blocked write - -**The problem.** Timed checks and delivery share one gate per subscriber (docs/auth.md: -"Timed checks run per subscriber and pause that subscriber's object delivery"). A write -to a browser that stopped reading blocks once the buffers between are full, and the -recheck waits behind it. `ReauthorizationTimeout` does not cover that wait. With -`WriteTimeout` zero, the default, the write blocks until something else ends the -request, so a revoked grant is never applied to that stream. krm-foyer reproduced it: a -stream whose reader stopped, its buffers filled, its grant revoked, stayed open with no -further reviews, past a bound of about 10 seconds. - -docs/auth.md does say the bound "assumes ... sinks do not block indefinitely", and the -sharedstream example sets `WriteTimeout`. But nothing ties the two together, and a host -that sets `ReauthorizationInterval` alone gets an interval that does not hold. - -**What we suggest,** either of: - -- **Refuse the combination:** `Handler` panics on `ReauthorizationInterval > 0` with - `WriteTimeout == 0`, as it does for other unsafe options, or defaults `WriteTimeout` - then; or -- **Decouple them:** run timed checks apart from delivery, and on a denial end the - request (close the connection), whatever the write in progress is doing. - -Either way, document the whole revocation budget in one place: interval, plus the check -timeout, plus one write timeout, plus cleanup, and whatever caching the host adds. - -**What krm-foyer does meanwhile.** It sets `WriteTimeout` (10 seconds, configurable) on -every stream, shared or not, and its documented bound adds it in. A test stops reading, -fills the buffers, revokes the grant, and sees the stream end within the bound. - -## Ask 11: let a host trigger a recheck - -**The problem.** Rechecks are timed, per subscriber, and only the interval can be -tuned. A shorter interval revokes sooner and costs more reviews; a longer one the -reverse. A host that learns of a change sooner (it watches RBAC's Roles and Bindings, a -webhook authorizer's policy, or its own session store) has no way to apply it before -the next tick. - -**What we suggest.** An API on the gateway to recheck now, for every subscriber or for -those matching a principal or scope, for example `Gateway.Reauthorize(func(Principal, -Scope) bool)`, through the same path as a timed check, so a denial ends only those -streams. A host could then pair an event-driven recheck (immediate for RBAC changes) -with a long periodic one as a safety net for what it cannot watch, such as group -membership from the issuer. For krm-foyer that would make the common revocation -immediate and cut the periodic review load several times over. - -**What krm-foyer does meanwhile.** Rechecks on the timer; its decision guide explains -the trade-off between the interval and the load ([watches](https://github.com/ConfigButler/krm-foyer/blob/main/docs/watches.md#load-on-the-api-server)). - -## Ask 12: recheck once per principal and scope - -**The problem.** Each subscriber has its own recheck timer. A user with nine tabs on one -scope is nine timers, each asking the same two questions, and the timers drift apart -as the tabs open at different times. The reviews then scale with streams, not with -users: with 200 users each on nine scopes, about 120 reviews a second at a 30-second -interval, against 13 for one scope. - -**What we suggest,** either of: - -- one timed check per (principal, scope), whose answer applies to every subscriber of - that pair; or -- a decision cache in `kube`, keyed on exactly the attributes a review sends, that a - host can wrap `SubjectAccessReviewAuthorizer` with, with a lifetime the host chooses - and counted in the revocation budget. Every shared host needs one, and getting the - key wrong is a disclosure, so it belongs next to the authorizer. - -**What krm-foyer does meanwhile.** Its own authorizer wraps the SubjectAccessReview -authorizer with such a cache: a decision is reused for 10 seconds for exactly the same -subject and asked attributes, two checks at once wait for one answer, and an error is -never kept. A fuzz test checks that the cache never changes an answer. Reuse still -depends on the timers falling within one lifetime of each other, which ask 12's first -form would make unnecessary. - -## Ask 13: say which attributes the review asks about - -**The problem.** A host that caches decisions has to know exactly which parts of a scope -the review asks about. Today `review` sends the group, version, resource, namespace and -name, and not the label selector (Kubernetes' `ResourceAttributes` has a `LabelSelector` -since 1.31, unused here). That is reasonable, since RBAC cannot grant by label, but it is -written down nowhere, and a later version that sends selectors would make a cache keyed -without them wrong. - -**What we suggest.** Document the attributes on `SubjectAccessReviewAuthorizer`, and -mention a change to them in the changelog as a breaking change for hosts that cache. An -exported function returning the `ResourceAttributes` for a scope and verb would let a -host key on exactly what is asked. - -**What krm-foyer does meanwhile.** Keys on those five attributes and the subject, and a -test fails if a review it observes carries a selector. - -## What we did not ask for - -- **A response-header timeout in the Kubernetes backend.** Ask 9 is the real fix; with - it, a slow opening costs only its own scope's streams, which their browsers can - abandon. -- **Sharing by default.** We agree with keeping `SharedBackend` opt-in: an identity that - may read everything, and the review load, are not costs every host should carry - unasked. krm-foyer keeps it opt-in too ([why](https://github.com/ConfigButler/krm-foyer/blob/main/docs/watches.md#why-sharing-stays-opt-in)). diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md new file mode 100644 index 0000000..b855b70 --- /dev/null +++ b/docs/field-reports/third-our-identity.md @@ -0,0 +1,137 @@ +# Watch streams, optional editing and native access + +**Current request, 2026-10-05.** Baseline: the stacked branch through PR #59 at `b5cc779`. +This document records requested work. The [README](../../README.md), [watching guide](../why-a-gateway.md), +[editor model](../client-state-model.md) and [saving guide](../saving.md) describe current use. +[Proposal 0006](../proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order) +consolidates completed work, delivery order, dependencies and deferred items. + +## Identity and ownership + +krm-stream provides efficient live Kubernetes views for browser applications, with optional editing. +Start with the watch: select an authorized scope, choose a view, maintain live resource state and +recover safely. Add the editor when the page needs to preserve local work as the resource changes. + +| Layer | Owns | +|---|---| +| Gateway | Scope and projection enforcement, redaction, suppression, optional upstream sharing and current SSE delivery | +| Connector | Fetch/body decoding, connection state, errors, cancellation and bounded recovery | +| Resource/editor store | Authoritative delivered objects, UID membership, snapshot pruning and optional draft reconciliation | +| Host application | Credentials, login/session lifecycle, authorization policy, UI, writes and domain progress | + +Projection controls delivered fields; suppression reduces downstream events; sharing reduces duplicate +upstream watches. None replaces subscriber authorization or host resource limits. Full/spec views +withhold core Secret values but disclose their paths and change revisions. They do not automatically +redact every sensitive field in other resource kinds. See [watching resources](../why-a-gateway.md). + +## Native watch connector + +**Adoption reason:** the host already exposes native Kubernetes reads and watches through a +session-authenticated proxy. A browser should consume those watches with fetch and reuse the resource +lifecycle and editor without requiring an additional SSE wrapper. Native watch JSON is the preferred +transport direction. Retain gateway SSE as a compatibility path for consumers seeking that protocol, +and retain the projected gateway for redaction, suppression and watch sharing. + +SSE itself can carry errors. The current fetch-based SSE connector already handles HTTP refusals, +in-stream errors, cancellation and bounded recovery. Browser `EventSource` has different header and +retry limitations. The native request simplifies integration and framing; it does not claim that +SSE cannot report an expired token. Compare error handling on both paths, including token/session +expiry, and stop a refused connection until the host restores access. + +krm-foyer is the motivating host: its `/k8s` native proxy and `/stream/v1` gateway routes let a page +choose either source under a session. Host integration and dependency upgrades remain separate work. +Keep the Go gateway in this repository and preserve its existing v1 SSE contract. + +### Contract before implementation + +Provide a browser connector for a native Kubernetes collection URL through a host proxy. Use fetch, +including caller-supplied fetch and same-origin cookies, without Node polyfills or browser Kubernetes +credentials. Reuse the existing frontend lifecycle and state-event input. API names are not settled; +one lifecycle does not require pretending the two wire protocols are the same. + +- Expose initialization, live state, stale state during reconnect, terminal refusal, explicit closure + and retry exhaustion. Consumer callback failures follow the existing cleanup-and-reject rule. +- Decode native watch frames directly into state events; do not encode them as SSE to parse them again. +- Separate collection resume checkpoints from object edit preconditions. A bookmark cannot supply an + individual object's edit version. Advance checkpoints only after successful state application. +- Represent native view identity explicitly, without inventing a gateway projection or redaction + revisions. Retain native fields for viewing; exclude machinery fields from accidental edits, + including the last-applied annotation. Define the host validation and native read/write contract. +- Require explicit source selection. Never retry a refused projected stream through native access. + Source, target, scope, view or login-identity changes must not silently combine drafts or redactions. +- Preserve save guards, submitted intent and later typing. A no-op or superseded write cannot wait + forever for an exact echo. Track dirty drafts, write acceptance and domain progress separately. + +Start with a proposal and an executable example. Expose the connector only once recovery, state +membership and editing contracts have passed acceptance. Keep the supported gateway connector in +place until then; do not advertise a native API in adoption snippets. + +### Recovery acceptance + +| Scenario | Required result | +|---|---| +| Initial streaming list, including an empty collection | `reset`, initial members, then `synced` only at the initial-end bookmark | +| EOF/read failure before the initial boundary | Restart initialization; an individual initial object's RV does not prove collection completeness | +| Disconnect after initialization | Resume from the last applied event/bookmark; retain membership, with no reset or snapshot pruning for a successful resume | +| HTTP or in-stream 410 | Start a fresh snapshot; retain old state as stale until completion, then prune missed deletes | +| API explicitly lacks streaming-list support | Complete a paginated list, then watch from its collection RV; auth and transient failures are not feature refusal | +| HTTP or in-stream 401/403 | Terminal for this connection; no retry loop or source fallback; a later accepted login can create another connection | +| 429/5xx, network failure, repeated short EOF | Bounded backoff and retry budget honoring hints; no hot loop | +| Close while reading or sleeping | Cancel request/backoff, release reader and deliver no later updates | +| Chunked UTF-8/JSON, malformed/truncated frame | Parse complete frames across chunks; partial input never completes a snapshot | +| Delete/recreate with the same name | New UID is separate; late responses cannot resurrect the old resource | +| Label-scope entry/exit | Membership follows the watch, including removal without an upstream delete | + +Cover repeated retry failure, expired list continuations, partial snapshots, scope/source changes and +cancellation during recovery. Do not assume periodic bookmarks: idle watches still need recovery. +Specify live/stale presentation during a successful resume without fabricating a `synced` snapshot. + +## Show the value of each path + +Use one small frontend and workload for native, `krm-full/v1` and `krm-spec/v1` views. Reuse rendering +and editing where their contracts permit it. Show a read-only view first, then an editor with local +image changes, unrelated replica changes and a real overlapping edit. No new dashboard or framework. + +| Operation | Native | Full | Spec | +|---|---|---|---| +| Initial load / spec edit | Original resource | Projected resource | Projected resource without status | +| Status-only update | Update | Update | No object event | +| Managed-fields-only update | Update | No object event | No object event | +| Rotate a test Secret | Authorized reader gets value | Value absent; redaction revision changes | Value absent; redaction revision changes | +| Reconnect after delete | Resume or resnapshot | Snapshot prunes absent UID | Snapshot prunes absent UID | + +Compare shared and unshared gateway runs separately, with identical scopes and multiple authorized +subscribers. Sharing is independent of view selection; a native/full/spec table alone cannot establish +upstream savings. Different scopes do not automatically share. + +Use synthetic Secret values and assert their absence from full/spec transcripts, including after +reconnect. Verify unauthorized callers and expired sessions on both paths, and that shared identity +or fallback cannot grant access. A host's separately authorized native route can disclose Secret +values; view selection is not a universal field-permission system. + +Measure downstream bytes/events, store notifications/renders, snapshot size and apply time, upstream +watches, gateway work and conditional-save outcomes under the same object counts and controller churn. +Report reconnect costs and authorization work. Suppression reduces downstream work; sharing reduces +upstream duplication; native resume can reduce snapshot work. Establish each benefit with measurements. + +## Save progress under suppressed churn + +A person editing spec should not repeatedly need a second Save when none of the relevant values +changed. Keep low browser traffic and real conflict protection. The current supported recovery is a +guarded projected read followed by review and another deliberate Save. + +[Proposal 0006, save-progress evaluation](../proposals/0006-stream-and-save-implementation-plan.md#5-save-progress-under-suppressed-churn) +owns evaluation of bounded submitted-intent recovery and optional coalesced version delivery, reusing +the real-API fixtures. It specifies guards, compatibility, measurements and acceptance; this request +does not duplicate them or commit to a new wire event. The evaluation can retain today's baseline. + +## Relationship to upstream continuation and adoption + +Proposal 0006's upstream continuation runs inside the gateway's Kubernetes backend; the native +connector resumes a browser-owned watch through a proxy. They have different checkpoint owners and +authorization lifecycles. Share scenarios, not cursors. Neither adds browser resume to gateway SSE v1. + +After the native connector is released, a host can upgrade and exercise both routes under the same +session, expiry and RBAC tests. Projected editors need projected recovery reads; a transparent native +proxy is not itself a replacement for that host contract. Current branch APIs and future requests +must remain distinct from a consumer's pinned dependency version. diff --git a/docs/glossary.md b/docs/glossary.md index 9d1d382..7755909 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,133 +1,98 @@ # Glossary for frontend developers -You do not need to know Kubernetes to use krm-stream. You do need about a dozen words. This page -defines them, then shows where each one appears in the library. +Start with [watching resources](why-a-gateway.md); add the [editor](client-state-model.md) when a page +needs drafts. These are the Kubernetes and editing terms used by those guides. -## The data +## Resource identity and content -**KRM (Kubernetes Resource Model)** is the shape every object here has. It is JSON with four -conventions: `apiVersion`, `kind`, `metadata`, and a body. Read it as a schema for a declarative -object with an identity and a desired state. Nothing about it is specific to containers. A -`Database`, a `FeatureFlag` or a `Tenant` can be a KRM resource. +**KRM (Kubernetes Resource Model)** is the JSON shape of a resource: `apiVersion`, `kind`, `metadata` +and kind-specific fields. A ConfigMap uses `data`; many resources have `spec` and `status`; a custom +resource can represent a product's own Database, FeatureFlag or Tenant. -**Resource** is one such object. Identified by `apiVersion`, `kind`, `namespace` and `name`, and by -`metadata.uid`. +**CRD (Custom Resource Definition)** registers a custom resource kind and its API/schema. -**`uid`** is what the store keys on. Kubernetes gives every object created over a cluster's lifetime a -distinct `uid`, so a resource deleted and recreated under the same name is a different object and does -not inherit the old draft. Key on `name` instead and a user's unsaved edits reattach to whatever takes -the name next. +**UID** is the resource's server-assigned identity, used as the store key. A same-name replacement +gets a new UID and never inherits the old draft. Identity is scoped to an upstream **target**: use +one store per stream, or key multiple targets by `(target, uid)`. Names are useful for display/routing, +not for transferring an edit to a replacement. -A `uid` is a UUID, but the guarantee is per cluster, so the protocol scopes it to one upstream target: -hold objects from more than one stream and you key on `(target, uid)`, or keep one store per stream. -See [spec §1](../spec/v1.md). +**Namespace** scopes namespaced resources. Other resource kinds are cluster-scoped. An omitted +namespace on a namespaced collection can mean all namespaces, so the host declares that access. -**CRD (Custom Resource Definition)** is how a team adds their own `kind`. It is why KRM works as an -application configuration API and not only as cluster plumbing: your product's domain objects can be -resources. +**Spec and status** commonly distinguish desired configuration from observed progress. The default +editor policy makes status read-only. A read-only field can still follow the server live; it is +omitted only if the selected projection says so. -**`spec` and `status`** are the split to remember. `spec` is what a human or agent wants. `status` -is what the system observed. Users edit `spec`. Nobody edits `status`, and the store enforces that: -`status` is read-only and never part of a save. +**Resource version** is a server-assigned token. An object's version is a conditional-write +precondition; a collection/watch version is a recovery checkpoint. They serve different purposes. +Suppression can keep an object's visible content correct while leaving its held write version stale. +See [quiet streams and saving](saving.md#why-a-quiet-stream-can-still-reject-a-save). -**`resourceVersion`** is an opaque server-assigned token that changes on every write. It works like -an ETag, and it is how the server detects that you edited a stale copy. +## Watching and views -**Namespace** is a folder. Resources live in one, and some kinds are cluster-wide instead. +**Watch** is Kubernetes' change feed: added, modified and deleted objects, plus control events such +as bookmarks and errors. History can expire and initialization/recovery must establish a complete +collection boundary. The current gateway handles these mechanics for its browser clients. -## The stream +**Scope** selects a target, resource kind, namespace, optional name and allowed labels. The host +still authorizes the request; a selector does not grant permission. -**Watch** is the Kubernetes primitive for "tell me when this changes". It yields a stream of `ADDED`, -`MODIFIED` and `DELETED` events. It is server-side and stateful, and it has edge cases: history -expiry, relist, bookmarks. The gateway handles those. You do not see them. +**Projection** is the named resource view delivered by the gateway. Full view includes status and +withholds core Secret values; spec view also omits status. Raw includes Secret values with host +permission but still strips managed fields and last-applied configuration. It is not native passthrough. -**Snapshot** is the initial run of events describing the world as it currently is, before live -changes arrive. It has a completion point, the `synced` event. Until a snapshot completes, the client -cannot tell whether a resource it remembers is gone or simply not re-sent yet, so an incomplete -snapshot never prunes state. +**Redaction** withholds a value while disclosing its path and change revision. There is no placeholder +in the object to save back accidentally. The UI renders withheld state from redaction metadata and +must not edit it. Built-in Secret redaction does not classify every sensitive CRD field. -**SSE (Server-Sent Events)** is the browser transport: an HTTP response that stays open and streams -text events. `EventSource` is built into every browser. It is one-directional, which is all a read -stream needs. +**Suppression** skips an object event when projected content excluding resourceVersion plus redaction +records are unchanged. It saves downstream work. A hidden Secret rotation still produces an update. -**Gateway** is the server-side piece you mount in your own Go application. It uses host-owned Kubernetes -clients, enforces host authorization and turns a watch into a scoped SSE stream. The browser never -receives a cluster credential or an API-server URL. +**Shared watch** uses one upstream watch for matching scopes in a shared backend. It saves duplicate +upstream work; each subscriber still needs authorization, a snapshot and its own delivery. -**Projection** is the subset of a resource the gateway sends. What the browser receives may be less -than what exists upstream. +**Snapshot** establishes complete scope membership. The current gateway emits `reset`, member upserts +and `synced`; only completion prunes unseen UIDs. A partial snapshot cannot establish absence. -**Redaction** is a path the gateway knows exists but withholds, such as a Secret value. It is not -sent as a placeholder; it is absent from the object, and listed in `redactions(id)` so the UI can -render it as withheld and offer no editor. It follows that a redacted field must never be written -back, or the browser would erase it. +**SSE (Server-Sent Events)** is the current gateway's text-event framing, consumed by the official +connector through **fetch**. Browser `EventSource` is another SSE client with different header/retry +limitations. Fetch can read SSE or native Kubernetes watch JSON and inspect HTTP responses; both +formats can carry errors. A native fetch/watch connector is requested, while SSE remains supported. -## The editing +**Gateway** is the embeddable Go read layer. It enforces host scope/view policy, projects resources +and delivers the current SSE protocol. A host can separately proxy native Kubernetes access with a +session cookie while keeping cluster credentials server-side. -**Draft** is the object your form is bound to. It is separate from the server object, and the stream -cannot overwrite it without telling you. +## Optional editing and saving -**Three-way merge**: while you are editing a resource, new server updates can arrive. A three-way -merge uses the version you started editing, your draft, and the new server version to combine -non-conflicting changes without overwriting your work. +**Server and draft** are separate objects in the editor: the latest delivered authoritative view and +that view with local edits. Store reads are detached copies; render them and edit through store APIs. -At each editable path the store asks two questions: did the server change this field, and did you -change this field? +**Three-way reconciliation** compares the previous server value (**base**), local draft (**ours**) +and incoming server value (**theirs**) at editable paths: -| Server changed it | You changed it | Result | +| Server changed | Local changed | Result | |---|---|---| -| yes | no | the server value flows into your draft | -| no | yes | your edit stands | -| yes | yes, to the same value | converge, no conflict | -| yes | yes, to a different value | your draft stands, and a conflict is recorded | - -Only the last row needs a human. Without this, a controller updating one annotation would discard -the text you were typing in an unrelated field. - -**Conflict** is the fourth row above. The draft is retained, and `conflicts(id)` exposes the server -value for review. `takeTheirs` and `revert` restore server values. The store leaves save policy to the -host; the conditional editor requires conflicts to be resolved before saving. - -**Associative list** is a Kubernetes array that behaves as a map. `spec.containers` is keyed by -`name`, not by index. The store merges these by key only when configured with -`withOpenAPIKeyedLists` and the host-supplied structural schema. Arrays are atomic by default. - -**RFC 7386 merge patch** is the save format: a JSON document containing only what changed, where -`null` means delete. The store builds it by diffing draft against server over editable paths only. -It never diffs the whole projected object, which would turn a field that is absent because of a -projection into a deletion. - -## How this hooks into krm-stream - -The read path, in the order the words appear: - -1. Your Go application mounts the **gateway**. It authenticates the user, decides the **scope**, and - opens a **watch**. -2. The gateway applies a **projection** and its **redactions**, then streams a **snapshot** over - **SSE**, followed by live updates. -3. `LiveResourceStore` consumes those events and keeps, per **`uid`**: `server(id)` for the server - object, `draft(id)` for yours, plus `conflicts(id)` and `redactions(id)`. One store per stream. -4. Every incoming update runs the **three-way merge** over `server` as the base you started from, - `draft` as what you typed, and the new server object. Server changes you did not touch appear in - the form. Your edits survive. Collisions land in `conflicts(id)`. - -The write path is not the library's: - -5. On save, `captureSave(id)` captures an **RFC 7386 merge patch**, UID and base resourceVersion - together, or returns `null` when nothing changed. -6. You send that intent to your own save endpoint. The store never writes to Kubernetes. -7. Your handler calls [`gateway.ValidateMergePatch`](../gateway/patch.go), which rejects a patch - touching anything the effective projection withheld or stripped: a redacted path, - `metadata.managedFields`, the last-applied annotation, and `status` under `ProjectionSpec`. It is - what stops a buggy or hostile browser from destroying what it was never shown. Do not skip it on - the grounds that the store is careful, because the store runs on the caller's machine. -8. The host writes with the captured UID and resourceVersion preconditions. The watch sees it, it - returns down the stream as an ordinary update, - and the merge converges your draft with it. Your own write needs no special handling. - -If you know TanStack Query or SWR, this is the same server cache with local edits, with two -differences: the cache is pushed rather than refetched, and the local edit is not discarded when new -server data arrives. - -Next: [Client state model](client-state-model.md) for the API surface, and -[Saving edits safely](saving.md) for the host's responsibilities on the write path. +| yes | no | Follow server | +| no | yes | Keep local edit | +| yes | yes, same value | Converge | +| yes | yes, different values | Keep local edit and expose conflict | + +The incoming server object becomes the next base. Untouched fields stay live while local work stays +in the draft. A **field conflict** needs explicit review; `revert` takes the current server value. + +**Associative list** is a Kubernetes array with schema-declared item keys. With the exact host-supplied +OpenAPI schema, the editor can merge these by key. Other arrays are atomic. The resulting merge patch +still replaces an array as one value. + +**Save intent** captures a detached merge patch, UID and resourceVersion together before awaiting a +request. The host authorizes, validates and conditionally writes it. A **merge patch** contains only +editable changes; `null` means deletion. Never write a whole projected object back with PUT. + +**Version rejection** means a precondition failed, not necessarily a field disagreement. Current +recovery is a guarded projected read, review and another deliberate Save. A **reconciliation guard** +prevents a delayed read/response from overwriting newer watch state or crossing UID/snapshot recovery. + +**Dirty draft, accepted write and domain progress** are separate facts. Later typing survives Save; +204 or a receipt confirms acceptance, while stream observation and rollout/Git completion have their +own meaning. See [saving edits safely](saving.md) for the complete contract and UI outcomes. diff --git a/docs/migrating.md b/docs/migrating.md index 86f839a..7354677 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -3,7 +3,9 @@ The release after 0.7 has one browser connector and one name for each gateway setting. Old names are removed rather than kept as aliases, so the compiler or a failed import finds every call site. The editor store's methods (`setValue`, `captureSave`, `captureReconciliation`, `adoptSaved` and so on) -are unchanged. +are unchanged on this branch. Proposed editor removals in proposal 0009 are not migration requirements +yet. A native fetch/watch connector is also requested work; this upgrade still uses gateway SSE. +For the overall direction, see [watching resources](why-a-gateway.md). ## Browser client diff --git a/docs/operations.md b/docs/operations.md index 6ffc6c8..6ebf49c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,5 +1,10 @@ # Operating krm-stream +Measure the watch path before enabling broader scopes or sharing: upstream watch count, downstream +bytes/events, snapshot size/time, subscriber authorization and browser notifications/renders. +Suppression reduces downstream work; sharing reduces duplicate upstream watches but retains +per-subscriber costs. See [watching resources](why-a-gateway.md) for those boundaries. + The gateway reports low-cardinality lifecycle signals through `Observer`, so hosts can count them without inspecting resources: @@ -24,7 +29,7 @@ browser receives only the protocol code and a message chosen for it. Redact befo | `shared_overflow` | one subscriber exceeded `SharedOptions.QueueDepth` | increase only after checking browser stalls and event rate; resnapshot is intentional | | `terminal_error` | logical-stream failure, observed before attempting its terminal frame; delivery can fail | alert by low-cardinality error code; browsers must not retry terminal errors | | `retryable_error` rising | streams ending with a non-terminal error, usually `UPSTREAM_UNAVAILABLE` | check API-server health and priority-and-fairness rejections; browsers reconnect on their own backoff | -| `event_suppressed` ratio | `krm-spec/v1` is removing expected churn | a sharp drop may mean callers selected `krm-full/v1` or a projection changed | +| `event_suppressed` ratio | the selected projection is removing unchanged-view churn | a sharp drop may mean callers selected `krm-full/v1` or a projection changed | | stream count / snapshot duration | connection pressure or oversized scopes | narrow namespaces/selectors; avoid accidental all-namespaces watches | | unorderable `resourceVersion` terminal errors | an unsupported or aggregated API does not meet strict ordering | use `OrderingLenient` only after accepting the reduced monotonicity guarantee | @@ -105,3 +110,8 @@ are tested; other middleware and proxy combinations need host tests. For authorization bursts and the limits of the 200-subscriber profile, see the [shared-host capacity guide](../gateway/kube/examples/sharedstream/README.md#verification-and-capacity). + +Upstream continuation, native browser watch recovery and save-progress evaluation are +[planned work](proposals/0006-stream-and-save-implementation-plan.md), not current runtime controls. +When measuring editing, distinguish stale-version 409s from field conflicts and include save latency +and successful saves during sustained controller churn, rather than only after it stops. diff --git a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md index 95fd130..df4f754 100644 --- a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md +++ b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md @@ -3,7 +3,8 @@ **Status: design rationale for the remaining stream and save work.** [Proposal 0006](0006-stream-and-save-implementation-plan.md) owns work order and acceptance criteria. -Use the [saving guide](../saving.md) for current host integration. +Use [watching resources](../why-a-gateway.md) for view selection, the [editor model](../client-state-model.md) +for optional reconciliation and the [saving guide](../saving.md) for current host integration. Kubernetes owns identity and conditional writes. The library supplies projected reads and one draft store; the host owns credentials, write policy and presentation. The sections below explain the @@ -122,11 +123,11 @@ losses of trustworthy state can also require initialization. Bookmarks are usefu promises of a fixed heartbeat cadence. [Watch recovery semantics](https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes) **Upstream:** today, routine watch closure can cause a new initial-state cycle even when retained -Kubernetes history could permit continuation. Prefer investigating a resumable Kubernetes backend -first. It could keep the existing `Watcher` seam and SSE protocol, returning a resync only when it -cannot establish continuity. The upstream [client-go reflector](https://github.com/kubernetes/client-go/blob/master/tools/cache/reflector.go) -is the reference to study before implementing more watch machinery. It must track the upstream checkpoint, including appropriate bookmarks, -rather than infer one from a browser's last visible resource. +Kubernetes history could permit continuation. Proposal 0006 assesses client-go options and proposes a +bounded loop behind the existing `Watcher` seam, preserving SSE and returning a resync when continuity +cannot be established. The upstream [client-go reflector](https://github.com/kubernetes/client-go/blob/master/tools/cache/reflector.go) +is useful reference machinery. A continuation checkpoint belongs to the backend, including appropriate +bookmarks; it cannot be inferred from a browser's last visible resource. This is still proposed work. **Downstream:** spec §7 deliberately prohibits SSE resume in v1. SharedBackend can serve a warm snapshot without opening one upstream watch per subscriber, but serialization, network bytes and browser @@ -151,8 +152,11 @@ Upstream continuation is a named follow-up, ahead of any downstream replay desig | Add a write-base/read-ticket abstraction | New host/server protocol | Could coordinate reads and intended writes | State, expiration, identity binding and replay concerns; too much core machinery now | | Move host to SSA or targeted JSON Patch | No SSE change | Different write tradeoffs | Host policy/validation work; not interchangeable concurrency semantics | -**Current choice:** retain existing emissions and explicit host save outcomes. Prefer complete existing event shapes over a new -version-only event if measurements later justify version delivery. If changing a named projection's +**Current choice:** retain existing emissions and explicit host save outcomes. +[Save-progress evaluation in proposal 0006](0006-stream-and-save-implementation-plan.md#5-save-progress-under-suppressed-churn) +evaluates bounded submitted-intent recovery and coalesced version delivery under sustained churn. +This is an evaluation request, not an implemented retry policy or wire event. Prefer complete +existing event shapes over a new version-only event if measurements later justify version delivery. If changing a named projection's promised suppression behavior, use an explicit new contract/projection identity or a coordinated pre-1.0 change; do not silently repurpose `krm-spec/v1`. Pre-release naming flexibility is useful, but it does not excuse ambiguous guarantees. @@ -169,8 +173,8 @@ recreation between those operations must never make the old draft modify the new validates UID immutability, but validation/storage ordering can affect the returned error. [Metadata update validation](https://github.com/kubernetes/apimachinery/blob/master/pkg/api/validation/objectmeta.go) -The UID-race response is not established by the existing stale-RV 409 test. Add a real API-server test -that forces this race, records the structured Kubernetes Status and verifies the replacement is -unchanged. If the host normalizes an identity-mismatch error to a conflict response, classify that -specific case. Keep schema/admission validation failures as validation errors; do not map every 422 -to 409. Preserve useful structured error information without exposing a raw protected object. +The real-API composition tests now force this preflight/PATCH race: Kubernetes checks the captured +resourceVersion before UID, returning a plain 409. The example host follows the rejection with one +GET to classify a replacement while leaving it unchanged. See [the completed baseline](0006-stream-and-save-implementation-plan.md#completed-baseline) +and [saving](../saving.md#on-409-reconcile-and-save-again). Preserve structured Kubernetes Status and +keep schema/admission errors distinct; do not map every 422 to a conflict. diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index cff7737..e15f11f 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -1,115 +1,99 @@ -# Proposal 0006: Remaining stream and save work +# Proposal 0006: Delivery plan for watch streams and optional editing -**Status: active follow-up plan.** +**Status: active delivery plan, updated 2026-10-05.** Implementation baseline: the stacked branch +through `b5cc779`. This is the single inventory of completed work, open work and ordering. Detailed +contracts stay in the linked proposals and guides rather than becoming another roadmap. -Follow the standing [design rules](../../CONTRIBUTING.md#design-rules) and -[release policy](../releasing.md). [Proposal 0005](0005-kubernetes-stream-and-save-semantics.md) -explains the unresolved tradeoffs; this document owns work order and acceptance criteria. +Start with [watching resources](../why-a-gateway.md); add the [editor](../client-state-model.md) only +where a page needs drafts. Native Kubernetes watches through fetch are the preferred direction; +retain gateway SSE compatibility and the projected gateway's redaction, suppression and sharing. +Follow the [design rules](../../CONTRIBUTING.md#design-rules) and [release policy](../releasing.md). +[Proposal 0005](0005-kubernetes-stream-and-save-semantics.md) owns the stream/save tradeoffs. -[Proposal 0007](0007-shared-stream-host-integration.md) proposes bounded HTTP delivery, lifecycle -observations and tested shared-host composition. That work can support the authorization bounds -and continuation measurements below, but adds no dependency or acceptance gate to this plan. -Hosts may demonstrate the existing requirements with their own bounded sinks and instrumentation. +## Completed work -[Proposal 0009](0009-stream-and-editor-separation.md) separates browser stream consumption from the -optional editor layer. It owns that API and package-boundary work; this plan retains the existing -behavioral follow-ups and acceptance criteria. Priorities 2–3 below form the editor track, and -priority 4 forms the stream track. Deliver them independently. Complete the browser separation -before expanding the editor's public API; it does not block gateway fixes, real-API evidence or -upstream continuation. +“Implemented” below means present in this checkout; it does not establish that the current stack is +merged, released or adopted by a host. Documentation changes are in the working tree. Final-commit +validation and consumer dependency upgrades remain separate delivery steps. -[Proposal 0010](0010-gateway-api-cleanup.md) owns separate Go API cleanup. Use its final serving and -configuration names once implemented; it adds no acceptance gate to the behavioral work here. - -## Baseline and order - -Use the [adoption guide](../adopting.md), [saving guide](../saving.md) and -[conditional editor](../../examples/conditional-save/README.md) for current behavior. - -| Priority | Remaining work | Completion evidence | +| Done now | Result | Reference | |---|---|---| -| 1 | Define convergence precisely — complete | [Evidence](../../conformance/README.md#convergence-evidence) | -| 2 | Publish tested deletion-recovery and keep-local recipes — complete | [Recipes](../../examples/editor-recipes/README.md) and [tests](../../packages/krm-stream/test/recipes.test.ts) | -| 3 | Harden real-API save composition and identity races — implemented | [`TestRealAPI` cases](../../gateway/kube/composition_e2e_test.go), run by `task test-real-api` | -| 4 | Measure and implement upstream continuation | Same-workload comparison proves continuity, bounded recovery and authorization. | - -Review the remaining priorities as separate changes. Baseline measurement can run alongside -priorities 2–3. Unit tests do not establish real-cluster composition, consumer readiness or capacity. - -## 1. Define convergence precisely - -Completed: [contract and executable final-write evidence](../../conformance/README.md#convergence-evidence). - -## 2. Tested adoption recipes - -Completed: the [editor recipes](../../examples/editor-recipes/README.md) keep a recovery copy before -deletion or pruning and keep a local value in a conflict, executed by the client suite through -stream events and linked from the saving and Vue guides. The acceptance criteria below are what -those tests cover. - -Keep the existing [user-facing outcomes](../saving.md#what-the-person-editing-sees) and host-owned -[receipt contract](../saving.md#answer-204-or-a-receipt-and-let-the-watch-echo-it) in the saving guide. - -For proposal 0009's create/delete guidance, use its event-only store input, consolidated connector and -guarded response contract. Successful creates/deletes remain host-owned pending confirmations until -their echo or a completed snapshot confirms state; do not restore unguarded adoption or optimistic -store deletion in a recipe. Keep server acceptance separate from confirmation to avoid duplicate -submissions when an echo is delayed or arrives before the response. - -### Deletion recovery copy - -Demonstrate a host subscription that snapshots detached `store.draft(uid)` on each notification while -that fixed UID exists, including the initial state. When removal or snapshot pruning makes it absent, -retain the last copy for explicit copy-out; do not try to read the removed draft. Capturing only on -Save misses later typing. Specify subscription cleanup, identity-scoped retention and expiry. - -**Acceptance:** edits immediately before deletion and snapshot pruning remain recoverable; a -replacement UID opens separately and never inherits the old draft. Test initial capture, edit-time -updates, null-state handling and disposal in the actual recipe. Keep one active draft store; the -recovery copy is not another reconciler. Link the tested recipe from the saving and Vue guides. - -### Explicit keep-local resolution - -Demonstrate capturing the chosen local value (including absence), resolving that path with -`revert`, then synchronously reapplying it through `setValue` or `removeKey`. Proposal 0009 removes -the equivalent `takeTheirs` alias. Verify the recipe against current behavior before publishing -executable guidance. - -**Acceptance:** cover nested paths, deletion, whole-array replacement and policy/redaction refusal; -preserve unrelated edits and conflicts. Capture a fresh save intent after review. Add a small helper -only if repeated consumer code warrants it; do not create another conflict registry. - -## 3. Real-API composition and identity races - -Implemented as the `TestRealAPI` cases, run by `task test-real-api`. The browser side runs the real store and conditional editor under node. A -real status subresource comes from a test-installed Widget CRD, and a test-only transport barrier -recreates the ConfigMap between the host's preflight GET and its PATCH. The race showed that -Kubernetes checks the captured `resourceVersion` before the UID, so a replacement arrives as a plain -409. The host now classifies it with one GET and answers with structured Kubernetes `Status` -responses. The criteria below are what those cases assert. - -The [existing real-API stale-RV test](../../gateway/kube/e2e_test.go) proves ordinary rejection, not -these compositions. Extend it alongside [store/example tests](../../packages/krm-stream/test/saving.test.ts) -and [host handler tests](../../gateway/kube/examples/conditionalsave/handler_test.go). - -- **Status churn:** use a real status subresource. Prove a persisted status change advances RV, - spec projection suppresses its notification, stale PATCH returns 409, guarded projected GET - advances the base without field conflicts, and a fresh intent succeeds when churn stops. Contrast - full projection and bookkeeping-only suppression. A no-op SSA reapply is not a valid fixture. -- **Overlapping recovery:** save starts live, status advances RV, upstream closure begins a snapshot, - and the post-409 GET arrives before synced. Assert refused reconciliation, retained drafts, - recovery presentation and later progress without blind PATCH retries. -- **UID race:** force deletion/recreation between host preflight GET and PATCH with a test-only - barrier. Preserve structured API Status and prove the replacement is unchanged. Classify the - observed identity mismatch specifically; never normalize all 422 validation errors to conflicts. - -**Acceptance:** observable winner/draft preservation and accurate outcomes, with the exact tested -commit, server version, commands, scenarios and results attached to the PR. Use existing host -validation boundaries; do not generalize the ConfigMap endpoint just to build a test. - -Wire focused real-API cases into CI or an explicitly invoked workflow whose successful run is merge -evidence. Keep broader aggregated-API coverage available through `task test-cluster`. Record skips -and unrun cases separately from fake-client results; update task/workflow comments to match coverage. +| Defined views and suppression | Full/spec Secret-value redaction, status omission in spec, redaction revisions and suppression excluding RV | [Proposal 0004](0004-views-and-bytes.md) | +| Gateway snapshot and shared-watch baseline | Complete snapshot boundaries, UID membership, optional watch sharing, bounded configured HTTP delivery and lifecycle observations | [Watching](../why-a-gateway.md), [proposal 0007](0007-shared-stream-host-integration.md) | +| Shared-watch hardening | Independent/cancellable openings, write-timeout requirement with timed authorization and exact SubjectAccessReview inputs | [Proposal 0008](0008-shared-watch-hardening.md) | +| Browser connector separation and fixes | One fetch/SSE connector delivers state events; bounded recovery, terminal refusal, callback-failure cleanup, protocol diagnostics and adopted-save membership fix | [Proposal 0009](0009-stream-and-editor-separation.md#implemented-connector-and-correctness-fixes) | +| Go API cleanup | Shared `StreamConfig`, consolidated serving/constructor names and internal repository harness | [Proposal 0010](0010-gateway-api-cleanup.md), [migration](../migrating.md) | +| Stream convergence contract | Projected-content guarantee and executable final-write evidence | [Conformance](../../conformance/README.md#convergence-evidence) | +| Optional editor and safe-save baseline | Draft reconciliation, explicit conflicts, atomic intent capture and guarded projected reads; recovery remains review plus another deliberate Save | [Editor model](../client-state-model.md), [saving](../saving.md) | +| Editor integration and recovery recipes | Tested deletion recovery, keep-local resolution and Vue subscription ownership | [Recipes](../../examples/editor-recipes/README.md), [Vue](../../examples/vue/README.md) | +| Real-API save composition | Cases for suppressed churn, guarded-read overlap, structured errors and same-name UID replacement | [Completed baseline](#completed-baseline) | +| Watch-first documentation | README, watch/edit/save guides, native-fetch request and compact decision records; duplicate/superseded guides removed | [README](../../README.md), [current request](../field-reports/third-our-identity.md) | + +## Open work and delivery order + +The numbers express the recommended order of attention, not a requirement to finish every earlier row +before starting a later one. Deliver separately reviewable changes and use the prerequisites below. + +| Order | Open work | Status / prerequisite | Completion result | +|---|---|---|---| +| 1 | Settle the native watch contract and comparison workload | Requested; start now using the existing event-based connector seam | Reviewed native view/edit contract, initial-list/resume rules, stale/live presentation, auth/expiry handling and SSE compatibility; one declared workload | +| 2 | Finish the editor API cleanup in proposal 0009 | Proposed; can proceed alongside native design; coordinate shared state-input changes | Bound event input, removal of unguarded/duplicate APIs, simpler redaction reads, trimmed exports, migrated examples and upgrade guide | +| 3 | Implement the native fetch connector with recovery acceptance | After native contract review; transport can use today's event consumer without waiting for editor cleanup | Native JSON directly becomes state events; paginated-list fallback, safe resume/resnapshot, terminal auth refusal, bounded recovery and disposal pass acceptance | +| 4 | Measure and implement gateway upstream continuation | Design exists below; independent of native connector and editor cleanup | Reviewed reopen/time bounds, same-workload baseline, tests and measured continuity without routine downstream resets; safe fallback and authorization preserved | +| 5 | Evaluate save progress during suppressed churn | Requested evaluation; existing real-API fixtures suffice; independent of native transport | Measured decision on bounded submitted-intent recovery first, then optional coalesced version delivery; baseline retained if benefit is insufficient | +| 6 | Deliver the comparative frontend and measurements | Baseline gateway/shared-watch measurements can start now; native comparison follows order 3; editing uses the API selected in order 2 | One viewer plus optional editor compares native/full/spec and shared/unshared runs, including conflicts, expiry, reconnect and synthetic Secret disclosure checks | +| 7 | Validate, release and adopt each ready change | Per change, after its own acceptance and final-commit CI; no release requires all tracks to finish | Matching npm/Go artifacts, migration guidance and host dependency updates with browser/session/RBAC acceptance | + +Native design, implementation and comparison are specified in the +[current request](../field-reports/third-our-identity.md#native-watch-connector). Editor cleanup is +specified in [proposal 0009](0009-stream-and-editor-separation.md#proposed-editor-cleanup). Gateway +continuation and save-progress evaluation retain their detailed sections below. + +### Dependencies and next steps + +1. Review the native source/view and recovery contract, including the editor's machinery-field and + host-validation rules. Agree the test workload before adding another public connector. +2. In parallel work tracks, review and finish the pending editor cleanup, review the existing gateway + continuation design and record gateway/save baselines. This describes scheduling; each change has + its own review and acceptance. +3. Implement native transport after its contract is settled. The current event consumer is enough for + transport work; a dedicated read-only store and new package entry points are not prerequisites. + Finalize the supported editor input before publishing the native editing example or new editor APIs. +4. Run the comparison against whichever implementations are ready, naming each tested revision and + feature set. Do not attribute native resume savings to gateway continuation or sharing savings to + suppression. Save-policy implementation requires a favorable evaluation and reviewed follow-up scope. +5. Validate and release ready increments independently under the lockstep policy. Check consumer pins + and host acceptance before marking adoption complete. The existing implemented stack can be + validated/released without waiting for the new native feature. + +There are no assigned dates or owners in this plan. Open design decisions are native view identity and +editable machinery, stale/live transitions on resume, gateway reopen deadlines/credential lifetime, +and the save-recovery policy and version-delivery compatibility decision. Resolve them in their +respective contract reviews; none authorizes a silent change to current projected-view semantics. + +## Deferred work + +| Item | Trigger for reconsideration | Current approach | +|---|---|---| +| Dedicated `ResourceStore`, shared snapshot-tracker extraction and stream/editor subpaths | Demonstrated viewer or unbundled-loading need with measurable benefit | `LiveResourceStore(readOnlyPolicy)` and the root/combined bundle; no extra store is required for native transport | +| Authorization triggers, grouped checks, decision cache and exported review helpers | Measured duplicate authorization work or a host lifecycle unmet by cancellation/timed checks | Existing per-subscriber checks and host-owned policy; [proposal 0008](0008-shared-watch-hardening.md#deferred-work-and-conditions-for-reconsideration) | +| Version-delivery or automatic save-recovery implementation | Order 5 establishes benefit, safe scope and explicit compatibility | Current suppression and another deliberate Save; evaluating alternatives is scheduled, implementation is conditional | +| Browser replay for gateway SSE, independent delivery switches, write tickets and general SSA abstraction | Separate concrete use case and reviewed protocol/write contract | Fresh browser snapshots, named projections and host-owned conditional merge PATCH | + +## Completed baseline + +Convergence is defined by [the protocol and executable evidence](../../conformance/README.md#convergence-evidence). +The [editor recipes](../../examples/editor-recipes/README.md) cover deletion recovery and explicit +keep-local resolution through store events. Preserve their draft, UID, policy and disposal guarantees +when changing the editor API. + +The `TestRealAPI` composition cases in [composition_e2e_test.go](../../gateway/kube/composition_e2e_test.go) +exercise status-subresource churn, bookkeeping-only suppression, overlapping guarded reads and +same-name UID replacement. They use the real store and conditional editor under node, a Widget CRD +and a test-only preflight/PATCH race barrier. They establish 409 rejection, guarded recovery and a +fresh deliberate save after churn stops; they do not establish usable save progress during sustained +churn. `task test-real-api` runs these cases. Record actual runs, server versions, skips and final +commit separately from fake-client and general CI results. ## 4. Measured upstream continuation @@ -128,7 +112,7 @@ is needed. Downstream v1 remains snapshot-based on a new connection. upstream cannot leave subscribers reporting live forever. Specify the exhaustion transition before implementation; avoid adding public retry knobs without a demonstrated host need. - Recheck the auth implications: fewer cycles mean fewer cycle-only authorization checks and fewer - `ClientFor` refresh calls. Timed subscriber checks must keep running during upstream recovery. + `Clients` refresh calls. Timed subscriber checks must keep running during upstream recovery. Document refreshing credentials and revocation expectations; do not accidentally weaken them. - Keep single-subscriber overflow recovery and the shared cache's ownership intact. Last subscriber departure must cancel an in-progress reconnect. No second shared-watch implementation. @@ -193,13 +177,15 @@ seam. | Context cancelled, or `Stop` | return at once, abandoning a reopen in progress | **Exhaustion transition.** At most three consecutive failed reopens, with jittered backoff of -roughly 250 ms, 1 s and 4 s, so a dead upstream is noticed within about six seconds. A reopened +roughly 250 ms, 1 s and 4 s. These delays total about six seconds; opening/network time is additional +and each reopen needs an explicit cancellable time bound before claiming a total recovery limit. A reopened watch that closes again before delivering any event, bookmarks included, counts as a failure, so a server that accepts and immediately drops watches cannot loop. The count resets when a reopened watch delivers an event. At the bound the watcher returns `ErrWatchClosed`: the gateway starts a fresh cycle, its existing rule turns a second early end into `UPSTREAM_UNAVAILABLE` and closes the -connection, and the client's own bounded retry budget takes over. Subscribers never report `live` -over a dead upstream for longer than that, and no public retry setting is added. +connection, and the client's own bounded retry budget takes over. Measure time to downstream +recovery/closure including opening delays; the backoff total alone does not bound stale `live` +presentation. No public retry setting is proposed without a demonstrated need. **Authorization and credentials.** Each routine upstream close used to start a cycle, and with it a cycle authorization check and a `Clients` call. With continuation those happen far less often. That @@ -235,6 +221,65 @@ user or resourceVersion. recovers safely, retries stop correctly, and authorization/credential lifecycle expectations remain explicit. No new SSE events or downstream replay protocol. +## 5. Save progress under suppressed churn + +**Status: requested evaluation, not an implemented retry or delivery feature.** A spec editor should +avoid repeated extra Save clicks when controller changes leave its relevant editable values unchanged. +The adopter has not yet measured frequency in a representative workload. Reuse the real-API fixtures +above and [proposal 0005's tradeoffs](0005-kubernetes-stream-and-save-semantics.md#host-write-strategies). + +Compare today's guarded read plus another deliberate Save with two optional approaches. Evaluate +bounded save recovery first; periodic version delivery still races changes between delivery and PATCH. + +### Bounded recovery of a submitted intent + +After a definite version rejection, refresh and reconcile under the existing UID, revision and +snapshot guards. Determine whether the original submitted intent is still valid by comparing its +relevant base values and dependencies. No field conflicts in the store is insufficient by itself. +A retry must preserve the submitted patch, exclude later typing, protect whole-array replacements +and refuse changed dependencies or replacement UIDs. Each candidate retry captures the validated +original intent and its new precondition together; never just replace the RV on an old patch. + +Keep explicit review for real disagreements. Bound attempts and elapsed recovery, explain exhaustion +and preserve drafts. Distinguish a definite 409 rejection from an unknown write outcome; do not +replay an ambiguous write automatically. Retain host authorization, projection validation and +serialization of writes. Any public recovery policy requires demonstrated use and its own contract. + +### Optional coalesced version delivery + +Retain the latest eligible version per subscriber and UID and compare periodic batches of small +version-only records with batched complete projected updates. Proposal 0005 currently prefers +existing event shapes if measurements justify more version delivery; do not choose a wire extension +in advance. + +Eligibility requires proof that the last delivered authoritative projected content, excluding RV but +including redaction records, still represents that UID at the newer version. The draft is not the +baseline. Bind delivery to the current view and snapshot and apply it in order. A pending record may +not overtake a visible update, resurrect a delete, roll a version backwards or cross a reconnect. +Clear or supersede pending records on those transitions. Collection bookmarks cannot supply an +object's edit version. Hidden Secret changes require normal redaction updates. + +Advance authoritative versions without resetting drafts or waking content-only subscribers. Capture +future save versions and patches together; do not mutate already captured intents when a batch +arrives. Declare which writes can rely on the guarantee, including dependencies outside the view. +Periodic delivery can reduce staleness but cannot eliminate the delivery/PATCH race. + +### Evidence and decision + +Use identical objects, subscriber counts, edits and status rates for the baseline and variants. +Include quiet periods, bursts and sustained churn. Measure stale-version 409s separately from field +conflicts, extra clicks, save completion latency, successful saves and bounded failures during churn, +GET/PATCH attempts, downstream bytes/events, notifications/renders and batch memory/delivery work. +Success after churn stops is insufficient evidence of usable editing during churn. + +Cover later typing, changed array members, concurrent spec edits, deletion/recreation, partial +snapshots, resets/reconnects with pending batches, expired sessions, denied access and hidden Secret +rotations. Preserve current `krm-spec/v1` status-only silence by default. Any new delivery mode needs +an explicit opt-in and compatibility decision; do not silently wake current consumers. + +Document measurements, the decision and adoption guidance. Retaining the baseline is a valid outcome +if added costs outweigh measured benefit. A universal automatic-save policy is outside this request. + ## Verification and scope Use [existing verification tasks](../../CONTRIBUTING.md#test-levels) appropriate to each change, @@ -258,6 +303,7 @@ and sinks have bounded completion times. Exercise quiet and active streams; cycl meet a bounded quiet-stream revocation target. See [authorization lifecycle](../auth.md#session-validity-and-timed-checks) for configuration and host responsibilities. -Version-only events, independent content/delivery switches, downstream replay, write tickets, -automatic conflict-free retry and a general SSA abstraction remain deferred until a concrete use -case and measurements justify them. Preserve current named-projection semantics and host policy. +Order 5 evaluates submitted-intent recovery and coalesced version delivery without authorizing +implementation or changing default emissions. Independent content/delivery switches, downstream +replay, write tickets and a general SSA abstraction remain deferred. Preserve current named-view +semantics, host policy and the distinction between native browser and gateway upstream recovery. diff --git a/docs/proposals/0007-shared-stream-host-integration.md b/docs/proposals/0007-shared-stream-host-integration.md index a3ec480..3b3ba3f 100644 --- a/docs/proposals/0007-shared-stream-host-integration.md +++ b/docs/proposals/0007-shared-stream-host-integration.md @@ -1,230 +1,41 @@ # Proposal 0007: Shared-stream transport correctness -**Status: historical implementation plan; implemented and released in 0.4.0.** It is kept as the -record of what was decided and why. Its instructions are not adopter guidance, and "current" below -describes the code at the time (`e450334`), not today's. For current guidance see -[authorization](../auth.md), [operations](../operations.md#bounded-http-delivery) and the -[shared ConfigMap host](../../gateway/kube/examples/sharedstream/README.md). +**Status: implemented and released in 0.4.0.** This compact record preserves the decisions; current +configuration and boundaries are in [authorization](../auth.md), [operations](../operations.md) and +the [shared-host example](../../gateway/kube/examples/sharedstream/README.md). -The transport, observation and example increments below have landed. The manual real-cluster scenario has been run at 2 and 200 identities and recorded in -[docs/facts/shared-host-rehearsal.md](../facts/shared-host-rehearsal.md); 200 subscribers cost -exactly one additional API-server watch. That is one run on one disposable cluster, without a proxy, -browsers or resource limits, and is not a capacity guarantee. +## Decisions -## Recommendation - -Centralize bounded HTTP delivery and balanced stream/subscription observations in the library. -Keep identity resolution as a tested example and use existing manual cluster tooling for capacity -measurements. The justification is consistent transport and lifecycle behavior across hosts; -line-count reduction is a possible adoption result, not an acceptance criterion. - -This responds to Voter's 2026-09-11 request against reported release 0.3.0 and the subsequent -scope review. The initial code review used `e450334`. Voter's unpublished implementation and -200-identity rehearsal have not been reproduced here. - -Follow the [design rules](../../CONTRIBUTING.md#design-rules) and -[release policy](../releasing.md). Credentials, sessions, cluster selection, allowlists, RBAC, -writes and metrics export remain host-owned. No wire or browser-store change is planned. - -| Increment | Deliver | Keep out | -|---|---|---| -| 1 | HTTP operation deadlines, capability check, transport-rejection observation and cancellation tests | New transport framework or promise of a universal revocation bound. | -| 2 | Stream close plus shared-subscription open/close observations | New observation fields, HTTP request accounting, authorization telemetry and upstream gauges. | -| 3 | Tested shared-host example with local subject resolution and capacity notes | Public identity helper, new cluster CI workflow or adopter-specific login suite. | - -[Proposal 0006](0006-stream-and-save-implementation-plan.md) remains independently deliverable. -These changes can help satisfy its existing evidence requirements, but are not additional gates. -Hosts can demonstrate bounded sinks and measurements through their own implementations. - -## 1. Bound HTTP delivery - -The current [`sse.go`](../../gateway/sse.go) discards flush errors, ignores the emit context, -and stops only the heartbeat goroutine when its write fails. Teardown waits for that goroutine. -In [`reauthorize.go`](../../gateway/reauthorize.go), downstream delivery holds the authorization -gate, and the periodic callback timeout starts after acquiring it. These paths need testing together. - -Moving `context.WithTimeout` above `gate.mu.Lock()` alone is not a teardown fix: -[`Mutex.Lock`](https://pkg.go.dev/sync#Mutex.Lock) does not accept cancellation. The budget can -expire while the goroutine still waits, and the in-flight sink write still needs to finish. -Do not ship that move as an independent bounded-revocation guarantee. For this increment, retain -`ReauthorizationTimeout` as the periodic callback budget and bound HTTP I/O at its owner. A -cancelable gate can be considered separately if measurements establish a remaining need; it -would still not terminate an arbitrary blocked sink. - -Add `WriteTimeout time.Duration` to `Options` and `Gateway` for HTTP serving only. Zero installs -no library deadline, positive values enable the bound, and negative values are configuration -errors. Validate numeric options at handler construction and before I/O for direct serving calls. -Use five seconds explicitly in the example, not as a universal default. - -Implement within the existing SSE transport and reuse it for normal streams and the identity/scope -`refuse` path in [`handler.go`](../../gateway/handler.go): - -- One serialized operation gets one absolute deadline covering write plus flush. Include initial - headers, heartbeat comments and terminal frames. Use an earlier request deadline where present. -- Clear deadlines after successful operations so quiet streams survive many timeout periods. - Attempt cleanup on exit after transport workers finish. Do not reset a failed transport to - attempt another frame. Document ownership: a previous host deadline cannot be retrieved and restored. -- Use [`http.ResponseController`](https://pkg.go.dev/net/http#ResponseController) for deadline - support and error-aware flushing. Propagate reported flush errors and short writes. An errorless - flusher cannot report every failure; successful flushing is not browser acknowledgement. -- Cancel the internal stream context on header, frame or heartbeat failure and join the heartbeat - worker. Queued operations must not resume after failure. Never abandon a goroutine in a write. -- Preserve ordinary terminal SSE refusals on supported transports, including error propagation - from their initial headers and flush. - -### Helper and compatibility decisions - -Remove exported `WriteSSEHeaders` and replace its serving-path use with an internal error-returning -header operation under the same deadline handling as frames. Update `Handler`, direct serving -entry points and refusal paths together. No compatibility shim; direct HTTP users should use -`Gateway.ServeStream` or `ServeStreamProjection`, which own headers and transport cleanup. - -Keep `NewSSESink(io.Writer)` for generic framing, including the existing buffer-based golden tests. -Its argument need not become `http.ResponseWriter`: the HTTP serving path already has that writer -and can supply controller-backed I/O through internal configuration of the same sink. There must -be one frame encoder. Generic callers receive no HTTP timeout guarantee; propagate flush errors -when their writer exposes an error-returning flush method. Change `SSESink.Heartbeat` to return -an error so its HTTP owner can cancel the stream on failure, and update callers to handle it. - -The increment's release notes must name removal of `WriteSSEHeaders`, the serving-method migration, -the `Heartbeat` return change, the new timeout/check API and error propagation. These are public -API changes under the pre-1.0 policy, even though no new wire behavior is introduced. - -### Unsupported writers - -Check deadline/flush capability before committing SSE headers or opening a subscription. The -request's writer does not exist at `Handler` construction, so middleware capability must be tested -at request time and in the host's integration tests. Transparent wrappers should implement `Unwrap`. - -Add `CheckHTTPStreaming(w http.ResponseWriter) error`, shared by bounded HTTP serving and host -tests. It checks exposed flush support through the controller-compatible wrapper chain and probes -write-deadline support by setting a zero deadline. It must not write headers/body or invoke flush. -Return an error identifying the missing capability, preserving `errors.Is(err, http.ErrNotSupported)` -for unsupported operations. Call only before streaming with no concurrent writer use: the probe -clears any existing write deadline and cannot restore it. It checks exposed capabilities, not -whether arbitrary middleware correctly implements them or whether a proxy buffers responses. - -Add a tested adoption recipe using `httptest.NewServer` with the host's actual middleware chain -around a test-only leaf handler that calls this check and records its result for assertion. Use a -real server writer, not `httptest.ResponseRecorder`, which does not supply network deadlines. -Cover transparent-wrapper success and opaque-wrapper failure. Never mount this probe as a public -production route. Real blocked-client tests remain necessary to establish deadline behavior. - -If the host requests a bound and the writer cannot provide it, abort with `http.ErrAbortHandler`. -Do not attempt an unbounded terminal frame: even a tiny frame can block during flush, so that fallback -would violate the requested guarantee. No new wire error code is needed. - -This failure can cause browser reconnects and must not be described as a clean terminal SSE refusal. -Emit `http_transport_rejected` once before aborting, using the existing observer and internal-error -code, with no raw middleware error or claim of frame delivery. This event has no corresponding -`stream_opened` and does not change a stream gauge. A broken transport cannot guarantee both bounded -completion and an explanatory frame reaching the browser. This tradeoff is deliberate, not a -solution to automatic reconnects. - -### Acceptance - -Use real HTTP/1.1 sockets, TLS HTTP/2 and controlled failing writers. Cover a non-reading peer with -enough data to exhaust buffers, quiet/active healthy streams across multiple timeout periods, -header/refusal and heartbeat-only failures, flush errors, short writes, transparent and opaque -wrappers. Verify HTTP/2 isolation on the same connection. Keep these tests in normal Go CI. - -Measure expiry, cancellation and periodic revocation during blocked delivery while another -subscriber continues. Assert handler/heartbeat completion, subscriber release and eventual last -upstream cleanup with explicit synchronization and declared timing tolerances. - -The write timeout excludes gate waiting, encoding CPU and arbitrary callbacks or backend -`Watch`/`Stop`. Shared opening currently uses a detached context under the backend mutex; do not -claim subscriber expiry bounds it. Generic sinks and host callbacks must cooperate with cancellation. -The total revocation budget still includes timer cadence, in-flight delivery, authorization and cleanup. - -## 2. Balance a small set of lifecycle observations - -Keep the existing `Observer` interface and `Observation` fields. The complete additions are below; -`http_transport_rejected` ships with increment 1, and the three lifetime events with increment 2. - -| Observation | Counting contract | +| Area | Result and boundary | |---|---| -| `http_transport_rejected` | One pre-stream capability rejection when bounded HTTP serving cannot be supported. No logical stream opens, no close is owed, and no SSE error delivery is attempted. | -| `stream_closed` | Pair existing `stream_opened` at `StreamProjection` entry with one close on return, including authorization failure. It excludes HTTP identity/scope refusals before entry. | -| `shared_subscription_opened` | Report successful attachment to a shared scope, including a warm-cache join. This is a new event; no shared-attach observation exists today. | -| `shared_subscription_closed` | Report once when that attachment becomes inactive through overflow, scope death or leave. A later repeated `Stop` must not count again. | - -Retain cycle, overflow, resync, suppression and terminal-error signals. A resnapshot can close and -reopen an attachment on one logical stream without creating a new upstream watch. These counts -are not physical API-server watch counts, HTTP request counts or proof that cleanup has finished. - -Preserve `terminal_error` for logical-stream failure. It already occurs before `sink.Emit` in -`emitTerminal`, so it has never guaranteed frame delivery. In increment 1, clarify this in the -`observe.go` comment and the operations alert row, and add the separate transport-rejection row. -Test that unsupported writers emit exactly one rejection, no `stream_opened` or `terminal_error`, -and open no backend; separately test that a failed terminal-frame write still reports the existing -logical-stream failure. Do not count either diagnostic as a lifetime open or close. - -Callbacks remain synchronous, prompt and concurrency-safe; no lossy library queue, new IDs or -statistics framework. Guarantee open-before-close for each lifetime, not global ordering. Hosts -update counters synchronously and must not block, panic or reenter the gateway from an observer. -Review callback placement under shared locks. Ignore unknown kinds and avoid turning `Scope` -values into metric labels; correct the overly broad safe-to-export wording in `observe.go`. - -**Acceptance:** race-test concurrent/warm-cache joins, denial, revocation, immediate upstream EOF, -recovery, overflow, transport failure and repeated cleanup. Assert intermediate counts and final -balance. Add a small tested counter mapping in the operations example. - -Keep HTTP entry/exit and authorization/SAR instrumentation in host wrappers for now. Defer upstream -handle observations too: they can describe a useful library lifetime, but do not measure physical -API-server watches. This smaller change will not let Voter delete all its watch/metrics wrappers; -retain them until equivalent observations are actually provided. Revisit additions when concrete -remaining integration code demonstrates their value, rather than requiring an arbitrary adopter count. - -## 3. Test the composition and record capacity - -Add `gateway/kube/examples/sharedstream`, following the existing conditional-save example's -placement. Use a local, tested function to issue SelfSubjectReview with a supplied participant -client and copy username, groups, UID and multi-valued extras into `kube.Subject`. Reject missing -username/nil results; preserve API and cancellation errors and independent returned data. No public -resolver API, credential discovery, identity cache, impersonation or guessed OIDC mapping. - -Show one process-wide shared backend, trusted session resolution, one server-selected cluster, -fixed namespace/name policy, participant SSR, service-account SAR/data access, explicit write and -periodic-check timeouts, and the earlier session/token expiry. Keep direct writes participant-owned -and link the existing save example. Browser identity headers must not select the client or override -the resolved subject. A service-account SSR resolves the service account; it is not participant identity. - -Opening SSR and initial/cycle authorization need explicit short callback deadlines in the example: -`ReauthorizationTimeout` covers periodic checks only. Do not impose a short deadline on the whole -healthy stream. Keep backend request timeouts and session deadlines distinct. - -Update [adoption](../adopting.md), [authorization](../auth.md), [operations](../operations.md), -the [gateway README](../../gateway/README.md) and [examples index](../../examples/README.md) -when implementation lands. Explain `2 * N / interval` SARs/second for allowed checks: about 13.3 -at 200 subscribers/30 seconds, plus opening/cycle bursts. Two hundred simultaneous openings can -need 400 SARs and 200 separate SSRs. Early denial can issue fewer; periodic checks may align. -Client throttling, shared rate limiters and API-server capacity all matter. Voter's reported -100-QPS/400-burst setting is evidence for its workload, not a default. - -Compile and test the example in normal CI, including identity copying, cancellation and host -boundary tests. Reuse manual `task test-cluster`/`task cluster-facts` machinery for a focused load -scenario; add an opt-in Task entry only if needed. No new cluster CI workflow. Record actual runs -in `docs/facts/` with commit, versions, workload, proxy/protocols, limits, commands and results. -Keep general guidance in operations/auth docs and unrun scenarios clearly separate from facts. - -Exercise concurrent openings/reconnects, one revoked identity, expiry, a blocked peer, continuing -peers and final cleanup. Use independent API-server evidence for physical watches. The 200-user, -30-second recheck, five-second check and 60-second closure profile from Proposal 0006 is a declared -measurement target, not a guarantee or release-wide load-test requirement. Measure snapshot bytes -and sustained resource/latency behavior; a short rehearsal cannot establish production capacity. -Do not import Voter's login/browser suite or extend Kubernetes support from its k3s 1.31.5 result. - -## Delivery - -Review transport, observations and example changes separately; observations can proceed independently. -For runtime PRs use the [existing checks](../../CONTRIBUTING.md#test-levels): `task fixtures-check`, -`task test`, `task lint`, focused race tests in changed Go modules, and wire/browser tests for HTTP -changes. Attach a manual real-cluster run for claims about actual SSR/SAR composition; do not label -fake-client results or skipped scenarios as cluster evidence. Planning-only changes need link and -whitespace checks. - -Use the lockstep release process and document option defaults, unsupported-writer behavior, flush -errors and event meanings. No release is required just for this proposal. Consumers pin released -artifacts and remove only wrappers whose behavior they have verified is covered; credentials, -scope/session policy and their boundary tests remain in the host. +| HTTP delivery | A configured positive `WriteTimeout` bounds each write-plus-flush operation, including headers, events and heartbeats; a quiet stream can outlive many timeout periods | +| Writer capabilities | Bounded serving requires flushing and write deadlines; unsupported middleware aborts before a logical stream opens | +| Lifecycle observations | Balanced stream and shared-subscription open/close events; bounded labels and synchronous, prompt callbacks | +| Host composition | Tested session/subject resolution, timed checks and write bounds in an example, rather than a public identity framework | + +The library owns HTTP deadlines during serving, clears them after successful operations and at exit, +and cannot restore a previous host deadline. A whole-response server timeout is not a per-operation +bound. HTTP/1.1, TLS HTTP/2, peer isolation and transparent/opaque wrappers have focused tests; mounted +middleware and proxy combinations still need host checks. Generic sinks are the host's to bound. + +Timed authorization and delivery share a subscriber gate. Write bounds matter to revocation, but +encoding, backend opening, gate waiting and arbitrary host callbacks are not bounded by the HTTP +write timeout. [The revocation budget](../auth.md#revocation-budget) owns that explanation. + +Stream observations count `Stream` entry/return, including authorization refusal but excluding earlier +HTTP identity/scope refusals. Shared-subscription observations count attachments, including warm-cache +joins; overflow or scope loss can replace an attachment without a new logical stream. Callbacks may +run concurrently under shared locks, must return promptly and must not reenter the gateway. + +## Capacity and deferred scope + +The [recorded shared-host rehearsal](../facts/shared-host-rehearsal.md) reports 200 subscribers under +a declared workload. Its authorization rate is part of the cost: allowed checks require list and watch +reviews per subscriber. Watch sharing reduces upstream duplication, while snapshot delivery, browser +work and authorization still scale with subscribers. The rehearsal is not a universal capacity claim. + +Public identity helpers, authorization caches, grouped scheduling and new metric APIs were not part +of this change. Use existing host wrappers and bounded labels. [Proposal 0008](0008-shared-watch-hardening.md) +records subsequent hardening; [proposal 0006](0006-stream-and-save-implementation-plan.md) owns remaining +continuation and save-progress evaluations. Native browser transport leaves these gateway boundaries +intact. diff --git a/docs/proposals/0008-shared-watch-hardening.md b/docs/proposals/0008-shared-watch-hardening.md index 80f5997..85ff7e2 100644 --- a/docs/proposals/0008-shared-watch-hardening.md +++ b/docs/proposals/0008-shared-watch-hardening.md @@ -1,214 +1,47 @@ -# Proposal 0008 Shared watch hardening implementation plan +# Proposal 0008: Shared-watch hardening -**Status: historical implementation plan; selected scope implemented, asks 11 and 12 deferred.** -It is kept as the record of what was decided and why; its instructions were for the implementer and -are not adopter guidance. For current guidance see -[session validity and timed checks](../auth.md#session-validity-and-timed-checks), the -[revocation budget](../auth.md#revocation-budget), -[what the SubjectAccessReview asks](../auth.md#what-the-subjectaccessreview-asks) and -[shared backends](../../gateway/README.md#shared-backends). +**Status: selected scope implemented.** This decision record replaces the old adopter issue inventory +and implementation handoff. Use [shared-watch authorization](../auth.md#shared-watch-authorization), +[the revocation budget](../auth.md#revocation-budget) and [operations](../operations.md) for current setup. -This is the implementer handoff for the [second krm-foyer field report](../field-reports/second-fieldreport.md). -Implement ask 9, the explicit HTTP configuration validation option from ask 10, and the -documentation portion of ask 13. These changes repair shared-watch opening and make the -requirements for timed authorization clearer. Asks 11 and 12 are deferred; they are not -acceptance criteria for this work. +## Decisions and guarantees -The local source review used commit `869ea81`. The field report describes a different baseline, -`5ca19ed`, and external krm-foyer reproductions. Those reproductions have not been run in this -repository. Confirm the implementation baseline and add repository-owned tests before changing code. +| Problem | Implemented decision | +|---|---| +| A blocked upstream open held the shared backend lock and unrelated scopes | Open each scope independently outside the backend-wide lock, with a shared opening context and cancellable callers | +| The last waiting caller could leave an opening running | Last-waiter departure cancels and discards the opening; last-subscriber departure stops an established watch | +| A blocked HTTP write could delay timed authorization indefinitely | Positive timed reauthorization requires a positive HTTP write timeout, validated at construction/direct serving | +| Hosts needed exact authorization inputs for safe caching | Document and test the adapter's complete SubjectAccessReview request | -## Decisions and work order +A caller's context bounds its own wait rather than the lifetime of other subscribers' shared watch. +An abandoned opening must not overwrite a replacement or publish its watcher; stop any discarded +watcher. Shared scope backoff and overflow resnapshot behavior remain unchanged. -| Order | Deliver | Acceptance evidence | -| --- | --- | --- | -| 1 | Ask 9: independent, cancellable shared-watch opening | Deterministic concurrency tests and Go race checks | -| 2 | Ask 10: require a positive HTTP write timeout with timed reauthorization | Construction and direct-serving validation, plus blocked-reader HTTP coverage | -| 3 | Ask 13: document the actual SubjectAccessReview request | Complete request assertions and matching API and adoption documentation | +`ReauthorizationTimeout` starts after the periodic check obtains the delivery gate. It bounds that +check's callbacks, not opening/cycle authorization or the entire revocation interval. Host callbacks +must honor contexts and generic sinks must bound delivery. A write timeout is necessary to bound +blocked HTTP I/O but does not make the total revocation budget equal to the callback timeout. -Deliver these as separate reviewable changes. They do not depend on authorization caching, -event-driven policy integration, new wire events, or browser changes. Keep the existing save and -draft-recovery work in [proposal 0006](0006-stream-and-save-implementation-plan.md) independently -deliverable. Follow [design rules](../../CONTRIBUTING.md#design-rules) and -[release policy](../releasing.md). - -## 1 Shared watch opening - -### Required behaviour - -In [shared.go](../../gateway/shared.go), move upstream `Watch` calls outside the backend-wide -mutex. Waiters must be able to abandon an opening through their own contexts. The upstream -opening must have a scope-owned context: cancelling the first caller must not cancel an opening -that another caller still needs. - -Maintain one current opening per scope. Register an opening under the lock, perform network work -without that lock, and publish its result only if that entry is still current. Count waiting callers -so the final departure cancels and removes an unfinished opening. Waiters for unrelated scopes must -continue independently. Keep the current live subscription lifetime: once attached, `Stop` releases -the subscription and the last subscriber cancels the shared upstream. - -Handle the handoff between opening and subscription explicitly. There must be no gap in which the -opening has no waiters or subscribers but retains an upstream watch. If an abandoned upstream -returns a watcher late, stop it and discard it. Its result must not delete a replacement opening, -populate its cache, or change its backoff. - -The implementation may use a private opening entry with a completion signal and waiter accounting; -no public opening API is required. Preserve existing snapshot framing, cache ownership, queue -overflow recovery, failure classification, backoff and lifecycle observations. A cancellation caused -only by all waiters departing is not an upstream failure and must not impose outage backoff. - -### Acceptance tests - -Use channel barriers and a fake upstream that can block inside `Watch`; avoid sleep-based ordering. -Tests must cover: - -- Scope A blocks while scope B opens and delivers its snapshot. -- Concurrent callers for the same scope cause exactly one current upstream opening. -- Cancelling one waiter returns promptly without disrupting remaining waiters. -- Cancelling the final waiter cancels the upstream context, removes the opening and permits a retry. -- A caller whose context is already cancelled does not create or retain an upstream opening. -- Cancellation racing with successful opening leaves neither a watcher nor a subscription orphaned. -- An abandoned opening returning late cannot mutate a newer entry; any returned watcher is stopped. -- A genuine retryable opening failure updates backoff once, and all relevant waiters receive the - classified failure. Existing terminal failures and retry hints retain their behaviour. -- Warm-cache joins, last-subscriber cleanup, overflow and balanced observations remain correct. - -Cancellation guarantees assume the upstream honours context cancellation. Do not promise to forcibly -terminate an arbitrary host backend; ensure its late result cannot affect current state. - -## 2 HTTP configuration for timed reauthorization - -### Chosen implementation - -Require `WriteTimeout > 0` whenever `ReauthorizationInterval > 0` for library-owned HTTP serving. -Reject the invalid combination with a clear configuration panic, consistent with existing option -validation. Do not silently select a timeout. - -Validate at `Handler` construction and before any response I/O in direct `ServeStream` and -`ServeStreamProjection` calls. Share private validation where useful so these paths cannot diverge. -Retain existing rejection of negative write timeouts. A zero write timeout remains valid for HTTP -streams with timed reauthorization disabled. - -Transport-neutral `Stream` and `StreamProjection` must continue to support timed checks with -host-owned bounded sinks. Do not require an HTTP-only option for those methods. Update the -`Options` and `Gateway` comments to distinguish these paths. - -Preserve the delivery gate and existing per-subscriber authorization and projection checks. This -increment does not introduce a cancellable gate, independent authorization scheduler, or force-close -API. Positive timeout capability checks must retain their current fail-before-streaming behaviour. - -### Documentation and limits - -Update [auth.md](../auth.md), [operations.md](../operations.md), [adopting.md](../adopting.md), and -affected examples. The timed-recheck example must set both authorization and write budgets. - -Explain that `ReauthorizationTimeout` starts after gate acquisition and covers the periodic callbacks. -Describe the revocation budget in one place, including timer scheduling, gate waiting, callback -timeout, host cache freshness, and termination/cleanup. Link to it from the other guides. -Do not turn interval + callback timeout + one write timeout into an unconditional bound: -terminal delivery, competing I/O, scheduling and host cleanup can add time. Distinguish cessation of -new object delivery, request return, and browser receipt. Already buffered bytes cannot be recalled. -Opening and cycle authorization still require host callback deadlines. - -### Acceptance tests - -- `Handler` rejects timed reauthorization with zero write timeout at construction. -- Both direct HTTP entry points reject that combination before headers, observations for logical - stream opening, or backend calls. Use a writer spy to prove no response I/O occurred. -- Positive write timeout with timed checks is accepted; zero timeout without timed checks remains - accepted. Generic bounded sinks can use timed checks without an HTTP write timeout. -- Reuse or extend the real HTTP blocked-reader tests in [sse_test.go](../../gateway/sse_test.go). - Fill transport buffers, revoke access, and verify that the request terminates within a declared - test budget and releases its subscription. A healthy subscriber must remain unaffected. -- Preserve HTTP/1.1 and TLS HTTP/2 coverage, unsupported-writer rejection, quiet-stream survival, - and authorization/projection denial tests. Do not require a terminal frame to reach a stalled client. - -Record which coverage is reused and which scenario is new. State the test's callback, write and -scheduling assumptions; a generous test tolerance is not a published production guarantee. - -This is a breaking configuration change. Include a `BREAKING CHANGE:` footer explaining that HTTP -hosts using timed reauthorization must choose a positive per-operation `WriteTimeout` and verify -their mounted middleware supports deadlines and flushing. Update repository callers together. - -## 3 SubjectAccessReview documentation - -Document [SubjectAccessReviewAuthorizer](../../gateway/kube/authz.go) as currently implemented: - -- Subject inputs are username, groups, UID and extras, supplied by the host's `SubjectFor`. -- The adapter checks `list` and `watch`, stopping on the first refusal or failure. -- Resource attributes are verb, group, version, resource, namespace and name. -- Label and field selectors and subresource are not supplied. Target and projection are not SAR - resource attributes; the configured client selects the authority receiving the review. - -Add this contract to the exported API comment and shared-watch authorization guide. State that -selectors currently do not narrow the authorization question. Keep this description specific to the -adapter; do not generalize it into a guarantee about all Kubernetes authorization policies. - -Extend [authz_test.go](../../gateway/kube/authz_test.go) to assert both verbs, every transmitted -subject/resource attribute, and the omitted selector/subresource fields. Include a named scope and -a scope carrying selectors so omissions are deliberate and tested. - -Explain that a host cache must distinguish the complete subject and request, including verb, and -isolate decisions by authority or target binding. Reusing a SAR result must not bypass current host -session validity or projection checks. No cache implementation or exported attribute builder is -part of this increment. - -Future changes to the documented authorization inputs require compatibility review and explicit -release notes for caching hosts. The current documentation change does not itself change SAR -semantics. Do not manually edit generated changelogs; use conventional commits and PR details. +The kube adapter checks `list`, then `watch`, using the host-provided subject and the scope's group, +version, resource, namespace and name. Selectors and subresource are not review attributes. Target +selects the review client rather than a Kubernetes review field. Host caches must distinguish full +subject, request/verb and API-server authority, and retain session/projection checks. ## Deferred work and conditions for reconsideration -**Ask 11:** no gateway-wide `Reauthorize(predicate)` API in this plan. Reconsider a minimal trigger -only with a concrete host integration, stream lifecycle and trigger-coalescing rules, a distinction -between scheduling and completion, and coordination with any authorization cache invalidation. -Policy watches and determining affected subjects remain host-owned. Preserve periodic checks as a -fallback, and do not promise immediate revocation from a trigger alone. - -**Ask 12:** no grouping scheduler and no decision cache in this plan. `Principal` is opaque; matching -users does not establish interchangeable sessions or projection policy. Correct the workload before -evaluating benefit: 200 users with nine tabs on one scope produce about 120 periodic SARs/s at a -30-second interval and could reduce to about 13/s with deduplication. Nine distinct scopes per user -still require about 120/s when grouping by principal and scope. These counts assume both verbs are -allowed and exclude opening and recovery checks. - -An optional kube cache can be reconsidered with measured duplicate requests, a complete key, -authority isolation, bounded memory, explicit TTL and invalidation, error handling and waiter -cancellation semantics. Deduplicating SARs must remain separate from subscriber-specific host -authorization and projection decisions. An exported attribute builder also remains deferred. - -## Verification and completion - -For each implementation PR, add focused tests proving the changed behaviour. Before opening the PR, -run `task fixtures-check`, `task test`, and `task lint` as required by CONTRIBUTING. Run the affected -Go package tests with the race detector for shared opening changes. Inspect existing test/task setup -to run each Go module correctly. No new wire fixture is needed unless implementation unexpectedly -changes the shared protocol contract; such a change requires separate scope review. - -The final handoff from the implementer must identify the tested commit, commands and results, -unrun external scenarios, compatibility change, and remaining limitations. All three selected -increments must be complete; deferred asks must not be reported as implemented. No benchmark, -external krm-foyer reproduction, universal capacity claim, or new real-cluster campaign is a merge -requirement for this bounded work. - -## Draft response to the krm foyer team - -Thanks for the concrete failure cases and the explanation of your workarounds. We are taking on -ask 9, the explicit HTTP validation option in ask 10, and documentation of the current SAR inputs -from ask 13. +Public authorization recheck triggers, grouped per-principal/scope scheduling, decision caches and +exported attribute builders remain deferred. Revisit them only with measured duplicate requests or a +specific host lifecycle that existing request cancellation/timed checks cannot handle. -We will isolate upstream openings by scope and cancel an unfinished opening when its final waiter -leaves. For HTTP streams with timed authorization, we will require an explicit positive write timeout -and document the budget and its limits. We will document the exact review inputs and add tests -protecting them. +Any future cache needs complete subject/request/authority keys, explicit validity and invalidation, +failure behavior and subscriber isolation. Grouping must not let one expired session retain access +because another session still passes. Triggers need defined interaction with delivery gates, pending +checks, teardown and cancellation. Do not infer a cache or immediate revocation guarantee from sharing. -We are deferring the public trigger API, grouped authorization scheduler, cache and exported -attribute builder. These need additional contracts around session identity, policy checks, -invalidation and measured duplicate load. Please distinguish nine tabs on one scope from nine -distinct scopes in the load example; grouping only removes duplicate questions for the same pair. +## Evidence and limits -Please share the opening-cancellation and blocked-reader reproductions with their tested commits and -transport settings. They will help us compare repository tests with your integration. Also include -verb and authority isolation explicitly in the cache-key description. We will provide a configuration -migration note with the timed-authorization change. This scope does not promise immediate revocation -or a universal bound for arbitrary host callbacks and sinks. +Focused tests cover independent openings, caller/last-waiter cancellation, discarded/replaced opens, +shared scope lifetime, unsafe configuration refusal, blocked-reader bounds and exact review attributes. +Adopter reproductions motivated the changes; repository tests establish their own scope. These changes +add no new wire event, authorization framework or capacity claim. Actual runtime PR evidence remains +in history; [proposal 0007](0007-shared-stream-host-integration.md) retains the earlier transport decisions. diff --git a/docs/proposals/0009-stream-and-editor-separation.md b/docs/proposals/0009-stream-and-editor-separation.md index 0ee0396..358094c 100644 --- a/docs/proposals/0009-stream-and-editor-separation.md +++ b/docs/proposals/0009-stream-and-editor-separation.md @@ -1,505 +1,119 @@ -# Proposal 0009 Stream and editor separation - -**Status: in progress.** Change 1, one connector that delivers state events, is implemented in -[#53](https://github.com/ConfigButler/krm-stream/pull/53); the host-callback exception rule and the -adopted-save `fix:` follow on the same stack. Change 2 is proposed. The read-only store and the -stream/editor entry points are [deferred](#deferred-until-a-read-only-view-needs-them) until a -read-only view needs them. Source review baseline: `a8281c5`, 2026-10-04. - -krm-stream should make live Kubernetes resources easy to consume, with editing available as an -additional layer. Keep one repository, one npm package and the existing lockstep release process. -Separate browser transport and authoritative resource state from local drafts, three-way -reconciliation, conflicts and patch capture. The Go gateway continues to serve the same v1 protocol. - -Follow the [design rules](../../CONTRIBUTING.md#design-rules) and [release policy](../releasing.md). -This proposal owns the separation; existing behavioral follow-ups remain in [proposal -0006](0006-stream-and-save-implementation-plan.md). - -## Why live watches and three-way merge belong together - -This section is background for readers new to the library. The rest of the proposal assumes it. - -### A live copy of a shared object - -A Kubernetes object is shared. Controllers write `status`, an autoscaler changes `spec.replicas`, -GitOps tooling and other people edit labels and spec. A browser page holds a copy of something other -parties keep changing. - -The stream keeps that copy current. The gateway authorizes the user's subscription, trims each -object to what the user may see (the projection), and sends complete projected objects as visible -state changes. It may suppress bookkeeping-only changes or coalesce intermediate updates. The -browser replaces its authoritative copy with each delivered object. After a reconnect the gateway -sends a fresh snapshot (`reset` … `synced`), and the browser drops whatever was not resent only when -that snapshot completes. The copy also recovers from deletes it missed while disconnected. - -A resource list or status page needs nothing more: replace the object, re-render. - -### Editing an object that keeps moving - -Now put a form on the same object. Someone starts changing the image tag. Meanwhile the autoscaler -raises `spec.replicas` from 3 to 5, and the stream delivers the new object. The page can do one of -three things: - -| Approach | What the person sees | Result | -|---|---|---| -| Replace the form with the new object | Their typing disappears | Lost work | -| Ignore updates while the form has edits | `replicas` still shows 3 | A stale form. A guarded save fails with 409; an unguarded full-object save puts `replicas` back to 3 | -| Three-way merge | `replicas` changes to 5 and the image tag stays as typed | Both changes kept. If both sides changed the same field, the form shows a conflict | - -Replacing a form on every live update destroys edits. Freezing its server state while editing leaves -it stale. Three-way reconciliation lets this editor incorporate live state while preserving the -person's draft and exposing conflicts for review. - -### What the merge compares - -The editor keeps two objects per resource: `server`, the last object the stream delivered, and -`draft`, that object plus the person's edits. When a new object arrives, each editable field is -compared three ways: - -- **base**: the field in the current `server` object, which the draft was built against; -- **ours**: the field in the `draft`; -- **theirs**: the field in the incoming object. - -If only theirs differs from base, the draft follows the server and the UI can highlight the change. -If only ours differs, the edit stays. If both changed it to the same value, they have converged. If -both changed it differently, the edit stays and the field is recorded as a conflict. The incoming -object then becomes `server`, and so the next base. The [client state -model](../client-state-model.md#editing-and-conflicts) has the full table. - -```mermaid -sequenceDiagram - participant K as Kubernetes - participant G as Gateway stream - participant E as Browser editor - participant H as Host save endpoint - G->>E: replicas 3, image v1 (becomes the base) - Note over E: person types image v2 (draft) - K->>G: autoscaler sets replicas 5 - G->>E: replicas 5, image v1 - Note over E: replicas follows the server, image v2 is kept,
the incoming object is the new base - E->>H: merge patch with only image v2, plus UID and base resourceVersion - H->>K: conditional PATCH - K->>G: the watch sees the write - G->>E: replicas 5, image v2, so the draft is clean again -``` - -### Why the editor uses the stream's guarantees - -A merge is only as correct as its inputs. The stream supplies an authoritative base under a defined -identity, projection and recovery contract: - -- Each resource update replaces the visible server copy, so removed fields do not linger. -- Resources are keyed by `uid`, so recreating a name never transfers an old draft to a new object. -- A completed snapshot repairs missed updates after reconnect; surviving resources keep their - drafts. -- A captured save binds the person's patch to the identity and version they reviewed. - -Projection and redaction determine what the person may see and edit. Snapshot pruning can remove a -deleted resource's draft, and merge patches replace arrays as whole values. The [client state -model](../client-state-model.md) and [saving guide](../saving.md) explain those limits. - -The live editor consumes the protocol's state; transport does not depend on the editor. The editor -can also reconcile directly supplied server objects without opening a connection. The current -connectors do not show that boundary: a list page must construct an editing store to use them. This -proposal makes transport deliver protocol events, adds a small read-only store for lists and -viewers, and keeps the editor as the layer a live form adds. Both stores use the same stream -contract. - -## Current coupling and scope - -The [protocol](../../spec/v1.md#10-conformance) already makes editing optional. Today all three -connectors take a concrete `LiveResourceStore`, and transport produces editor-specific -`StreamChange` results. The managed connector also detects reset through the editor's `onChange` -callback. The [root entry](../../packages/krm-stream/src/index.ts) exports transport and editing -alongside implementation utilities. - -The connector imports of the store are type-only and disappear from JavaScript. The work is to -separate their contracts and state application, expose a read-only store, and simplify the public -browser API while touching these modules. Preserve merge behavior, gateway behavior and v1 wire -framing. [Proposal 0010](0010-gateway-api-cleanup.md) owns separate Go API cleanup. - -## Ownership and dependencies - -```mermaid -flowchart LR - gateway["Go gateway"] --> transport["Browser connector"] - transport -->|"State events"| resources["ResourceStore"] - transport -->|"State events"| editor["LiveResourceStore"] - resources --> viewer["Live list or viewer"] - editor --> form["Live form with drafts and conflicts"] -``` - -| Layer | Owns | +# Proposal 0009: Stream and editor separation + +**Status: partly implemented on the stacked branch through `b5cc779`.** One event-based connector, +host-callback failure handling and the adopted-save snapshot-membership fix are implemented. +Editor API cleanup remains proposed. A dedicated read-only store and separate entry points remain +deferred. Current APIs are documented in the [client README](../../packages/krm-stream/README.md) and +[upgrade guide](../migrating.md); this proposal does not advertise future APIs as available. + +## Direction and ownership + +Start with watch streams and live resource state; editing is an optional layer. Keep one repository, +one npm package, zero browser runtime dependencies and lockstep releases. Follow the +[design rules](../../CONTRIBUTING.md#design-rules) and [release policy](../releasing.md). + +The connector owns decoding, ordering, connection state, cancellation and bounded recovery. State +application owns complete resources, UID membership and snapshot pruning. The editor adds drafts, +edit policy, three-way reconciliation, conflicts and patch capture. The host owns authentication, +authorization policy, credentials, UI, writes and deletion recovery copies. + +The [editor model](../client-state-model.md) explains why a moving server copy and local draft belong +together; [saving](../saving.md) owns the conditional-write contract. Keep those explanations there +rather than duplicating them in this implementation plan. + +The [native-watch request](../field-reports/third-our-identity.md#native-watch-connector) extends this +separation: prefer fetch consuming native Kubernetes frames where the host provides a proxy, with +SSE retained as a compatibility path and the current gateway delivery format. Share lifecycle and +state application without converting native JSON to SSE. Native support is not implemented here. + +## Implemented connector and correctness fixes + +`connectResourceStream(url, consume, options)` is the one current connector. `consume` receives +`ResourceStateEvent` synchronously and independently of a store. It can call the standalone +`applyStreamEvent(store, event)` or another state consumer. The connector handles wire sequence and +errors privately; its handle exposes `state`, `subscribe`, `close` and `closed`. + +- Reset publishes `syncing` before application; `synced` is applied before publishing `live`. +- Sequence gaps discard the event beyond the gap and recover through a fresh snapshot. Gap details + appear on the resulting `retrying` state. Unknown wire events still participate in sequencing. +- HTTP refusals and in-stream errors preserve terminal/retry classifications and retry hints. +- Consumer, state-subscriber or error-handler exceptions stop recovery, finish cleanup and reject + `closed` with the first host exception. They must not become network retry storms. +- Close/abort prevents later events, including remaining events in a decoded chunk. Close during + `synced` prevents a later `live` publication. +- A mismatched optional protocol header is terminal before event application; an absent header is + accepted. No wire negotiation was added. +- Adopted save responses no longer mark snapshot membership. Only authoritative stream upserts + establish membership; a late response cannot preserve a UID absent from a completed snapshot. + +The callback must finish synchronously. An async callback type-checks but cannot establish these +application-before-live and exception guarantees. Callers handle `closed` rejection and dispose all +subscriptions. Native `EventSource` framing/cookie compatibility remains tested separately from the +reference fetch connector's retry policy. + +## Proposed editor cleanup + +Keep reconciliation behavior and the gateway v1 contract. Reduce public input and duplicate exports +as one separately reviewable breaking change: + +| Area | Proposed change | |---|---| -| Gateway | Authorization enforcement, projection, watches, framing and SSE delivery | -| Browser connector | Decoding, sequence checks, connection state, errors and bounded retries | -| Read-only store | Complete projected objects, redactions, UID membership and snapshot pruning | -| Editor store | Authoritative base, drafts, reconciliation, edit policy, conflicts and patch capture | -| Host | Credentials, identity, authorization policy, UI, writes and retention of deleted drafts | - -A view chooses one active store. An editor keeps its own authoritative base; it does not mirror a -`ResourceStore`. Both stores use the same internal snapshot tracker, with a separate instance per -store. Until a read-only view needs `ResourceStore`, the editor is the only store, and a list -renders from a `LiveResourceStore` with a read-only policy. Share small value and dispatch helpers -where useful. Keep framework adapters and a generic merge library outside this work. - -## One public connector - -Make the existing managed fetch connector the only public connector, named `connectResourceStream`. -Move the existing single-connection fetch loop behind it as a private implementation. Remove -`connectManagedResourceStream` and `connectWithEventSource` rather than forwarding their old names. -Fetch already supports both same-origin cookies and bearer headers; a second public transport is -unnecessary for either authentication case. - -The native EventSource helper closes on a sequence gap without arranging a new snapshot connection, -and leaves reconnect timing to the browser instead of enforcing the protocol's retry hints. This is -a limitation of that helper, not a reason to remove native SSE support from the gateway. - -The proposed public signature is: - -```ts -type ResourceEventConsumer = (event: ResourceStateEvent) => void; - -function connectResourceStream( - url: string, - consume: ResourceEventConsumer, - options?: ResourceStreamOptions, -): ResourceStreamHandle; -``` - -`ResourceStreamHandle` keeps `state`, `subscribe`, `close` and `closed`. `ResourceStreamOptions` -keeps `onError`, `signal`, injected fetch, credentials, headers and the existing retry settings. -Remove public `onOpen`, `onSynced`, `onGap`, `onStateChange` and `onChange`. Observe connection -state through `state` plus `subscribe`, and errors through `onError`. Store results or subscriptions -drive rendering. A consumer already receives `synced` when it needs per-snapshot work. - -Keep the existing statuses: `connecting`, `syncing`, `live`, `retrying`, `closed`, `terminal` and -`exhausted`. Defer opening until the handle exists so subscribers can observe the initial -transition. Read `state` for initial presentation; `subscribe` observes subsequent publications. -Preserve a `live` publication after every completed snapshot, including when the previous status was -live. A sequence gap must remain diagnosable: add an optional -`gap: { expected: number; received: number }` to the `retrying` state caused by that gap, clearing -it on the next attempt. Keep other diagnostics in `onError`; do not add another lifecycle callback -or a general retry telemetry API. - -### Delivery and completion - -1. Decode and check the per-connection sequence before delivering state. On a gap, discard the event - beyond the gap, close the connection and request a fresh snapshot through bounded retry. -2. Observe `reset` directly in transport: enter `syncing` and clear the healthy-period timer before - state application. This must not depend on what a store returns. -3. Deliver recognized state events exactly once and synchronously, in stream order. Unknown wire - event types still participate in sequence accounting and are then ignored. Error events go only - to `onError`. Discard an upsert or deletion without its required object or identity; this - preserves current no-op state behavior and is not comprehensive object schema validation. -4. Apply `synced` through the consumer before publishing `live` and starting the healthy-period - timer. Pruning already precedes `live` today; rendering moves from the old `onChange`, which ran - afterwards, into state application before `live`. -5. Preserve retry hints, exhaustion, terminal refusal and in-band `RESYNC_REQUIRED` recovery. A - retryable error does not itself restart an open connection; reconnect when that connection ends. -6. After close or abort, deliver no later events from the same decoded chunk. If the consumer closes - during `synced`, publish no later `live` transition. The existing fetch loop already checks abort - between events; preserve this and test it. - -The consumer must complete state application synchronously. An `async` consumer type-checks, because -its result is ignored, but returns before applying anything and its exceptions never reach `closed`; -the documentation says so rather than adding a runtime check. Both stores expose `applyStreamEvent` -as a bound arrow field, so passing `store.applyStreamEvent` directly is safe and preserves `this`. -Verify direct method passing as well as wrapped consumers that render the returned change. - -An exception from host code is a bug, not a network failure, so it is never retried. The rule covers -the consumer, `subscribe` callbacks and `onError` alike. Review found the first implementation -handled only the consumer: an observer that threw during a connection was swallowed as a network -failure and retried until the budget ran out, so a render bug became a reconnect storm, and one that -threw between connections rejected `closed` without publishing `closed`. Capture the first exception -where it is thrown, even after the callback called `close()`. Finish reader/listener/timer cleanup, -still notify every subscriber of the state being published, publish `closed` unless the stream -already ended `terminal` or `exhausted`, and reject `closed` with that exception. Do not classify it -as a Kubernetes error. Ordinary network failure still follows the existing retry policy. Callers -await `closed` or attach a rejection handler; tests explicitly observe failure, including -close-then-throw. Completion settles only after the callback returns and cleanup finishes. - -### Protocol version diagnostics - -On a successful streaming response, inspect `X-KRM-Stream-Protocol` before consuming events. A -present value other than the supported version is a terminal local `INTERNAL` error with a clear -protocol-mismatch message: cancel the response body, publish `terminal` and do not retry. A missing -header is accepted because [v1 versioning](../../spec/v1.md#0-versioning) makes this diagnostic -header optional. HTTP refusals retain their existing classification. No version negotiation or new -wire error is introduced. Keep the protocol constant internal and its Go/TypeScript consistency -test. Keep public `VERSION`: build provenance has an established use for vendored assets. - -## One public stream input per store - -Export `ResourceStateEvent`, a discriminated union of `reset`, `added`, `modified`, `deleted` and -`synced` state events. Upserts require `object` and may carry wire-shaped `Redaction[]`; deletion -requires `identity`. Reset keeps target, scope and projection metadata. This input does not require -`seq` and carries no transport error fields. The connector retains wire sequencing privately and -passes state events; hosts can supply the same state shapes directly without opening a connection. - -Both stores expose a bound `applyStreamEvent(event)` method. In `LiveResourceStore`, make -`beginSnapshot`, `endSnapshot`, `applyServerEvent` and `removeResource` private. Remove the -standalone `applyStreamEvent(store, event)` export and fold public `ApplyResult` into -`StreamChange`. Remove `ApplyOptions`: direct state input accepts only the wire redaction shape, -while stored/read redactions may still use parsed path segments. Tests must use the public event -input for behavior; private helpers remain available for the editor's guarded reconciliation -implementation. - -### Shared snapshot tracking (deferred) - -Deferred with `ResourceStore`: extract the tracker when a second store needs it. Until then the -editor keeps its private membership state, where the membership rule below already holds: only -stream upserts mark a UID as seen. - -Extract `#seen` and `#snapshotRevision` into one internal `SnapshotTracker` before adding -`ResourceStore`. It owns only the active membership pass, seen UIDs and generation: - -- `begin()` increments generation and starts a fresh seen set, even when replacing a partial pass. -- `markSeen(uid)` records membership only during an active pass. -- `finish(currentUIDs)` returns unseen UIDs and ends the pass before removal and notification. - Without an active pass it returns an empty list. This is the shared implementation of pruning. - -Only `applyStreamEvent` upserts mark membership. Guarded reads follow a separate private update path -and never mark a UID as seen. Reconciliation captures generation and rejects while a pass is active -or after generation changed, including after a completed newer pass. Preserve per-resource revision -and UID checks in the editor. Use both stores to verify interrupted and restarted snapshots; use the -editor to verify late reads before, during and after a recovery cycle. Keep tracker internals -private. - -### Read-only store (deferred) - -Deferred until a read-only view needs it. By this proposal's own account the reference use is the -repository's example, not an external request, and krm-foyer, the one known consumer, edits. The -design stays here so the store can be built as its own change when a list or viewer needs it. - -Add a read-only list to the [vanilla browser example](../../examples/vanilla-browser/README.md), -which currently demonstrates editing. Show resource identity, status and redaction metadata without -edit controls. This is the concrete reference use case for `ResourceStore`; it is not an asserted -external consumer request. Its public surface is: - -```ts -class ResourceStore { - applyStreamEvent: (event: ResourceStateEvent) => ResourceChange; - ids(): string[]; - server(uid: string): KRMObject; - redactions(uid: string): { path: Path; rev: number }[]; - subscribe(callback: () => void): () => void; -} - -interface ResourceChange { - type: ResourceStateEvent["type"]; - uid?: string; - added: boolean; - removed: string[]; -} -``` - -Replace complete projected objects, never deep-merge them. Reset removes nothing, upserts mark seen, -and only synced prunes. Replayed events are idempotent. A new UID under the same name starts a -separate entry. Copy incoming objects/redactions and return detached reads using existing -missing-UID conventions. Notify after object or membership changes; connection state comes from the -handle. Each store belongs to one target and scope. Independent streams must use independent store -instances. - -`LiveResourceStore(readOnlyPolicy)` still retains a draft and runs reconciliation. Remove the named -`readOnlyPolicy` definition/export once `ResourceStore` exists. Preserve all-read-only editor policy -coverage through `regionPolicy([])`; a read-only editor policy and a resource viewer remain -different uses. `ResourceStore` loads no merge engine and exposes no editing API. - -```ts -import { ResourceStore, connectResourceStream } from "@configbutler/krm-stream/stream"; - -const resources = new ResourceStore(); -const stopRendering = resources.subscribe(renderResources); -const connection = connectResourceStream(url, resources.applyStreamEvent); -const stopConnection = connection.subscribe(renderConnection); -void connection.closed.catch(reportApplicationError); - -// On view disposal: -stopRendering(); -stopConnection(); -connection.close(); -``` - -### Editor store and save results - -Keep drafts, policies, conflicts, keyed-list reconciliation, changes, patch capture and guarded -reconciliation. `LiveResourceStore.applyStreamEvent` returns: - -```ts -interface StreamChange extends ResourceChange { - structural: boolean; - flashed: Path[]; - conflicts: Path[]; -} -``` - -Both stores report UIDs actually removed by deleted or synced events. Repetition produces no second -removal. The editor reports `structural: true` on snapshot pruning that removes resources and keeps -its current upsert/highlight/conflict results. Define common change types independently of either -store so the editor does not import `ResourceStore`. - -Remove `adoptSaved`: it is unguarded, so an old save response can still overwrite newer watch state. -It also marked membership during recovery: an old save response adopted after reset survived an -empty synced snapshot, retaining a deleted UID. That half is a bug, fixed separately as a `fix:` -ahead of change 2 with a regression test, so the removal no longer carries a correctness fix. Remove -public optimistic `removeResource` alongside the other direct inputs. - -Successful edits normally return 204 and converge through the watch echo. For an existing UID, a -host returning an object uses `captureReconciliation(uid)` captured before the request. The guard -refuses newer bases, deleted/recreated UIDs and snapshot overlap; it does not insert a new resource. -Creates wait for their added echo. A host tracks successful creates as pending confirmation so they -cannot be submitted twice, correlating an echo with server-assigned identity/receipt when available. -Handle an echo arriving before the create response as well as after it. Pending deletes likewise -wait for stream absence, rather than removing authoritative state optimistically. Snapshot -completion can confirm missed echoes. Host write status and host confirmation state are separate -from store data. Use a small host-state recipe and executed tests; this does not add create/delete -endpoints to the library or turn the replay demo into a CRUD backend. - -Simplify `ReconciliationOptions` to `{ redactedPaths?: string[] }`. Omission preserves current -protections; an explicit list removes absent paths, preserves known revisions and rejects unknown -paths rather than inventing revision counters. Remove the `redacted` response branch from the -conditional editor example and revise its tests. Stream input still carries wire redaction -revisions; a stateless GET does not. Preserve edits made while a save/read was pending. - -Remove `takeTheirs`; `revert` already implements that operation. Remove the unused `status(uid)` -convenience method; read `server(uid).status`. Update the keep-local recipe in [proposal -0006](0006-stream-and-save-implementation-plan.md) to use `revert`. - -```ts -import { connectResourceStream } from "@configbutler/krm-stream/stream"; -import { LiveResourceStore } from "@configbutler/krm-stream/editor"; - -const editor = new LiveResourceStore(); -const connection = connectResourceStream(url, (event) => { - renderEditor(editor.applyStreamEvent(event)); -}); -void connection.closed.catch(reportApplicationError); - -editor.setValue(uid, ["spec", "replicas"], 3); -const intent = editor.captureSave(uid); -// The host validates and applies the captured intent through its save endpoint. -``` - -## Package boundaries and public exports - -The `stream` and `editor` subpaths are deferred with `ResourceStore`: they help an unbundled browser -that loads only the stream layer, and krm-foyer's example vendors the single-file bundle, which -contains both layers anyway. Change 2 still trims the root exports as described at the end of this -section. - -| Import | Exports | -|---|---| -| `@configbutler/krm-stream/stream` | Connector, lifecycle types, resource state/wire types, URL builder, `ResourceStore`, version stamp | -| `@configbutler/krm-stream/editor` | `LiveResourceStore`, change/save/reconciliation types, edit policies and schema helpers | -| `@configbutler/krm-stream` | Combined convenience exports from those implementations | -| `@configbutler/krm-stream/bundle` | Existing single-file combined build | - -Retain stream/editor subpaths. An unbundled browser importing the combined root loads its whole -module graph; bundler tree-shaking does not solve that supported use case. The stream entry loads no -editor store, merge, edit-policy or schema implementation. Editor modules load no connector. Assert -the emitted module graph and declarations rather than claiming a bundle-size improvement. The -combined bundle still contains both layers. Defer a stream-only single-file bundle until needed. - -Remove public exports of `clone`, `deepEqual`, `get`, `has`, `isPrefix`, `parsePointer`, `pathKey`, -`SSEDecoder` and `StreamSequence`; they remain internal helpers and package-test imports as needed. -Retain useful policies, schema helpers and public domain types. Verify repository and available host -imports before implementing removals; unavailable host source is not evidence of no users. Add -`sideEffects: false` only after auditing top-level effects; it can assist bundlers but does not -replace explicit entry boundaries. Keep one npm package, zero runtime dependencies and existing ESM -emission. - -## Implementation order and migration - -Deliver in this order, keeping each intermediate commit buildable. Review split the original change -2, which bundled removals, the tracker, a new store and entry points; only the removals and fixes -remain planned. - -| Change | Deliver | Acceptance evidence | -|---|---|---| -| 1 | One connector, event consumers, consolidated lifecycle observation, host-callback exceptions, version diagnostics, spec §7 and consumer rule 9 wording | Plain callbacks recover on gaps, respect retry policy and expose host exceptions after cleanup without retrying; all connector callers migrated; raw EventSource browser check kept | -| Fix | Adopted save responses never count as snapshot membership, as a `fix:` | A response adopted after a reset does not survive an empty snapshot | -| 2 | Editor removals and fixes: bound `applyStreamEvent` with private snapshot and upsert methods; the standalone helper, `ApplyResult`, `ApplyOptions`, `adoptSaved`, `takeTheirs` and `status()` removed; simplified `ReconciliationOptions`; trimmed root exports; create/delete host guidance | Editor behavior through the public event input; late-read and snapshot regressions; host examples confirm creates and deletes by echo; docs and package metadata match the API | - -### Deferred until a read-only view needs them - -| Item | Trigger | Until then | -|---|---|---| -| `SnapshotTracker` extraction, `ResourceStore`, `readOnlyPolicy` removal and the read-only example | A list or viewer that should not load the merge engine, or a consumer asking for a read-only store | A list renders from `LiveResourceStore` with a read-only policy and carries a merge engine it does not use | -| `stream` and `editor` subpath entry points, the `sideEffects` audit and module-graph assertions | An unbundled consumer that measurably benefits from loading only the stream layer | Hosts import the root or the single-file bundle, which contain both layers | - -Each deferred item keeps the design and acceptance criteria in this document; build it as its own -change when its trigger appears. - -Use `feat!:` and short `BREAKING CHANGE:` notes for public removals. List connector/name/options -changes, store input removals, response-redaction simplification, utility exports and completion -rejections. Remove old names and signatures without overloads or forwarding aliases. Keep the -current lockstep release process; generated versions and changelogs remain Release Please's -responsibility. - -Change 1 migrated tests, the conditional-save, Vue and browser examples, READMEs, guides and -`url.ts` examples to the connector, fixed stale native EventSource descriptions, and added an -[upgrade guide](../migrating.md). Change 2 migrates store callers, package metadata and read-only -policy/type comments, and extends that guide. Lead with streaming and describe editing as optional; -retain useful merge keywords. A save success must not clear edits made while it was in flight. - -Change 1 updated [spec §7](../../spec/v1.md#7-transport--authentication) to say the reference -connector uses fetch for cookies and headers, and [consumer rule 9](../../spec/v1.md#10-conformance) -to require closing the connection on terminal refusal rather than naming a JavaScript class. Keep -gateway native SSE/cookie support and a raw `EventSource` browser check. That check verifies -framing, cookies, fresh snapshots on native reconnect and terminal shutdown through test-owned -handling; it is not another official connector and does not claim browser-native retry timing -satisfies the reference retry policy. These are implementation-neutral wording changes, not a new -wire version. Keep documented v1 event fields and error codes, including `SLOW_CONSUMER`; their -removal is separate protocol scope. - -## Verification and completion - -Extend existing suites with observable outcomes: - -- Plain consumer, including a bound store method: chunked framing, ignored unknown events, gap - discard and recovery, terminal refusal, retry hints/exhaustion, abort and repeated snapshot - cycles. Verify state subscriptions replace lifecycle callbacks and gap context appears on retrying - state. -- Close from synced without a later live publication; close then throw without losing the original - exception; no retries after an exception from the consumer, a subscriber or `onError`; no pending - reader, timer or listener after cleanup. -- Matching, missing and mismatched protocol headers: mismatches apply no events and never retry. -- Both stores, once `ResourceStore` lands: replacement/field removal, detached data, redaction - changes, repeated replay, deletion while disconnected, same-name/new-UID replacement, - partial/restarted snapshots, synced without reset, and matching removal reports. Use - authoritative-state conformance and convergence fixtures for both stores and keep edit/conflict - fixtures specific to the editor. -- Editor: late GETs during and across completed recovery cycles, later typing during saves, refused - replacement UIDs, redaction-path omissions/unknown paths, conflict retention and keyed lists. Keep - the adopted-save regression until `adoptSaved` is removed; show that a guarded late response never - counts as membership and an empty completed snapshot prunes the UID. -- Host examples: no synthetic adoption/deletion, create confirmation in either echo/response order, - pending-confirmation entries cannot be resubmitted, and a failed write leaves authoritative state - unchanged. No reconciliation guard inserts a resource it did not capture. -- Packed exports and declarations: root and combined bundle expose the intended API. Real Go-wire - and Chromium checks cover the editor through the public connector and direct raw EventSource - compatibility. When the deferred entry points land, the emitted graph shows stream imports run - with editor modules absent, and the checks cover both stores. - -For runtime PRs run `task fixtures-check`, `task test`, `task lint`, `task e2e-wire`, -`task e2e-browser` and `task pack-client`, and inspect final-commit CI. Existing goldens must retain -wire behavior. Real-cluster/capacity campaigns are not a browser-separation merge gate. Report the -final tested commit, results, unrun host checks and limitations. - -The planned work is complete when one public connector serves the editor, the editor's public input -is the event stream with no unguarded adoption, a live editor preserves draft and save guarantees, -published artifacts work and migration guidance matches the implementation. A read-only view that -loads no editor modules through a stream entry is the completion criterion of the deferred items. -This document alone is not implementation evidence. - -## Relationship to the remaining roadmap - -[Proposal 0006](0006-stream-and-save-implementation-plan.md) retains its behavioral follow-ups: -editor recovery/keep-local recipes and real-API save races, plus independently deliverable measured -upstream continuation. Align its recipes with this proposal's smaller API. Implement separation -before expanding editor public surface; it does not block gateway fixes or real-API evidence. -[Proposal 0010](0010-gateway-api-cleanup.md) owns Go cleanup and can proceed independently. -[Proposal 0008](0008-shared-watch-hardening.md) remains completed; deferred authorization triggers -and caches are not reopened. Let demonstrated stream and editing needs drive their respective -follow-ups. +| State input | Bound `store.applyStreamEvent(event)`; private snapshot/upsert/removal helpers; remove standalone `applyStreamEvent`, `ApplyOptions` and duplicate result types | +| Save adoption | Remove unguarded `adoptSaved` and public optimistic `removeResource`; existing-resource asynchronous responses use captured reconciliation guards | +| Redaction reads | Simplify `ReconciliationOptions` to `{ redactedPaths?: string[] }`; keep revisions on authoritative stream events | +| Duplicate helpers | Remove `takeTheirs` in favor of `revert`, and `status(uid)` in favor of `server(uid).status` | +| Root exports | Keep domain APIs, policies and schema helpers; make cloning/path/decoder/sequence implementation utilities private | + +Reconciliation captures UID, resource revision and snapshot generation before a request. Refuse a +response after a newer base, during or across snapshot recovery, for a deleted/replaced UID, or with +unknown redaction paths. Preserve typing after capture. Omitted redaction paths keep protections; +explicit paths remove absent protections and preserve known revisions, never inventing counters. + +Successful edits normally converge through the watch or a guarded projected read. Creates/deletes +remain host-owned intents and accepted-but-pending confirmations until an echo or completed snapshot. +Account for either echo/response order and prevent duplicate submissions. A guard does not insert a +new resource. Failed writes leave authoritative state intact; do not fabricate events or UIDs. + +Keep deletion recovery and keep-local recipes on store APIs. `StreamChange` should report actual +removals from delete/snapshot completion, structural changes, highlights and conflicts; repeated +replay must not report a second removal. Retain the adopted-save regression until adoption is removed. + +## Deferred until a read-only view needs them + +A viewer currently uses `LiveResourceStore(readOnlyPolicy)` and carries the editor implementation. +Add `ResourceStore` only for a demonstrated list/viewer need. It would expose event application, +`ids`, detached `server`/`redactions` reads and subscription, without drafts or merge APIs. Complete +upserts replace objects; only a completed snapshot prunes unseen UIDs; replay stays idempotent. + +Extract a shared internal snapshot tracker when the second store needs it. Its generation, seen set +and begin/finish logic must distinguish authoritative stream membership from guarded reads. Each +store owns one source/scope and has its own tracker; an editor does not mirror a second resource store. + +Separate `stream` and `editor` package entry points remain deferred until an unbundled consumer +benefits measurably from avoiding editor modules. Keep the combined root and single-file bundle. +When implemented, assert the emitted graph: stream imports load no editor/merge/schema implementation, +and editor imports load no connector. Audit effects before declaring `sideEffects: false`; no new +stream-only bundle is justified yet. Existing `readOnlyPolicy` stays exported until a dedicated +viewer store replaces its role. + +## Acceptance and migration + +Implement cleanup independently of native transport, gateway fixes and real-API evidence. Inventory +repository and available consumer imports; unavailable source does not establish no users. Remove +superseded APIs before 1.0 without aliases, update all examples and extend [migration](../migrating.md). +Do not change release-generated versions or changelogs by hand. + +Verify observable behavior through public events: complete replacements, redactions, UID changes, +partial/restarted snapshots, deletion during disconnect, later typing, late reads across snapshots, +conflict retention and keyed lists. Verify connection cleanup, callback exceptions, gap recovery, +retry hints/exhaustion and protocol diagnostics. Host recipes must preserve intent, confirmation and +no-duplicate-submission behavior. Packed exports and declarations must match the supported API. + +For runtime changes run `task fixtures-check`, `task test`, `task lint`, `task e2e-wire`, +`task e2e-browser` and `task pack-client`, then inspect final-commit CI. Entry-point graph and dual-store +checks apply only when their deferred changes are implemented. Documentation edits need link and +example/diagram review, not a cluster campaign. + +[Proposal 0006](0006-stream-and-save-implementation-plan.md) owns gateway continuation and save-progress +evaluation. [Proposal 0010](0010-gateway-api-cleanup.md) records implemented Go cleanup. Completed +shared-watch hardening does not reopen authorization triggers or caches without measured demand. diff --git a/docs/proposals/0010-gateway-api-cleanup.md b/docs/proposals/0010-gateway-api-cleanup.md index e2a365e..9bcf3d9 100644 --- a/docs/proposals/0010-gateway-api-cleanup.md +++ b/docs/proposals/0010-gateway-api-cleanup.md @@ -1,187 +1,47 @@ -# Proposal 0010 Gateway API cleanup +# Proposal 0010: Gateway API cleanup -**Status: implemented** in [#54](https://github.com/ConfigButler/krm-stream/pull/54), as the two -changes below, pending review. Source review baseline: `a8281c5`; proposal review branch: `0cde13b`, -2026-10-04. +**Status: implemented on the stacked branch through `b5cc779`.** This is the decision record for +PR #54. Use [the gateway README](../../gateway/README.md) for current setup and +[upgrading from 0.7](../migrating.md#go-gateway) for the full migration table. -Simplify gateway configuration and remove duplicate entry points and repository-only harness -exports. Preserve authorization, projection, bounded HTTP delivery, shared-watch lifecycle and v1 -wire behavior. Deliver independently from [browser -separation](0009-stream-and-editor-separation.md). Follow the [design -rules](../../CONTRIBUTING.md#design-rules) and [release policy](../releasing.md): remove superseded -names before 1.0 and migrate callers together. +## Decision -The source audit establishes repository use, not every consumer's use. Field reports record -krm-foyer adoption but do not establish which exports its current checkout imports. Inventory -repository callers and available host source during implementation; report unavailable checks. -Published APIs can change before 1.0, with migration notes and no forwarding aliases. +Keep one name per API, share stream configuration and remove repository-only runtime exports before +1.0. Preserve authorization, named projections, bounded HTTP delivery, shared-watch lifecycle and +v1 wire behavior. The gateway remains the projected watch layer and current SSE delivery path; +[native browser transport](../field-reports/third-our-identity.md#native-watch-connector) is separate +requested work. SSE compatibility does not require duplicate Go APIs. -## Selected changes - -| Area | Current problem | Result | -|---|---|---| -| Stream methods | Default-view methods forward to projection-aware variants | One HTTP method and one transport-neutral method, each taking a requested projection | -| Shared backend | Default constructor forwards to an options constructor | One constructor with explicit zero-value-capable options | -| Configuration | Options and Gateway repeat fields with manual copying and different authorization names | Shared stream configuration with one name per setting | -| Projection | Projection is ignored when Projections is supplied | One policy field; static selection uses StaticProjection | -| SSE details | Public Comment is used only by a test; heartbeat default docs are ambiguous | Private comment helper and default constant; public configurable interval | -| Repository harness | Production package exports fixtures, loaders and scripted replay | Internal harness used by replay and fixture-driven tests | -| Scope formatting | Scope.Query is used only by a conformance test | Test-owned query construction; public scope parsing retained | - -## Stream methods and shared constructor - -Keep these names with projection-aware signatures: - -```go -func (g *Gateway) ServeStream( - w http.ResponseWriter, r *http.Request, - principal Principal, scope Scope, requested Projection, -) - -func (g *Gateway) Stream( - ctx context.Context, principal Principal, scope Scope, - requested Projection, sink Sink, -) error - -func NewSharedBackend(upstream Backend, options SharedOptions) *SharedBackend -``` - -Empty requested projection asks the configured policy for its default; it never bypasses policy. -Rename existing projection-aware implementations to these names. Remove `ServeStreamProjection`, -`StreamProjection` and `NewSharedBackendWithOptions`. Default callers pass an empty projection or -`SharedOptions{}` respectively. Migrate Handler, replay, tests and kube examples without wrappers. - -## Shared stream configuration - -Extract shared fields into one exported `StreamConfig`. Options and Gateway embed it: - -```go -type Options struct { - StreamConfig - Principal func(*http.Request) (Principal, error) - Scopes ScopePolicy -} - -type Gateway struct { - StreamConfig - // Existing private runtime/test fields stay here. -} -``` - -`StreamConfig` contains `Authorizer`, `Clients`, `Projections`, `Ordering`, `Observer`, -`Diagnostics`, `HeartbeatInterval`, `WriteTimeout`, `ReauthorizationInterval` and -`ReauthorizationTimeout`. Use Authorizer consistently, removing Gateway.Auth. Handler copies the -single config value rather than assigning every shared field individually. Preserve host-callback -validation. - -Go composite literals must explicitly name the embedded configuration: - -```go -gateway.Options{ - Principal: userFromSession, - Scopes: allowedScopes, - StreamConfig: gateway.StreamConfig{ - Authorizer: authorizeScope, - Clients: clientsForUser, - Projections: gateway.StaticProjection(gateway.ProjectionSpec), - WriteTimeout: 5 * time.Second, - ReauthorizationInterval: 30 * time.Second, - ReauthorizationTimeout: 5 * time.Second, - }, -} -``` - -The cost is one level of nesting in a host's literals. Assignments such as `options.Authorizer = …` -keep working through field promotion, and the [upgrade guide](../migrating.md) maps each old literal -to the new one. - -Remove the Projection configuration field. Nil Projections retains the safe full policy through -`StaticProjection(ProjectionFull)`. A host with one view supplies StaticProjection; a host selecting -by principal/scope supplies ProjectionPolicy. Raw Secret disclosure continues to require explicit -host policy. Preserve projection authorization during opening, cycles and timed checks. - -Preserve validation, including negative values and positive HTTP write timeout with timed checks. -The shared struct must not impose HTTP-only restrictions on transport-neutral sinks. Direct HTTP -validation still occurs before response I/O or backend opening. Update comments and error messages -to identify the shared Ordering setting without assuming Handler users configure a Gateway literal. -The strict-ordering refusal is the one message on the wire this touches: its golden transcript -changes in message prose only, which both suites compare loosely. - -## Repository-only exports - -Move [conformance.go](../../gateway/conformance.go) and [scripted.go](../../gateway/scripted.go) -into `gateway/internal/conformance` or similarly narrow internal packages. Move Fixture, WatchOp, -FixtureEvent, Corpus, loaders and ScriptedBackend together as needed. The harness may import gateway -domain types; production gateway must not import it. Use explicit corpus paths from commands/tests. -Remove the production relative-path convenience loader; a test helper may supply the repository -path. - -A `package gateway` test importing a harness that imports gateway creates a cycle. Move -fixture-driven conformance and golden tests to `package gateway_test`, including fixture-dependent -cases currently in `stream_test.go`; adapt their recording/replay helpers. Leave private -implementation tests in `package gateway`. Do not convert the entire suite or export private helpers -to avoid the cycle. Inspect shared helper dependencies before editing. - -Keep replay and tests on the same fixture interpreter. Preserve use of the production SSE encoder in -golden generation, update flags, protocol.json generation, fixture paths and task names. Do not -replace executable wire evidence with manually encoded transcripts. Core gateway must no longer -include repository corpus filesystem loading in its runtime module graph. - -Unexport SSESink.Comment and retain its short-write/failure tests. Rename the heartbeat default to -private `defaultHeartbeatInterval` and document the 20-second default on public config. Move -Scope.Query's only current use into scope conformance test support. Retain ScopeFromQuery and -canonical parser/TypeScript URL checks against the existing corpus. - -## Capabilities retained deliberately - -| Reviewed removal | Decision and reason | +| Area | Implemented result | |---|---| -| SLOW_CONSUMER | Keep Go constant, TypeScript type, schema and spec. Built-in sharing resyncs on overflow, but v1 defines this code for other producers/backends. A teardown test also uses it. No built-in emission does not establish no protocol use. | -| Sink, Stream and SSE encoding APIs | Keep the transport-neutral seam and current framing APIs. They provide distinct capabilities rather than forwarding aliases. Removing them is a separate scope decision. | -| OrderingLenient | Keep explicit support for unorderable aggregated backends. Both strict and lenient behavior have unit tests; the recorded cluster run did not exercise lenient ordering. Removing it changes supported backend behavior. | -| Event.Target on reset | Keep v1 fields and generated output. Redundancy with scope.target is a protocol design question, not justification for a silent removal. | - -For generic sinks, clarify the existing requirement that the host bounds delivery and honors -cancellation. Proposal 0008 repaired library-owned HTTP configuration; it did not promise to bound -arbitrary host I/O. Keep generic bounded-sink tests separate from HTTP blocked-reader/deadline -tests. Deleting the protocol seam is not needed to preserve the HTTP guarantee. - -Protocol removals require a separately reviewed compatibility/version decision. This cleanup removes -duplicate configuration and forwarding paths while preserving the protocol and backend contract. - -## Implementation order and acceptance - -Deliver two changes, each buildable and fully migrated: - -| Change | Deliver | Evidence | -|---|---|---| -| 1 | Shared config, projection-policy field, consolidated stream methods and constructor, corrected comments/errors | Handler/direct HTTP and generic sinks preserve policy, validation, deadlines and observations; examples build | -| 2 | Internal corpus/replay harness, test relocation, private Comment/default, test-owned scope query | Fixture/golden output unchanged; replay/browser checks pass; public gateway no longer owns corpus loading | - -Update READMEs, adoption, auth and operations guides, root examples and kube examples. Historical -proposals and dated field reports remain historical: update current-guidance links or add a short -migration note when useful, rather than rewriting the APIs they originally reviewed. [Proposal -0006](0006-stream-and-save-implementation-plan.md) keeps its acceptance criteria and uses the final -method/configuration names in later work. - -Run `task fixtures-check`, `task test`, `task lint`, `task e2e-wire`, `task e2e-browser` and -`task pack-client`. Run race checks in both Go modules for changed lifecycle paths. No new cluster -campaign or capacity claim is required for these API/harness changes. - -Focused acceptance must establish: - -- Nil projection policy redacts Secrets; static policy refuses a different requested view; dynamic - policy runs at opening, cycles and timed checks. -- Handler/direct HTTP preserve fail-before-I/O validation, unsupported-writer refusal, - blocked-client deadlines and healthy-peer isolation. Generic bounded sinks remain usable. -- Shared-backend zero/explicit options preserve cancellable openings, sharing, overflow recovery, - backoff, last-subscriber cleanup and balanced observations. -- Both config entry points use the same fields/names without per-field mapping. Current code and - guidance no longer reference removed public APIs. -- Fixture bytes, protocol.json and canonical query expectations remain unchanged. External fixture - tests and private tests compile without cycles; replay uses the internal harness. - -Use `feat!:` and `BREAKING CHANGE:` notes listing removals, new signatures, nested config literals, -Authorizer naming and StaticProjection migration. Keep lockstep releases and generated changelogs. -Report final tested commit, outcomes, available host checks and unavailable integrations. Complete -this proposal only after those changes land with evidence, independently of proposal 0009. +| Stream methods | `ServeStream` and transport-neutral `Stream`, each taking the requested projection | +| Shared backend | `NewSharedBackend(upstream, options)` with zero-value-capable options | +| Configuration | `Options` and `Gateway` embed the same `StreamConfig`, using `Authorizer` consistently | +| Projection policy | One `Projections` field; static selection uses `StaticProjection`; nil permits full view only | +| Repository harness | Corpus/fixture/scripted-watch machinery moved into `gateway/internal/conformance` | +| SSE/query details | Private comment helper and heartbeat default; test-owned scope query construction | + +Composite literals explicitly name `StreamConfig`; promoted assignments remain available. Empty +requested projection asks the host policy for its default and never bypasses it. Raw Secret disclosure +still requires explicit permission. HTTP validation fails before I/O or backend opening; arbitrary +transport-neutral sinks remain the host's to bound and cancel. + +## Capabilities retained + +- `Sink`, `Stream` and SSE encoding support distinct host integrations. +- `OrderingLenient` supports known upstreams with unorderable versions, with reduced monotonicity. +- `SLOW_CONSUMER` and reset target fields remain in v1 even when the built-in path does not need them. +- Replay and tests share the fixture interpreter and production encoder; internalizing the harness + does not replace executable wire evidence with manually written transcripts. + +No protocol field or error code was removed. Compatibility changes to the wire require their own +version decision. Browser separation is owned by [proposal 0009](0009-stream-and-editor-separation.md), +and behavioral work by [proposal 0006](0006-stream-and-save-implementation-plan.md). + +## Evidence and limits + +Focused checks cover policy defaults/refusal, validation, writer capabilities/deadlines, healthy-peer +isolation, shared opening/overflow/backoff/cleanup and internal replay/fixture imports. Runtime change +validation includes fixtures, package/lint, real wire, browser, packed client and Go race checks. +These criteria do not by themselves prove final-commit CI, consumer upgrades or cluster capacity. +See the PR for actual run evidence. Release versions and changelogs remain generated. diff --git a/docs/saving.md b/docs/saving.md index e964125..f6e0510 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -1,12 +1,26 @@ # Saving edits safely -krm-stream is a read library. Your application owns its HTTP save endpoint, audit policy and -Kubernetes client. The recommended sequence is: +The optional editor preserves drafts as the [live resource view](why-a-gateway.md) changes and +captures a narrow save intent. Your application owns its HTTP save endpoint, write authorization, +audit policy and Kubernetes client. Read the [editor state model](client-state-model.md) for edit +policy, three-way reconciliation and explicit conflict resolution. The supported save sequence is: 1. the browser captures a save intent; 2. the host validates it and sends a conditional Kubernetes merge PATCH; -3. the host answers 204, and the watch echoes the write back into the store; -4. on 409, the browser reconciles a guarded, projected GET, and the person saves again deliberately. +3. the host answers 204 or a receipt; the watch normally reflects a visible change, with a guarded + projected read when an echo cannot confirm the result; +4. on 409, the browser reconciles a guarded, projected GET, and the person reviews and saves again + deliberately. + +Serialize saves per editor, resolve field conflicts before submission and enable writes while the +connection is live. Capture intent before any await and preserve later typing. A dirty draft, an +accepted write and application progress are separate states; neither a clean form nor a watch echo +proves a rollout or Git workflow completed. A definite 409 rejection differs from a lost response +whose write outcome is unknown; do not blindly replay an ambiguous write. + +This guide describes projected editing through the current gateway. The requested native connector +needs an explicit native read/write and edit-policy contract before sharing these recipes. A raw +response must never be fed into a projected editor as recovery or save confirmation. The [conditional-save example](../examples/conditional-save/README.md) implements all of it: a compilable host endpoint, client reconciliation, race tests and a real-cluster 409 test. @@ -131,6 +145,9 @@ guarantee. Sustained invisible churn can prevent save progress. An accepted projected GET advances the base without a snapshot. When no field conflicts exist, explain the refreshed base and offer a new save. +Bounded recovery of the originally submitted intent and optional coalesced version delivery are +[evaluation work](proposals/0006-stream-and-save-implementation-plan.md#5-save-progress-under-suppressed-churn), +not supported automatic retry policies. The default spec view stays quiet for status-only updates. ## What the person editing sees diff --git a/docs/vue.md b/docs/vue.md deleted file mode 100644 index e73e826..0000000 --- a/docs/vue.md +++ /dev/null @@ -1,35 +0,0 @@ -# Vue integration - -A copyable Vue 3 composable covers the adapter: reactive snapshots for drafts, changes, conflicts, -redactions and connection state, plus automatic subscription cleanup. Vue remains a host dependency; -the core package and its browser bundle have no framework dependencies. - -The [composable source](../examples/vue/useLiveResource.ts) and -[tests](../examples/vue/useLiveResource.test.ts) are typechecked and executed by `task test-vue` and CI. -Copy the source into your host and change its library import to `@configbutler/krm-stream`. - -```ts -const { resource, state } = useLiveResource(store, uid, connection); -``` - -Use `resource.value?.draft` in script and `resource?.draft` in a template. A missing/deleted UID gives -`null`. For deletion recovery, capture the initial draft and subscribe to store notifications, retaining -`store.draft(uid)` while the fixed UID exists and keeping the last copy once it is absent. Capturing -only when Save is clicked loses subsequent edits. The null notification is too late to read the -removed draft. Use the tested -[recovery-copy recipe](../examples/editor-recipes/README.md#recover-work-after-a-deletion) beside -`useLiveResource` for the same UID, dispose it with the editor, and never apply a recovery copy -automatically to a replacement UID. - -For a changing selection, remount a keyed editor or create a new effect scope. Use store -methods such as `setValue`, `removeKey`, `revert` and the -[keep-local recipe](../examples/editor-recipes/README.md#keep-the-local-value-in-a-conflict) for -edits: do not bind `v-model` directly to the draft snapshot. Store reads are detached copies, and -editing them bypasses policy checks and change notifications. - -The connection belongs to its creator. A parent sharing one connection across many editors closes -it when the parent unmounts; an editor that creates its own connection can register -`onScopeDispose(() => connection.close())`. Removing one editor's subscriptions must not close a -connection used by other editors. Capture and submit saves with the -[conditional-save example](../examples/conditional-save/README.md); expose request errors and saving -state from that host-owned controller. Enable Save while `state.status === "live"`. diff --git a/docs/why-a-gateway.md b/docs/why-a-gateway.md index 78defea..ea0500a 100644 --- a/docs/why-a-gateway.md +++ b/docs/why-a-gateway.md @@ -1,33 +1,103 @@ -# Why a gateway +# Watching resources -The gateway embeds Kubernetes reads in a browser application while keeping credentials and policy -on the host. It also gives the browser a snapshot boundary and a stable event vocabulary. +krm-stream starts with Kubernetes watches and delivers a defined live resource view to a browser. +Use it for lists and status pages, then add the [editor](client-state-model.md) when a page needs +local drafts. The host owns authentication, authorization policy, credentials and writes. -## From Kubernetes watch to browser stream +## Choose a scope and a view -A Kubernetes watch is a chunked HTTP response containing newline-delimited JSON. It carries object -updates, bookmarks and history-expiry errors. Native `EventSource` expects SSE framing and cannot set -an authorization header, so it cannot consume that response directly. +Scope selects the target, resource kind, namespace, optional name and allowed label selector. +Projection selects what is delivered. Suppression decides whether a change to that view needs a +new object event. These are separate decisions. -Direct browser access would also require cluster credentials and appropriate API-server CORS -configuration. Raw watch objects include fields such as Secret values and managed fields. The host -must decide which scopes and fields it can disclose before sending them to the browser. +The host maps target names to configured clusters and authorizes the scope and projection. The +browser can request a named view; it cannot supply arbitrary field rules or an API-server URL. +There is no general gateway field selector or built-in status-only projection. -The gateway uses a host-supplied Kubernetes client, applies the selected projection and emits -`reset` … `synced` snapshots followed by live updates. The browser connector, `connectResourceStream`, -handles bounded reconnection; a new connection receives a fresh snapshot. See the -[architecture diagram](../README.md#how-it-fits) and [protocol](../spec/v1.md). +| Projection | Fields delivered | Secret values | Updates suppressed | +|---|---|---|---| +| `krm-full/v1` | Includes status | Withheld | Bookkeeping-only | +| `krm-spec/v1` | Omits status | Withheld | Bookkeeping-only and status-only | +| `krm-raw/v1` | Includes status | Included when host policy permits | Bookkeeping-only | + +Every view strips `metadata.managedFields` and the last-applied-configuration annotation. Raw is a +named projection, not native passthrough. Full/spec redaction covers core Kubernetes Secret values, +not arbitrary sensitive content in ConfigMaps or custom resources. Withheld key paths and change +revisions are disclosed in `redacted`; the object contains no mask to accidentally save back. + +For a host that permits these requests: + +```text +/resource-stream/v1?target=production&version=v1&resource=configmaps&namespace=app&projection=krm-full/v1 +/resource-stream/v1?target=production&group=apps&version=v1&resource=deployments&namespace=app&projection=krm-spec/v1 +/resource-stream/v1?target=production&version=v1&resource=pods&namespace=app&labelSelector=app%3Dhello&projection=krm-full/v1 +``` + +Enable `ScopePolicy.AllowLabelSelector` deliberately before accepting the last request. Use +`resourceStreamURL` to encode queries in application code; see [adoption](adopting.md). + +## What suppression saves + +The gateway compares projected content excluding `metadata.resourceVersion`, plus redaction records, +with what it last delivered for that UID. Unchanged views produce no object event. Secret rotations +still produce updates because their redaction revisions change. Every complete snapshot includes all +members, including unchanged objects. + +This reduces downstream bytes and event processing. Fewer store notifications and renders depend +on how the application consumes events; suppression does not eliminate the gateway's upstream work. +The stream represents current state and may coalesce intermediate changes; use an audit system when +every transition matters. + +A quiet view can hold an older write version even while its displayed fields are correct. That +tradeoff matters when adding editing: see [quiet streams and saving](saving.md#why-a-quiet-stream-can-still-reject-a-save). + +## Snapshot and recovery + +Each connection begins with `reset`, zero or more resource upserts, then `synced`. Live upserts replace +complete projected objects. `reset` retains old state while the snapshot arrives; only `synced` prunes +members that were not resent. An interrupted snapshot cannot prove absence. UID distinguishes a +recreated resource from an earlier object with the same name. + +The fetch connector exposes `connecting`, `syncing`, `live`, `retrying`, `closed`, `terminal` and +`exhausted`. Apply state events synchronously before rendering completion. Retryable failures recover +within the connector's bounded budget; 401/403 and terminal stream errors stop the connection. +Dispose subscriptions and close the handle when their owner leaves. See the +[client lifecycle reference](../packages/krm-stream/README.md#connections). + +A new browser connection receives a fresh snapshot. SSE sequence numbers and object versions are not +browser resume tokens. Proposed gateway upstream continuation can reduce resnapshots without changing +that browser contract; see [proposal 0006](proposals/0006-stream-and-save-implementation-plan.md#4-measured-upstream-continuation). ## Optional watch sharing -Without sharing, each stream uses its own backend watch. [`SharedBackend`](../gateway/shared.go) can -instead keep one upstream watch per scope and serve each subscriber from its cache. A joining -subscriber still receives a complete projected snapshot, so sharing saves upstream work without -eliminating browser transfer or reconciliation costs. - -A shared watch uses one service identity, so the host's `Authorizer` becomes the only access check -between each subscriber and cached objects; a permissive check can disclose the service identity's -data. Sharing is opt-in for this reason. Pair it with -[`kube.SubjectAccessReviewAuthorizer`](../gateway/kube/authz.go) to check each subscriber's -Kubernetes permissions, and configure timed checks when quiet-stream revocation must be bounded. See -[authorization](auth.md) and [operations](operations.md). +Without sharing, each stream uses its own backend watch. `SharedBackend` can keep one upstream watch +per matching scope in that backend and serve subscribers from its cache. Each subscriber still needs +its own snapshot, delivery and browser reconciliation. Different scopes or host processes do not +become one watch automatically. + +A shared watch uses one service identity. The host authorizes each subscriber before serving cached +objects, checks its disclosure policy, and configures timed checks when quiet-stream revocation must +be bounded. Pair sharing with `kube.SubjectAccessReviewAuthorizer` for Kubernetes permission checks. +See [shared-watch authorization](auth.md#shared-watch-authorization) and [operations](operations.md). +Sharing reduces duplicate resource consumption; it does not supply a rate limiter or admission policy. + +## Native watch direction and SSE compatibility + +The preferred direction is a fetch connector that consumes native Kubernetes watch JSON through a +host proxy and reuses the frontend lifecycle and state-event input. An application with native API +access should not need to wrap those frames in SSE to use the resource store. Native transport keeps +upstream fields and does not provide the gateway's projection, redaction, suppression or sharing. + +Keep the Go gateway and SSE as a supported compatibility path for consumers choosing that protocol, +and as the existing delivery path for projected views. SSE is framing; fetch and `EventSource` are +client mechanisms. Both native JSON and SSE can carry errors. The current fetch-based SSE connector +already handles HTTP refusal, terminal in-stream errors, cancellation and bounded retries. Native +`EventSource` has different header and retry limitations and cannot parse Kubernetes watch JSON. + +A same-origin, cookie-authenticated host proxy can provide native access while retaining cluster +credentials server-side. The [native connector request](field-reports/third-our-identity.md#native-watch-connector) +is not implemented yet. Until its recovery and editing contracts pass acceptance, use the gateway +connector described in [adoption](adopting.md). Applications must choose a source explicitly and never fall back to native access +after a projected stream is refused. + +Use [adoption](adopting.md) for today's supported wiring and [saving](saving.md) when adding writes. diff --git a/examples/README.md b/examples/README.md index 8d62f9b..985d2e7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,7 +1,10 @@ # Examples -The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against the replay gateway. -For host integration patterns, use these small recipes: +Start with [watching resources](../docs/why-a-gateway.md) and supported host wiring, then add the +optional editor. The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against +the current SSE replay gateway. Native fetch/watch support and the comparative view/sharing demo +are [requested work](../docs/field-reports/third-our-identity.md#show-the-value-of-each-path). +For current host integration patterns, use these small recipes: - [Same-origin cookie application](../docs/adopting.md#1-mount-the-stream-endpoint): fetch connector, session cookie, dynamic client acting as the user. diff --git a/examples/conditional-save/README.md b/examples/conditional-save/README.md index d79f5b6..da6731e 100644 --- a/examples/conditional-save/README.md +++ b/examples/conditional-save/README.md @@ -1,7 +1,10 @@ # Conditional save with a live draft -This example composes the existing store, connector and host-owned writes. It adds no merge -algorithm or shared watch implementation. +Add this controller when a live resource view needs editing. It composes the existing store, +fetch-based gateway connector and host-owned writes under the [editor model](../../docs/client-state-model.md) +and [saving contract](../../docs/saving.md). Draft dirtiness, write acceptance and application progress +remain separate; typing after Save stays in the draft. The example uses the current projected/SSE +path and performs no automatic write retry. - [editor.ts](editor.ts) captures a save intent synchronously, handles HTTP 409 by reconciling a projected GET, retains in-flight edits and rejects late responses superseded by the watch. @@ -48,7 +51,8 @@ resourceVersion into the Kubernetes patch. A stale version produces a safe 409, bookkeeping-only changes were suppressed by the full projection. Spec projection also suppresses status-only updates. This is safe rejection, but sustained churn can hinder save progress. An accepted GET advances the base without requiring a snapshot. Reconcile first; never transplant an old -patch onto the latest version. No automatic write retry is performed. +patch onto the latest version. [Save-progress evaluation](../../docs/proposals/0006-stream-and-save-implementation-plan.md#5-save-progress-under-suppressed-churn) +compares possible improvements without changing this supported baseline. This endpoint returns 204 for successful writes. A host may instead return a receipt-only HTTP 200 under the [saving guide’s receipt contract](../../docs/saving.md#answer-204-or-a-receipt-and-let-the-watch-echo-it); diff --git a/examples/vanilla-browser/README.md b/examples/vanilla-browser/README.md index bb52f45..8eda175 100644 --- a/examples/vanilla-browser/README.md +++ b/examples/vanilla-browser/README.md @@ -26,3 +26,9 @@ Secret values. Useful fixtures include: | `named-object-absent` | An empty snapshot is a valid resource state. | Add `pace=0ms` for the fastest replay or use a positive value to inspect each event. + +The example uses today's projected SSE connector over fetch. For a read-only page, render server +state with `LiveResourceStore(readOnlyPolicy)` as shown in the [README](../../README.md#watch-a-resource-view-today). +Editing is an optional layer; the [editor model](../../docs/client-state-model.md) and +[saving guide](../../docs/saving.md) describe its intended use. A native watch/view comparison is +requested work, not a feature of this replay demo. diff --git a/examples/vue/README.md b/examples/vue/README.md index 5fcaddc..bd721db 100644 --- a/examples/vue/README.md +++ b/examples/vue/README.md @@ -1,9 +1,37 @@ # Vue adapter example -Copy `useLiveResource.ts` into a Vue 3 host and change its library import to -`@configbutler/krm-stream`. Call it in `setup()` or an active effect scope. See -[Vue integration](../../docs/vue.md) for ownership and editing guidance. +A copyable Vue 3 composable covers the adapter: reactive snapshots for drafts, changes, conflicts, +redactions and connection state, plus automatic subscription cleanup. Vue remains a host dependency; +the core package and its browser bundle have no framework dependencies. -Run `task test-vue` from the repository root. This typechecks the source and tests its reactive -resource/connection state and subscription disposal. CI runs the same task. Vue is a dependency of -this private example only; the core package and bundle remain framework-independent. +The [composable source](useLiveResource.ts) and +[tests](useLiveResource.test.ts) are typechecked and executed by `task test-vue` and CI. +Copy the source into your host and change its library import to `@configbutler/krm-stream`. + +```ts +const { resource, state } = useLiveResource(store, uid, connection); +``` + +Use `resource.value?.draft` in script and `resource?.draft` in a template. A missing/deleted UID gives +`null`. For deletion recovery, capture the initial draft and subscribe to store notifications, retaining +`store.draft(uid)` while the fixed UID exists and keeping the last copy once it is absent. Capturing +only when Save is clicked loses subsequent edits. The null notification is too late to read the +removed draft. Use the tested +[recovery-copy recipe](../editor-recipes/README.md#recover-work-after-a-deletion) beside +`useLiveResource` for the same UID, dispose it with the editor, and never apply a recovery copy +automatically to a replacement UID. + +For a changing selection, remount a keyed editor or create a new effect scope. Use store +methods such as `setValue`, `removeKey`, `revert` and the +[keep-local recipe](../editor-recipes/README.md#keep-the-local-value-in-a-conflict) for +edits: do not bind `v-model` directly to the draft snapshot. Store reads are detached copies, and +editing them bypasses policy checks and change notifications. + +The connection belongs to its creator. A parent sharing one connection across many editors closes +it when the parent unmounts; an editor that creates its own connection can register +`onScopeDispose(() => connection.close())`. Removing one editor's subscriptions must not close a +connection used by other editors. Capture and submit saves with the +[conditional-save example](../conditional-save/README.md); expose request errors and saving +state from that host-owned controller. Enable Save while `state.status === "live"`. + +Vue is a dependency of this private example only; the core package and bundle remain framework-independent. diff --git a/gateway/README.md b/gateway/README.md index 6a62530..62efa71 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -1,9 +1,13 @@ # Go gateway -The `gateway` module converts a Kubernetes-style `Backend` watch into the KRM resource-stream -protocol. It is transport-neutral until `Handler` or `ServeStream` adds SSE framing. It has no -Kubernetes client dependency; [`gateway/kube`](kube/) provides the `client-go` backend and the -SubjectAccessReview authorizer. +The `gateway` module adds authorized scopes, named views, redaction, suppression and optional sharing +to Kubernetes-style `Backend` watches. It delivers live state for viewers and optional browser editors. +It is transport-neutral until `Handler` or `ServeStream` adds the current SSE framing. Core has no +Kubernetes client dependency; [`gateway/kube`](kube/) provides the client-go backend and authorizer. + +A fetch-based native browser connector is [requested work](../docs/field-reports/third-our-identity.md#native-watch-connector). +The direction retains this gateway and SSE compatibility; native access alone does not provide these +projected-view guarantees. See [watching resources](../docs/why-a-gateway.md) for the overall contract. ```go handler := gateway.Handler(gateway.Options{ @@ -51,10 +55,15 @@ upstream whose versions cannot be ordered. The normative details are in [`spec/v | Projection | Purpose | |---|---| -| `krm-raw/v1` | Full upstream object for a host that has already made its own disclosure decision. | +| `krm-raw/v1` | Includes Secret values with host permission, but still strips managed fields and last-applied configuration; not native passthrough. | | `krm-full/v1` | Removes metadata noise and redacts Secret values. | | `krm-spec/v1` | The full view without status-driven browser churn. | +All views strip managed fields and last-applied configuration. Full/spec withhold core Secret values; +key paths and change revisions remain visible. Sensitive fields in other resource kinds are not +automatically redacted. Suppression compares projected content excluding RV plus redaction records; +a hidden Secret rotation still emits an update. It reduces downstream work, not incoming watch changes. + The gateway never writes. `ValidateMergePatch` checks a browser's patch against the projection before the host sends it; see [saving](../docs/saving.md). @@ -70,7 +79,8 @@ resnapshotted after `SharedOptions.QueueDepth` live events rather than buffered | Guide | For | |---|---| -| [Adopting](../docs/adopting.md) | The recommended host and browser wiring, start to finish. | +| [Watching](../docs/why-a-gateway.md) | Scope, views, suppression, sharing and transport direction. | +| [Adopting](../docs/adopting.md) | Supported host and browser wiring, start to finish. | | [Authorization](../docs/auth.md) | Backend choice, session validity, timed checks and the revocation budget. | | [Saving](../docs/saving.md) | The host-owned conditional write path. | | [Operations](../docs/operations.md) | Runtime controls, observations, alerts and bounded HTTP delivery. | diff --git a/packages/krm-stream/README.md b/packages/krm-stream/README.md index 47ae970..9da9140 100644 --- a/packages/krm-stream/README.md +++ b/packages/krm-stream/README.md @@ -9,17 +9,22 @@ stream in a browser or JavaScript application. It provides: `applyStreamEvent` to apply a state event to it. - `resourceStreamURL` for the v1 scope query format. -The package is headless and does not choose a UI framework. It works with the Go gateway in this -repository or any conforming producer. +Start with a watch-backed list or viewer; add draft reconciliation only when the page needs editing. +The package is headless and does not choose a UI framework. Today it consumes gateway SSE through +fetch, from the Go gateway or another v1 producer. A fetch-based native Kubernetes watch connector +is the [preferred direction and requested work](../../docs/field-reports/third-our-identity.md#native-watch-connector), +with gateway SSE retained as a compatibility path. It is not an available connector yet. ```ts -import { LiveResourceStore, applyStreamEvent, connectResourceStream, resourceStreamURL } from "@configbutler/krm-stream"; +import { LiveResourceStore, readOnlyPolicy, applyStreamEvent, connectResourceStream, resourceStreamURL } from "@configbutler/krm-stream"; -const store = new LiveResourceStore(); -connectResourceStream( +const store = new LiveResourceStore(readOnlyPolicy); +const connection = connectResourceStream( resourceStreamURL("/resource-stream/v1", { version: "v1", resource: "configmaps", namespace: "app" }), (event) => applyStreamEvent(store, event), ); +connection.closed.catch(reportApplicationError); +// Close connection and dispose rendering subscriptions on view teardown. ``` ## Vendoring it without a bundler @@ -51,7 +56,7 @@ before 1.0. Renamed APIs are removed rather than retained as compatibility alias [upgrading from 0.7](../../docs/migrating.md). See the repository [README](../../README.md), [client state model](../../docs/client-state-model.md), and [release guide](../../docs/releasing.md). -## Connections and conditional saves +## Connections ```ts const connection = connectResourceStream(url, event => renderChange(applyStreamEvent(store, event)), { @@ -76,7 +81,8 @@ it type-checks, but `live` could then be published before `synced` is applied, a would never reach `closed`. Errors go to `onError`, never to the callback; unknown event types are ignored. It requests a fresh snapshot on sequence gaps, network failures, HTTP 408/429/5xx, EOF, and a connection the gateway -closes after a retryable error such as `UPSTREAM_UNAVAILABLE`. Existing drafts survive recovery. +closes after a retryable error such as `UPSTREAM_UNAVAILABLE`. Drafts for surviving UIDs survive +recovery; deletion and completed snapshot pruning remove them. Read `connection.state` for the initial presentation and `subscribe` for every later state. States are `connecting`, `syncing`, `live`, `retrying`, `closed`, `terminal`, and `exhausted`. A reset makes the @@ -106,9 +112,19 @@ The server's retry hint sets the least the next reconnect waits, within `maxRetr a refusal's body is a Kubernetes `Status`, as a host proxying `/k8s` might send, `onError` receives its `message` instead of `stream: HTTP 403`. -There is no native `EventSource` connector: `EventSource` cannot send headers, leaves reconnect timing -to the browser, and cannot request a fresh snapshot after a sequence gap. Gateways still serve native -`EventSource` clients with session cookies (spec §7). +There is no official `EventSource` helper. It cannot send explicit authorization headers and leaves +reconnect timing to the browser; a host-owned helper must handle protocol sequence gaps and terminal +errors itself. Gateway framing still supports browser `EventSource` with session cookies (spec §7). + +## Optional editing + +Use `new LiveResourceStore()` for an editor, or provide a policy that narrows the form's editable +regions. The store keeps delivered state and the draft separate: untouched fields follow incoming +updates, local changes stay, and differing concurrent edits become explicit conflicts. Use store +methods rather than mutating detached reads. See the [editor state model](../../docs/client-state-model.md). + +A dirty draft, an accepted write and observed workload progress are separate states. A save captures +only the submitted intent; typing after Save must survive the result. The host performs all writes. A quiet stream can still hold an older write version. See the [visual explanation](../../docs/saving.md#why-a-quiet-stream-can-still-reject-a-save) and @@ -119,5 +135,5 @@ A quiet stream can still hold an older write version. See the See the [complete conditional-save example](../../examples/conditional-save/README.md) and [save guide](../../docs/saving.md) for host preconditions, real Kubernetes 409s, and draft preservation. -For Vue 3, use the [copyable composable](../../docs/vue.md) for reactive resource and connection state +For Vue 3, use the [copyable composable](../../examples/vue/README.md) for reactive resource and connection state with automatic subscription cleanup. Vue stays in the host application. diff --git a/spec/v1.md b/spec/v1.md index 690fe7f..28f8776 100644 --- a/spec/v1.md +++ b/spec/v1.md @@ -1,15 +1,12 @@ # KRM resource stream protocol — the wire contract -> **Status:** specification, **v1 candidate**. The thin, language-neutral contract shared by two -> independently useful components: the **[resource-stream-gateway](../gateway/README.md)** (Go — -> *produces* this stream from a Kubernetes watch) and the **[live KRM reconcile engine](../docs/client-state-model.md)** -> (JS — *consumes* it to merge edits and render live status). Specified once, here, so the two sides -> cannot drift. +> **Status:** specification, **v1 candidate**. This is the current gateway SSE contract shared by +> [the Go gateway](../gateway/README.md) and [the browser client](../packages/krm-stream/README.md). +> It defines live projected resource state; draft editing is an optional consumer behavior. > -> **It streams KRM, and says so.** The payload is a Kubernetes object — `apiVersion`, `kind`, -> `metadata`, and whatever else that kind really has — not an abstracted "document". That fidelity is -> the point: the whole value of this family is being a faithful, live window onto the Kubernetes -> Resource Model. **Live `status` in a browser, with real RBAC and real attribution, is the product.** +> The preferred native-fetch direction is [requested work](../docs/field-reports/third-our-identity.md#native-watch-connector). +> This specification remains the SSE compatibility contract. Native Kubernetes JSON is not v1 SSE, +> and this documentation revision changes no wire behavior or conformance requirement. A resource stream is a one-way feed, over Server-Sent Events, of the current-and-then-live state of a **scope** (one named object, or a collection of a kind in a namespace). The server absorbs every From c6c225df78a4c22f093574998b5248d0730106e7 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 11:10:05 +0000 Subject: [PATCH 3/3] docs: frame native and gateway as sources and scope the native viewer slice Describe the native host-proxy source and the gateway's projected views as a choice of guarantees rather than a framing preference, note that the SSE connector's HTTP 4xx rule does not apply to native 410 history expiry, and narrow the next native work to a read-only LIST/WATCH viewer that re-lists on reconnect and reuses the existing lifecycle and read-only store. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 5 +- README.md | 27 +-- docs/adopting.md | 11 +- docs/alternatives.md | 2 +- docs/auth.md | 6 +- docs/client-state-model.md | 11 +- docs/field-reports/third-our-identity.md | 211 ++++++++---------- docs/migrating.md | 3 +- ...006-stream-and-save-implementation-plan.md | 82 ++++--- .../0009-stream-and-editor-separation.md | 10 +- docs/proposals/0010-gateway-api-cleanup.md | 5 +- docs/saving.md | 8 +- docs/why-a-gateway.md | 47 ++-- examples/README.md | 5 +- examples/vanilla-browser/README.md | 5 +- gateway/README.md | 5 +- packages/krm-stream/README.md | 9 +- spec/v1.md | 7 +- 18 files changed, 226 insertions(+), 233 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d31625..f2e373b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,9 @@ Run `task fixtures-check`, `task test`, and `task lint` before opening a pull re Start with watch streams and authoritative resource views; add editing as an optional layer. The [README](README.md) explains current capabilities and the native-fetch direction. Current API -examples must match this branch; native transport, editor cleanup and save-progress evaluation -remain proposals until implemented. +examples must match this branch. The next native slice is read-only LIST/WATCH using the existing +lifecycle and state-input helper; editor cleanup, native editing and larger comparisons are separate. +Native transport and save-progress evaluation remain proposals until implemented. - Keep the core gateway free of `client-go`; Kubernetes integration belongs in `gateway/kube`. - Keep the browser client framework-free and free of runtime dependencies. diff --git a/README.md b/README.md index f7733da..23108f3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Efficient live Kubernetes views for browser applications, with optional editing. `krm-stream` helps applications watch Kubernetes resources while controllers and other users keep changing them. Its gateway delivers a defined resource view, withholds selected values, suppresses -irrelevant updates and optionally shares upstream watches. Its headless TypeScript client maintains +irrelevant updates and optionally shares upstream watches. Its headless TypeScript client adds connection lifecycle, recovery and live state and reconciles incoming changes with local drafts when a page needs editing. Your application supplies authentication, authorization policy, Kubernetes credentials, UI and writes. @@ -43,23 +43,24 @@ Choose a view explicitly: All three remove `metadata.managedFields` and the last-applied-configuration annotation. `krm-raw/v1` is still a projection. A hidden Secret rotation produces a redaction update in full/spec. -Each browser connection starts with a complete snapshot, then follows visible changes. On reconnect, +Each current gateway connection starts with a complete snapshot, then follows visible changes. On reconnect, a fresh snapshot repairs missed changes and deletes; resources are pruned only when it completes. This is a live state feed: intermediate updates may be coalesced. See [watching resources](docs/why-a-gateway.md). -## Transport direction and current support +## Choose a source -The preferred direction is to consume native Kubernetes watches through a host proxy using fetch, -with one frontend lifecycle for state, errors, cancellation and bounded recovery. Hosts that already -provide native API access should not need to translate it into SSE to reuse the client. -Keep gateway SSE as a compatibility path for applications choosing that protocol, and retain the -projected gateway for redaction, suppression and watch sharing. +Start with the resource state and guarantees the page needs; framing is an implementation detail. -**Today, the supported connector consumes gateway SSE through fetch.** The native watch connector -is [requested work](docs/field-reports/third-our-identity.md#native-watch-connector), not an available API. -SSE framing can carry errors; the current fetch connector handles HTTP refusals and in-stream errors, -including authentication expiry. The native direction simplifies framing and integration rather than -introducing error handling for the first time. No transport fallback may bypass a refused view. +| Source | Use it for | Current support | +|---|---|---| +| Native through a host proxy | Original Kubernetes resources without adopting the gateway; credentials stay on the host | A focused read-only fetch connector is [next work](docs/field-reports/third-our-identity.md#native-watch-connector) | +| Gateway | Named views, Secret-value redaction, suppression and optional upstream sharing | Supported today through the fetch/SSE connector | + +Native is the straightforward starting point for hosts that already proxy Kubernetes. Gateway SSE +is the delivery format for projected views; its added capabilities remain useful. Both sources should +reuse connection state, errors, cancellation, bounded recovery and state application. +The first native slice lists then watches and re-lists on reconnect. Native editing, resume and a +larger comparison example follow separately. No source fallback may bypass a refused view. ## Watch a resource view today diff --git a/docs/adopting.md b/docs/adopting.md index e6fc17d..bdfe4fd 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -5,10 +5,13 @@ currently supported gateway stream into an existing Go application. The library and browser reconciliation; your application owns identity, authorization policy, credentials and writes. Choose scope, projection and sharing separately using [watching resources](why-a-gateway.md). -The preferred transport direction is a native Kubernetes watch through a host proxy and fetch, with -SSE retained for compatibility and existing projected streams. The native connector is -[requested work](field-reports/third-our-identity.md#native-watch-connector); use the supported wiring -below until its lifecycle, recovery and editing contracts are implemented. +Choose the source for the guarantees the page needs: native access retains original resources through +an existing host proxy; the gateway adds projected views, redaction, suppression and optional sharing, +delivered over SSE. Both should reuse a fetch-based frontend lifecycle. + +The [first native slice](field-reports/third-our-identity.md#slice-1-a-native-viewer) is a small read-only +LIST/WATCH connector with re-list recovery and a viewer. Native editing is separate and does not block +that slice. Today the supported connector uses the gateway wiring below. ## 1. Mount the stream endpoint diff --git a/docs/alternatives.md b/docs/alternatives.md index a3c74ce..21885db 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -6,7 +6,7 @@ and optional draft reconciliation. Choose tools according to the part your appli | Need | Relevant approach | Where krm-stream fits | |---|---|---| -| Native watch through an existing browser proxy | Host proxy plus a native fetch/watch consumer | A shared frontend lifecycle is the preferred direction; the native connector is requested, while gateway SSE is supported today. | +| Native watch through an existing browser proxy | Host proxy plus a native fetch/watch consumer | A read-only native connector with shared lifecycle is next work; gateway SSE delivers the supported projected source today. | | Kubernetes API access from a server | Kubernetes client libraries and raw watches | The Go adapter uses `client-go`; the gateway adds projections and browser snapshot framing. | | A complete Kubernetes UI | Dashboard applications and their plugin APIs | krm-stream supplies state and transport; the host builds the UI. | | Review and deliver configuration packages | KRM package and GitOps systems | The host can record accepted writes in its delivery workflow. | diff --git a/docs/auth.md b/docs/auth.md index 8a5e942..6ead23e 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -13,8 +13,10 @@ is in [adopting](adopting.md). Watch sharing is an efficiency choice; it never g the service identity's access. Full/spec Secret redaction also does not replace scope authorization. These settings describe the current gateway. The requested native fetch connector uses a host proxy -and the same session boundary, but needs its own recovery contract. SSE remains a compatibility path; -changing framing must not bypass refusal or extend session lifetime. +and the same session boundary. Native retains original authorized resources; gateway SSE delivers +projected views. Neither source may bypass refusal or extend session lifetime. The first native +slice is read-only and must stop on terminal authentication failure, with a new connection after +access is restored; native editing is separate. ## Browser sessions diff --git a/docs/client-state-model.md b/docs/client-state-model.md index ea434b9..6a7ed86 100644 --- a/docs/client-state-model.md +++ b/docs/client-state-model.md @@ -20,14 +20,19 @@ The connector consumes and delivers state events independently of the editor. Do not mutate a `draft()` return value directly: reads are detached copies, and that bypasses edit policy and notifications. Independent sources/scopes need separate stores. Switching identity or view must not silently reuse drafts, redactions or snapshot state. Native watch integration is -[requested](field-reports/third-our-identity.md#native-watch-connector); the current guidance assumes -complete projected objects from the gateway. +[requested](field-reports/third-our-identity.md#native-watch-connector). The first native slice uses the +store read-only; this guide's editing and save examples currently use gateway views. Native editing +requires a source-bound policy and host read/write contract in a later slice. + +Keep the existing standalone `applyStreamEvent(store, event)` for the native viewer. A bound store +method is proposed editor cleanup in proposal 0009, not a current API or a prerequisite for native +transport. No new store is needed. ## State per resource | Value | Meaning | |---|---| -| `server(id)` | The latest delivered complete projected object. Every upsert replaces it. | +| `server(id)` | The latest complete object delivered by the chosen source. Current gateway objects are projected; native objects retain upstream fields. Every upsert replaces it. | | `draft(id)` | The object rendered and edited by the UI. Editable regions are reconciled with server changes. | | `conflicts(id)` | Server values that changed concurrently with a different local edit. | | `redactions(id)` | Paths known to exist upstream but intentionally withheld by the selected projection. | diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md index b855b70..0dd9cb9 100644 --- a/docs/field-reports/third-our-identity.md +++ b/docs/field-reports/third-our-identity.md @@ -1,137 +1,106 @@ # Watch streams, optional editing and native access -**Current request, 2026-10-05.** Baseline: the stacked branch through PR #59 at `b5cc779`. -This document records requested work. The [README](../../README.md), [watching guide](../why-a-gateway.md), -[editor model](../client-state-model.md) and [saving guide](../saving.md) describe current use. -[Proposal 0006](../proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order) -consolidates completed work, delivery order, dependencies and deferred items. +**Current request, 2026-10-05. Slice 1 is not implemented.** The implementation starting point includes +connector separation, Go API cleanup and real-API save tests. Use [proposal 0006](../proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order) +for completed work, delivery order and independent tracks. ## Identity and ownership -krm-stream provides efficient live Kubernetes views for browser applications, with optional editing. -Start with the watch: select an authorized scope, choose a view, maintain live resource state and -recover safely. Add the editor when the page needs to preserve local work as the resource changes. +krm-stream provides live Kubernetes resource state for browser applications, with optional editing. +Its client adds connection lifecycle, recovery and a resource store. Its gateway adds named views, +redaction, suppression and optional upstream watch sharing. Editing adds drafts, reconciliation, +conflict review and captured save intent; the host owns every write. -| Layer | Owns | -|---|---| -| Gateway | Scope and projection enforcement, redaction, suppression, optional upstream sharing and current SSE delivery | -| Connector | Fetch/body decoding, connection state, errors, cancellation and bounded recovery | -| Resource/editor store | Authoritative delivered objects, UID membership, snapshot pruning and optional draft reconciliation | -| Host application | Credentials, login/session lifecycle, authorization policy, UI, writes and domain progress | +| Source | Delivered content | Added value | +|---|---|---| +| Native through a host proxy — requested | Original Kubernetes resources the host authorizes, including Secret values and machinery fields | Reuse connection lifecycle and live state without adopting the gateway | +| Gateway — supported today | Named projected views delivered over SSE | Selected disclosure, fewer downstream events and optional shared upstream watches | -Projection controls delivered fields; suppression reduces downstream events; sharing reduces duplicate -upstream watches. None replaces subscriber authorization or host resource limits. Full/spec views -withhold core Secret values but disclose their paths and change revisions. They do not automatically -redact every sensitive field in other resource kinds. See [watching resources](../why-a-gateway.md). +Native is the straightforward entry point for a host that already proxies Kubernetes. Gateway SSE +is the delivery format of the projected source, not a legacy label for its capabilities. Both sources +use fetch and should share frontend lifecycle and state application. SSE and native JSON can both +carry errors. The existing SSE connector already handles HTTP refusals and in-stream errors. + +Scope, projection, suppression and sharing have separate costs and guarantees; see +[watching resources](../why-a-gateway.md). Full/spec redaction withholds core Secret values while +revealing paths and change revisions. It does not classify arbitrary sensitive fields in other kinds. +The host owns credentials, session validity, scope authorization, UI and resource limits. ## Native watch connector -**Adoption reason:** the host already exposes native Kubernetes reads and watches through a -session-authenticated proxy. A browser should consume those watches with fetch and reuse the resource -lifecycle and editor without requiring an additional SSE wrapper. Native watch JSON is the preferred -transport direction. Retain gateway SSE as a compatibility path for consumers seeking that protocol, -and retain the projected gateway for redaction, suppression and watch sharing. - -SSE itself can carry errors. The current fetch-based SSE connector already handles HTTP refusals, -in-stream errors, cancellation and bounded recovery. Browser `EventSource` has different header and -retry limitations. The native request simplifies integration and framing; it does not claim that -SSE cannot report an expired token. Compare error handling on both paths, including token/session -expiry, and stop a refused connection until the host restores access. - -krm-foyer is the motivating host: its `/k8s` native proxy and `/stream/v1` gateway routes let a page -choose either source under a session. Host integration and dependency upgrades remain separate work. -Keep the Go gateway in this repository and preserve its existing v1 SSE contract. - -### Contract before implementation - -Provide a browser connector for a native Kubernetes collection URL through a host proxy. Use fetch, -including caller-supplied fetch and same-origin cookies, without Node polyfills or browser Kubernetes -credentials. Reuse the existing frontend lifecycle and state-event input. API names are not settled; -one lifecycle does not require pretending the two wire protocols are the same. - -- Expose initialization, live state, stale state during reconnect, terminal refusal, explicit closure - and retry exhaustion. Consumer callback failures follow the existing cleanup-and-reject rule. -- Decode native watch frames directly into state events; do not encode them as SSE to parse them again. -- Separate collection resume checkpoints from object edit preconditions. A bookmark cannot supply an - individual object's edit version. Advance checkpoints only after successful state application. -- Represent native view identity explicitly, without inventing a gateway projection or redaction - revisions. Retain native fields for viewing; exclude machinery fields from accidental edits, - including the last-applied annotation. Define the host validation and native read/write contract. -- Require explicit source selection. Never retry a refused projected stream through native access. - Source, target, scope, view or login-identity changes must not silently combine drafts or redactions. -- Preserve save guards, submitted intent and later typing. A no-op or superseded write cannot wait - forever for an exact echo. Track dirty drafts, write acceptance and domain progress separately. - -Start with a proposal and an executable example. Expose the connector only once recovery, state -membership and editing contracts have passed acceptance. Keep the supported gateway connector in -place until then; do not advertise a native API in adoption snippets. - -### Recovery acceptance - -| Scenario | Required result | -|---|---| -| Initial streaming list, including an empty collection | `reset`, initial members, then `synced` only at the initial-end bookmark | -| EOF/read failure before the initial boundary | Restart initialization; an individual initial object's RV does not prove collection completeness | -| Disconnect after initialization | Resume from the last applied event/bookmark; retain membership, with no reset or snapshot pruning for a successful resume | -| HTTP or in-stream 410 | Start a fresh snapshot; retain old state as stale until completion, then prune missed deletes | -| API explicitly lacks streaming-list support | Complete a paginated list, then watch from its collection RV; auth and transient failures are not feature refusal | -| HTTP or in-stream 401/403 | Terminal for this connection; no retry loop or source fallback; a later accepted login can create another connection | -| 429/5xx, network failure, repeated short EOF | Bounded backoff and retry budget honoring hints; no hot loop | -| Close while reading or sleeping | Cancel request/backoff, release reader and deliver no later updates | -| Chunked UTF-8/JSON, malformed/truncated frame | Parse complete frames across chunks; partial input never completes a snapshot | -| Delete/recreate with the same name | New UID is separate; late responses cannot resurrect the old resource | -| Label-scope entry/exit | Membership follows the watch, including removal without an upstream delete | - -Cover repeated retry failure, expired list continuations, partial snapshots, scope/source changes and -cancellation during recovery. Do not assume periodic bookmarks: idle watches still need recovery. -Specify live/stale presentation during a successful resume without fabricating a `synced` snapshot. +**Adoption reason:** a host such as krm-foyer already exposes native collection URLs through its +session-authenticated `/k8s` proxy. Its page should reuse krm-stream lifecycle and state without first +wrapping native watch frames in SSE. Keep the projected gateway and its current v1 wire contract. + +### Slice 1: a native viewer + +Deliver one small read-only connector, using the existing `ResourceStateEvent` consumer and +`LiveResourceStore(readOnlyPolicy)`. Keep the standalone `applyStreamEvent` API for this slice; +proposal 0009's bound-method/editor cleanup is independent. No second store, framework or native +write endpoint is needed. A short contract, executable example and focused tests belong in this +change; native editing and the larger comparison project do not gate it. + +- Use caller-supplied fetch or global fetch, same-origin credentials by default and cancellable reads. +- Perform an ordinary collection LIST, then WATCH from its collection resourceVersion. Emit `reset`, + an `added` for each member and `synced` for a complete snapshot, including an empty collection. + Declare live only after snapshot application and watch acceptance; do not start the healthy-period + timer while the watch request is still opening. +- Require a complete unpaginated LIST in slice 1. Do not request a page limit. If the response has a + nonempty continuation token, refuse the unsupported partial collection with a clear terminal error + before emitting `reset` or `synced`; it is not safe to prune from one page. +- Validate collection items and identity before delivery. Supply missing item type metadata from a + concrete typed collection where valid, preserving fields already present; never guess kind from a + plural resource URL or label an item with the collection's `List` kind. +- Decode native JSON frames across byte/chunk boundaries, including split UTF-8. Map resource events + to added/modified/deleted and derive deletion identity from the object. Bookmarks affect neither + membership nor an individual object's version; resume checkpoints are unnecessary for slice 1. +- Every reconnect starts a fresh LIST. HTTP/in-stream 410 triggers bounded recovery to a fresh + snapshot. Do not route native 410 through the SSE transport's terminal HTTP classification. +- HTTP/in-stream 401/403 and other 4xx except 408/429/410 are terminal. Retry transient failures, + 408/429, 5xx, malformed/truncated frames and EOF with the existing bounded budget/backoff and hints. + Repeated 410/short EOF must not create an immediate re-list loop. +- Share lifecycle and HTTP/error helpers where useful, without importing native framing through an + SSE encoder/decoder. No SSE sequence or gateway protocol-header check belongs to native transport. +- Close/abort cancels LIST, WATCH, body reads and sleep; no subsequent events or live publication. + Host consumer/subscriber/error-handler exceptions preserve existing cleanup and rejected completion. +- Use separate stores for separate sources, scopes and identities. Never fall back from a refused + projected source to native access. Raw/native objects have no gateway projection or redaction revisions. + +### Acceptance for slice 1 + +Tests cover an empty collection; complete-list validation, unexpected continuation and type metadata; +chunked frames; malformed/truncated input; initial/list/watch failures; HTTP/in-stream 410 and auth +refusal; transient retries, exhaustion and retry hints; close during fetch/read/backoff; host callback +exceptions; UID replacement and missed deletes or selector exits repaired by re-list. Verify live +readiness, fresh snapshot pruning and preserved existing SSE lifecycle behavior. + +Add a minimal native viewer with identity, current fields and connection state. It makes no save +claims and demonstrates cleanup. Show how to target an existing host proxy. If an existing real-API +harness can exercise LIST/WATCH cheaply, add one focused integration case and report its actual run; +a new cluster campaign is not a prerequisite. Existing tests still apply to any changed lifecycle. + +## Follow-ups after the viewer + +Native editing is a separate slice: define the editable policy, exclude machinery and last-applied +annotation changes, and bind host reads/writes to the same source and UID. Preserve later typing, +submitted intent and reconciliation guards. A native response must not recover a projected editor. +Keep [the editor model](../client-state-model.md) and [saving guide](../saving.md) as the detailed contracts. + +Resumable native watches, streaming-list initialization and pagination are later improvements with +their own recovery tests. Slice 1 improves adoption and lifecycle reuse; re-listing on every reconnect +does not claim resume efficiency. Gateway upstream continuation has a different checkpoint owner and +can proceed independently. Neither implies browser replay for gateway SSE v1. ## Show the value of each path -Use one small frontend and workload for native, `krm-full/v1` and `krm-spec/v1` views. Reuse rendering -and editing where their contracts permit it. Show a read-only view first, then an editor with local -image changes, unrelated replica changes and a real overlapping edit. No new dashboard or framework. - -| Operation | Native | Full | Spec | -|---|---|---|---| -| Initial load / spec edit | Original resource | Projected resource | Projected resource without status | -| Status-only update | Update | Update | No object event | -| Managed-fields-only update | Update | No object event | No object event | -| Rotate a test Secret | Authorized reader gets value | Value absent; redaction revision changes | Value absent; redaction revision changes | -| Reconnect after delete | Resume or resnapshot | Snapshot prunes absent UID | Snapshot prunes absent UID | - -Compare shared and unshared gateway runs separately, with identical scopes and multiple authorized -subscribers. Sharing is independent of view selection; a native/full/spec table alone cannot establish -upstream savings. Different scopes do not automatically share. - -Use synthetic Secret values and assert their absence from full/spec transcripts, including after -reconnect. Verify unauthorized callers and expired sessions on both paths, and that shared identity -or fallback cannot grant access. A host's separately authorized native route can disclose Secret -values; view selection is not a universal field-permission system. - -Measure downstream bytes/events, store notifications/renders, snapshot size and apply time, upstream -watches, gateway work and conditional-save outcomes under the same object counts and controller churn. -Report reconnect costs and authorization work. Suppression reduces downstream work; sharing reduces -upstream duplication; native resume can reduce snapshot work. Establish each benefit with measurements. +After the viewer works, compare native/full/spec and shared/unshared gateway runs using the same +objects and churn. Demonstrate Secret disclosure, downstream bytes/events, notifications/renders, +snapshots, authorization work and upstream watches. Add editing once its native contract exists. +Keep this comparison and the larger benchmark outside slice 1; begin with the small viewer. ## Save progress under suppressed churn -A person editing spec should not repeatedly need a second Save when none of the relevant values -changed. Keep low browser traffic and real conflict protection. The current supported recovery is a -guarded projected read followed by review and another deliberate Save. - -[Proposal 0006, save-progress evaluation](../proposals/0006-stream-and-save-implementation-plan.md#5-save-progress-under-suppressed-churn) -owns evaluation of bounded submitted-intent recovery and optional coalesced version delivery, reusing -the real-API fixtures. It specifies guards, compatibility, measurements and acceptance; this request -does not duplicate them or commit to a new wire event. The evaluation can retain today's baseline. - -## Relationship to upstream continuation and adoption - -Proposal 0006's upstream continuation runs inside the gateway's Kubernetes backend; the native -connector resumes a browser-owned watch through a proxy. They have different checkpoint owners and -authorization lifecycles. Share scenarios, not cursors. Neither adds browser resume to gateway SSE v1. - -After the native connector is released, a host can upgrade and exercise both routes under the same -session, expiry and RBAC tests. Projected editors need projected recovery reads; a transparent native -proxy is not itself a replacement for that host contract. Current branch APIs and future requests -must remain distinct from a consumer's pinned dependency version. +[Proposal 0006](../proposals/0006-stream-and-save-implementation-plan.md#5-save-progress-under-suppressed-churn) +owns the independent evaluation of bounded submitted-intent recovery and optional coalesced version +delivery. Current projected recovery remains a guarded read, review and another deliberate Save. +It does not block the native viewer and does not authorize an automatic write policy in slice 1. diff --git a/docs/migrating.md b/docs/migrating.md index 7354677..80dfef3 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -4,7 +4,8 @@ The release after 0.7 has one browser connector and one name for each gateway se removed rather than kept as aliases, so the compiler or a failed import finds every call site. The editor store's methods (`setValue`, `captureSave`, `captureReconciliation`, `adoptSaved` and so on) are unchanged on this branch. Proposed editor removals in proposal 0009 are not migration requirements -yet. A native fetch/watch connector is also requested work; this upgrade still uses gateway SSE. +yet. A read-only native fetch/watch connector is separate requested work and uses today's standalone +state-input helper. This upgrade still uses gateway SSE; native viewing does not wait for editor cleanup. For the overall direction, see [watching resources](why-a-gateway.md). ## Browser client diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index e15f11f..b79240f 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -1,20 +1,21 @@ # Proposal 0006: Delivery plan for watch streams and optional editing -**Status: active delivery plan, updated 2026-10-05.** Implementation baseline: the stacked branch -through `b5cc779`. This is the single inventory of completed work, open work and ordering. Detailed -contracts stay in the linked proposals and guides rather than becoming another roadmap. +**Status: active delivery plan, updated 2026-10-05.** This is the single inventory of completed work, +open work and ordering. The starting implementation includes connector separation, Go API cleanup +and real-API save-composition tests. Detailed contracts stay in the linked proposals and guides +rather than becoming another roadmap. Start with [watching resources](../why-a-gateway.md); add the [editor](../client-state-model.md) only -where a page needs drafts. Native Kubernetes watches through fetch are the preferred direction; -retain gateway SSE compatibility and the projected gateway's redaction, suppression and sharing. +where a page needs drafts. Prioritize a read-only native fetch connector for hosts with a proxy; +the gateway remains the projected source with redaction, suppression and sharing, delivered over SSE. Follow the [design rules](../../CONTRIBUTING.md#design-rules) and [release policy](../releasing.md). [Proposal 0005](0005-kubernetes-stream-and-save-semantics.md) owns the stream/save tradeoffs. ## Completed work “Implemented” below means present in this checkout; it does not establish that the current stack is -merged, released or adopted by a host. Documentation changes are in the working tree. Final-commit -validation and consumer dependency upgrades remain separate delivery steps. +merged, released or adopted by a host. Final-commit validation and consumer dependency upgrades +remain separate delivery steps. | Done now | Result | Reference | |---|---|---| @@ -34,42 +35,35 @@ validation and consumer dependency upgrades remain separate delivery steps. The numbers express the recommended order of attention, not a requirement to finish every earlier row before starting a later one. Deliver separately reviewable changes and use the prerequisites below. -| Order | Open work | Status / prerequisite | Completion result | -|---|---|---|---| -| 1 | Settle the native watch contract and comparison workload | Requested; start now using the existing event-based connector seam | Reviewed native view/edit contract, initial-list/resume rules, stale/live presentation, auth/expiry handling and SSE compatibility; one declared workload | -| 2 | Finish the editor API cleanup in proposal 0009 | Proposed; can proceed alongside native design; coordinate shared state-input changes | Bound event input, removal of unguarded/duplicate APIs, simpler redaction reads, trimmed exports, migrated examples and upgrade guide | -| 3 | Implement the native fetch connector with recovery acceptance | After native contract review; transport can use today's event consumer without waiting for editor cleanup | Native JSON directly becomes state events; paginated-list fallback, safe resume/resnapshot, terminal auth refusal, bounded recovery and disposal pass acceptance | -| 4 | Measure and implement gateway upstream continuation | Design exists below; independent of native connector and editor cleanup | Reviewed reopen/time bounds, same-workload baseline, tests and measured continuity without routine downstream resets; safe fallback and authorization preserved | -| 5 | Evaluate save progress during suppressed churn | Requested evaluation; existing real-API fixtures suffice; independent of native transport | Measured decision on bounded submitted-intent recovery first, then optional coalesced version delivery; baseline retained if benefit is insufficient | -| 6 | Deliver the comparative frontend and measurements | Baseline gateway/shared-watch measurements can start now; native comparison follows order 3; editing uses the API selected in order 2 | One viewer plus optional editor compares native/full/spec and shared/unshared runs, including conflicts, expiry, reconnect and synthetic Secret disclosure checks | -| 7 | Validate, release and adopt each ready change | Per change, after its own acceptance and final-commit CI; no release requires all tracks to finish | Matching npm/Go artifacts, migration guidance and host dependency updates with browser/session/RBAC acceptance | - -Native design, implementation and comparison are specified in the -[current request](../field-reports/third-our-identity.md#native-watch-connector). Editor cleanup is -specified in [proposal 0009](0009-stream-and-editor-separation.md#proposed-editor-cleanup). Gateway -continuation and save-progress evaluation retain their detailed sections below. - -### Dependencies and next steps - -1. Review the native source/view and recovery contract, including the editor's machinery-field and - host-validation rules. Agree the test workload before adding another public connector. -2. In parallel work tracks, review and finish the pending editor cleanup, review the existing gateway - continuation design and record gateway/save baselines. This describes scheduling; each change has - its own review and acceptance. -3. Implement native transport after its contract is settled. The current event consumer is enough for - transport work; a dedicated read-only store and new package entry points are not prerequisites. - Finalize the supported editor input before publishing the native editing example or new editor APIs. -4. Run the comparison against whichever implementations are ready, naming each tested revision and - feature set. Do not attribute native resume savings to gateway continuation or sharing savings to - suppression. Save-policy implementation requires a favorable evaluation and reviewed follow-up scope. -5. Validate and release ready increments independently under the lockstep policy. Check consumer pins - and host acceptance before marking adoption complete. The existing implemented stack can be - validated/released without waiting for the new native feature. - -There are no assigned dates or owners in this plan. Open design decisions are native view identity and -editable machinery, stale/live transitions on resume, gateway reopen deadlines/credential lifetime, -and the save-recovery policy and version-delivery compatibility decision. Resolve them in their -respective contract reviews; none authorizes a silent change to current projected-view semantics. +| Order | Open work | Dependencies and completion | +|---|---|---| +| 1 | Native viewer: a short contract, shared lifecycle refactor, LIST/WATCH connector, tests and minimal example in one focused PR | Use today's event consumer and read-only store; re-list on reconnect. Native editing, editor cleanup, resume and benchmarks do not gate this slice. Acceptance lives in the [request](../field-reports/third-our-identity.md#slice-1-a-native-viewer). | +| 2 | Native editing | After the viewer; define source-bound edit policy and host validation, protect machinery fields and preserve save guards. Coordinate any public editor changes with proposal 0009. | +| 3 | Native reconnect efficiency | After the viewer; evaluate resumable watches, streaming-list initialization and pagination with explicit membership/checkpoint tests. Do not claim these efficiencies for slice 1. | +| 4 | Gateway upstream continuation | Independent track with a design below; review reopen/credential bounds, record a baseline, implement and measure. No dependency on native transport. | +| 5 | Save progress during suppressed churn | Independent evaluation using existing real-API fixtures; bounded submitted-intent recovery first, optional version delivery second. Implementation depends on measured benefit and reviewed scope. | +| 6 | Comparative frontend and measurements | Follow working native viewing; add editing after order 2. Compare native/full/spec and shared/unshared workloads; the larger project is outside slice 1. | +| Separate | Editor API cleanup in proposal 0009 | Independently proposed. Keep current standalone `applyStreamEvent` in slice 1; neither bound methods nor a new read-only store are required for native transport. | +| Per change | Validation, release and host adoption | Release ready increments under the existing lockstep policy; do not wait for every track. Check each change's final-commit CI, artifact/API notes and host acceptance. | + +### Next implementation + +Start the native viewer from the branch containing the implemented connector separation and Go API +cleanup. Keep native frame parsing separate from SSE while sharing lifecycle and useful HTTP helpers. +The short read-only contract and test results belong in the implementation PR; no additional +editor-design review or comparison campaign must finish first. + +Preserve existing SSE behavior and public APIs during the lifecycle refactor. Slice 1 requires complete +LIST membership, correct type/UID handling, an accepted WATCH before live readiness, transport-specific +410 recovery, terminal refusals, bounded repeated failure and cancellation. Use unpaginated lists; +reject unexpected continuation rather than pruning a partial snapshot. Add one small runnable native +viewer and document the two sources. See the [request](../field-reports/third-our-identity.md#acceptance-for-slice-1). + +Keep the current standalone state-input helper. Proposed editor removals remain in +[proposal 0009](0009-stream-and-editor-separation.md#proposed-editor-cleanup), with brief API change notes +when implemented. Native editing can follow the viewer without waiting for resume or streaming lists. +Gateway continuation and save measurements can proceed independently. Retain the existing projected +save flow and explicit protocol-version policy; neither needs redesign to deliver a native viewer. ## Deferred work @@ -303,7 +297,7 @@ and sinks have bounded completion times. Exercise quiet and active streams; cycl meet a bounded quiet-stream revocation target. See [authorization lifecycle](../auth.md#session-validity-and-timed-checks) for configuration and host responsibilities. -Order 5 evaluates submitted-intent recovery and coalesced version delivery without authorizing +The save-progress track evaluates submitted-intent recovery and coalesced version delivery without authorizing implementation or changing default emissions. Independent content/delivery switches, downstream replay, write tickets and a general SSA abstraction remain deferred. Preserve current named-view semantics, host policy and the distinction between native browser and gateway upstream recovery. diff --git a/docs/proposals/0009-stream-and-editor-separation.md b/docs/proposals/0009-stream-and-editor-separation.md index 358094c..e3fc5d5 100644 --- a/docs/proposals/0009-stream-and-editor-separation.md +++ b/docs/proposals/0009-stream-and-editor-separation.md @@ -1,6 +1,6 @@ # Proposal 0009: Stream and editor separation -**Status: partly implemented on the stacked branch through `b5cc779`.** One event-based connector, +**Status: partly implemented.** One event-based connector, host-callback failure handling and the adopted-save snapshot-membership fix are implemented. Editor API cleanup remains proposed. A dedicated read-only store and separate entry points remain deferred. Current APIs are documented in the [client README](../../packages/krm-stream/README.md) and @@ -22,9 +22,11 @@ together; [saving](../saving.md) owns the conditional-write contract. Keep those rather than duplicating them in this implementation plan. The [native-watch request](../field-reports/third-our-identity.md#native-watch-connector) extends this -separation: prefer fetch consuming native Kubernetes frames where the host provides a proxy, with -SSE retained as a compatibility path and the current gateway delivery format. Share lifecycle and -state application without converting native JSON to SSE. Native support is not implemented here. +separation: native is the original-resource source through a host proxy; the gateway provides +projected views over SSE. Share lifecycle and state application without converting native JSON to SSE. +The first native slice is a read-only viewer using today's event consumer and standalone +`applyStreamEvent`. It does not depend on the editor cleanup below or a new store. Native support +is not implemented here; native editing follows separately. ## Implemented connector and correctness fixes diff --git a/docs/proposals/0010-gateway-api-cleanup.md b/docs/proposals/0010-gateway-api-cleanup.md index 9bcf3d9..140653b 100644 --- a/docs/proposals/0010-gateway-api-cleanup.md +++ b/docs/proposals/0010-gateway-api-cleanup.md @@ -1,6 +1,6 @@ # Proposal 0010: Gateway API cleanup -**Status: implemented on the stacked branch through `b5cc779`.** This is the decision record for +**Status: implemented.** This is the decision record for PR #54. Use [the gateway README](../../gateway/README.md) for current setup and [upgrading from 0.7](../migrating.md#go-gateway) for the full migration table. @@ -10,7 +10,8 @@ Keep one name per API, share stream configuration and remove repository-only run 1.0. Preserve authorization, named projections, bounded HTTP delivery, shared-watch lifecycle and v1 wire behavior. The gateway remains the projected watch layer and current SSE delivery path; [native browser transport](../field-reports/third-our-identity.md#native-watch-connector) is separate -requested work. SSE compatibility does not require duplicate Go APIs. +requested work. The gateway remains the projected source over SSE; a native viewer does not require +changes to its APIs or framing. | Area | Implemented result | |---|---| diff --git a/docs/saving.md b/docs/saving.md index f6e0510..e278d9d 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -19,8 +19,9 @@ proves a rollout or Git workflow completed. A definite 409 rejection differs fro whose write outcome is unknown; do not blindly replay an ambiguous write. This guide describes projected editing through the current gateway. The requested native connector -needs an explicit native read/write and edit-policy contract before sharing these recipes. A raw -response must never be fed into a projected editor as recovery or save confirmation. +starts with read-only viewing. Native editing is a later slice requiring an explicit read/write and +edit-policy contract before sharing these recipes. A raw response must never be fed into a +projected editor as recovery or save confirmation. The [conditional-save example](../examples/conditional-save/README.md) implements all of it: a compilable host endpoint, client reconciliation, race tests and a real-cluster 409 test. @@ -158,7 +159,8 @@ and any receipt or Git workflow; connection state is not a save guarantee. |---|---|---| | `version-stale`, no field conflicts | “Configuration refreshed. Your edits are intact; review and save again.” | Capture a new intent on the next deliberate Save. Do not show an empty conflict panel. | | `draft-conflict` | Show local and current values at each conflicting field. | Offer explicit resolution: `revert` takes the server's value, the [keep-local recipe](../examples/editor-recipes/README.md#keep-the-local-value-in-a-conflict) keeps the person's. Keep the rest of the form visible. | -| Connection retrying or `recovering` | “Reconnecting. Your unsaved changes are still here.” | Disable writes until live; after a refused GET, require a later accepted guarded read before another write. | +| Connection `retrying` / `syncing` | “Reconnecting. Your unsaved changes are still here.” | Disable writes until live. | +| Example editor outcome `recovering` | “Refreshing configuration. Your edits are still here.” | A usable base is not established; require an accepted guarded read before another write. This is not a connection status. | | `saved`, watch confirmation pending | “Saved to Kubernetes; waiting for live confirmation.” | Preserve later typing. Track any receipt separately from draft state. | | Session expiry or access denial | Explain sign-in or access outcome. | Handle identity-scoped recovery; do not retry terminal auth failures indefinitely or label them field conflicts. | | `unavailable`, deleted/recreated UID | “This configuration was removed. A replacement must be opened separately.” | Offer copy-out from a retained recovery copy; never apply the old draft to the replacement. | diff --git a/docs/why-a-gateway.md b/docs/why-a-gateway.md index ea0500a..1e69d52 100644 --- a/docs/why-a-gateway.md +++ b/docs/why-a-gateway.md @@ -60,7 +60,9 @@ recreated resource from an earlier object with the same name. The fetch connector exposes `connecting`, `syncing`, `live`, `retrying`, `closed`, `terminal` and `exhausted`. Apply state events synchronously before rendering completion. Retryable failures recover -within the connector's bounded budget; 401/403 and terminal stream errors stop the connection. +within the connector's bounded budget. For the current SSE connector, every HTTP 4xx except 408/429, +terminal stream errors and a mismatched protocol header stop the connection. Native history-expiry +410 needs different recovery; it must be classified by native transport, not this SSE rule. Dispose subscriptions and close the handle when their owner leaves. See the [client lifecycle reference](../packages/krm-stream/README.md#connections). @@ -81,23 +83,26 @@ be bounded. Pair sharing with `kube.SubjectAccessReviewAuthorizer` for Kubernete See [shared-watch authorization](auth.md#shared-watch-authorization) and [operations](operations.md). Sharing reduces duplicate resource consumption; it does not supply a rate limiter or admission policy. -## Native watch direction and SSE compatibility - -The preferred direction is a fetch connector that consumes native Kubernetes watch JSON through a -host proxy and reuses the frontend lifecycle and state-event input. An application with native API -access should not need to wrap those frames in SSE to use the resource store. Native transport keeps -upstream fields and does not provide the gateway's projection, redaction, suppression or sharing. - -Keep the Go gateway and SSE as a supported compatibility path for consumers choosing that protocol, -and as the existing delivery path for projected views. SSE is framing; fetch and `EventSource` are -client mechanisms. Both native JSON and SSE can carry errors. The current fetch-based SSE connector -already handles HTTP refusal, terminal in-stream errors, cancellation and bounded retries. Native -`EventSource` has different header and retry limitations and cannot parse Kubernetes watch JSON. - -A same-origin, cookie-authenticated host proxy can provide native access while retaining cluster -credentials server-side. The [native connector request](field-reports/third-our-identity.md#native-watch-connector) -is not implemented yet. Until its recovery and editing contracts pass acceptance, use the gateway -connector described in [adoption](adopting.md). Applications must choose a source explicitly and never fall back to native access -after a projected stream is refused. - -Use [adoption](adopting.md) for today's supported wiring and [saving](saving.md) when adding writes. +## Native and gateway sources + +Native access is the straightforward starting point for a host that already proxies Kubernetes. +The requested native fetch connector adds lifecycle and state-store reuse while retaining original +resource fields. The gateway adds defined views, redaction, suppression and watch sharing, delivered +over SSE. Choose the guarantees the page needs. Native access alone does not provide those gateway +capabilities, and `krm-raw/v1` still differs from native data. + +Both sources use fetch; native Kubernetes JSON and gateway SSE are different framing formats, and +both can carry errors. The existing SSE connector already handles refusal, expiry, cancellation and +bounded retries. Browser `EventSource` is a different client mechanism with header/retry limitations. + +The [first native slice](field-reports/third-our-identity.md#slice-1-a-native-viewer) is a read-only +viewer: ordinary LIST, then WATCH from the collection version, with a fresh LIST on every reconnect. +It reuses the existing lifecycle and `LiveResourceStore(readOnlyPolicy)`. HTTP/in-stream 410 recovers +within the bounded policy; terminal auth refusal never selects another source. An unexpected paginated +response must be refused rather than marked complete. Native editing, resume and streaming lists follow +separately; neither editor cleanup nor a comparative benchmark blocks the viewer. + +Today use the [gateway wiring](adopting.md); native code snippets will be added with the implementation. +A same-origin, cookie-authenticated host proxy keeps Kubernetes credentials server-side. Keep stores +separate across sources, scopes and identities and never fall back from a refused projected source +into native access. The native connector is not implemented by this documentation change. diff --git a/examples/README.md b/examples/README.md index 985d2e7..c2ba10a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,8 +2,9 @@ Start with [watching resources](../docs/why-a-gateway.md) and supported host wiring, then add the optional editor. The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against -the current SSE replay gateway. Native fetch/watch support and the comparative view/sharing demo -are [requested work](../docs/field-reports/third-our-identity.md#show-the-value-of-each-path). +the current gateway source over SSE. A minimal native viewer is +[next work](../docs/field-reports/third-our-identity.md#slice-1-a-native-viewer); it reuses the current +read-only store and lifecycle. Native editing and the larger view/sharing comparison follow separately. For current host integration patterns, use these small recipes: - [Same-origin cookie application](../docs/adopting.md#1-mount-the-stream-endpoint): fetch diff --git a/examples/vanilla-browser/README.md b/examples/vanilla-browser/README.md index 8eda175..25b4de6 100644 --- a/examples/vanilla-browser/README.md +++ b/examples/vanilla-browser/README.md @@ -30,5 +30,6 @@ Add `pace=0ms` for the fastest replay or use a positive value to inspect each ev The example uses today's projected SSE connector over fetch. For a read-only page, render server state with `LiveResourceStore(readOnlyPolicy)` as shown in the [README](../../README.md#watch-a-resource-view-today). Editing is an optional layer; the [editor model](../../docs/client-state-model.md) and -[saving guide](../../docs/saving.md) describe its intended use. A native watch/view comparison is -requested work, not a feature of this replay demo. +[saving guide](../../docs/saving.md) describe its intended use. A small native viewer is the next +slice; native editing and a larger source comparison follow separately. They are not features of +this gateway replay demo. diff --git a/gateway/README.md b/gateway/README.md index 62efa71..f0f3368 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -6,8 +6,9 @@ It is transport-neutral until `Handler` or `ServeStream` adds the current SSE fr Kubernetes client dependency; [`gateway/kube`](kube/) provides the client-go backend and authorizer. A fetch-based native browser connector is [requested work](../docs/field-reports/third-our-identity.md#native-watch-connector). -The direction retains this gateway and SSE compatibility; native access alone does not provide these -projected-view guarantees. See [watching resources](../docs/why-a-gateway.md) for the overall contract. +Native is the original-resource source; this gateway delivers projected views over SSE. Native +access alone does not provide these projected-view guarantees. A small native viewer can ship +independently of native editing or changes to this gateway. See [watching resources](../docs/why-a-gateway.md) for the overall contract. ```go handler := gateway.Handler(gateway.Options{ diff --git a/packages/krm-stream/README.md b/packages/krm-stream/README.md index 9da9140..4e4bcbb 100644 --- a/packages/krm-stream/README.md +++ b/packages/krm-stream/README.md @@ -12,8 +12,11 @@ stream in a browser or JavaScript application. It provides: Start with a watch-backed list or viewer; add draft reconciliation only when the page needs editing. The package is headless and does not choose a UI framework. Today it consumes gateway SSE through fetch, from the Go gateway or another v1 producer. A fetch-based native Kubernetes watch connector -is the [preferred direction and requested work](../../docs/field-reports/third-our-identity.md#native-watch-connector), -with gateway SSE retained as a compatibility path. It is not an available connector yet. +is [next work](../../docs/field-reports/third-our-identity.md#slice-1-a-native-viewer): a read-only +LIST/WATCH source with re-list recovery and a small viewer, reusing the same lifecycle and store. +Native retains original authorized resources; gateway SSE delivers projected views with redaction, +suppression and optional sharing. The native connector is not implemented yet; editing and resume +are later slices. Keep current `applyStreamEvent` unchanged for the viewer. ```ts import { LiveResourceStore, readOnlyPolicy, applyStreamEvent, connectResourceStream, resourceStreamURL } from "@configbutler/krm-stream"; @@ -72,7 +75,7 @@ unsubscribe(); connection.close(); ``` -`connectResourceStream` is the package's one connector. It uses fetch for same-origin cookies or +`connectResourceStream` is the current gateway connector. It uses fetch for same-origin cookies or bearer headers; `credentials: "include"` opts into cross-origin cookies. It decodes and sequence-checks the stream, and calls your callback synchronously, exactly once per state event (`reset`, `added`, `modified`, `deleted`, `synced`), in stream order, without the wire `seq`. diff --git a/spec/v1.md b/spec/v1.md index 28f8776..660cd5e 100644 --- a/spec/v1.md +++ b/spec/v1.md @@ -4,9 +4,10 @@ > [the Go gateway](../gateway/README.md) and [the browser client](../packages/krm-stream/README.md). > It defines live projected resource state; draft editing is an optional consumer behavior. > -> The preferred native-fetch direction is [requested work](../docs/field-reports/third-our-identity.md#native-watch-connector). -> This specification remains the SSE compatibility contract. Native Kubernetes JSON is not v1 SSE, -> and this documentation revision changes no wire behavior or conformance requirement. +> A read-only native fetch connector is [requested work](../docs/field-reports/third-our-identity.md#slice-1-a-native-viewer). +> This specification describes the gateway's projected source over SSE. Native Kubernetes JSON is a +> different source format and does not use the SSE sequence or protocol-header rules. No wire behavior +> or conformance requirement changes in this documentation revision. A resource stream is a one-way feed, over Server-Sent Events, of the current-and-then-live state of a **scope** (one named object, or a collection of a kind in a namespace). The server absorbs every