Skip to content

feat(config): lenient etcd parsing with tri-state compatibility reporting - #872

Merged
membphis merged 6 commits into
mainfrom
claude/aisix-871-lenient-parsing-1cc693
Aug 4, 2026
Merged

feat(config): lenient etcd parsing with tri-state compatibility reporting#872
membphis merged 6 commits into
mainfrom
claude/aisix-871-lenient-parsing-1cc693

Conversation

@membphis

@membphis membphis commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes #871 (PR 1 of 2 — the lenient parse + tri-state + full reporting half; PR 2 covers RED last-known-good retention across resync/restart).

Problem

Every resource struct carried #[serde(deny_unknown_fields)], which also generated additionalProperties: false in the runtime schemas — so a document containing any field this DP version does not know was rejected whole-row at the etcd loader. Under the supported rolling-upgrade order (control plane first, data planes behind), every additive schema change became a breaking one: the admin edits a resource, an older DP rejects the watch event and keeps serving the stale value, and the next resync or restart silently drops the row entirely. For an api_key that means the credential stops authenticating, byte-identical to "no such key".

Design

Uniform tri-state compatibility, derived mechanically at the existing validate_and_parse chokepoint — no per-resource rules:

  • RED — incompatible. Lenient-schema or serde deserialization fails: type conflict, missing required field, constraint violation, unknown enum value, or an unknown field inside a closed tagged-enum shape. Whole-row reject as before, now logged at ERROR and reported as rejected[] (unchanged surface).
  • YELLOW — partially compatible. The struct parses and all constraints pass, but unknown fields are present: the row loads and serves with those fields ignored, and serde_ignored collects the exact paths (array indices normalized to [], Option layers stripped). Logged WARN, deduplicated per (kind, field-set).
  • GREEN — fully compatible. Exact match; unchanged.

Strict write / lenient read

Only the etcd loader becomes lenient. Strictness moved out of the structs into a mechanical close_unknown_fields pass over the shared schema producers: the write paths (Admin API, standalone file source) compile the closed set and keep returning 400 on unknown fields, while the loader compiles the open set. Both sets build from the same producers, so they cannot drift field-wise.

