Pathway: Route a chart's hooks, transparently

A repository document, rendered for the site. View source markdown.

Generated at: 2026-07-30T12:38:02.000Z UTC · source: committed helm-expt evidence for this rendered repository document.

_A journey pathway (UX test) in the helm-expt test map._

UNOFFICIAL/EXPERIMENTAL. This is the first of the user-journey test pathways (see docs/planning/user-journey-test-pathways-brief.md). A test pathway is a named, runnable walkthrough defined by (persona) × (the decisions they make) × (a class of charts). This one covers the hook / lifecycle chart class. It answers the real question a user asks - "How do I run THIS chart (which has a hook) with cub, and what happens to the hook - can I see and approve it, or does it run silently?" - and it doubles as the convince-the-team artifact for the hook-handling method.


The user and the question

Hooks are the hardest case for the Generative GitOps thesis (docs/user/generative-gitops-fit.md, why-this-does-not-collapse.md): they are the last hidden imperative step in a Helm install. Render parity can prove every non-hook object, but a hook is, by construction, a thing Helm runs for you - a Job, a test, a delete-policy cleanup - often privileged, usually invisible to your desired-state config. If config-as-data leaks anywhere, it leaks here.

The method this pathway validates: render parity proves the non-hook object set; each hook is re-expressed as an explicit, non-automatic lifecycle route - visible, runnable, and receipted (automatic: false).


The decision sequence

Each step names the cub / cub-scout move and states the honest disposition - including where the proof stops.

1. Pick a base → render parity proves the non-hook objects

Render the chart's default base and compare object-for-object against upstream Helm. For fluent/fluent-bit@0.57.6 / default the render-parity lane passes and the chart reaches the live-parity outcome level (data/master-catalog-matrix/matrix.csv, data/outcome-coverage/base-outcomes.csv). Disposition: pass - the steady-state object set (the DaemonSet, Service, RBAC, ConfigMap) is proven equivalent. This is the claim render parity is allowed to make, and no more (docs/user/what-we-refuse-to-claim.md).

2. Separate the hook from the steady state

A hook is not part of the steady-state set. Fluent Bit's test hook is recorded as a lifecycle route-action (data/lifecycle-route-actions/actions.csv):

fieldvalue
quirk_classhook-test
route_nameexplicit-test-check
lifecycle_phaseobserve
action_kindrun-test
execution_modeuser-executes
automaticfalse
evidencedata/hook-lifecycle/receipts/fluent-fluent-bit/default/latest.yaml

So the hook is classified and named, not silently bundled into the apply. Across the whole catalog, 0 of 25 route-actions are automatic - nothing claims the product runs a hook for you (data/lifecycle-route-actions/summary.md). That honesty is what makes the model credible.

3. Day-1 preview → see desired-vs-live before anything runs

Before applying or running anything, preview the change against the live cluster, field-level, with no controller in the loop:

cub-scout compare three-way --dry-from <rendered-objects-dir> --scope namespace/<ns> --format json --fail-on warning

This is shipped today (cub-scout v2.5.0; compare three-way --dry-from, read-only, tool-agnostic). The set-level cub-scout compare object-set --dry-from / ObjectSetDiffReceipt that aggregates per-object deltas into one signed receipt is forthcoming (design: cub-scout docs/proposals/object-set-diff.md) - cite it as "coming," do not invoke it yet. Disposition: a dry-run preview is a preview, not a guarantee the apply will converge - but it is strictly better than applying blind.

4. Run the hook - explicitly, visibly, with a receipt

The test hook runs as its named route (run-test), deliberately, and writes a receipt. The committed live evidence for the real chart is fluent-bit's HookLifecycleRouteReceipt (data/hook-lifecycle/receipts/fluent-fluent-bit/default/latest.yaml):

route:
  summary: The chart hook is a test hook and is routed to an explicit post-install check.
execution:
  helmHooksExecutedByHarness: false      # the product did not silently run a Helm hook
  observedRoute: { daemonSetReady: true, serviceEndpointReady: true, healthEndpoint: pass }

The mechanism is proven self-contained by npm run hook-test:proof (scripts/run-hook-test-proof.mjs on a throwaway kind cluster; summary at data/hook-test-proof/summary.md, receipt at runs/hook-test-proof/receipt.yaml):

Delivery pathtest hookresult
helm template (Argo-style render; no run step)hook-annotated (separable)pass
helm install (workload applied)skipped (Helm does not run it)pass
helm test (explicit run-test route)executed (recorded result)pass

Disposition: observed - there is a live receipt. Re-run --verify to confirm it stays fresh (the verify path re-derives the summary from the receipt and byte-compares).

5. Where the proof stops (honest disposition)

Fluent Bit's test hook is observed and routed. The broader lifecycle lane is not finished: 62 lifecycle cells are defined-but-not-yet-observed (data/matrix-completion-audit/summary.md: lifecycle lane = 62, each "route defined, not yet observed live → observe and record a receipt"). The pathway is honest about this: a route being known is not a route being observed, and neither is automatic.


Visible vs. silent (the convince-the-team table)

The same test hook, under each delivery tool:

A colored, self-contained version of this comparison is at data/hook-test-proof/visible-vs-silent.html.


The argument

Silent hook execution is the one place the config-as-data promise leaks: a privileged Job you never saw and never approved, run for you at install time. Routing hooks - visible, runnable, receipted, automatic: false - is the natural completion of the same thesis, not a different one. You can still automate a hook: wire it into your own GitOps test/PostSync step. But then you do it knowingly, with an audit trail, instead of inheriting a hidden imperative step. The honesty that earns trust: 0 of 25 route-actions claim automatic execution; helmHooksExecutedByHarness: false everywhere.

Falsification

The method fails if there is a hook that cannot be expressed as a route - one that genuinely needs silent execution. None has been found across the hook classes. The blocked routes (7 of 25) are preconditions - missing images, CRDs, or dependencies - not un-routable hooks (data/lifecycle-route-actions/actions.csv, data/hook-disposition/evidence/). Finding a genuinely un-routable hook would refute the model; not finding one, across every class below, confirms it.


How this generalizes (the hook-class span)

This pathway is the test-hook instance. The same shape (render parity for the non-hook set + explicit routed hook + receipt + visible-vs-silent table) covers the other hook classes, each of which already has - or honestly lacks - a committed live receipt:

Hook classChartCommitted receipt?
test hookfluent/fluent-bit✅ (this pathway)
cleanup / delete-policykyverno/kyverno · projectcalico/tigera-operator✅ (tigera actually ran the upstream Job, helmHooksExecutedByHarness: true)
CRD-bootstrapgatekeeper/gatekeeper
webhook-certprometheus-community/kube-prometheus-stack
post-apply provisioning Jobkafka / minio classnone yet - image-blocked precondition; the honest gap

The post-apply-Job class is the open edge: its candidates are blocked on purged upstream image tags (a precondition), not on un-routability - which is itself evidence for the model, recorded honestly rather than claimed.


Reproduce / verify

npm run hook-test:proof          # live: spin a throwaway kind cluster, write receipt + summary
npm run hook-test:proof:verify   # no cluster: re-derive the summary from the receipt, byte-compare

Where this sits in the model