Skip to content

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
mainfrom
openapi-guardian/2026-09-21
Open

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

Conversation

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

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 build and npm run test pass on this branch (0 errors, 23 warnings — the same 23 array-params-plural warnings as main).

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-api at 95c8010.

Fixed in this PR

Type Count
Typos / definitions 8
Required vs optional 2
Inaccuracies (types, nullability, enums, shapes) 3
Filters / query params / paths 7

18 files changed, excluding the regenerated bundle.

Field-level evidence

Typos / definitions

  • src/schemas/ActivityLogObject.yaml activity_type: "actitivy" → "activity", and example billing_metric.createdbillable_metric.created — evidence: Clickhouse::ActivityLog::ACTIVITY_TYPES has no billing_metric.* key; the real values are billable_metric.created / .updated / .deleted.
  • src/resources/activity_logs.yml activity_types[] example: same fix, same evidence.
  • src/schemas/WebhookEndpointObject.yaml lago_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.yaml summary+description: "The payment status of an invoice has been updated" → "of a payment request" — evidence: Webhooks::PaymentRequests::PaymentStatusUpdatedService serializes a payment_request, and config/webhook_event_types.yml describes it as "The payment status of a payment request has been updated".
  • src/webhooks/payment_request_created.yaml summary+description: "An new" → "A new".
  • src/schemas/WebhookEndpointCreateInput.yaml and WebhookEndpointUpdateInput.yaml signature_algo: the description was truncated mid-sentence ("The signature used for the webhook. If no value is passed,"). Completed on both, and default: jwt added on create only — evidence: WebhookEndpoints::CreateService assigns params[:signature_algo]&.to_sym || :jwt, and webhook_endpoints.signature_algo is NOT NULL DEFAULT jwt. WebhookEndpoints::UpdateService has 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 the currency parameter while the operation carried the stub "This endpoint is used to list MRR." Swapped: the calculation text is now the operation description, and currency gets the same wording as its four sibling endpoints and as MrrObject.currency.
  • src/resources/usages.yaml time_granularity: the description advertised yearly, which the parameter's own enum does not allow and which Types::DataApi::TimeGranularityEnum (daily, weekly, monthly) does not define either. Also added default: daily — evidence: DataApi::UsagesService#filtered_params does filtered[:time_granularity] ||= "daily".

Required vs optional

  • src/schemas/WebhookEndpointUpdateInput.yaml: webhook_url removed from required — evidence: WebhookEndpoints::UpdateService assigns every attribute behind params.key?(...), so PUT /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_id removed from required and from properties — see [BREAKING-DOC] below.

Inaccuracies

  • src/schemas/ApiLogObject.yaml request_body: type: stringtype: object — see [BREAKING-DOC].
  • src/schemas/ApiLogObject.yaml request_response: type: stringtype: [object, "null"] — see [BREAKING-DOC].
  • src/webhooks/credit_note_provider_refund_failure.yaml: organization_id was listed in required but never defined as a property, so the schema required a field it did not describe. Added — evidence: Webhooks::BaseService#call builds 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.yml request_pathsrequest_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_filters passes clients: params[:clients] and ApiLogsQuery#with_clients scopes on the client column. It was the only honored filter absent from the spec.
  • src/resources/{gross_revenues,invoice_collections,invoiced_usages,mrrs,overdue_balances}.yaml: billing_entity_code added to all five — evidence: each analytics controller passes billing_entity_id: billing_entity&.id, and Api::V1::Analytics::BaseController#billing_entity resolves it from params[:billing_entity_code]. Introduced as a shared src/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_id removed. V1::ActivityLogSerializer#serialize returns exactly twelve keys and organization_id is not one of them; ModelSerializer adds nothing. It was declared required, 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: stringobject, and request_response becomes nullable. Both columns are ClickHouse Map types (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. All three hand-written clients that model the object already treat them as objects: Python dict / Optional[dict], Go map[string]interface{}, Rust Map<String, serde_json::Value> / Option<…>. Nullability of request_response comes from Utils::ApiLog#response_data, which writes nil when 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_logs request_paths renamed to request_paths[] and retyped to an array. ApiLogsQuery#with_request_paths calls filters.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.

