Skip to content

fix: OpenAPI guardian sweep 2026-10-05 (plans, entitlements/features, add-ons) - #587

Open
lago-claude-ai-agent[bot] wants to merge 4 commits into
mainfrom
openapi-guardian/2026-10-05
Open

lago-claude-ai-agent[bot] wants to merge 4 commits into
mainfrom
openapi-guardian/2026-10-05

Conversation

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

OpenAPI Guardian sweep — 2026-10-05 (slice 1: plans, entitlements/features, add_ons)

Automated spec sweep vs lago-api and the SDK clients.
A human must review and merge — this agent never merges.
npm run build and npm run test pass on this branch (0 errors).

Run mode: weekly, slice = ISO week 41 % 8 = 1. The invocation specified a weekly cadence, so the weekly formula was used rather than the twice-weekly one.

Fixed in this PR

Type Count
Typos / definitions 4
Required vs optional 0
Inaccuracies (types, nullability, enums, shapes) 9
Filters / query params / paths 0

8 files changed under src/ (plus the regenerated bundle). Nothing deferred.

Field-level evidence

Wrong response shape (2)

  • src/resources/subscription_entitlement.yaml DELETE /subscriptions/{external_id}/entitlements/{feature_code}: SubscriptionEntitlement → SubscriptionEntitlements — evidence: Api::V1::Subscriptions::EntitlementsController#destroy renders CollectionSerializer.new(..., collection_name: "entitlements"), i.e. {"entitlements": [...]}, never {"entitlement": {...}}. CollectionSerializer#serialize only adds meta when a meta option is passed and this action passes none, so the no-meta SubscriptionEntitlements is the exact shape.
  • src/resources/subscription_entitlement_privileges.yaml same operation class — evidence: Api::V1::Subscriptions::Entitlements::PrivilegesController#destroy, identical CollectionSerializer call.

Both are corroborated by the Go client, which already models these two deletes as SubscriptionEntitlementResult and reads .Entitlements (subscription_entitlements.go, Delete and DeletePrivilege).

Missing response fields, always emitted (3)

  • src/schemas/PlanObject.yaml parent_id: added, nullable uuid — evidence: V1::PlanSerializer emits parent_id: model.parent_id unconditionally; plans.parent_id uuid in db/structure.sql with no NOT NULL. Already modelled by the Rust client (lago-types/src/models/plan.rs).
  • src/schemas/PlanObject.yaml pending_deletion: added, boolean — evidence: same serializer; pending_deletion boolean DEFAULT false NOT NULL.
  • src/schemas/PlanObject.yaml applicable_usage_thresholds: added — evidence: V1::PlanSerializer#applicable_usage_thresholds via ApplicableUsageThresholdSerializer, and Api::V1::PlansController passes applicable_usage_thresholds in includes: on both render_plan and index, so it is always present. Reuses the existing ApplicableUsageThreshold schema already referenced by the subscription schemas; no new file, no _index.yaml change.

Missing request field (1)

  • src/schemas/PlanCreateInput.yaml plan.fixed_charges[].apply_units_immediately: added — evidence: Api::V1::PlansController#input_params is shared by create and update and permits :apply_units_immediately inside fixed_charges; FixedCharges::CreateService forwards it to FixedCharges::EmitEventsService. PlanUpdateInput already documented it. The Ruby client whitelists it for both calls (whitelist_fixed_charges).

Nullability (6) — all [BREAKING-DOC], see below

  • src/schemas/PlanObject.yaml invoice_display_name, description, trial_period — evidence: plans.invoice_display_name character varying, description character varying, trial_period double precision, all without NOT NULL and with no presence validation on Plan. description's example was also an empty string; replaced with the one PlanCreateInput already uses.
  • src/schemas/FixedChargeObject.yaml invoice_display_name — fixed_charges.invoice_display_name character varying. The schema also lists this key as required, so null is reachable on a key that is always sent.
  • src/schemas/MinimumCommitmentObject.yaml invoice_display_name — commitments.invoice_display_name character varying.
  • src/schemas/Entitlement/SubscriptionEntitlementPrivilegeObject.yaml plan_value gains a null branch — evidence: Entitlement::SubscriptionEntitlementQuery#privilege_sql selects pv.value AS plan_value across a FULL OUTER JOIN and deliberately keeps rows where pv.entitlement_privilege_id IS NULL -- Privilege is in sub but not in plan; Utils::Entitlement.cast_value returns nil for nil. The sibling override_value already carried the null branch, so this was an oversight rather than a design choice.

