contractpolicyenforcer: policy cache keyed by URL only — in-place DeDi record rotation serves a stale policy for up to cacheTTL (48h in devkit), bypassing checksum verification
- Dominant language
- Go
- Stars
- 61
- Forks
- 55
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 6
Description
## Summary
`contractpolicyenforcer`'s `PolicyCache` is keyed by **policy URL alone**, with no record
version, digest, or content hash in the key or the entry. Where a DISCOM rotates a policy by
publishing a **new version of an existing DeDi record** — one of the two options the DISCOM
policy guide explicitly offers — the lookup URL does not change, so participants keep
enforcing the superseded policy until their own independent TTLs expire. The shipped devkit
sets `cacheTTL: 172800` (48 h).
Because a cache hit short-circuits the fetch entirely, `verifyChecksum` never runs during that
window — the integrity mechanism is bypassed precisely in the case where the policy changed.
I want to be precise about scope: **if** a DISCOM rotates by publishing a *new record*
(version in `record_name`, as `…-policy-v1` suggests), the URL changes, the cache key changes,
and everything behaves correctly. So this is a robustness gap that the guide's own wording
leaves open, not a guaranteed break. It seemed worth reporting because the failure is silent
and the fail-closed enforcement path makes the consequences asymmetric.
## Evidence
`plugins/contractpolicyenforcer/cache.go` — the entry carries no version:
```go
type cacheEntry struct {
pq rego.PreparedEvalQuery
query string
fetchedAt time.Time
}
```
and the hit test is freshness-by-clock only:
```go
if ok && entry.query == query && time.Since(entry.fetchedAt) < c.ttl {
return entry.pq, nil
}
```
`config.go` enforces a floor of `MinCacheTTL = 24 * time.Hour`; the shipped
`devkits/p2p-trading-ies-wave2/config/local-p2p-trading-sellerapp.yaml` uses
`cacheTTL: "172800"` (48 h) together with `violationActions: "select,init,confirm"`.
Meanwhile DeDi advertises a much shorter freshness expectation and records are mutable in
place. From the live registry today:
```console
$ curl -s https://api.dedi.global/dedi/lookup/indiaenergystack.in | jq '{ttl, version_count, updated_at}'
{
"ttl": 600,
"version_count": 2,
"updated_at": "2026-07-22T03:19:09.375Z"
}
```
`ttl: 600` vs. the adapter's 172800 is a **288×** difference, and `version_count: 2` on a
stable URL is the in-place rotation this issue is about.
The guide (`specification/policies/discom-policy-guide/README.md` §7.2) offers both branches
in one sentence:
> To change the policy, publish a **new** tag and a **new** DeDi record version (or a new
> record) — never rewrite a tag.
The first branch keeps the lookup URL constant. The second changes it. Only the second is safe
under the current cache key, and the guide does not say so.
## Why it matters
Enforcement is per-participant and fail-closed on `violationActions`. Two adapters that warmed
their caches at different times can therefore evaluate **different versions of the same policy
URL at the same moment**, and nothing in the protocol surfaces the divergence — the NACK an
operator sees carries no indication that the counterparty was judging against different rules.
Since the policy is authored by a commercial counterparty and sets wheeling charges and
penalties, a settlement disagreement here is a billing disagreement.
## Suggested fixes (in rough order of cost)
1. **Honour DeDi's `ttl`.** The registry already tells you how long its answer is good for;
the resolver could clamp `cacheTTL` to it, or at minimum warn when config exceeds it by an
order of magnitude.
2. **Include the resolved identity in the cache key** — the record `version` / `digest`, or a
hash of the fetched rego. Cheap to add to `cacheEntry`, and it makes staleness detectable
rather than silent.
3. **Revalidate rather than re-fetch.** A conditional request (ETag / `If-None-Match`) against
the DeDi lookup keeps registry load near the current level while bounding skew to minutes.
4. **Tighten the guide.** If keying by URL is the intended contract, §7.2 should say plainly
that rotation *requires* a new `record_name`, and that updating a record version in place is
unsupported. That alone closes the gap at zero code cost.
Happy to send a PR for (2) if you'd like it — it is a small change to `cache.go` and the
existing tests in `enforce_test.go` cover the surrounding behaviour.
## Environment
Read at `main` (`plugins/contractpolicyenforcer/cache.go`, 136 lines) on 2026-07-29; live DeDi
values fetched the same day.
---
*Unrelated, noticed while verifying the above — say the word and I'll open it separately:*
`devkits/p2p-trading-ies-wave2/README.md` (line 57) documents the policy record as
`https://api.dedi.global/dedi/lookup/indiaenergystack.in/ies-policies/ies-p2p-network-settlement-rego-policy-v1`,
which returns `404 {"message":"Record not found"}` (3/3 attempts today), and the
`ies-policies` registry reports `record_count: 0`. With `violationActions:
"select,init,confirm"` and fail-closed resolution, a devkit run pointed at the live registry
would NACK on `select`.
Contributor guide
Research direction
Start in plugins/contractpolicyenforcer/cache.go, then read the surrounding cache tests in enforce_test.go and the cacheTTL handling in config.go. Confirm the intended freshness or record-identity behavior with maintainers, add regression coverage for in-place DeDi rotation, and consider the work done when rotated policies cannot remain silently stale while existing enforcement tests pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100