yeke.io · docs · guardrail

Guardrail policies

Use policies to decide when an operation should stop or require extra approval. This guide covers CEL conditions, effects, examples and testing before production.

File mode (YEKE_POLICY_DIR) works on every tier. The signed package mode, the dual-approval fields, and maintenance windows are Enterprise and are marked in their own section below.

What a policy is, and where it runs in the chain

Policies can restrict existing permissions or require extra checks. They cannot grant access that Kubernetes RBAC does not allow.

Every write in YEKE goes through a single chain, and guardrail is one link in it. The order is fixed; the policy runs after dry-run, which means it decides while holding the apiserver's own answer to "what would this have done".

POST /operations
  → validation → prior state → collection expansion → classification
  → dry-run → guardrail → plan hash → approval card → apply

The practical consequence is what your condition gets to see: object is the object dry-run returned (or the request body if there is none), and step.dryRun.changedPaths is the list of paths that will actually change. A policy looks at a measurement, not a guess.

What a policy cannot do

  • It does not replace Kubernetes RBAC. The request reaches the apiserver under the user's own identity and the apiserver makes the final call. Guardrail cannot widen what RBAC granted, and it cannot approve what RBAC denied — it puts a gate in front, it does not stand in for it.
  • There is no allow or bypass effect. There are four effects (deny, require, warn, redact) and none of them loosens anything. Guardrail only tightens.
  • The structural approval floor is independent of policy. Even with an entirely empty policy set, every write asks for at least standard approval and every step classified destructive asks for at least elevated. "No destructive operation without approval" rests on code, not on policy hygiene — no policy can go below that floor.
  • A policy error does not mean "go ahead". If match throws at evaluation time, that step counts as deny and the card shows Policy error together with the policy's id. The cost is real: a broken policy stops operations — but it stops them visibly.

File mode and fields

In file mode, restart core to load policy changes.

YEKE_POLICY_DIR points at a directory; the *.yaml and *.yml files inside it are loaded at startup, in name order. A file is either a policy document (top-level policies:) or a freeze calendar (top-level freezes:) — the two cannot live in the same file, the loader rejects it. The distinction comes from the top-level key, not from the filename: renaming a file does not change what it contains.

# /etc/yeke/policies/10-production.yaml
policies:
  - id: org.production-delete
    description: "Deletes in production namespaces"
    enabled: true
    match: 'step.action == "delete" && step.target.namespace == "production"'
    effect: require
    level: elevated
FieldRequiredWhat it does
idyes The policy's name (1–128 characters). Appears on the card and in the audit trail. If the same id is defined in two sources core will not start — which one is valid cannot remain ambiguous.
descriptionno Free-form description. With no messageCode and no message, this is what the card shows as the reason; with none of them, the card shows the policy's id.
enabledno (true) A policy set to false is still compiled but never evaluated. Learning that a disabled policy is broken on the day you enable it would defer the error to the worst possible moment.
matchyes A CEL expression (1–8192 characters) that must return a bool. If it does not compile, core will not start.
effectyes deny · require · warn · redact. Meanings are in the table below.
levelno standard or elevated. Only valid with effect: require; written with any other effect, core will not start. Omitted, it is standard.
messageno The one-line reason for the match, in the operator's own words (≤2048 characters). If messageCode is also written, this field is never read.
messageCodeno The code for that reason — a closed set (below). The code wins, because the code is the half that can be translated: the card shows it in the reader's own language.
flagsno Flags added to the classification. The irreversible flag is set only by a policy; the card then draws the step as not revertible.
caveatsno Extra warnings on the card — the operator's own sentences. The interface draws them labelled as coming from the server and does not translate them.
caveatCodesno The code for a warning — a closed set (below). On the card, codes come first and free text after.
severityno Raises the step's destructiveness class. The only accepted value is destructive; writing mutating is an attempt to lower it and is rejected. Cannot be used with effect: redact.
redactPathsconditional The field paths to mask. Only valid with effect: redact, and there it is required — an empty list is rejected too.
requireSecondApprovalno Enterprise. When true, a matching plan asks for a second approver's consent. Independent of the effect: a warn policy may also want a second set of eyes.
requiredApproverGroupno Enterprise. Narrows the approver set to one group. Cannot be written without requireSecondApproval: true — narrowing an approval that is never asked for would silently do nothing.

