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:
- 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.
- Managed vs. unmanaged. Most of
config.tomlconfigures 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. - 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
ProjectConfigconvergence projections (ADR 0021). The caller builds the local operand withfromConfigDocument({config, document})— raw-presence-masked, canonicalized, secret-omitting — and the remote operand withfromApiProjectConfig(response). All wire-shape knowledge (renames, inversions, unit conversions, the GoTrue key table) lives inprojectConfigMappingRows, 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, fromProjectConfig's own shape (DOCUMENT_ONLY_LOCAL_PATHS, ADR 0021, CLI-2316) — aProjectConfig-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 aDOCUMENT_ONLY_LOCAL_PATHSmember, or it silently becomes permanentlyunmanagedbelow regardless of whetherfromApiProjectConfigreports 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 afterdb.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'sproject_id, the base config otherwise. remote_onlysuppression 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 declaredunconfiguredValue(the platform's own report of an unconfigured feature — GoTrue'ssessions_timebox: 0canonicalizes to the STRING"0s", pinned by the recordedconfig_authfixtures). 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 declareplatformRenderedinstead, 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 andapi.extra_search_pathis a literalsearch_path, so reordering is drift;auth.additional_redirect_urlsopts 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
isSecretrows define the masked surface, and locally-declared ones are surfaced inConfigChangeSet.maskedso a clean change list is visibly a partial claim. The same visibility rule coversConfigChangeSet.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 nowDISABLED_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-offapi.{schemas,extra_search_path,max_rows}siblings (api.enabledis itself derived fromdb_schemaviaremoteDataApiDisabled, the same factschemascarries),auth.email.smtp's non-host fields (smtp.enabledis likewise derived fromsmtp_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-enabledsiblings,storage.analytics/storage.vector's quota fields, anddb.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 tolocal_only+ an explicit scope note instead of an error or silent omission. This resource is read viaapi.executeRawrather than the generated client'sexecute, per ADR 0019 rule 2: a strictSchema.Structdecode would drop excess properties and reject unknown enum members (a newpooler.pool_modevalue, 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 pullincluded, must read it the same way, or deliberately reintroduce contract overrides for it. - Change entries carry what a consumer needs verbatim:
pathis a SEGMENT ARRAY end to end (ansms.test_otpphone-number key may itself contain a.; joining is display-only),declareddistinguishes file-written values from schema-materialized defaults, and aremote_onlyentry keeps the materialized local default so "what wouldconfig pushchange?" is answerable from the change alone — the primary dashboard-drift use case, and whatconfig pullwill need to write back. - Command surface (review revision, PR #6295): the target flag is
link's settled vocabulary — a single--project-refaccepting 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/--outputflag is rejected outright — every value,prettyincluded — 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-codegives drift its own exit code2, keeping1for 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$schemadocument reference (config_schema). - The interpolation pipeline records the resolving env var name on
"environment"value origins, so a change on anenv()-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
- 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. - 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. - 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.
- Reuse
config push'sconfig-syncdiffing (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 rebuiltconfig pushto consume this classifier'sProjectConfig/ConfigChangeSetdirectly (ADR 0021's 2026-09-04 CLI-2314 addendum), so this alternative is no longer live to reconsider.
Consequences
config pullgets its comparison engine for free: the change set is typed data, andfromApiProjectConfigalready 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'sproject-config-api-drift.unit.test.tstype-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_onlydrift 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_onlyentries a user cannot fix by editing their file.
Related Decisions
- 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
_apiResponseescape hatch of the remote operand. - ADR 0020: Config Naming Vocabulary —
CliConfigvsProjectConfig. - 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
unmanagedbullet above used to name, and the survivingDISABLED_SENTINEL_PRUNESre-derivation that replaced it. - ADR 0006: Environment Management — remote blocks and branch mapping semantics.