ChargeObject.invoice_display_name was checked against the same rule and is already nullable — no change.

Typos / definitions (4)

  • src/resources/plan_entitlements.yaml and src/resources/subscription_entitlements.yaml: "All features and privileges" → single space (both PATCH descriptions).
  • src/schemas/Entitlement/SubscriptionEntitlementPrivilegeObject.yaml: "Applicable value this this subscription" → "for this subscription".
  • src/resources/feature_privilege.yml: deleteFeaturePrivilege carried a full sentence in summary and repeated it in description; summary shortened to "Delete a privilege" to match the sibling feature operations.

[BREAKING-DOC] flags

Six nullability widenings and two response-shape corrections. Each is proven by the code or the schema above, but all are potentially breaking for consumers generating types from this spec — a strongly-typed client will now have to handle null where it previously assumed a value, and the two DELETE operations change their top-level key.

  • PlanObject.invoice_display_name, PlanObject.description, PlanObject.trial_period → nullable
  • FixedChargeObject.invoice_display_name → nullable
  • MinimumCommitmentObject.invoice_display_name → nullable
  • SubscriptionEntitlementPrivilegeObject.plan_value → oneOf gains a null branch
  • DELETE /subscriptions/{external_id}/entitlements/{feature_code} → returns entitlements (array), not entitlement
  • DELETE /subscriptions/{external_id}/entitlements/{feature_code}/privileges/{privilege_code} → same

These make the spec match what the API already returns today; the break is in the documented contract, not in runtime behaviour.

Contract compatibility impact

Decision: BLOCK · 45 blocking · 2 warning · 24 informational

BLOCK is advisory and requires explicit human review; this agent never merges or approves. The checker is correct to block — the diff genuinely contains consumer-breaking patterns (nullability widening and a removed response property), which is exactly what the [BREAKING-DOC] section above asks a reviewer to weigh. It was not overridden by judgment.

Per-schema rollup (the authoritative view of what changed — 4 schema edits produce the 45 blocking rows):

Schema edited Change Operations inheriting it Rows
PlanObject invoice_display_name, description, trial_period → nullable 7 (GET/POST /plans, GET/PUT/DELETE /plans/{code}, GET /subscriptions/{external_id}, POST /subscriptions) 21 BLOCK
FixedChargeObject invoice_display_name → nullable 15 (7 nested inside PlanObject + 8 standalone plan/subscription fixed-charge operations) 15 BLOCK
MinimumCommitmentObject invoice_display_name → nullable 7 (nested inside PlanObject) 7 BLOCK
subscription_entitlement{,_privileges}.yaml DELETE response entitlement → entitlements 2 2 BLOCK
SubscriptionEntitlementPrivilegeObject plan_value oneOf gains a null branch 2 (GET/PATCH /subscriptions/{external_id}/entitlements) 2 WARN
PlanObject (additive) +parent_id, +pending_deletion, +applicable_usage_thresholds 7 21 INFO
PlanCreateInput (additive) +fixed_charges[].apply_units_immediately 1 1 INFO
SubscriptionEntitlements (additive side of the shape fix) entitlements added as required 2 2 INFO

The two WARN rows are the oneOf composition change on plan_value; the checker cannot classify oneOf edits automatically, so they are kept here rather than resolved. They are the same change described under [BREAKING-DOC], reviewed manually against privilege_sql.

