diff --git a/CHANGELOG.md b/CHANGELOG.md
index 150cea4..366d5fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project are documented here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html): only a major version changes the
public API, a minor version adds to it, and a patch version fixes it.
+## [Unreleased]
+
+### Added
+
+- `feature_flags.targeting_context()`, `feature_flags.lint_rule()` and `feature_flags.test_rule()`:
+ the facts a targeting rule can read, the check the API runs before accepting one, and a dry run
+ of a rule against a customer or an instance.
+
## [1.0.0] - 2026-10-01
The first release of the Kaiten Python SDK, generated and tested against the contract of
diff --git a/README.md b/README.md
index 423071d..57b58b6 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ client.instances.report_usage("acme-production", "seats", 1)
- **Webhooks included.** Svix signatures verified, all 55 event types parsed into models.
- **Flags through OpenFeature.** Kaiten speaks OFREP, so flags are defined here and
evaluated with the [OpenFeature SDK](#feature-flags) — a vendor-neutral API, not ours.
-- **Contract-tested.** All 108 operations the SDK wraps are exercised against the contract, and
+- **Contract-tested.** All 111 operations the SDK wraps are exercised against the contract, and
every request body is validated against the API's own schema.
## Contents
@@ -302,6 +302,27 @@ client.feature_flags.create(
)
```
+A rule that names a fact or an entitlement that does not exist does not fail: it never matches.
+Check one before it ships — the same check the API runs on create and update — and run it
+against a real customer or instance to see the facts it reads:
+
+```python
+client.feature_flags.targeting_context() # every fact a rule can read under __kaiten
+
+verdict = client.feature_flags.lint_rule("__kaiten.license.familySlug == 'growth'")
+if not verdict.valid:
+ print(verdict.issues[0].message, verdict.issues[0].line, verdict.issues[0].column)
+
+trial = client.feature_flags.test_rule(
+ "__kaiten.entitlements['seats'].percentage >= 0.8",
+ targeting_key="acme",
+ context={"kaiten": {"instanceSlug": "acme-production"}},
+)
+print(trial.matched, trial.facts)
+```
+
+Neither writes, meters nor audits anything.
+
**This SDK does not evaluate flags, on purpose.** Kaiten implements
[OFREP](https://openfeature.dev/specification/appendix-c), the OpenFeature Remote Evaluation
Protocol, so evaluation belongs to [OpenFeature](https://openfeature.dev) itself: install the
@@ -574,10 +595,9 @@ logging.getLogger("kaitencloud").setLevel(logging.INFO)
## API reference
-The Core API clients cover 98 of its 107 operations. Of the nine left out, three are
-rule-editor tooling for the Kaiten console, four are a signed-in person's own notification feed
-and preferences — which an API token has no equivalent of — and two are the OFREP evaluation
-endpoints, which an [OpenFeature provider](#feature-flags) calls. The Platform API clients cover
+The Core API clients cover 101 of its 107 operations. Of the six left out, four are a signed-in
+person's own notification feed and preferences — which an API token has no equivalent of — and
+two are the OFREP evaluation endpoints, which an [OpenFeature provider](#feature-flags) calls. The Platform API clients cover
all 10 of its operations. The async clients have the same methods.
@@ -648,6 +668,9 @@ all 10 of its operations. The async clients have the same methods.
| `client.feature_flags.update()` | `PUT /feature-flags/{featureFlagSlug}` |
| `client.feature_flags.delete()` | `DELETE /feature-flags/{featureFlagSlug}` |
| `client.feature_flags.manifest()` | `GET /openfeature/v0/manifest` |
+| `client.feature_flags.targeting_context()` | `GET /feature-flags/targeting/context` |
+| `client.feature_flags.lint_rule()` | `POST /feature-flags/targeting/lint` |
+| `client.feature_flags.test_rule()` | `POST /feature-flags/targeting/test` |
diff --git a/openapi/coverage.yaml b/openapi/coverage.yaml
index 7a099de..273d5e0 100644
--- a/openapi/coverage.yaml
+++ b/openapi/coverage.yaml
@@ -71,6 +71,10 @@ core:
update-feature-flag: feature_flags.update
delete-feature-flag: feature_flags.delete
get-openfeature-manifest: feature_flags.manifest
+ # targeting rule authoring: what a rule can read, and whether it lints and matches
+ get-targeting-context: feature_flags.targeting_context
+ lint-targeting-rule: feature_flags.lint_rule
+ test-targeting-rule: feature_flags.test_rule
# instances
getInstances: instances.list
@@ -138,12 +142,6 @@ core:
delete-service-account-token: service_accounts.delete_token
excluded:
- get-targeting-context: >-
- Rule-editor tooling for the Kaiten console, with no SDK audience. kaiten's own
- operation-coverage test excludes the same three operations for the same reason.
- lint-targeting-rule: Rule-editor tooling for the Kaiten console, with no SDK audience.
- test-targeting-rule: Rule-editor tooling for the Kaiten console, with no SDK audience.
-
listNotifications: >-
A person's own notification feed. It addresses the signed-in user's rows -- an unread
count and a set of choices about what to be told -- and a machine client holding an API
diff --git a/scripts/generate_models.py b/scripts/generate_models.py
index 1ea111c..441da06 100644
--- a/scripts/generate_models.py
+++ b/scripts/generate_models.py
@@ -96,6 +96,7 @@
("License", "type"): "LicenseType",
("LicenseEntitlement", "entitlementType"): "LicenseEntitlementType",
("MetadataField", "resourceType"): "MetadataResourceType",
+ ("TargetingContextNode", "type"): "TargetingFactType",
}
# Attribute names that would shadow a pydantic.BaseModel member, keyed by (schema, property).
@@ -119,6 +120,7 @@
"MetadataResourceType": "The resource a metadata field applies to.",
"ResetAnchor": "What a periodic usage window is aligned on.",
"ResetPeriod": "How often a periodic entitlement's usage resets.",
+ "TargetingFactType": "The shape of a fact a targeting rule can read.",
"Scope": (
"A permission an organization credential can carry, ``read:`` or "
"``write:``; ``write`` implies ``read`` on the same resource."
diff --git a/src/kaitencloud/_async/resources/feature_flags.py b/src/kaitencloud/_async/resources/feature_flags.py
index 44fdaf0..ffa5a32 100644
--- a/src/kaitencloud/_async/resources/feature_flags.py
+++ b/src/kaitencloud/_async/resources/feature_flags.py
@@ -14,7 +14,10 @@
FeatureFlagType,
ManifestEnvelope,
ManifestFlag,
+ TargetingContext,
TargetingParam,
+ TargetingRuleRehearsal,
+ TargetingRuleVerdict,
VariantParam,
)
from ._base import AsyncResource
@@ -119,6 +122,63 @@ async def update(
)
await self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body)
+ async def targeting_context(self) -> TargetingContext:
+ """Return what a targeting rule can read: the facts Kaiten adds under ``__kaiten``.
+
+ Each node names a fact as a rule writes it, its shape, and for a map the keys it has
+ now -- the organization's entitlement slugs, its instances' metadata keys. A rule may
+ also read any attribute the application puts in its evaluation context.
+ """
+ return await self._api.get("/feature-flags/targeting/context", TargetingContext)
+
+ async def lint_rule(self, rule: str) -> TargetingRuleVerdict:
+ """Check a CEL targeting rule the way create and update do, without writing anything.
+
+ A rule that names a fact or an entitlement that does not exist never matches rather
+ than failing, so this is how to catch it before it ships. A refused rule is a verdict
+ with ``valid=False`` and its positioned issues, not an error.
+ """
+ return await self._api.send(
+ "POST",
+ "/feature-flags/targeting/lint",
+ TargetingRuleVerdict,
+ body={"rule": rule},
+ idempotent=True,
+ )
+
+ async def test_rule(
+ self,
+ rule: str,
+ *,
+ targeting_key: str | None = None,
+ context: Mapping[str, Any] | None = None,
+ ) -> TargetingRuleRehearsal:
+ """Run a targeting rule against one evaluation context, as an evaluation would.
+
+ Nothing is written, metered or audited. The context is enriched from the same sources
+ an evaluation uses, so the answer carries the facts the rule actually read alongside
+ whether it matched -- usually the answer to why it did not.
+
+ Args:
+ rule: The CEL expression.
+ targeting_key: Who is evaluating; a customer slug gets that customer's license and
+ entitlements.
+ context: The rest of the evaluation context, e.g.
+ ``{"kaiten": {"instanceSlug": "acme-production"}}`` for an instance's facts.
+ """
+ body: dict[str, Any] = {"rule": rule}
+ if targeting_key is not None:
+ body["targetingKey"] = targeting_key
+ if context is not None:
+ body["context"] = dict(context)
+ return await self._api.send(
+ "POST",
+ "/feature-flags/targeting/test",
+ TargetingRuleRehearsal,
+ body=body,
+ idempotent=True,
+ )
+
async def delete(self, feature_flag_slug: str) -> None:
"""Delete the feature flag identified by ``feature_flag_slug``."""
await self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug))
diff --git a/src/kaitencloud/_sync/resources/feature_flags.py b/src/kaitencloud/_sync/resources/feature_flags.py
index d0f2413..b65a54e 100644
--- a/src/kaitencloud/_sync/resources/feature_flags.py
+++ b/src/kaitencloud/_sync/resources/feature_flags.py
@@ -16,7 +16,10 @@
FeatureFlagType,
ManifestEnvelope,
ManifestFlag,
+ TargetingContext,
TargetingParam,
+ TargetingRuleRehearsal,
+ TargetingRuleVerdict,
VariantParam,
)
from ._base import Resource
@@ -121,6 +124,63 @@ def update(
)
self._api.send_empty("PUT", api_path("feature-flags", feature_flag_slug), body=body)
+ def targeting_context(self) -> TargetingContext:
+ """Return what a targeting rule can read: the facts Kaiten adds under ``__kaiten``.
+
+ Each node names a fact as a rule writes it, its shape, and for a map the keys it has
+ now -- the organization's entitlement slugs, its instances' metadata keys. A rule may
+ also read any attribute the application puts in its evaluation context.
+ """
+ return self._api.get("/feature-flags/targeting/context", TargetingContext)
+
+ def lint_rule(self, rule: str) -> TargetingRuleVerdict:
+ """Check a CEL targeting rule the way create and update do, without writing anything.
+
+ A rule that names a fact or an entitlement that does not exist never matches rather
+ than failing, so this is how to catch it before it ships. A refused rule is a verdict
+ with ``valid=False`` and its positioned issues, not an error.
+ """
+ return self._api.send(
+ "POST",
+ "/feature-flags/targeting/lint",
+ TargetingRuleVerdict,
+ body={"rule": rule},
+ idempotent=True,
+ )
+
+ def test_rule(
+ self,
+ rule: str,
+ *,
+ targeting_key: str | None = None,
+ context: Mapping[str, Any] | None = None,
+ ) -> TargetingRuleRehearsal:
+ """Run a targeting rule against one evaluation context, as an evaluation would.
+
+ Nothing is written, metered or audited. The context is enriched from the same sources
+ an evaluation uses, so the answer carries the facts the rule actually read alongside
+ whether it matched -- usually the answer to why it did not.
+
+ Args:
+ rule: The CEL expression.
+ targeting_key: Who is evaluating; a customer slug gets that customer's license and
+ entitlements.
+ context: The rest of the evaluation context, e.g.
+ ``{"kaiten": {"instanceSlug": "acme-production"}}`` for an instance's facts.
+ """
+ body: dict[str, Any] = {"rule": rule}
+ if targeting_key is not None:
+ body["targetingKey"] = targeting_key
+ if context is not None:
+ body["context"] = dict(context)
+ return self._api.send(
+ "POST",
+ "/feature-flags/targeting/test",
+ TargetingRuleRehearsal,
+ body=body,
+ idempotent=True,
+ )
+
def delete(self, feature_flag_slug: str) -> None:
"""Delete the feature flag identified by ``feature_flag_slug``."""
self._api.send_empty("DELETE", api_path("feature-flags", feature_flag_slug))
diff --git a/src/kaitencloud/types/__init__.py b/src/kaitencloud/types/__init__.py
index c128fc0..86d1c02 100644
--- a/src/kaitencloud/types/__init__.py
+++ b/src/kaitencloud/types/__init__.py
@@ -80,6 +80,12 @@
ServiceAccount,
SystemTokenIssuance,
Targeting,
+ TargetingContext,
+ TargetingContextNode,
+ TargetingFactType,
+ TargetingRuleIssue,
+ TargetingRuleRehearsal,
+ TargetingRuleVerdict,
Targetings,
Token,
UsageBehavior,
@@ -186,7 +192,13 @@
"ServiceAccount",
"SystemTokenIssuance",
"Targeting",
+ "TargetingContext",
+ "TargetingContextNode",
+ "TargetingFactType",
"TargetingParam",
+ "TargetingRuleIssue",
+ "TargetingRuleRehearsal",
+ "TargetingRuleVerdict",
"Targetings",
"Token",
"UsageBehavior",
diff --git a/src/kaitencloud/types/_core.py b/src/kaitencloud/types/_core.py
index c09dbd4..833b070 100644
--- a/src/kaitencloud/types/_core.py
+++ b/src/kaitencloud/types/_core.py
@@ -83,6 +83,12 @@
"ServiceAccount",
"SystemTokenIssuance",
"Targeting",
+ "TargetingContext",
+ "TargetingContextNode",
+ "TargetingFactType",
+ "TargetingRuleIssue",
+ "TargetingRuleRehearsal",
+ "TargetingRuleVerdict",
"Targetings",
"Token",
"UsageBehavior",
@@ -156,6 +162,9 @@
``write:``; ``write`` implies ``read`` on the same resource.
"""
+TargetingFactType = Literal["string", "number", "boolean", "object", "map", "dyn"]
+"""The shape of a fact a targeting rule can read."""
+
UsageBehavior = Literal["append", "set"]
"""How a usage report applies: ``append`` folds the value in, ``set`` overwrites it."""
@@ -1451,6 +1460,85 @@ class SystemTokenIssuance(KaitenModel):
"""Server-assigned identifier of the minted credential"""
+class TargetingContext(KaitenModel):
+ roots: list[TargetingContextNode]
+ """Identifiers the server guarantees, and what is reachable below each. Not a closed set: a
+ host may target on its own attributes too
+ """
+
+
+class TargetingContextNode(KaitenModel):
+ description: str | None = None
+ """What this value means, shown by the editor"""
+
+ fields: list[TargetingContextNode] | None = None
+ """Named children, for an object"""
+
+ known_keys: list[str] | None = Field(default=None, alias="knownKeys")
+ """Keys this map currently has, for completion. Not a closed set"""
+
+ name: str
+ """Name as it is written in a rule"""
+
+ optional: bool | None = None
+ """Whether this value may be absent, in which case has() is worth using"""
+
+ type: TargetingFactType | str
+ """Shape of this node's value"""
+
+ values: TargetingContextNode | None = None
+ """Shape shared by every entry, for a map"""
+
+
+class TargetingRuleIssue(KaitenModel):
+ column: int
+ """1-based column the problem starts on, or 0 when it has no position"""
+
+ end_column: int | None = Field(default=None, alias="endColumn")
+ """1-based column just past the problem, or 0 when only a point is known"""
+
+ end_line: int | None = Field(default=None, alias="endLine")
+ """1-based line the problem ends on, or 0 when only a point is known"""
+
+ line: int
+ """1-based line the problem starts on, or 0 when it has no position"""
+
+ message: str
+ """What is wrong, in the same words the write path refuses it with"""
+
+
+class TargetingRuleRehearsal(KaitenModel):
+ evaluation_error: str | None = Field(default=None, alias="evaluationError")
+ """Why the engine could not answer, when it could not — e.g. the rule reads a host
+ attribute this context does not carry. At a real evaluation the rule would simply not
+ match
+ """
+
+ facts: dict[str, Any]
+ """The __kaiten namespace as enriched for this context — what a rule actually reads. Empty
+ sub-roots mean the context resolved nothing for them
+ """
+
+ issues: list[TargetingRuleIssue]
+ """Problems the lint found. The rule is only run when there are none"""
+
+ matched: bool
+ """Whether the rule matched this context. Meaningful only when valid and evaluationError is
+ empty
+ """
+
+ valid: bool
+ """Whether the rule lints clean"""
+
+
+class TargetingRuleVerdict(KaitenModel):
+ issues: list[TargetingRuleIssue]
+ """Every problem found, positioned where it is written. Empty when the rule is accepted"""
+
+ valid: bool
+ """Whether the write path would accept this rule"""
+
+
class Token(KaitenModel):
"""A token's metadata. Never its secret."""
@@ -1574,6 +1662,11 @@ class Variant(KaitenModel):
RolloutStep,
ServiceAccount,
SystemTokenIssuance,
+ TargetingContext,
+ TargetingContextNode,
+ TargetingRuleIssue,
+ TargetingRuleRehearsal,
+ TargetingRuleVerdict,
Token,
User,
Variant,
diff --git a/tests/contract.py b/tests/contract.py
index 43452a5..ea8a8cb 100644
--- a/tests/contract.py
+++ b/tests/contract.py
@@ -149,6 +149,11 @@ def _without_read_only(node: Any) -> Any:
return node
+# What sample() answers for a schema already being expanded: a recursive schema stops there,
+# as an empty array or an absent property, which is still a payload the schema describes.
+_RECURSION = object()
+
+
def sample(
schema: Any, surface: str, *, full: bool = True, seen: frozenset[str] = frozenset()
) -> Any:
@@ -158,7 +163,7 @@ def sample(
if "$ref" in schema:
name = schema["$ref"].rsplit("/", 1)[-1]
if name in seen:
- return None
+ return _RECURSION
target = document(surface)["components"]["schemas"][name]
return sample(target, surface, full=full, seen=seen | {name})
for key in ("oneOf", "anyOf"):
@@ -185,7 +190,8 @@ def sample(
# False, so a sampled page never claims more rows than it holds.
return False
if kind == "array":
- return [sample(schema.get("items"), surface, full=full, seen=seen)] if full else []
+ item = sample(schema.get("items"), surface, full=full, seen=seen)
+ return [item] if full and item is not _RECURSION else []
if kind == "object":
properties: dict[str, Any] = schema.get("properties") or {}
required = set(schema.get("required") or [])
@@ -194,6 +200,7 @@ def sample(
for name, node in properties.items()
if full or name in required
}
+ value = {name: v for name, v in value.items() if v is not _RECURSION}
extra = schema.get("additionalProperties")
if not properties and full:
value["key"] = (
diff --git a/tests/test_contract.py b/tests/test_contract.py
index 0ce28e0..44ca9b6 100644
--- a/tests/test_contract.py
+++ b/tests/test_contract.py
@@ -306,6 +306,22 @@ class Case:
),
Case("delete-feature-flag", lambda c: c.feature_flags.delete("new-checkout")),
Case("get-openfeature-manifest", lambda c: c.feature_flags.manifest()),
+ Case("get-targeting-context", lambda c: c.feature_flags.targeting_context()),
+ Case(
+ "lint-targeting-rule",
+ lambda c: c.feature_flags.lint_rule("__kaiten.license.slug == 'growth'"),
+ required=frozenset({"rule"}),
+ ),
+ Case(
+ "test-targeting-rule",
+ lambda c: c.feature_flags.test_rule(
+ "__kaiten.instance.status == 'HEALTHY'",
+ targeting_key="acme",
+ context={"kaiten": {"instanceSlug": "acme-production"}},
+ ),
+ minimal=lambda c: c.feature_flags.test_rule("true"),
+ required=frozenset({"rule"}),
+ ),
# instances
Case("getInstances", lambda c: c.instances.list()),
Case("getInstance", lambda c: c.instances.get("acme-production")),