From 336ba4f972b6bcfebbea0ab26bda15a019209f05 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 21 Aug 2026 04:25:49 +0000 Subject: [PATCH 1/7] fix(schema): make the lenient read set open unknown fields at every depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strict/lenient split (#871) only ever opened the document ROOT: the lenient set is the strict producers minus the `close_unknown_fields` pass, and that pass closes the root and the top-level definitions. Every other closure — the ones a producer injects by hand, and the ones `schemars` emits from a nested `#[serde(deny_unknown_fields)]` struct — stood in both validator sets, and the config structs' `deny_unknown_fields` stood in the type itself, where the loader deserializes the same types and cannot opt out. So an additive optional field inside a nested config object was not the ignored-and-reported case the compat contract promises: it was row-fatal one release back. For a guardrail that means the content policy stops enforcing (`custom_patterns[].replacement` does exactly this to a v0.10.0 data plane); for an exporter, telemetry goes dark. The read set now strips `additionalProperties: false` at every depth (`open_unknown_fields`), and the write contract that used to live in the structs moves into the strict schema: the guardrail producer closes its struct-shaped definitions and its kind branches, with the flattened parent's properties copied into each branch first, so the allowed set is exactly what serde enforced. Tolerated is not silent. `serde_ignored` never fires inside serde-buffered content — the flattened tagged config of a guardrail or exporter, an untagged `ConditionNode`/`OnEmbeddingFailure` — and a `oneOf` failure collapses to one root-level error naming no field, so the loader takes the unknown-field paths for those four resources from `unknown_field_paths`, which reads them off the strict schema. Published schemas: only `guardrail.schema.json` changes, and only by stating what serde already enforced (the ten kind branches gain the shared root properties and `additionalProperties: false`). No resource's write contract loosens. --- CLAUDE.md | 3 +- crates/aisix-core/src/bin/dump-schema.rs | 18 +- crates/aisix-core/src/models/guardrail.rs | 60 +- crates/aisix-core/src/models/mod.rs | 13 +- .../src/models/observability_exporter.rs | 81 +- crates/aisix-core/src/models/schema.rs | 740 ++++++++++++++++-- crates/aisix-etcd/src/loader.rs | 100 +++ .../guardrail_read_path_forward_compat.rs | 60 ++ schemas/resources/guardrail.schema.json | 450 +++++++++++ 9 files changed, 1391 insertions(+), 134 deletions(-) create mode 100644 crates/aisix-server/tests/guardrail_read_path_forward_compat.rs diff --git a/CLAUDE.md b/CLAUDE.md index f1f4dba1e..907be6265 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,8 @@ The five kinds are the cross-plane taxonomy (cp-admin.yaml `kind`); this repo's - The per-target invariant (`crates/aisix-proxy/AGENTS.md`: "a per-model gate binds each target") is written around `resolve_attempt_models` — the routing-group trunk. **Ensemble panel/judge (`ProxyModelCaller::call`, the streaming judge) and semantic targets (`semantic::resolve`) bypass that trunk**, so a gate wired only into the trunk is silently absent there (the 2026-08 audit found member IP allowlist, health consumption, and retries all missing on the semantic path for exactly this reason — #958). A new per-target gate must be wired into the sub-dispatch paths too, or explicitly deferred with a filed issue. Prefer routing every dispatch through one shared chokepoint so the family can't drift. - **Strict writes, lenient loads.** `model_one_of` has two variants: the **strict** schema (declarative resources file, the published `schemas/resources/model.schema.json`, every strict validator consumer) forbids a knob a kind never resolves — accepted-but-unread config is the #962 class; the **lenient** loader keeps the base XOR so stored rows written by an older build still load, with `Model::strip_kind_inapplicable` dropping the dead knob and reporting it as `inapplicable:` through the partial-compat channel. The two lists MUST mirror each other exactly (strict-forbidden ⇔ lenient-stripped) — a field forbidden-but-not-stripped half-honors; stripped-but-not-forbidden vanishes on load while the write path accepts it. A knob is enforced exactly as written or rejected, never half-honored (#963). - **Never make a field of a projected resource required at the TYPE level.** Requiredness belongs in the strict schema (`require_property` in `models/schema.rs`), never in the struct: the loader validates leniently and then deserializes, and a row it cannot deserialize is **skipped entirely** (`aisix-etcd/src/loader.rs`). Skipping is survivable for a resource the request path treats as optional, but an `api_key` row that fails to load stops authenticating **every** kind of traffic, not just the feature whose field changed — a far worse outcome than the field defaulting. So a new non-`Option` field, or one that loses `#[serde(default)]`, silently turns every already-projected row into a dead one. Give it a serde default whose meaning is fail-closed, and add it to `required` in the strict schema so the write path still refuses to guess. The control plane must also re-emit the affected collection once (`ReprojectMcpAclOnce` is the pattern) — the stored shape changed, but nothing else re-projects a row whose *content* did not. (Lesson from #993: `allow` was required at the type level in #992, which made every key still projected as `mcp_access: {"mode": "inherit"}` unloadable.) -- **Never change a projected field's shape or value domain in place — the previous release must keep loading the row.** The supported upgrade order is control plane first, then data planes, with an arbitrarily long window; a released DP is immutable, so whatever the new CP projects must still parse one release back. Unknown *fields* are tolerated by design (serde_ignored → partial-compat report), but a malformed *known* field — a reshape, a lost default, a new enum value — fails the row and the loader skips it whole (the blast radius of the rule above). A reshape therefore ships under a NEW field name (the old one is never reused), or as a **same-name dual-generation document** when the old and new keys don't collide: the CP emits one document valid for both generations, and this side carries a consumed-and-ignored tombstone for the old selector — `McpAccess::legacy_mode` is the template: `#[serde(default, rename = "...", skip_serializing)]` + `#[schemars(skip)]` so the strict write path still rejects it, with a `COMPAT-SINCE:` marker naming the retirement condition (see the next section). A new enum value in an existing field cannot be made safe DP-side at all (lenient parsing keeps enums closed — it row-kills every older DP), so the paired CP PR must gate it behind `dpCompatGate` until the fleet minimum reads it. Whenever new semantics are invisible to the old release, verify the old default direction there: fail-closed or no-op is required; if it is fail-open, the CP must project an old-shape tombstone at the most restrictive value. +- **Never change a projected field's shape or value domain in place — the previous release must keep loading the row.** The supported upgrade order is control plane first, then data planes, with an window that is minutes long in practice but must still be survived; a released DP is immutable, so whatever the new CP projects must still parse one release back. Unknown *fields* are tolerated by design at every depth, nested config objects included, and reported through the partial-compat channel — but only from the release that opened the read schema all the way down; a 0.10.0 or older binary drops the whole row for a field added inside a guardrail or exporter config block, and always will, because a released binary cannot be taught anything. A malformed *known* field — a reshape, a lost default, a new enum value — fails the row and the loader skips it whole on every release (the blast radius of the rule above). Two constraints keep the tolerance real on this side: unknown-field strictness lives in the strict schema (`models/schema.rs`), never in a nested `#[serde(deny_unknown_fields)]` — the loader deserializes the same types and cannot opt out of a type-level guard; and `serde_ignored` never fires inside serde-buffered content (a `#[serde(flatten)]`ed tagged enum, an untagged variant), so those resources take their report from `unknown_field_paths` instead of loading silently. A reshape therefore ships under a NEW field name (the old one is never reused), or as a **same-name dual-generation document** when the old and new keys don't collide: the CP emits one document valid for both generations, and this side carries a consumed-and-ignored tombstone for the old selector — `McpAccess::legacy_mode` is the template: `#[serde(default, rename = "...", skip_serializing)]` + `#[schemars(skip)]` so the strict write path still rejects it, with a `COMPAT-SINCE:` marker naming the retirement condition (see the next section). A new enum value in an existing field cannot be made safe DP-side at all (lenient parsing keeps enums closed — it row-kills every older DP), so the paired CP PR must gate it behind `dpCompatGate` until the fleet minimum reads it. Whenever new semantics are invisible to the old release, verify the old default direction there: fail-closed or no-op is required; if it is fail-open, the CP must project an old-shape tombstone at the most restrictive value. +- **Size the mitigation to what the window costs.** The window is minutes, not a season — a control plane and its data planes are upgraded back to back — and anything that merely degrades in it self-heals on the next DP build: a knob not yet honoured, a field reported instead of read, a resource serving its previous semantics. That does not earn a dual-generation document, a tombstone, or holding a finished feature back a release; ship it and let the CP warn at save time (`dpCompatGate`). Three things do not self-heal, and keep the heavy technique regardless of how short the window is: a run-once migration, which rewrites stored data permanently; a security control the old binary cannot see, where the exposure has already happened however briefly (fail-closed or an old-shape tombstone, never bare degradation); and a **core carrier row** — `api_keys`, models, provider keys — where a skipped row is not a degradation but an outage, since that key authenticates nothing at all until the DP is upgraded. If a change is none of the three, the proportionate mitigation is the report. - **`ensemble` is an experimental surface.** Its known parity gaps — member `allowed_cidrs`/guardrail/cooldown/health consumption, Prometheus token+spend attribution, response caching, parent-level generic knobs — are deliberate TODOs under a single future design pass. Do NOT piecemeal-fix one gap ahead of that pass, and do NOT re-audit them as fresh findings. (The one exception is a marshal-family or shared-chokepoint change where covering ensemble is a one-line parallel edit, e.g. projecting an entry-level field the DP already enforces.) - Adding a NEW kind = sweeping every existing model-keyed mechanism against it (grep the kind predicates in `models/model.rs`; every hit re-answers the questions above). diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 35bf65cb1..911781532 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -49,23 +49,7 @@ fn main() { // `ensemble`/`rate_limit`/`routing` have no standalone validator (they // are nested struct types) so they dump straight from the struct via // `schema_for!`, closed the same way. - for resource in [ - "api_key", - "cache_policy", - "model", - "rate_limit_policy", - "provider_key", - "observability_exporter", - "guardrail", - "guardrail_attachment", - "mcp_server", - "mcp_policy", - "a2a_agent", - "oidc_provider", - "claim_mapping", - "passthrough_route", - "mcp_auth_settings", - ] { + for resource in schema::RESOURCES { dump_value( &out_dir, resource, diff --git a/crates/aisix-core/src/models/guardrail.rs b/crates/aisix-core/src/models/guardrail.rs index c15218e5f..10b38e0c7 100644 --- a/crates/aisix-core/src/models/guardrail.rs +++ b/crates/aisix-core/src/models/guardrail.rs @@ -78,7 +78,6 @@ pub enum KeywordPattern { /// Config block for `kind: "keyword"`. #[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct KeywordConfig { /// Blocklist patterns. An empty list is valid and allows every /// request, equivalent to `enabled: false`. @@ -122,7 +121,6 @@ pub enum BedrockLatencyMode { /// projection time so the DP always holds plaintext in memory. The /// key is never logged. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct AzureContentSafetyConfig { /// Azure Cognitive Services resource endpoint, e.g. /// `https://my-resource.cognitiveservices.azure.com`. @@ -158,7 +156,6 @@ fn default_acs_timeout_ms() -> u32 { /// default. Only `api_key` is a secret (decrypted by cp-api before kine /// projection). Every other field travels in the clear. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct AzureContentSafetyTextModerationConfig { /// Azure Cognitive Services resource endpoint. The gateway appends /// `/contentsafety/text:analyze?api-version=2024-09-01`. @@ -282,7 +279,6 @@ fn default_acs_on_buffer_exceeded() -> String { /// projection. It is plaintext in DP memory only and never logged). Every other /// field travels in the clear. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct AliyunTextModerationConfig { /// Aliyun region the guardrail lives in, e.g. `cn-shanghai`. The gateway /// builds the endpoint `https://green-cip..aliyuncs.com`. @@ -361,7 +357,6 @@ fn default_aliyun_risk_level_threshold() -> String { /// projection. It is plaintext in DP memory only and never logged). Every /// other field travels in the clear. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct AliyunAiGuardrailConfig { /// Aliyun region the guardrail lives in, e.g. `cn-shanghai`. The gateway /// builds the endpoint `https://green-cip..aliyuncs.com`. @@ -423,7 +418,6 @@ fn default_aliyun_aig_service_level() -> String { /// detector to enable; `action` optionally overrides the guardrail-level /// `default_action` for this detector only. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct PiiDetectorConfig { /// Built-in detector to enable for this PII guardrail entry. #[serde(rename = "type")] @@ -437,7 +431,6 @@ pub struct PiiDetectorConfig { /// One operator-supplied regex detector for `kind: "pii"`. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct PiiCustomPattern { /// Detector name surfaced in the mask token (`[_REDACTED]`), /// telemetry counts, and block reasons. Never the matched value. @@ -478,7 +471,6 @@ pub struct PiiCustomPattern { /// content-filter envelope. Matched values never appear in logs, telemetry, /// or error envelopes — only detector names and match counts do. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct PiiConfig { /// Built-in detectors to enable. The resource schema rejects unknown /// detector ids, so a typo cannot silently disable the policy. @@ -521,7 +513,6 @@ fn default_aliyun_window_overlap_size() -> u32 { /// `aisix-guardrails::BedrockGuardrail` from this and dispatches /// `ApplyGuardrail` on every governed request. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct BedrockConfig { /// Guardrail identifier issued by the AWS console. #[schemars(length(min = 1, max = 64))] @@ -553,7 +544,6 @@ pub struct BedrockConfig { /// configuration is applied; the plaintext is held in memory only and is /// never logged. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct LakeraConfig { /// Lakera API key sent as a `Authorization: Bearer` header. Decrypted /// before projection. Plaintext is held in memory only and is not logged. @@ -604,7 +594,6 @@ pub struct LakeraConfig { /// configuration is applied; the plaintext is held in memory only and is /// never logged. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] -#[serde(deny_unknown_fields)] pub struct OpenaiModerationConfig { /// OpenAI API key sent as a `Authorization: Bearer` header. Decrypted /// before projection. Plaintext is held in memory only and is not logged. @@ -649,7 +638,6 @@ fn default_openai_moderation_model() -> String { /// `action` optionally overrides the guardrail-level `default_action` for /// this entity only — the same per-detector shape as `kind: "pii"`. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct PresidioEntityConfig { /// Presidio entity type, e.g. `EMAIL_ADDRESS`, `PERSON`, `US_SSN`. #[serde(rename = "type")] @@ -671,7 +659,6 @@ pub struct PresidioEntityConfig { /// compliance posture, and selectable anonymize operators. No vendor secret: /// both URLs point at customer-run containers. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] -#[serde(deny_unknown_fields)] pub struct PresidioConfig { /// Presidio analyzer base URL, e.g. `http://presidio-analyzer:3000`. /// The gateway appends `/analyze`. @@ -737,6 +724,14 @@ fn default_presidio_language() -> String { /// Provider discriminator. The kind drives which `*_config` block is /// expected. Serde's `tag = "kind"` keeps us honest at parse time. +/// +/// The per-kind configs deliberately carry no `#[serde(deny_unknown_fields)]`: +/// `Guardrail` flattens this enum, so a type-level guard here also applies to +/// the etcd loader, which deserialises the same types — and a guardrail row +/// dropped for one unknown field is a content policy that silently stops +/// enforcing. Unknown-field strictness belongs to the write path and lives in +/// the branch and definition closures in +/// [`guardrail_root_schema`](crate::models::schema::guardrail_root_schema). #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum GuardrailKind { @@ -1120,30 +1115,34 @@ mod tests { } #[test] - fn unknown_field_rejected_by_inner_kind_struct() { - // The outer Guardrail can't use deny_unknown_fields (see its - // doc comment), but the inner KeywordConfig does — and serde - // surfaces unknown fields from the flattened inner type at - // the top level. Net effect: typos are still caught. + fn unknown_field_is_rejected_on_write_and_kept_on_read() { + // The typo guard used to be `deny_unknown_fields` on the config + // structs, which sat in the TYPE — so it also applied to the etcd + // loader, which deserialises the same types, and killed the whole + // row. It now lives in the strict schema: `aisix validate` and the + // file source still refuse the typo, while a stored document keeps + // loading with the field ignored. let v = json!({ "name": "g", "kind": "keyword", "patterns": [], "extra": "nope" }); - let r: Result = serde_json::from_value(v); - assert!(r.is_err()); + assert!(crate::models::validate_guardrail(&v).is_err()); + assert!(crate::models::validate_guardrail_lenient(&v).is_ok()); + let g: Guardrail = serde_json::from_value(v).expect("the read path keeps the row"); + assert_eq!(g.name, "g"); } #[test] - fn p0c_fields_dont_trip_keyword_config_deny_unknown_fields() { - // `KeywordConfig` has `deny_unknown_fields`. The P0c fields - // (`enforcement_mode`, `mandatory`, `direction`) are declared on - // the outer `Guardrail` struct with `#[serde(default)]`, so serde - // absorbs them at the outer level before the flattened inner sees - // the remaining fields. This test pins that routing: if any of - // these fields accidentally reached `KeywordConfig`, the parse - // would return an unknown-field error. + fn p0c_fields_travel_past_the_flattened_kind() { + // The P0c fields (`enforcement_mode`, `mandatory`, `direction`) are + // declared on the outer `Guardrail` struct with `#[serde(default)]`, + // so serde absorbs them at the outer level before the flattened + // inner kind sees the remaining fields. The strict schema owes them + // the same passage from the other direction: a closed kind branch + // lists only its own kind's fields, so the root properties are + // copied in (`guardrail_root_schema`) or every real document fails. let v = json!({ "name": "g", "kind": "keyword", @@ -1152,8 +1151,9 @@ mod tests { "mandatory": true, "direction": "input" }); - let g: Guardrail = serde_json::from_value(v) - .expect("P0c fields must not trip KeywordConfig deny_unknown_fields"); + assert!(crate::models::validate_guardrail(&v).is_ok()); + let g: Guardrail = + serde_json::from_value(v).expect("outer fields must not reach the inner kind"); assert_eq!(g.enforcement_mode, "monitor"); assert!(g.mandatory); assert_eq!(g.direction, "input"); diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index 5c13d627a..139f7d437 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -81,12 +81,13 @@ pub use routing::{ WhenAllUnavailablePolicy, }; pub use schema::{ - validate_a2a_agent, validate_a2a_agent_lenient, validate_apikey, validate_apikey_lenient, - validate_cache_policy, validate_cache_policy_lenient, validate_claim_mapping, - validate_claim_mapping_lenient, validate_guardrail, validate_guardrail_attachment, - validate_guardrail_attachment_lenient, validate_guardrail_lenient, validate_mcp_auth_settings, - validate_mcp_auth_settings_lenient, validate_mcp_policy, validate_mcp_policy_lenient, - validate_mcp_server, validate_mcp_server_lenient, validate_model, validate_model_lenient, + unknown_field_paths, validate_a2a_agent, validate_a2a_agent_lenient, validate_apikey, + validate_apikey_lenient, validate_cache_policy, validate_cache_policy_lenient, + validate_claim_mapping, validate_claim_mapping_lenient, validate_guardrail, + validate_guardrail_attachment, validate_guardrail_attachment_lenient, + validate_guardrail_lenient, validate_mcp_auth_settings, validate_mcp_auth_settings_lenient, + validate_mcp_policy, validate_mcp_policy_lenient, validate_mcp_server, + validate_mcp_server_lenient, validate_model, validate_model_lenient, validate_observability_exporter, validate_observability_exporter_lenient, validate_oidc_provider, validate_oidc_provider_lenient, validate_passthrough_route, validate_passthrough_route_lenient, validate_provider_key, validate_provider_key_lenient, diff --git a/crates/aisix-core/src/models/observability_exporter.rs b/crates/aisix-core/src/models/observability_exporter.rs index 024d764fc..95c08cb53 100644 --- a/crates/aisix-core/src/models/observability_exporter.rs +++ b/crates/aisix-core/src/models/observability_exporter.rs @@ -35,6 +35,13 @@ use serde::{Deserialize, Serialize}; use crate::resource::Resource; /// Exporter backend selected by the `kind` discriminator. +/// +/// The per-kind configs deliberately carry no `#[serde(deny_unknown_fields)]`: +/// a type-level guard here would also apply to the etcd loader, which +/// deserialises the same types and would drop the whole exporter row the +/// first time a newer control plane added a field. Unknown-field strictness +/// is the write path's, and lives in the branch closures in +/// [`observability_exporter_root_schema`](crate::models::schema::observability_exporter_root_schema). #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ExporterKind { @@ -46,7 +53,6 @@ pub enum ExporterKind { /// OTLP/HTTP trace exporter configuration. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq)] -#[serde(deny_unknown_fields)] pub struct OtlpHttpConfig { /// Full URL of the OTLP/HTTP traces endpoint. Include the receiver's /// expected path, such as `/v1/traces`. @@ -76,7 +82,6 @@ pub struct OtlpHttpConfig { /// Aliyun SLS PutLogs exporter configuration. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct AliyunSlsConfig { /// SLS regional endpoint host without a scheme, such as `ap-southeast-3.log.aliyuncs.com`. /// Signed requests are sent to this endpoint with the SLS project as the host prefix. @@ -127,7 +132,6 @@ const fn default_content_max_bytes() -> u32 { /// Datadog native Logs HTTP intake exporter configuration. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct DatadogConfig { /// Datadog site, such as `datadoghq.com`, `us3.datadoghq.com`, or `datadoghq.eu`. #[schemars(regex( @@ -177,7 +181,6 @@ const fn default_dd_content_max_bytes() -> u32 { /// Object-storage exporter configuration for S3, GCS, Azure Blob, and compatible S3 backends. #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] pub struct ObjectStoreConfig { /// Which object-storage backend the bucket lives in. pub provider: ObjectStoreProvider, @@ -403,13 +406,19 @@ mod tests { } #[test] - fn rejects_unknown_inner_field() { - // OtlpHttpConfig has deny_unknown_fields so a typo at the - // flattened level is rejected. - let r: Result = serde_json::from_str( + fn rejects_unknown_inner_field_on_write_and_ignores_it_on_read() { + // The closed kind branch in the strict schema is what refuses a typo + // at the flattened level. The loader's lenient twin drops that + // closure, so a stored exporter written by a newer control plane + // keeps exporting with the field ignored instead of going dark. + let v: serde_json::Value = serde_json::from_str( r#"{"name":"x","kind":"otlp_http","endpoint":"https://x","timeout":5}"#, - ); - assert!(r.is_err()); + ) + .unwrap(); + assert!(crate::models::validate_observability_exporter(&v).is_err()); + assert!(crate::models::validate_observability_exporter_lenient(&v).is_ok()); + let e: ObservabilityExporter = serde_json::from_value(v).unwrap(); + assert!(matches!(e.kind, ExporterKind::OtlpHttp(_))); } #[test] @@ -497,17 +506,23 @@ mod tests { #[test] fn rejects_plaintext_credentials_in_config() { // The AccessKey must NEVER be a config field — only a - // `credential_ref`. `deny_unknown_fields` on the inner config - // rejects any attempt to smuggle a plaintext key onto the kine path. + // `credential_ref`. The closed kind branch in the strict schema + // rejects any attempt to smuggle a plaintext key in; on the read + // path the field is ignored, never consumed as a credential. for key in ["access_key_secret", "access_key_id", "ak", "sk"] { - let json = format!( + let v: serde_json::Value = serde_json::from_str(&format!( r#"{{"name":"x","kind":"aliyun_sls","endpoint":"ap-southeast-3.log.aliyuncs.com","project":"p","logstore":"l","credential_ref":"r","{key}":"AKIASECRET"}}"# - ); - let r: Result = serde_json::from_str(&json); + )) + .unwrap(); assert!( - r.is_err(), + crate::models::validate_observability_exporter(&v).is_err(), "plaintext credential field `{key}` must be rejected" ); + let e: ObservabilityExporter = serde_json::from_value(v).unwrap(); + assert!( + !serde_json::to_string(&e).unwrap().contains("AKIASECRET"), + "plaintext credential field `{key}` must not be retained" + ); } } @@ -606,21 +621,27 @@ mod tests { #[test] fn rejects_plaintext_credentials_in_object_store_config() { // Cloud keys must NEVER be config fields — only `credential_ref`. - // `deny_unknown_fields` rejects any smuggled plaintext secret. + // The closed kind branch in the strict schema rejects any smuggled + // plaintext secret; on the read path it is ignored, never consumed. for key in [ "access_key_id", "secret_access_key", "sas_token", "service_account_json", ] { - let json = format!( + let v: serde_json::Value = serde_json::from_str(&format!( r#"{{"name":"x","kind":"object_store","provider":"s3","bucket":"b","prefix":"p","credential_ref":"r","{key}":"PLAINTEXT"}}"# - ); - let r: Result = serde_json::from_str(&json); + )) + .unwrap(); assert!( - r.is_err(), + crate::models::validate_observability_exporter(&v).is_err(), "plaintext credential field `{key}` must be rejected" ); + let e: ObservabilityExporter = serde_json::from_value(v).unwrap(); + assert!( + !serde_json::to_string(&e).unwrap().contains("PLAINTEXT"), + "plaintext credential field `{key}` must not be retained" + ); } } @@ -695,17 +716,23 @@ mod tests { #[test] fn rejects_plaintext_api_key_in_datadog_config() { // The Datadog API key must NEVER be a config field — only a - // `credential_ref`. `deny_unknown_fields` on the inner config rejects - // any attempt to smuggle a plaintext key onto the kine path. + // `credential_ref`. The closed kind branch in the strict schema + // rejects any attempt to smuggle a plaintext key in; on the read + // path it is ignored, never consumed as a credential. for key in ["api_key", "apikey", "dd_api_key", "key"] { - let json = format!( + let v: serde_json::Value = serde_json::from_str(&format!( r#"{{"name":"x","kind":"datadog","site":"datadoghq.com","credential_ref":"r","service":"s","{key}":"DDSECRET"}}"# - ); - let r: Result = serde_json::from_str(&json); + )) + .unwrap(); assert!( - r.is_err(), + crate::models::validate_observability_exporter(&v).is_err(), "plaintext credential field `{key}` must be rejected" ); + let e: ObservabilityExporter = serde_json::from_value(v).unwrap(); + assert!( + !serde_json::to_string(&e).unwrap().contains("DDSECRET"), + "plaintext credential field `{key}` must not be retained" + ); } } } diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index 90ac21041..c2a7bbf1f 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -26,12 +26,21 @@ //! ignored — and reported — instead of whole-row rejected. //! //! Both sets build from the same per-resource producers; strictness is a -//! mechanical [`close_unknown_fields`] pass over the produced value, so the -//! two can never drift field-wise. Deliberately closed subschemas (the -//! `observability_exporter` branches and the guardrail tagged sub-enums, -//! injected inside the producers) stay closed in BOTH sets: serde silently -//! ignores unknown fields inside those tagged shapes, so an open schema -//! there would be an unreportable — silent — tolerance. +//! mechanical pair of passes over the produced value — [`close_unknown_fields`] +//! for the write set, [`open_unknown_fields`] for the read set — so the two +//! can never drift field-wise. Every closure, whether the write pass placed it +//! or a producer injected it by hand (the `observability_exporter` and +//! guardrail kind branches, the guardrail tagged sub-enums, the untagged +//! `ConditionNode`/`OnEmbeddingFailure` variants), belongs to the write set +//! alone: the read pass strips `additionalProperties: false` at EVERY depth, +//! so an optional field a newer control plane added inside a nested config +//! object is ignored instead of taking the whole row down with it. +//! +//! Tolerated is not the same as silent. Unknown fields inside serde-buffered +//! content — everything under the guardrail/exporter `#[serde(flatten)]`ed +//! tagged config, or inside an untagged variant — never reach +//! `serde_ignored`, so the loader takes their paths from +//! [`unknown_field_paths`], which reads them off the strict schema. //! //! The watch path reuses step 2 on incoming events — malformed payloads are //! skipped with a warning and do not take down the gateway. @@ -71,6 +80,28 @@ pub static SCHEMAS: Lazy> = Lazy::new(|| Arc::new(Schemas::compile( /// against this set (issue #871); every write path stays on [`SCHEMAS`]. pub static LENIENT_SCHEMAS: Lazy> = Lazy::new(|| Arc::new(Schemas::compile(false))); +/// Every resource with a runtime validator, by the name +/// [`resource_root_schema`] takes. The published schema files and the +/// validator sets are built from this list, so a new resource cannot reach +/// one without reaching the other. +pub const RESOURCES: [&str; 15] = [ + "model", + "api_key", + "provider_key", + "guardrail", + "guardrail_attachment", + "cache_policy", + "observability_exporter", + "rate_limit_policy", + "mcp_server", + "mcp_policy", + "a2a_agent", + "oidc_provider", + "claim_mapping", + "passthrough_route", + "mcp_auth_settings", +]; + /// Whether a resource's write contract closes unknown top-level fields. /// `cache_policy`, `guardrail`, `guardrail_attachment` and /// `observability_exporter` historically ship open root schemas (documented @@ -108,8 +139,12 @@ pub fn resource_root_schema(resource: &str, strict: bool) -> Value { "mcp_auth_settings" => mcp_auth_settings_root_schema(), other => panic!("unknown resource {other:?}"), }; - if strict && closes_on_write(resource) { - close_unknown_fields(&mut schema); + if strict { + if closes_on_write(resource) { + close_unknown_fields(&mut schema); + } + } else { + open_unknown_fields(&mut schema); } schema } @@ -156,16 +191,25 @@ impl Schemas { /// map-typed field; /// - enum-shaped definitions (no `properties`) are skipped. pub fn close_unknown_fields(schema: &mut Value) { - fn close_object(node: &mut Value) { - let Some(obj) = node.as_object_mut() else { - return; - }; - if obj.contains_key("properties") && !obj.contains_key("additionalProperties") { - obj.insert("additionalProperties".to_string(), json!(false)); - } + close_object(schema); + close_definitions(schema); +} + +/// Insert `additionalProperties: false` on one schema node, if it is a plain +/// object schema that has not already stated its own answer. +fn close_object(node: &mut Value) { + let Some(obj) = node.as_object_mut() else { + return; + }; + if obj.contains_key("properties") && !obj.contains_key("additionalProperties") { + obj.insert("additionalProperties".to_string(), json!(false)); } +} - close_object(schema); +/// Close every struct-shaped `definitions` entry, leaving the root alone. +/// Used on its own by the producers of resources whose ROOT stays open on the +/// write path but whose nested structs do not (`guardrail`). +fn close_definitions(schema: &mut Value) { if let Some(Value::Object(defs)) = schema.get_mut("definitions") { for def in defs.values_mut() { close_object(def); @@ -173,6 +217,200 @@ pub fn close_unknown_fields(schema: &mut Value) { } } +/// Open a produced resource schema against unknown fields at EVERY depth: +/// drop `additionalProperties: false` wherever it sits — the root, a +/// `definitions` entry, a `oneOf`/`anyOf` branch, a nested property. +/// +/// Not a mirror of [`close_unknown_fields`], deliberately. Closing is a +/// shallow, hand-placed decision (closing an `if`/`then` overlay would reject +/// every field the overlay does not list), so it touches only the root and +/// the definitions. Opening has to reach everywhere a closure can be written +/// — including the ones `schemars` renders from a nested +/// `#[serde(deny_unknown_fields)]` struct and the ones a producer injects by +/// hand — because a closure the read path leaves standing is a whole stored +/// row lost the first time a newer control plane adds a field under it. +/// +/// The read set is therefore free of `additionalProperties: false` by +/// construction, which is what makes "an additive optional field is ignored +/// by older data planes" true at every nesting depth instead of only at the +/// document root. +pub fn open_unknown_fields(schema: &mut Value) { + match schema { + Value::Object(obj) => { + if obj.get("additionalProperties") == Some(&Value::Bool(false)) { + obj.remove("additionalProperties"); + } + for child in obj.values_mut() { + open_unknown_fields(child); + } + } + Value::Array(items) => { + for item in items { + open_unknown_fields(item); + } + } + _ => {} + } +} + +/// The resources whose unknown-field report cannot come from +/// `serde_ignored`, mapped to their strict schema. +/// +/// `serde_ignored` reports a field only when the ignored-field callback +/// fires, and it never fires inside serde-buffered content: a +/// `#[serde(flatten)]`ed internally-tagged enum (the entire `guardrail` and +/// `observability_exporter` config subtree) and untagged variants +/// (`ConditionNode`, `OnEmbeddingFailure`) are deserialised from a buffered +/// `Content`, out of the wrapper's sight. Those are exactly the places the +/// read schema now leaves open, so without a second source the tolerance +/// would be silent. +/// +/// A resource listed here must not carry a schema-hidden but +/// serde-consumed field — a `#[schemars(skip)]` tombstone like +/// `McpAccess::legacy_mode`. This walk reads known field names off the +/// schema, so a field the schema does not mention would be reported as +/// unknown on every row that carries it. +static REPORT_SCHEMAS: Lazy> = Lazy::new(|| { + [ + "guardrail", + "observability_exporter", + "model", + "rate_limit_policy", + ] + .into_iter() + .map(|resource| (resource, resource_root_schema(resource, true))) + .collect() +}); + +/// Paths of the fields `value` carries that this build's write contract for +/// `resource` does not know, at every depth — the loader's unknown-field +/// report for the resources `serde_ignored` cannot see into +/// ([`REPORT_SCHEMAS`]). Returns an empty vector for every other resource. +/// +/// `jsonschema` cannot stand in for this: a `oneOf` failure collapses to one +/// root-level "not valid under any of the schemas" error that names no field, +/// and every guardrail and exporter document is a `oneOf`. +/// +/// Conservative by construction. A key is reported only when NO branch +/// applicable at that position declares it, so cross-kind leakage (a +/// `datadog` exporter carrying an `otlp_http` field) does not show up here — +/// that is a malformed document, not a field from a newer build, and the +/// write path rejects it. Map-valued fields (`headers`, +/// `severity_threshold_by_category`) declare a value schema rather than +/// properties, so their keys are never unknown. +pub fn unknown_field_paths(resource: &str, value: &Value) -> Vec { + let Some((_, schema)) = REPORT_SCHEMAS.iter().find(|(name, _)| *name == resource) else { + return Vec::new(); + }; + let empty = serde_json::Map::new(); + let definitions = schema + .get("definitions") + .and_then(Value::as_object) + .unwrap_or(&empty); + let mut out = Vec::new(); + collect_unknown_fields(&[schema], definitions, value, "", &mut out); + out +} + +/// Flatten the schema nodes that apply to one value position: the node +/// itself, whatever its `$ref` names, and every `allOf`/`oneOf`/`anyOf` +/// member. Branch selection is deliberately skipped — the union of what all +/// branches declare is what keeps [`unknown_field_paths`] conservative. +fn expand_applicable<'a>( + nodes: &[&'a Value], + definitions: &'a serde_json::Map, +) -> Vec<&'a Value> { + let mut pending: Vec<&Value> = nodes.to_vec(); + let mut applicable = Vec::new(); + while let Some(node) = pending.pop() { + let Some(obj) = node.as_object() else { + continue; + }; + if let Some(name) = obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix("#/definitions/")) + { + if let Some(target) = definitions.get(name) { + pending.push(target); + } + } + for combinator in ["allOf", "oneOf", "anyOf"] { + if let Some(Value::Array(members)) = obj.get(combinator) { + pending.extend(members.iter()); + } + } + if obj.contains_key("properties") + || obj.contains_key("additionalProperties") + || obj.contains_key("items") + { + applicable.push(node); + } + } + applicable +} + +fn collect_unknown_fields( + nodes: &[&Value], + definitions: &serde_json::Map, + value: &Value, + path: &str, + out: &mut Vec, +) { + let applicable = expand_applicable(nodes, definitions); + match value { + Value::Object(fields) => { + // Nothing here describes an object shape (a `true` schema, an + // enum) — no field name can be called unknown against it. + if !applicable.iter().any(|n| n.get("properties").is_some()) { + return; + } + for (key, child) in fields { + let mut child_nodes: Vec<&Value> = Vec::new(); + for node in &applicable { + if let Some(declared) = node.get("properties").and_then(|p| p.get(key)) { + child_nodes.push(declared); + } else if let Some(values) = node + .get("additionalProperties") + .filter(|v| v.is_object() || **v == Value::Bool(true)) + { + child_nodes.push(values); + } + } + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + if child_nodes.is_empty() { + out.push(child_path); + } else { + collect_unknown_fields(&child_nodes, definitions, child, &child_path, out); + } + } + } + Value::Array(items) => { + let item_nodes: Vec<&Value> = applicable + .iter() + .filter_map(|node| node.get("items")) + .collect(); + if item_nodes.is_empty() { + return; + } + for (index, item) in items.iter().enumerate() { + collect_unknown_fields( + &item_nodes, + definitions, + item, + &format!("{path}.{index}"), + out, + ); + } + } + _ => {} + } +} + #[derive(Debug, Error)] #[error("schema validation failed at `{path}`: {message}")] pub struct SchemaError { @@ -441,11 +679,11 @@ pub fn model_root_schema(strict: bool) -> Value { .insert("oneOf".to_string(), one_of); // `OnEmbeddingFailure` is `#[serde(untagged)]` with an object variant // (`{ "target": … }`): serde buffers untagged content and silently - // swallows unknown fields inside it, invisible to both the write - // path's serde step and the loader's `serde_ignored` reporting. The - // schema closure is therefore the only non-silent guard — same - // reasoning as the tagged-enum branch closures below, applied to - // both validator sets. + // swallows unknown fields inside it, invisible to the write path's + // serde step and to `serde_ignored` alike. The schema closure is + // therefore the write path's only guard — and, once the read pass + // opens it, what `unknown_field_paths` reads to keep the loader's + // tolerance from being silent. if let Some(any_of) = schema .get_mut("definitions") .and_then(|d| d.get_mut("OnEmbeddingFailure")) @@ -845,22 +1083,33 @@ pub fn mcp_policy_root_schema(strict: bool) -> Value { /// Canonical JSON Schema for the `guardrail` resource, derived from the /// [`Guardrail`](crate::models::Guardrail) struct. `schemars` renders the -/// internally-tagged `GuardrailKind` as a native top-level `oneOf`; the -/// top-level object and its branches are intentionally open (matching the -/// hand-written schema — unknown inner fields are caught by serde at -/// deserialize). Three things need fixing up: +/// internally-tagged `GuardrailKind` as a native top-level `oneOf`. Five +/// things need fixing up: /// -/// 1. The tagged sub-enums (`KeywordPattern`/`BedrockAWSCredentials`/ +/// 1. Unknown-field strictness, which used to live in the config structs as +/// `#[serde(deny_unknown_fields)]` and now lives here. `Guardrail` +/// flattens a tagged enum, so serde hands the whole config subtree to a +/// buffered `Content`: a type-level `deny` there applied to the etcd READ +/// path as well, where it killed every row whose control plane had added +/// a field, and it was invisible in the published schema besides. The +/// write contract is expressed instead by closing every struct-shaped +/// definition and every kind branch — the shared root properties are +/// copied into each branch first, since a closed branch lists only its own +/// kind's fields while the document also carries `name`, `enabled`, +/// `hook_point`, … from the flattened parent. Allowed = root fields ∪ +/// `kind` ∪ that kind's fields: exactly what serde enforced, now on the +/// write path only ([`open_unknown_fields`] strips it for the loader). +/// 2. The tagged sub-enums (`KeywordPattern`/`BedrockAWSCredentials`/ /// `BedrockLatencyMode`) lose `deny_unknown_fields` in their `oneOf` /// branches, so each is re-closed with `additionalProperties: false`. -/// 2. The stringly-typed moderation fields carry closed sets the hand-written +/// 3. The stringly-typed moderation fields carry closed sets the hand-written /// schema enforced via `enum`. They stay `String` on the struct (their /// values flow through `aisix-guardrails` as strings; converting them to /// Rust enums would churn that crate's processing), so the closed set is /// injected here into the relevant property. -/// 3. `schemars` leaves discriminator tag fields and collection item schemas +/// 4. `schemars` leaves discriminator tag fields and collection item schemas /// without descriptions, so the public schema fills those gaps. -/// 4. `created_at` republishes its `date-time` format (annotation-only). +/// 5. `created_at` republishes its `date-time` format (annotation-only). pub fn guardrail_root_schema() -> Value { let mut schema = struct_root_schema::(false); let obj = schema @@ -1048,6 +1297,32 @@ pub fn guardrail_root_schema() -> Value { created_at.insert("format".to_string(), json!("date-time")); } + // Point 1 of the doc comment: the write-path closure the config structs + // used to carry in their types. Copy the flattened parent's properties + // into each branch before closing it, or the closure would reject + // `name`/`enabled`/`hook_point`/… on every document. + let top_props = obj + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if let Some(Value::Array(branches)) = obj.get_mut("oneOf") { + for branch in branches.iter_mut() { + let Some(b) = branch.as_object_mut() else { + continue; + }; + let props = b + .entry("properties".to_string()) + .or_insert_with(|| json!({})); + if let Some(props) = props.as_object_mut() { + for (key, value) in &top_props { + props.entry(key.clone()).or_insert_with(|| value.clone()); + } + } + b.insert("additionalProperties".to_string(), json!(false)); + } + } + close_definitions(&mut schema); schema } @@ -1203,7 +1478,10 @@ pub fn cache_policy_root_schema() -> Value { /// serde does not enforce it there either, so each branch is re-closed with /// `additionalProperties: false` (rejecting a smuggled plaintext secret). /// Because a closed branch only lists its own kind's fields, the shared -/// top-level `name`/`enabled` are copied into every branch. +/// top-level `name`/`enabled` are copied into every branch. This is the +/// WRITE contract: [`open_unknown_fields`] strips it for the loader, which +/// would otherwise lose the whole exporter row — telemetry off for the +/// length of the upgrade window — over one optional field. /// 2. The `object_store` cloud-identity cross-field rule (cloud_identity ⇒ /// provider ∈ {s3,gcs} and no credential_ref; otherwise credential_ref /// required) is injected as an `allOf`/`if`/`then`/`else` — `schemars` can't @@ -1283,11 +1561,12 @@ fn branch_kind(branch: &serde_json::Map) -> Option<&str> { /// ([`super::rate_limit_policy::rate_limit_policy_form_one_of`]), which also /// carries the classic form's "at least one of `max_requests`/`max_tokens`"; /// - the `PolicySchedule` day-selector XOR; -/// - closing the `ConditionNode` object variants in **both** validator sets: -/// the node is `#[serde(untagged)]`, so serde buffers its content and -/// silently swallows unknown fields inside it, invisible to the write -/// path's serde step and the loader's `serde_ignored` reporting alike — the -/// schema closure is the only non-silent guard (same reasoning as +/// - closing the `ConditionNode` object variants on the write path: the node +/// is `#[serde(untagged)]`, so serde buffers its content and silently +/// swallows unknown fields inside it, invisible to the write path's serde +/// step and to `serde_ignored` alike — the schema closure is the only +/// guard there, and the only thing [`unknown_field_paths`] can read to +/// report what the opened read schema now lets through (same reasoning as /// `OnEmbeddingFailure` in [`model_root_schema`]). /// /// The tree caps (depth/leaf counts), the operator×dimension admission @@ -3167,10 +3446,11 @@ mod tests { } #[test] - fn rate_limit_policy_rejects_unknown_field_inside_condition_node() { + fn rate_limit_policy_condition_node_unknown_field_writes_red_reads_reported() { // ConditionNode is #[serde(untagged)]: serde silently swallows - // unknown fields inside untagged content, so the schema closure - // on the node definitions is the only guard — in both sets. + // unknown fields inside untagged content, so the schema closure is + // the only guard on write — and `unknown_field_paths` is the only + // report on read, since serde_ignored cannot see in there either. let v = json!({ "name": "sneaky", "conditions": [ @@ -3179,7 +3459,11 @@ mod tests { "limits": { "rpm": 5 } }); assert!(validate_rate_limit_policy(&v).is_err()); - assert!(validate_rate_limit_policy_lenient(&v).is_err()); + validate_rate_limit_policy_lenient(&v).expect("a stored policy keeps limiting"); + assert_eq!( + unknown_field_paths("rate_limit_policy", &v), + vec!["conditions.0.extra".to_string()] + ); } #[test] @@ -3594,6 +3878,340 @@ mod tests { // ---- strict-write / lenient-read split (issue #871) ---- + #[test] + fn lenient_set_carries_no_closure_at_any_depth() { + // The mechanical guarantee behind "an additive optional field is + // ignored, not fatal": there is nowhere left in the read set for a + // closure to hide. A single `additionalProperties: false` surviving + // in here — placed by a producer, or emitted by `schemars` from a + // nested `#[serde(deny_unknown_fields)]` struct — is one whole + // stored row lost the first time a newer control plane writes a + // field under it. + for resource in RESOURCES { + let mut found = Vec::new(); + closure_paths(&resource_root_schema(resource, false), "", &mut found); + assert!( + found.is_empty(), + "{resource} read schema still closes {found:?}" + ); + } + } + + fn closure_paths(node: &Value, path: &str, out: &mut Vec) { + match node { + Value::Object(obj) => { + if obj.get("additionalProperties") == Some(&Value::Bool(false)) { + out.push(path.to_string()); + } + for (key, child) in obj { + closure_paths(child, &format!("{path}/{key}"), out); + } + } + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + closure_paths(item, &format!("{path}/{index}"), out); + } + } + _ => {} + } + } + + /// Every nested object the read path used to close, one document each, + /// carrying a field this build does not know. Before this pair of passes + /// existed, every one of these was a skipped row on a data plane one + /// release behind its control plane — a guardrail that stopped + /// enforcing, an exporter that stopped exporting. + #[test] + fn formerly_closed_nested_objects_load_on_read_and_are_reported() { + let bedrock = |credentials: Value, latency: Value| { + json!({ + "name": "b", "kind": "bedrock", + "guardrail_id": "gr-1", "guardrail_version": "1", "region": "us-east-1", + "aws_credentials": credentials, + "latency_mode": latency, + }) + }; + let serial = json!({"kind": "serial"}); + let credentials = json!({"kind": "static", "access_key_id": "a", "secret_access_key": "b"}); + let cases: Vec<(&str, Value, &str)> = vec![ + ( + "guardrail", + json!({ + "name": "p", "kind": "pii", + "custom_patterns": [{"name": "n", "regex": "x", "future_knob": 1}] + }), + "custom_patterns.0.future_knob", + ), + ( + "guardrail", + json!({ + "name": "p", "kind": "pii", + "detectors": [{"type": "email", "future_knob": 1}] + }), + "detectors.0.future_knob", + ), + ( + "guardrail", + json!({ + "name": "p", "kind": "presidio", + "analyzer_url": "http://a", "anonymizer_url": "http://b", + "entities": [{"type": "EMAIL_ADDRESS", "future_knob": 1}] + }), + "entities.0.future_knob", + ), + ( + "guardrail", + json!({ + "name": "k", "kind": "keyword", + "patterns": [{"kind": "literal", "value": "x", "future_knob": 1}] + }), + "patterns.0.future_knob", + ), + ( + "guardrail", + bedrock( + json!({ + "kind": "static", "access_key_id": "a", "secret_access_key": "b", + "future_knob": 1 + }), + serial.clone(), + ), + "aws_credentials.future_knob", + ), + ( + "guardrail", + bedrock( + credentials.clone(), + json!({"kind": "serial", "future_knob": 1}), + ), + "latency_mode.future_knob", + ), + ( + "guardrail", + bedrock( + credentials, + json!({"kind": "timed", "timeout_ms": 200, "future_knob": 1}), + ), + "latency_mode.future_knob", + ), + ( + // Not a nested object at all: the guardrail ROOT, where the + // guard was `deny_unknown_fields` on the flattened kind + // config rather than a schema closure. Same blast radius. + "guardrail", + json!({"name": "k", "kind": "keyword", "patterns": [], "future_knob": 1}), + "future_knob", + ), + ( + "observability_exporter", + json!({ + "name": "o", "kind": "otlp_http", + "endpoint": "https://otel.example/v1/traces", "future_knob": 1 + }), + "future_knob", + ), + ( + "observability_exporter", + json!({ + "name": "o", "kind": "aliyun_sls", + "endpoint": "ap-southeast-3.log.aliyuncs.com", + "project": "p", "logstore": "l", "credential_ref": "r", + "future_knob": 1 + }), + "future_knob", + ), + ( + "observability_exporter", + json!({ + "name": "o", "kind": "object_store", "provider": "s3", + "bucket": "b", "prefix": "p", "credential_ref": "r", + "future_knob": 1 + }), + "future_knob", + ), + ( + "observability_exporter", + json!({ + "name": "o", "kind": "datadog", "site": "datadoghq.com", + "credential_ref": "r", "service": "s", "future_knob": 1 + }), + "future_knob", + ), + ( + "rate_limit_policy", + json!({ + "name": "r", + "conditions": [ + {"logic": "and", "future_knob": 1, "children": [ + {"dimension": "team", "operator": "==", "value": "t"} + ]} + ], + "limits": {"rpm": 5} + }), + "conditions.0.future_knob", + ), + ( + "model", + json!({ + "display_name": "s", + "semantic": { + "embedding_model": "e", + "routes": [{"name": "a", "target": "m", "examples": ["x"]}], + "default": "d", + "match": {"threshold": 0.5}, + "on_embedding_failure": {"target": "t", "future_knob": 1} + } + }), + "semantic.on_embedding_failure.future_knob", + ), + ]; + + for (resource, document, field) in cases { + let strict = strict_validator(resource); + let lenient = lenient_validator(resource); + assert!( + validate(strict, &document).is_err(), + "{resource}/{field}: the write contract must keep rejecting the typo" + ); + validate(lenient, &document).unwrap_or_else(|err| { + panic!("{resource}/{field}: stored row must keep loading, got {err}") + }); + assert_eq!( + unknown_field_paths(resource, &document), + vec![field.to_string()], + "{resource}/{field}: tolerated is not the same as silent" + ); + } + } + + #[test] + fn opening_the_read_set_does_not_disturb_one_of_selection() { + // The reason opening was safe to begin with: every union among the + // formerly-closed objects discriminates on a `kind` const, not on + // the closure. The sharpest case is a document whose extra field is + // exactly a SIBLING branch's field — under `oneOf`, a second + // matching branch would fail the document just as hard as the + // closure used to. + let sibling_field = json!({ + "name": "b", "kind": "bedrock", + "guardrail_id": "gr-1", "guardrail_version": "1", "region": "us-east-1", + "aws_credentials": {"kind": "static", "access_key_id": "a", "secret_access_key": "b"}, + // `timeout_ms` belongs to the `timed` branch; `serial` no longer + // rejects it, and must still be the only branch that matches. + "latency_mode": {"kind": "serial", "timeout_ms": 200} + }); + validate_guardrail_lenient(&sibling_field).expect("exactly one branch still matches"); + assert!(validate_guardrail(&sibling_field).is_err()); + let parsed: crate::models::Guardrail = + serde_json::from_value(sibling_field.clone()).expect("row deserializes"); + match &parsed.config { + crate::models::GuardrailKind::Bedrock(cfg) => assert!( + matches!(cfg.latency_mode, crate::models::BedrockLatencyMode::Serial), + "the `kind` const, not the closure, picks the branch" + ), + other => panic!("wrong kind: {}", other.kind_str()), + } + // A sibling branch declares `timeout_ms`, so the report stays quiet: + // it is a malformed document, not a field from a newer build, and + // the strict path above is what says so. + assert!(unknown_field_paths("guardrail", &sibling_field).is_empty()); + + // Same at the top level: a `keyword` guardrail carrying a `pii` + // field still resolves to `keyword`, because every other branch + // pins its own `kind`. Cross-kind leakage is a malformed document, + // not a newer build's field, so the write path rejects it and the + // report stays quiet about it (see `unknown_field_paths`). + let cross_kind = json!({ + "name": "k", "kind": "keyword", "patterns": [], + "detectors": [{"type": "email"}] + }); + validate_guardrail_lenient(&cross_kind).expect("exactly one branch still matches"); + assert!(validate_guardrail(&cross_kind).is_err()); + let parsed: crate::models::Guardrail = + serde_json::from_value(cross_kind.clone()).expect("row deserializes"); + assert_eq!(parsed.config.kind_str(), "keyword"); + assert!(unknown_field_paths("guardrail", &cross_kind).is_empty()); + } + + #[test] + fn valid_documents_report_no_unknown_fields() { + // The other half of the report's contract: a document this build + // fully understands must stay GREEN. A field the schema cannot see + // but serde consumes — a `#[schemars(skip)]` tombstone — would show + // up here as a permanent YELLOW on every row, which is why the + // resources `unknown_field_paths` covers must not carry one. + let documents: Vec<(&str, Value)> = vec![ + ( + "guardrail", + json!({ + "name": "p", "kind": "pii", "enabled": true, "hook_point": "both", + "fail_open": false, "enforcement_mode": "monitor", "mandatory": true, + "direction": "input", "created_at": "2026-01-01T00:00:00Z", + "detectors": [{"type": "email", "action": "mask"}], + "custom_patterns": [ + {"name": "n", "regex": "x(y)", "action": "mask", "replacement": "***"} + ], + "default_action": "mask" + }), + ), + ( + "observability_exporter", + json!({ + "name": "o", "enabled": true, "kind": "otlp_http", + "endpoint": "https://otel.example/v1/traces", + "headers": {"x-team": "abc"} + }), + ), + ( + "rate_limit_policy", + json!({ + "name": "r", + "conditions": [ + {"logic": "and", "children": [ + {"dimension": "team", "operator": "==", "value": "t"} + ]} + ], + "limits": {"rpm": 5} + }), + ), + ( + "model", + json!({ + "display_name": "d", "provider": "openai", "model_name": "gpt-4o", + "provider_key_id": "11111111-1111-1111-1111-111111111111" + }), + ), + ]; + for (resource, document) in documents { + validate(strict_validator(resource), &document) + .unwrap_or_else(|err| panic!("{resource} fixture must be valid: {err}")); + assert!( + unknown_field_paths(resource, &document).is_empty(), + "{resource}: a fully known document must not report unknown fields" + ); + } + } + + fn strict_validator(resource: &str) -> &'static Validator { + match resource { + "guardrail" => &SCHEMAS.guardrail, + "observability_exporter" => &SCHEMAS.observability_exporter, + "rate_limit_policy" => &SCHEMAS.rate_limit_policy, + "model" => &SCHEMAS.model, + other => panic!("no strict validator wired for {other}"), + } + } + + fn lenient_validator(resource: &str) -> &'static Validator { + match resource { + "guardrail" => &LENIENT_SCHEMAS.guardrail, + "observability_exporter" => &LENIENT_SCHEMAS.observability_exporter, + "rate_limit_policy" => &LENIENT_SCHEMAS.rate_limit_policy, + "model" => &LENIENT_SCHEMAS.model, + other => panic!("no lenient validator wired for {other}"), + } + } + #[test] fn lenient_set_tolerates_unknown_fields_strict_set_rejects() { let v = json!({ @@ -3624,35 +4242,47 @@ mod tests { } #[test] - fn lenient_set_keeps_deliberate_closures_closed() { - // The observability-exporter branches guard the credential_ref - // indirection against a smuggled plaintext secret, and serde - // cannot report ignored fields inside tagged-enum content — so - // these closures must hold on the READ path too, or the - // tolerance would be silent. + fn lenient_set_opens_the_producers_closures_and_reports_them_instead() { + // These closures used to hold on the read path too, on the argument + // that serde cannot report ignored fields inside tagged-enum + // content and a silent tolerance is worse than a rejection. But a + // rejection here is the whole row: an exporter that stops + // exporting, a guardrail that stops enforcing, because a newer + // control plane added one optional field. The read path opens them + // and `unknown_field_paths` supplies the report serde cannot. let exporter = json!({ "name": "o", "kind": "otlp_http", "endpoint": "https://otel.example/v1/traces", "smuggled_secret": "sk-x" }); - assert!(validate_observability_exporter_lenient(&exporter).is_err()); + assert!(validate_observability_exporter(&exporter).is_err()); + validate_observability_exporter_lenient(&exporter) + .expect("a stored exporter keeps exporting"); + assert_eq!( + unknown_field_paths("observability_exporter", &exporter), + vec!["smuggled_secret".to_string()] + ); - // Same for the guardrail tagged sub-enums: serde silently - // swallows unknown fields inside inline-tagged variants. + // Same for the guardrail tagged sub-enums. let guardrail = json!({ "name": "kw", "kind": "keyword", "patterns": [{"kind": "literal", "value": "x", "extra": 1}] }); - assert!(validate_guardrail_lenient(&guardrail).is_err()); + assert!(validate_guardrail(&guardrail).is_err()); + validate_guardrail_lenient(&guardrail).expect("a stored guardrail keeps enforcing"); + assert_eq!( + unknown_field_paths("guardrail", &guardrail), + vec!["patterns.0.extra".to_string()] + ); } #[test] - fn on_embedding_failure_object_variant_is_closed_on_both_paths() { + fn on_embedding_failure_object_variant_writes_red_reads_reported() { // `OnEmbeddingFailure` is untagged with an object variant: serde // buffers untagged content and silently swallows unknown fields // inside it — invisible to serde_ignored too. The producer closes - // the object branch so the typo is caught on write AND stays a - // loud (RED) rejection on read instead of a silent tolerance. + // the object branch so the typo is caught on write; on read the + // row loads and `unknown_field_paths` names the field. let v = json!({ "display_name": "prod-chat", "semantic": { @@ -3664,7 +4294,11 @@ mod tests { } }); assert!(validate_model(&v).is_err()); - assert!(validate_model_lenient(&v).is_err()); + validate_model_lenient(&v).expect("a stored router keeps routing"); + assert_eq!( + unknown_field_paths("model", &v), + vec!["semantic.on_embedding_failure.sneaky".to_string()] + ); // The legitimate shapes keep validating on both paths. let ok = json!({ diff --git a/crates/aisix-etcd/src/loader.rs b/crates/aisix-etcd/src/loader.rs index 0ddc09979..142e4c518 100644 --- a/crates/aisix-etcd/src/loader.rs +++ b/crates/aisix-etcd/src/loader.rs @@ -518,6 +518,16 @@ where ignored.push(normalize_ignored_path(&path.to_string())); }) { Ok(t) => { + // `serde_ignored` is blind inside serde-buffered content, which + // is the whole config subtree of some kinds; those resources get + // their unknown-field paths off the schema instead. + if let Some(resource) = schema_reported_resource(parsed.kind) { + ignored.extend( + aisix_core::models::unknown_field_paths(resource, value) + .iter() + .map(|path| normalize_ignored_path(path)), + ); + } if let Err(err) = semantic(&t) { tracing::error!(key = %key, error = %err, "semantic validation failed; skipping (incompatible row)"); stats.schema_rejected += 1; @@ -566,6 +576,25 @@ where } } +/// The schema resource name behind an etcd row kind, for the kinds whose +/// unknown fields `serde_ignored` cannot see: `guardrails` and +/// `observability_exporters` deserialise their entire config through a +/// `#[serde(flatten)]`ed tagged enum, `models` and `rate_limit_policies` +/// through untagged variants (`OnEmbeddingFailure`, `ConditionNode`). Serde +/// buffers all of it, so the ignored-field callback never fires there and +/// the row would load silently. Every other kind is fully covered by +/// `serde_ignored`; the two sources overlap harmlessly, since the paths are +/// normalized the same way and deduped. +fn schema_reported_resource(row_kind: &str) -> Option<&'static str> { + match row_kind { + "guardrails" => Some("guardrail"), + "observability_exporters" => Some("observability_exporter"), + "models" => Some("model"), + "rate_limit_policies" => Some("rate_limit_policy"), + _ => None, + } +} + /// Cap on ignored-field paths reported per row. A row over the cap keeps /// its first entries (sorted) plus a `...truncated` sentinel, so the /// truncation shows up in every downstream report instead of silently @@ -957,6 +986,77 @@ mod tests { ); } + #[test] + fn guardrail_unknown_field_inside_a_nested_config_object_loads_and_is_reported() { + // The shape that used to take the whole row down: a control plane + // one release ahead adds an optional field inside a nested guardrail + // config object (this is what `custom_patterns[].replacement` did to + // a 0.10.0 data plane). The row must load with the field ignored — + // a skipped guardrail row is a content policy that silently stops + // enforcing — and the field must still be named in the report, + // which `serde_ignored` cannot do inside the flattened tagged kind. + let entries = vec![raw( + "/aisix/guardrails/g-forward", + br#"{ + "name": "redact-versions", + "kind": "pii", + "custom_patterns": [{ + "name": "eda_version", + "regex": "version\\s*:\\s*(\\d+)", + "action": "mask", + "future_knob": "from-a-newer-cp" + }] + }"#, + 1, + )]; + let (snap, stats) = build_snapshot("/aisix", &entries); + + assert_eq!(stats.accepted, 1, "rejections: {:?}", stats.rejections); + assert!(stats.rejections.is_empty()); + assert_eq!(snap.guardrails.len(), 1); + let entry = snap.guardrails.get_by_id("g-forward").unwrap(); + assert_eq!(entry.value.name, "redact-versions"); + + assert_eq!( + stats.partially_compatible, + vec![PartialCompatEntry { + kind: "guardrails".into(), + field: "custom_patterns.[].future_knob".into(), + count: 1, + }] + ); + } + + #[test] + fn observability_exporter_unknown_field_loads_and_is_reported() { + // Same shape one resource over: the exporter kind block is + // flattened and tagged too, so its closure lived on the read path + // and its report was impossible. An exporter that stops exporting + // is a fleet going dark on telemetry for the length of the window. + let entries = vec![raw( + "/aisix/observability_exporters/o-forward", + br#"{ + "name": "otlp", + "kind": "otlp_http", + "endpoint": "https://otel.example/v1/traces", + "future_knob": true + }"#, + 1, + )]; + let (snap, stats) = build_snapshot("/aisix", &entries); + + assert_eq!(stats.accepted, 1, "rejections: {:?}", stats.rejections); + assert_eq!(snap.observability_exporters.len(), 1); + assert_eq!( + stats.partially_compatible, + vec![PartialCompatEntry { + kind: "observability_exporters".into(), + field: "future_knob".into(), + count: 1, + }] + ); + } + #[test] fn api_key_legacy_mcp_access_mode_tombstone_is_not_partial_compat() { // The CP projects `"mode": "deny"` inside `mcp_access` so a 0.9.x diff --git a/crates/aisix-server/tests/guardrail_read_path_forward_compat.rs b/crates/aisix-server/tests/guardrail_read_path_forward_compat.rs new file mode 100644 index 000000000..0f88aa92c --- /dev/null +++ b/crates/aisix-server/tests/guardrail_read_path_forward_compat.rs @@ -0,0 +1,60 @@ +//! The etcd read path, end to end, for a guardrail written by a control +//! plane one release ahead of this binary. +//! +//! The supported upgrade order puts the control plane in front of its data +//! planes, so for the length of that window a released — and therefore +//! unchangeable — data plane reads documents that carry fields it has never +//! heard of. An optional field is supposed to cost an entry in the +//! partial-compat report and nothing else. For anything nested inside a +//! guardrail's config it used to cost the whole row instead, which for a +//! content policy means it stops enforcing: PII flows through unmasked +//! until the data plane is upgraded. This pins the loader and the guardrail +//! build together, because either one alone can still drop the row. + +use aisix_etcd::loader::{build_snapshot, PartialCompatEntry}; +use aisix_etcd::provider::RawEntry; +use aisix_guardrails::{build_chain_from_snapshot, Guardrail}; + +/// A `kind: "pii"` guardrail whose custom pattern carries one field this +/// build does not know — the shape `custom_patterns[].replacement` had +/// against a 0.10.0 data plane. +const GUARDRAIL_FROM_A_NEWER_CP: &[u8] = br#"{ + "name": "redact-versions", + "kind": "pii", + "custom_patterns": [{ + "name": "eda_version", + "regex": "version\\s*:\\s*(\\d+(?:\\.\\d+)+)", + "action": "mask", + "replacement": "***", + "future_knob": "written by a newer control plane" + }] +}"#; + +#[test] +fn guardrail_with_an_unknown_nested_field_loads_and_still_masks() { + let entries = vec![RawEntry { + key: "/aisix/guardrails/g-1".to_string(), + value: GUARDRAIL_FROM_A_NEWER_CP.to_vec(), + revision: 1, + }]; + let (snapshot, stats) = build_snapshot("/aisix", &entries); + + assert_eq!(stats.accepted, 1, "rejections: {:?}", stats.rejections); + assert_eq!(snapshot.guardrails.len(), 1); + assert_eq!( + stats.partially_compatible, + vec![PartialCompatEntry { + kind: "guardrails".into(), + field: "custom_patterns.[].future_knob".into(), + count: 1, + }], + "the row loads, and the operator is told which field was ignored" + ); + + let chain = build_chain_from_snapshot(&snapshot.guardrails, None); + assert_eq!(chain.len(), 1, "the loaded row must reach the chain"); + let redacted = chain + .redact_input_text("tool version: 12.1 done") + .expect("the guardrail still masks"); + assert_eq!(redacted.text, "tool version: *** done"); +} diff --git a/schemas/resources/guardrail.schema.json b/schemas/resources/guardrail.schema.json index 2ef693637..b4596dc40 100644 --- a/schemas/resources/guardrail.schema.json +++ b/schemas/resources/guardrail.schema.json @@ -252,8 +252,43 @@ "description": "Content policy evaluated before or after upstream calls.", "oneOf": [ { + "additionalProperties": false, "description": "In-process literal/regex blocklist. Always available.", "properties": { + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for literal and regular expression matching.", "enum": [ @@ -261,6 +296,16 @@ ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "patterns": { "description": "Blocklist patterns. An empty list is valid and allows every request, equivalent to `enabled: false`.", "items": { @@ -276,6 +321,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "AWS Bedrock managed guardrail using `ApplyGuardrail` on input, output, or both.", "properties": { "aws_credentials": { @@ -286,6 +332,31 @@ ], "description": "IAM credentials for Bedrock requests." }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, "guardrail_id": { "description": "Guardrail identifier issued by the AWS console.", "maxLength": 64, @@ -298,6 +369,15 @@ "minLength": 1, "type": "string" }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for Amazon Bedrock Guardrails.", "enum": [ @@ -313,6 +393,16 @@ ], "description": "Bedrock guardrail latency policy. Timed mode caps wait time with `timeout_ms`." }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "output_fail_open": { "default": false, "description": "Fail-open policy for the output hook. When disabled (the default), a Bedrock outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", @@ -335,6 +425,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "Azure AI Content Safety Prompt Shield. Detects jailbreak and indirect injection attacks via the `/contentsafety/text:shieldPrompt` API.", "properties": { "api_key": { @@ -342,11 +433,45 @@ "minLength": 1, "type": "string" }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, "endpoint": { "description": "Azure Cognitive Services resource endpoint, e.g. `https://my-resource.cognitiveservices.azure.com`. The gateway appends `/contentsafety/text:shieldPrompt?api-version=2024-09-01`.", "minLength": 1, "type": "string" }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for Azure Prompt Shield.", "enum": [ @@ -354,6 +479,16 @@ ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "output_fail_open": { "default": false, "description": "Fail-open policy for the output hook. When disabled (the default), an Azure outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", @@ -376,6 +511,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "Azure AI Content Safety Text Moderation. Category-severity and blocklist moderation via the `/contentsafety/text:analyze` API, on input and/or output, including streaming output.", "properties": { "api_key": { @@ -412,16 +548,50 @@ }, "type": "array" }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, "endpoint": { "description": "Azure Cognitive Services resource endpoint. The gateway appends `/contentsafety/text:analyze?api-version=2024-09-01`.", "minLength": 1, "type": "string" }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, "halt_on_blocklist_hit": { "default": false, "description": "Forwarded to Azure's `haltOnBlocklistHit`.", "type": "boolean" }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for Azure text moderation.", "enum": [ @@ -429,6 +599,11 @@ ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, "max_buffer_bytes": { "default": 262144, "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", @@ -436,6 +611,11 @@ "minimum": 1.0, "type": "integer" }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "on_buffer_exceeded": { "default": "fail_closed", "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", @@ -524,6 +704,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "Aliyun content-safety guardrail. Risk-level moderation via the `TextModerationPlus` action on `green-cip..aliyuncs.com`, on input and/or output, including streaming output.", "properties": { "access_key_id": { @@ -536,12 +717,46 @@ "minLength": 1, "type": "string" }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, "endpoint": { "default": null, "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", "minLength": 1, "type": "string" }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for Aliyun text moderation.", "enum": [ @@ -549,6 +764,11 @@ ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, "max_buffer_bytes": { "default": 262144, "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", @@ -556,6 +776,11 @@ "minimum": 1.0, "type": "integer" }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "on_buffer_exceeded": { "default": "fail_closed", "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", @@ -623,6 +848,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "Aliyun AI Guardrails (AI 安全护栏). Policy-driven moderation via the `MultiModalGuard` action on `green-cip..aliyuncs.com`; the verdict follows the console-configured policy's `Suggestion`. On input and/or output, including streaming output.", "properties": { "access_key_id": { @@ -635,18 +861,57 @@ "minLength": 1, "type": "string" }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, "endpoint": { "default": null, "description": "Explicit endpoint override as a full URL with no trailing slash. When set, it takes precedence over `region`.", "minLength": 1, "type": "string" }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "enum": [ "aliyun_ai_guardrail" ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, "max_buffer_bytes": { "default": 262144, "description": "Max bytes buffered in `buffer_full` mode before `on_buffer_exceeded` applies.", @@ -654,6 +919,11 @@ "minimum": 1.0, "type": "integer" }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "on_buffer_exceeded": { "default": "fail_closed", "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", @@ -712,8 +982,14 @@ "type": "object" }, { + "additionalProperties": false, "description": "Built-in sensitive-data detection and redaction inside AISIX. Built-in detectors and custom regex patterns can mask matched spans or block traffic on input, output, or both, including buffered streaming output. Always available and does not call an external service.", "properties": { + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, "custom_patterns": { "default": [], "description": "Operator-supplied regex detectors, evaluated after the built-ins.", @@ -739,6 +1015,35 @@ }, "type": "array" }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for in-process sensitive-data detection and redaction.", "enum": [ @@ -746,6 +1051,11 @@ ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, "max_buffer_bytes": { "default": 262144, "description": "Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.", @@ -753,6 +1063,11 @@ "minimum": 1.0, "type": "integer" }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "on_buffer_exceeded": { "default": "fail_closed", "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", @@ -765,6 +1080,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "Lakera Guard screening via `POST /v2/guard`. Prompt-injection, jailbreak, and content-policy detections block traffic. Detections that involve only PII mask spans using the returned offsets. Applies on input, output, or both, including buffered streaming output.", "properties": { "api_key": { @@ -772,12 +1088,46 @@ "minLength": 1, "type": "string" }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, "endpoint": { "default": null, "description": "Endpoint override, e.g. a regional or self-hosted Lakera deployment. The gateway appends `/v2/guard`. Defaults to `https://api.lakera.ai`.", "minLength": 1, "type": "string" }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for Lakera Guard screening.", "enum": [ @@ -785,6 +1135,11 @@ ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, "max_buffer_bytes": { "default": 262144, "description": "Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.", @@ -792,6 +1147,11 @@ "minimum": 1.0, "type": "integer" }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "on_buffer_exceeded": { "default": "fail_closed", "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", @@ -823,6 +1183,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "OpenAI Moderation API category screening via `POST /moderations`. Flagged content or configured category thresholds block traffic. This guardrail is detection-only and never rewrites content. Applies on input, output, or both, including buffered streaming output.", "properties": { "api_key": { @@ -840,12 +1201,46 @@ "description": "Per-category score thresholds, e.g. `{\"violence\": 0.5}`. When set, only the listed categories are enforced and a category blocks when its score reaches the threshold. When empty (the default), the provider's `flagged` decision determines whether to block.", "type": "object" }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, "endpoint": { "default": null, "description": "Endpoint override, such as an Azure OpenAI deployment. AISIX appends `/moderations`. Defaults to `https://api.openai.com/v1`.", "minLength": 1, "type": "string" }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for the OpenAI Moderation API.", "enum": [ @@ -853,12 +1248,22 @@ ], "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, "model": { "default": "omni-moderation-latest", "description": "Moderation model sent to the provider. The default is `omni-moderation-latest`; provider model names are not restricted by AISIX.", "minLength": 1, "type": "string" }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "output_fail_open": { "default": false, "description": "Fail-open policy for the output hook. When disabled (the default), an OpenAI outage blocks model output instead of releasing unscanned content. The input hook continues to use the top-level `fail_open` policy.", @@ -880,6 +1285,7 @@ "type": "object" }, { + "additionalProperties": false, "description": "Self-hosted Microsoft Presidio PII detection and anonymization. Analyzer entities can mask or block per entity, and masked entities use the selected anonymize operator. Applies on input, output, or both, including buffered streaming output.", "properties": { "analyzer_url": { @@ -892,6 +1298,11 @@ "minLength": 1, "type": "string" }, + "created_at": { + "description": "RFC3339 creation timestamp. When present, guardrails are evaluated from oldest to newest. Resources without this timestamp sort after resources that have it.", + "format": "date-time", + "type": "string" + }, "default_action": { "default": "mask", "description": "Action for entities that do not set their own override.", @@ -901,6 +1312,21 @@ ], "type": "string" }, + "direction": { + "default": "both", + "description": "Attachment direction hint. Guardrail execution still follows `hook_point`.", + "type": "string" + }, + "enabled": { + "default": true, + "description": "When false, the chain skips this rule entirely. Allows operators to stage a rule before enabling it.", + "type": "boolean" + }, + "enforcement_mode": { + "default": "block", + "description": "How AISIX handles matching content. Enforcing mode applies the guardrail verdict; monitor mode records what would have happened without blocking or redacting the caller-visible response.", + "type": "string" + }, "entities": { "default": [], "description": "Entities to detect. Empty (the default) analyzes with Presidio's full recognizer set and applies `default_action` to every hit.", @@ -909,6 +1335,20 @@ }, "type": "array" }, + "fail_open": { + "default": true, + "description": "Behavior when a remote API guardrail cannot reach its upstream. `true` allows the request and records the bypass reason in `usage_events.guardrail_bypassed_reason`. `false` blocks with 422. Keyword guardrails do not use this setting.", + "type": "boolean" + }, + "hook_point": { + "allOf": [ + { + "$ref": "#/definitions/GuardrailHookPoint" + } + ], + "default": "both", + "description": "Where in the lifecycle this rule runs." + }, "kind": { "description": "Guardrail provider type for self-hosted Microsoft Presidio PII detection and anonymization.", "enum": [ @@ -922,6 +1362,11 @@ "minLength": 1, "type": "string" }, + "mandatory": { + "default": false, + "description": "Whether guardrail evaluation errors are fatal. When `true`, a remote guardrail that cannot reach its upstream blocks the request instead of failing open, overriding `fail_open` on the failure path. The default `false` keeps the `fail_open` behavior.", + "type": "boolean" + }, "max_buffer_bytes": { "default": 262144, "description": "Max bytes buffered for a streamed response before `on_buffer_exceeded` applies.", @@ -929,6 +1374,11 @@ "minimum": 1.0, "type": "integer" }, + "name": { + "description": "Operator-facing name that surfaces in metric labels and error reasons.", + "minLength": 1, + "type": "string" + }, "on_buffer_exceeded": { "default": "fail_closed", "description": "Buffer-overflow policy for streamed output when the buffer cap is hit.", From be4eb37ba3e705980e64d300ef9b7f19d7c7f52c Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 21 Aug 2026 04:26:45 +0000 Subject: [PATCH 2/7] docs: name the release that opened the read schema --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 907be6265..339a15b61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,7 @@ The five kinds are the cross-plane taxonomy (cp-admin.yaml `kind`); this repo's - The per-target invariant (`crates/aisix-proxy/AGENTS.md`: "a per-model gate binds each target") is written around `resolve_attempt_models` — the routing-group trunk. **Ensemble panel/judge (`ProxyModelCaller::call`, the streaming judge) and semantic targets (`semantic::resolve`) bypass that trunk**, so a gate wired only into the trunk is silently absent there (the 2026-08 audit found member IP allowlist, health consumption, and retries all missing on the semantic path for exactly this reason — #958). A new per-target gate must be wired into the sub-dispatch paths too, or explicitly deferred with a filed issue. Prefer routing every dispatch through one shared chokepoint so the family can't drift. - **Strict writes, lenient loads.** `model_one_of` has two variants: the **strict** schema (declarative resources file, the published `schemas/resources/model.schema.json`, every strict validator consumer) forbids a knob a kind never resolves — accepted-but-unread config is the #962 class; the **lenient** loader keeps the base XOR so stored rows written by an older build still load, with `Model::strip_kind_inapplicable` dropping the dead knob and reporting it as `inapplicable:` through the partial-compat channel. The two lists MUST mirror each other exactly (strict-forbidden ⇔ lenient-stripped) — a field forbidden-but-not-stripped half-honors; stripped-but-not-forbidden vanishes on load while the write path accepts it. A knob is enforced exactly as written or rejected, never half-honored (#963). - **Never make a field of a projected resource required at the TYPE level.** Requiredness belongs in the strict schema (`require_property` in `models/schema.rs`), never in the struct: the loader validates leniently and then deserializes, and a row it cannot deserialize is **skipped entirely** (`aisix-etcd/src/loader.rs`). Skipping is survivable for a resource the request path treats as optional, but an `api_key` row that fails to load stops authenticating **every** kind of traffic, not just the feature whose field changed — a far worse outcome than the field defaulting. So a new non-`Option` field, or one that loses `#[serde(default)]`, silently turns every already-projected row into a dead one. Give it a serde default whose meaning is fail-closed, and add it to `required` in the strict schema so the write path still refuses to guess. The control plane must also re-emit the affected collection once (`ReprojectMcpAclOnce` is the pattern) — the stored shape changed, but nothing else re-projects a row whose *content* did not. (Lesson from #993: `allow` was required at the type level in #992, which made every key still projected as `mcp_access: {"mode": "inherit"}` unloadable.) -- **Never change a projected field's shape or value domain in place — the previous release must keep loading the row.** The supported upgrade order is control plane first, then data planes, with an window that is minutes long in practice but must still be survived; a released DP is immutable, so whatever the new CP projects must still parse one release back. Unknown *fields* are tolerated by design at every depth, nested config objects included, and reported through the partial-compat channel — but only from the release that opened the read schema all the way down; a 0.10.0 or older binary drops the whole row for a field added inside a guardrail or exporter config block, and always will, because a released binary cannot be taught anything. A malformed *known* field — a reshape, a lost default, a new enum value — fails the row and the loader skips it whole on every release (the blast radius of the rule above). Two constraints keep the tolerance real on this side: unknown-field strictness lives in the strict schema (`models/schema.rs`), never in a nested `#[serde(deny_unknown_fields)]` — the loader deserializes the same types and cannot opt out of a type-level guard; and `serde_ignored` never fires inside serde-buffered content (a `#[serde(flatten)]`ed tagged enum, an untagged variant), so those resources take their report from `unknown_field_paths` instead of loading silently. A reshape therefore ships under a NEW field name (the old one is never reused), or as a **same-name dual-generation document** when the old and new keys don't collide: the CP emits one document valid for both generations, and this side carries a consumed-and-ignored tombstone for the old selector — `McpAccess::legacy_mode` is the template: `#[serde(default, rename = "...", skip_serializing)]` + `#[schemars(skip)]` so the strict write path still rejects it, with a `COMPAT-SINCE:` marker naming the retirement condition (see the next section). A new enum value in an existing field cannot be made safe DP-side at all (lenient parsing keeps enums closed — it row-kills every older DP), so the paired CP PR must gate it behind `dpCompatGate` until the fleet minimum reads it. Whenever new semantics are invisible to the old release, verify the old default direction there: fail-closed or no-op is required; if it is fail-open, the CP must project an old-shape tombstone at the most restrictive value. +- **Never change a projected field's shape or value domain in place — the previous release must keep loading the row.** The supported upgrade order is control plane first, then data planes, with an window that is minutes long in practice but must still be survived; a released DP is immutable, so whatever the new CP projects must still parse one release back. Unknown *fields* are tolerated by design at every depth, nested config objects included, and reported through the partial-compat channel — but only from the release that opened the read schema all the way down (#1014); a 0.10.0 or older binary drops the whole row for a field added inside a guardrail or exporter config block, and always will, because a released binary cannot be taught anything. A malformed *known* field — a reshape, a lost default, a new enum value — fails the row and the loader skips it whole on every release (the blast radius of the rule above). Two constraints keep the tolerance real on this side: unknown-field strictness lives in the strict schema (`models/schema.rs`), never in a nested `#[serde(deny_unknown_fields)]` — the loader deserializes the same types and cannot opt out of a type-level guard; and `serde_ignored` never fires inside serde-buffered content (a `#[serde(flatten)]`ed tagged enum, an untagged variant), so those resources take their report from `unknown_field_paths` instead of loading silently. A reshape therefore ships under a NEW field name (the old one is never reused), or as a **same-name dual-generation document** when the old and new keys don't collide: the CP emits one document valid for both generations, and this side carries a consumed-and-ignored tombstone for the old selector — `McpAccess::legacy_mode` is the template: `#[serde(default, rename = "...", skip_serializing)]` + `#[schemars(skip)]` so the strict write path still rejects it, with a `COMPAT-SINCE:` marker naming the retirement condition (see the next section). A new enum value in an existing field cannot be made safe DP-side at all (lenient parsing keeps enums closed — it row-kills every older DP), so the paired CP PR must gate it behind `dpCompatGate` until the fleet minimum reads it. Whenever new semantics are invisible to the old release, verify the old default direction there: fail-closed or no-op is required; if it is fail-open, the CP must project an old-shape tombstone at the most restrictive value. - **Size the mitigation to what the window costs.** The window is minutes, not a season — a control plane and its data planes are upgraded back to back — and anything that merely degrades in it self-heals on the next DP build: a knob not yet honoured, a field reported instead of read, a resource serving its previous semantics. That does not earn a dual-generation document, a tombstone, or holding a finished feature back a release; ship it and let the CP warn at save time (`dpCompatGate`). Three things do not self-heal, and keep the heavy technique regardless of how short the window is: a run-once migration, which rewrites stored data permanently; a security control the old binary cannot see, where the exposure has already happened however briefly (fail-closed or an old-shape tombstone, never bare degradation); and a **core carrier row** — `api_keys`, models, provider keys — where a skipped row is not a degradation but an outage, since that key authenticates nothing at all until the DP is upgraded. If a change is none of the three, the proportionate mitigation is the report. - **`ensemble` is an experimental surface.** Its known parity gaps — member `allowed_cidrs`/guardrail/cooldown/health consumption, Prometheus token+spend attribution, response caching, parent-level generic knobs — are deliberate TODOs under a single future design pass. Do NOT piecemeal-fix one gap ahead of that pass, and do NOT re-audit them as fresh findings. (The one exception is a marshal-family or shared-chokepoint change where covering ensemble is a one-line parallel edit, e.g. projecting an entry-level field the DP already enforces.) - Adding a NEW kind = sweeping every existing model-keyed mechanism against it (grep the kind predicates in `models/model.rs`; every hit re-answers the questions above). From 70e2262d6626b9abdfe0250786289b7ce2bc4919 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 21 Aug 2026 04:31:39 +0000 Subject: [PATCH 3/7] fix(schema): name the unknown field behind a guardrail/exporter oneOf rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `oneOf` failure collapses to one root-level "not valid under any of the schemas", so the most ordinary write-path error on a guardrail or exporter — a typo in a resources file — arrived naming no field. The strict schema knows every field name, which is what `unknown_field_paths` reads, so the names replace the message when they are the whole story (same probe discipline as `validate_model`'s dead-knob case). --- crates/aisix-core/src/models/schema.rs | 133 ++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 2 deletions(-) diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index c2a7bbf1f..17adafd6c 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -520,7 +520,15 @@ pub fn validate_provider_key(value: &Value) -> Result<(), SchemaError> { } pub fn validate_guardrail(value: &Value) -> Result<(), SchemaError> { - validate(&SCHEMAS.guardrail, value) + match validate(&SCHEMAS.guardrail, value) { + Ok(()) => Ok(()), + Err(err) => Err(name_unknown_fields( + "guardrail", + &SCHEMAS.guardrail, + value, + err, + )), + } } pub fn validate_cache_policy(value: &Value) -> Result<(), SchemaError> { @@ -528,7 +536,85 @@ pub fn validate_cache_policy(value: &Value) -> Result<(), SchemaError> { } pub fn validate_observability_exporter(value: &Value) -> Result<(), SchemaError> { - validate(&SCHEMAS.observability_exporter, value) + match validate(&SCHEMAS.observability_exporter, value) { + Ok(()) => Ok(()), + Err(err) => Err(name_unknown_fields( + "observability_exporter", + &SCHEMAS.observability_exporter, + value, + err, + )), + } +} + +/// Replace a `oneOf` rejection with the unknown field names behind it. +/// +/// `jsonschema` collapses every branch failure of a `oneOf` into one +/// root-level "not valid under any of the schemas" — true, but naming no +/// field, and a guardrail or exporter document IS a `oneOf`, so its most +/// ordinary write-path error (a typo) arrives unreadable. The strict schema +/// knows every field name, which is what [`unknown_field_paths`] reads. +/// +/// Same discipline as [`validate_model`]: the names replace the message only +/// when they are the WHOLE story. The document is re-validated with exactly +/// those fields removed, and one that also violates something else keeps the +/// original error, which points at the other problem. Only field NAMES are +/// added, never instance values, so the masking contract in [`validate`] +/// holds. +fn name_unknown_fields( + resource: &str, + validator: &Validator, + value: &Value, + err: SchemaError, +) -> SchemaError { + let unknown = unknown_field_paths(resource, value); + if unknown.is_empty() { + return err; + } + let mut probe = value.clone(); + for path in &unknown { + remove_path(&mut probe, path); + } + if validate(validator, &probe).is_err() { + return err; + } + SchemaError { + path: err.path, + message: format!( + "unknown field(s): {}", + unknown + .iter() + .map(|path| format!("`{path}`")) + .collect::>() + .join(", ") + ), + } +} + +/// Remove one dotted path produced by [`unknown_field_paths`] from a +/// document; a numeric segment indexes into an array. +fn remove_path(value: &mut Value, path: &str) { + let mut segments = path.split('.').peekable(); + let mut node = value; + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + if let Some(fields) = node.as_object_mut() { + fields.remove(segment); + } + return; + } + node = match (node, segment.parse::()) { + (Value::Array(items), Ok(index)) => match items.get_mut(index) { + Some(item) => item, + None => return, + }, + (Value::Object(fields), _) => match fields.get_mut(segment) { + Some(field) => field, + None => return, + }, + _ => return, + }; + } } pub fn validate_rate_limit_policy(value: &Value) -> Result<(), SchemaError> { @@ -3878,6 +3964,49 @@ mod tests { // ---- strict-write / lenient-read split (issue #871) ---- + #[test] + fn write_rejection_names_the_unknown_field_behind_the_one_of() { + // A typo in a resources file must say which field it was. The + // guardrail and exporter documents are `oneOf`s, so `jsonschema` + // reports only "not valid under any of the schemas" and the author + // is left guessing. + let err = validate_guardrail(&json!({ + "name": "p", "kind": "pii", + "custom_patterns": [{"name": "n", "regex": "x", "replacment": "***"}] + })) + .expect_err("the write contract rejects the typo"); + assert!( + err.message.contains("custom_patterns.0.replacment"), + "unhelpful message: {}", + err.message + ); + + let err = validate_observability_exporter(&json!({ + "name": "o", "kind": "otlp_http", + "endpoint": "https://otel.example/v1/traces", "timout_ms": 5 + })) + .expect_err("the write contract rejects the typo"); + assert!( + err.message.contains("timout_ms"), + "unhelpful message: {}", + err.message + ); + + // A document that ALSO violates something else keeps the original + // error: replacing it would leave `path` and `message` describing + // different fields. + let err = validate_guardrail(&json!({ + "name": "", "kind": "pii", + "custom_patterns": [{"name": "n", "regex": "x", "replacment": "***"}] + })) + .expect_err("an empty name is still a violation"); + assert!( + !err.message.contains("replacment"), + "the other problem must win: {}", + err.message + ); + } + #[test] fn lenient_set_carries_no_closure_at_any_depth() { // The mechanical guarantee behind "an additive optional field is From 88e8a32ea31c3388a6e734fc57e783f586415168 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 21 Aug 2026 04:33:29 +0000 Subject: [PATCH 4/7] test(schema): make the accepted corpora guard the unknown-field report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every document the write contract accepts is one this build fully understands, so it must report nothing. The report runs on every row of these kinds, where a false positive would be a permanent partial-compat warning on a healthy fleet — the resource and model corpora now assert it across every accepted case. --- .../tests/model_schema_characterization.rs | 12 ++++++++- .../tests/resource_schema_characterization.rs | 25 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/aisix-core/tests/model_schema_characterization.rs b/crates/aisix-core/tests/model_schema_characterization.rs index 166260ebc..7d5a3255c 100644 --- a/crates/aisix-core/tests/model_schema_characterization.rs +++ b/crates/aisix-core/tests/model_schema_characterization.rs @@ -11,15 +11,25 @@ //! validator wrongly rejected them even though the `RateLimit` struct and the //! rate limiter support them, so they now ACCEPT — the deliberate bug fix. -use aisix_core::models::schema::validate_model; +use aisix_core::models::schema::{unknown_field_paths, validate_model}; use serde_json::{json, Value}; /// Assert that the current `validate_model` ACCEPTS `value`. +/// +/// An accepted document is one this build fully understands, so it must also +/// report no unknown fields — the loader derives that report from the strict +/// schema and runs it on every model row, where a false positive would be a +/// permanent partial-compat warning on a healthy fleet. #[track_caller] fn accept(label: &str, value: Value) { if let Err(e) = validate_model(&value) { panic!("expected ACCEPT for `{label}`, got reject: {e}"); } + let unknown = unknown_field_paths("model", &value); + assert!( + unknown.is_empty(), + "`{label}` is fully valid but reports unknown fields: {unknown:?}" + ); } /// Assert that the current `validate_model` REJECTS `value`. diff --git a/crates/aisix-core/tests/resource_schema_characterization.rs b/crates/aisix-core/tests/resource_schema_characterization.rs index b90a95af8..1b9a77d0a 100644 --- a/crates/aisix-core/tests/resource_schema_characterization.rs +++ b/crates/aisix-core/tests/resource_schema_characterization.rs @@ -8,14 +8,23 @@ //! case is obvious. New resources append their own table as they migrate. use aisix_core::models::schema::{ - validate_apikey, validate_cache_policy, validate_guardrail, validate_guardrail_attachment, - validate_observability_exporter, validate_provider_key, validate_rate_limit_policy, + unknown_field_paths, validate_apikey, validate_cache_policy, validate_guardrail, + validate_guardrail_attachment, validate_observability_exporter, validate_provider_key, + validate_rate_limit_policy, }; use serde_json::{json, Value}; /// Run a corpus of `(label, expect_accept, payload)` against `validate`. +/// +/// Every accepted case doubles as a guard on the loader's unknown-field +/// report: a document the write contract accepts is a document this build +/// fully understands, so it must report nothing. The report is derived from +/// the strict schema (`unknown_field_paths`), and it runs on every row of +/// these kinds — a producer that stops declaring a field would turn the whole +/// corpus into permanent partial-compat warnings. #[track_caller] fn check( + resource: &str, validate: fn(&Value) -> Result<(), aisix_core::models::schema::SchemaError>, cases: &[(&str, bool, Value)], ) { @@ -27,6 +36,11 @@ fn check( "expected ACCEPT for `{label}`, got: {:?}", result.err() ); + let unknown = unknown_field_paths(resource, payload); + assert!( + unknown.is_empty(), + "`{label}` is fully valid but reports unknown fields: {unknown:?}" + ); } else { assert!( result.is_err(), @@ -39,6 +53,7 @@ fn check( #[test] fn cache_policy_corpus() { check( + "cache_policy", validate_cache_policy, &[ ( @@ -111,6 +126,7 @@ fn cache_policy_corpus() { #[test] fn apikey_corpus() { check( + "api_key", validate_apikey, &[ ( @@ -195,6 +211,7 @@ fn apikey_corpus() { #[test] fn rate_limit_policy_corpus() { check( + "rate_limit_policy", validate_rate_limit_policy, &[ ( @@ -283,6 +300,7 @@ fn provider_key_corpus() { // written before the field's rename to `api_key`. The canonical // spelling is pinned by the dedicated cases below. check( + "provider_key", validate_provider_key, &[ ( @@ -430,6 +448,7 @@ fn provider_key_corpus() { #[test] fn observability_exporter_corpus() { check( + "observability_exporter", validate_observability_exporter, &[ // otlp_http @@ -575,6 +594,7 @@ fn observability_exporter_corpus() { #[test] fn guardrail_corpus() { check( + "guardrail", validate_guardrail, &[ // keyword @@ -748,6 +768,7 @@ fn guardrail_corpus() { #[test] fn guardrail_attachment_corpus() { check( + "guardrail_attachment", validate_guardrail_attachment, &[ ( From cdd270d82eeb787f77586f0d0e6619917dad72d3 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 21 Aug 2026 04:34:10 +0000 Subject: [PATCH 5/7] refactor(loader): compute the schema-derived report only for accepted rows --- crates/aisix-etcd/src/loader.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/aisix-etcd/src/loader.rs b/crates/aisix-etcd/src/loader.rs index 142e4c518..f237c9af2 100644 --- a/crates/aisix-etcd/src/loader.rs +++ b/crates/aisix-etcd/src/loader.rs @@ -518,16 +518,6 @@ where ignored.push(normalize_ignored_path(&path.to_string())); }) { Ok(t) => { - // `serde_ignored` is blind inside serde-buffered content, which - // is the whole config subtree of some kinds; those resources get - // their unknown-field paths off the schema instead. - if let Some(resource) = schema_reported_resource(parsed.kind) { - ignored.extend( - aisix_core::models::unknown_field_paths(resource, value) - .iter() - .map(|path| normalize_ignored_path(path)), - ); - } if let Err(err) = semantic(&t) { tracing::error!(key = %key, error = %err, "semantic validation failed; skipping (incompatible row)"); stats.schema_rejected += 1; @@ -537,6 +527,16 @@ where return None; } stats.accepted += 1; + // `serde_ignored` is blind inside serde-buffered content, which + // for some kinds is the whole config subtree; those take their + // unknown-field paths off the schema instead. + if let Some(resource) = schema_reported_resource(parsed.kind) { + ignored.extend( + aisix_core::models::unknown_field_paths(resource, value) + .iter() + .map(|path| normalize_ignored_path(path)), + ); + } if !ignored.is_empty() { // YELLOW: loaded, but fields this build does not know were // ignored — typically written by a newer control plane. From d02b17c234686bbd07227480e4394ac5971f5289 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 21 Aug 2026 04:35:36 +0000 Subject: [PATCH 6/7] docs: fix the article left by the window re-size --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 339a15b61..3b87dcb8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,7 @@ The five kinds are the cross-plane taxonomy (cp-admin.yaml `kind`); this repo's - The per-target invariant (`crates/aisix-proxy/AGENTS.md`: "a per-model gate binds each target") is written around `resolve_attempt_models` — the routing-group trunk. **Ensemble panel/judge (`ProxyModelCaller::call`, the streaming judge) and semantic targets (`semantic::resolve`) bypass that trunk**, so a gate wired only into the trunk is silently absent there (the 2026-08 audit found member IP allowlist, health consumption, and retries all missing on the semantic path for exactly this reason — #958). A new per-target gate must be wired into the sub-dispatch paths too, or explicitly deferred with a filed issue. Prefer routing every dispatch through one shared chokepoint so the family can't drift. - **Strict writes, lenient loads.** `model_one_of` has two variants: the **strict** schema (declarative resources file, the published `schemas/resources/model.schema.json`, every strict validator consumer) forbids a knob a kind never resolves — accepted-but-unread config is the #962 class; the **lenient** loader keeps the base XOR so stored rows written by an older build still load, with `Model::strip_kind_inapplicable` dropping the dead knob and reporting it as `inapplicable:` through the partial-compat channel. The two lists MUST mirror each other exactly (strict-forbidden ⇔ lenient-stripped) — a field forbidden-but-not-stripped half-honors; stripped-but-not-forbidden vanishes on load while the write path accepts it. A knob is enforced exactly as written or rejected, never half-honored (#963). - **Never make a field of a projected resource required at the TYPE level.** Requiredness belongs in the strict schema (`require_property` in `models/schema.rs`), never in the struct: the loader validates leniently and then deserializes, and a row it cannot deserialize is **skipped entirely** (`aisix-etcd/src/loader.rs`). Skipping is survivable for a resource the request path treats as optional, but an `api_key` row that fails to load stops authenticating **every** kind of traffic, not just the feature whose field changed — a far worse outcome than the field defaulting. So a new non-`Option` field, or one that loses `#[serde(default)]`, silently turns every already-projected row into a dead one. Give it a serde default whose meaning is fail-closed, and add it to `required` in the strict schema so the write path still refuses to guess. The control plane must also re-emit the affected collection once (`ReprojectMcpAclOnce` is the pattern) — the stored shape changed, but nothing else re-projects a row whose *content* did not. (Lesson from #993: `allow` was required at the type level in #992, which made every key still projected as `mcp_access: {"mode": "inherit"}` unloadable.) -- **Never change a projected field's shape or value domain in place — the previous release must keep loading the row.** The supported upgrade order is control plane first, then data planes, with an window that is minutes long in practice but must still be survived; a released DP is immutable, so whatever the new CP projects must still parse one release back. Unknown *fields* are tolerated by design at every depth, nested config objects included, and reported through the partial-compat channel — but only from the release that opened the read schema all the way down (#1014); a 0.10.0 or older binary drops the whole row for a field added inside a guardrail or exporter config block, and always will, because a released binary cannot be taught anything. A malformed *known* field — a reshape, a lost default, a new enum value — fails the row and the loader skips it whole on every release (the blast radius of the rule above). Two constraints keep the tolerance real on this side: unknown-field strictness lives in the strict schema (`models/schema.rs`), never in a nested `#[serde(deny_unknown_fields)]` — the loader deserializes the same types and cannot opt out of a type-level guard; and `serde_ignored` never fires inside serde-buffered content (a `#[serde(flatten)]`ed tagged enum, an untagged variant), so those resources take their report from `unknown_field_paths` instead of loading silently. A reshape therefore ships under a NEW field name (the old one is never reused), or as a **same-name dual-generation document** when the old and new keys don't collide: the CP emits one document valid for both generations, and this side carries a consumed-and-ignored tombstone for the old selector — `McpAccess::legacy_mode` is the template: `#[serde(default, rename = "...", skip_serializing)]` + `#[schemars(skip)]` so the strict write path still rejects it, with a `COMPAT-SINCE:` marker naming the retirement condition (see the next section). A new enum value in an existing field cannot be made safe DP-side at all (lenient parsing keeps enums closed — it row-kills every older DP), so the paired CP PR must gate it behind `dpCompatGate` until the fleet minimum reads it. Whenever new semantics are invisible to the old release, verify the old default direction there: fail-closed or no-op is required; if it is fail-open, the CP must project an old-shape tombstone at the most restrictive value. +- **Never change a projected field's shape or value domain in place — the previous release must keep loading the row.** The supported upgrade order is control plane first, then data planes, with a window that is minutes long in practice but must still be survived; a released DP is immutable, so whatever the new CP projects must still parse one release back. Unknown *fields* are tolerated by design at every depth, nested config objects included, and reported through the partial-compat channel — but only from the release that opened the read schema all the way down (#1014); a 0.10.0 or older binary drops the whole row for a field added inside a guardrail or exporter config block, and always will, because a released binary cannot be taught anything. A malformed *known* field — a reshape, a lost default, a new enum value — fails the row and the loader skips it whole on every release (the blast radius of the rule above). Two constraints keep the tolerance real on this side: unknown-field strictness lives in the strict schema (`models/schema.rs`), never in a nested `#[serde(deny_unknown_fields)]` — the loader deserializes the same types and cannot opt out of a type-level guard; and `serde_ignored` never fires inside serde-buffered content (a `#[serde(flatten)]`ed tagged enum, an untagged variant), so those resources take their report from `unknown_field_paths` instead of loading silently. A reshape therefore ships under a NEW field name (the old one is never reused), or as a **same-name dual-generation document** when the old and new keys don't collide: the CP emits one document valid for both generations, and this side carries a consumed-and-ignored tombstone for the old selector — `McpAccess::legacy_mode` is the template: `#[serde(default, rename = "...", skip_serializing)]` + `#[schemars(skip)]` so the strict write path still rejects it, with a `COMPAT-SINCE:` marker naming the retirement condition (see the next section). A new enum value in an existing field cannot be made safe DP-side at all (lenient parsing keeps enums closed — it row-kills every older DP), so the paired CP PR must gate it behind `dpCompatGate` until the fleet minimum reads it. Whenever new semantics are invisible to the old release, verify the old default direction there: fail-closed or no-op is required; if it is fail-open, the CP must project an old-shape tombstone at the most restrictive value. - **Size the mitigation to what the window costs.** The window is minutes, not a season — a control plane and its data planes are upgraded back to back — and anything that merely degrades in it self-heals on the next DP build: a knob not yet honoured, a field reported instead of read, a resource serving its previous semantics. That does not earn a dual-generation document, a tombstone, or holding a finished feature back a release; ship it and let the CP warn at save time (`dpCompatGate`). Three things do not self-heal, and keep the heavy technique regardless of how short the window is: a run-once migration, which rewrites stored data permanently; a security control the old binary cannot see, where the exposure has already happened however briefly (fail-closed or an old-shape tombstone, never bare degradation); and a **core carrier row** — `api_keys`, models, provider keys — where a skipped row is not a degradation but an outage, since that key authenticates nothing at all until the DP is upgraded. If a change is none of the three, the proportionate mitigation is the report. - **`ensemble` is an experimental surface.** Its known parity gaps — member `allowed_cidrs`/guardrail/cooldown/health consumption, Prometheus token+spend attribution, response caching, parent-level generic knobs — are deliberate TODOs under a single future design pass. Do NOT piecemeal-fix one gap ahead of that pass, and do NOT re-audit them as fresh findings. (The one exception is a marshal-family or shared-chokepoint change where covering ensemble is a one-line parallel edit, e.g. projecting an entry-level field the DP already enforces.) - Adding a NEW kind = sweeping every existing model-keyed mechanism against it (grep the kind predicates in `models/model.rs`; every hit re-answers the questions above). From bc24fe57932f0ec966d492d891b2686b704b3422 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 21 Aug 2026 05:00:39 +0000 Subject: [PATCH 7/7] docs(loader): the normalized array path carries its own segment --- crates/aisix-etcd/src/loader.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/aisix-etcd/src/loader.rs b/crates/aisix-etcd/src/loader.rs index f237c9af2..42bab794b 100644 --- a/crates/aisix-etcd/src/loader.rs +++ b/crates/aisix-etcd/src/loader.rs @@ -127,8 +127,8 @@ pub struct PartialCompatEntry { pub kind: String, /// Dotted path of the ignored field inside the document /// (e.g. `quota_profile` or `rate_limit.burst`). Array indices are - /// normalized to `[]` (`routing.targets[].priority`) so the entry - /// count is bounded by the document shape, not the data volume. + /// normalized to a `[]` segment (`routing.targets.[].priority`) so the + /// entry count is bounded by the document shape, not the data volume. pub field: String, /// Number of rows of this kind carrying this unknown field in the /// build.