From a49db2e30c1f5ff40909cec10760650213236f35 Mon Sep 17 00:00:00 2001 From: tomflenner Date: Fri, 16 Oct 2026 00:20:21 +0200 Subject: [PATCH] feat: targeting rule authoring, for agents that write flags Wraps the three operations coverage.yaml excluded as console-only tooling: get-targeting-context, lint-targeting-rule and test-targeting-rule, as feature_flags.targeting_context, lint_rule and test_rule. They have an SDK audience now. An agent writing a flag through the SDK (the Kaiten MCP server is one) has no editor to tell it that a rule names a fact or an entitlement that does not exist, and such a rule never fails: it never matches. These are how it finds out before it saves. TargetingContextNode is the first recursive model, so the test payload generator learned to stop a recursion with an empty array or an absent property, rather than a null the schema does not allow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: tomflenner --- CHANGELOG.md | 8 ++ README.md | 33 ++++++- openapi/coverage.yaml | 10 +- scripts/generate_models.py | 2 + .../_async/resources/feature_flags.py | 60 ++++++++++++ .../_sync/resources/feature_flags.py | 60 ++++++++++++ src/kaitencloud/types/__init__.py | 12 +++ src/kaitencloud/types/_core.py | 93 +++++++++++++++++++ tests/contract.py | 11 ++- tests/test_contract.py | 16 ++++ 10 files changed, 292 insertions(+), 13 deletions(-) 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")),