Journal — 2026-05-13
2026-05-13 — Slice 6.3.A merged: demarkus-broker chart skeleton + security defaults
PR #115 merged to main as c71f499. First of four §6.3 sub-PRs. Lays the chart skeleton: Deployment + Service + config Secret + issuances Secret + ServiceAccount + values schema. Templates for RBAC, NetworkPolicy, PDB, Ingress, and the cert-manager Certificate ship in later slices but the values schema is declared up front so subsequent slices add templates without churning operator-facing values.yaml.
What landed
deploy/helm/demarkus-broker/— Chart.yaml + values.yaml + 7 starting templates (deployment.yaml,service.yaml,serviceaccount.yaml,secret-config.yaml,secret-issuances.yaml,NOTES.txt,_helpers.tpl).- Cookie-key resolution helper (
demarkus-broker.resolveCookieKeyin_helpers.tpl). Precedence: explicitserver.cookieKey→lookupof the live config Secret'scookie-keydata field → freshly-generated 32-byte base64. The lookup branch is what keeps the HMAC key (and therefore every in-flight state cookie) stable acrosshelm upgrade. The same$cookieKeyvariable feeds both the standalonecookie-keydata field AND thecookieKey:field embedded insideconfig.yamlin the rendered Secret — single value, single render pass, never drifts. - Issuances Secret seed (
secret-issuances.yaml). Renders an emptydata: {}Secret on first install withhelm.sh/resource-policy: keepon the metadata. Subsequent installs/upgrades hit thelookup-skip branch so helm sees no rendered resource and the keep-annotation on the LIVE Secret prevents helm GC. Net effect: chart updates never touch broker-minted issuance state. - OIDC client-secret schema, mutual-exclusion guarded at render-time.
oidc.clientSecret(cleartext via values) andoidc.existingSecretRef.name(operator-managed Secret) are mutually exclusive. Setting both failshelm templatewith a clear message; setting neither fails. Render-time fail-fast rather than pod-startupCreateContainerConfigErroris the whole point — the binary'sapplyEnvOverridesenv-var path arrives in §6.3.C, so 6.3.A ships the cleartext path only but pins the mutual-exclusion guard now. - PodSecurityContext + container securityContext lockdown.
runAsNonRoot: true,runAsUser: 65532,runAsGroup: 65532,fsGroup: 65532,seccompProfile: RuntimeDefault,readOnlyRootFilesystem: true,allowPrivilegeEscalation: false,capabilities: drop ["ALL"]. Five hardening defaults pinned in values.yaml; a "convenience" relaxation in any one of them should surface as a test failure in the §6.3.D helm-unittest sweep. - TopologySpreadConstraints by
kubernetes.io/hostname,whenUnsatisfiable: ScheduleAnyway. Two replicas spread across nodes when possible; single-node clusters still schedule. The emptymatchLabels: {}in values.yaml is intentional — the deployment template overrides it withselectorLabelsso the constraint always targets the right pods even if a future operator mistakes the empty default for "fill these in." Easy to break in a refactor; worth pinning in §6.3.D. checksum/configpod annotation so a config Secret change triggers a rollout. Without it, broker pods stale-mount after a config update.rateLimit.trustForwardedFor: truechart default, inverting the binary'sfalsedefault (C.4 §Decisions). The chart's deployment assumption is "behind an Ingress that strips spoofed X-Forwarded-For"; flipping the binary default at the chart level keeps the per-IP/auth/loginlimiter effective. Operator-facing docs in the chart README call out the failure mode (flipping the flag in a directly-internet-exposed broker).
Decisions that landed
- Explicit
metadata.namespaceon both config and issuances Secrets. CodeRabbit flagged that omitting it lets helm default to the release namespace silently — fine for the default case, surprising when an operator setsserver.brokerNamespaceto put the issuances Secret elsewhere. Both Secrets now resolve their namespace through thedemarkus-broker.brokerNamespacehelper (which falls back to.Release.Namespace), so lookup + render always agree. worldAllowsnil-.allowtemplate guard. CodeRabbit found thesecret-config.yamlrange .Values.worldsblock would panic when a world omitted theallow:block entirely (the production-meaningful "all empty = open world" predicate path from C.1). Wrapped with{{- $allow := default (dict) .allow }}so.allow.domainsetc. each getdefault (list)fallbacks. Pre-fix the chart aborted; post-fix the rendered config emits empty arrays for all three dimensions and the binary'sworldAllowspredicate takes the back-compat all-empty match.lookup-skip namespace consistency. The issuances Secret'slookupcall uses the samebrokerNamespacehelper as the rendered metadata, so operators that overrideserver.brokerNamespaceget the lookup hitting the correct namespace and the keep-annotation surviving across upgrades. CodeRabbit fix.
Next: §6.3.B — RBAC (per-world Roles + broker-namespace Role for sweeper Lease + issuances Secret), NetworkPolicy default-on, PodDisruptionBudget minAvailable: 1. Schema declared in A; templates ship in B.
2026-05-13 — Slice 6.3.B merged: per-world RBAC, broker-namespace RBAC, NetworkPolicy, PodDisruptionBudget
PR #116 merged to main as 175e230. Second of four §6.3 sub-PRs. Adds the RBAC + NetworkPolicy + PDB templates whose values schema 6.3.A pre-declared. Two new template files for RBAC (broker-ns and per-world) plus one each for NetworkPolicy and PDB.
What landed
rbac-worlds.yaml— per-world Role + RoleBinding loop.range .Values.worldsemits N×2 resources, each Role scoped to a singleresourceNames: [<tokensSecret>]with verbsget/update(NOTcreate). The world's tokens Secret must already exist from the §6.1 server chart's bootstrap Job; the broker only needs to read+patch, never create. If a world's Secret is missing at first mint the broker surfaces a clear "Secret not found" error rather than silently bootstrapping into a namespace it shouldn't own.rbac-broker-ns.yaml— broker-namespace Role + RoleBinding. Two rules:coordination.k8s.io/leasesget/create/updatescoped toresourceNames: [<sweeper.leaseName>](the C.2 leader-election Lease), andsecretsget/updatescoped toresourceNames: [<issuancesSecret>]. The leases rule includescreateso first-leadership-acquisition can seed the Lease — the k8s RBAC caveat "create cannot be restricted by resourceName" means create effectively allows any Lease in the namespace, acceptable scope since the namespace is broker-owned. The doc-comment in the template spells this out so a future tightening to justupdate+getdoesn't break first-install silently.networkpolicy.yaml— default-on (networkPolicy.enabled: true). Ingress allows traffic only from the configuredingressFromNamespace(defaultingress-nginx) on the server port via the automatickubernetes.io/metadata.namelabel (k8s 1.21+). Egress allows DNS tokube-system(UDP+TCP 53) and unrestricted TCP 443 — the latter intentionally NOT pinned to apiserver/OIDC issuer CIDRs because pinning breaks across managed-k8s providers and every IdP frontend rotation. Doc-comment notes the failure mode for self-hosted OIDC on non-443 (e.g. Keycloak) and older clusters lacking the automatic namespace label.poddisruptionbudget.yaml— defaultminAvailable: 1. With two replicas and PDB-1, a single voluntary disruption (node drain, helm upgrade rollout) leaves the broker servable. Cheap insurance against in-cluster maintenance churn.serviceAccount.workloadIdentity.gsavalues plumbing into the ServiceAccount annotations. Off by default; setting it stampsiam.gke.io/gcp-service-accounton the SA so GKE Workload Identity bindings can map broker→GSA without static keys. Validated values-shape only; integration is operator-side.
Decisions that landed
rbac.create: trueas the default so opinionated installs work out of the box; flippable to false for clusters that manage RBAC out-of-band (Argo CD AppProject, OPA, GitOps Roles). Same shape asserviceAccount.create: true.- Verbs
get/updateon world tokens Secrets, NOTcreate. Pinning the minimal set keeps the per-world Role aligned with what the broker actually does (read-modify-write viamutateSecret'sresourceVersionretry). A future refactor that addscreateshould land with a test failure so the question "do we want the broker bootstrapping world Secrets?" gets re-asked deliberately. §6.3.D helm-unittest will pin this. - NetworkPolicy egress unrestricted on TCP 443 by design. Restricting it to specific CIDRs trades the egress-blast-radius gain for an upgrade-and-multi-cluster brittleness that's worse in practice (apiserver IP changes per cluster; IdP frontend rotation is on-call paging territory). The default-on ingress namespace scope is the dominant security gain; egress 443 wide-open is the operator-friendly tradeoff. Documented in the doc-comment.
Next: §6.3.C — Ingress + cert-manager Certificate + oidc.existingSecretRef plumbing via a new OIDC_CLIENT_SECRET env-var override path in broker.LoadConfig.applyEnvOverrides. The env var is a small additive surface on §6.2's "broker binary complete" framing — explicitly a one-line escape hatch for the production-secret-ref deployment shape, not a feature-set expansion.
2026-05-13 — Slice 6.3.C merged: Ingress + cert-manager Certificate + external OIDC secrets
PR #117 merged to main as 599f63a. Third of four §6.3 sub-PRs. Adds Ingress + cert-manager Certificate templates and the oidc.existingSecretRef deployment path — the production shape where the OAuth client secret lives in an externally-managed Kubernetes Secret (External Secrets Operator, Sealed Secrets, Vault) instead of being baked into the chart-rendered config Secret via helm release history. Pairs the chart-side wiring with a small additive surface on the broker binary: an OIDC_CLIENT_SECRET env-var override in broker.LoadConfig.
What landed
ingress.yaml— opt-in (ingress.enabled: falsedefault). When enabled, requiresingress.host; supports three TLS modes mutually-exclusive at the template level:tls.existingSecret(operator-providedkubernetes.io/tls),tls.certManager.enabled: true(chart provisions a cert-manager Certificate viacertificate.yaml), or neither (Ingress controller terminates TLS externally). DefaultingressClassName: nginx; default path/Prefix.annotationsblock left to the operator for controller-specific knobs (proxy-body-size,cert-manager.io/cluster-issuer, etc.).certificate.yaml— cert-managerCertificateresource conditionally rendered when bothingress.enabled: trueandingress.tls.certManager.enabled: true. Targets the chart-named TLS Secret (<fullname>-tls); the Ingress references the same name so cert-manager writes the Secret and the Ingress picks it up by reference. DNS01 vs HTTP01 lives on the issuer, not on the Certificate resource — issuer config is operator-side.oidc.existingSecretRefvalues block (name + key). Whennameis set, the deployment template renders anOIDC_CLIENT_SECRETenv on the container viasecretKeyRefpointing at the operator-managed Secret, AND the rendered config.yaml leavesclientSecret: ""blank so the env-var override path is the unambiguous runtime source.broker.LoadConfig.applyEnvOverrides()(tools/demarkus-broker/internal/broker/config.go:262) — pure additive helper invoked between YAML parse andvalidate(). Today onlyOIDC_CLIENT_SECRETis recognized. Env wins over file when both set; empty env treated as unset so an accidentally-cleared variable doesn't blank out a file-supplied value at runtime. Net: production deployments keep the OAuth secret out of helm release history without changing the binary's startup contract.- README expanded with production checklist (Ingress + cert-manager + existingSecretRef pattern) and the
rateLimit.trustForwardedFor: trueinvariant call-out (only safe behind an XFF-stripping Ingress; the failure mode of flipping it on a directly-internet-exposed broker is the documented footgun). NOTES.txtpost-install message summarizing rendered endpoints, RBAC scope, TLS mode, and the OIDC redirect URI the operator needs to register at the IdP.
Decisions that landed
existingSecretRef.keyrequired at render-time whennameis set. Render-time fail-fast on a blankkeyinstead ofCreateContainerConfigErrorat pod schedule. Three fail-render guards now live insecret-config.yaml(clientSecret + existingSecretRef.name both set, neither set, name set but key blank); §6.3.D will pin all three so the guards never regress to silent precedence.tls.existingSecretandtls.certManager.enabled: truemutually exclusive. Allowing both would render an Ingress pointing at one Secret whilecertificate.yamlprovisions a different Secret — silent TLS-source ambiguity. Render-timefailkeeps the contract unambiguous. CodeRabbit fix.- Env wins over file, empty env is unset. The two-line
applyEnvOverridesbody is small enough that the test surface (4-rowTestLoadConfigOIDCClientSecretEnvOverrideinconfig_test.go) is the primary documentation of the contract. Critical row: "empty env does not clobber file value" — deterministic viat.Setenv("OIDC_CLIENT_SECRET", "")so the test asserts the empty-as-unset property regardless of whether the parent CI shell happens to have the env var exported.
Framing: env-var override is not a §6.2 reopening. It's a one-line escape hatch for the production-secret-ref deployment shape, not a feature-set expansion. §6.2 "broker binary complete" stands; the plan v14 bump will capture this as a §6.3 §Decisions bullet with a footnote on §6.2 §Status rather than reopening §6.2 in §Sequencing.
Test surface — TestLoadConfigOIDCClientSecretEnvOverride 4 rows: env overrides file, env supplies value when file empty, empty env does not clobber file value, no env + no file → validate() rejects oidc.clientSecret is required. Coverage stable at 83.4%.
Next: §6.3.D — helm-unittest test files for the broker chart covering the chart invariants (resource-policy: keep, RBAC verbs, NetworkPolicy egress/ingress shape, OIDC mutual-exclusion guards, OIDC_CLIENT_SECRET env wiring, securityContext lockdown, topology spread, checksum/config), CI plumbing to run helm-unittest against both demarkus-server and demarkus-broker charts on PR, and folding in a test-broker Go-test job (the §6.2 broker binary currently has zero CI coverage — .github/workflows/ci.yml's detect-changes only filters protocol/server/client, not tools/). Kind upgrade-wipe regression test deferred to a sidecar PR bundled with the §6.1 chart's missing helm.sh/resource-policy: keep annotation on the demarkus-server tokens Secret — plan v13 §6.1 prose claims it's set but deploy/helm/demarkus-server/templates/tokens.yaml ships without it. Real prior-work debt; tracked separately so §6.3.D isn't blocked behind the §6.1 fix.
2026-05-13 — Slice 6.3.D.1 merged: chart unittest suites + CI plumbing, broker binary CI gap closed
PR #118 merged to main as part of §6.3 broker chart work. Fourth and last sub-PR for §6.3 chart proper. One CodeRabbit round with five comments (four inline + one out-of-diff); all addressed without pushback — none were strategic enough to warrant a defense.
What landed
deploy/helm/demarkus-broker/tests/— seven helm-unittest test suites covering the chart's load-bearing invariants: Deployment (replicas, security context lockdown, HTTP probes, POD_NAME env, OIDC_CLIENT_SECRET env wiring in both modes, populated topology-spread matchLabels,checksum/configrollout-on-config-change), broker-ns + per-world RBAC (verbs scoped toget/update-not-create, lease verbsget/create/update,rbac.create=falseskip), NetworkPolicy (default-on, ingress namespace scoping, DNS+443 egress shape), Secret config (rate-limit + sweeper defaults explicitly rendered, OIDC mutex guards × 3 fail-render cases, world-without-allow-block doesn't panic), Secret issuances (helm.sh/resource-policy: keep+ brokerNamespace helper consistency), Ingress + cert-manager Certificate (TLS mode mutex, host-required, className override), PodDisruptionBudget (defaultminAvailable: 1, selector labels). 49 tests across 7 suites..github/workflows/ci.yml— addedbroker(tools/demarkus-broker/**+tools/go.mod+tools/go.sum+protocol/**) andcharts(deploy/helm/**) paths to the change-detection filter. Newtest-brokerjob: Go test/vet/lint/build fortools/demarkus-broker/withworking-directory: tools/demarkus-broker(narrowed past the broadertools/scope to avoid lint failures ontools/tools.go). Newtest-chartsjob:helm linton all three charts +helm unitteston server + broker. Both new jobstimeout-minutes: 20. helm pinned to v3.13.2; helm-unittest pinned to v0.6.2 (compatible across the local helm version).deploy/helm/demarkus-server/tests/statefulset_test.yaml— fixed real prior-work bug. The existing suite only loadedstatefulset.yaml, so the StatefulSet template'sinclude "...tokens.yaml"for thechecksum/configannotation failed render and all 13 tests errored out. Addedtokens.yamlto the suite templates list +template: statefulset.yamlper-test to scope assertions. Nobody had noticed because helm-unittest never ran in CI. The §6.1 chart-test claims in plan v13 weren't actually executing — until now.
Decisions / observations
- Fold-in of
test-brokerwas the right call. The handoff's §6.3.D scope proposed adding helm-unittest to CI + flagged separately that the §6.2 broker binary had zero CI coverage. Folding the binary-CI job into the same PR meant the broker chart shipped paired with a binary that finally has real CI gates. Without the fold-in we'd have had the absurdity of testing the chart's behavior in CI while the binary it wraps remained CI-uncovered. - helm-unittest v0.6.2 over v1.x. v1.0+ uses
platformHooksin plugin.yaml which needs helm v3.16+; Fritz's local helm is v3.13.2. Pinning v0.6.2 in CI (matching the local-dev validation surface) keeps the test behavior identical between developer machines and CI. Bump together later when helm itself bumps. - Per-test
template:scope is required when one templateincludes another. The sharedchecksum/config: {{ include "...secret-config.yaml" }}pattern (and the §6.1 chart's analogoustokens.yamlinclude) means tests for the parent template must list BOTH templates in the suitetemplates:filter, and then narrow each test's assertion target via the singulartemplate:field. Discovered while debugging the brokerdeployment_test.yaml; the same fix retroactively rescued the §6.1 statefulset suite.
CodeRabbit comments addressed (all five, no pushback)
- Broker path filter narrowing —
tools/**→tools/demarkus-broker/**+tools/go.mod+tools/go.sum. CodeRabbit's static analysis confirmedtools/tools.gosits outside the broker package; the broad filter would trigger broker CI on unrelated tool changes. - Job timeouts —
timeout-minutes: 20ontest-brokerandtest-charts. Default GitHub Actions timeout (360min) is too lenient for these jobs. - golangci-lint scope —
working-directory: tools→tools/demarkus-broker. Same reasoning as #1; verified locally that golangci-lint walks up totools/go.modfor module resolution. - CLAUDE.md "Records" fragment removed — line 38 had a malformed header ("Records (one per decision, zero-padded 4-digit sequence)") followed by a sub-bullet describing journals not records. Collapsed into a single journal entry matching the surrounding flat-list style. Surfaced to Fritz that a separate ADR-style Records concept can be added back if intended.
- CLAUDE.md journal-path contradiction (out-of-diff) — line 27 said
/journal.md, line 39 said/<project>/journal/<YYYY-MM-DD>.md. Updated line 27 to match the dated convention. Caveat flagged: this made CLAUDE.md aspirational ahead of the soul, which still had the legacy/journal.mdshape at PR-merge time. Migration to dated files happens in this very session, immediately after PR merge.
Prior-work debt surfaced (still open, sidecar PR §6.3.D.2 scope)
- §6.1 demarkus-server
tokens.yamlSecret missinghelm.sh/resource-policy: keep. Plan v13 §6.1 §Status claims the annotation is set and a kind integration test pins the upgrade-wipe regression. Reality at PR-merge time: the annotation is NOT on the rendered Secret and no kind test exists in CI. Real bug in shipped §6.1 chart; bundle the fix with the §6.3.D.2 kind harness so the regression test catches it directly. - §6.2 broker binary had zero CI coverage at the start of this session. Closed by the
test-brokerjob in this PR; surfacing it here so the gap doesn't get lost in the diff. - helm-unittest wasn't running anywhere. The §6.1 chart's existing test files weren't executed by CI, AND the
statefulset_test.yamlsuite was structurally broken from the day it was written. Both fixed in this PR. Lesson: chart-test files in the repo don't equal CI test coverage of charts; the workflow plumbing matters at least as much as the test files.
Next: §6.3.D.2 sidecar — kind upgrade-wipe regression for both charts, bundled with the §6.1 helm.sh/resource-policy: keep + helm.sh/hook: pre-install fix on demarkus-server/templates/tokens.yaml. Test shape: install chart, kubectl-patch the rendered tokens/issuances Secret to seed fake data, helm upgrade with a values change, assert seeded data persists. Broker pod doesn't need to be ready for the property to hold — kind harness can skip OIDC discovery via a values stub. Plan bump to v14 after §6.3.D.2 lands so v14 captures coherent §6.3-complete landed-state.
2026-05-13 — Journal migrated to dated per-day files
Operational note. Until this session, all session notes lived in a single growing /journal.md file (v57 at migration time, ~140KB / 51 entries across 23 unique dates). PR #118 merged a CLAUDE.md edit committing to a /journal/<YYYY-MM-DD>.md per-day convention; this entry is the first written under that convention.
Migration mechanic: the legacy /journal.md body was parsed by ## YYYY-MM-DD — heading boundaries, grouped by date (multiple entries on the same date stayed in one file as sequential ## sections), and published as /journal/<YYYY-MM-DD>.md for each unique date. Same content, different topology. The old /journal.md was kept (mark_archive not used — the version history at the legacy path is still readable) and /index.md updated to point at the new directory.
Why per-day: a 140KB single file was bumping fetch-result size limits (had to be saved to file rather than returned inline; agents had to read the saved file in chunks). Per-day files cap at ~40KB worst case (2026-05-12 was the longest day, with 5 entries totaling 38KB) and most days are well under 10KB. Future fetches by date go straight to the right file rather than scrolling a 51-entry catalog.
Convention going forward: mark_append to the current date's file when adding a session note. If the file doesn't exist yet, mark_publish it. The CLAUDE.md "End of Session" line points operators (and agents) at the right path.
2026-05-13 — Slice 6.3.D.2 merged: §6.1 server chart resource-policy fix + kind upgrade-wipe regression
PR #119 merged to main as 3d3b4bc. Sidecar to §6.3.D.1; closes the §6.1 prior-work debt that earlier plan versions claimed was already in place. Lands the helm.sh/resource-policy: keep annotation on the demarkus-server tokens Secret AND an end-to-end kind regression that proves the upgrade-wipe property — for BOTH charts — rather than re-asserting it in prose. With this, all five §6.3 sub-slices (A/B/C/D.1/D.2) are merged on 2026-05-13 and §6.3 broker chart work is fully complete.
What landed
- Three-branch hybrid template at
deploy/helm/demarkus-server/templates/tokens.yamlkeyed on the live Secret's state:- First install (
lookupreturns nil) — render fresh content withhelm.sh/resource-policy: keep.$rawTokenresolves to a freshly-generated admin token; both the tokens Secret and the token-values Secret ship with consistent raw+hash. - Legacy upgrade (Secret exists but LACKS the keep annotation) — render the EXISTING data verbatim plus the keep annotation. Migration path from §6.1's original template (PR #107) which shipped without the annotation; without this branch, the §6.1.D.2 lookup-skip branch would emit nothing, helm would diff old-annotation-less against new-absent, and DELETE the Secret on apply, wiping every broker-minted user-token entry along the way. Re-rendering existing data makes helm's apply a content no-op while the annotation lands so future upgrades flip to branch 3.
- Race-free no-op (Secret exists AND has the keep annotation) — emit nothing. helm sees the Secret removed from the rendered manifest, would normally GC it, but the live annotation blocks deletion. After the first migration, upgrades NEVER touch the Secret content, leaving the broker as the sole writer and eliminating any race window between
helm templateandhelm apply.
- First install (
deploy/helm/test-upgrade-wipe.sh— 142-line generic kind upgrade-wipe regression. Usage: install chart with values stub, kubectl-patch the rendered Secret to seed fake data, helm upgrade with a values change, assert seeded data persists, uninstall, assert Secret survives uninstall under the keep annotation.SIMULATE_LEGACY_NO_KEEPenv flag exercises the migration path by stripping the annotation from the live Secret between install and upgrade — the test then asserts the legacy-upgrade branch re-applies the annotation AND preserves existing data.deploy/helm/demarkus-server/tests/tokens_test.yaml— three new assertions added (10 total tests in the file now): tokens Secret carrieshelm.sh/resource-policy: keep, token-values Secret carries the same, and the rendered Secret has explicitmetadata.namespaceso thelookup-skip branch checks the right namespace on upgrade (rather than helm's release-namespace default which silently misaligns when an operator deploys with--namespace)..github/workflows/ci.yml— newtest-charts-kindjob (47 lines). Spins up a kind cluster, runstest-upgrade-wipe.shthree times: server normal mode + serverSIMULATE_LEGACY_NO_KEEPmode + broker normal mode. Broker pod does NOT need to be ready (OIDC discovery is irrelevant to the upgrade-wipe property); the harness uses a values stub. Job runs only on charts-path changes.
Decisions
- Three-branch hybrid over "always render" (Option A). Option A keeps helm "managing" the Secret on every upgrade — helm template renders content (from lookup), helm apply writes it. That opens a race window: between helm's lookup snapshot and the apply, the broker is concurrently writing via
mutateSecret(resourceVersion optimistic concurrency). Apply uses last-writer-wins on fields not under strategic-merge-patch, so a concurrent broker write between lookup and apply gets clobbered. The race-free branch 3 of the hybrid sidesteps this entirely: helm emits nothing, the live keep annotation blocks GC, broker stays the sole writer. The cost is a one-time legacy branch for migration; once a release passes through branch 2, all subsequent upgrades hit branch 3. SIMULATE_LEGACY_NO_KEEPenv flag rather than a separate script. Same regression script exercises both the normal three-branch path AND the migration path. Two scripts would have drifted in the assertion shape; one script with a flag keeps the property-under-test identical across modes.- CI runs three matrix entries, not nine. Server normal + server legacy + broker normal covers the property. Broker doesn't have a legacy state to migrate from (it shipped with the keep annotation from §6.3.A day one), so a broker legacy mode would test nothing. Agent chart doesn't have a stateful Secret to wipe — out of scope.
CodeRabbit rounds
Two rounds during PR #119 review.
- Round 1 caught two critical issues. (a) The original draft of
tokens.yamlhad only two branches (lookup-skip + first-install) — it would have wiped Secrets on the FIRST upgrade of any pre-D.2 release because legacy Secrets had no keep annotation, so lookup-skip would emit nothing, helm would diff against the missing-from-rendered-manifest state, and delete. The three-branch hybrid was the response: branch 2 catches the legacy state, re-renders existing data verbatim plus the annotation, then future upgrades land on branch 3. (b) The original test script didn't exercise the legacy-upgrade path at all — only the first-install + race-free no-op.SIMULATE_LEGACY_NO_KEEPwas the response: identical script with an env flag that strips the annotation between install and upgrade to simulate a pre-D.2 release's state, then asserts the legacy branch re-applies the annotation while preserving data. - Round 2 caught a
kubectl jsonpathissue. The original script used dot-notation for the data field lookup:kubectl get secret ... -o jsonpath="{.data.tokens-toml-b64}". The hyphenated key happens to work on kubectl's permissive parser, but isn't spec-conformant — a stricter k8s version or a downstream tool reading the same expression could break silently. Switched to bracket form:{.data['tokens-toml-b64']}. Empirically the dot form was passing; the bracket form is defensive against the permissive-parser regression.
Test surface
deploy/helm/demarkus-server/tests/tokens_test.yaml: 10 tests total, 3 new (keep annotation × 2 Secrets + explicit namespace).deploy/helm/test-upgrade-wipe.sh: end-to-end kind cluster + helm install/upgrade/uninstall + Secret data assertion. Doesn't add test counts; adds a property that prose can't carry.test-charts-kindCI job: three matrix entries (server normal + server legacy + broker normal).
Resolved risks
- The §6.1 chart upgrade-wipe gap that plan v14 carried as an open risk (the prose claim of "
resource-policy: keepis in place" vs. the reality of the missing annotation) is now resolved — the kind regression pins the property end-to-end so the prose is no longer load-bearing. Plan v15 risk entry can be removed or downgraded to "resolved (pinned by kind regression)". - The §6.1 chart-test plumbing latent failure surfaced in §6.3.D.1 is also closed end-to-end: the unittest suites run in CI AND the kind harness exercises the live-cluster behavior the unittests can't reach (lookup against an actual Secret, helm upgrade flow, uninstall).
Next
§6.3 fully complete. Phase 6 remaining sub-phases: §6.4 universe topology examples (ApplicationSet + Kustomize overlay), §6.5 observability recipes (Datadog / OTel Collector / Vector / Fluent Bit / Grafana Alloy), §6.6 documentation suite (install / security / operations / observability / per-chart READMEs), §6.7 release pipeline (GHCR images + OCI charts, multi-binary server image, CLI relocation to tools/). Open question for Fritz: order of attack. §6.7 is the most-unblocking — without it, §6.4 examples and §6.6 docs both reference not-yet-released artifacts. §6.5 is independent of §6.7. Likely sequence: §6.7 → §6.5 (parallel-able) → §6.6 incremental → §6.4 last. Waiting on Fritz to pick.