diff --git a/README.md b/README.md index 5dcc6c6..4075eee 100644 --- a/README.md +++ b/README.md @@ -67,34 +67,37 @@ server. ```mermaid flowchart LR - person["Person\nediting a form"] - browser["Your browser app\n@configbutler/krm-stream"] - store["LiveResourceStore\nbase + local draft + live update\nthree-way merge"] - host["Your Go application\nidentity, authorization, scope\nprojection and redaction"] - api["Kubernetes API\nand resource watch"] - resources["KRM resources\napps, policies, databases,\nplatform and business objects"] - save["Your save endpoint\nvalidates and applies a patch"] - - person --> browser --> store - store <-->|"snapshots and live deltas (SSE)"| host - host <-->|"read and watch"| api --> resources - store -->|"draft, conflicts, merge patch"| browser - browser -->|"explicit save"| save --> api - - classDef person fill:#fff3cd,stroke:#d39e00,color:#3f3000; - classDef browser fill:#dff3ff,stroke:#1677a4,color:#062f45; - classDef host fill:#e4f7e8,stroke:#27834c,color:#113d23; - classDef api fill:#f8e0ef,stroke:#a83970,color:#4b1230; - classDef store fill:#ede7ff,stroke:#6750a4,color:#2d1e5c; - class person person; - class browser,store browser; - class host,save host; - class api,resources api; + 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; ``` 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. +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. + +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). ## Start here diff --git a/conformance/README.md b/conformance/README.md index f10eab4..cc8ce35 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -125,7 +125,9 @@ Every fixture names the rule it defends, in `why:`. The ones that catch real bug | `resync-midstream` | upstream continuity can be lost *without* the SSE connection dropping → a fresh cycle mid-stream | | `nested-field-removed` | `added`/`modified` **replace**; a deep-merge would resurrect a field the server deleted (a ghost) | | `status-follow-live` | `status` is read-only under the full projection: it follows the server live, and never becomes dirty, never conflicts, never enters a patch | -| `status-only-churn` | a spec-only projection suppresses controller status churn entirely, without disturbing an in-flight spec edit | +| `status-only-churn` | a final status-only write under spec projection is suppressed; visible content converges with an older held RV and an intact spec edit | +| `final-redaction-rotation` | a final hidden-value change emits a redaction revision even when projected content excluding RV is unchanged | +| `final-bookkeeping-only` | a final ignored bookkeeping write is suppressed; visible content converges with an older held RV | | `edit-vs-unrelated-change` | **R-THREEWAY** — the base is the previous *server* object | | `conflict-and-converge` | a conflict clears when the server's value arrives at what you typed | | `dotted-label-keys` | **R-ID** — `app.kubernetes.io/name` is ONE path segment. Dot-joining it is silently wrong | @@ -161,3 +163,22 @@ reader trust a mental model that will mislead them the next time. 2. Add `fixtures/.yaml`. Say in `why:` which rule it defends; if it doesn't defend one, ask whether it earns its keep. 3. `task fixtures` (rebuilds `gen/`), then `task test`. Commit the YAML **and** the generated JSON. + +## Convergence evidence + +The [normative invariant](../spec/v1.md#6-ordering-delivery--the-state-guarantee) and +[suppression decision](../docs/proposals/0004-views-and-bytes.md#suppression) share one comparison. +[Final bookkeeping](fixtures/final-bookkeeping-only.yaml) and +[final spec-only status churn](fixtures/status-only-churn.yaml) run through the +[gateway conformance suite](../gateway/stream_conformance_test.go) and the client object and SSE +suites. The [convergence test](../packages/krm-stream/test/convergence.test.ts) compares the held +content with the final upstream body under that projection and asserts the older held RV explicitly. +The [gateway suppression test](../gateway/stream_test.go) also checks the suppression observation +and original delivered object under all three built-in projections. +The symmetric [final Secret rotation](fixtures/final-redaction-rotation.yaml) must emit its changed +redaction revision; that same client test checks the held records at each delivered upsert. +Existing snapshot, pruning, ordering and redaction tests remain part of `task test`. + +This narrows the promised invariant without changing wire emissions. The conventional `fix:` commit +records the clarification for Release Please's generated release notes; no manual changelog entry +is maintained. Adoption recipes, real-API save hardening and watch continuation remain subsequent work. diff --git a/conformance/bodies/cm-app.v2-bookkeeping.yaml b/conformance/bodies/cm-app.v2-bookkeeping.yaml new file mode 100644 index 0000000..0eb964b --- /dev/null +++ b/conformance/bodies/cm-app.v2-bookkeeping.yaml @@ -0,0 +1,23 @@ +# A final bookkeeping-only revision of cm-app.v1: visible data stays the same. +# Every built-in projection ignores these added managedFields and last-applied metadata. +apiVersion: v1 +kind: ConfigMap +metadata: + uid: cm-app-0001 + name: app-config + namespace: app + resourceVersion: "1002" + managedFields: + - manager: bookkeeping + operation: Apply + apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: {} + labels: + app.kubernetes.io/name: shop + annotations: + note: keep + kubectl.kubernetes.io/last-applied-configuration: '{"apiVersion":"v1","kind":"ConfigMap"}' +data: + log-level: info + replicas: "3" diff --git a/conformance/bodies/secret-token.v2-wire.yaml b/conformance/bodies/secret-token.v2-wire.yaml new file mode 100644 index 0000000..e30579c --- /dev/null +++ b/conformance/bodies/secret-token.v2-wire.yaml @@ -0,0 +1,13 @@ +# The same Secret under `krm-full/v1`: values are omitted from the object. The accompanying event's +# `redacted` entries declare which paths exist upstream, allowing the client to render them as +# read-only without introducing a placeholder value. +apiVersion: v1 +kind: Secret +metadata: + uid: secret-token-0001 + name: git-creds + namespace: app + resourceVersion: "4002" + labels: + app.kubernetes.io/name: shop +type: Opaque diff --git a/conformance/bodies/secret-token.v2.yaml b/conformance/bodies/secret-token.v2.yaml new file mode 100644 index 0000000..85b1430 --- /dev/null +++ b/conformance/bodies/secret-token.v2.yaml @@ -0,0 +1,14 @@ +# Final token rotation: username and all visible content are unchanged except RV. +apiVersion: v1 +kind: Secret +metadata: + uid: secret-token-0001 + name: git-creds + namespace: app + resourceVersion: "4002" + labels: + app.kubernetes.io/name: shop +type: Opaque +data: + token: bmV3LXRva2Vu # "new-token"; still withheld + username: Ym9i # "bob" diff --git a/conformance/fixtures/final-bookkeeping-only.yaml b/conformance/fixtures/final-bookkeeping-only.yaml new file mode 100644 index 0000000..d37d33e --- /dev/null +++ b/conformance/fixtures/final-bookkeeping-only.yaml @@ -0,0 +1,23 @@ +id: final-bookkeeping-only +title: A final bookkeeping-only write leaves visible content converged and the held RV older. +why: > + Spec §6 excludes metadata.resourceVersion from convergence. The final upstream write changes only + ignored managedFields and last-applied metadata plus RV. No later visible write refreshes the RV. +suites: [gateway, client] +scope: { target: demo, version: v1, resource: configmaps, namespace: app } +projection: krm-full/v1 +watch: + - { op: list, bodies: [cm-app.v1] } + - { op: modified, body: cm-app.v2-bookkeeping } +events: + - { type: reset } + - { type: added, body: cm-app.v1 } + - { type: synced } +client: + expect: + uids: [cm-app-0001] + dirty: [] + conflicts: [] + draftSubset: + metadata: { resourceVersion: "1001" } + data: { log-level: info, replicas: "3" } diff --git a/conformance/fixtures/final-redaction-rotation.yaml b/conformance/fixtures/final-redaction-rotation.yaml new file mode 100644 index 0000000..eb048e5 --- /dev/null +++ b/conformance/fixtures/final-redaction-rotation.yaml @@ -0,0 +1,31 @@ +id: final-redaction-rotation +title: A final Secret rotation must deliver its changed redaction revision. +why: > + Spec §6 includes redaction records in the visible comparison. The final token rotation leaves + projected content excluding RV unchanged, but its changed redaction revision MUST reach the client. + No later write or snapshot can repair a missed rotation; the hidden token must remain absent. +suites: [gateway, client] +scope: { target: demo, version: v1, resource: secrets, namespace: app } +projection: krm-full/v1 +watch: + - { op: list, bodies: [secret-token.v1] } + - { op: modified, body: secret-token.v2 } +events: + - { type: reset } + - type: added + body: secret-token.v1-wire + redacted: [{ path: "/data/token", rev: 1 }, { path: "/data/username", rev: 1 }] + - { type: synced } + - type: modified + body: secret-token.v2-wire + redacted: [{ path: "/data/token", rev: 2 }, { path: "/data/username", rev: 1 }] +client: + expect: + uids: [secret-token-0001] + dirty: [] + conflicts: [] + draftSubset: + metadata: { resourceVersion: "4002" } + absentPaths: [[data]] + readOnlyPaths: [[data, token], [data, username]] + patch: null diff --git a/conformance/fixtures/status-only-churn.yaml b/conformance/fixtures/status-only-churn.yaml index f63f791..b3d2aeb 100644 --- a/conformance/fixtures/status-only-churn.yaml +++ b/conformance/fixtures/status-only-churn.yaml @@ -4,6 +4,9 @@ why: > `krm-spec/v1` ignores `status`, so a controller-only update must emit no event at all. Meanwhile the user is editing `spec.replicas`; the suppressed status heartbeat must not touch that draft. + The status write is final: spec §6 visible content converges without refreshing the held RV 3001 + to upstream RV 3002. No later event is needed to satisfy convergence. + On a real workload this — not editing — is the dominant traffic on the stream. suites: [gateway, client] scope: { target: demo, group: apps, version: v1, resource: deployments, namespace: app } @@ -25,5 +28,6 @@ client: dirty: [[spec, replicas]] # the ONLY dirty path — status can never be one conflicts: [] draftSubset: + metadata: { resourceVersion: "3001" } spec: { replicas: 4 } # the edit survived suppressed status churn patch: { spec: { replicas: 4 } } # status is NOT in the patch. It cannot be. diff --git a/conformance/gen/bodies.json b/conformance/gen/bodies.json index 7ec49f2..2829aa3 100644 --- a/conformance/gen/bodies.json +++ b/conformance/gen/bodies.json @@ -35,6 +35,36 @@ "replicas": "3" } }, + "cm-app.v2-bookkeeping": { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "uid": "cm-app-0001", + "name": "app-config", + "namespace": "app", + "resourceVersion": "1002", + "managedFields": [ + { + "manager": "bookkeeping", + "operation": "Apply", + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": {} + } + ], + "labels": { + "app.kubernetes.io/name": "shop" + }, + "annotations": { + "note": "keep", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\"}" + } + }, + "data": { + "log-level": "info", + "replicas": "3" + } + }, "cm-app.v2": { "apiVersion": "v1", "kind": "ConfigMap", @@ -488,5 +518,37 @@ "token": "aHVudGVyMg==", "username": "Ym9i" } + }, + "secret-token.v2-wire": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "uid": "secret-token-0001", + "name": "git-creds", + "namespace": "app", + "resourceVersion": "4002", + "labels": { + "app.kubernetes.io/name": "shop" + } + }, + "type": "Opaque" + }, + "secret-token.v2": { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "uid": "secret-token-0001", + "name": "git-creds", + "namespace": "app", + "resourceVersion": "4002", + "labels": { + "app.kubernetes.io/name": "shop" + } + }, + "type": "Opaque", + "data": { + "token": "bmV3LXRva2Vu", + "username": "Ym9i" + } } } diff --git a/conformance/gen/fixtures.json b/conformance/gen/fixtures.json index 5ccc20b..69d51a9 100644 --- a/conformance/gen/fixtures.json +++ b/conformance/gen/fixtures.json @@ -509,6 +509,158 @@ } } }, + { + "id": "final-bookkeeping-only", + "title": "A final bookkeeping-only write leaves visible content converged and the held RV older.", + "why": "Spec §6 excludes metadata.resourceVersion from convergence. The final upstream write changes only ignored managedFields and last-applied metadata plus RV. No later visible write refreshes the RV.\n", + "suites": [ + "gateway", + "client" + ], + "scope": { + "target": "demo", + "version": "v1", + "resource": "configmaps", + "namespace": "app" + }, + "projection": "krm-full/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "cm-app.v1" + ] + }, + { + "op": "modified", + "body": "cm-app.v2-bookkeeping" + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "added", + "body": "cm-app.v1" + }, + { + "type": "synced" + } + ], + "client": { + "expect": { + "uids": [ + "cm-app-0001" + ], + "dirty": [], + "conflicts": [], + "draftSubset": { + "metadata": { + "resourceVersion": "1001" + }, + "data": { + "log-level": "info", + "replicas": "3" + } + } + } + } + }, + { + "id": "final-redaction-rotation", + "title": "A final Secret rotation must deliver its changed redaction revision.", + "why": "Spec §6 includes redaction records in the visible comparison. The final token rotation leaves projected content excluding RV unchanged, but its changed redaction revision MUST reach the client. No later write or snapshot can repair a missed rotation; the hidden token must remain absent.\n", + "suites": [ + "gateway", + "client" + ], + "scope": { + "target": "demo", + "version": "v1", + "resource": "secrets", + "namespace": "app" + }, + "projection": "krm-full/v1", + "watch": [ + { + "op": "list", + "bodies": [ + "secret-token.v1" + ] + }, + { + "op": "modified", + "body": "secret-token.v2" + } + ], + "events": [ + { + "type": "reset" + }, + { + "type": "added", + "body": "secret-token.v1-wire", + "redacted": [ + { + "path": "/data/token", + "rev": 1 + }, + { + "path": "/data/username", + "rev": 1 + } + ] + }, + { + "type": "synced" + }, + { + "type": "modified", + "body": "secret-token.v2-wire", + "redacted": [ + { + "path": "/data/token", + "rev": 2 + }, + { + "path": "/data/username", + "rev": 1 + } + ] + } + ], + "client": { + "expect": { + "uids": [ + "secret-token-0001" + ], + "dirty": [], + "conflicts": [], + "draftSubset": { + "metadata": { + "resourceVersion": "4002" + } + }, + "absentPaths": [ + [ + "data" + ] + ], + "readOnlyPaths": [ + [ + "data", + "token" + ], + [ + "data", + "username" + ] + ], + "patch": null + } + } + }, { "id": "key-removed-upstream", "title": "A map key deleted upstream disappears from the draft — while an in-flight edit survives.", @@ -1269,7 +1421,7 @@ { "id": "status-only-churn", "title": "A status-only update produces no event when the selected projection ignores status.", - "why": "`krm-spec/v1` ignores `status`, so a controller-only update must emit no event at all. Meanwhile the user is editing `spec.replicas`; the suppressed status heartbeat must not touch that draft.\nOn a real workload this — not editing — is the dominant traffic on the stream.\n", + "why": "`krm-spec/v1` ignores `status`, so a controller-only update must emit no event at all. Meanwhile the user is editing `spec.replicas`; the suppressed status heartbeat must not touch that draft.\nThe status write is final: spec §6 visible content converges without refreshing the held RV 3001 to upstream RV 3002. No later event is needed to satisfy convergence.\nOn a real workload this — not editing — is the dominant traffic on the stream.\n", "suites": [ "gateway", "client" @@ -1328,6 +1480,9 @@ ], "conflicts": [], "draftSubset": { + "metadata": { + "resourceVersion": "3001" + }, "spec": { "replicas": 4 } diff --git a/conformance/gen/sse/final-bookkeeping-only.sse b/conformance/gen/sse/final-bookkeeping-only.sse new file mode 100644 index 0000000..af96d67 --- /dev/null +++ b/conformance/gen/sse/final-bookkeeping-only.sse @@ -0,0 +1,8 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"configmaps","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","data":{"log-level":"info","replicas":"3"},"kind":"ConfigMap","metadata":{"annotations":{"note":"keep"},"labels":{"app.kubernetes.io/name":"shop"},"name":"app-config","namespace":"app","resourceVersion":"1001","uid":"cm-app-0001"}},"redacted":[]} + +data: {"seq":3,"type":"synced"} + diff --git a/conformance/gen/sse/final-redaction-rotation.sse b/conformance/gen/sse/final-redaction-rotation.sse new file mode 100644 index 0000000..dc55a34 --- /dev/null +++ b/conformance/gen/sse/final-redaction-rotation.sse @@ -0,0 +1,10 @@ +: connection 1 + +data: {"seq":1,"type":"reset","target":"demo","scope":{"target":"demo","version":"v1","resource":"secrets","namespace":"app"},"projection":"krm-full/v1"} + +data: {"seq":2,"type":"added","object":{"apiVersion":"v1","kind":"Secret","metadata":{"labels":{"app.kubernetes.io/name":"shop"},"name":"git-creds","namespace":"app","resourceVersion":"4001","uid":"secret-token-0001"},"type":"Opaque"},"redacted":[{"path":"/data/token","rev":1},{"path":"/data/username","rev":1}]} + +data: {"seq":3,"type":"synced"} + +data: {"seq":4,"type":"modified","object":{"apiVersion":"v1","kind":"Secret","metadata":{"labels":{"app.kubernetes.io/name":"shop"},"name":"git-creds","namespace":"app","resourceVersion":"4002","uid":"secret-token-0001"},"type":"Opaque"},"redacted":[{"path":"/data/token","rev":2},{"path":"/data/username","rev":1}]} + diff --git a/docs/adopting.md b/docs/adopting.md index 5ffa4e7..0090ce5 100644 --- a/docs/adopting.md +++ b/docs/adopting.md @@ -138,6 +138,10 @@ const store = new LiveResourceStore(withOpenAPIKeyedLists(defaultPolicy, deploym `x-kubernetes-list-type: map` with `x-kubernetes-list-map-keys` are merged by key; every other list stays safely atomic. +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) +for the illustrated flow and the host's recovery responsibilities. + ## 4. Share watches only with Kubernetes-backed authorization `SharedBackend` saves upstream watches but runs as one service identity. Pair it with diff --git a/docs/proposals/0004-views-and-bytes.md b/docs/proposals/0004-views-and-bytes.md index 97ca93c..1b6b40f 100644 --- a/docs/proposals/0004-views-and-bytes.md +++ b/docs/proposals/0004-views-and-bytes.md @@ -5,8 +5,8 @@ ## Decision The gateway sends a named, host-authorized projection of each Kubernetes object and suppresses an -upstream update when the consumer-visible event is unchanged. The goal is not merely smaller status -events; a consumer that does not render status receives **no event** for status-only churn. +upstream update when projected content excluding `metadata.resourceVersion`, plus redaction records, +is unchanged. The goal is not merely smaller status events; a consumer that does not render status receives **no event** for status-only churn. The object remains a strict subset of the API-server object. The gateway may remove values but never add or replace an object value. Information about removed values belongs in the event envelope, never @@ -27,7 +27,7 @@ A projection applies one of three actions to each path. | Action | Value on wire | Path disclosed | Event on change | |---|---|---:|---:| -| `send` | yes | n/a | yes | +| `send` | yes | n/a | yes, except RV alone ([suppression](#suppression)) | | `redact` | no | yes, in `redacted[]` | yes | | `ignore` | no | no | no | @@ -62,15 +62,16 @@ After projection and redaction revision calculation, the gateway builds a canoni It hashes that view and emits an `added` or `modified` event only when it differs from the last value emitted for that UID in the current snapshot cycle. `resourceVersion` stays in the wire object; it is -excluded only from this internal comparison. `seq` is assigned after the suppression decision and is -also excluded. +excluded from both this suppression comparison and the +[convergence invariant](../../spec/v1.md#6-ordering-delivery--the-state-guarantee). `seq` is assigned +after the suppression decision and is also excluded. The suppression map is cleared on every `reset`. A reset marks the consumer's resources unseen, so an unchanged snapshot object must still be emitted or `synced` would prune it. Redaction revision state does not reset until the connection ends, allowing a resnapshot to report that a hidden value changed while upstream continuity was lost. -Suppression is defined over the event envelope, not only the projected object. A Secret rotation can +Suppression compares projected content excluding RV plus redaction records. A Secret rotation can leave the projected object unchanged; its redaction revision changes, so the event must be emitted. ## Redaction revisions @@ -112,7 +113,8 @@ an SSE `id` and does not provide replay or resume semantics. - `krm-spec/v1` makes a status-blind editor cheap under controller churn without weakening the complete-object invariant for the fields it receives. - A suppressed update may leave the consumer's `metadata.resourceVersion` stale. It remains a safe - save precondition: the API server rejects a stale write with 409. Capture it with the patch, + save precondition belonging to the delivered revision, with no freshness or downstream resume + guarantee: the API server rejects a stale write with 409. Capture it with the patch, reconcile before retrying, and never substitute a new version onto an old patch. Narrow patches limit write scope; they do not prevent lost updates. See [saving](../saving.md). - Shared upstream watches remain safe: projection, redaction revision, suppression digest, and sequence diff --git a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md index fff0565..4839fce 100644 --- a/docs/proposals/0005-kubernetes-stream-and-save-semantics.md +++ b/docs/proposals/0005-kubernetes-stream-and-save-semantics.md @@ -1,6 +1,6 @@ # Proposal 0005: Stream and conditional-save tradeoffs -**Status: design rationale. Normative convergence clarification remains pending.** +**Status: design rationale. [Normative convergence clarification](../../spec/v1.md#6-ordering-delivery--the-state-guarantee) adopted.** [Proposal 0006](0006-stream-and-save-implementation-plan.md) owns the remaining work and acceptance criteria. Current adoption behavior belongs in the [saving guide](../saving.md). Managed recovery, @@ -58,33 +58,15 @@ shipped editor outcomes. A refreshed base enables review; it cannot promise the ## Convergence needs precise equality -[Spec §3](../../spec/v1.md#3-projection-and-redaction--part-of-the-wire-not-an-implementation-detail) -explicitly permits status suppression. Proposal 0004 explicitly excludes resourceVersion from the -digest. But §6 says the consumer map equals the projected view and promises delivery of the final -state. Literal whole-object equality includes resourceVersion. A final suppressed metadata/status -write can leave that field behind indefinitely. - -This is at least an ambiguity between the declared suppression rule and the broad invariant. We -should resolve it explicitly, not claim that current code gives exact Kubernetes-object equality at -all times. The “corresponding logical stream position” wording limits the invariant, but does not -clearly tell adopters which version remains meaningful after suppression. - -**Recommended clarification, for review:** - -> Convergence is over the declared visible comparison: projected object content excluding -> metadata.resourceVersion, together with the stream's redaction records. The resourceVersion in a -> delivered object is genuine and belongs to that delivered revision; suppressed updates do not -> refresh it. It can be echoed as a conditional-write precondition, but is not a current-version -> guarantee or a downstream resume checkpoint. - -Keep completeness within the projection, safe snapshot pruning, and within-cycle ordering as separate -invariants. Scope redaction counters to one connection; do not equate counters from separate -connections. Do not change opaque-version handling in the browser. - -This documents the implemented semantics, but it narrows how a reader might interpret the existing -invariant. Call it out in release notes. If the project instead wants exact projected-object equality -including version metadata, it needs a different emission policy; that cannot be presented as a -wording-only fix. +The former §6 invariant allowed a reader to expect whole-object equality, including resourceVersion, +while the implemented suppression comparison already excluded it. A final suppressed metadata/status +write could leave the held version behind indefinitely. The “corresponding logical stream position” +wording limited when equality applied, but did not define the right comparison. + +[Spec §6](../../spec/v1.md#6-ordering-delivery--the-state-guarantee) now keeps that positional guarantee +and defines its comparison explicitly. [Executable evidence](../../conformance/README.md#convergence-evidence) +covers both suppressed final writes and delivered redaction changes. Wire emissions are unchanged; +the narrowed guarantee is recorded through the conventional-commit release process. ## Host write strategies diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md index 4373cb5..a7aa78d 100644 --- a/docs/proposals/0006-stream-and-save-implementation-plan.md +++ b/docs/proposals/0006-stream-and-save-implementation-plan.md @@ -15,31 +15,19 @@ shipped in [0.3.0](../../packages/krm-stream/CHANGELOG.md). Use the | Priority | Remaining work | Completion evidence | |---|---|---| -| 1 | Define convergence precisely | Spec, suppression rules, conformance and release notes agree. | +| 1 | Define convergence precisely — complete | [Evidence](../../conformance/README.md#convergence-evidence) | | 2 | Publish tested deletion-recovery and keep-local recipes | Executed examples preserve unsaved work and unrelated conflicts. | | 3 | Harden real-API save composition and identity races | Exact-commit API evidence, separate from fake-client CI. | | 4 | Measure and implement upstream continuation | Same-workload comparison proves continuity, bounded recovery and authorization. | -Review these as separate changes. Baseline measurement can run alongside priorities 1–3. Complete -the normative amendment before the next release presenting convergence as settled. Adopter-reported +Review the remaining priorities as separate changes. Baseline measurement can run alongside +priorities 2–3. The normative amendment is complete; see priority 1's evidence. Adopter-reported unit tests support adoption, but do not establish real-cluster composition, consumer readiness or 200-attendee capacity. ## 1. Define convergence precisely -Change [spec/v1.md](../../spec/v1.md), [proposal 0004](0004-views-and-bytes.md), conformance and release -notes together. Define convergence over projected content excluding `metadata.resourceVersion`, -plus redaction records, unless explicit contract review chooses a different emission policy. - -Delivered RVs belong to delivered revisions, remain opaque in the browser and are valid conditional -write preconditions. Suppression does not refresh them; they promise neither freshness nor downstream -resume. Preserve snapshot completeness, pruning, ordering and per-connection redaction semantics. -Distinguish convergence after quiescence from equality while updates are in flight. Clarify spec §3 -that SSA needs a host-owned managed-field and omission policy; merge-patch helpers do not supply it. - -**Acceptance:** add final-write cases for ignored bookkeeping metadata and spec-only status churn: -no event, converged visible content, older held RV. Invariant, suppression and save guidance agree. -Release notes identify the narrowed invariant; unchanged emissions do not make this merely editorial. +Completed: [contract and executable final-write evidence](../../conformance/README.md#convergence-evidence). ## 2. Tested adoption recipes diff --git a/docs/saving.md b/docs/saving.md index 75afd2e..bd0b588 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -50,13 +50,51 @@ It does not grant write permission, choose a projection, fetch the object, issue optimistic concurrency. Those stay with the host. Do not use whole-object `PUT`: projected objects are intentionally incomplete, and a `PUT` can delete fields the browser never saw. -Every projection can hold an older `metadata.resourceVersion`: all suppress changes to stripped -bookkeeping metadata, and `krm-spec/v1` additionally suppresses status-only changes. A version -precondition remains safe: rejection prevents a lost update. Sustained invisible churn can prevent -save progress, but a 409 is a failed version precondition, not necessarily a disagreement at an -editable field. An accepted projected GET advances the base without requiring a snapshot. Render -actual draft conflicts separately; when none exist, explain the refreshed base and offer a newly -captured save. If reconciliation is refused, preserve the draft and recover before writing again. +## Why a quiet stream can still reject a save + +The gateway sends the fields your view needs. It suppresses updates that change only ignored fields +or `metadata.resourceVersion`. The browser keeps the version of the revision it actually received. +That version is still a valid conditional-write precondition, but it may already be out of date. + +```mermaid +sequenceDiagram + participant K as Kubernetes API + participant G as Gateway using krm-spec/v1 + participant S as Browser store + participant H as Your save endpoint + K->>G: Snapshot: replicas 2, status starting, RV 100 + G->>S: reset, added (replicas 2, RV 100), synced + S->>S: User edits draft to replicas 3 + K->>G: Final write: replicas 2, status ready, RV 101 + G->>G: Status omitted, visible comparison unchanged + Note over G,S: No event: server view stays replicas 2, RV 100
Draft stays replicas 3 + S->>H: On Save: patch replicas 3 with captured RV 100 + H->>K: Conditional PATCH with RV 100 + K-->>H: 409: version precondition failed + H-->>S: Report stale version, follow save recovery flow +``` + +The displayed server content is right even though the held version is older. The local draft is the +person's proposed change; it is not part of stream convergence. A failed version precondition does +not by itself mean the person and server disagree about an editable field. Follow the +[save outcomes](#what-the-person-editing-sees) to refresh, reconcile and capture a newly reviewed intent. + +| Final upstream change | What the gateway delivers | What the browser holds | +|---|---|---| +| Bookkeeping metadata only | No event in any built-in projection | Same visible content, older RV | +| Status only | No event under `krm-spec/v1`; an update under `krm-full/v1` | Spec view can keep an older RV; full view receives live status | +| Secret token rotates | Under `krm-full/v1` or `krm-spec/v1`, an update with a higher redaction revision | The token remains withheld; its change is visible | + +The RV labels above are illustrative opaque strings. Neither RV nor event `seq` provides downstream +resume: a new connection starts a complete snapshot. The +[normative contract](../spec/v1.md#6-ordering-delivery--the-state-guarantee) defines the exact comparison +and its guarantee at each delivered stream position; it does not promise zero transport latency. + +Sustained invisible churn can prevent save progress, but a 409 is a failed version precondition, +not necessarily a disagreement at an editable field. An accepted projected GET advances the base +without requiring a snapshot. Render actual draft conflicts separately; when none exist, explain +the refreshed base and offer a newly captured save. If reconciliation is refused, preserve the draft +and recover before writing again. Do not remove concurrency protection or blindly retry the old patch with a newer version. ## What the person editing sees diff --git a/gateway/stream_test.go b/gateway/stream_test.go index bd8a668..f0be5af 100644 --- a/gateway/stream_test.go +++ b/gateway/stream_test.go @@ -627,3 +627,51 @@ func TestAuthorizerDeniesBeforeTheWatchIsEverOpened(t *testing.T) { t.Fatal("unreachable") } } + +// The shared fixture pins full-projection bytes. Here every built-in projection must actively +// suppress the final bookkeeping write and retain the version of the delivered revision. +func TestFinalBookkeepingSuppressionAcrossProjections(t *testing.T) { + c := corpus(t) + base, err := c.Body("cm-app.v1") + if err != nil { + t.Fatal(err) + } + final, err := c.Body("cm-app.v2-bookkeeping") + if err != nil { + t.Fatal(err) + } + for _, projection := range []Projection{ProjectionRaw, ProjectionFull, ProjectionSpec} { + t.Run(string(projection), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + suppressed := 0 + gw := &Gateway{ + Auth: AllowAll{}, Projection: projection, + Clients: func(context.Context, string, Principal) (Backend, error) { + return &stubBackend{events: []WatchEvent{ + {Type: WatchAdded, Object: base}, + {Type: WatchBookmark, InitialEventsEnd: true}, + {Type: WatchModified, Object: final}, + }}, nil + }, + Observer: ObserverFunc(func(o Observation) { + if o.Kind == ObservationEventSuppressed && o.EventType == EventModified { + suppressed++ + cancel() + } + }), + } + sink := &recordingSink{} + err := gw.Stream(ctx, nil, Scope{Target: "demo", Version: "v1", Resource: "configmaps"}, sink) + if !equalTypes(types(sink.events), EventReset, EventAdded, EventSynced) { + t.Fatalf("final bookkeeping write emitted an event: %v", types(sink.events)) + } + if !errors.Is(err, context.Canceled) || suppressed != 1 { + t.Fatalf("final write: error=%v, suppressed=%d; want cancellation after one suppression", err, suppressed) + } + if got := mustJSON(t, sink.events[1].Object); got != mustJSON(t, base) { + t.Fatalf("held object = %s; want original delivered content and RV 1001", got) + } + }) + } +} diff --git a/packages/krm-stream/README.md b/packages/krm-stream/README.md index e4659f2..43c5d5e 100644 --- a/packages/krm-stream/README.md +++ b/packages/krm-stream/README.md @@ -81,6 +81,10 @@ explicit user retry. The low-level fetch and native EventSource connectors remai EventSource owns network reconnects but closes on sequence gaps. Use the managed connector for bounded recovery and observable lifecycle state. +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 +[normative contract](../../spec/v1.md#6-ordering-delivery--the-state-guarantee). + `store.captureSave(uid)` captures a detached patch, UID and base resourceVersion together. `store.captureReconciliation(uid)` guards a projected asynchronous response against newer watch state. See the [complete conditional-save example](../../examples/conditional-save/README.md) and diff --git a/packages/krm-stream/test/convergence.test.ts b/packages/krm-stream/test/convergence.test.ts new file mode 100644 index 0000000..3386a60 --- /dev/null +++ b/packages/krm-stream/test/convergence.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { applyStreamEvent, LiveResourceStore, SSEDecoder } from "../src/index.ts"; +import { body } from "./conformance.ts"; + +for (const scenario of [ + { + fixture: "final-bookkeeping-only", + upstream: "cm-app.v2-bookkeeping", + heldRV: "1001", + finalRV: "1002", + stripStatus: false, + }, + { fixture: "status-only-churn", upstream: "deploy-web.v2", heldRV: "3001", finalRV: "3002", stripStatus: true }, +]) { + test(`${scenario.fixture}: final visible content converges with an older held RV`, () => { + // These bytes are generated by the real gateway after the entire watch script is consumed. + // There is no later visible write or resnapshot to hide an incorrect final-state invariant. + const bytes = readFileSync( + new URL(`../../../conformance/gen/sse/${scenario.fixture}.sse`, import.meta.url), + "utf8", + ); + const events = new SSEDecoder().push(bytes); + assert.deepEqual( + events.map((event) => event.type), + ["reset", "added", "synced"], + ); + const store = new LiveResourceStore(); + for (const event of events) applyStreamEvent(store, event); + + const upstream = body(scenario.upstream); + const uid = upstream.metadata.uid; + const held = structuredClone(store.server(uid)); + assert.equal(held.metadata.resourceVersion, scenario.heldRV); + assert.equal(upstream.metadata.resourceVersion, scenario.finalRV); + assert.notEqual(held.metadata.resourceVersion, upstream.metadata.resourceVersion); + assert.deepEqual(store.redactions(uid), []); + assert.deepEqual(events[1]!.redacted, []); + + // Spell out the declared projection independently of the gateway's suppression digest. + delete upstream.metadata.managedFields; + const annotations = upstream.metadata.annotations as Record | undefined; + if (annotations) delete annotations["kubectl.kubernetes.io/last-applied-configuration"]; + if (scenario.stripStatus) delete upstream.status; + delete upstream.metadata.resourceVersion; + delete held.metadata.resourceVersion; + assert.deepEqual(held, upstream); + }); +} + +test("a final Secret rotation converges over redaction records at each delivered revision", () => { + const bytes = readFileSync( + new URL("../../../conformance/gen/sse/final-redaction-rotation.sse", import.meta.url), + "utf8", + ); + const events = new SSEDecoder().push(bytes); + assert.deepEqual( + events.map((event) => event.type), + ["reset", "added", "synced", "modified"], + ); + const store = new LiveResourceStore(); + const uid = "secret-token-0001"; + let upserts = 0; + for (const event of events) { + applyStreamEvent(store, event); + if (event.type !== "added" && event.type !== "modified") continue; + upserts++; + const upstream = body(upserts === 1 ? "secret-token.v1" : "secret-token.v2"); + delete upstream.data; + assert.deepEqual(store.server(uid), upstream); + assert.deepEqual(store.redactions(uid), [ + { path: ["data", "token"], rev: upserts }, + { path: ["data", "username"], rev: 1 }, + ]); + } + assert.equal(upserts, 2); + const initial = body("secret-token.v1-wire"); + const final = structuredClone(store.server(uid)); + delete initial.metadata.resourceVersion; + delete final.metadata.resourceVersion; + assert.deepEqual(final, initial, "redaction changed despite otherwise equal visible content"); +}); diff --git a/spec/v1.md b/spec/v1.md index 35b4d55..ac126be 100644 --- a/spec/v1.md +++ b/spec/v1.md @@ -118,7 +118,9 @@ Rules: - **The write path MUST NOT round-trip a projection.** A consumer MUST NOT send a whole-object `PUT` built from a projected object — it would delete every path the projection removed. Saves are a constrained patch (RFC 7386 merge patch or server-side apply) over the paths the consumer actually - edited. + edited. For server-side apply, the host MUST define a field manager, managed-field ownership + and omission policy; omitting a previously owned field can delete it. Merge-patch helpers do not + supply an SSA policy. ### 3.1 Why a redacted value is *omitted* and not *masked* — normative @@ -295,8 +297,9 @@ Normative: before `synced`. If the connection drops mid-cycle, the incomplete snapshot MUST NOT cause any pruning — the state from *before* the `reset` is retained until a cycle completes. -- **Gap-free handoff.** The gateway MUST guarantee that every state change occurring after a snapshot's - logical boundary is either delivered as a subsequent event **or** causes a new snapshot cycle. +- **Gap-free handoff.** The gateway MUST guarantee that every visible state change occurring after a + snapshot's logical boundary is either delivered as a subsequent event, subject to §6's coalescing + rules, **or** causes a new snapshot cycle. *How* it achieves this is the gateway's business and is deliberately absent from this contract. (Streaming lists, list-then-watch at a pinned `resourceVersion`, and an informer cache with a replay buffer are all conforming backends — see the [gateway guide](../gateway/README.md).) @@ -306,23 +309,39 @@ Normative: ## 6. Ordering, delivery & the state guarantee -The service is **state-convergent, not event-complete.** It does not promise to deliver every -intermediate Kubernetes transition; it promises that what you end up holding is right. +The service is **state-convergent, not event-complete.** Convergence is defined over each UID's +**projected content excluding `metadata.resourceVersion`, plus its `redacted` records**. Local drafts +are outside this invariant. **The primary invariant:** > For each logical stream: immediately after `synced`, and after each subsequent `added` / `modified` / -> `deleted` until the next `reset`, the consumer's authoritative object map equals the gateway's -> projected view of server state at the corresponding logical stream position. Any loss of continuity -> produces another snapshot cycle rather than silent continuation. - -Between `reset` and `synced` the consumer deliberately holds transitional state, and there is always -transport latency; neither is a violation. +> `deleted` until the next `reset`, the consumer's authoritative UID map MUST equal the gateway's +> projected server view at the corresponding logical stream position, comparing projected content +> excluding `metadata.resourceVersion` plus redaction records on that connection. Any loss of +> continuity MUST produce another snapshot cycle rather than silent continuation. + +*(Non-normative.)* A logical stream position is not the API server's wall-clock present: updates may +still be in transit or awaiting delivery. Once upstream changes stop and pending updates arrive, the +consumer converges to the final visible state under this comparison. This does not require upstream +quiescence for the positional invariant to hold. Between `reset` and `synced` the consumer deliberately +holds transitional state (§5). + +A completed snapshot MUST include every in-scope object even when its content is unchanged; pruning +still occurs only on `synced`. Delivery order and per-object monotonicity still apply. + +The gateway MAY suppress an update when the projected content excluding `metadata.resourceVersion` +and the redaction records equal the last delivered view for that UID in the current snapshot cycle. +This includes a **final** bookkeeping-only change in any built-in projection, and a final status-only +change under `krm-spec/v1`. With no event delivered, the consumer continues holding its previous RV. +A changed redaction record is a visible change, even if the projected object is unchanged. Redaction +revisions are per connection: a reset preserves them, while a new connection starts fresh (§3). What is guaranteed: - **No persistent ghosts.** A deletion the consumer missed is resolved by the next snapshot cycle. -- **No silently missed final state.** The last state of an object is always delivered or snapshotted. +- **No silently missed final visible state.** The final projected content and redaction records + MUST be delivered or snapshotted unless they already equal the held view under the comparison above. - **Ordered application within one stream.** SSE preserves order; there is no cross-stream ordering. - **Per-object monotonicity.** Within one snapshot cycle, the gateway MUST NOT emit a state for a uid that is *older* than one it already emitted for that uid. This is what makes coalescing safe (below) @@ -335,10 +354,14 @@ What is **not** guaranteed: - **Every intermediate version.** The gateway MAY coalesce rapid updates to the same uid and emit only the latest. Controllers rewrite `status` continuously; a browser that renders at 60Hz has no use for 200 intermediate `observedGeneration` bumps. Per-object monotonicity makes this safe: a coalesced - stream converges to the same final state. + stream converges to the same final visible state under the comparison above. **`resourceVersion` is opaque to consumers.** A consumer MAY echo `metadata.resourceVersion` back on -save for optimistic concurrency, and MUST NOT parse, compare, or order by it. +save for optimistic concurrency, and MUST NOT parse, compare, or order by it. Each delivered RV MUST +belong to the delivered object revision; it remains a valid conditional-write precondition, even if +suppression leaves it older than the API server's current RV. Valid does not mean fresh or guaranteed +to succeed: a stale precondition can be rejected even when visible content has converged. Neither an +object RV nor `seq` provides downstream resume; every new connection requires a snapshot (§7). > *(Non-normative — and the reason this protocol can promise per-object monotonicity at all.)* Within a > single upstream target, a modern Kubernetes API server's `resourceVersion` for a given **resource @@ -474,6 +497,7 @@ A **gateway** conforms iff: 8. It never emits, within a snapshot cycle, a state for a uid older than one already emitted. 9. It emits no SSE `id:` lines; it heartbeats; a `terminal` error is its last event and it then closes. 10. Its scope is allowlisted and server-normalized; it never accepts a raw API URL. +11. It satisfies the positional convergence invariant using the visible comparison in §6. > There is deliberately no rule here about rejecting a save. A gateway has no write path (§3) — the > save is the host's endpoint, and so is the duty to refuse a patch that touches a redacted path. A @@ -494,7 +518,7 @@ A **consumer** conforms iff: ### 10.1 The conformance tests that actually matter -These are the eight that catch real bugs; the rest are variations. +These ten scenarios catch the core contract failures. | # | Scenario | Expected | |---|---|---| @@ -506,3 +530,5 @@ These are the eight that catch real bugs; the rest are variations. | 6 | server removes a **nested** field (`spec.b`) | consumer's authoritative object no longer has it (replacement, not deep-merge) | | 7 | redact a `Secret` value, then save an unrelated field | the value is not on the wire at all, so it cannot be written back; the unrelated edit still saves normally. Keys-only disclosure survives via `redacted` (§3.1) | | 8 | run one randomized operation sequence through **two different backends** (streaming-list, informer fan-out) | both produce **identical** final projected consumer state | +| 9 | final bookkeeping-only change, or final status-only change under `krm-spec/v1` | no event is needed: projected content excluding RV plus redaction records converges, while the held RV remains older | +| 10 | final Secret value rotation with otherwise unchanged projected content | `modified` carries the incremented redaction revision; the hidden value remains absent |