It never swallows a key it does not know; it dies at startup

  • A typo does not pass quietly. The schema is strict: a key that is not in the list above (levell, affect) is rejected. Swallowed, it would leave the operator with "I wrote it but it doesn't work".
  • A broken policy keeps core from starting. Unparseable YAML, a field that does not match the schema, a match that will not compile, a repeated id — all four stop startup. Skipping the broken policy and carrying on would produce a rule the operator believes they wrote and the system never loaded.
  • Setting YEKE_POLICY_DIR to something unreadable also stops startup. If the directory does not exist, do not set the variable at all: assuming it exists and failing to read it means policies silently fail to load.
  • A change is a file plus a restart. In file mode there is no write endpoint for policies; the loaded set stays fixed for the life of the process. The only source that takes effect without a restart is the signed package (below).

Writing match — the CEL context

The naming follows Kubernetes' own ValidatingAdmissionPolicy vocabulary: object and oldObject mean here what they mean there.

The expression is evaluated per step — each expanded target appears separately — and must return a bool. A non-bool result (writing just step.action, say) is an error, not a "true".

VariableContents
op.source Who built the plan: "ui" (the interface), "nl" (chat), "api" (a direct API caller; the shell is, in the schema's own vocabulary, an API caller too).
op.identity.user
op.identity.groups
The Kubernetes identity the request will reach the apiserver with, and its groups.
op.cluster.id
op.cluster.name
The target cluster.
op.cluster.execRecording The cluster's recording posture — independent of the license. For a configuration audit: "is this cluster set up for recording".
op.cluster.recordingActive Posture and the session-recording license allowing use — whether recording will actually start for this session. When the license is removed, posture stays true but this field becomes false; a rule that wants assurance should read this one.
op.revertOf The id of the source plan if this one is a revert, otherwise null.
step.action create · update · patch · delete · stream.
step.method
step.path
The step's HTTP verb and apiserver path — testable with startsWith, contains, matches.
step.target.schemaId The target's schema id: group.singular when there is a group (apps.deployment), just the singular name in the core group (pod).
step.target.group
step.target.version
step.target.kind
step.target.resource
API group ("" for the core group), version, kind, and the plural resource name.
step.target.namespaced Whether the type is namespaced (bool).
step.target.namespace
step.target.name
step.target.subresource
The target's namespace, name, and subresource. All three are always present; absence is represented by "", never by a missing key.
step.classification.severity mutating or destructive.
step.classification.flags The flag list: cluster-scoped, collection, finalizers, owner-managed, orphan-delete, dry-run-unsupported, irreversible, stream, conflicting-declaration, plus type-specific flags shaped subresource:<name>.
step.dryRun.status ok · failed · unsupported · skipped · not-applicable.
step.dryRun.changedPaths The list of field paths dry-run measured as actually changing.
object The new state: the dry-run result, or the request body if there is none. null on a delete step.
oldObject The live prior state. null on a create step.

Two classes of field — and the has() rule

Every field under op and step is a contract field and is always present; you can read it directly. object and oldObject, by contrast, are real Kubernetes objects and their fields may be missing — and reading a missing field is an error in CEL, not a silent false. The error falls to the fail-closed side, which means your policy can deny a plan it never meant to. The rule is simple: guard every path under object / oldObject with has().

# wrong — if the object has no spec the policy throws and the step is denied
match: 'object.spec.replicas == 0'

# right — has() does not throw on a null root either, it returns false
match: 'has(object.spec) && has(object.spec.replicas) && object.spec.replicas == 0'

Supported syntax

The engine's semantics are checked at every startup against a conformance corpus of 54 expressions; if it fails, core does not start. Every construct below is verified in that corpus:

  • Comparison and logic: ==, !=, &&, ||, !, the ternary (? :). Short-circuiting works (false && <error> is false) but errors are not swallowed: true && <error> is an error.
  • in: both list membership (step.target.resource in ["pods", "nodes"]) and map keys ("critical" in oldObject.metadata.labels).
  • has(): does the field exist. Returns false for a missing field instead of throwing.
  • Indexing: oldObject.metadata.labels["app"], step.classification.flags[0]. Careful: reaching for a map key or a list index that is not there is an error — ask with in or has() before you index.
  • String functions: startsWith(), endsWith(), contains(), matches() (regular expression).
  • Macros and size: exists(), all(), size().
  • Comparing against null: object == null, op.revertOf != null. But reaching underneath a null root (object.metadata.name) is an error.
  • Anything unrecognized raises a visible error: unknown variable, unknown field, unknown function, unknown method, syntax error. None of them quietly become false — that is exactly what the corpus is there to measure.

Examples

Eight complete policies. The names live in the organization's own namespace (org.), which is all it takes to avoid colliding with the built-in set.

1) Deletes in production namespaces need elevated approval

