Skip to content

feat(operator,crdt,swim): tenant-budget plumbing via SWIM-gossiped GCounter (grid#40) - #47

Merged
nerdalert merged 7 commits into
praxis-proxy:mainfrom
jordigilh:feat/grid-40-tenant-budget-plumbing
Aug 13, 2026
Merged

feat(operator,crdt,swim): tenant-budget plumbing via SWIM-gossiped GCounter (grid#40)#47
nerdalert merged 7 commits into
praxis-proxy:mainfrom
jordigilh:feat/grid-40-tenant-budget-plumbing

Conversation

@jordigilh

Copy link
Copy Markdown
Contributor

Summary

Fixes #40 — adds Grid-side plumbing for MVP 1b tenant-budget tracking: a per-tenant budget policy on GridNetwork, a tenant-keyed GCounter carried in the SWIM-gossiped grid state snapshot, and a derived spendRatio exposed on GridNetwork.status. Enforcement (degrade to a cheaper backend / reject at 100%) is explicitly out of scope here — that's cross-repo, gateway-side praxis-ai policy-filter work, same precedent as the provider_route gap tracked in #44.

What's built

  1. CRD fieldGridNetworkSpec gains budget_policy: Option<BudgetPolicyConfig>, following the sibling scoring_policy field's serde idiom exactly (same #[serde(default, skip_serializing_if = "Option::is_none")]). validate_budget_policy() rejects negative/NaN/infinite caps and duplicate or blank tenant_ids; the generated CRD schema additionally enforces a numeric minimum on capUsd as defense in depth.
  2. Tenant-keyed GCounterGridStateSnapshot gains tenant_spend: BTreeMap<String, GCounter>, reusing the crdt crate's already-proven CRDT laws (commutative/associative/idempotent merge) rather than inventing new CRDT theory.
  3. SWIM broadcast wiringStateBroadcastHandler::receive_item merges the new field alongside the existing capabilities merge.
  4. Exposed signalGridNetworkStatus gains budget_status: Vec<TenantBudgetStatus>, populated per-reconcile via a pure tenant_spend_status()/resolve_budget_statuses() pair, mirroring the existing overlay_status precedent. Deliberately not folded into render_routing_overlay() or score_backends(): budget is a per-tenant status concern, a different axis from per-backend routing scores.

A real bug this surfaced

Writing a live, real-UDP integration test (see below) rather than only mocked unit tests caught a genuine defect: StateBroadcast::is_metadata_only() only checked providers/capabilities, so a broadcast carrying only a tenant_spend change (the common case once provider topology has settled and spend keeps accruing) was silently classified as metadata-only, and receive_item skipped the merge entirely — meaning tenant spend would never propagate once provider state stopped churning. Fixed by including tenant_spend.is_empty() in the check, with regression tests at both the carries_grid_state() and receive_item levels.

Testing (TDD RED → GREEN → REFACTOR, pyramid invariant)

  • Unit (100% line coverage of every new pure function, via cargo-llvm-cov): validate_budget_policy, spend_ratio, cents_to_usd, the tenant_spend CRDT merge (add-only, per-site-max, idempotent, commutative, associative, serde round-trip, old-wire-shape default), tenant_spend_status/resolve_budget_statuses. 35 scenarios across crdt, operator, and swim.
  • Integration: CRD schema-generation (budgetPolicy appears in the OpenAPI schema, deny_unknown_fields rejects malformed shape), receive_item merge-wiring (including the is_metadata_only regression above, both truth values), reconcile wiring that budget_status is populated from policy + merged CRDT state.
  • Real-network integration (operator::swim_runtime, live-validated on helios08): three real SwimHandle instances bound to real UDP sockets — no mocked message-passing. Two sites independently record tenant spend and converge with each other; a third site then joins late ("the partition heals") and must converge to the true cross-site sum purely through SWIM's own gossip. Ties the pipeline together end-to-end by running the same resolve_budget_statuses() the reconciler calls, against the late joiner's independently-converged view.

Validation

  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo +nightly-2026-03-28 fmt --all -- --check — clean
  • cargo machete — clean
  • cargo test --workspace — all passing, including the new real-UDP convergence test
  • Live-validated on helios08: full workspace test suite re-run there against a clean checkout of this branch (real remote infra, not just local).

Explicit non-goals (AC5)

  • "Degrade to cheaper backend" / "reject at 100%" enforcement is not implemented here.
  • Real per-request tenant attribution (which request belongs to which tenant) depends on upstream ai#130/ai#104 and doesn't exist yet — the SWIM-level convergence proof above uses realistic cost values (mirroring scoring::BackendConfig::cost_per_1k_input) as a stand-in for the not-yet-existing gateway-side increment call site.

@jordigilh
jordigilh force-pushed the feat/grid-40-tenant-budget-plumbing branch from 08e1cf1 to 9427aeb Compare August 12, 2026 19:30
@jordigilh

Copy link
Copy Markdown
Contributor Author

Pushed a fix commit (be06ebd) addressing the automated review findings, full TDD (RED → GREEN → REFACTOR), make lint clean, 100% line coverage of the new logic confirmed via cargo-llvm-cov.

Bugbot — high: spend-only broadcast wipes origin providers

Confirmed real. receive_item treated any non-metadata broadcast (including a tenant-spend-only one) as an authoritative provider-state sync, so replace_origin_providers ran with an empty provider list and deleted the origin's real provider records at every peer.

Fix: added StateBroadcast::carries_provider_state() and gated the replace_origin_providers call on it — a spend-only broadcast now only updates tenant_spend, leaving the origin's existing provider records untouched. Regression test: receive_item_spend_only_broadcast_does_not_wipe_origins_existing_providers.

Security review — medium: SWIM gossip can forge per-tenant spend

Confirmed real. GCounter::merge took the max of every slot in the incoming counter with no check that a slot's key matched the broadcast's claimed origin_site, so a compromised/buggy peer could embed an inflated slot for another site and have it accepted mesh-wide.

Fix: added GCounter::retain_origin and GridStateSnapshot::merge_tenant_spend_from_origin, used only at the wire-ingest boundary in receive_item — it keeps just the claimed origin's own slot per tenant and drops everything else, mirroring how provider records are already scoped to their origin. The existing permissive merge_tenant_spend is untouched and still used for trusted full-snapshot-to-full-snapshot merges. Tests: merge_tenant_spend_from_origin_drops_forged_foreign_slots, merge_tenant_spend_from_origin_accepts_the_claimed_origins_own_slot, plus idempotency/no-genuine-contribution cases.

Security review — medium: unbounded tenant_spend growth from gossip

Confirmed real, no eviction or cap existed. Fix:

  • GCounter::remove_slot + GridStateSnapshot::remove_origin_tenant_spend, wired into OriginStateHandle::remove_origin's existing dead-member eviction sweep (mirrors remove_origin_providers). A tenant whose spend becomes fully empty after eviction is pruned entirely.
  • MAX_TRACKED_TENANTS (1024, mirroring the existing max_origins bound) enforced in merge_tenant_spend_from_origin: already-tracked tenants keep accepting updates; brand-new tenant_ids are refused once at capacity.

Security review — medium/operational: cross-tenant spend visible in budgetStatus

Noted, not a code bug in this PR's scope: GridNetworkStatus.budgetStatus is exposed via the standard Kubernetes API, so any principal with get/list on GridNetwork can see every tenant's spend, not just their own. This is an RBAC/deployment concern the same way any other CR status field is — recommending cluster operators scope GridNetwork read access accordingly, and flagging it here so it's visible to reviewers rather than silently assumed. Happy to open a follow-up doc/issue if we want it tracked separately.

All 3 crates (crdt, swim, operator) pass cargo test --workspace and make lint (clippy/fmt/machete) clean after this fix.

@jordigilh

Copy link
Copy Markdown
Contributor Author

Filed #48 to track the RBAC/cross-tenant-visibility question on budgetStatus flagged above — not a blocker for this PR, but capturing it as a decision-needed item with 2-3 proposed options rather than letting it drop. Labeled question + triage/needs-triage pending a second pair of eyes.

@jordigilh

Copy link
Copy Markdown
Contributor Author

Ran a self-audit of this PR against the project's own CI matrix (all of Tests/Coverage/MSRV/Documentation/Helm/Unicode Safety/Supply Chain are currently sitting in action_required, presumably pending a maintainer approving workflow runs for a first-time external contributor — so I reproduced each one locally against this branch before it gets that approval).

Found and fixed in d9a3648:

  • deploy/crds/gridnetwork.yaml and charts/grid-operator/crds/gridnetwork.yaml hadn't been regenerated after the budgetPolicy/budgetStatus schema change — both files had zero occurrences of either field. Since this CRD has no preserveUnknownFields, a real cluster installing from the previously-checked-in manifests would have the API server silently prune spec.budgetPolicy as unknown on admission, so the feature would pass every unit/integration test (none of which go through K8s API validation) while being inert on any real install. Regenerated both via ./scripts/generate-deployment-crds.sh — purely additive, 78 lines, 0 removed.
  • docs/architecture/crds.md had no mention of the new fields, unlike the scoringPolicy/metricsRefreshInterval precedent (each documented in the same commit that introduced them). Added an example, extended the status-fields list, and a short "Tenant budget tracking" section that also links to Decision needed: GridNetwork.status.budgetStatus exposes all tenants' spend to any RBAC-permitted reader #48 for the RBAC/visibility question.

Re-verified clean after both fixes: cargo clippy --workspace --all-targets -- -D warnings, cargo +nightly-2026-03-28 fmt --all -- --check, cargo machete, cargo test --workspace (79/79 in touched crates), make coverage-check (85.06% workspace lines vs. the 80% gate), make doc (rustdoc -D warnings), and a hidden-Unicode scan of every touched file (mirrors the unicode-safety-check gate). No TODO/FIXME/debug leftovers or new unwrap/expect/panic! in the diff.

Everything else checked out: budget_policy/budget_status follow the existing scoring_policy/overlay_status serde idioms (#[serde(default, ...)], so old GridNetwork objects deserialize unaffected), no new dependencies were added (so cargo audit/cargo deny have zero delta from this PR), and there are no open review threads beyond the RBAC question already tracked in #48.

@jordigilh

Copy link
Copy Markdown
Contributor Author

Found one more gap while double-checking whether this needed helm-unittest coverage: charts/grid-site (the site-operator-facing chart that renders an actual GridNetwork from values.yaml) never got budgetPolicy wired in, unlike the two prior sibling fields (scoringPolicy, metricsRefreshInterval), which both updated this same chart's template + values.schema.json when they were added. Since that schema has additionalProperties: false, deploying via this chart gave no way to set a budget policy at all.

Fixed in c2ab36f:

  • Added the budgetPolicy block to charts/grid-site/templates/gridnetwork.yaml (verbatim toYaml passthrough, matching the existing gatewayRefs/swim/tls style, since tenants[] is open-ended rather than a fixed enum).
  • Added the matching budgetPolicy/tenants[] schema to values.schema.json.
  • Added a helm-unittest suite for this chart (charts/grid-site/tests/gridnetwork_test.yaml) — new to the repo; none of the 3 charts' tests/ directories use that framework today, they're all helm test hook Pods for post-install smoke checks. Scoped narrowly to the new template logic: budget policy omitted when unset, tenants render verbatim when set, empty tenant list doesn't error.
  • Wired helm unittest charts/grid-site into the Helm CI workflow, scoped to this one chart rather than requiring it repo-wide.

Re-verified: helm unittest charts/grid-site (3/3), full ./scripts/verify-helm-chart.sh (162/162, using its real example-values files — grid-site's own values.yaml defaults don't satisfy its own schema even before this change, that's pre-existing and unrelated), actionlint/shellcheck clean on the touched workflow, cargo clippy/fmt/machete unaffected (no Rust touched this round).

@jordigilh

Copy link
Copy Markdown
Contributor Author

Live cluster validation (Kind, helios08)

Validated this PR end-to-end on a real cluster rather than relying solely on
static checks:

1. Full CI-equivalent Kind lifecycle — ran KIND=1 GRID_OPERATOR_CI_TAG=ci ./scripts/verify-helm-chart.sh (the exact command this repo's own "Helm" CI
workflow runs) against a freshly built grid-operator:ci image built with
podman build --network=host on helios08: 182/182 checks passed, twice
(before and after the fix below), including operator install, CRD
establishment, deployment rollout, helm test, upgrade with
resourceNamespaces, uninstall with CRD/CR retention, and gateway chart
wiring.

2. Targeted budgetPolicy live validation — deployed grid-operator +
grid-site (with the regenerated CRDs from this PR) to a separate Kind
cluster and exercised the actual feature against a live API server, not just
helm template:

  • helm install with budgetPolicy.tenants set → CR persisted verbatim
    (including a fractional capUsd: 250.5), proving the CRD schema is no
    longer stale/pruning the field.
  • Operator reconciled and populated status.budgetStatus with correct
    zero-spend entries per tenant.
  • helm upgrade changing the tenant set → both spec.budgetPolicy and
    status.budgetStatus updated to match on the next reconcile.
  • helm upgrade removing budgetPolicy entirely → status.budgetStatus
    cleared back to empty, as expected.

3. Found + fixed a pre-existing, unrelated bug during that last step
removing budgetPolicy (landing on an all-chart-defaults gridNetwork)
caused spec: to render as YAML null, which the API server rejects with
spec: Required value. Confirmed via git archive origin/main that this
reproduces identically on an unmodified main checkout — it's not caused by
this PR, but it also means the chart's own documented minimal install
(gridNetwork.name + gridSite.name only) is currently broken on main.
Filed as #49 and fixed here in
41c7026
since I was already in this exact template for the budgetPolicy wiring —
gridId now renders unconditionally instead of being gated behind with,
guaranteeing spec is never null. Added a helm-unittest regression case
for it. Re-ran the full 182-check Kind lifecycle after the fix: still 182/182.

No further gaps found from this round of live-cluster testing.

@nerdalert nerdalert left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few changes if you don't mind! Feel free to suggest different. Thanks!

kubeconform -v

- name: Install helm-unittest
run: helm plugin install https://github.com/helm-unittest/helm-unittest --version "${HELM_UNITTEST_VERSION}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The required Helm validate check currently fails at this install step before any chart tests run. With the workflow-pinned Helm v3.17.3, helm-unittest v1.1.1 cannot load because its plugin manifest contains the unknown platformHooks field. Please pin a Helm/plugin combination that works in CI (and ideally checksum-pin the installed artifact), then confirm this required check is green.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A minimal candidate is to retain Helm 3 and pin the last pre-platformHooks plugin manifest (then verify the required job):

-  HELM_UNITTEST_VERSION: v1.1.1
+  # v0.8.2 uses the Helm 3 `command`/`hooks` plugin manifest.
+  HELM_UNITTEST_VERSION: v0.8.2

The v0.8.2 manifest uses the Helm 3-compatible command/hooks fields; v1.1.1 uses platformCommand/platformHooks. The green CI job should remain the deciding proof.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4a1fb5f — pinned exactly as suggested: HELM_UNITTEST_VERSION: v0.8.2, plus a comment explaining why (v1.x's plugin.yaml uses platformCommand/platformHooks, which the pinned Helm v3.17.3 can't load; v0.8.2 is the last release on the command/hooks schema). Confirmed against both plugin manifests directly before pushing.

Heads up: the Helm workflow run on this push is currently sitting at action_required (the fork-PR approval gate), not failing — would appreciate an approve on the run so the green check shows up.

Comment thread swim/src/state_broadcast.rs Outdated
.send_modify(|snapshot| snapshot.remove_origin_providers(origin));
self.state_tx.send_modify(|snapshot| {
snapshot.remove_origin_providers(origin);
snapshot.remove_origin_tenant_spend(origin);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This removes historical spend when SWIM evicts an origin, so the supposedly cumulative grow-only counter can decrease during ordinary membership churn. A transient site failure or restart could lower spendRatio and reopen exhausted budget. Provider records are membership-scoped, but cumulative spend should remain until an explicit budget epoch/reset policy removes it; could we retain tenant-spend slots here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The minimal cumulative-budget correction is simply to stop coupling spend retention to member liveness:

         self.state_tx.send_modify(|snapshot| {
             snapshot.remove_origin_providers(origin);
-            snapshot.remove_origin_tenant_spend(origin);
         });

If spend needs expiry, it should be implemented later as an explicit budget epoch/window reset rather than as a side effect of SWIM eviction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4a1fb5f exactly per your diff — remove_origin no longer calls remove_origin_tenant_spend; only provider/transport/cert state is cleared on eviction now. Added origin_state_handle_remove_origin_preserves_that_origins_tenant_spend (inverts the old test's assertion) to lock in the new behavior, and expanded the doc comment on remove_origin explaining why cumulative spend must survive membership churn.

Filed grid#52 to track the explicit budget-epoch/window reset design, plus bounding per-tenant site-slot growth now that eviction no longer prunes it — a design spike, not blocking this PR.

/// Returns [`BudgetPolicyValidationError`] for the first invalid tenant entry
/// found, in declaration order: a blank `tenantId`, a duplicate `tenantId`, a
/// negative `capUsd`, or a non-finite `capUsd`.
pub fn validate_budget_policy(policy: &BudgetPolicyConfig) -> Result<(), BudgetPolicyValidationError> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This validation function currently has no production callers; every reference is in tests. The CRD numeric minimum catches negative caps, but blank and duplicate tenant IDs can still reach reconciliation despite the documented rejection behavior. Please invoke this validation in the reconcile/admission path and surface a clear invalid-policy condition, or enforce the same constraints in the generated schema.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A small defensive reconcile-time fix would be:

     let name = grid_network_name(&network)?;
+
+    if let Some(policy) = network.spec.budget_policy.as_ref() {
+        crate::crd::grid_network::validate_budget_policy(policy).map_err(|error| {
+            OperatorError::InvalidResource(format!("invalid budgetPolicy: {error:?}"))
+        })?;
+    }
 
     info!(name, "reconciling GridNetwork");

A proper Display implementation for BudgetPolicyValidationError would make the status/log message cleaner, but the important part is ensuring the production reconcile path actually invokes the validation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Wired in 4a1fb5f along the lines of your diff — added a reject_invalid_budget_policy helper (calls validate_budget_policy, maps to OperatorError::InvalidResource) and invoke it as the first line of reconcile(). Also added a thiserror::Error derive + #[error(...)] messages on BudgetPolicyValidationError, so the error renders via {error} (e.g. tenant "tenant-a" appears more than once) instead of a {error:?} Debug dump. Added 6 unit tests for reject_invalid_budget_policy: absent policy, valid policy, blank tenant id, duplicate tenant id, negative cap, non-finite cap.

@praxis-bot praxis-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.

Thorough, well-structured PR. The is_metadata_only bug fix (spend-only broadcasts were silently dropped) and the carries_provider_state guard (preventing spend-only broadcasts from wiping existing providers via replace_origin_providers) are well-designed and well-tested. Trust-boundary enforcement in merge_tenant_spend_from_origin (slot stripping + MAX_TRACKED_TENANTS cap) is solid. CRDT property tests (idempotent, commutative, associative) and the real-UDP three-node convergence test give good confidence. The Helm template fix for spec: null when all optional fields are at their falsy defaults is a nice catch. One medium-priority gap noted inline.

.map(|swim| swim.state_snapshot().tenant_spend)
.unwrap_or_default();
let budget_statuses =
crate::crd::grid_network::resolve_budget_statuses(network.spec.budget_policy.as_ref(), &tenant_spend);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium -- validation gap: validate_budget_policy() is defined and thoroughly tested but is not called anywhere in the reconcile path. The CRD schema prevents negative capUsd and requires fields, but it cannot enforce uniqueness on tenants[].tenantId array items. A duplicate tenantId that passes CRD admission would reach tenant_spend_status() and produce duplicate budgetStatus entries with the same tenantId.

Consider calling validate_budget_policy() before resolve_budget_statuses() here and logging a warning (or setting a status condition) for invalid policies. This would also catch any edge cases that slip past CRD schema validation in non-Kubernetes code paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already resolved on this branch, ahead of this review: reject_invalid_budget_policy() (grid_network.rs:200) wraps validate_budget_policy() and is called as the very first line of reconcile() (line 229), before anything else including resolve_budget_statuses() -- so an invalid budgetPolicy (including a duplicate tenantId, which the CRD schema indeed can't express) now short-circuits reconciliation with OperatorError::InvalidResource rather than reaching status resolution at all. Covered by reject_invalid_budget_policy_rejects_duplicate_tenant_id and 4 sibling unit tests. This landed in commit 062e071 addressing nerdalert's earlier review on this same PR -- should be visible once CI catches up on this rebase.

…ounter (grid#40)

Adds Grid-side plumbing for MVP 1b tenant-budget tracking: a per-tenant
budget policy on GridNetwork, a tenant-keyed GCounter carried in the
SWIM-gossiped grid state snapshot, and a derived spendRatio exposed on
GridNetwork.status. Enforcement (degrade/reject at a threshold) stays
out of scope here -- that's cross-repo, gateway-side praxis-ai policy
filter work, same precedent as the provider_route gap tracked in
grid#44.

1. CRD field -- GridNetworkSpec gains budget_policy: Option<BudgetPolicyConfig>,
   following the sibling scoring_policy field's serde idiom exactly.
   validate_budget_policy() rejects negative/NaN/infinite caps and
   duplicate or blank tenant_ids; the generated CRD schema additionally
   enforces a numeric minimum on capUsd as defense in depth.

2. Tenant-keyed GCounter -- GridStateSnapshot gains
   tenant_spend: BTreeMap<String, GCounter>, reusing the crdt crate's
   already-proven CRDT laws (commutative/associative/idempotent
   merge) rather than inventing new CRDT theory. increment_tenant_spend()
   and merge_tenant_spend() round out the API.

3. SWIM broadcast wiring -- StateBroadcastHandler::receive_item merges
   the new field alongside the existing capabilities merge. Also fixes
   a real bug this surfaced: StateBroadcast::is_metadata_only() only
   checked providers/capabilities, so a broadcast carrying *only* a
   tenant_spend change (the common case once provider topology has
   settled) was silently classified as metadata-only and its merge
   was skipped entirely. Caught by a real-UDP two-origin-site gossip
   integration test, not a mock.

4. Exposed signal -- GridNetworkStatus gains
   budget_status: Vec<TenantBudgetStatus>, populated per-reconcile via
   a pure tenant_spend_status()/resolve_budget_statuses() pair,
   mirroring the existing overlay_status precedent. Not folded into
   render_routing_overlay() or score_backends(): budget is a per-tenant
   status concern, a different axis from per-backend routing scores.

Testing (TDD RED-GREEN-REFACTOR, pyramid invariant):
- Unit: 100% line coverage of every pure function above (validate,
  spend_ratio, merge, status derivation) via cargo-llvm-cov.
- Integration: CRD schema-generation, receive_item merge-wiring
  (including the is_metadata_only regression above), and reconcile
  wiring that budget_status is populated from policy + merged CRDT
  state.
- Real-network integration (operator::swim_runtime, live-validated on
  helios08): three real SwimHandle instances bound to real UDP
  sockets. Two sites independently record tenant spend and converge
  with each other; a third site then joins late ("partition heals")
  and must converge to the true cross-site sum purely through SWIM's
  own gossip -- no manual message shuttling. Ties the full pipeline
  together end to end by running the same resolve_budget_statuses()
  the reconciler calls, against the late joiner's independently
  converged view.

Fixes praxis-proxy#40

Signed-off-by: Jordi Gil <jgil@redhat.com>
Bugbot (high): a tenant-spend-only broadcast from an origin was being
treated as an authoritative provider-state sync, so `receive_item` ran
the destructive `replace_origin_providers` retain-then-replace with an
empty provider list and wiped that origin's real provider records at
every peer. Added `StateBroadcast::carries_provider_state` and gated
the replace call on it, decoupling provider-state sync from tenant
spend the same way gateway-address/cert already have independent
revision lanes.

Security review (medium): `GCounter::merge` blindly took the max of
every slot in an incoming counter with no check that the slot belonged
to the broadcast's claimed origin, letting a compromised/buggy peer
forge another site's recorded spend by embedding extra slots in its
own payload. Added `GCounter::retain_origin` and
`GridStateSnapshot::merge_tenant_spend_from_origin`, used at the
wire-ingest boundary in `receive_item` to accept only the claimed
origin's own slot per tenant, mirroring how provider records are
already scoped to their claimed origin. The permissive
`merge_tenant_spend` is preserved for trusted full-snapshot merges.

Security review (medium): tenant_spend had no eviction path and no
bound, so a churning or malicious origin could grow the map without
limit. Added `GCounter::remove_slot` /
`GridStateSnapshot::remove_origin_tenant_spend`, wired into
`OriginStateHandle::remove_origin`'s existing eviction sweep, and a
`MAX_TRACKED_TENANTS` hard bound (1024, mirroring the existing
`max_origins` bound) enforced in `merge_tenant_spend_from_origin`:
already-tracked tenants keep accepting updates, but brand-new
tenant_ids are refused once at capacity.

Full TDD: RED tests added first for all three fixes, then GREEN
implementation, then REFACTOR (clippy/fmt/machete clean). 100% line
coverage of the new logic confirmed via cargo-llvm-cov; remaining
"missed" lines in the touched files are pre-existing gaps (doc
comments, struct fields, an unrelated version-mismatch branch) outside
this diff.

Signed-off-by: Jordi Gil <jgil@redhat.com>
…fields

A GA-readiness audit of this PR found the checked-in CRD manifests had not
been regenerated after the budgetPolicy/budgetStatus schema change, and the
feature was undocumented.

1. deploy/crds/gridnetwork.yaml and charts/grid-operator/crds/gridnetwork.yaml
   were stale: both were byte-identical to each other but contained zero
   occurrences of budgetPolicy/budgetStatus, while the Rust CRD struct had 18.
   The existing budget_policy_appears_in_crd_schema unit test only exercises
   the in-memory kube-rs/schemars generation and can't catch drift against
   the exported YAML actually shipped to clusters via Helm or `kubectl apply
   -f deploy/crds/`. Concretely: any real installation using the previously
   checked-in manifests would have the Kubernetes API server silently prune
   spec.budgetPolicy as an unknown field on a structural schema (no
   preserveUnknownFields on this CRD), so the whole feature would appear to
   work in every unit/integration test -- none of which go through the K8s
   API's OpenAPI validation -- while being inert end to end. Regenerated both
   files via the project's own ./scripts/generate-deployment-crds.sh; diff is
   purely additive (78 lines, 0 removed) and confined to the new fields.

2. docs/architecture/crds.md had no mention of budgetPolicy/budgetStatus.
   Sibling GridNetworkSpec fields (scoringPolicy, metricsRefreshInterval) were
   each documented in the same commit that introduced them; this PR's original
   commits touched only *.rs files. Added a budgetPolicy example to the
   GridNetwork sample manifest, added budgetStatus[] to the status-fields
   list, and a "Tenant budget tracking" section covering what the fields mean,
   the explicit non-enforcement scope, the per-request-attribution dependency
   on praxis-ai#130/#104, and a pointer to grid#48 for the RBAC/cross-tenant
   visibility question already raised on this PR.

Re-verified after both changes: cargo clippy/fmt/machete clean, cargo test
--workspace passing (79/79 in the touched crates), make coverage-check
passing at 85.06% workspace lines (gate is 80%), make doc (rustdoc -D
warnings) clean, and a hidden-Unicode scan of every touched file clean
(mirrors the org's unicode-safety-check gate).

Signed-off-by: Jordi Gil <jgil@redhat.com>
`charts/grid-site` declaratively renders a `GridNetwork` from `values.yaml`
(`templates/gridnetwork.yaml`) -- the site-operator-facing deployment path,
distinct from `grid-operator` which only installs the controller + CRDs.
The two prior GridNetworkSpec field additions (scoringPolicy in c7c8f9e,
metricsRefreshInterval in c6e4e6a) both wired their field into this chart's
template and values.schema.json; budgetPolicy did not, so anyone deploying
via this chart had no way to configure a tenant budget policy at all -- the
schema's `additionalProperties: false` would hard-reject even a hand-added
`budgetPolicy:` key in a values file.

1. Added a `{{- with .Values.gridNetwork.budgetPolicy }}` block to
   templates/gridnetwork.yaml, passing the value through verbatim via
   `toYaml` (same style already used for `gatewayRefs`/`swim`/`tls`, since
   `tenants[]` is an open-ended list rather than a fixed enum like
   scoringPolicy).
2. Added a matching `budgetPolicy` object to values.schema.json: a
   `tenants` array of `{tenantId (non-empty string), capUsd (number,
   minimum 0)}`, mirroring the CRD schema's own field-level constraints.

Also added a helm-unittest suite (new to this chart -- none of the 3 chart
`tests/` directories in this repo use the helm-unittest framework today,
they're all `helm test` hook Pods for post-install smoke checks instead)
scoped to this template's new logic:
  - budgetPolicy omitted from the rendered spec when unset
  - budgetPolicy.tenants renders verbatim when set
  - an explicit empty tenants list renders without erroring

Wired `helm unittest charts/grid-site` into the "Helm" CI workflow
(.github/workflows/helm.yaml), scoped to this one chart rather than
introducing an unittest requirement across all 3 charts, which is out of
scope here.

Re-verified: `helm unittest charts/grid-site` (3/3 passing), full
`./scripts/verify-helm-chart.sh` (162/162 passing, uses real example values
files -- grid-site's own values.yaml defaults don't satisfy its schema even
before this change, pre-existing and unrelated), `actionlint` and
`shellcheck` clean on the touched workflow/scripts, `cargo clippy/fmt/
machete` unaffected (no Rust files touched).

Signed-off-by: Jordi Gil <jgil@redhat.com>
…stall

Discovered while live-validating budgetPolicy on a Kind cluster (helios08):
removing budgetPolicy via `helm upgrade` (leaving every other gridNetwork.*
value at its falsy chart default) made spec: render with no children at
all, i.e. YAML null. The GridNetwork CRD's structural schema requires spec
to be present as an object, so the API server rejected it with
"spec: Required value" on both create and update.

Confirmed this pre-dates this branch by reproducing the same failure
against an unmodified checkout of origin/main's chart via
kubectl create --dry-run=server. Filed as grid#49 for traceability.

gridId is now rendered unconditionally (defaulting to "", which the CRD
already treats as the server-side default) instead of being gated behind
`with`, guaranteeing spec always has at least one key.

Verified on a live Kind cluster on helios08:
- kubectl create --dry-run=server on the minimal-fields render now
  succeeds (previously: "spec: Required value")
- helm upgrade removing budgetPolicy from an existing release now
  succeeds and status.budgetStatus clears back to empty
- full KIND=1 ./scripts/verify-helm-chart.sh (182 checks) still passes

Added a helm-unittest regression case asserting spec.gridId is always
present when only the required name fields are set.

Fixes praxis-proxy#49

Signed-off-by: Jordi Gil <jgil@redhat.com>
- Pin helm-unittest to v0.8.2 in the Helm CI workflow. v1.x's plugin.yaml
  uses the platformCommand/platformHooks manifest schema, which the
  pinned Helm v3.17.3 cannot load; v0.8.2 is the last release using the
  command/hooks schema Helm 3.17 supports.
- Stop clearing tenant_spend on SWIM origin eviction (remove_origin).
  tenant_spend is a cumulative grow-only ledger; wiping it on ordinary
  membership churn let spendRatio drop on a restart/blip and reopen an
  already-exhausted budget. Follow-up design for an explicit
  budget-epoch/window reset (and bounding per-tenant site-slot growth)
  is tracked in grid#52.
- Wire validate_budget_policy into GridNetwork's reconcile path via a
  new reject_invalid_budget_policy helper, so blank/duplicate tenantIds
  and non-finite capUsd values (which the CRD schema's numeric minimum
  cannot express) are rejected before reconciliation proceeds. Added a
  proper Display impl for BudgetPolicyValidationError via thiserror so
  the resulting error message is human-readable, not a Debug dump.

Fixes review comments on grid#47.


Signed-off-by: Jordi Gil <jgil@redhat.com>
@jordigilh
jordigilh force-pushed the feat/grid-40-tenant-budget-plumbing branch from 4a1fb5f to 062e071 Compare August 13, 2026 14:03
@nerdalert
nerdalert merged commit 55e0961 into praxis-proxy:main Aug 13, 2026
16 checks passed
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.

feat: tenant-budget Grid-side plumbing for MVP 1b (app/tenant-based routing)

3 participants