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
Open
lago-claude-ai-agent[bot] wants to merge 4 commits into
lago-claude-ai-agent[bot] wants to merge 4 commits into
Conversation
- "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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 buildandnpm run testpass 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
8 files changed under
src/(plus the regenerated bundle). Nothing deferred.Field-level evidence
Wrong response shape (2)
src/resources/subscription_entitlement.yamlDELETE /subscriptions/{external_id}/entitlements/{feature_code}:SubscriptionEntitlement→SubscriptionEntitlements— evidence:Api::V1::Subscriptions::EntitlementsController#destroyrendersCollectionSerializer.new(..., collection_name: "entitlements"), i.e.{"entitlements": [...]}, never{"entitlement": {...}}.CollectionSerializer#serializeonly addsmetawhen ametaoption is passed and this action passes none, so the no-metaSubscriptionEntitlementsis the exact shape.src/resources/subscription_entitlement_privileges.yamlsame operation class — evidence:Api::V1::Subscriptions::Entitlements::PrivilegesController#destroy, identicalCollectionSerializercall.Both are corroborated by the Go client, which already models these two deletes as
SubscriptionEntitlementResultand reads.Entitlements(subscription_entitlements.go,DeleteandDeletePrivilege).Missing response fields, always emitted (3)
src/schemas/PlanObject.yamlparent_id: added, nullable uuid — evidence:V1::PlanSerializeremitsparent_id: model.parent_idunconditionally;plans.parent_id uuidindb/structure.sqlwith noNOT NULL. Already modelled by the Rust client (lago-types/src/models/plan.rs).src/schemas/PlanObject.yamlpending_deletion: added, boolean — evidence: same serializer;pending_deletion boolean DEFAULT false NOT NULL.src/schemas/PlanObject.yamlapplicable_usage_thresholds: added — evidence:V1::PlanSerializer#applicable_usage_thresholdsviaApplicableUsageThresholdSerializer, andApi::V1::PlansControllerpassesapplicable_usage_thresholdsinincludes:on bothrender_planandindex, so it is always present. Reuses the existingApplicableUsageThresholdschema already referenced by the subscription schemas; no new file, no_index.yamlchange.Missing request field (1)
src/schemas/PlanCreateInput.yamlplan.fixed_charges[].apply_units_immediately: added — evidence:Api::V1::PlansController#input_paramsis shared by create and update and permits:apply_units_immediatelyinsidefixed_charges;FixedCharges::CreateServiceforwards it toFixedCharges::EmitEventsService.PlanUpdateInputalready documented it. The Ruby client whitelists it for both calls (whitelist_fixed_charges).Nullability (6) — all [BREAKING-DOC], see below
src/schemas/PlanObject.yamlinvoice_display_name,description,trial_period— evidence:plans.invoice_display_name character varying,description character varying,trial_period double precision, all withoutNOT NULLand with no presence validation onPlan.description's example was also an empty string; replaced with the onePlanCreateInputalready uses.src/schemas/FixedChargeObject.yamlinvoice_display_name—fixed_charges.invoice_display_name character varying. The schema also lists this key asrequired, so null is reachable on a key that is always sent.src/schemas/MinimumCommitmentObject.yamlinvoice_display_name—commitments.invoice_display_name character varying.src/schemas/Entitlement/SubscriptionEntitlementPrivilegeObject.yamlplan_valuegains anullbranch — evidence:Entitlement::SubscriptionEntitlementQuery#privilege_sqlselectspv.value AS plan_valueacross aFULL OUTER JOINand deliberately keeps rows wherepv.entitlement_privilege_id IS NULL -- Privilege is in sub but not in plan;Utils::Entitlement.cast_valuereturnsnilfornil. The siblingoverride_valuealready carried the null branch, so this was an oversight rather than a design choice.ChargeObject.invoice_display_namewas checked against the same rule and is already nullable — no change.Typos / definitions (4)
src/resources/plan_entitlements.yamlandsrc/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:deleteFeaturePrivilegecarried a full sentence insummaryand repeated it indescription; 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
nullwhere it previously assumed a value, and the two DELETE operations change their top-level key.PlanObject.invoice_display_name,PlanObject.description,PlanObject.trial_period→ nullableFixedChargeObject.invoice_display_name→ nullableMinimumCommitmentObject.invoice_display_name→ nullableSubscriptionEntitlementPrivilegeObject.plan_value→oneOfgains a null branchDELETE /subscriptions/{external_id}/entitlements/{feature_code}→ returnsentitlements(array), notentitlementDELETE /subscriptions/{external_id}/entitlements/{feature_code}/privileges/{privilege_code}→ sameThese 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
BLOCKis 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):
PlanObjectinvoice_display_name,description,trial_period→ nullableGET/POST /plans,GET/PUT/DELETE /plans/{code},GET /subscriptions/{external_id},POST /subscriptions)FixedChargeObjectinvoice_display_name→ nullablePlanObject+ 8 standalone plan/subscription fixed-charge operations)MinimumCommitmentObjectinvoice_display_name→ nullablePlanObject)subscription_entitlement{,_privileges}.yamlentitlement→entitlementsSubscriptionEntitlementPrivilegeObjectplan_valueoneOfgains a null branchGET/PATCH /subscriptions/{external_id}/entitlements)PlanObject(additive)+parent_id,+pending_deletion,+applicable_usage_thresholdsPlanCreateInput(additive)+fixed_charges[].apply_units_immediatelySubscriptionEntitlements(additive side of the shape fix)entitlementsadded as requiredThe two
WARNrows are theoneOfcomposition change onplan_value; the checker cannot classifyoneOfedits automatically, so they are kept here rather than resolved. They are the same change described under [BREAKING-DOC], reviewed manually againstprivilege_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-updaterun)PlanChargeInputsendsamount_currency, whichPlansController#input_paramsdoes not permit insidecharges— the field is silently dropped. It also lackscodeandinvoice_display_name, both permitted per charge.PlanInput.MetadataandPlan.Metadataaremap[string]string, but metadata values are nullable (MetadataInput/MetadataObjectallownull, and the same file'sPlanMetadataResultalready usesmap[string]*string).PlanResponse/Planomitparent_id,pending_deletionandapplicable_usage_thresholds, the three fields this PR adds to the spec.DELETE /features/{code}/privileges/{privilege_code}; the plan-scoped privilege delete is covered but the feature-scoped one is not.Planomitspending_deletion,bill_fixed_charges_monthly,metadataandentitlements. Reported as the known partial-coverage gap, not as drift.scripts/build_npm.ts), with no committed types, so it will pick these fixes up on the next regeneration — including the correctedSubscriptionEntitlementsdelete shape.Suspected lago-api bugs (spec unchanged)
V1::PlanSerializeremitscustomers_count: 0,active_subscriptions_count: 0anddraft_invoices_count: 0as literal zeros, whilePlan#customers_count,#active_subscriptions_countand#draft_invoices_countare real, working methods that the serializer never calls. The three keys are therefore always0on 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 modelsactive_subscriptions_countanddraft_invoices_countas 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_logsandGET /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 atguide/security/security-logs.mdx, but absent from the spec and fromapi-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_logsand/api_logsare both in the spec, so this is a gap rather than a deliberate omission. Adding it needsopenapi-spec-update+sdk-clients-update+ a docs page, so it is deliberately not added in a sweep.Needs human confirmation (not changed)
SubscriptionEntitlementreferenced by nothing butsrc/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 newoas3-unused-componentlint warning (24 warnings vs 23 onmain; still 0 errors,npm run testgreen). Please confirm whether to keep it as a compatibility shim or delete it in a follow-up.EntitlementUpdateInputdeclaresentitlementsrequired, butPlans::EntitlementsController#update_paramsand its subscription counterpart both useparams.fetch(:entitlements, {}).permit!, so the API accepts the key being absent. OnPOST(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/DELETEon subscription entitlements accept a legacystatusquery param alongsidesubscription_status(params[:subscription_status] || params[:status], commented as backward compatibility). Onlysubscription_statusis 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)
Cleaupstack, permissionread; [ING-512] docs(alerts): document notify_on on alert thresholds #585 byaquinofb, permissionread). Neither is feedback on a guardian branch and neither influenced this sweep; listed only so a human knows they were seen and skipped.Human feedback addressed
lovrocolic, permissionadmin) remain settled and were not re-raised:PaymentObject.payment_provider_type,PaymentRequestCreateInput.payment_method,PaymentCreateInput.paid_at, and theresend_emailendpoint family. No internal Rails class names appear anywhere in this diff.LEARNINGS.mdwere read and applied. Their sources are now confirmed as write-access (lovrocolic→admin). The "lago-api bug is not a spec bug" entry is what kept the three hardcoded plan counts out of the diff and in the backend-bugs section above. No new durable rule emerged this run, so no learnings PR was opened./security_logslead is confirmed and routed above; #679'sinvoice.add_on_addednote needs no action, the spec already marks that webhookdeprecated: truevia fix(webhook): deprecate the stale invoice.add_on_added webhook #577.Known limitation
SLACK_READ_ACCESS=no); PR comments were the only feedback channel.Deferred to next run