policies:
  - id: org.production-delete
    description: "Deletes in production namespaces"
    match: >
      step.action == "delete" &&
      step.target.namespace in ["production", "production-data"]
    effect: require
    level: elevated
    caveats:
      - "Production namespace: open the change record before applying."

Every delete step in those two namespaces raises the card to elevated — the apply button stays disabled until the user types the target's name exactly. The caveats line appears on the card as the operator's own sentence, untranslated.

2) Writing to kube-system is denied

policies:
  - id: org.system-namespace-write
    description: "kube-system is not written through YEKE"
    match: >
      step.action in ["create", "update", "patch", "delete"] &&
      step.target.namespace == "kube-system"
    effect: deny
    message: "kube-system belongs to cluster administration; this change does not go through YEKE."

A single deny on a single step drops the whole plan; the card is drawn as not applicable. The built-in builtin.system-namespace only raises the approval level for the same target — this policy tightens it, because in guardrail the combination always favors the most restrictive rule.

3) Scaling to zero raises a warning

policies:
  - id: org.scale-to-zero-warning
    description: "Remind the on-call when scaling to zero"
    match: >
      has(object.spec) && has(object.spec.replicas) &&
      object.spec.replicas == 0
    effect: warn
    message: "Scaling to zero should be announced to the on-call rotation."

warn does not touch the approval level; it adds a line to the card. Scope note: this condition only sees scaling done through the main object. The canonical path (the scale subresource) carries a Scale object where a value of zero drops out of the body entirely — the built-in builtin.scale-to-zero has a two-branch condition for exactly that reason. Scaling to zero already asks for elevated approval through that built-in; this policy only adds the organization's own sentence on top.

4) Mask a password field in your own type

policies:
  - id: org.database-password-mask
    description: "Password fields in our own database type are masked"
    match: >
      step.target.group == "example.io" &&
      step.target.resource == "databases"
    effect: redact
    redactPaths:
      - "spec.connection.password"
      - "spec.connection.users[].password"
    message: "Password fields are masked in the plan response and the audit trail."

The path format descends with dots, and [] means "every element of this array". The mask replaces the value with a keyed marker: the value becomes unreadable, but whether two records carry the same value is still visible — "did this field change" stays answerable after masking. A Secret's data / stringData are already masked by a built-in; this example carries the same protection to your own type.

Write the condition around what the object carries, not around the verb. Putting step.action in a redact policy drops the mask exactly where it is needed most: on a delete there is no request body, and the only thing the user sees on the card is the whole live object.

5) Every write in one CRD group opens a card

policies:
  - id: org.example-group-approval
    description: "Every write in the example.io group opens an approval card"
    match: 'step.target.group == "example.io"'
    effect: require
    messageCode: SYSTEM_NAMESPACE

With no level, it is standard: one card, no name to type. The structural floor still holds — a delete in the same group is elevated anyway. messageCode is picked from a closed set and shows on the card in the reader's language; when no code in the set fits, write your own sentence with message.

6) Condition on what dry-run actually changes

policies:
  - id: org.replica-change
    description: "Elevated approval when dry-run changes the replica count"
    match: >
      step.dryRun.status == "ok" &&
      "spec.replicas" in step.dryRun.changedPaths
    effect: require
    level: elevated

Because guardrail runs after dry-run, changedPaths is a measurement rather than a guess: even if the request appears to touch that field, the path is absent when the value is unchanged. The status == "ok" clause is deliberate — when dry-run did not run (unsupported, skipped) the list is empty, and a rule reading an empty list would quietly never match.

7) A labelled object cannot be deleted

policies:
  - id: org.critical-label-guard
    description: "An object labelled critical=yes cannot be deleted through YEKE"
    match: >
      step.action == "delete" &&
      has(oldObject.metadata) && has(oldObject.metadata.labels) &&
      "critical" in oldObject.metadata.labels &&
      oldObject.metadata.labels["critical"] == "yes"
    effect: deny
    message: "This object is labelled critical; deleting it goes through a separate process."