Severity Operation Location Finding Required action
BLOCK GET /activity_logs /responses/200/application/json/activity_logs/items/organization_id response property 'organization_id' was removed Preserve the response field or coordinate consumer migration.
BLOCK GET /activity_logs/{activity_id} /responses/200/application/json/activity_log/organization_id response property 'organization_id' was removed Preserve the response field or coordinate consumer migration.
BLOCK GET /api_logs /parameters/query:request_paths Request parameter 'query:request_paths' was removed Preserve the parameter or coordinate generated-SDK migration.
BLOCK GET /api_logs /responses/200/application/json/api_logs/items/request_body response type changed from [string] to [object] Preserve the existing wire type or require explicit human approval.
BLOCK GET /api_logs /responses/200/application/json/api_logs/items/request_response response value became nullable Preserve nullability or require explicit human approval.
BLOCK GET /api_logs /responses/200/application/json/api_logs/items/request_response response type changed from [string] to [object] Preserve the existing wire type or require explicit human approval.
BLOCK GET /api_logs/{request_id} /responses/200/application/json/api_log/request_body response type changed from [string] to [object] Preserve the existing wire type or require explicit human approval.
BLOCK GET /api_logs/{request_id} /responses/200/application/json/api_log/request_response response value became nullable Preserve nullability or require explicit human approval.
BLOCK GET /api_logs/{request_id} /responses/200/application/json/api_log/request_response response type changed from [string] to [object] Preserve the existing wire type or require explicit human approval.
WARN GET /api_logs /responses/200/application/json/api_logs/items/request_body response schema keyword 'format' changed Review the generated-SDK and wire-contract effect manually.
WARN GET /api_logs /responses/200/application/json/api_logs/items/request_body response schema keyword 'additionalProperties' changed Review the generated-SDK and wire-contract effect manually.
WARN GET /api_logs /responses/200/application/json/api_logs/items/request_response response schema keyword 'format' changed Review the generated-SDK and wire-contract effect manually.
WARN GET /api_logs /responses/200/application/json/api_logs/items/request_response response schema keyword 'additionalProperties' changed Review the generated-SDK and wire-contract effect manually.
WARN GET /api_logs/{request_id} /responses/200/application/json/api_log/request_body response schema keyword 'additionalProperties' changed Review the generated-SDK and wire-contract effect manually.
WARN GET /api_logs/{request_id} /responses/200/application/json/api_log/request_body response schema keyword 'format' changed Review the generated-SDK and wire-contract effect manually.
WARN GET /api_logs/{request_id} /responses/200/application/json/api_log/request_response response schema keyword 'format' changed Review the generated-SDK and wire-contract effect manually.
WARN GET /api_logs/{request_id} /responses/200/application/json/api_log/request_response response schema keyword 'additionalProperties' changed Review the generated-SDK and wire-contract effect manually.
WARN WEBHOOK credit_note_provider_refund_failure /webhooks/credit_note_provider_refund_failure Webhook 'credit_note_provider_refund_failure' contract changed Review webhook consumers and payload compatibility manually.
WARN WEBHOOK payment_request_created /webhooks/payment_request_created Webhook 'payment_request_created' contract changed Review webhook consumers and payload compatibility manually.
WARN WEBHOOK payment_request_payment_status_updated /webhooks/payment_request_payment_status_updated Webhook 'payment_request_payment_status_updated' contract changed Review webhook consumers and payload compatibility manually.

The 8 informational findings are all additive and need no caller migration: billing_entity_code on the five analytics endpoints, clients[] and request_paths[] on GET /api_logs, and webhook_url becoming optional on PUT /webhook_endpoints/{lago_id}.

Reading of the result: every BLOCK maps 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 webhook WARNs are the checker declining to classify a webhook diff; two are pure summary/description text and the third adds the missing organization_id property definition — none alters a payload the API sends. Nothing here was downgraded by judgement.

BLOCK is 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-update run)

Resource Client(s) Divergence
activity_logs Go ActivityLog.ResourceId carries the JSON tag "rounding_precision" (activity_log.go). The API emits resource_id, so the field never unmarshals. Looks like a copy-paste from a pricing struct.
activity_logs Rust ActivityLogObject has no activity_object_changes field, though the serializer always emits it.
api_logs Go, Rust The clients filter 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.ApiVersion is []string where the spec says a scalar — both forms work, since ApiLogsQuery#with_api_version does where(api_version: …). The JavaScript client is generated from the published spec and its committed surface for this slice is the hand-written webhook_types.ts re-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_failure cannot be subscribed to. WebhookEndpoint::WEBHOOK_EVENT_TYPES, the allowlist validating event_types, is built from config/webhook_event_types.yml, which names this event credit_note.provider_refund_failure. But Webhooks::CreditNotes::PaymentProviderRefundFailureService#webhook_type returns credit_note.refund_failure, and Webhooks::BaseService#subscribed? matches event_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 carry credit_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 in subscribed?), not a spec edit.
  • signature_algo: null on PUT /webhook_endpoints/{lago_id}. Both input schemas allow null in the enum. On create that is right — CreateService falls back to :jwt. On update, UpdateService assigns params[:signature_algo]&.to_sym whenever the key is present, so an explicit null writes nil to a NOT NULL column and fails outside the RecordInvalid rescue. 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.
  • Two v1 endpoints exist in lago-api with no spec coverage: GET /security_logs and GET /security_logs/{log_id} (SecurityLogsController, premium + ensure_security_logs_enabled), and the full usage_attribution_types CRUD (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

Deferred to next run

  • Document the event_types enum on the webhook-endpoint schemas. WebhookEndpoint::WEBHOOK_EVENT_TYPES is a closed allowlist of 75 values and invalid entries are rejected with invalid_types, so the three event_types fields (object, create input, update input) should carry it. Held back deliberately: one of those 75 values is the credit_note.provider_refund_failure mismatch 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.
  • Document the ["*"] shorthand for event_types. WebhookEndpoint#normalize_event_types converts a single-element ["*"] to nil, i.e. "send everything" — the same effect as passing null, which is the only form the description mentions today. Bundled with the enum work above since it touches the same three descriptions.
  • InvoiceCollectionObject requires only month and invoices_count, while amount_cents, currency and payment_status are always emitted by V1::Analytics::InvoiceCollectionSerializer and are required on the four sibling analytics objects. UsageObject.is_billable_metric_deleted is likewise set unconditionally by DataApi::UsagesService but not required. Both are safe tightenings of a response guarantee; left out to keep this diff focused on defects.

Process feedback for the retro

  • The slice table needs four new entries. orders, order_forms, quotes and quote_versions are 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 match OrdersQuery, OrderFormsQuery, QuotesQuery and the quote-version controllers exactly, including the top-level (not wrapper-nested) expires_at on 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.yml is an underused source of truth. It carries a canonical name, description, category and deprecated flag for all 75 events. Diffing it against src/webhooks/*.yaml is what surfaced the payment_request.payment_status_updated summary bug and the credit_note.provider_refund_failure mismatch. 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_ACCESS is no in 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.
  • Two cheap structural checks earned their place and should be encoded: every required entry must resolve to a defined property (found the credit_note_provider_refund_failure gap), and every webhook file must define the four keys Webhooks::BaseService always sends.

Generated by the lago-openapi-guardian agent. It never merges, approves, or enables auto-merge.

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