The published schemas/resources/*.json are byte-identical after regeneration — the closing transform provably reproduces what deny_unknown_fields used to generate, and the CI schema-drift gate doubles as a write-contract-unchanged check. The published files deliberately keep the strict shape: they feed the Admin API reference, which documents the write contract.

Deliberate closures stay closed

The hand-injected additionalProperties: false on the observability-exporter branches and the guardrail tagged sub-enums survives in both validator sets. Two reasons, verified empirically: those branches guard the credential_ref indirection against plaintext-secret smuggling, and serde silently swallows unknown fields inside inline-tagged enum content (serde_ignored cannot see through the Content buffering) — an open schema there would be an unreportable, silent tolerance, violating the no-silent-window constraint. Unknown fields in those subtrees therefore stay RED, same stance as unknown enum values.

Reporting (landed atomically with the flip — no silent window)

  • GET /status/config gains partially_compatible[] next to rejected[], aggregated per (kind, field) with row counts. This is the config_hash companion the issue requires: YELLOW rows are served (so the hash covers them and the state stays synced), and the explicit list is what distinguishes "fully synced" from "synced with fields this version does not enforce".
  • The managed-mode heartbeat gains partially_compatible_resources (omitted when empty; the CP heartbeat handler was verified to tolerate unknown fields, so no paired CP change is needed to ship — surfacing is api7/AISIX-Cloud#1227).
  • Prometheus gains aisix_config_partially_compatible_resources{kind}, mirroring the rejected-resources gauge including stale-label zeroing. Field paths stay off the labels (not a bounded set); per-field detail lives in /status/config.
  • The supervisor retains YELLOW state per etcd key in its own capped buffer (1024, WARN on truncation) — never sharing the 256-cap rejection buffer, so YELLOW volume cannot evict RED entries. A rejected update for a YELLOW key keeps the signal (the previously loaded value still serves); a delete or an exact-match re-put clears it; a resync replaces it wholesale.

The three pinned *_currently_rejected payloads

Flipped to partially-compatible loads, per-case: the Bedrock and Vertex bridges read region/project from the credential JSON inside api_key, and the Azure bridge derives its resource from api_base with a pinned API version — none of the top-level aws_region / gcp_project / gcp_region / azure_resource_name / api_version fields is consumed by this DP, so a dead model field would be strictly worse than an ignored-and-reported one.

Enum-addition policy (rider)

CONTRIBUTING.md now documents the rollout rule: added fields are the safe, reported-as-partial change; a new enum value is not forward compatible by design (no old behavior exists for a value the DP cannot interpret) and each addition needs an explicit decision — version-gate at the CP, a #[serde(other)] fallback only where semantically safe, or an accepted loud rejection.

Ecosystem alignment

Surveyed mainstream implementations (primary sources linked from #871): service proxies consuming dynamically-delivered config default to ignoring unknown fields with a deduplicated warning and a counter, keeping strictness opt-in; the major container orchestrator makes readers lenient by several minor versions and puts strictness on the write side only; hybrid-mode API gateways either report a per-DP compatibility status enum or strip unknown fields CP-side per DP version; the dominant serialization framework restored unknown-field preservation as its default specifically for uncoordinated rolling upgrades. The previous behavior here (whole-row reject, no reporting) was stricter than every surveyed system; this PR lands on the common ground — lenient read, loud reporting, write-side strictness — plus the per-DP compatibility status idea for the heartbeat.

Scope notes

  • The standalone file source stays strict: a hand-edited resources file has no version-skew writer, so strictness there is pure typo protection.
  • unknown_kind (an entirely new resource kind from a newer CP) keeps its existing behavior (skipped + reported), unchanged by this PR.
  • No CP change required to merge: the CP tolerates the new heartbeat field today; consuming/surfacing it is tracked in api7/AISIX-Cloud#1227.

Testing

  • The reproducing test landed first (red on the old behavior): an api_key document with an unknown field must be accepted and reported partially compatible.
  • Loader unit tests: root and nested unknown-field YELLOW with exact dotted paths, cross-row aggregation, unknown-enum-value stays RED, the three provider_key flips.
  • Supervisor unit tests: YELLOW retention across put/replace/clear/delete/resync, and survival when an update for the same key is rejected.
  • Heartbeat wiremock tests: field present when wired, omitted when empty (historical body shape preserved).
  • Metrics tests: gauge rendering and stale-label zeroing.
  • E2E against the real binary + etcd (new config-forward-compat-e2e.test.ts): the unknown-field api_key authenticates a real chat; partially_compatible[] + gauge report it; an unknown routing strategy stays rejected; deleting the row clears the report and zeroes the gauge; a converged same-version config reports zero YELLOW (typo-catching preserved); the Admin API still 400s on unknown fields.
  • Full suite: cargo test --workspace green; full e2e suite green (175 files, 461 tests); cargo fmt --check, clippy --workspace, dump-schema (zero diff), and dump-openapi all clean.

Summary by CodeRabbit

  • New Features
    • Configuration reads now tolerate unknown fields for forward compatibility while continuing to reject invalid values and unsupported enum entries.
    • Partial compatibility is reported through configuration status, metrics, and heartbeat data.
    • Strict validation remains enforced for configuration writes.
  • Documentation
    • Added guidance on schema evolution, compatibility policies, and enum rollout strategies.
  • Tests
    • Added end-to-end coverage for compatible reads, status reporting, metrics, cleanup, and rejected writes.

An api_key document carrying a field this DP version does not know is
currently whole-row rejected at schema validation. Issue #871 requires
the row to load with the unknown field ignored and be reported as
partially compatible, aggregated per kind and field path.

The test pins the target contract and fails against current behavior;
the PartialCompatEntry reporting surface is added as data only, with
no behavior change yet.
Split resource schema validation into strict-write / lenient-read:

- The strict validator set (validate_*, SCHEMAS) keeps rejecting unknown
  fields on every write path (Admin API, file source). Strictness now
  comes from a mechanical close_unknown_fields pass over the shared
  producers instead of #[serde(deny_unknown_fields)]; the published
  schemas/resources/*.json are byte-identical, proving the write
  contract unchanged.
- The lenient set (validate_*_lenient, LENIENT_SCHEMAS) tolerates
  unknown fields and is used only by the etcd snapshot loader, so a
  document written by a newer control plane loads instead of being
  whole-row rejected under the CP-before-DP upgrade order.

The loader classifies every row:

- RED (incompatible): lenient schema or serde fails - type conflicts,
  missing required fields, range violations, unknown enum values, and
  unknown fields inside closed tagged-enum shapes where serde silently
  drops what it cannot see. Skipped as before, now logged at ERROR.
- YELLOW (partially compatible): the row loads with unknown fields
  ignored; serde_ignored collects the exact paths, aggregated per
  (kind, field) with row counts in a buffer separate from the
  rejection list. WARN deduped per (kind, field-set).
- GREEN: exact match, unchanged.

deny_unknown_fields is dropped from the 25 plain resource structs; the
guardrail and observability-exporter tagged payload structs keep it, and
the hand-injected additionalProperties:false on the exporter branches
and guardrail sub-enums stays closed in both sets (secret-smuggling
guard; also the only non-silent option inside Content-buffered shapes).

The three provider_key *_currently_rejected characterization tests flip
to partially-compatible loads: the Bedrock and Vertex bridges read
region/project from the credential JSON inside api_key and the Azure
bridge derives its resource from api_base, so the adapter_map top-level
fields are not consumed by this DP and a dead model field would be
worse than an ignored-and-reported one.

Serde-level rejects_unknown_* unit tests flip to
tolerates_unknown_*_for_forward_compat; closed enum values keep
rejecting.
…871)

The supervisor retains the loader's per-row unknown-field observations
in a buffer of its own, keyed by etcd key so watch events merge
incrementally: a Put replaces or clears the key's entry (a rejected
update keeps it - the previously loaded value is still what serves), a
Delete removes it, a resync replaces the map wholesale. Capped
separately from the rejection buffer so YELLOW volume can never evict
RED entries, with a WARN when the cap truncates the report.

Reporting surfaces, all landed with the leniency flip so there is no
silent-tolerance window:

- GET /status/config gains partially_compatible[] next to rejected[],
  aggregated per (kind, field) with row counts - the companion that
  keeps a matching config_hash from hiding that enforcement differs
  from the stored documents.
- The managed-mode heartbeat gains partially_compatible_resources,
  omitted when empty so older control planes keep seeing the
  historical body shape (their handler tolerates unknown fields).
- Prometheus gains aisix_config_partially_compatible_resources{kind},
  a row-count gauge mirroring aisix_config_rejected_resources
  including the stale-label zeroing discipline; per-field detail stays
  off the labels (field paths are not a bounded set).

File mode passes an empty aggregate: the standalone file source keeps
validating strictly, since a hand-edited file has no version-skew
writer and strictness there is typo protection.
E2E against the real binary and etcd:

- an api_key document carrying an unknown field authenticates real
  traffic and is reported in partially_compatible[] with the exact
  field and row count, plus the per-kind gauge on the metrics listener;
- an unknown enum value (routing strategy) stays rejected - no lenient
  fallback exists for a value the gateway cannot interpret;
- deleting the forward-compat row clears the report and zeroes the
  gauge, and a converged same-version config reports zero partially
  compatible rows, preserving the typo-catching strictness;
- the Admin API write path still rejects unknown fields with 400.

CONTRIBUTING gains the rollout policy for resource-model changes:
added fields are the safe, reported-as-partial change; new enum values
are not forward compatible by design and each needs an explicit
decision - version-gate at the control plane, a #[serde(other)]
fallback only where semantically safe, or an accepted loud rejection.
The published resource schemas describe the Admin API write contract;
the etcd read path is deliberately more lenient since the tri-state
compatibility change, with unknown fields loaded-and-reported instead
of whole-row rejected. Also records why the observability-exporter
branches stay closed on both paths.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@membphis, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: faadd360-2e77-4379-9bee-2f57ebd65ce9

📥 Commits

Reviewing files that changed from the base of the PR and between ded6208 and 7f3bc88.

📒 Files selected for processing (9)
  • CONTRIBUTING.md
  • crates/aisix-core/src/bin/dump-schema.rs
  • crates/aisix-core/src/models/guardrail.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-etcd/src/loader.rs
  • schemas/resources/guardrail_attachment.schema.json
  • schemas/resources/model.schema.json
  • schemas/resources/semantic.schema.json
  • tests/e2e/src/cases/config-forward-compat-e2e.test.ts
📝 Walkthrough

Walkthrough

Changes

The change adds lenient etcd reads for unknown fields while retaining strict Admin API writes. It records partial compatibility in configuration status, Prometheus metrics, heartbeat payloads, and end-to-end tests. Known enum and schema violations remain rejected.

Configuration compatibility

Layer / File(s) Summary
Strict and lenient schema contracts
CONTRIBUTING.md, crates/aisix-core/src/models/*, crates/aisix-core/src/models/schema.rs, crates/aisix-core/src/bin/dump-schema.rs, schemas/README.md
Model deserialization accepts unknown fields. Strict and lenient validators share schema generation while preserving constraint and enum validation.
Lenient loading and retention
crates/aisix-etcd/src/loader.rs, crates/aisix-etcd/src/supervisor.rs
The loader collects ignored fields with serde_ignored. The supervisor retains, replaces, clears, aggregates, and reports partial-compatibility rows.
Status and metrics reporting
crates/aisix-core/src/config_status.rs, crates/aisix-core/src/filesource/status.rs, crates/aisix-obs/src/metrics.rs, crates/aisix-admin/src/lib.rs
Status output exposes sorted kind, field, and count records. Metrics expose per-kind gauges and clear stale series.
Heartbeat integration and end-to-end validation
crates/aisix-server/src/heartbeat.rs, crates/aisix-server/src/main.rs, tests/e2e/src/cases/config-forward-compat-e2e.test.ts
Heartbeat payloads include non-empty partial-compatibility aggregates. Tests cover accepted unknown fields, rejected enum values, cleanup, metrics, and strict writes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Etcd
  participant Loader
  participant Supervisor
  participant Status
  participant Metrics
  participant Heartbeat
  Etcd->>Loader: Read configuration rows
  Loader->>Loader: Validate and collect ignored fields
  Loader->>Supervisor: Serve rows and compatibility data
  Supervisor->>Status: Record partial compatibility
  Supervisor->>Metrics: Synchronize per-kind counts
  Supervisor->>Heartbeat: Provide recent compatibility aggregate
  Heartbeat->>Heartbeat: Serialize non-empty compatibility entries
Loading

Possibly related PRs

  • api7/aisix#822: Introduced related MCP policy and API-key models with strict validation.
  • api7/aisix#825: Introduced related OIDC provider and API-key schema validation.
  • api7/aisix#848: Modified related A2A and MCP schema validation.

Suggested reviewers: moonming, jarvis9443

🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements lenient reads and YELLOW reporting, but it explicitly defers required RED last-known-good retention across resyncs and restarts. Implement RED last-known-good retention across resyncs and restarts, including deletion and staleness reporting, before merging.
E2e Test Quality Review ⚠️ Warning The E2E suite has a hidden dependency: the deletion test uses yellowKeyId created only by the first test, so isolated or reordered runs are unsafe. Create the YELLOW fixture within each test or use explicit shared setup with deterministic cleanup; also assert upstream.receivedRequests to prove the external call occurred.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: lenient etcd parsing with tri-state compatibility reporting.
Out of Scope Changes check ✅ Passed All reviewed changes support issue #871, including lenient loading, strict writes, compatibility reporting, documentation, and tests.
Security Check ✅ Passed No issues found in categories 1-7: reporting exposes only kinds, paths, and counts; errors are masked, and no new secret writes, mutating endpoints, ownership, TLS, or reference paths exist.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/aisix-871-lenient-parsing-1cc693

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
crates/aisix-core/src/models/model.rs (1)

515-527: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every newly lenient nested model type.

The test checks only an unknown top-level field on Model. It does not verify unknown fields for ModelCost, BackgroundModelCheck, CooldownConfig, or AutoPromptCaching. Add one nested compatibility case for each changed type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-core/src/models/model.rs` around lines 515 - 527, Extend
tolerates_unknown_top_level_fields_for_forward_compat with separate
deserialization cases covering ModelCost, BackgroundModelCheck, CooldownConfig,
and AutoPromptCaching. Add an unknown field to each type’s JSON fixture, assert
deserialization succeeds, and verify a representative known field to confirm the
nested model remains correctly parsed.

Source: Coding guidelines

crates/aisix-server/src/heartbeat.rs (1)

482-487: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider bounding the aggregate size on the wire.

config_hash carries a defensive clamp at CONFIG_HASH_MAX_CHARS even though the value is a fixed-length hash. partially_compatible_resources has no equivalent bound.

The DP side is already well bounded: MAX_RETAINED_PARTIAL_ROWS caps retained rows at 1024, and array-index normalization keeps entry count tied to document shape rather than data volume. So the practical risk is small. A control plane that introduces many distinct unknown field names per kind would still grow this array without limit.

A take(N) here would match the defensive posture applied to config_hash and keep the heartbeat body size predictable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-server/src/heartbeat.rs` around lines 482 - 487, The partially
compatible resource aggregate is unbounded on the wire. In the heartbeat
construction around partially_compatible_resources, limit the iterator to a
suitable explicit maximum with take(N) before collecting, matching the defensive
bounding used for config_hash; preserve the existing sorting and
PartialCompatWire conversion behavior.
crates/aisix-etcd/src/loader.rs (2)

482-508: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Emit the WARN outside the dedup lock.

warn_partial_compat_deduped holds the WARNED mutex guard across tracing::warn!. Two consequences follow:

  1. The critical section covers subscriber work — formatting and writer I/O. Supervisor::apply_put runs concurrently (the supervisor has a 200-way concurrent-put test), so every YELLOW row on every thread serializes on this one global mutex while a log line is written.
  2. A panic inside the subscriber while the guard is held poisons the mutex. Every later call then hits the .expect(...) and panics, which turns a logging fault into a loader fault.

Decide under the lock, then release it before emitting.

♻️ Proposed fix: release the lock before logging
     let fields_joined = fields.join(",");
     let entry = (kind.to_string(), fields_joined);
-    let mut warned = WARNED
-        .get_or_init(|| Mutex::new(HashSet::new()))
-        .lock()
-        .expect("partial-compat warn dedup set is never poisoned");
-    if warned.contains(&entry) {
-        return;
-    }
+    // Decide and record under the lock; log after releasing it so a
+    // subscriber's formatting/IO never runs inside the critical section
+    // (and a panic there cannot poison the set).
+    let set = WARNED.get_or_init(|| Mutex::new(HashSet::new()));
+    {
+        let mut warned = match set.lock() {
+            Ok(g) => g,
+            // A poisoned set only costs dedup accuracy, never correctness.
+            Err(poisoned) => poisoned.into_inner(),
+        };
+        if warned.contains(&entry) {
+            return;
+        }
+        if warned.len() < MAX_REMEMBERED {
+            warned.insert(entry.clone());
+        }
+    }
     tracing::warn!(
         key = %key,
         kind = %kind,
         ignored_fields = %entry.1,
         "row loaded with unknown fields ignored (partially compatible; \
          likely written by a newer control plane)"
     );
-    if warned.len() < MAX_REMEMBERED {
-        warned.insert(entry);
-    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-etcd/src/loader.rs` around lines 482 - 508, Update
warn_partial_compat_deduped so the WARNED mutex is held only while checking and
recording the entry, then explicitly release the guard before calling
tracing::warn!. Preserve the existing deduplication and MAX_REMEMBERED behavior,
including returning without logging for previously seen entries.

404-410: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

RED rows re-log at ERROR on every resync.

Schema failures now log at ERROR. The YELLOW path is deduplicated by warn_partial_compat_deduped, but this path is not. A resync rebuilds the whole snapshot on a cadence, so one permanently invalid row emits one ERROR per row per cycle for the life of the process.

The severity bump is intentional and correct. Consider applying the same dedup discipline here so a single unfixable row does not dominate the error stream. The supervisor already retains the rejection for reporting, so log repetition adds no signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-etcd/src/loader.rs` around lines 404 - 410, The
schema-validation failure in the validate path logs repeatedly on every snapshot
resync. Apply the existing deduplication approach used by
warn_partial_compat_deduped to this rejection path, while preserving ERROR
severity and the supervisor’s retained rejection reporting; ensure each
permanently invalid key is logged only once.
crates/aisix-server/src/main.rs (1)

780-810: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the new field to the heartbeat comment list.

The comment block above enumerates what each tick reports: rejected_resources, applied_revision, config_hash, supported_guardrail_kinds, and exporter_health. The wiring now also reports partially_compatible_resources. Extend the list so the enumeration stays complete.

The fetcher wiring itself matches the three sibling fetchers exactly.

📝 Proposed comment update
     //   - config_hash: the hash of the applied (served) config set, so
     //     cp-api can diff the hash a node reports against the hash it
     //     expects that node to be serving (`#774`)
+    //   - partially_compatible_resources: served rows carrying fields
+    //     this build does not know, aggregated per (kind, field), so
+    //     cp-api can show "loaded but not enforced" during a rollout
+    //     where the CP writes ahead of the DP fleet (`#871`)
     //   - supported_guardrail_kinds + exporter_health (`#519` B.6 / D.2)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-server/src/main.rs` around lines 780 - 810, Update the heartbeat
comment block above heartbeat_task to include partially_compatible_resources in
the enumerated fields reported on each tick, alongside the existing fields.
Leave the fetcher wiring and surrounding implementation unchanged.
crates/aisix-etcd/src/supervisor.rs (1)

1646-1731: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add coverage for the retention cap.

The four new tests cover put, clear-on-exact-match, delete, resync replacement, and retention across a rejected update. They do not exercise MAX_RETAINED_PARTIAL_ROWS.

Two cap behaviors are worth pinning, because both are silent from the caller's view:

  • set_partial_rows stops inserting after the cap and warns once.
  • update_partial_row skips a new key at the cap but still replaces an existing key.

The second rule is the subtle one. A test would prevent a future refactor from dropping updates for rows already reported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-etcd/src/supervisor.rs` around lines 1646 - 1731, Add tests
covering MAX_RETAINED_PARTIAL_ROWS through set_partial_rows and
update_partial_row. Verify set_partial_rows stops adding rows at the cap and
emits the warning only once, while update_partial_row ignores a new key when
full but still replaces an existing retained key. Preserve assertions for
retained contents and replacement behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CONTRIBUTING.md`:
- Around line 56-59: Update the “Adding a field” guidance in CONTRIBUTING.md to
limit the compatibility rule to open schema branches. Explicitly state that
intentionally closed branches, including observability-exporter, remain strict
and may be rejected by older gateways rather than reported as partially
compatible; preserve the existing observability details for open branches.

In `@crates/aisix-core/src/bin/dump-schema.rs`:
- Around line 78-108: Regenerate all committed resource schemas using the
updated dump function and commit the resulting changes under schemas/resources.
Ensure cache policy, guardrail, guardrail attachment, observability exporter,
and any other struct-shaped resource roots include additionalProperties: false,
and verify the generated diff contains no remaining schema drift.

In `@tests/e2e/src/cases/config-forward-compat-e2e.test.ts`:
- Around line 173-179: Make the test named “deleting the forward-compat row
clears the report; converged config has zero partially-compatible rows”
self-contained instead of relying on the first test’s yellowKeyId assignment.
Create its own partially compatible API-key row and retain the existing delete
and assertions using that locally created identifier, so it passes when run
independently.

---

Nitpick comments:
In `@crates/aisix-core/src/models/model.rs`:
- Around line 515-527: Extend
tolerates_unknown_top_level_fields_for_forward_compat with separate
deserialization cases covering ModelCost, BackgroundModelCheck, CooldownConfig,
and AutoPromptCaching. Add an unknown field to each type’s JSON fixture, assert
deserialization succeeds, and verify a representative known field to confirm the
nested model remains correctly parsed.

In `@crates/aisix-etcd/src/loader.rs`:
- Around line 482-508: Update warn_partial_compat_deduped so the WARNED mutex is
held only while checking and recording the entry, then explicitly release the
guard before calling tracing::warn!. Preserve the existing deduplication and
MAX_REMEMBERED behavior, including returning without logging for previously seen
entries.
- Around line 404-410: The schema-validation failure in the validate path logs
repeatedly on every snapshot resync. Apply the existing deduplication approach
used by warn_partial_compat_deduped to this rejection path, while preserving
ERROR severity and the supervisor’s retained rejection reporting; ensure each
permanently invalid key is logged only once.

In `@crates/aisix-etcd/src/supervisor.rs`:
- Around line 1646-1731: Add tests covering MAX_RETAINED_PARTIAL_ROWS through
set_partial_rows and update_partial_row. Verify set_partial_rows stops adding
rows at the cap and emits the warning only once, while update_partial_row
ignores a new key when full but still replaces an existing retained key.
Preserve assertions for retained contents and replacement behavior.

In `@crates/aisix-server/src/heartbeat.rs`:
- Around line 482-487: The partially compatible resource aggregate is unbounded
on the wire. In the heartbeat construction around
partially_compatible_resources, limit the iterator to a suitable explicit
maximum with take(N) before collecting, matching the defensive bounding used for
config_hash; preserve the existing sorting and PartialCompatWire conversion
behavior.

In `@crates/aisix-server/src/main.rs`:
- Around line 780-810: Update the heartbeat comment block above heartbeat_task
to include partially_compatible_resources in the enumerated fields reported on
each tick, alongside the existing fields. Leave the fetcher wiring and
surrounding implementation unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c0864c1b-0fa8-454d-bed6-631659eefe2b

📥 Commits

Reviewing files that changed from the base of the PR and between 9295db9 and ded6208.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (29)
  • CONTRIBUTING.md
  • Cargo.toml
  • crates/aisix-admin/src/lib.rs
  • crates/aisix-core/src/bin/dump-schema.rs
  • crates/aisix-core/src/config_status.rs
  • crates/aisix-core/src/filesource/status.rs
  • crates/aisix-core/src/models/a2a_agent.rs
  • crates/aisix-core/src/models/apikey.rs
  • crates/aisix-core/src/models/embedding.rs
  • crates/aisix-core/src/models/ensemble.rs
  • crates/aisix-core/src/models/mcp_policy.rs
  • crates/aisix-core/src/models/mcp_server.rs
  • crates/aisix-core/src/models/mod.rs
  • crates/aisix-core/src/models/model.rs
  • crates/aisix-core/src/models/oidc_provider.rs
  • crates/aisix-core/src/models/provider_key.rs
  • crates/aisix-core/src/models/rate_limit.rs
  • crates/aisix-core/src/models/rate_limit_policy.rs
  • crates/aisix-core/src/models/routing.rs
  • crates/aisix-core/src/models/schema.rs
  • crates/aisix-core/src/models/semantic.rs
  • crates/aisix-etcd/Cargo.toml
  • crates/aisix-etcd/src/loader.rs
  • crates/aisix-etcd/src/supervisor.rs
  • crates/aisix-obs/src/metrics.rs
  • crates/aisix-server/src/heartbeat.rs
  • crates/aisix-server/src/main.rs
  • schemas/README.md
  • tests/e2e/src/cases/config-forward-compat-e2e.test.ts

Comment thread CONTRIBUTING.md
Comment on lines 78 to +108
fn dump<T: JsonSchema>(out_dir: &Path, name: &str) {
// Serialize the `RootSchema` directly to preserve schemars' native key
// ordering. (Routing through `serde_json::Value` would re-sort keys.)
let mut json =
serde_json::to_string_pretty(&schemars::schema_for!(T)).expect("serialize schema");
// These nested types belong to closed resources, so re-close the root
// and every struct-shaped definition on the typed schema — the same
// strictness `schema::close_unknown_fields` applies to the resource
// documents, kept typed here so the key order stays schemars-native.
let mut root = schemars::schema_for!(T);
close_object_schema(&mut root.schema);
for def in root.definitions.values_mut() {
if let schemars::schema::Schema::Object(obj) = def {
close_object_schema(obj);
}
}
let mut json = serde_json::to_string_pretty(&root).expect("serialize schema");
json.push('\n');
let path = out_dir.join(format!("{name}.schema.json"));
fs::write(&path, json).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
println!("wrote {}", path.display());
}

/// Insert `additionalProperties: false` on a struct-shaped schema object
/// (one that lists `properties`), unless it already pins a value.
fn close_object_schema(schema: &mut schemars::schema::SchemaObject) {
let Some(object) = schema.object.as_deref_mut() else {
return;
};
if !object.properties.is_empty() && object.additional_properties.is_none() {
object.additional_properties = Some(Box::new(schemars::schema::Schema::Bool(false)));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Show the committed nested-type schemas and check that every
# struct-shaped object carries additionalProperties: false.
set -euo pipefail

fd -t f '.*\.schema\.json$' schemas/resources | sort

for name in ensemble rate_limit routing semantic embedding; do
  f="schemas/resources/${name}.schema.json"
  [ -f "$f" ] || { echo "MISSING $f"; continue; }
  echo "== $f =="
  jq '{root_ap: .additionalProperties,
       defs_missing_ap: (.definitions // {} | to_entries
         | map(select(.value.properties != null and (.value | has("additionalProperties") | not)))
         | map(.key))}' "$f"
done

# The strict resource schemas come from `resource_root_schema(_, true)`.
for f in $(fd -t f '.*\.schema\.json$' schemas/resources); do
  jq -e 'if (.properties != null) then (.additionalProperties != null) else true end' "$f" >/dev/null \
    || echo "root not closed: $f"
done

Repository: api7/aisix

Length of output: 1585


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== dump-schema.rs relevant content =="
sed -n '1,160p' crates/aisix-core/src/bin/dump-schema.rs | cat -n

echo "== schema file summary =="
for f in $(fd -t f '.*\.schema\.json$' schemas/resources | sort); do
  status="OK"
  jq -e 'if (.properties != null or (is_object and any(. != null))) then (.additionalProperties != null) else true end' "$f" >/dev/null || status="CLOSED_FALSE"
  jq -e 'if (.properties != null or (is_object and any(. != null))) then .definitions as $d | to_entries | map(select(.value.properties != null and (has("additionalProperties") | not or .value.additionalProperties == null))) | length == 0; else true end' "$f" >/dev/null || status="DEF_MISSING_AP"
  echo "$status:$f"
done

Repository: api7/aisix

Length of output: 31347


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json, pathlib

for path in sorted(pathlib.Path('schemas/resources').glob('*.schema.json')):
    data = {}
    with open(path, 'r') as f:
        data = json.load(f)
    root = data.get('additionalProperties')
    missing_defs = [
        defname for defname, def_schema in (data.get('definitions') or {}).items()
        if isinstance(def_schema, dict) and def_schema.get('properties')
           and def_schema.get('additionalProperties') is None
    ]
    root_status = 'present' if root is not None else 'missing'
    print(f"{path}: root_additionalProperties={root_status}, missing_struct_defs={missing_defs}")
PY

echo "== schema resource producer search =="
rg -n "resource_root_schema|close_unknown_fields|deny_unknown_fields" crates/aisix-core/src/models crates/aisix-core/src -g '*.rs' | head -200

Repository: api7/aisix

Length of output: 11290


Regenerate the schema files before merging.

The generator now closes struct-shaped schemas with additionalProperties: false, but the committed outputs do not all match that shape. Some resource roots still miss additionalProperties, including cache policy, guardrail, guardrail attachment, and observability exporter; update schemas/resources/*.schema.json and run the generator diff so schema drift cannot land silently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-core/src/bin/dump-schema.rs` around lines 78 - 108, Regenerate
all committed resource schemas using the updated dump function and commit the
resulting changes under schemas/resources. Ensure cache policy, guardrail,
guardrail attachment, observability exporter, and any other struct-shaped
resource roots include additionalProperties: false, and verify the generated
diff contains no remaining schema drift.

Comment thread tests/e2e/src/cases/config-forward-compat-e2e.test.ts
MEDIUM-1: the managed control plane writes env_id on every
guardrail_attachment document (its own tenancy scoping; the gateway
never reads it). Declare the field on the model as known-and-ignored so
a same-version managed fleet reports zero partially-compatible rows -
a standing false version-skew alarm would train operators to ignore
the YELLOW signal. A control-plane source sweep confirmed this is the
only field any CP marshal site writes that the DP models do not know.

MEDIUM-2: OnEmbeddingFailure is untagged with an object variant; serde
buffers untagged content and silently swallows unknown fields inside
it, invisible to serde_ignored on read and to the serde step on write.
Close the object branch in the producer (both validator sets), same
rule as the tagged-enum closures; the dump for the standalone nested
schemas applies the same closure through anyOf branches. Published
schema diff is this deliberate tightening plus the documented env_id.

MEDIUM-3: pin the deliberate closures on the LENIENT set with tests -
an exporter-branch smuggled field and a guardrail sub-enum unknown
field must stay rejected on the read path, or a refactor moving branch
closing into the strict pass would silently open them.

MEDIUM-4: cap ignored-field paths at 64 per row with a visible
truncation sentinel; one document could otherwise flood logs, the
retained map, the status JSON and the heartbeat body.

LOW: WARN-dedup lock is poison-tolerant (a panicking subscriber must
not wedge snapshot builds); path-normalization aliasing documented;
CONTRIBUTING names the two kinds (guardrail, observability_exporter)
where any new field still whole-row rejects; e2e drives the YELLOW
journey through a second traffic-bearing kind (a model with an unknown
field serves chat).
@membphis

membphis commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Independent audit — outcome

A fresh, zero-context audit agent reviewed this PR per the repository merge gate. Verdict: no HIGH findings; all core claims verified (lenient parse mechanics, strict-write preservation via schema regeneration, separate YELLOW retention, all three reporting surfaces, CP-side verification that the flipped provider_key fields are genuinely unconsumed and that the CP heartbeat handler tolerates the new field). 4 MEDIUM and 4 LOW findings — all fixed in code in 7f3bc88:

Finding Disposition
MEDIUM-1: CP writes env_id on every guardrail_attachment → permanent false YELLOW on same-version managed fleets Fixed: env_id declared on the model as known-and-ignored (documented in the published schema). A full CP marshal-site sweep confirmed this is the only CP-written field the DP models did not know, across all 12 kinds. Loader test added with the real CP projection shape.
MEDIUM-2: untagged OnEmbeddingFailure object variant silently tolerated unknown fields on BOTH paths (pre-existing) Fixed: object branch closed in the producer for both validator sets, plus the standalone nested-schema dump; tests pin strict AND lenient rejection. This is a deliberate write-path tightening (a typo there was previously accepted and silently dropped).
MEDIUM-3: no test pinned the deliberate closures on the lenient set Fixed: lenient_set_keeps_deliberate_closures_closed + strict/lenient split tests added.
MEDIUM-4: unbounded per-row field reporting (logs / status JSON / heartbeat body) Fixed: 64-field cap per row with a visible ...truncated sentinel; test added.
LOW-1 poisonable WARN-dedup lock Fixed: poison-tolerant lock recovery.
LOW-2 path-normalization aliasing Documented on the normalizer (lossy by design; per-kind field name is the actionable signal).
LOW-3 CONTRIBUTING overstated "adding a field is safe" Fixed: guardrail / observability_exporter named as exempt kinds (closed tagged shapes — treat additions like enum values).
LOW-4 YELLOW journey e2e covered one kind Fixed: e2e now also serves chat through a model document carrying an unknown field.

Note on the "byte-identical schemas" claim in the description: it held exactly for the leniency flip itself (proving the closing transform reproduces deny_unknown_fields output). The audit-fix commit then makes three deliberate schema changes: the documented env_id on guardrail_attachment (open resource — write behavior unchanged) and the OnEmbeddingFailure object-branch closure in model + semantic (the MEDIUM-2 tightening).

Verification after fixes: cargo test --workspace green, clippy/fmt clean, forward-compat + status-config + guardrail e2e suites green against real etcd.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Config forward-compat: unknown fields from a newer CP whole-row-reject resources on older DPs — lenient parse + tri-state compat status

1 participant