Note why both guards are needed: has() asks whether the label map exists, in asks whether the key exists in it. Without the second, an object that has labels but no critical key would make the indexing throw and the step would fail closed to deny — the policy would stop objects it never meant to.

8) No terminal in production while recording is off

policies:
  - id: org.no-unrecorded-terminal-in-production
    description: "No terminal session in production clusters while recording is off"
    match: >
      step.action == "stream" &&
      op.cluster.name.startsWith("production") &&
      !op.cluster.recordingActive
    effect: deny
    message: "A terminal session on this cluster can only open while recording is on."

This is the built-in closed template ops.recording-required, copied into your own namespace. The condition deliberately reads recordingActive, not execRecording: when the license is removed the cluster's recording posture stays true but recording does NOT start, so a rule reading posture alone would quietly go unenforced on that installation.

Effects, levels, and codes

Multiple policies can match a step. Their effects are combined; policy order cannot weaken the checks.

EffectWhat happens
deny The plan is rejected. One deny on one step drops the whole plan. level cannot be written.
require An approval card opens. level: standard is a single approval; level: elevated asks the user to type the target's name exactly — apply stays disabled until they do.
warn Shows as a warning on the card; does not touch the approval level. This is how you try a new rule out in the field.
redact The values at redactPaths are masked in the plan response and in the audit trail. Such a policy never enters the card's policy list — its message does not go over the wire, it stands there for the operator reading the YAML.
  • Levels combine by maximum: approvalLevel = max(matched require levels, the structural floor). A policy cannot lower the floor.
  • Flags, warnings, and class raises accumulate and cannot be taken back: once a policy has pulled a step to destructive, no other policy brings it down.
  • There is exactly one pass. A second round is never run so that another policy could match on a flag a first one added (irreversible, say) — the guarantee that order does not affect the outcome depends on this.
  • Two policies giving the same effect with different messages are both listed. The card does not hide which rule is speaking.

The messageCode values you can write

A closed set — the product's own sentences, translated in both languages:

IRREVERSIBLE_STORAGE · NAMESPACE_DELETE · CRD_DELETE · CLUSTER_SCOPED_DELETE · DRY_RUN_UNSUPPORTED · POD_EVICTION · SYSTEM_NAMESPACE · RBAC_BINDING_WRITE · SCALE_TO_ZERO · NL_STRICT · SHELL_STRICT

The caveatCodes values you can write

REVERT_DELETES_CREATED · REVERT_RESTORES_PRIOR · REVERT_RECREATES_OBJECT · SUBRESOURCE_CREATE_NO_TARGET · FINALIZERS_PENDING · FINALIZERS_ON_DELETING · OWNER_MANAGED · ORPHAN_PROPAGATION · FINALIZERS_DRY_RUN · SEQUENCED_IN_PLAN · STORAGE_DATA_LOST · NAMESPACE_CASCADE · CRD_INSTANCES_DELETED · DRY_RUN_UNVERIFIED · EVICTION_DELETES_POD · RBAC_GRANTS_AUTHORITY · SCALE_TO_ZERO_OUTAGE

For a warning the set has no equivalent for, write your own sentence with caveats. Picking a code is always better — a code is translated, free text is not.

Dual approval, maintenance windows, package mode

All three are Enterprise. The first two live inside the policy data; the third changes where the policy comes from.

The dual-approval fields

policies:
  - id: org.dual-approval
    description: "Deployment changes in production need a second set of eyes"
    match: >
      step.action in ["update", "patch"] &&
      step.target.resource == "deployments" &&
      step.target.namespace == "production"
    effect: require
    level: elevated
    requireSecondApproval: true
    requiredApproverGroup: "sre-approvers"
  • Who counts as an eligible approver: anyone other than the plan owner, whose account is active, and who holds an identity on that cluster through an identity rule. If the policy names a group, that group must also be among the user's resolved groups — the groups come from the identity rule; no second notion of a group is introduced.
  • The set is never stored, it is recomputed every time. A stored list goes stale: accounts are deactivated, identity mappings are removed, groups narrow.
  • The second approver only consents; the plan is still applied by its owner. Consent is bound to the plan's current hash; if the plan is refreshed and the hash changes, the consent falls (the record is not deleted, the gate simply stops counting it) and is asked for again.
  • It is off by default. Until a rule is written, no plan asks for a second approval — a deliberate choice so that a single-admin install is never locked out.
  • requiredApproverGroup cannot stand alone. Narrowing an approval that is never asked for would silently do nothing; the loader rejects it at startup.

