fix: OpenAPI guardian sweep 2026-09-21 (webhooks, webhook endpoints, activity logs, api logs, analytics) - #584
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
- ActivityLogObject.activity_type: fix 'actitivy' misspelling and the 'billing_metric.created' example, which is not a real activity type; Clickhouse::ActivityLog::ACTIVITY_TYPES emits 'billable_metric.created'. - activity_logs activity_types[] filter: same example fix. - WebhookEndpointObject.lago_id: described the wallet, not the endpoint. - payment_request.payment_status_updated webhook: summary and description said 'invoice' instead of 'payment request'. - payment_request.created webhook: 'An new' -> 'A new'. - WebhookEndpoint create/update inputs: signature_algo description was truncated mid-sentence. Completed it, and documented the jwt default proven by WebhookEndpoints::CreateService (params[:signature_algo] || :jwt) and the NOT NULL DEFAULT on webhook_endpoints.signature_algo. - analytics/mrr: the MRR calculation text sat on the currency parameter while the operation carried a stub description. Swapped them back. - analytics/usage: time_granularity listed 'yearly', which neither the controller's enum nor Types::DataApi::TimeGranularityEnum supports, and omitted the 'daily' default applied by DataApi::UsagesService.
…points
- WebhookEndpointUpdateInput no longer requires webhook_url.
WebhookEndpoints::UpdateService assigns each attribute behind
params.key?(...), so PUT /webhook_endpoints/{lago_id} accepts a payload
that omits webhook_url and leaves the stored value untouched.
- ActivityLogObject drops organization_id. [BREAKING-DOC]
V1::ActivityLogSerializer never emits it: its serialize hash is
activity_id, activity_type, activity_source, activity_object,
activity_object_changes, user_email, resource_id, resource_type,
external_customer_id, external_subscription_id, logged_at, created_at,
and ModelSerializer adds nothing. The field was also declared required,
so the spec promised a key the API has never sent. The Python and Rust
clients model the object without it too.
- api_logs: request_paths is an array, not a scalar string. [BREAKING-DOC]
ApiLogsQuery#with_request_paths calls filters.request_paths.map, which a
String does not answer, and the controller spec drives it with
{request_paths: ['*billable_metrics*']}. Renamed to request_paths[] with
array items, matching the repo convention for array query params, and
documented the '*' wildcard the query builds a LIKE for.
The Go (RequestPaths []string) and Rust (Vec<String>) clients agree.
- api_logs: add the clients[] filter. Api::V1::ApiLogsController#index_filters
passes clients: params[:clients] and ApiLogsQuery#with_clients scopes on
the client column. It was the only honored filter missing from the spec.
- analytics: add billing_entity_code to all five endpoints. Every analytics
controller passes billing_entity_id: billing_entity&.id, and
Api::V1::Analytics::BaseController#billing_entity looks the entity up by
params[:billing_entity_code]. Added as a shared parameter file, since the
five endpoints take it identically.
- ApiLogObject.request_body and .request_response are objects, not
strings. [BREAKING-DOC]
Both are Clickhouse Map columns (db/clickhouse_migrate/cloud/04_api_logs.sql:
Map(String, String) and Map(String, Nullable(String))), so the adapter
hands V1::ApiLogSerializer a Hash, which it passes straight through.
Types::ApiLogs::Object proves the Hash by calling transform_values on
both, and all three hand-written clients that model the object agree:
Python dict / Optional[dict], Go map[string]interface{}, Rust
Map<String, serde_json::Value> / Option<...>.
request_response is also nullable: Utils::ApiLog#response_data writes
nil when the response body is empty.
Values are left as free-form because the Map stores nested structures
JSON-encoded, which is what the 'TODO: remove this once we have a proper
way to handle JSON in Clickhouse' parser in the GraphQL type works around.
- credit_note.provider_refund_failure webhook: organization_id was listed
in required but never defined as a property, so the schema required a
field it did not describe. Webhooks::BaseService#call puts
organization_id in every webhook payload; this was the only one of the
76 webhook files missing the definition.
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-09-21 (slice 7: webhooks, webhook endpoints, activity logs, api logs, analytics + uncovered resources)
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, 23 warnings — the same 23array-params-pluralwarnings asmain).Run mode: weekly cadence, so the rotation slice is ISO week 39 % 8 = 7. Slice 7 is the catch-all:
webhooks(+src/webhooks/),webhook_endpoints,activity_logs,api_logs, the six analytics endpoints, and everything not claimed by slices 0–6. Source of truth:lago-apiat95c8010.Fixed in this PR
18 files changed, excluding the regenerated bundle.
Field-level evidence
Typos / definitions
src/schemas/ActivityLogObject.yamlactivity_type: "actitivy" → "activity", and examplebilling_metric.created→billable_metric.created— evidence:Clickhouse::ActivityLog::ACTIVITY_TYPEShas nobilling_metric.*key; the real values arebillable_metric.created/.updated/.deleted.src/resources/activity_logs.ymlactivity_types[]example: same fix, same evidence.src/schemas/WebhookEndpointObject.yamllago_id: "Unique identifier assigned to the wallet" → "to the webhook endpoint" — copy-paste from the wallet schema; the rest of the same sentence already said "webhook endpoint's record".src/webhooks/payment_request_payment_status_updated.yamlsummary+description: "The payment status of an invoice has been updated" → "of a payment request" — evidence:Webhooks::PaymentRequests::PaymentStatusUpdatedServiceserializes apayment_request, andconfig/webhook_event_types.ymldescribes it as "The payment status of a payment request has been updated".src/webhooks/payment_request_created.yamlsummary+description: "An new" → "A new".src/schemas/WebhookEndpointCreateInput.yamlandWebhookEndpointUpdateInput.yamlsignature_algo: the description was truncated mid-sentence ("The signature used for the webhook. If no value is passed,"). Completed on both, anddefault: jwtadded on create only — evidence:WebhookEndpoints::CreateServiceassignsparams[:signature_algo]&.to_sym || :jwt, andwebhook_endpoints.signature_algoisNOT NULL DEFAULTjwt.WebhookEndpoints::UpdateServicehas no default, it just skips the attribute, so the update wording says the current value is kept.src/resources/mrrs.yaml: the five-bullet MRR calculation text sat on thecurrencyparameter while the operation carried the stub "This endpoint is used to list MRR." Swapped: the calculation text is now the operation description, andcurrencygets the same wording as its four sibling endpoints and asMrrObject.currency.src/resources/usages.yamltime_granularity: the description advertisedyearly, which the parameter's own enum does not allow and whichTypes::DataApi::TimeGranularityEnum(daily, weekly, monthly) does not define either. Also addeddefault: daily— evidence:DataApi::UsagesService#filtered_paramsdoesfiltered[:time_granularity] ||= "daily".Required vs optional
src/schemas/WebhookEndpointUpdateInput.yaml:webhook_urlremoved fromrequired— evidence:WebhookEndpoints::UpdateServiceassigns every attribute behindparams.key?(...), soPUT /webhook_endpoints/{lago_id}accepts a payload that omits it and keeps the stored value. The spec was rejecting valid partial updates.src/schemas/ActivityLogObject.yaml:organization_idremoved fromrequiredand fromproperties— see [BREAKING-DOC] below.Inaccuracies
src/schemas/ApiLogObject.yamlrequest_body:type: string→type: object— see [BREAKING-DOC].src/schemas/ApiLogObject.yamlrequest_response:type: string→type: [object, "null"]— see [BREAKING-DOC].src/webhooks/credit_note_provider_refund_failure.yaml:organization_idwas listed inrequiredbut never defined as a property, so the schema required a field it did not describe. Added — evidence:Webhooks::BaseService#callbuilds every webhook payload as{webhook_type, object_type, organization_id, <object_type> => …}. A scan of all 76 webhook files found this was the only one missing it.Filters / query params
src/resources/api_logs.ymlrequest_paths→request_paths[], scalar string → array of string, plus a note about*wildcards — see [BREAKING-DOC].src/resources/api_logs.yml:clients[]added — evidence:Api::V1::ApiLogsController#index_filterspassesclients: params[:clients]andApiLogsQuery#with_clientsscopes on theclientcolumn. It was the only honored filter absent from the spec.src/resources/{gross_revenues,invoice_collections,invoiced_usages,mrrs,overdue_balances}.yaml:billing_entity_codeadded to all five — evidence: each analytics controller passesbilling_entity_id: billing_entity&.id, andApi::V1::Analytics::BaseController#billing_entityresolves it fromparams[:billing_entity_code]. Introduced as a sharedsrc/parameters/billing_entity_code.yaml(registered in_index.yaml), since the five take it identically.[BREAKING-DOC] flags
Three changes tighten or move the documented contract. Each is proven by the code, but each changes what a consumer generated from this spec expects, so please weigh them consciously.
ActivityLogObject.organization_idremoved.V1::ActivityLogSerializer#serializereturns exactly twelve keys andorganization_idis not one of them;ModelSerializeradds nothing. It was declaredrequired, so the spec promised a key the API has never sent. The Python (ActivityLogResponse) and Rust (ActivityLogObject) clients also model the object without it, so no SDK is relying on it. Removing rather than making it optional is the honest reading: the field does not exist.ApiLogObject.request_body/request_response:string→object, andrequest_responsebecomes nullable. Both columns are ClickHouseMaptypes (db/clickhouse_migrate/cloud/04_api_logs.sql:Map(String, String)andMap(String, Nullable(String))), so the adapter handsV1::ApiLogSerializera Hash which it passes straight through.Types::ApiLogs::Objectproves the Hash by callingtransform_valueson both. All three hand-written clients that model the object already treat them as objects: Pythondict/Optional[dict], Gomap[string]interface{}, RustMap<String, serde_json::Value>/Option<…>. Nullability ofrequest_responsecomes fromUtils::ApiLog#response_data, which writesnilwhen the response body is empty. Values are left free-form (additionalProperties: true) because the Map stores nested structures JSON-encoded — the thing the "TODO: remove this once we have a proper way to handle JSON in Clickhouse" parser in the GraphQL type works around. If that TODO is ever resolved the values become real JSON and this schema still holds.api_logsrequest_pathsrenamed torequest_paths[]and retyped to an array.ApiLogsQuery#with_request_pathscallsfilters.request_paths.map, which a String does not answer, so the scalar form the spec documented raises rather than filtering; the controller spec drives it as{request_paths: ["*billable_metrics*"]}. The compatibility checker reports this as a parameter removal, which is formally correct — but no working caller can exist on the old form, since sending it as a scalar cannot succeed against the shipped API. The Go (RequestPaths []string) and Rust (Vec<String>) clients already send an array.Contract compatibility impact
Decision: BLOCK — advisory, as this is a guardian sweep.
9 blocking · 11 warning · 8 informational.
The 8 informational findings are all additive and need no caller migration:
billing_entity_codeon the five analytics endpoints,clients[]andrequest_paths[]onGET /api_logs, andwebhook_urlbecoming optional onPUT /webhook_endpoints/{lago_id}.Reading of the result: every
BLOCKmaps one-to-one to a [BREAKING-DOC] item above, and each is the spec catching up to shipped behaviour rather than the API changing. The three webhookWARNs are the checker declining to classify a webhook diff; two are pure summary/description text and the third adds the missingorganization_idproperty definition — none alters a payload the API sends. Nothing here was downgraded by judgement.BLOCKis advisory and requires explicit human review; this agent never merges or approves.Customer exposure: unknown. No authorized usage-evidence source was provided for this run, and the checker has no production telemetry, so no claim is made about which consumers are affected.
SDK drift (spec is right — needs an
sdk-clients-updaterun)activity_logsActivityLog.ResourceIdcarries the JSON tag"rounding_precision"(activity_log.go). The API emitsresource_id, so the field never unmarshals. Looks like a copy-paste from a pricing struct.activity_logsActivityLogObjecthas noactivity_object_changesfield, though the serializer always emits it.api_logsclientsfilter is missing from the typed filter structs (ApiLogListInput,ApiLogFilters). Ruby and Python pass filters as free-form kwargs, so they are unaffected.Not drift, recorded so a later run does not re-raise them: Go's
ApiLogListInput.ApiVersionis[]stringwhere the spec says a scalar — both forms work, sinceApiLogsQuery#with_api_versiondoeswhere(api_version: …). The JavaScript client is generated from the published spec and its committed surface for this slice is the hand-writtenwebhook_types.tsre-keying layer, which has no slice-7 divergence; its generated types will pick these fixes up on the next regeneration.Needs human confirmation (not changed)
credit_note.provider_refund_failurecannot be subscribed to.WebhookEndpoint::WEBHOOK_EVENT_TYPES, the allowlist validatingevent_types, is built fromconfig/webhook_event_types.yml, which names this eventcredit_note.provider_refund_failure. ButWebhooks::CreditNotes::PaymentProviderRefundFailureService#webhook_typereturnscredit_note.refund_failure, andWebhooks::BaseService#subscribed?matchesevent_types.include?(webhook_type)against that emitted value. So an endpoint filtered to the only accepted spelling never receives the event, and the spelling that would match is rejected as an invalid type. The spec is correct as-is — it documents the payload, which really does carrycredit_note.refund_failure— so nothing was changed here, per the standing convention that the spec keeps describing the intended contract when lago-api is the defective side. This needs a lago-api fix (align the config name, or normalise insubscribed?), not a spec edit.signature_algo: nullonPUT /webhook_endpoints/{lago_id}. Both input schemas allownullin the enum. On create that is right —CreateServicefalls back to:jwt. On update,UpdateServiceassignsparams[:signature_algo]&.to_symwhenever the key is present, so an explicitnullwritesnilto aNOT NULLcolumn and fails outside theRecordInvalidrescue. Left unchanged for the same reason as above: the permissive contract looks intended and the API is the side that misbehaves. Worth a decision on whether to guard it in lago-api or narrow the update schema.GET /security_logsandGET /security_logs/{log_id}(SecurityLogsController, premium +ensure_security_logs_enabled), and the fullusage_attribution_typesCRUD (UsageAttributionTypesController, behind a feature flag). Both are gated, so the omission may well be deliberate. Following the standing convention that undocumented endpoints spanning docs, SDKs and spec are their own task rather than a sweep item, they are raised once here and not added.Docs-guardian leads triaged
invoice.add_on_added, thatlago-apinever emits, and asked whether the spec should keep carrying it. Already decided and shipped — fix(webhook): deprecate the stale invoice.add_on_added webhook #577 marked itdeprecated: trueand pointed readers atinvoice.one_off_created, keeping the entry so existing integrations can still look up the payload shape. Re-verified this run:grep add_on_addedoverlago-apiat95c8010is still empty, the event is absent from bothSendWebhookJob::WEBHOOK_SERVICESandconfig/webhook_event_types.yml, and the spec file already carries the deprecation. No change needed; the docs side is right to omit it.invoice.add_on_addeditem — same resolution.Deferred to next run
event_typesenum on the webhook-endpoint schemas.WebhookEndpoint::WEBHOOK_EVENT_TYPESis a closed allowlist of 75 values and invalid entries are rejected withinvalid_types, so the threeevent_typesfields (object, create input, update input) should carry it. Held back deliberately: one of those 75 values is thecredit_note.provider_refund_failuremismatch above, and publishing an enum containing a value that can never match is worse than publishing none. Worth doing as soon as that is settled.["*"]shorthand forevent_types.WebhookEndpoint#normalize_event_typesconverts a single-element["*"]tonil, i.e. "send everything" — the same effect as passingnull, which is the only form the description mentions today. Bundled with the enum work above since it touches the same three descriptions.InvoiceCollectionObjectrequires onlymonthandinvoices_count, whileamount_cents,currencyandpayment_statusare always emitted byV1::Analytics::InvoiceCollectionSerializerand are required on the four sibling analytics objects.UsageObject.is_billable_metric_deletedis likewise set unconditionally byDataApi::UsagesServicebut not required. Both are safe tightenings of a response guarantee; left out to keep this diff focused on defects.Process feedback for the retro
orders,order_forms,quotesandquote_versionsare not named in any slice, so they fall to slice 7 by the catch-all rule. They were verified this run — filters, paths and request bodies all matchOrdersQuery,OrderFormsQuery,QuotesQueryand the quote-version controllers exactly, including the top-level (not wrapper-nested)expires_aton approve — and found clean. They are substantial enough to deserve their own slice rather than riding along with the remainder sweep.config/webhook_event_types.ymlis an underused source of truth. It carries a canonical name, description, category anddeprecatedflag for all 75 events. Diffing it againstsrc/webhooks/*.yamlis what surfaced thepayment_request.payment_status_updatedsummary bug and thecredit_note.provider_refund_failuremismatch. Worth making a standard slice-7 check — with the caveat that several spec descriptions are deliberately richer than the config one-liner, so it is a signal for contradictions, not a target to align to.$SLACK_READ_ACCESSisnoin this environment, so Slack threads could not be read and PR comments were the only feedback channel. This is the third consecutive guardian run (docs and openapi) to report it.requiredentry must resolve to a defined property (found thecredit_note_provider_refund_failuregap), and every webhook file must define the four keysWebhooks::BaseServicealways sends.Generated by the
lago-openapi-guardianagent. It never merges, approves, or enables auto-merge.