Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/2026-08-27_verifier_policy_hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions changelog/2026-09-05_policy_hook_standalone_only.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions deployment/changesets/apply_verifier_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
114 changes: 114 additions & 0 deletions deployment/changesets/apply_verifier_config_policy_hook_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions deployment/topology.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
4 changes: 4 additions & 0 deletions docs/config/verifier/committee/config.documented.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 21 additions & 17 deletions integration/pkg/constructors/committee_verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package constructors

import (
"context"
"errors"
"fmt"
"time"

Expand All @@ -23,7 +24,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"
Expand All @@ -36,9 +36,9 @@ 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: 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,
Expand All @@ -47,12 +47,23 @@ 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")

// 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 {
Comment on lines +53 to +61
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)
Expand Down Expand Up @@ -230,22 +241,15 @@ 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 {
lggr.Errorw("Failed to create commit verifier", "error", err)
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,
Expand Down Expand Up @@ -313,7 +317,7 @@ func NewVerificationCoordinator(

verifierCoordinator, err := verifier.NewCoordinator(
lggr,
gatedVerifier,
commitVerifier,
sourceReaders,
observedOffchainWriter,
coordinatorConfig,
Expand Down
46 changes: 0 additions & 46 deletions integration/pkg/constructors/options.go

This file was deleted.

Loading
Loading