Measuring API drift: 1,291 fields GitLab sends that this library does not model

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

Why now

#2269 (closed) asked how to notice when this library falls behind the API it wraps, and the answer there was that there is no great way to detect it, with the OpenAPI schema not comprehensive enough to serve as the oracle. That issue is closed, which is why this is a new one rather than a comment on it.

I have been building an MCP server on top of this library (https://github.com/jmrplens/gitlab-mcp-server) and needed that answer for my own reasons, so I built the measurement. This issue is the result: the method, what it currently finds, what it cannot see, and an offer.

I can confirm the OpenAPI observation independently. I started there, and the schema gives no response body for 553 of 1847 operations, so a comparison against it silently passes over the endpoints it does not describe.

The method

Boot a released gitlab-ee image and ask the loaded Rails application what its own REST API is, through one gitlab-rails runner script. What comes back is every Grape entity with the fields it exposes, the entity each field renders with, and the condition gating it, read from inside the image; every mounted route with the entity its desc annotates; and the licensed-feature table evaluated. It needs no licence and no fixtures, because a licence gates feature_available? when a request is served, not when a class is defined.

Then pair each struct in this library with the endpoints its own service methods route to, parsed from the route() templates and withMethod options in the source, and diff what the endpoint sends against what the struct carries.

Why the application rather than a scan of the same Ruby: a scan cannot see a name that is not written down. GeoSiteStatus exposes around 600 fields by iterating a constant assembled from two method calls, so the source says only "expose the loop variable". Measured against a scan of the same release with inheritance resolved, 551 of 582 entities agree and the 31 that do not hold 1,935 fields the scan never saw.

What it finds today

Measured against GitLab 19.3.1-ee: 897 distinct fields that an endpoint sends and the struct decoding it does not carry. Every one of them is a field this library does not model, not a field my own code fails to publish.

That number has been through two corrections I would rather state than have someone find. The raw run reports 1,424, of which 61 are answered by my own declaration table, mostly routes whose desc annotates an entity the endpoint does not actually render. The remaining 1,363 are reported once per entity that renders the field, so a field carried by four user entities appears four times. A third correction came later and is the largest: 997 of the 1,001 Geo findings name one entity, API::Entities::GeoSiteStatus, and 394 of them are the same field names counted a second time against a second output type, so Geo is 607 distinct pairs rather than 1,001. Counted as distinct fields the total is 897.

The headline is still misleading on its own, so here is the shape of it immediately:

  • 607 of them are one entity, API::Entities::GeoSiteStatus, and they are a matrix rather than a list. See the collapsible block below before reading anything into that number: it is not 607 things to write down.
  • The remaining 290 spread across 27 structs, and that is the part a person can act on. 188 of the 290 are sent with no condition on them at all.
  • None of the 290 is modelled by this library today. The same pass that finds them checks each against the struct's own json tags, so a field my server publishes from its own captured response and one this library lacks are told apart rather than conflated.
  • The record separates the fields sent unconditionally from those behind a condition and carries the condition text for each, so the two never have to be guessed at.

The 27 structs a person can act on

One row per struct, with the entities its endpoints render. "Always" counts the fields the entity exposes with no condition on them at all, so every response of every endpoint that renders it carries them.

These are distinct fields. My raw run counts 362 for the same set, because it reports a field once per entity that renders it: User is 16 fields seen through ServiceAccount, UserProfile, UserPublic and UserWithAdmin, which is 64 rows of one report and 16 things to add. Where a pairing was ambiguous between BasicMergeRequest and MergeRequest I have counted it once, under MergeRequest.

Struct Fields Always Rendered by
ProjectGroup 49 35 Group
Group 31 14 BasicProjectDetails, Group, and 1 more
IssueRelation 25 19 RelatedIssue
MemberRole 25 25 MemberRole
MergeRequest 22 20 ApprovalState, MergeRequestBasic, and 1 more
Project 17 4 Project
User 16 8 ServiceAccount, UserProfile, and 2 more
AccessRequest 15 8 AccessRequester, Member
BillableGroupMember 13 7 Member
PipelineInfo 12 12 Ci::Pipeline
Issue 11 9 EpicIssue, MRNote
Namespace 8 0 Namespace
Hook 7 5 Hook
BasicMergeRequest 6 4 MergeRequestBasic
Package 5 2 Package
ProjectDeployKey 4 2 DeployKeysProject
ProjectUser 4 3 UserBasic
Snippet 4 2 ProjectSnippet
ContributionEvent 3 2 Event
ProjectEvent 3 2 Event
GroupHook 2 1 GroupHook
GroupMember 2 1 Member
InstanceDeployKey 2 2 DeployKey
MergeRequestDependency 1 0 MergeRequestDependency
ProjectApprovalRule 1 0 ProjectApprovalRule
ProjectHook 1 0 ProjectHook
SSHKey 1 1 SSHKey

The field lists, one collapsible block each.

ProjectGroup, 49 fields

Rendered by API::Entities::Group.

Field Sent
allow_personal_snippets under a condition
archived always
auto_devops_enabled always
auto_duo_code_review_enabled under a condition
built_in_project_templates_enabled under a condition
created_at always
crm_enabled always
custom_attributes always
default_branch always
default_branch_protection always
default_branch_protection_defaults always
description always
duo_core_features_enabled under a condition
duo_features_enabled under a condition
duo_namespace_access_rules under a condition
emails_disabled always
emails_enabled always
file_template_project_id under a condition
ldap_access always
ldap_cn always
ldap_group_links under a condition
lfs_enabled always
lock_built_in_project_templates_enabled under a condition
lock_duo_features_enabled under a condition
lock_math_rendering_limits_enabled always
lock_resource_access_token_notify_inherited always
marked_for_deletion_on always
math_rendering_limits_enabled always
max_artifacts_size always
mentions_disabled always
organization_id always
parent_id always
path always
project_creation_level always
repository_storage under a condition
request_access_enabled always
require_two_factor_authentication always
resource_access_token_notify_inherited always
root_storage_statistics under a condition
saml_group_links under a condition
share_with_group_lock always
shared_runners_setting always
show_diff_preview_in_email always
statistics always
subgroup_creation_level always
two_factor_grace_period always
visibility always
web_based_commit_signing_enabled under a condition
wiki_access_level always
Group, 31 fields

Rendered by API::Entities::BasicProjectDetails, API::Entities::Group, API::Entities::GroupDetail.

Field Sent
ai_settings under a condition
allow_personal_snippets under a condition
auto_ban_user_on_excessive_projects_download under a condition
auto_duo_code_review_enabled under a condition
built_in_project_templates_enabled under a condition
duo_core_features_enabled under a condition
duo_namespace_access_rules under a condition
forks_count under a condition
http_url_to_repo always
last_activity_at always
license always
license_url always
lock_built_in_project_templates_enabled under a condition
lock_resource_access_token_notify_inherited always
name_with_namespace always
namespace always
path_with_namespace always
readme_url under a condition
resource_access_token_notify_inherited always
service_access_tokens_expiration_enforced under a condition
show_diff_preview_in_email always
ssh_url_to_repo always
star_count always
step_up_auth_required_oauth_provider under a condition
tag_list always
topics always
unique_project_download_limit under a condition
unique_project_download_limit_alertlist under a condition
unique_project_download_limit_allowlist under a condition
unique_project_download_limit_interval_in_seconds under a condition
web_based_commit_signing_enabled under a condition
IssueRelation, 25 fields

Rendered by API::Entities::RelatedIssue.

Field Sent
_links always
blocking_issues_count always
closed_at always
closed_by always
discussion_locked always
downvotes always
epic under a condition
epic_iid under a condition
has_tasks always
health_status under a condition
imported always
imported_from always
issue_type always
iteration under a condition
merge_requests_count always
moved_to_id always
service_desk_reply_to always
severity always
start_date always
subscribed under a condition
task_completion_status always
task_status under a condition
time_stats always
type always
upvotes always
MemberRole, 25 fields

Rendered by API::Entities::MemberRole.

Field Sent
admin_ai_catalog_item always
admin_ai_catalog_item_consumer always
admin_integrations always
admin_protected_branch always
admin_protected_environments always
admin_runners always
admin_security_attributes always
apply_security_scan_profiles always
create_security_scan_profiles always
delete_security_scan_profiles always
destroy_package always
read_admin_cicd always
read_admin_groups always
read_admin_monitoring always
read_admin_projects always
read_admin_subscription always
read_admin_users always
read_agent_artifacts always
read_compliance_dashboard always
read_crm_contact always
read_security_attribute always
read_security_scan_profiles always
read_virtual_registry always
update_sec_ai_workflow_settings always
update_security_scan_profiles always
MergeRequest, 22 fields

Rendered by API::Entities::ApprovalState, API::Entities::MergeRequestBasic, API::Entities::MergeRequestChanges.

Field Sent
approval_rules_left always
approvals_before_merge always
approvals_left always
approvals_required always
approved always
approved_by always
approver_groups always
approvers always
changes always
description_html under a condition
has_approval_rules always
invalid_approvers_rules always
merge_request_approvers_available always
merge_status always
multiple_approval_rules_available always
overflow always
reference always
require_password_to_approve always
suggested_approvers always
title_html under a condition
user_can_approve always
user_has_approved always
Project, 17 fields

Rendered by API::Entities::Project.

Field Sent
description_html always
duo_dependency_bump_breaking_changes_enabled under a condition
duo_foundational_flows_enabled under a condition
duo_remote_flows_enabled under a condition
duo_sast_fp_detection_enabled under a condition
duo_sast_vr_workflow_enabled under a condition
duo_secret_detection_fp_enabled under a condition
max_pipelines_per_merge_train under a condition
merge_train_enforcement under a condition
only_allow_merge_if_all_status_checks_passed under a condition
repository_object_format always
secret_push_protection_enabled under a condition
security_policy_pipeline_must_succeed under a condition
show_diff_preview_in_email always
spp_repository_pipeline_access under a condition
warn_about_potentially_unwanted_characters always
web_based_commit_signing_enabled under a condition
User, 16 fields

Rendered by API::Entities::ServiceAccount, API::Entities::UserProfile, API::Entities::UserPublic, API::Entities::UserWithAdmin.

Field Sent
avatar_path under a condition
bio_html always
commit_email always
discord always
enterprise_group_associated_at under a condition
enterprise_group_id under a condition
followers under a condition
following under a condition
github always
is_followed under a condition
local_time always
preferred_language always
pronouns always
provisioned_by_group_id under a condition
unconfirmed_email under a condition
work_information always
AccessRequest, 15 fields

Rendered by API::Entities::AccessRequester, API::Entities::Member.

Field Sent
avatar_path under a condition
avatar_url always
created_by under a condition
custom_attributes always
email under a condition
expires_at always
group_saml_identity under a condition
group_scim_identity under a condition
is_using_seat under a condition
locked always
member_role always
membership_state always
override under a condition
public_email always
web_url always
BillableGroupMember, 13 fields

Rendered by API::Entities::Member.

Field Sent
access_level always
avatar_path under a condition
created_by under a condition
custom_attributes always
expires_at always
group_saml_identity under a condition
group_scim_identity under a condition
is_using_seat under a condition
locked always
member_role always
membership_state always
override under a condition
public_email always
PipelineInfo, 12 fields

Rendered by API::Entities::Ci::Pipeline.

Field Sent
archived always
before_sha always
committed_at always
coverage always
detailed_status always
duration always
finished_at always
queued_duration always
started_at always
tag always
user always
yaml_errors always
Issue, 11 fields

Rendered by API::Entities::EpicIssue, API::Entities::MRNote.

Field Sent
blocking_issues_count always
epic_iid under a condition
has_tasks always
imported always
imported_from always
note always
relative_position always
severity always
start_date always
task_status under a condition
type always
Namespace, 8 fields

Rendered by API::Entities::Namespace.

Field Sent
additional_purchased_storage_ends_on under a condition
additional_purchased_storage_size under a condition
end_date under a condition
extra_shared_runners_minutes_limit under a condition
max_seats_used_changed_at under a condition
projects_count under a condition
root_repository_size under a condition
shared_runners_minutes_limit under a condition
Hook, 7 fields

Rendered by API::Entities::Hook.

Field Sent
alert_status always
branch_filter_strategy always
custom_headers under a condition
custom_webhook_template always
disabled_until always
organization_id under a condition
push_events_branch_filter always
BasicMergeRequest, 6 fields

Rendered by API::Entities::MergeRequestBasic.

Field Sent
approvals_before_merge always
description_html under a condition
merge_status always
reference always
title_html under a condition
work_in_progress always
Package, 5 fields

Rendered by API::Entities::Package.

Field Sent
conan_package_name under a condition
creator_id always
project_id under a condition
project_path under a condition
versions always
ProjectDeployKey, 4 fields

Rendered by API::Entities::DeployKeysProject.

Field Sent
last_used_at always
projects_with_readonly_access under a condition
projects_with_write_access under a condition
usage_type always
ProjectUser, 4 fields

Rendered by API::Entities::UserBasic.

Field Sent
avatar_path under a condition
custom_attributes always
locked always
public_email always
Snippet, 4 fields

Rendered by API::Entities::ProjectSnippet.

Field Sent
http_url_to_repo under a condition
imported always
imported_from always
ssh_url_to_repo under a condition
ContributionEvent, 3 fields

Rendered by API::Entities::Event.

Field Sent
imported always
imported_from always
wiki_page under a condition
ProjectEvent, 3 fields

Rendered by API::Entities::Event.

Field Sent
imported always
imported_from always
wiki_page under a condition
GroupHook, 2 fields

Rendered by API::Entities::GroupHook.

Field Sent
organization_id under a condition
repository_update_events always
GroupMember, 2 fields

Rendered by API::Entities::Member.

Field Sent
avatar_path under a condition
custom_attributes always
InstanceDeployKey, 2 fields

Rendered by API::Entities::DeployKey.

Field Sent
last_used_at always
usage_type always
MergeRequestDependency, 1 fields

Rendered by API::Entities::MergeRequestDependency.

Field Sent
blocked_merge_request under a condition
ProjectApprovalRule, 1 fields

Rendered by API::Entities::ProjectApprovalRule.

Field Sent
coverage_minimum_threshold under a condition
ProjectHook, 1 fields

Rendered by API::Entities::ProjectHook.

Field Sent
organization_id under a condition
SSHKey, 1 fields

Rendered by API::Entities::SSHKey.

Field Sent
last_used_at always
Geo, and why its number is not what it looks like

I first reported this as 1,001 fields across two structs, generated per replicable resource. Having looked properly, that was wrong twice over, and both errors are worth stating because they are the two failure modes this whole report is exposed to.

It is one entity, not two. 997 of the 1,001 findings name API::Entities::GeoSiteStatus. They arrive against two different Go types, and the 394 reported against the second are a strict subset of the 603 reported against the first, so counting both double-counts. API::Entities::GeoSite accounts for exactly four findings: blob_download_timeout, checksum_mismatch_report_threshold, checksum_mismatch_self_heal_cooldown_minutes and selective_sync_organization_ids. That entity has 23 fields in total and none of them is generated.

And the remaining 603 are a matrix. API::Entities::GeoSiteStatus exposes 93 distinct replicable prefixes, each carrying up to twelve of the same metrics: count, registry_count, synced_count, failed_count, checksummed_count, checksum_failed_count, checksum_total_count, verified_count, verification_failed_count, synced_in_percentage, verified_in_percentage and replication_enabled. 535 of the field names decompose that way exactly, and most of the rest are one more metric per resource, oldest_unsynced_time.

So the honest description is not "600 generated fields nobody can enumerate". It is one object of about 93 replicables times about 12 metrics, flattened into a single namespace by Grape.

That matters for what anyone would do about it. Written as named struct fields it is 600 lines that need regenerating every time GitLab adds a replicable, and a new replicable would silently read as fresh drift on the next release. Read as a map keyed by replicable, with the metrics as a small struct, it is one shape that absorbs new replicables on its own. I am not asking for either here; I am withdrawing the suggestion that this part is unenumerable, because it is not.

What it cannot see

Stating these before anyone else has to find them.

  • REST only. The GraphQL surface is a separate walk with its own results and is not in these numbers.
  • A route whose desc names the wrong entity inflates the count. The pairing trusts that annotation, and where it is wrong the comparison is wrong with it. I carry a declaration table for the cases I have found and would rather be told about others.
  • A field GitLab offers is a candidate, not a defect. GitLab adds fields weekly. Nothing here says this library is broken; it says the two have drifted, which is a different and much less alarming claim.
  • The record is a pin. It describes one released image, so it ages, and re-measuring is one command rather than a re-derivation.

What this is not asking for

Not a bulk addition. I read the answer in issue 2269 that these are easy to edit when somebody needs them, and I think that policy is the right one for a library this size: a field nobody consumes is a field nobody notices is wrong.

So this is a measurement offered as a menu, not a backlog. I am sending merge requests only for the fields I actually consume, one struct at a time, each with the entity and line that proves it.

What I have sent so far

Each of these references this issue, and each carries the GitLab source for the field it adds.

Merge request Struct Field State
!3040 (merged) Appearance, ChangeAppearanceOptions site_name merged, released in v3.2.0
!3041 Topic organization_id open
!3042 (merged) BroadcastMessage color merged, released in v3.1.0
!3043 (merged) Agent (cluster agents) is_receptive merged, released in v3.3.0
!3044 LicenseTemplate popular open
!3045 (merged) SecureFile file_extension merged, released in v3.3.0
!3046 (merged) GroupSCIMIdentity extern_uid (wrong json tag, not a missing field) merged, released in v3.2.0
!3047 (merged) GroupServiceAccount public_email, unconfirmed_email merged, released in v3.4.0
!3048 Hook (system hooks), AddHookOptions, EditHookOptions organization_id, alert_status, disabled_until, push_events_branch_filter, branch_filter_strategy, custom_webhook_template, custom_headers open
!3049 (merged) InstanceDeployKey, ProjectDeployKey last_used_at, usage_type open
!3050 ContributionEvent, ProjectEvent imported, imported_from, wiki_page open
!3051 Namespace root_repository_size, projects_count, shared_runners_minutes_limit, extra_shared_runners_minutes_limit, additional_purchased_storage_size, additional_purchased_storage_ends_on, max_seats_used_changed_at, end_date open
!3052 Package, plus the new PackageVersion and GetProjectPackage conan_package_name, creator_id, versions open
!3053 (merged) Snippet imported, imported_from, ssh_url_to_repo, http_url_to_repo open

Two of these are worth a word beyond the table.

!3046 (merged) is the one finding of a different kind: the field is modelled, under a json tag GitLab does not send, so it decodes as empty and no amount of reading the struct reveals it. A comparison against what the endpoint actually sends is the only thing that finds that class.

!3047 (merged) came from a review comment on my own repository claiming GroupServiceAccount already carried unconfirmed_email. It does not; ServiceAccount does. Checking rather than accepting the claim turned one field into a small map of the same entity modelled three times and unevenly:

Struct id username name email unconfirmed_email public_email
ServiceAccount (users.go) yes yes yes yes yes no
ProjectServiceAccount yes yes yes yes yes no
GroupServiceAccount yes yes yes yes no no

All three decode API::Entities::ServiceAccount. Five of its six fields are unconditional, so every response of every service account endpoint carries them. unconfirmed_email is the exception: it is exposed under if: ->(service_account) { service_account.unconfirmed_email.present? } and so appears only while a confirmation is pending. public_email is therefore missing from all three structs and is sent every time, and unconfirmed_email is missing from the group one.

!3047 (merged) adds both to GroupServiceAccount. I have not sent the public_email additions for the other two, and will if this shape of contribution is wanted.

The six from !3048 on are a second batch and are shaped differently, which is worth saying rather than leaving to be discovered. The first eight were one unconditional field per struct, chosen because they were the least arguable cases. These are whole structs: thirty-two fields across six of them, and most are conditional, so each merge request says who receives each field rather than only where it is exposed. Two of them turned up something a field list alone would not have. !3052 needed GetProjectPackage as well, because versions is exposed unless: opts[:collection] and the only endpoint that sends it had no wrapper, so adding the field alone would have added a field nothing could populate. And !3049 is as interesting for what it leaves alone: projects_with_write_access and projects_with_readonly_access are gated on options that only GET /deploy_keys passes, so InstanceDeployKey is right to carry them and ProjectDeployKey is right not to.

What went to GitLab's own documentation

Checking each field against doc/api/ before sending it turned up a second kind of gap, and I am recording it here because it is the same measurement finding it and because it bears on how much a documentation-derived oracle can be trusted.

Of the eight fields in the first tranche, two were documented nowhere on their page, and a third page carried public_email in none of its fourteen example responses. So for those fields the drift is not only between GitLab and this library; it is also between GitLab and its own reference documentation, which is where a caller would go to find out whether the field exists at all.

Merge request Page What it adds
gitlab-org/gitlab!254507 (merged) doc/api/secure_files.md file_extension in all four example responses
gitlab-org/gitlab!254511 doc/api/cluster_agents.md is_receptive in the three response attribute tables and the four example responses
gitlab-org/gitlab!254519 (merged) doc/api/service_accounts.md public_email in all fourteen service account example responses, and email in the two that predate it
gitlab-org/gitlab!254538 doc/api/system_hooks.md the seven entity fields missing from all three example responses, and custom_webhook_template in the two request parameter tables
gitlab-org/gitlab!254540 doc/api/deploy_keys.md last_used_at and usage_type in all nine example responses, and three fingerprint_sha256 keys that had been left as an empty string
gitlab-org/gitlab!254542 doc/api/events.md wiki_page, through a wiki page event added to one example, since none of the nine events shown was one
gitlab-org/gitlab!254543 doc/api/namespaces.md the four compute minute and purchased storage attributes, in the block where the page already shows its permission-gated ones
gitlab-org/gitlab!254547 doc/api/packages.md conan_package_name, and a correction to the versions example, which showed a pipelines array where the entity exposes a single pipeline and omitted tags
gitlab-org/gitlab!254552 doc/api/snippets.md, doc/api/project_snippets.md ssh_url_to_repo and http_url_to_repo in the response attribute tables and examples that lacked them, and imported and imported_from in the public snippets section

The question

Two, and either answer is useful:

  1. Would a regenerated drift record, or a check that runs it, be welcome in this repository? I am happy to contribute the generator rather than keep it on my side, and equally happy to leave it where it is if on-demand editing is the policy you want to keep.
  2. If it stays on my side, is there interest in me posting the full list so other contributors can pick from it? It is a good source of small, well-evidenced first contributions, which is why I am offering it rather than quietly working through it alone.
Edited by José M. Requena Plens