Files
acdl/.ciagent/RESEARCH.md
T
Jon Chery f753353ad4 docs(P00): research findings — v1.25 kyverno-json engine surface, 4 policy targets, PolicyEngine swap boundary
RESEARCH.md: kyverno-json CLI (kj scan), ValidatingPolicy structure,
assertion trees + ~ modifier + JMESPath, output shape, severity-via-
annotation convention, 4 policy targets (contract/stack-IR/plan-JSON/
meta), PolicyEngine protocol + OPA-equivalent swap surface, latency
<1s (parallel with checkov), deterministic-not-AI tenet, ECS catalog
prior art, 5 logged assumptions (A1..A5).

PERSONAS.md: 4 active personas (lead-developer, backend-engineer,
new policy-engineer, data-engineer); frontend-engineer deactivated.
policy-engineer owns kyverno-json policies + engine translation +
STANDARDS.md policy-authoring section.

ARCHITECTURE.md §12.7: Policy Engine Registry — protocol, registry,
NullEngine fallback, engine enum reuse (D-116), defense-in-depth
critical-override (D-119), graceful degradation (D-120).

---ci---
project: acdl
phase: 0
milestone: v1.25
status: research
---/ci---
2026-08-12 18:09:12 +00:00

438 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Nova — v1.25 Research Findings
> Phase: research (pre-execution). Milestone: v1.25 (kyverno-json Unified
> Policy Engine). Status: research. Researcher: ci-researcher.
> Autonomy: full.
## 1. Problem domain
Nova's compliance/policy posture is fragmented across three engines with
three rule languages and three adapter shapes (see PROJECT.md v1.25
"Why" for the full diagnosis). The `PolicyCheckResult` schema
(`schemas/policy_check_result.schema.json`) is already the engine-agnostic
contract that `core/confidence_signal.py` consumes — the *contract* is
right; the *orchestration* is fragmented. There is no single declarative
place where "what Nova considers compliant" lives. The K8s-only Kyverno
adapter (`adapters/kyverno/`) can't help because it only speaks to K8s
manifests and the platform emits Terraform (D-053).
`kyverno-json` is the correction: a Kyverno-ecosystem runtime that applies
Kyverno policies to **any** JSON/YAML payload. It becomes the **unified
orchestrator** of compliance checks, behind a swappable `PolicyEngine`
protocol so OPA can replace it one day. Checkov and Wiz remain as
raw-finding adapters feeding *into* kyverno-json meta-policies.
## 2. kyverno-json — the engine surface
### 2.1 What it is
[kyverno-json](https://github.com/kyverno/kyverno-json) is a standalone Go
binary from the Kyverno project. It is a **separate runtime** from the
Kyverno K8s admission controller — same policy lineage, different
application target. Where Kyverno (K8s) evaluates `ClusterPolicy`
resources against Kubernetes manifests at admission time, kyverno-json
evaluates `ValidatingPolicy` resources against **any** JSON or YAML
payload file via the CLI (`kj scan`) or a Go library. It is **not** a
Python package (no PyPI release); it is installed via
`go install github.com/kyverno/kyverno-json/cmd/kj@latest` (D-115) or by
downloading a pinned binary from GitHub releases.
### 2.2 CLI surface (the v1.25 invocation path)
The v1.25 engine uses the `kj scan` subcommand:
```
kyverno-json scan [flags]
Flags:
--labels strings Labels selectors for policies
--output string Output format (text or json) (default "text")
--payload string Path to payload (json or yaml file)
--policy strings Path to kyverno-json policies
--pre-process strings JMESPath expression used to pre process payload
```
The `KyvernoJsonEngine.evaluate()` implementation (REQ-293) invokes:
```
kj scan --policy <policy_dir> --payload <payload.json> --output json
```
and parses the JSON `results[]` array. The `--pre-process` flag is
available for JMESPath pre-projection (noted for the meta-policy use case
where the payload is the merged PCR list and a pre-process expression
can index by `ruleId` — recorded as a future optimization, not used in
v1.25's initial implementation).
Other subcommands (`kj jp`, `kj serve`, `kj playground`, `kj docs`) are
out of scope for v1.25. `kj serve` is the long-running web-app mode
(noted as a future consideration for lower-latency evaluation in the
Out of Scope section of REQUIREMENTS.md). `kj jp` is the JMESPath REPL —
useful for policy authoring/debugging, not invoked by the engine.
### 2.3 Policy structure (the `ValidatingPolicy` resource)
kyverno-json policies are Kubernetes-style resources (cluster-scoped)
belonging to the `json.kyverno.io` API group, kind `ValidatingPolicy`,
version `v1alpha1`:
```yaml
apiVersion: json.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
name: <policy-name> # becomes the KJ_<policy-name> ruleId prefix
spec:
rules:
- name: <rule-name>
identifier: <jmespath> # optional — path to the unique entry id
match: # assertion tree — which payload entries
any: # the rule applies to
- <assertion>
exclude: # optional — exclude matching entries
any:
- <assertion>
context: # optional — named bindings available to
- name: <binding> # the rule's assertions ($<binding>)
variable: <value>
validate:
message: "<human-readable>" # optional per-rule message
assert:
all: # all assertions must hold
- check: <assertion-tree>
message: "<per-check>"
# OR
any: # at least one assertion must hold
- check: <assertion-tree>
```
Key differences from K8s Kyverno policies:
- **Always cluster-scoped** — no `namespace` field.
- **No `forEach`, pattern operators, anchors, or wildcards.** Iteration
is done via the `~` projection modifier in assertion trees (see §2.4).
- **Assertion trees** with JMESPath expressions replace Kyverno's
pattern-matching syntax (see §2.4).
### 2.4 Assertion trees (the rule language)
An `assert` declaration contains an `all` or `any` list. Each entry has a
`check` (the assertion tree — a nested JMESPath projection) and an
optional `message`. **All comparisons happen in the leaves of the tree.**
A simple example (assert a pod doesn't use the default service account):
```yaml
validate:
assert:
all:
- message: "serviceAccountName 'default' is not allowed"
check:
spec:
(serviceAccountName == 'default'): false
```
The `(expression)` syntax evaluates a JMESPath expression; the result
becomes the current object for descendants; the leaf value is compared
to the expected value.
**Iteration via the `~` modifier.** The `~` prefix on a key applies
descendant assertions to **each element** of an array/map individually
(rather than comparing the whole array). Given `foo.bar: [1,2,3]`:
```yaml
check:
foo:
~.bar: # iterate each element
(@ < `5`): true # assert each element < 5
```
The `~index_name.bar` form binds the index (array) or key (map) to
`$index_name` for use in descendants. This is how v1.25 iterates
`resources[]` in the Stack IR policies (REQ-297) and
`planned_values.root_module.resources[]` in the plan-JSON policies
(REQ-300).
**Explicit bindings** via `->binding_name` allow descendants to refer
to a parent node via `$binding_name`. Built-in bindings: `$payload`
(the whole input), `$policy`, `$rule`.
**Escaping** via `\key\` prevents projection when a payload key collides
with the projection syntax. Not needed for Nova payloads (no `(key)`
fields), noted for completeness.
### 2.5 Output shape (what `kj scan --output json` produces)
The JSON output is a `results[]` array. Each result entry has (at
minimum):
- `policy`: the policy metadata.name
- `rule`: the rule name
- `result`: `"pass"` | `"fail"` | `"error"` | `"skip"` (lowercase)
- `message`: the assertion message (or engine error message)
- `resource`: the matched payload entry (the `identifier` value, or the
whole payload when no identifier/match)
- `namespace`/`kind`/`name`: K8s-style fields (present but empty for
non-K8s payloads — the K8s Kyverno adapter's evidence uses these; the
kyverno-json engine's evidence uses `assertion`/`jmespath` instead)
- `severity`: not present by default (kyverno-json does not assign
severities — the Nova policy author assigns severity via a Nova-
specific annotation; see §2.6)
The `KyvernoJsonEngine._to_pcr()` translator (REQ-293) maps:
- `policy``ruleId` (prefixed `KJ_<policy_name>` per D-116)
- `result``result` (`pass`/`fail`/`error` → pass/fail/error;
`skip`/`skipped` → skipped)
- `message``message`
- `resource``resourceRef` + `evidence.resource`
- severity from the policy's `metadata.annotations` (see §2.6)
- `engine: "kyverno"` (per D-116 — no new enum value)
### 2.6 Severity assignment (Nova convention)
kyverno-json does not natively assign severities to results. Nova's
confidence signal requires a `severity` per PCR (critical/high/medium/
low/info). The v1.25 convention: each Nova policy file declares its
severity via a `metadata.annotations` field:
```yaml
metadata:
name: forbid-public-ingress
annotations:
nova.cloudinit.dev/severity: high
```
The `KyvernoJsonEngine._to_pcr()` reads this annotation from the loaded
policy YAML (not from the scan result — the result doesn't carry it) and
applies it to every result that policy produces. Default when absent:
`info`. This keeps severity in the policy (declarative, version-
controlled) rather than in the engine adapter (imperative). The
annotation key is `nova.cloudinit.dev/severity` (matches the existing
`nova.cloudinit.dev` namespace used in `schemas/tagging-standard.json`).
## 3. The four policy targets (v1.25 scope)
### 3.1 Consumer contract JSON (REQ-295)
The payload is the parsed contract dict (the raw YAML loaded as JSON).
Policies assert the `contract.schema.json` constraints declaratively:
`require-id-pattern` (JMESPath regex `^[a-z][a-z0-9-]{2,5}$` over
`id`), `require-env-in-enum` (`environment` in `["dev","qa","prod","dr"]`),
`require-infrastructure-min-1` (`length(infrastructure) > 0`),
`forbid-unknown-fields` (keys subset of the 4 allowed). These are the
declarative equivalent of the jsonschema constraints — they let Nova
apply its own compliance posture (e.g. forbid a specific env for a
specific consumer) on top of schema validity without editing the
jsonschema.
**Invocation point:** `core/contract_resolver.py` pre-resolve (REQ-296).
Early-fail: if a contract policy fails, the resolver still proceeds
(the confidence signal decides the gate, consistent with the existing
`--soft-fail` Checkov pattern) — but the failing PCRs are in the
`policy` input, which lowers the score.
### 3.2 Resolved Target Stack IR JSON (REQ-297)
The payload is the resolved Stack IR dict produced by
`core/contract_resolver.py` (the merged module outputs). Policies
assert over `resources[]` (the array of resolved resources):
`require-tagging-standard` (every resource's `tags` has `nova:owner` +
`nova:environment` — ports
`adapters/terraform/policy/custom_rules/nova_tagging.py`),
`forbid-public-ingress` (no resource has `public_ingress: true` — the
v1.0 demo rule, now declarative), `require-encryption-by-default` (every
S3/EBS/KMS-aliased resource carries encryption config — ports the v1.8
D-encryption-default rule). The `~` modifier iterates `resources[]`.
**Invocation point:** `core/contract_resolver.py` post-resolve (REQ-298).
Additive — the resolver's return values and exceptions are unchanged;
the PCRs are appended to the contract-policy PCRs.
### 3.3 Terraform plan JSON (REQ-300)
The payload is `terraform show -json <tfplan>` output. Policies assert
over `planned_values.root_module.resources[]`:
`forbid-plaintext-secrets` (no `aws_db_instance.password` /
`aws_iam_user.login_profile.password` in plaintext — ports
`CKV_AWS_41/45/46`), `forbid-iam-wildcard` (no `Action: "*"` or
`Resource: "*"` in `aws_iam_policy.PolicyDocument` — ports
`CKV_AWS_1/40`), `require-kms-reference` (KMS keys referenced by alias,
not inline key material — ports `CKV_AWS_7/33`). These are declarative
**mirrors** of `checkov_adapter.py:RULE_MAP` — the Checkov rule stays
the source of truth for `terraform_plan` scanning; the kyverno-json
policy covers the same plan JSON with a different rule language
(defense-in-depth against engine drift).
**Invocation point:** `run_platform.sh` Step 5 (REQ-301). After
Checkov/Wiz produce raw PCRs, the script runs `kj scan` over the plan
JSON; both PCR lists concatenate into the confidence signal's `policy`
input. When `which kj` is false, the script logs and proceeds with the
Checkov/Wiz list only.
### 3.4 PolicyCheckResult records (meta-policies, REQ-303)
The payload is the **merged** `list[PolicyCheckResult]` produced by
checkov + wiz + the plan-JSON policies. This is the most novel target —
kyverno-json policies over the policy results themselves.
`block-on-any-critical` asserts no PCR has `severity: "critical"` +
`result: "fail"`; if any does, the meta-policy emits a `fail` PCR with
`ruleId: "KJ_META_BLOCK_CRITICAL"` and severity `critical`. This is the
declarative source of truth for "critical = block" (D-119 — the
`confidence_signal.py` `PENALTY["critical"]: None` hard-override stays
as defense-in-depth). `tagging-rules-agree` cross-checks the Checkov
`NOVA_TAG_NAMING` result against the kyverno-json
`KJ_REQUIRE_TAGGING_STANDARD` result by `resourceRef`; divergence emits
an `error` PCR (D-118).
**Invocation point:** after the three target policies (contract/stack-
IR/plan-JSON) produce their PCR lists, the merged list is the payload
for the meta-policies. The meta-policy PCRs are appended to the merged
list, which is what the confidence signal consumes.
## 4. The `PolicyEngine` swap boundary
### 4.1 Protocol shape (REQ-291)
A Python `Protocol` (PEP 544 — structural subtyping, no inheritance):
```python
class PolicyEngine(Protocol):
@property
def name(self) -> str: ...
def is_configured(self) -> bool: ...
def evaluate(self, payload: dict | str, policy_dir: Path,
contract_id: str) -> list[dict]: ...
```
`list[dict]` (not `list[PolicyCheckResult]` — there's no dataclass; the
schema is enforced via `jsonschema` validation in tests, matching the
existing adapter pattern). The registry selects the active engine from
`config.json.policy.engine`. A `NullEngine` is the fallback when the
`policy` key is absent (emits `SKIPPED` — backward compatibility for
tests that don't set the key).
### 4.2 The OPA-equivalent surface (future swap)
OPA (Open Policy Agent) is the most likely future replacement. The
mapping:
| Nova `PolicyEngine` member | kyverno-json impl | OPA equivalent |
|---|---|---|
| `name` | `"kyverno-json"` | `"opa"` |
| `is_configured()` | `which kj` | `which opa` |
| `evaluate(payload, policy_dir, contract_id)` | `kj scan --policy <dir> --payload <json> -o json` | `opa eval -d <dir> -i <json> 'data.nova.<...>'` |
| Policy file format | `ValidatingPolicy` (YAML) | Rego (`.rego`) |
| Result shape | `results[]` (pass/fail/error/skip) | `result` (set of violations) |
| Severity | Nova annotation `nova.cloudinit.dev/severity` | Nova convention (Rego `metadata` or a wrapper) |
The protocol is minimal (3 members) specifically so the OPA
implementation is a known quantity: an `OpaEngine` class that shells to
`opa eval`, translates the Rego violation set to PCR dicts, and
implements `is_configured()` via `which opa`. The policy *files* would
need rewriting (Rego, not ValidatingPolicy) — but the protocol, the
registry, the confidence signal, and the PCR schema are all untouched.
This is the swap boundary the user asked for ("Implemented as an
adapter since we might one day decide to replace it with something else
like OPA").
### 4.3 Why not a full plugin registry?
A `setuptools` entry-point plugin registry (like checkov's
`--external-checks-dir`) was considered and rejected: Nova has 1 active
engine today (kyverno-json) and at most 2 in the foreseeable future
(kyverno-json + OPA). A `Protocol` + `dict` registry in
`core/policy_engine.py` is the right weight — discoverable, typed,
testable, and ~40 lines. An entry-point registry adds packaging
complexity (entry-point metadata, version resolution) for no gain at
this scale. The `register(name, factory)` method on the registry is
the extension point if a future milestone needs runtime plugin
discovery.
## 5. Latency / MTTR impact (G-Q3 anticipation)
NORTH_STAR.md MTTR target: < 60s p95. `run_platform.sh` Step 5 today
runs Checkov over the terraform plan (typically 5-15s for a small
stack). Adding `kj scan` over the same plan JSON adds:
- Process spawn: ~50ms (Go binary startup)
- Policy load: ~20ms (a handful of YAML files)
- Assertion evaluation: ~100-500ms (JMESPath over a small plan)
- Total: < 1s for a typical Nova stack
The kyverno-json pass runs **in parallel** with Checkov (REQ-301 — the
script launches both and waits on both), so the wall-clock impact is
`max(checkov_time, kj_time)` ≈ checkov_time (kj is faster). The
contract + stack-IR policies run during resolve (already a fast step).
Meta-policies run over the merged list (in-memory, < 10ms). **No
measurable MTTR impact** is expected. This will be verified in P3
VERIFY with a timing assertion.
## 6. "Platform functions without AI" tenet (G-Q1 / D-120)
kyverno-json is deterministic (same policy + payload → same result,
every run). It is not an LLM, not a probabilistic model, not a
"judgement" engine. The NORTH_STAR.md tenet ("the platform functions
without AI — 'AI decisions' are really automated decisions")
distinguishes AI (non-reproducible) from automation (reproducible).
kyverno-json is the latter. Adding it is **more** aligned with the
tenet than the current imperative Python in `core/env_transition.py`
and `core/regression_verify.py`, because the policy is declarative
(visible, auditable, version-controlled) rather than imperative (logic
hidden in function bodies). The `is_configured()` guard ensures the
platform functions without the binary (graceful skip → `SKIPPED` PCR
→ confidence signal proceeds).
## 7. ECS policy catalog overlap (prior art)
The kyverno-json catalog ships ECS policies that overlap with Nova's
L1 modules: `ecs-cluster-enable-logging`, `ecs-cluster-required-
container-insights`, `ecs-service-public-ip`, `ecs-service-required-
latest-platform-fargate`, `ecs-task-definition-fs-read-only`. These are
**reference policies**, not drop-in Nova policies — they target the
AWS ECS API shape (`type: aws_ecs_service` etc.), not Nova's Stack IR
shape. v1.25 policies target the Nova IR (REQ-297) and the terraform
plan JSON (REQ-300), not the raw AWS API. The catalog is useful as
prior art for JMESPath patterns over ECS resources — the
`ecs-service-public-ip` policy's `contains('$allowed-values',
@.assign_public_ip)` pattern informs the Nova `forbid-public-ingress`
policy shape. No catalog policies are imported directly in v1.25.
## 8. Risks & mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| `kj` binary not in CI image | medium | blocks P3+ tests | `is_configured()` guard + `pytest.skip` + `scripts/install-kyverno-json.sh` |
| kyverno-json output shape changes across versions | low | breaks `_to_pcr()` | pin `@latest` to a known-good commit in `install-kyverno-json.sh` after P1 smoke; defensive parsing (malformed → `error` PCR, not exception) |
| Policy explosion (4 targets × N rules) | medium | maintenance load | wave ordering (PLAN); policies co-located per target dir; meta-policy cross-check keeps the set auditable |
| Checkov + kj tagging-rule drift | medium | false `error` PCRs | `tagging-rules-agree` meta-policy emits `error` on divergence (visible, not silent); the Checkov rule stays source of truth for HCL, kj for IR |
| OPA swap turns out harder than the protocol implies | low | future milestone rework | RESEARCH §4.2 documents the OPA-equivalent surface; the protocol is the contract, not the implementation |
| `--pre-process` needed for meta-policies but undocumented behavior | low | meta-policy bugs | v1.25 meta-policies use plain assertion trees over the PCR list (no pre-process); `--pre-process` noted as a future optimization only |
## 9. Assumptions (logged, full autonomy)
- A1: `kj scan --output json` produces a stable `results[]` array shape.
Will be verified in P1 smoke test (`_smoke.json` policy + a trivial
payload); if the shape differs, `_to_pcr()` is adjusted defensively
(malformed → `error` PCR). Confidence: 0.85.
- A2: The `nova.cloudinit.dev/severity` annotation convention is
read by the engine from the policy YAML (loaded once per evaluate()
call). kyverno-json does not validate unknown annotations — they pass
through. Confidence: 0.90.
- A3: The `~` projection modifier iterates `resources[]` in the Stack
IR and `planned_values.root_module.resources[]` in the plan JSON
correctly. Verified in P2/P3 tests. Confidence: 0.85.
- A4: `go install` works in the CI image (Go toolchain available or
installable). If not, the binary-release download path is the
documented fallback in `install-kyverno-json.sh`. Confidence: 0.80.
- A5: The `NullEngine` fallback (when `policy` key absent in
config.json) keeps all existing tests passing — they don't set the
key, so they get `NullEngine``SKIPPED` PCRs → confidence signal
proceeds with `policy` input `[SKIPPED]` → per-input score 1.0
(skipped counts as pass in `_per_input_score`). Confidence: 0.95
(verified against `confidence_signal.py:84-89`).
## 10. Decisions referenced
D-115 (install path), D-116 (engine enum reuse), D-117 (adapter
signatures unchanged), D-118 (tagging cross-check), D-119 (critical-
override defense-in-depth), D-120 (deterministic not AI). See
CLARIFY.md for the full resolution text.
## 11. Architecture updates (deferred to RESEARCH-stage file edits)
- `.ciagent/ARCHITECTURE.md` gains §12.7 "Policy Engine Registry" with
the registry diagram. Deferred to the RESEARCH commit (this file's
commit) — the section is authored as part of this research.
- `schemas/README.md` notes `engine: "kyverno"` is shared by the K8s
adapter and kyverno-json (distinguished by `ruleId` prefix).
- `modules/STANDARDS.md` gains a "Policy authoring standard" section
(P4, REQ-307).
- `docs/METRICS.md` notes the policy engine is swappable (P4, REQ-307).