A maintenance window — a separate file

The calendar does not live in a policy file but in a separate file in the same directory, under the top-level key freezes:. A file carrying both policies: and freezes: is rejected — what a file contains should be visible the moment it is opened.

# /etc/yeke/policies/90-calendar.yaml
freezes:
  - id: weekend
    description: "Weekend change freeze"
    timezone: Europe/Istanbul          # REQUIRED
    weekly:
      - { from: "Fri 17:00", to: "Mon 09:00" }
    dates:
      - { from: "2026-12-29T00:00:00", to: "2027-01-02T09:00:00" }
    scope: { clusters: ["*"] }
  • timezone is required and is an IANA zone name. "Friday 17:00" is a real question in an organization; assuming UTC produces a silent error whose symptom looks like "the window shifted by two hours".
  • weekly takes the shape "Day HH:MM" — a three-letter weekday (MonSun) on a 24-hour clock.
  • dates are local wall-clock times (YYYY-MM-DDTHH:mm:ss) with no timezone suffix: a suffix would open a second source of time that could contradict the window's own timezone.
  • weekly and dates can both be left empty — a window that never matches is far cheaper than one that always does.
  • scope.clusters is the set of clusters the window covers; ["*"] means all of them. An apply inside the window is rejected and the plan record is left unmutated — the details are on the Governance page.
  • Freeze ids are a separate namespace from policy ids, but must still be unique among themselves: two windows sharing a name would make "which window rejected this" unanswerable in the trail.

Package mode — signed, effective immediately

With YEKE_POLICY_MODE=central the policy set comes from a signed package rather than a directory. The body is written without the signature field, and version must increase on every install:

{
  "version": 3,
  "policies": [
    {
      "id": "org.production-delete",
      "match": "step.action == \"delete\" && step.target.namespace == \"production\"",
      "effect": "require",
      "level": "elevated"
    }
  ],
  "freezes": [
    {
      "id": "weekend",
      "timezone": "Europe/Istanbul",
      "weekly": [{ "from": "Fri 17:00", "to": "Mon 09:00" }],
      "scope": { "clusters": ["*"] }
    }
  ]
}

The fields are identical to file mode — package and file go through the same compiler, so a policy rejected in file mode is rejected in a package too. Signing is done with the organization's own private key:

pnpm --filter @yeke/core exec tsx tools/policy-package/sign.ts \
  --private-key policy-private.pem \
  --package package.json \
  --out package-signed.json

With an encrypted PEM (an ENCRYPTED PRIVATE KEY header, or the older Proc-Type: 4,ENCRYPTED), the passphrase is read from the YEKE_POLICY_KEY_PASSPHRASE environment variable or a secret prompt; a plain PEM never asks for one.

The signed body is installed with PUT /api/policy/package under an admin session; to upload it from a screen, use /admin/policy. The install takes effect immediately and core does not restart — but it does not affect a plan already in flight: the plan record carries the policy version it was classified under, and apply runs with that version.

  • The two modes cannot both be active. Giving YEKE_POLICY_MODE=central together with YEKE_POLICY_DIR stops core with an explicit error — merging two sources would silently answer the question "which of two policies with the same id wins".
  • A bad signature or a stale version does not drop the package in force. The install is rejected and the previous version stays. An equal or lower version is rejected too.
  • The built-in set is loaded in package mode as well. The package is the operator's set; it does not replace the product's own policies.

Key generation, the full install endpoint, and the license behavior are on the Governance page.

Trying a policy out

Start with a warning policy. Review the results before enabling denial or additional approval.

  • Write it as warn first. Ship a new rule with effect: warn, watch it appear on the card for exactly the steps you meant, and only then turn it into require or deny. A badly written deny stops correct operations too.
  • Build the plan, read the card, do not apply. Build a plan from the interface or with POST /api/clusters/:clusterId/operations; the card shows the ids of the matched policies, their messages, the warnings, the approval level, and which fields were masked. Then cancel the plan with POST /api/clusters/:clusterId/operations/:opId/cancel — building a plan applies nothing, and cancelling it leaves a trail entry.
  • The startup log counts what was loaded. As core starts it writes that the conformance corpus passed and how many policies are active, by name; if freeze windows exist it lists them with their zones. If you cannot find your policy's name there, it is either enabled: false or its file was never read.