Customer exposure: unknown. No authorized usage-evidence source was provided for this run, and no customer identifiers appear here or in the diff.

SDK drift (spec is right — needs an sdk-clients-update run)

Resource Client(s) Divergence
plans Go PlanChargeInput sends amount_currency, which PlansController#input_params does not permit inside charges — the field is silently dropped. It also lacks code and invoice_display_name, both permitted per charge.
plans Go PlanInput.Metadata and Plan.Metadata are map[string]string, but metadata values are nullable (MetadataInput/MetadataObject allow null, and the same file's PlanMetadataResult already uses map[string]*string).
plans Python, Go PlanResponse / Plan omit parent_id, pending_deletion and applicable_usage_thresholds, the three fields this PR adds to the spec.
features, entitlements Python No feature, privilege or entitlement models or client at all — the whole surface is unsupported.
features Ruby No method for DELETE /features/{code}/privileges/{privilege_code}; the plan-scoped privilege delete is covered but the feature-scoped one is not.
plans, add_ons, features, entitlements Rust Add-ons, features and entitlements are absent; Plan omits pending_deletion, bill_fixed_charges_monthly, metadata and entitlements. Reported as the known partial-coverage gap, not as drift.
all JavaScript None. Generated from the published spec at build time (scripts/build_npm.ts), with no committed types, so it will pick these fixes up on the next regeneration — including the corrected SubscriptionEntitlements delete shape.

Suspected lago-api bugs (spec unchanged)

  • V1::PlanSerializer emits customers_count: 0, active_subscriptions_count: 0 and draft_invoices_count: 0 as literal zeros, while Plan#customers_count, #active_subscriptions_count and #draft_invoices_count are real, working methods that the serializer never calls. The three keys are therefore always 0 on every plan response. Per the 2026-09-09 ruling I did not mirror this into the contract: adding the fields would document counts that are never populated, and omitting them is the lesser inaccuracy. The Rust client models active_subscriptions_count and draft_invoices_count as meaningful counts, so a consumer is already relying on values the API no longer produces. This looks like either a deliberate performance stub or a regression — a backend call either way. Once lago-api decides, the spec side is a one-line addition.

Needs a coordinated change (docs + spec + SDKs)

  • GET /api/v1/security_logs and GET /api/v1/security_logs/{log_id}: confirmed present in lago-api (config/routes/shared_api.rb:5, Api::V1::SecurityLogsController) and documented in the guide at guide/security/security-logs.mdx, but absent from the spec and from api-reference. Raised as a lead by docs-guardian PR Docs Guardian sweep — 2026-09-28 (slice 0: introduction, welcome, Lago Cloud, self-hosted, security) lago-doc#683 and verified here. Siblings /activity_logs and /api_logs are both in the spec, so this is a gap rather than a deliberate omission. Adding it needs openapi-spec-update + sdk-clients-update + a docs page, so it is deliberately not added in a sweep.

Needs human confirmation (not changed)

  • New unused component. Switching the two subscription-entitlement deletes to the collection shape leaves SubscriptionEntitlement referenced by nothing but src/schemas/_index.yaml. I kept both the file and its registration: dropping the component would remove a published schema that generated clients export as a type, which is a larger break than the one it would tidy up. The cost is one new oas3-unused-component lint warning (24 warnings vs 23 on main; still 0 errors, npm run test green). Please confirm whether to keep it as a compatibility shim or delete it in a follow-up.
  • EntitlementUpdateInput declares entitlements required, but Plans::EntitlementsController#update_params and its subscription counterpart both use params.fetch(:entitlements, {}).permit!, so the API accepts the key being absent. On POST (full replace) an absent key would clear every entitlement on the plan, so the required flag is arguably protecting callers rather than describing the code. Left as-is deliberately; tell me which reading you want and I will make the spec match.
  • GET/PATCH/DELETE on subscription entitlements accept a legacy status query param alongside subscription_status (params[:subscription_status] || params[:status], commented as backward compatibility). Only subscription_status is documented. That looks intentional, so nothing was changed — confirm if the legacy alias should be documented as deprecated instead.

Unverified external comments (author lacks write access — not acted on)

Human feedback addressed

Known limitation

  • Slack read scope unavailable (SLACK_READ_ACCESS=no); PR comments were the only feedback channel.

Deferred to next run

  • Nothing deferred.

lago-claude-ai-agent Bot and others added 4 commits October 5, 2026 08:11
- "All features  and privileges" -> single space, on the plan and
  subscription entitlement PATCH descriptions
- "Applicable value this this subscription" -> "for this subscription"
  on SubscriptionEntitlementPrivilegeObject.value
- deleteFeaturePrivilege carried the whole sentence in its summary and
  repeated it in the description; summary shortened to match the house
  style used by the sibling feature operations

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PlanCreateInput.plan.fixed_charges[] was missing apply_units_immediately
while PlanUpdateInput already documented it. Api::V1::PlansController
#input_params is shared by create and update and permits
:apply_units_immediately inside fixed_charges, and
FixedCharges::CreateService forwards it to
FixedCharges::EmitEventsService, so POST /plans honours it exactly like
PUT /plans/{code} does. The Ruby client already whitelists the field for
both calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[BREAKING-DOC] Both DELETE operations declared a single-entitlement
body, but the API returns the whole remaining collection.

Api::V1::Subscriptions::EntitlementsController#destroy and
Api::V1::Subscriptions::Entitlements::PrivilegesController#destroy both
render CollectionSerializer.new(..., collection_name: "entitlements"),
so the body is {"entitlements": [...]}, not {"entitlement": {...}}.
CollectionSerializer only adds "meta" when a meta option is passed and
neither action passes one, so SubscriptionEntitlements (no meta) is the
exact shape. The Go client already models both deletes as
SubscriptionEntitlementResult and reads .Entitlements.

The now-unreferenced SubscriptionEntitlement component is deliberately
kept and still registered in schemas/_index.yaml: dropping it would
remove a published component that generated clients export as a type.
This produces one new oas3-unused-component lint warning; lint stays at
0 errors and npm run test is green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PlanObject — V1::PlanSerializer emits three keys the schema never
declared, and the plans controller passes every include on both
render_plan and index, so they are always present:
- parent_id: "parent_id uuid" in db/structure.sql, nullable, emitted as
  model.parent_id. The Rust client already models it.
- pending_deletion: "pending_deletion boolean DEFAULT false NOT NULL".
- applicable_usage_thresholds: ApplicableUsageThresholdSerializer,
  reached via Plan#applicable_usage_thresholds, which resolves to the
  parent plan's thresholds for an overriding plan. Reuses the existing
  ApplicableUsageThreshold schema already used by subscriptions.

[BREAKING-DOC] nullability, all three columns declared without NOT NULL
in db/structure.sql and with no presence validation on Plan:
- PlanObject.invoice_display_name ("invoice_display_name character
  varying")
- PlanObject.description ("description character varying"); its example
  was also an empty string, replaced with the one PlanCreateInput uses
- PlanObject.trial_period ("trial_period double precision")
- FixedChargeObject.invoice_display_name ("fixed_charges
  .invoice_display_name character varying"), which the schema also
  lists as required, so null is reachable on a key that is always sent
- MinimumCommitmentObject.invoice_display_name ("commitments
  .invoice_display_name character varying")

[BREAKING-DOC] SubscriptionEntitlementPrivilegeObject.plan_value gains a
null branch. SubscriptionEntitlementQuery#privilege_sql selects
"pv.value AS plan_value" across a FULL OUTER JOIN and keeps rows where
"pv.entitlement_privilege_id IS NULL -- Privilege is in sub but not in
plan", so plan_value is NULL for override-only privileges;
Utils::Entitlement.cast_value returns nil for nil. The sibling
override_value already had the null branch.

ChargeObject.invoice_display_name was checked and is already nullable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

0 participants