From 6f7e7c0cbebeefff84380d059970533bedbdb9bb Mon Sep 17 00:00:00 2001 From: Terry Tata Date: Sat, 5 Sep 2026 12:00:49 -0700 Subject: [PATCH 1/3] fix(verifier): reject [policy_hook] in Chainlink-node mode at boot --- .../pkg/constructors/committee_verifier.go | 32 ++++++------- integration/pkg/constructors/options.go | 46 ------------------- verifier/docs/policy_hook.md | 7 +-- 3 files changed, 19 insertions(+), 66 deletions(-) delete mode 100644 integration/pkg/constructors/options.go diff --git a/integration/pkg/constructors/committee_verifier.go b/integration/pkg/constructors/committee_verifier.go index 0c92c8c16..ad00a546c 100644 --- a/integration/pkg/constructors/committee_verifier.go +++ b/integration/pkg/constructors/committee_verifier.go @@ -23,7 +23,6 @@ import ( "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/commit" "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/heartbeat" "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/monitoring" - "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/policy" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" "github.com/smartcontractkit/chainlink-evm/pkg/chains/legacyevm" @@ -36,9 +35,10 @@ import ( // cfg.ResolvedAggregators(); the legacy single-aggregator config has an empty SecretName, so its // credential is keyed by "". // -// Optional dependencies are passed as opts rather than as parameters. This constructor is called -// from the Chainlink node repo, so a positional signature that grows breaks that build until a -// change lands there, and the two cannot land at once. +// This constructor is called from the Chainlink node repo, so the signature must not grow +// positionally: an optional dependency is added as a variadic functional option, which a caller +// that never passes it does not notice. A positional parameter breaks that build the moment it +// lands, and the two repos cannot land a change at the same instant. func NewVerificationCoordinator( lggr logger.Logger, cfg commit.Config, @@ -47,10 +47,7 @@ func NewVerificationCoordinator( signer verifier.MessageSigner, relayers map[protocol.ChainSelector]legacyevm.Chain, ds sqlutil.DataSource, - opts ...VerificationCoordinatorOption, ) (*verifier.Coordinator, error) { - options := newVerificationCoordinatorOptions(opts) - lggr = logging.WithService(lggr, "verifier") if err := cfg.Validate(); err != nil { @@ -58,6 +55,14 @@ func NewVerificationCoordinator( return nil, fmt.Errorf("invalid ccv verifier configuration: %w", err) } + // The policy hook is not supported on a verifier running inside a Chainlink node: its HMAC + // credential is resolved from the standalone verifier's secrets file, which does not exist + // here. Configuring the section must fail loudly rather than screen traffic with an + // unauthenticated hook. + if cfg.PolicyHook != nil { + return nil, fmt.Errorf("invalid ccv verifier configuration: [policy_hook] is not supported on a verifier running inside a Chainlink node; run the standalone verifier to use the policy hook") + } + if err := commit.ValidateSignerAddress(cfg.SignerAddress, signingAddress); err != nil { return nil, err } @@ -230,7 +235,8 @@ func NewVerificationCoordinator( ChainStatusFlushThreshold: chainstatus.DefaultFlushThreshold, } - // Create commit verifier (with ECDSA signer) + // Create commit verifier (with ECDSA signer). The policy hook is rejected above for this + // entry point, so the verifier runs ungated here. ecdsaSigner := commit.NewECDSASignerWithKeystoreSigner(signer) commitVerifier, err := commit.NewCommitVerifier(coordinatorConfig, signingAddress, ecdsaSigner, lggr, verifierMonitoring) if err != nil { @@ -238,14 +244,6 @@ func NewVerificationCoordinator( return nil, fmt.Errorf("failed to create commit verifier: %w", err) } - // Apply the operator's policy hook. With no [policy_hook] section this returns the commit - // verifier unchanged. - gatedVerifier, err := policy.WrapVerifier(lggr, cfg.VerifierID, commitVerifier, cfg.PolicyHook, verifierMonitoring, options.policyHookCredential) - if err != nil { - lggr.Errorw("Failed to apply policy hook", "error", err) - return nil, fmt.Errorf("failed to apply policy hook: %w", err) - } - heartbeatSender, err := heartbeatclient.NewFanOutHeartbeatSender( heartbeatTargets, cfg.VerifierID, @@ -313,7 +311,7 @@ func NewVerificationCoordinator( verifierCoordinator, err := verifier.NewCoordinator( lggr, - gatedVerifier, + commitVerifier, sourceReaders, observedOffchainWriter, coordinatorConfig, diff --git a/integration/pkg/constructors/options.go b/integration/pkg/constructors/options.go deleted file mode 100644 index 9329f2ddf..000000000 --- a/integration/pkg/constructors/options.go +++ /dev/null @@ -1,46 +0,0 @@ -package constructors - -import ( - "github.com/smartcontractkit/chainlink-ccv/protocol/common/hmac" -) - -// VerificationCoordinatorOption configures an optional dependency of NewVerificationCoordinator. -// -// These are options rather than parameters because the constructor's only caller is the Chainlink -// node repo. A positional signature that grows breaks that build the moment this lands, and the -// two repos cannot land a change at the same instant, so anything optional goes here and the -// existing call site keeps compiling until it opts in. -type VerificationCoordinatorOption func(*verificationCoordinatorOptions) - -// verificationCoordinatorOptions holds the resolved options. The zero value is the behavior of a -// caller that passes none. -type verificationCoordinatorOptions struct { - // policyHookCredential is nil when the caller supplied none, which calls the operator's - // policy endpoint unauthenticated (and fails construction if the [policy_hook] section - // sets require_auth). - policyHookCredential *hmac.ClientConfig -} - -func newVerificationCoordinatorOptions(opts []VerificationCoordinatorOption) verificationCoordinatorOptions { - var resolved verificationCoordinatorOptions - for _, opt := range opts { - if opt != nil { - opt(&resolved) - } - } - return resolved -} - -// WithPolicyHookCredential supplies the HMAC credential the operator's policy endpoint identifies -// this verifier by. -// -// The standalone verifier reads this pair from the [policy_hook] table of its secrets file. A -// verifier running inside a Chainlink node has no such file, so the node resolves the credential -// and passes it here, the same way it passes the aggregator credentials. -// -// Omit it, or pass nil, to call the endpoint unauthenticated. That is the supported shape when the -// endpoint runs inside the verifier's own cluster; an operator who wants a missing credential to -// be fatal instead sets require_auth on the [policy_hook] section, which fails construction here. -func WithPolicyHookCredential(cred *hmac.ClientConfig) VerificationCoordinatorOption { - return func(o *verificationCoordinatorOptions) { o.policyHookCredential = cred } -} diff --git a/verifier/docs/policy_hook.md b/verifier/docs/policy_hook.md index 54e116693..206a60697 100644 --- a/verifier/docs/policy_hook.md +++ b/verifier/docs/policy_hook.md @@ -309,9 +309,10 @@ at least 32 bytes hex-encoded, which is what the shared scheme requires; generat `hmac.GenerateCredentials`. Setting one of the two and not the other is a startup error rather than a silent downgrade to no authentication. -A verifier running inside a Chainlink node has no secrets file, so the node supplies its credential -through `constructors.WithPolicyHookCredential`, the same way it supplies the aggregator -credentials. Without that option the endpoint is called unauthenticated. +A verifier running inside a Chainlink node has no secrets file, and the hook is not supported +there: configuring the `[policy_hook]` section on that entry point is a startup error rather than +a silent downgrade to calling the endpoint unauthenticated. Run the standalone verifier to use the +hook. Set `require_auth = true` on any verifier whose endpoint checks the signature. Without it a credential that failed to reach the container leaves the verifier calling unauthenticated, the From d7f79c0cf4a4667da24e7d30af17a9769fb910c5 Mon Sep 17 00:00:00 2001 From: Terry Tata Date: Wed, 9 Sep 2026 04:03:01 -0700 Subject: [PATCH 2/3] comments --- changelog/2026-08-27_verifier_policy_hook.md | 4 ++++ .../changesets/apply_verifier_config.go | 12 ++++++++++ deployment/topology.go | 4 ++++ .../verifier/committee/config.documented.toml | 4 ++++ .../pkg/constructors/committee_verifier.go | 24 ++++++++++++------- verifier/docs/policy_hook.md | 12 ++++++++-- verifier/pkg/commit/config.go | 4 ++++ 7 files changed, 53 insertions(+), 11 deletions(-) diff --git a/changelog/2026-08-27_verifier_policy_hook.md b/changelog/2026-08-27_verifier_policy_hook.md index c360e79b0..fc45538e2 100644 --- a/changelog/2026-08-27_verifier_policy_hook.md +++ b/changelog/2026-08-27_verifier_policy_hook.md @@ -140,6 +140,10 @@ an error rather than a silent downgrade. Errors name the field, never the value. ### `NewVerificationCoordinator` policy credential (additive, not breaking) +> Superseded by `2026-09-05_policy_hook_standalone_only.md`. The hook is no longer supported on a +> verifier running inside a Chainlink node, and `constructors.WithPolicyHookCredential` is gone. +> The rest of this section describes the shape that shipped here. + `cmd/verifier/servicefactory.go` resolves the credential from the secrets file. The Chainlink-node path has no secrets file, so `constructors.NewVerificationCoordinator` accepts one from its caller, as it already does for the aggregator credentials. It arrives as a variadic option rather than a diff --git a/deployment/changesets/apply_verifier_config.go b/deployment/changesets/apply_verifier_config.go index c0591257d..fb06d9836 100644 --- a/deployment/changesets/apply_verifier_config.go +++ b/deployment/changesets/apply_verifier_config.go @@ -498,6 +498,18 @@ func buildVerifierJobSpecs( return nil, affectedScope, err } + // A verifier running inside a Chainlink node rejects [policy_hook] at startup: the + // endpoint credential comes from the standalone verifier's secrets file, which that + // deployment has no equivalent of. Emitting the section into a cl-mode spec would ship a + // job that cannot load, so the mismatch fails here, where the operator can still fix the + // topology. + if mode == shared.NOPModeCL && nop.PolicyHook != nil { + return nil, affectedScope, fmt.Errorf( + "NOP %q has a [policy_hook] but runs in %q mode; the policy hook is supported on a standalone verifier only", + nopAlias, mode, + ) + } + sortedFinalityCheckers := slices.Clone(disableFinalityCheckers) slices.Sort(sortedFinalityCheckers) diff --git a/deployment/topology.go b/deployment/topology.go index aa5c3d0f8..520b6a901 100644 --- a/deployment/topology.go +++ b/deployment/topology.go @@ -91,6 +91,10 @@ type NOPConfig struct { // this NOP receives. It is per-NOP because the endpoint is the operator's own: two NOPs in // the same committee run different policies, or one runs none. Omit it to leave the NOP's // verifier ungated. + // + // Supported on a NOP in "standalone" mode only. A verifier running inside a Chainlink node + // rejects the section at startup, so ApplyVerifierConfig refuses to build a spec for a + // "cl"-mode NOP that sets it. PolicyHook *policy.Config `toml:"policy_hook,omitempty"` } diff --git a/docs/config/verifier/committee/config.documented.toml b/docs/config/verifier/committee/config.documented.toml index 91b3878e6..821983f43 100644 --- a/docs/config/verifier/committee/config.documented.toml +++ b/docs/config/verifier/committee/config.documented.toml @@ -145,6 +145,10 @@ disable_finality_checkers = [] # default) leaves the verifier behaving exactly as it did before the hook existed; the # section is omitted from marshaled job specs when unset, so specs are unchanged for a # verifier that does not use it. +# Supported on the standalone verifier only. The endpoint credential is resolved from the +# verifier secrets file, which a verifier running inside a Chainlink node does not have, so +# that entry point rejects this section at startup instead of calling the endpoint +# unauthenticated. [policy_hook] # base_url is the root the operator serves the policy operation under. The verifier POSTs to # base_url + "/v1/evaluate", once per message. Required, and must be https unless diff --git a/integration/pkg/constructors/committee_verifier.go b/integration/pkg/constructors/committee_verifier.go index ad00a546c..350a5a7fc 100644 --- a/integration/pkg/constructors/committee_verifier.go +++ b/integration/pkg/constructors/committee_verifier.go @@ -2,6 +2,7 @@ package constructors import ( "context" + "errors" "fmt" "time" @@ -36,9 +37,8 @@ import ( // credential is keyed by "". // // This constructor is called from the Chainlink node repo, so the signature must not grow -// positionally: an optional dependency is added as a variadic functional option, which a caller -// that never passes it does not notice. A positional parameter breaks that build the moment it -// lands, and the two repos cannot land a change at the same instant. +// positionally: a new parameter breaks that build the moment it lands, and the two repos cannot +// land a change at the same instant. func NewVerificationCoordinator( lggr logger.Logger, cfg commit.Config, @@ -50,17 +50,23 @@ func NewVerificationCoordinator( ) (*verifier.Coordinator, error) { lggr = logging.WithService(lggr, "verifier") - if err := cfg.Validate(); err != nil { - lggr.Errorw("Invalid CCV verifier configuration.", "error", err) - return nil, fmt.Errorf("invalid ccv verifier configuration: %w", err) - } - // The policy hook is not supported on a verifier running inside a Chainlink node: its HMAC // credential is resolved from the standalone verifier's secrets file, which does not exist // here. Configuring the section must fail loudly rather than screen traffic with an // unauthenticated hook. + // + // Checked before cfg.Validate, which validates the section's own fields: a malformed hook on + // an entry point where no hook is valid should report that it is unsupported, not that its + // base_url is wrong. if cfg.PolicyHook != nil { - return nil, fmt.Errorf("invalid ccv verifier configuration: [policy_hook] is not supported on a verifier running inside a Chainlink node; run the standalone verifier to use the policy hook") + err := errors.New("[policy_hook] is not supported on a verifier running inside a Chainlink node; run the standalone verifier to use the policy hook") + lggr.Errorw("Invalid CCV verifier configuration.", "error", err) + return nil, fmt.Errorf("invalid ccv verifier configuration: %w", err) + } + + if err := cfg.Validate(); err != nil { + lggr.Errorw("Invalid CCV verifier configuration.", "error", err) + return nil, fmt.Errorf("invalid ccv verifier configuration: %w", err) } if err := commit.ValidateSignerAddress(cfg.SignerAddress, signingAddress); err != nil { diff --git a/verifier/docs/policy_hook.md b/verifier/docs/policy_hook.md index 206a60697..a87b6e0f3 100644 --- a/verifier/docs/policy_hook.md +++ b/verifier/docs/policy_hook.md @@ -142,6 +142,10 @@ The hook is off unless the `[policy_hook]` section is present in the committee v verifier without it behaves exactly as it did before the hook existed, down to the bytes of its job spec. +The hook runs on the standalone verifier only. A verifier running inside a Chainlink node has no +secrets file to resolve the endpoint credential from, so it rejects the section at startup rather +than calling the endpoint unauthenticated. + ```toml [policy_hook] base_url = "https://policy.internal.example.com" @@ -180,12 +184,15 @@ checkpoint rewind to recover, so an outage approaching a week is an operational something the retry loop rides out. In a JD deployment the section is emitted into the job spec from the NOP's entry in the environment -topology, which is where an operator sets their own endpoint: +topology, which is where an operator sets their own endpoint. The NOP has to be in `standalone` +mode, since that is the only deployment the hook is supported on; `ApplyVerifierConfig` refuses to +build a spec for a `cl`-mode NOP that sets the section: ```toml [[environment_topology.nop_topology.nops]] alias = "acme-verifier-1" name = "acme-verifier-1" +mode = "standalone" [environment_topology.nop_topology.nops.policy_hook] base_url = "https://policy.internal.acme.example" ``` @@ -317,7 +324,8 @@ hook. Set `require_auth = true` on any verifier whose endpoint checks the signature. Without it a credential that failed to reach the container leaves the verifier calling unauthenticated, the endpoint answering 401, and every message on the lane retrying until the queue's 7-day deadline. With -it the node refuses to start. The boot log line carries `authenticated=true|false` either way. +it the verifier process refuses to start. The boot log line carries `authenticated=true|false` +either way. ## Observing it diff --git a/verifier/pkg/commit/config.go b/verifier/pkg/commit/config.go index 2b3bbf9b1..0981e6db2 100644 --- a/verifier/pkg/commit/config.go +++ b/verifier/pkg/commit/config.go @@ -229,6 +229,10 @@ type Config struct { // default) leaves the verifier behaving exactly as it did before the hook existed; the // section is omitted from marshaled job specs when unset, so specs are unchanged for a // verifier that does not use it. + // Supported on the standalone verifier only. The endpoint credential is resolved from the + // verifier secrets file, which a verifier running inside a Chainlink node does not have, so + // that entry point rejects this section at startup instead of calling the endpoint + // unauthenticated. PolicyHook *policy.Config `toml:"policy_hook,omitempty"` // CommitteeConfig that is needed by the SourceReader and the application. From 1731ce6660eae27582a47e9d2cf9fb0a796ac1c7 Mon Sep 17 00:00:00 2001 From: Terry Tata Date: Wed, 9 Sep 2026 04:13:13 -0700 Subject: [PATCH 3/3] changelog --- .../2026-09-05_policy_hook_standalone_only.md | 88 ++++++++++++++ .../apply_verifier_config_policy_hook_test.go | 114 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 changelog/2026-09-05_policy_hook_standalone_only.md create mode 100644 deployment/changesets/apply_verifier_config_policy_hook_test.go diff --git a/changelog/2026-09-05_policy_hook_standalone_only.md b/changelog/2026-09-05_policy_hook_standalone_only.md new file mode 100644 index 000000000..dac866ee3 --- /dev/null +++ b/changelog/2026-09-05_policy_hook_standalone_only.md @@ -0,0 +1,88 @@ +# Policy hook is standalone-verifier only + +## Executive Summary + +- The operator policy hook is supported on the standalone verifier only. A verifier running inside + a Chainlink node now rejects a `[policy_hook]` section at startup instead of gating traffic with + an endpoint call it cannot authenticate. +- `constructors.WithPolicyHookCredential` and `constructors.VerificationCoordinatorOption` are + removed, and `NewVerificationCoordinator` loses its variadic `opts ...` parameter. No caller + passed an option, so the signature change is source-compatible. +- The credential the hook signs with is resolved from the verifier secrets file's `[policy_hook]` + table. A verifier inside a Chainlink node has no such file. The credential was going to have to + be threaded in from the node repo, and until it was, the section booted a hook that called the + operator's endpoint unauthenticated: the endpoint answers 401, the verifier reads that as + "verdict unknown", and every message on the lane retries until the queue's 7-day deadline. That + is a worse outcome than refusing to boot. +- `ApplyVerifierConfig` rejects a `cl`-mode NOP that carries a `[policy_hook]`, so the unsupported + combination fails while the topology is still editable instead of shipping a job spec the node + cannot load. +- No change to the standalone verifier: same wire contract, same config, same behavior. Nothing in + `verifier/pkg/policy` changed. + +## AI Adapter Index + +| Symbol | Kind | Search | Location | Section | +|---|---|---|---|---| +| `constructors.NewVerificationCoordinator` | behavior-changed | `func NewVerificationCoordinator\(` | `integration/pkg/constructors/committee_verifier.go:41` | [#rejection-at-boot](#rejection-at-boot) | +| `constructors.WithPolicyHookCredential` / `constructors.VerificationCoordinatorOption` | removed | `WithPolicyHookCredential\(` | `integration/pkg/constructors/options.go` (deleted) | [#rejection-at-boot](#rejection-at-boot) | +| `changesets.buildVerifierJobSpecs` | behavior-changed | `policy hook is supported on a standalone` | `deployment/changesets/apply_verifier_config.go:504` | [#rejection-at-plan-time](#rejection-at-plan-time) | + +## Breaking Changes + +`constructors.WithPolicyHookCredential` and `constructors.VerificationCoordinatorOption` are gone, +along with the variadic `opts ...VerificationCoordinatorOption` parameter on +`NewVerificationCoordinator`. The option shipped in `2026-08-27_verifier_policy_hook.md` and no +caller adopted it, so existing call sites keep compiling unchanged. + +A Chainlink-node verifier whose job spec carries `[policy_hook]` no longer starts. That +configuration was never usable: it called the operator's endpoint unauthenticated. + +## Migration Guide + +Nothing to do for a verifier with no `[policy_hook]` section, which is every deployment that has +not opted into the hook. + +To run the hook, run the standalone verifier. In a JD deployment that means the NOP carrying the +section is in `standalone` mode: + +```toml +[[environment_topology.nop_topology.nops]] +alias = "acme-verifier-1" +name = "acme-verifier-1" +mode = "standalone" + [environment_topology.nop_topology.nops.policy_hook] + base_url = "https://policy.internal.acme.example" +``` + +`ApplyVerifierConfig` fails if a NOP carries a hook without that mode, so the mismatch surfaces +while the topology is still editable rather than as a job the node cannot load. An unset `mode` +defaults to `cl` and is rejected the same way. + +## Rejection at boot + +`NewVerificationCoordinator` returns +`invalid ccv verifier configuration: [policy_hook] is not supported on a verifier running inside a +Chainlink node; run the standalone verifier to use the policy hook` when `cfg.PolicyHook != nil`, +and logs it under the same `Invalid CCV verifier configuration.` line the constructor's other +config failures use. + +The check runs before `cfg.Validate()`, which validates the section's own fields. A malformed hook +on an entry point where no hook is valid should say the section is unsupported, not that its +`base_url` is wrong. + +The constructor no longer calls `policy.WrapVerifier`. With the section rejected, the wrap was a +no-op that returned the commit verifier unchanged, so the coordinator takes `commitVerifier` +directly. `cmd/verifier/servicefactory.go` — the standalone path — still calls `WrapVerifier` with +the credential from the secrets file, and is the only construction site with a gate. + +## Rejection at plan time + +`buildVerifierJobSpecs` errors with +`NOP %q has a [policy_hook] but runs in %q mode; the policy hook is supported on a standalone verifier only` +when a NOP resolves to `cl` mode and carries the section. Without it the changeset would emit a +well-formed spec that the node rejects at job load, which puts the failure on the operator's node +rather than on the plan they can still change. + +`build/devenv/env-policy-hook.toml` already sets `mode = "standalone"` on both hooked NOPs, so +`TestE2ESmoke_PolicyHook` is unaffected. diff --git a/deployment/changesets/apply_verifier_config_policy_hook_test.go b/deployment/changesets/apply_verifier_config_policy_hook_test.go new file mode 100644 index 000000000..37af39ae1 --- /dev/null +++ b/deployment/changesets/apply_verifier_config_policy_hook_test.go @@ -0,0 +1,114 @@ +package changesets + +import ( + "strings" + "testing" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-ccv/deployment/adapters" + "github.com/smartcontractkit/chainlink-ccv/deployment/shared" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/commit" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/policy" +) + +// buildSpecsForPolicyHookNOP builds the verifier job specs for a single NOP carrying hook in the +// given mode. +func buildSpecsForPolicyHookNOP(t *testing.T, mode shared.NOPMode, hook *policy.Config) (shared.NOPJobSpecs, error) { + t.Helper() + registerEVMChainTypeForIdentities() + + specs, _, err := buildVerifierJobSpecs( + map[string]*adapters.VerifierContractAddresses{ + "1": { + CommitteeVerifierAddress: "0xCommittee1", + OnRampAddress: "0xOnRamp1", + }, + }, + map[string]string{"1": "0xExec1"}, + nil, + []verifierNOPInput{{ + Alias: "nop1", + SignerAddressByFamily: map[string]string{"evm": "0x47a5eed5c86da7dd9bb75488cd3832dd6782252e"}, + Mode: mode, + PolicyHook: hook, + }}, + verifierCommitteeInput{ + Qualifier: "default", + NOPAliases: []shared.NOPAlias{"nop1"}, + Aggregators: []AggregatorRef{ + {Name: "agg-a", Address: "agg-a:50051", InsecureAggregatorConnection: true}, + }, + }, + "", + nil, + "evm", + true, + defaultApplyVerifierConfigApplyOverrides(), + ) + return specs, err +} + +// parseStandaloneVerifierConfig reads the config out of a standalone-mode spec, which carries it +// under appConfig rather than committeeVerifierConfig. +func parseStandaloneVerifierConfig(t *testing.T, jobSpec string) commit.Config { + t.Helper() + const open = "appConfig = '''\n" + i := strings.Index(jobSpec, open) + require.GreaterOrEqual(t, i, 0, "job spec must contain appConfig") + rest := jobSpec[i+len(open):] + end := strings.Index(rest, "'''") + require.GreaterOrEqual(t, end, 0) + var cfg commit.Config + require.NoError(t, toml.Unmarshal([]byte(rest[:end]), &cfg)) + return cfg +} + +func testPolicyHook() *policy.Config { + return &policy.Config{BaseURL: "https://policy.internal.acme.example"} +} + +// A verifier running inside a Chainlink node rejects [policy_hook] at startup, so emitting the +// section into a cl-mode spec ships a job that cannot load. The mismatch has to surface here, +// while the operator can still fix the topology, rather than as a job that fails on the node. +func TestBuildVerifierJobSpecs_RejectsPolicyHookOnCLModeNOP(t *testing.T) { + _, err := buildSpecsForPolicyHookNOP(t, shared.NOPModeCL, testPolicyHook()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "policy hook is supported on a standalone verifier only") +} + +// An unset mode defaults to cl, so a topology that only adds the hook is rejected the same way. +func TestBuildVerifierJobSpecs_RejectsPolicyHookOnDefaultModeNOP(t *testing.T) { + _, err := buildSpecsForPolicyHookNOP(t, "", testPolicyHook()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "policy hook is supported on a standalone verifier only") +} + +func TestBuildVerifierJobSpecs_EmitsPolicyHookOnStandaloneNOP(t *testing.T) { + specs, err := buildSpecsForPolicyHookNOP(t, shared.NOPModeStandalone, testPolicyHook()) + require.NoError(t, err) + + jobs := specs["nop1"] + require.Len(t, jobs, 1) + for _, spec := range jobs { + cfg := parseStandaloneVerifierConfig(t, spec) + require.NotNil(t, cfg.PolicyHook) + assert.Equal(t, "https://policy.internal.acme.example", cfg.PolicyHook.BaseURL) + } +} + +// A cl-mode NOP without a hook is untouched by the guard. +func TestBuildVerifierJobSpecs_CLModeWithoutPolicyHookIsUnaffected(t *testing.T) { + specs, err := buildSpecsForPolicyHookNOP(t, shared.NOPModeCL, nil) + require.NoError(t, err) + + jobs := specs["nop1"] + require.Len(t, jobs, 1) + for _, spec := range jobs { + assert.Nil(t, parseVerifierConfig(t, spec).PolicyHook) + } +}