[guardrail] CEL conformance corpus passed (54 expressions)
[guardrail] 12 active policies loaded (2 files): builtin.irreversible-storage, …
[guardrail] 1 change-freeze windows loaded: weekend (Europe/Istanbul)

Errors that stop startup

The error always names the policy and the file it came from; core never opens for listening.

Policy file is not valid YAML (/etc/yeke/policies/10-production.yaml): …
Policy file does not match the schema (/etc/yeke/policies/10-production.yaml):
  policies.0.effect: Invalid enum value
Policy id defined twice: 'org.production-delete'
  (/etc/yeke/policies/10-production.yaml and /etc/yeke/policies/20-extra.yaml).
  Which one is valid cannot remain ambiguous.
Policy 'org.example' (…) could not be compiled: …
  • An id collision counts across files — and against the built-in set. Give your own policies a prefix.
  • A disabled policy is compiled too. A broken policy written enabled: false still stops startup; it does not defer the error to the day you enable it.
  • A runtime error is not visible at startup. Compilation checks match's syntax and its type; a missing-field error only surfaces once a matching plan is built, and that step becomes deny. Which is why the has() discipline matters.

Built-in policies

Seventeen entries, fourteen of them on. All of them ship as data — the engine knows no resource names at all.

PolicyEffectWhat it does
builtin.irreversible-storagerequire · elevated Deleting a PersistentVolume or PersistentVolumeClaim; adds the irreversible flag.
builtin.namespace-deleterequire · elevated Deleting a namespace — everything inside cascades away with it.
builtin.crd-deleterequire · elevated Deleting a CustomResourceDefinition; every instance of that type goes with it.
builtin.cluster-scoped-deleterequire · elevated Deleting a cluster-scoped object.
builtin.dry-run-unsupportedrequire · elevated An operation dry-run could not run on — an unverified change is being applied.
builtin.pod-evictionrequire · elevated The eviction subresource; raises the class to destructive, because it deletes a pod despite being a create-only request.
builtin.system-namespacerequire · elevated A destructive operation inside kube-system, kube-public, or kube-node-lease.
builtin.rbac-binding-writerequire · elevated Writing a RoleBinding or ClusterRoleBinding — the objects that hand out authority. YEKE's own "grant access" button goes through this gate too.
builtin.scale-to-zerorequire · elevated Bringing the replica count to zero; covers both the main object and the scale subresource branch.
builtin.node-cordonwarn Cordoning a node (closing it to new pods) while the cluster has another schedulable node. Warns without asking for approval.
builtin.node-cordon-last-noderequire · elevated Cordoning the cluster's last schedulable node; raises the class to destructive, because new pods have no node to land on.
builtin.node-cordon-topology-unknownrequire · elevated Cordoning a node when the number of schedulable nodes could not be read — approval is raised because it is unknown whether this is the last one.
builtin.redact-secretsredact A Secret's data and stringData — on reads, writes, and deletes alike.
builtin.redact-env-valuesredact Environment variable values, across the four places a PodSpec can live × the three container lists in it. valueFrom is deliberately out of scope: it holds a reference, not a value.
ops.nl-strictoff A template. Turned on, every write coming from chat (op.source == "nl") asks for elevated approval.
ops.shell-strictoff A template. Turned on, every write from the shell opens its own approval card; the write path authorized by the session grant is disabled.
ops.recording-requiredoff A template. Turned on, denies a stream step while op.cluster.recordingActive is false — a terminal session that will not be recorded cannot open.

Can they be turned off

The enabled field lives inside the built-in file itself, and that file ships with the image. You cannot disable or override a built-in policy from YEKE_POLICY_DIR: defining the same id in a second source stops core at startup, and the built-in set is loaded in package mode as well. What the operator has is addition — guardrail tightens, it does not loosen. If you want the behavior of the three templates above (ops.nl-strict, ops.shell-strict, ops.recording-required), write the same condition as your own policy in your own namespace.

The rule is data, not code

A new CRD is classified with a safe default without touching any code and any policy; the thing that adds type-specific knowledge is the file you write.