0022. Config Diff Classification and Managed Surface

Status: accepted Date: 2026-08-20 (registry consolidation 2026-08-28; review revision 2026-08-31; public entrypoint promotion 2026-09-03)

Problem Statement

supabase config diff (CLI-2156) compares the local config.toml against the effective configuration GET /v2/projects/{ref}/config reports, and config pull (CLI-2064) will delegate to the same engine. Three classification problems make a naive walk wrong:

  1. Key-set asymmetry. The earlier POC walked only keys present in the remote response, so a property the file declares and the remote doesn't return was structurally invisible. The inverse walk (local keys only) would hide remote-side drift the file never mentions.
  2. Managed vs. unmanaged. Most of config.toml configures the local stack — [studio], ports, image pins, [db.migrations] — and has no platform counterpart. Reporting those as drift is noise; deciding which properties the platform manages needs a source of truth that cannot drift from the code that reads the response.
  3. Incomparable values. The platform masks secrets (HMAC, never plaintext), reports byte counts where the file writes "50MiB", comma-joins arrays, and types some scalars differently than the schema. Comparing representations instead of meanings misreports drift; silently skipping them misreports cleanliness.

This ADR was first accepted with a self-contained translation table inside config-diff.ts (a read-function-per-managed-path port of the Go CLI's FromRemoteAuthConfig at 7b469f5b3). CLI-2230 (PR supabase/cli#6339) then landed the registry-driven ProjectConfig convergence normalizers — the same translation, shared with Studio, governed by ADR 0019 (passthrough), ADR 0020 (naming), and ADR 0021 (convergence semantics). Keeping two translations would have been exactly the parallel code path the repo's refactoring policy forbids, so the classifier now consumes the registry; this revision records the consolidated design.

Decision

@supabase/config owns the comparison core as pure, synchronous functions (config-diff.ts), with no dependency on @supabase/api, output formatting, or command flags — layered on CLI-2230's registry rather than a translation of its own. diffProjectConfig is now exported from the public . entrypoint, promoted from the internal subpath because Studio became a second real consumer — both apps/cli and Studio read the same classification:

  • Both operands are ProjectConfig convergence projections (ADR 0021). The caller builds the local operand with fromConfigDocument({config, document}) — raw-presence-masked, canonicalized, secret-omitting — and the remote operand with fromApiProjectConfig(response). All wire-shape knowledge (renames, inversions, unit conversions, the GoTrue key table) lives in projectConfigMappingRows, once, shared with Studio and the future push mapper.
  • The managed surface is the registry's. The classifier walks the union of both operands' leaf paths filtered by isComparableProjectConfigPath — a path with no registry row is unmanaged by construction and never reported ([studio], ports, image pins, [realtime] locals, compute). Most of these same paths are ALSO excluded one layer earlier, from ProjectConfig's own shape (DOCUMENT_ONLY_LOCAL_PATHS, ADR 0021, CLI-2316) — a ProjectConfig-contract-level guarantee for consumers other than this classifier (Studio, unmappedApiFields), not a duplicate of this rule. The two mechanisms must never diverge in the direction that matters here: a comparable (registry-mapped) path must never ALSO be a DOCUMENT_ONLY_LOCAL_PATHS member, or it silently becomes permanently unmanaged below regardless of whether fromApiProjectConfig reports a real value for it — project-config.unit.test.ts's "no comparableProjectConfigPaths entry ... is ever a DOCUMENT_ONLY_LOCAL_PATHS member" test pins this (added after db.major_version/db.pooler.{pool_mode,default_pool_size,max_client_conn} briefly violated it — PR #6451 review round, ADR 0021's CLI-2316 addendum).
  • Three-way classification per comparable path, driven by declared presence (the raw pre-decode document), which a decoded config cannot recover: update (declared + reported, values differ), remote_only (reported while undeclared — or while the feature it belongs to is switched off and no actor's write path can express the declared state — and differing from the suppression baseline), local_only (a declared local projection value the response did not account for: parsed-but-never-pushed attributes and permission-truncated responses). The local operand follows ADR 0018: the branch's merged effective config when the target ref matches a [remotes.*] block's project_id, the base config otherwise.
  • remote_only suppression baseline: the default config's own convergence projection, falling back — for push-gated containers the projection is silent on — to the raw default config's value (db.network_restrictions' allow-all default IS the platform's unconfigured state), then to the registry row's declared unconfiguredValue (the platform's own report of an unconfigured feature — GoTrue's sessions_timebox: 0 canonicalizes to the STRING "0s", pinned by the recorded config_auth fixtures). Platform-RENDERED strings (mailer template/notification subjects) get no pinned baseline at all: the platform authors those values and rewords them across environments, so a pinned copy cannot be simultaneously correct for prod and staging — their rows declare platformRendered instead, and any remote value is suppressed while the local projection is silent (a locally declared subject still classifies normally). "Unconfigured" is never inferred from type-level zero values — canonicalization can turn a platform zero into a non-zero shape, and PR #6295's review reproduced 15 noise lines on an untouched staging project from exactly that inference. A path with no baseline at any tier reports rather than guesses (over-report over under-report). An unconfigured project therefore diffs clean instead of flooding with platform-default noise.
  • Equality is meaning-based: the normalizers canonicalize representations (durations, byte sizes, comma-joins) per ADR 0021, and the classifier's residual equality tolerates string/number and string/boolean scalar skew. Whether an array is a SET or a SEQUENCE is per-field wire knowledge and lives on the registry row (arrayEquality), defaulting to sequence — api.schemas' first entry is PostgREST's default schema and api.extra_search_path is a literal search_path, so reordering is drift; auth.additional_redirect_urls opts into set semantics.
  • Secrets are "present, unknown". Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's isSecret rows define the masked surface, and locally-declared ones are surfaced in ConfigChangeSet.masked so a clean change list is visibly a partial claim. The same visibility rule covers ConfigChangeSet.unmanaged: a declared comparable path the local projection drops can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. This family is no longer whole-container omissions predicting what a push mapper happened to send (that mechanism, applyPushUnmanagedOmissions, was retired on CLI-2314 — see ADR 0021's 2026-09-04 addendum for the full re-derivation); it is now DISABLED_SENTINEL_PRUNES's surviving sibling-only rules, each re-derived from a platform data-model fact independent of any actor's write path — e.g. the Data-API-off api.{schemas,extra_search_path,max_rows} siblings (api.enabled is itself derived from db_schema via remoteDataApiDisabled, the same fact schemas carries), auth.email.smtp's non-host fields (smtp.enabled is likewise derived from smtp_host.length > 0, not an independent field), auth.captcha.provider/secret (a disabled captcha's provider choice is genuinely retained-but-inert on the platform), auth.oauth_server's two non-enabled siblings, storage.analytics/storage.vector's quota fields, and db.network_restrictions's CIDR fields (a document-only management opt-out with no v2 API counterpart at all). The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to local_only + an explicit scope note instead of an error or silent omission. This resource is read via api.executeRaw rather than the generated client's execute, per ADR 0019 rule 2: a strict Schema.Struct decode would drop excess properties and reject unknown enum members (a new pooler.pool_mode value, say) before the lenient config mirror ever ran on the body, leaving nothing left for it to be lenient about — diff.integration.test.ts's "an unknown enum value in the response degrades instead of failing" case pins the resulting degrade-not-fail behavior. Any future typed consumer of this resource, config pull included, must read it the same way, or deliberately reintroduce contract overrides for it.
  • Change entries carry what a consumer needs verbatim: path is a SEGMENT ARRAY end to end (an sms.test_otp phone-number key may itself contain a .; joining is display-only), declared distinguishes file-written values from schema-materialized defaults, and a remote_only entry keeps the materialized local default so "what would config push change?" is answerable from the change alone — the primary dashboard-drift use case, and what config pull will need to write back.
  • Command surface (review revision, PR #6295): the target flag is link's settled vocabulary — a single --project-ref accepting a project ref or a branch name/UUID (ref-shaped values always read as refs; a UUID resolves without a linked parent) — not a bespoke --target. The global -o/--output flag is rejected outright — every value, pretty included — with a message pointing at --output-format (the CLI-2156 ruling: no Go-compat contract for net-new commands, recorded as the scoped exception to Legacy Shell Invariant #6). --exit-code gives drift its own exit code 2, keeping 1 for failures, so scripts can tell "drifted" from "token expired". The machine payload versions its own contract (schema_version: 1, an integer) separately from the user-controlled $schema document reference (config_schema).
  • The interpolation pipeline records the resolving env var name on "environment" value origins, so a change on an env()-fed property can name the variable involved.

The command layer (apps/cli/src/legacy/commands/config/diff/) only resolves the target, fetches, projects, and renders. Per ADR 0021's rendering rule, reported "local" values are the convergence projection — what pushing the file would produce hosted — not the file's literal spelling.

Considered Alternatives

  1. Derive the managed set from the response keys (the POC's approach): whatever the remote returns is what's compared. Structurally blind to local_only, and a permission-truncated response silently shrinks the comparison.
  2. Schema annotations (x-managed) on each property: keeps the knowledge in the schema, but the annotation and the response-reading code can disagree, and the annotation cannot express per-property wire transforms that the registry rows carry.
  3. The original self-contained translation table (this ADR's first accepted form): correct, but once CLI-2230 landed the registry it became a ~600-line parallel implementation of the same mapping with independently-drifting transforms. Superseded by the consolidation above.
  4. Reuse config push's config-sync diffing (apps/cli/src/legacy/commands/config/push/config-sync/): those helpers produced per-service unified-diff text against the v1 per-service endpoints for push previews, not a typed change set, and lived in the CLI app. At the time this ADR was first accepted they were the Go-parity push path the registry rows were themselves mined from (CLI-2230), with a shared push mapper tracked as a follow-up; CLI-2313 deleted them outright and rebuilt config push to consume this classifier's ProjectConfig/ConfigChangeSet directly (ADR 0021's 2026-09-04 CLI-2314 addendum), so this alternative is no longer live to reconsider.

Consequences

  • config pull gets its comparison engine for free: the change set is typed data, and fromApiProjectConfig already produces the local representation of any remote value it needs to write.
  • Adding a newly platform-managed property is one registry row (shared with Studio); forgetting it means the property is silently unmanaged (never misreported as drift), which fails safe.
  • The wire shape is pinned by apps/cli's project-config-api-drift.unit.test.ts type-drift guard plus the registry's lenient decode (ADR 0019); API evolution degrades to "not reported" rather than crashing, and the live test is the tripwire.
  • Platform defaults that diverge from schema defaults surface as remote_only drift by design — the file's meaning is defined by the schema defaults reference (ADR 0018), not by what the platform would have picked.
  • The classifier inherits ADR 0021's limits verbatim: ADR 0021's "honest-but-push-unactionable" residual category (unconditionally-mapped fields with no local-silence signal, tracked on CLI-2266) surfaces here as remote_only entries a user cannot fix by editing their file.
  • ADR 0018: Sparse Config Subtraction — the defaults baseline and merged-remote-block local operand this classification builds on.
  • ADR 0019: Raw API-Response Passthrough — the leniency boundary and _apiResponse escape hatch of the remote operand.
  • ADR 0020: Config Naming Vocabulary — CliConfig vs ProjectConfig.
  • ADR 0021: ProjectConfig Convergence Semantics — what the two operand-producing normalizers compute, and why comparing them structurally is meaningful. Its 2026-09-04 (CLI-2314) addendum records the retirement of the whole-container "unmanaged-by-push" family this ADR's unmanaged bullet above used to name, and the surviving DISABLED_SENTINEL_PRUNES re-derivation that replaced it.
  • ADR 0006: Environment Management — remote blocks and branch mapping semantics.