Skip to content

feat(cli): add config diff command - #6295

Open
kanadgupta wants to merge 17 commits into
developfrom
kanadgupta/cli-2156-add-supabase-config-diff-to-the-cli
Open

feat(cli): add config diff command#6295
kanadgupta wants to merge 17 commits into
developfrom
kanadgupta/cli-2156-add-supabase-config-diff-to-the-cli

Conversation

@kanadgupta

@kanadgupta kanadgupta commented Aug 21, 2026

Copy link
Copy Markdown
Member

Implements CLI-2156: a read-only supabase config diff that classifies drift between supabase/config.toml and the effective configuration GET /v2/projects/{ref}/config reports for a target project or branch. Never writes. Builds on CLI-2155's sparse subtraction/defaults (#6205) and consumes CLI-2230's ProjectConfig convergence normalizers (#6339) as its comparison operands.

What changed

packages/config — the comparison core (ADR 0022)

  • config-diff.ts: a pure classifier producing a typed ConfigChangeSet (update / remote_only / local_only, plus masked and per-class counts), reusable by config pull without the command layer.
  • Both operands are CLI-2230 convergence projections (ADR 0021): fromConfigDocument({config, document}) locally (raw-presence-masked, canonicalized, secret-omitting) and fromApiProjectConfig(response) remotely. All wire knowledge — renames, boolean inversions, duration/byte-size conversions, the GoTrue key table — lives in the shared projectConfigMappingRows registry, so the managed surface is isComparableProjectConfigPath by construction: a path with no registry row ([studio], ports, image pins, [realtime] locals, workers) can never be reported.
  • Classification is driven by the raw document's declared-key set — the one signal a decoded config cannot recover — so "the file wrote the default" and "the file is silent" classify differently (update vs suppressed/remote_only).
  • remote_only suppression baseline: the default config's own convergence projection, falling back to the raw schema default for push-gated containers (network restrictions' allow-all default is exactly the platform's unconfigured state), then to the type's zero value. An untouched project diffs clean.
  • Secrets (the registry's isSecret rows) are "present, unknown": both normalizers omit them, they never classify or count, and locally-declared ones surface via masked so a clean diff is visibly a partial claim.
  • Residual equality is meaning-based: multiset array comparison (additional_redirect_urls order is not drift) and string/number, string/boolean scalar tolerance.
  • io.ts/lib/env.ts: value origins now record the resolving env-var name, so a change on an env()-fed property names the variable.

apps/cli — legacy-shell command

  • legacy/commands/config/diff/: command + handler + errors + SIDE_EFFECTS.md. Target resolution: --target <branch-name|uuid|ref> (same acceptance as link; 404 → "run supabase branches list"), --project-ref, else the linked ref; --target + --project-ref together is a hard error. When the resolved ref matches a [remotes.*] block's project_id, the local operand is the branch's merged effective config (ADR 0018), otherwise the base config — the echoed line always says which.
  • Output: text (unset renders (unset) / (not returned); (from env VAR) annotations; masked note) and --output-format json|stream-json (structured payload with schema_version, target, scope, changes[], masked[], counts). The comparison-scope line lists which response blocks were carried. The Go-compat -o/--output flag is rejected outright (every value, pretty included) with an error pointing at --output-format — per the ticket-thread decision that net-new commands carry no Go parity contract. --exit-code sets exit 1 on drift via ProcessControl.setExitCode after the payload is emitted.
  • legacy/shared/legacy-branch-ref.resolver.ts: the branch name/UUID/ref resolver hoisted out of the branches family (cross-family use) with injected error mappers; the branches family keeps a thin binding so its call sites are unchanged.

Docs: ADR 0022 (classification + managed surface, incl. the registry consolidation and its relationship to ADR 0019/0020/0021), go-cli-divergences.md TS-only command entry (replacing the ticket's stale go-cli-porting-status.md criterion), per-command SIDE_EFFECTS.md.

History note for reviewers

The branch was first implemented with a self-contained translation table (a ~900-line port of the Go CLI's FromRemoteAuthConfig). After #6339 landed the registry-driven normalizers on develop — with this command as their named consumer — the merge commit (766182f) brought develop in and the follow-up (cae9c14) deleted the tables and rebuilt the classifier on the registry, per the repo's no-parallel-code-paths policy. ADR 0022's "Considered Alternatives" records both designs.

Decisions & assumptions worth reviewing

  1. Managed surface = the shared registry (vs schema annotations or response-key walking): single source of truth shared with Studio and the future push mapper; a missing row fails safe (silently unmanaged, never misreported). Alternatives in ADR 0022.
  2. Suppression baseline chain (default projection → raw schema default → zero value) is a judgment call: without it, every unconfigured OAuth provider and the platform's allow-all network restrictions read as drift on untouched projects. The cost is that a remote override that happens to equal a schema default on an undeclared path is not reported.
  3. schema_version in the payload is the file's $schema ref (falling back to the current schema URL) — CLI-2155 shipped no separate version token.
  4. Rendered "local" values are convergence projections (ADR 0021), i.e. what pushing the file would produce hosted — canonicalized durations/byte sizes, push-gated omissions — not necessarily the file's literal spelling. Documented in SIDE_EFFECTS.md.
  5. Legacy -o support was implemented per the original acceptance criteria, then removed after Colum confirmed on the ticket that parity isn't a goal for net-new commands. The flag now fails fast with a bespoke invalid-input error; the JSON payload always carries explicit null for unset sides.
  6. Masked credentials are transparent: listed in masked[] / a text note rather than silently skipped, and never affect --exit-code.
  7. Partial responses degrade, never error: comparable paths the response doesn't carry are local_only when declared locally, silent otherwise; the scope line calls out missing blocks. (Today's v2 schema requires all six blocks, so this is belt-and-braces for API evolution and permission-trimmed keys.)
  8. The classifier inherits ADR 0021's limits: unconditionally-mapped fields with no local-silence signal can surface as honest-but-push-unactionable remote_only entries (tracked on CLI-2266).

🤖 Generated with Claude Code

Base automatically changed from kanad-claude/config-default-values-mapping-ced354 to develop August 24, 2026 14:23
@Coly010
Coly010 force-pushed the kanadgupta/cli-2156-add-supabase-config-diff-to-the-cli branch from 8bc72ee to 0181e6c Compare August 24, 2026 14:23
kanadgupta and others added 4 commits August 24, 2026 15:03
Adds the pure comparison engine for supabase config diff: a managed-surface
table (defined by the v2 project-config translation, so unmapped schema paths
are unmanaged by construction), a change-set classifier with update /
remote_only / local_only classes, order-insensitive type-aware equality,
byte-size canonicalization, masked-secret transparency, and env-var name
threading through the interpolation pipeline onto value origins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ADR 0019 (CLI-2156)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read-only drift report between supabase/config.toml and the effective
configuration GET /v2/projects/{ref}/config reports for a target project or
branch. Target resolution via --target (branch name/UUID/ref, link-style
acceptance) or --project-ref or the linked ref; matching [remotes.*] blocks
become the merged local operand per ADR 0018. Text, --output-format
json/stream-json, and Go-compat -o encodings share one structured payload;
--exit-code flips exit 1 on drift after the payload is out. Hoists the branch
name/UUID resolver to legacy/shared with injected error mappers. Adds ADR
0019, SIDE_EFFECTS.md, a go-cli-divergences entry, 26 integration tests
(handler at 100% branch coverage), format unit tests, and a live golden path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Colum confirmed on the ticket that net-new commands carry no Go parity
contract, so the Go-compat -o/--output flag is now rejected outright (every
value, pretty included) with an error pointing at --output-format, failing
fast before target resolution or any network call. Drops the four Go-encoder
emit branches, simplifies the JSON payload to always carry explicit nulls for
unset sides, and updates SIDE_EFFECTS.md, the divergences entry, and the
tests. Ticket acceptance criteria amended accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kanadgupta
kanadgupta force-pushed the kanadgupta/cli-2156-add-supabase-config-diff-to-the-cli branch from 0181e6c to 24607dc Compare August 24, 2026 20:03
kanadgupta and others added 2 commits August 27, 2026 16:48
…iff-to-the-cli

Resolution notes beyond the textual conflicts:
- ADR renumbered 0019 -> 0022 (develop took 0019-0021).
- The env-var-name threading on value origins re-applied to the relocated
  CliConfigValueOrigin (config-document.ts); the loader body kept it via
  auto-merge.
- Mechanical adaptation to the CliConfig rename and entrypoint split
  (loadCliConfig via @supabase/config/effect, CLI_CONFIG_SCHEMA_URL,
  EffectiveConfig, CliConfigParseError, mockLegacyCliSettings).
- diff.live.test.ts rewritten for the new fixture-based live harness.

The config-diff translation tables still exist at this commit; the follow-up
commit consolidates them onto CLI-2230's registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…156)

CLI-2230 (#6339) landed the registry-driven ProjectConfig convergence
normalizers with config diff as their intended consumer (ADR 0021), which
made this branch's self-contained translation tables a parallel
implementation of the same mapping. The classifier now takes two
ProjectConfig projections — fromConfigDocument({config, document}) locally
(raw-presence-masked) and fromApiProjectConfig(response) remotely — walks
the union of their leaves filtered by isComparableProjectConfigPath, and
keeps the declared-set-driven classes, masked transparency (registry
isSecret rows), and env naming. remote_only suppression baselines on the
default config's projection, falling back to the raw default value for
push-gated containers (network restrictions' allow-all) and then the zero
value. Deletes config-diff.{managed,auth,read}.ts (~900 lines); scope
reporting moves to the command layer off the raw response attributes; ADR
0022 rewritten to record the consolidation; --target registered in the
CLI-1896 value-consuming flag guard; purity-pin allowlists extended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kanadgupta

Copy link
Copy Markdown
Member Author

Merged develop in (merge commit, no rebase) and consolidated the comparison core onto CLI-2230's registry:

  • Merge commit (766182f): conflict resolution + mechanical adaptation — our ADR renumbered 0019→0022 (develop took 0019–0021), the env-var-name threading re-applied to the relocated CliConfigValueOrigin, CliConfig/entrypoint-split renames, the live test rewritten for the fixture-based live harness, and the v2 test fixture gaining the new required database.major_version.
  • Consolidation (cae9c14): feat(config): add toProjectConfig and the ProjectConfig hosted subset (CLI-2230) #6339 built fromConfigDocument/fromApiProjectConfig explicitly as this diff's operands (ADR 0021), so the branch's own ~900-line translation tables (config-diff.{managed,auth,read}.ts) are deleted and diffProjectConfig now compares the two convergence projections over isComparableProjectConfigPath, with masked secrets from the registry's isSecret rows and remote_only suppression baselined on the default config's projection (falling back to raw schema defaults for push-gated containers, e.g. network restrictions' allow-all). Classification semantics (declared-set-driven update/remote_only/local_only, masked note, env-var naming, --exit-code) are unchanged. ADR 0022 records the consolidation and its relationship to ADR 0019/0020/0021.

@kanadgupta
kanadgupta marked this pull request as ready for review August 27, 2026 22:25
@kanadgupta
kanadgupta requested a review from a team as a code owner August 27, 2026 22:25

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cae9c14a97

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +72 to +76
if (Option.isSome(goOutputFlag)) {
return yield* new LegacyConfigDiffOutputFlagUnsupportedError({
message:
"the -o/--output flag is not supported by config diff; use --output-format json|stream-json instead.",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor the legacy output flag instead of rejecting it

When any global -o/--output value is supplied—including pretty—the handler exits before performing the diff. Legacy handlers are required to honor both output mechanisms, with --output taking priority, so scripted invocations such as config diff -o json unexpectedly fail instead of producing the requested payload. Implement the legacy encodings rather than rejecting the flag.

AGENTS.md reference: apps/cli/AGENTS.md:L300-L300

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 04a174a-o json|yaml|toml|env now encode the structured payload via the shared encoders with --output taking priority, and pretty falls through to --output-format, per Invariant #6 and the backups/list pattern.

if (flags.exitCode && changeSet.changes.length > 0) {
yield* processControl.setExitCode(1);
}
}).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Flush telemetry for pre-resolution failures

The finalizers only wrap the inner effect after ref has been resolved, so unsupported-output errors, flag conflicts, missing-link failures, and branch-resolution failures all bypass telemetryState.flush. Those are normal command invocations and failures, and omitting the flush violates the legacy lifecycle invariant and leaves telemetry state incomplete; wrap the full invocation with the telemetry finalizer while keeping the ref-dependent cache finalizer appropriately scoped.

AGENTS.md reference: apps/cli/AGENTS.md:L290-L290

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fba78b7Effect.ensuring(telemetryState.flush) wraps the full handler body (config load and branch resolution included), with the ref-dependent cache finalizer gated on a resolved ref. Failure-path assertions added in 977e300.

if (Option.isSome(flags.target) && !LEGACY_BRANCH_PROJECT_REF_PATTERN.test(flags.target.value)) {
const target = flags.target.value;
branch = target;
const parentRef = yield* resolver.resolve(Option.none());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve UUID targets without requiring a linked project

For a UUID --target, resolver.resolve(Option.none()) runs before legacyResolveBranchProjectRef, even though the UUID lookup endpoint does not use a parent ref. Consequently, in an unlinked non-interactive directory, config diff --target <uuid> fails with LegacyProjectNotLinkedError without making the documented /v1/branches/{uuid} request; only branch-name targets should require the parent project ref.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fba78b7 — the parent ref is passed to the branch resolver lazily and evaluated only for branch-NAME lookups, so a UUID --project-ref resolves through GET /v1/branches/{id} in an unlinked directory. Pinned by an integration test with projectId: Option.none().

Comment thread apps/cli/docs/go-cli-divergences.md Outdated
| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request <route> [--method <METHOD>]`. |
| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. |
| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. |
| `config diff` | [`../src/legacy/commands/config/diff/diff.command.ts`](../src/legacy/commands/config/diff/diff.command.ts) | Read-only drift report between `supabase/config.toml` and `GET /v2/projects/{ref}/config` (CLI-2156). TS-only: the old Go CLI had no config diff. `--target` accepts a branch name/UUID/ref; `--exit-code` exits 1 on drift. Rejects the Go-compat `-o/--output` flag outright — machine output is `--output-format json\|stream-json` only (no Go parity contract for net-new commands, per the CLI-2156 discussion). Comparison core lives in `@supabase/config` (ADR 0022). |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the entry from the frozen divergence record

This adds config diff to go-cli-divergences.md, but that file is explicitly frozen and new CLI behavior must instead be documented through help text, tests, and SIDE_EFFECTS.md. Remove this row so the historical record does not keep accumulating current feature documentation.

AGENTS.md reference: apps/cli/AGENTS.md:L539-L542

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 04a174a — the file is restored to develop's version (row and reflow both gone).

Comment on lines +68 to +71
// Net-new TS command with no Go parity contract: the Go-compat `-o/--output`
// flag is rejected outright (every value, `pretty` included) rather than
// honored — machine output goes through `--output-format` only (CLI-2156,
// per Colum). Checked first so no target resolution or network call runs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Describe the output behavior without Go-parity framing

This new comment defines the command's behavior by saying it has no Go parity contract, and the same framing is repeated in the new error and side-effect documentation. New legacy work must describe behavior on its own terms rather than use the removed Go implementation as the compatibility baseline; rephrase this around the supported output flags themselves.

AGENTS.md reference: apps/cli/AGENTS.md:L56-L58

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the framing disappeared with the code that carried it: the -o rejection comment/error (04a174a) and the SIDE_EFFECTS "no Go CLI equivalent" line. The command's behavior is now described in its own terms throughout.

Comment on lines +126 to +129
const loaded = yield* loadCliConfig(runtimeInfo.cwd, {
projectRef: ref,
goViperCompat: true,
}).pipe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the local config before resolving remote branches

When --target is a branch name or UUID, target resolution can make a Management API request before this load runs. Therefore a missing or malformed local config does not abort before network activity as promised by LegacyConfigDiffLoadConfigError and SIDE_EFFECTS.md; for example, config diff --target staging can contact /v1/projects/.../branches/staging before reporting the TOML parse failure. Parse and validate the local document before performing branch lookup, then apply the target-specific remote overlay once the ref is known.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fba78b7 — the config is loaded and validated before any network call, with the [remotes.*] overlay applied by a re-load once the resolved ref is known (only configs that declare remotes reload). A malformed TOML with a branch target now pins zero API requests in the integration suite.

Comment on lines +95 to +98
ref = yield* legacyResolveBranchProjectRef(target, parentRef, {
mapGetError: mapBranchResolveError,
mapFindError: mapBranchResolveError,
}).pipe(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Show progress while resolving branch targets

For branch-name and UUID targets, legacyResolveBranchProjectRef performs a Management API request without an output.task, so text-mode users receive no progress indication while that network request is pending. Wrap this lookup in a task and fail or clear it on every exit, as is already done for the subsequent project-config fetch.

AGENTS.md reference: apps/cli/AGENTS.md:L425-L427

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fba78b7 — branch resolution runs under output.task("Resolving branch...") with fail/clear on every exit, matching the config fetch.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Supabase CLI preview

npx --yes https://pkg.pr.new/supabase/cli/supabase@b02b1948733b4ab69fdb90caf0d2646305837e3a

Preview package for commit b02b194.

@Coly010 Coly010 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran an adversarial review of this PR: four independent passes (implementation, architecture, security, DX) plus live runs from source against staging (api.supabase.green) with a real linked project. Every finding was reproduced by running code, not read off the diff.

Verdict: request changes. The design underneath — the registry-driven managed surface, the secret triple-gating, convergence-projection operands — held up under genuinely hostile probing (16 secret-leak probes across all output modes failed; unknown future secret API fields fail safe; read-only is proven by mtime+content pinning; machine stdout stays payload-pure; the branch-resolver hoist is behaviour-identical with all 88 branches tests green). The blockers are concentrated in the classifier's equality/suppression edges, the flag wiring, and documented claims the code doesn't keep.

Blockers (all inline)

  1. --exit-code is accidentally required → plain supabase config diff (the help's own first example) errors out. One-line fix; needs a parser-level test.
  2. Order-insensitive array equality false-negatives on api.schemas / api.extra_search_path--exit-code exits 0 on real drift.
  3. The remote_only suppression baseline misses canonicalized zeros and platform-default subjects → untouched projects report drift. Live on staging: 15 of 18 remote-only entries on a near-default project were this noise ("0s" session values + 13 template/notification subjects + 3 storage defaults).
  4. False clean: a declared auth.oauth_server.enabled disagreeing with the remote prints "No config differences found."

Confirmed live against staging

Beyond blocker 1, the command hard-fails on staging today: SchemaError(Missing key at ["data"]["attributes"]["storage"]["database_pool_mode"]) — the generated contract requires every block key, so the documented "partial responses degrade, never error" behaviour is unreachable (inline on SIDE_EFFECTS.md). I only obtained a successful run by locally patching the contract to make that key optional. With that patch, the happy path works well end-to-end: 29 classified changes, correct counts, (from env VAR) annotations, masked-credentials note, machine payload and exit codes all as designed.

Majors (all inline)

  • remote_only erases the local value it just compared — the output can't answer "what would config push change?".
  • --workdir is silently ignored (config push shares the bug).
  • Telemetry flush + linked-project cache skipped on every pre-resolution failure path (Legacy Shell Invariant #1).
  • ANSI/control-character injection via unsanitized path segments and names in text output (legacySanitizeInlineName exists for exactly this and is used 14 lines away).
  • --exit-code conflates drift with failure (both exit 1).
  • JSON schema_version is the user's $schema URL, not a payload contract version.
  • Response-decode failures mislabeled as network errors, dropping the upstream suggestion and bypassing the purpose-built actionability adapter.
  • Dotted-path round-tripping silently drops record keys containing ..

Smaller items not carried by an inline thread

  • DiffProjectConfigOptions asks for local and declared separately — two params that must come from the same load, with nothing enforcing it. Consider accepting the loaded pair and deriving both.
  • counts is derived state computed in three places (config-diff.ts, both formatters + changes.length in the handler); one will drift. Either drop it from the package type or make total part of it and use it everywhere.
  • No docs-site overlay (config push has docs/supabase/config/push.md), so the published reference page for a semantically subtle command falls back to one sentence — nothing on --exit-code, (unset) vs (not returned), masking, or the fact that rendered local values are convergence projections (a user who writes "1m" and sees "1m0s" will grep their file and file an issue; worth a one-line note in the output or docs).
  • One concept, three spellings: [remote only] / remote-only / remote_only across label, summary, and JSON. And N difference(s) where the count is known at render time.
  • --target <uuid> is echoed as a quoted display name (Comparing against '1111…-…'); the branch's actual name is never shown.
  • SIDE_EFFECTS.md "config.toml is read before any network call" is false for --target <branch> — branch resolution runs first, so a broken TOML burns an API round-trip, and in a fresh directory the "run supabase link" error wins over the friendlier "run supabase init" one.
  • JSON scope lists only present blocks; consumers must re-derive the missing set from a hardcoded list — consider scope: {present, missing}.
  • ADR 0022 ships as proposed (README row too) while its body says it "was first accepted", and it's silent on three shipped decisions: --target, the -o rejection, and the remote_only local-value nulling.

Test-suite structure (why the suite missed the blockers)

The parser is never exercised (blocker 1); the auth: {} fixture means the largest, most transform-heavy mapping surface never runs end-to-end (blocker 3); the live test asserts only exit 0 where its own comment says cleanliness is the point; and no test asserts a secret string is absent from output. Details inline on the fixture.

What's genuinely good here

The (from env VAR) annotation, the (unset)/(not returned)/null distinctions, byte sizes rendered in the user's units, masked secrets surfaced-but-never-counted, the read-only proof in tests, and the registry consolidation over a parallel translation table are all exactly right. This is close to a really good command — it just can't currently be invoked, and each of its two core promises (no false drift, no false clean) has a reproduced counterexample.

),
Flag.optional,
),
exitCode: Flag.boolean("exit-code").pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: Flag.boolean("exit-code") without Flag.withDefault(false) makes this a required flag, so the command cannot be invoked as documented:

$ supabase config diff
Error: required flag(s) "exit-code" not set

That's the exact invocation in the EXAMPLES block below and in the generated docs spec (which even emits default_value: "false"). The integration suite can't catch this because every test hands a pre-built flags object to the handler and never goes through the parser — please ship a parser-level test with the one-line fix (e.g. an e2e assertion that config diff with no args doesn't emit required flag(s)), or the next boolean flag will regress the same way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04a174a: Flag.withDefault(false), and — since you're right that the integration suite structurally can't catch this — a new diff.e2e.test.ts pins the parser at the subprocess boundary (config diff with no args must not emit required flag(s)), so the next boolean flag can't regress the same way.

* scalars tolerate string/number and string/boolean representation skew.
*/
export function isEqualConfigValue(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: comparing every array as a multiset false-negatives on order-significant config. api.schemas (PostgREST's default schema is the first entry) and api.extra_search_path (a literal search_path, where order is resolution order) are sequences, not sets. Reproduced: local ["public","extensions"] vs remote db_extra_search_path: "extensions,public"changes: [], so --exit-code exits 0 on a difference that changes runtime behaviour — in a drift detector.

"Is this array a set or a sequence?" is per-field wire semantics, i.e. registry knowledge: suggest a row property (arrayEquality: "set" | "sequence") on projectConfigMappingRows, defaulting to sequence (over-report rather than under-report), with additional_redirect_urls opting into set semantics.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted your design verbatim in 3f23ca8: arrayEquality: "set" | "sequence" on projectConfigMappingRows, defaulting to sequence (over-report), with additional_redirect_urls opting into set semantics. Your exact repro — local ["public","extensions"] vs db_extra_search_path: "extensions,public" — is now a pinned unit test (both api.schemas and api.extra_search_path), and isEqualConfigValue's exported default flipped to sequence with the mode as an explicit parameter.

Comment thread packages/config/src/config-diff.ts Outdated
return `j:${JSON.stringify(value)}`;
}

function isZeroValue(value: unknown): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: this fallback tests JS zeros, but the operand is already canonicalized by the registry — GoTrue's sessions_timebox: 0 arrives here as the string "0s" and escapes the check. Confirmed live against staging: a near-default project reports auth.sessions.timebox / auth.sessions.inactivity_timeout as [remote only] remote: "0s", plus 13 more noise lines for auth.email.template.*.subject / notification.*.subject (platform-reported defaults with no baseline in the default CLI config; the three storage.* entries look like the same class). On the staging bench project, 15 of the 18 remote_only entries were this noise — an untouched project fails --exit-code, which is exactly the flooding ADR 0022 says the baseline prevents.

Suggest not inferring "unconfigured" from JS zeros at all: put the platform's unconfigured value on the registry row (e.g. unconfiguredValue), and add a registry-driven test enumerating every comparable path whose baseline is undefined, asserting its zero form suppresses.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 3f23ca8: zero inference is gone entirely — the suppression baseline is now default-projection → raw-default → the row's declared unconfiguredValue, and a path with no baseline at any tier reports. Rows declare: "0s" for both session bounds, the 13 provisioning-default subject strings, and false for the 7 notification toggles. For provenance I didn't guess the subjects: they're pinned byte-for-byte from the recorded real responses already in this repo (apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/), and the notification default comes from supabase/auth's NotificationsConfiguration (default:"false"). The registry-driven test you asked for walks every comparable path whose config-side baseline is undefined: rows with unconfiguredValue must classify their unconfigured report clean, and absence-class rows must REPORT a zero-form value rather than swallow it.

Comment thread packages/config/src/config-diff.ts Outdated
// (e.g. `db.network_restrictions.allowed_cidrs`'s allow-all default is
// exactly the platform's unconfigured state), then to the type's zero
// value (the platform's report of an unconfigured feature).
const baseline = valueAtPath(defaults, path) ?? valueAtPath(getDefaultCliConfig(), path);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker (false clean): applyPushUnmanagedOmissions unconditionally drops auth.oauth_server from the local projection, but auth.oauth_server.enabled is a comparable registry path the API reports. Reproduced end-to-end: file declares [auth.oauth_server] enabled = true, remote reports oauth_server_enabled: false → local is silent, remote equals the raw default, suppressed here — and the command prints No config differences found. A declared local value that genuinely disagrees with the remote vanishes with no change entry, no masked note, no signal.

Suggestion (additive): a third bucket alongside maskedunmanaged: ReadonlyArray<string> for declared paths the local projection dropped — surfaced like the masked note ("N declared property(ies) cannot be pushed and were not compared: …"). The classifier already has both inputs it needs (declared + local silence).

Related, same line: the ?? valueAtPath(getDefaultCliConfig(), path) fallback is hard-coded, so the defaults option (which no call site currently passes) only controls one of the three baseline tiers. Either make the option the complete baseline or delete it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both adopted in 3f23ca8. ConfigChangeSet.unmanaged is the third bucket: declared comparable paths the local projection dropped (your auth.oauth_server.enabled repro, sentinel-pruned siblings, unselected SMS providers), surfaced like the masked note — text prints Note: 1 declared property cannot be pushed and was not compared: auth.oauth_server.enabled, and the machine message carries the caveat too. Your exact end-to-end repro is a pinned test. The defaults option is deleted — with no call site and only one of three tiers under its control it was dead optionality, and the tiering is now fully internal.

Comment thread packages/config/src/config-diff.ts Outdated
remote: remoteValue,
...(envVariable === undefined ? {} : { envVariable }),
}
: { path, class: "remote_only", local: undefined, remote: remoteValue },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major: on an undeclared path the classifier had the local effective value (the materialized default it just compared) and throws it away. The user sees:

api.max_rows [remote only]
  local:  (unset)
  remote: 250

…when the local effective value is 1000 and a config push would overwrite the remote 250 with it. That's the "someone changed it in the dashboard" case — the primary reason this command exists — and [remote only] reads as "key exists only remotely", which is false for anything with a schema default. It also collapses two states the future config pull consumer needs distinguished (file-silent vs materialized-default-disagrees).

Suggestion: keep local populated and add readonly declared: boolean to ConfigChange (already computed above); render local: 1000 (schema default — not declared in config.toml).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 3f23ca8: local stays populated with the materialized default and ConfigChange carries declared: boolean; text renders local: 1000 (schema default — not declared in config.toml) (pinned integration test with your exact max_rows: 250 scenario), and the JSON payload carries declared per change so the future config pull consumer gets the file-silent vs default-disagrees distinction.

]),
Command.withHandler((flags) =>
legacyConfigDiff(flags).pipe(
withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safeFlags: ["project-ref"] logs the ref verbatim while config push (same family, same flag) redacts it, and the established safe list in apps/cli/CLAUDE.md doesn't include the config family. Telemetry drift is silent and breaks dashboards — either drop this or add it to config push and extend the documented list in the same change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fba78b7, taking your "add it to config push and extend the documented list" option — with one wrinkle the flag reshape introduced: diff's --project-ref now accepts branch names, so it uses link's ref-shape-gated guard (verbatim only when PROJECT_REF_PATTERN matches; a user-created branch name never reaches PostHog). Push's ref-only flag logs verbatim unconditionally. The documented safe list in apps/cli/CLAUDE.md now names the config family and the branch-accepting guard rule.

"LegacyConfigDiffReadStatusError",
)<StatusErrorArgs> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return statusCodeActionability(this.status);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/v2/projects/{ref}/config names a user-selected resource, so a 404 means "wrong project ref" — user-actionable. Without { notFoundIsInvalidInput: true } (which LegacyConfigDiffBranchResolveStatusError above and all 11 push.errors.ts status errors on ref-addressed routes pass), this classifies a 404 as an external-service problem and skews the KPI split.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 27b62ef: LegacyConfigDiffReadStatusError passes { notFoundIsInvalidInput: true }, matching the branch-resolve error and the ref-addressed push.errors.ts convention, with a comment stating the rule.

push-gated omissions), not necessarily the file's literal spelling.
- **Partial responses:** a managed property the response does not carry is `local_only`
when the file declares it and silent otherwise; a missing block is called out on the
scope line rather than treated as an error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This claim (and ADR 0022's "partially-populated responses degrade … instead of an error") doesn't hold: V2GetProjectConfigOutput makes all six blocks — and their keys — required, so a missing block or key fails the typed decode inside the API client before any of this leniency runs. Confirmed live: staging doesn't return storage.database_pool_mode yet, and the command hard-fails on every invocation with

failed to read project config: SchemaError(Missing key
  at ["data"]["attributes"]["storage"]["database_pool_mode"])

— i.e. the command is currently broken against staging, and a permission-truncated response (the case the ADR names) surfaces as an opaque SchemaError. Consequences: the scope line's "(not returned: …)" branch is unreachable in production (it prints the constant six-block list on every run), and two diff.format.unit.test.ts cases exercise unreachable states.

Pick one: loosen the contract (make blocks/keys optional, matching auth's leniency — the stated intent) and keep the scope machinery, or delete the scope machinery and correct this doc + ADR 0022. Don't leave the doc asserting behaviour the contract forbids.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the first option in a32ada7: the contract is loosened rather than the scope machinery deleted. All 13 object-level required arrays under data.attributes (the six blocks and their nested containers) are relaxed through the established openapi-overrides.json test+replace mechanism — the same pattern as the SAML attribute_mapping and custom-hostname entries — so your staging failure shape (missing storage.database_pool_mode) and permission-truncated blocks decode and degrade as ADR 0022 intended. @supabase/config's lenient mirror was already optional-everywhere, so only the generated side moved. A new packages/api client test pins the exact staging shape decoding, and the two formerly-unreachable scope-line states are now real (and empty blocks count as not-returned — see the scope thread). The envelope (data/type/id/attributes) and array-item shapes stay strict: a partial response omits fields, not halves of array elements.

Comment thread apps/cli/docs/go-cli-divergences.md Outdated
| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request <route> [--method <METHOD>]`. |
| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. |
| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. |
| `config diff` | [`../src/legacy/commands/config/diff/diff.command.ts`](../src/legacy/commands/config/diff/diff.command.ts) | Read-only drift report between `supabase/config.toml` and `GET /v2/projects/{ref}/config` (CLI-2156). TS-only: the old Go CLI had no config diff. `--target` accepts a branch name/UUID/ref; `--exit-code` exits 1 on drift. Rejects the Go-compat `-o/--output` flag outright — machine output is `--output-format json\|stream-json` only (no Go parity contract for net-new commands, per the CLI-2156 discussion). Comparison core lives in `@supabase/config` (ADR 0022). |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apps/cli/CLAUDE.md declares this file "a frozen historical record … Do not add new entries — new flags and features are simply new CLI behavior." This row was flagged independently by every review pass. Suggest dropping it (the -o rationale already lives in SIDE_EFFECTS.md and the error text; ADR 0022 is the right home for the decision), which also undoes the table reflow that turned a 1-line change into a 15-line diff. SIDE_EFFECTS.md:9 points here too and should stop.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped in 04a174a by restoring the file to develop's version outright, which also undoes the table reflow. The -o rationale is moot now that the flag is honored (see that thread); the surviving decisions live in SIDE_EFFECTS.md and ADR 0022 (now accepted, a4dc035).

default_pool_size: 20,
max_client_conn: 100,
},
auth: {},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This auth: {} is the structural blind spot that let both classifier blockers through: v2 attributes.auth is an open record, so an empty object is schema-valid — meaning the entire GoTrue mapping surface (~200 keys; all the duration/inversion/sentinel logic) is never exercised end-to-end, and the fixture docstring's "an empty config.toml diffs clean" claim is only true because auth is empty.

Highest-value test additions:

  1. A realistic all-defaults auth block in this fixture, asserting a fresh config diffs clean (this is where the "0s" suppression bug lives).
  2. The same cleanliness assertion in diff.live.test.ts — its own comment names "the GoTrue-keyed auth record … classifying cleanly" as the one thing mocks can't prove, then asserts only exitCode === 0.
  3. A not.toContain(<secret>) assertion on the masking scenario (it already seeds GITHUB_SECRET=shh and an HMAC-shaped remote value without asserting absence), so the "secrets never leak" claim is pinned against formatter changes.
  4. Telemetry-flush assertions on the -o, flag-conflict, and branch-404 paths (currently they'd fail).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four adopted in 977e300 (with the fixture landing on the classifier fixes from 3f23ca8):

  1. The shared fixture now carries a ~65-key realistic fresh-project auth record at platform defaults — sessions zeros, all 13 subjects, notification toggles, durations, inversions — and the existing clean-config test proves the whole surface classifies cleanly against an empty config.toml.
  2. diff.live.test.ts asserts no auth. change lines on a fresh project, not just exit 0.
  3. The masking scenario now asserts not.toContain for both the resolved plaintext and an HMAC-shaped remote digest, on both streams, in text and JSON modes (including the payload serialization).
  4. Telemetry-flush assertions cover the failure paths that still exist after the -o rejection and flag conflict were retired: branch 404, missing config, and malformed TOML with a branch target (the last also pinning zero API requests, per the read-before-network fix).

kanadgupta and others added 7 commits August 31, 2026 14:13
The generated contract required every block and block key of
V2ProjectConfigResponse, so a platform that reports a subset — staging
predates storage.database_pool_mode; a permission-truncated response can
omit whole blocks — failed the typed decode inside the API client before
any consumer-side leniency could run. config diff hard-failed on every
staging invocation with a SchemaError, and the documented "partially
populated responses degrade, never error" behavior (ADR 0022) was
unreachable.

Relax all 13 object-level required arrays under data.attributes through
the established openapi-overrides.json mechanism (test+replace pairs,
same pattern as the SAML attribute_mapping and custom-hostname entries).
The envelope (data/type/id/attributes) and array-item shapes stay
strict: a partial response omits fields, not halves of array elements.
@supabase/config's lenient mirror (ProjectConfigApiAttributes) already
modeled every block as optional, so only the drift-guard test needed
NonNullable on the generated side. A new client test pins the exact
staging shape (missing storage.database_pool_mode and whole blocks)
decoding successfully.

Addresses PR #6295 review (Coly010): SIDE_EFFECTS.md contract thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four classifier fixes from the PR #6295 review, all expressed as registry
row knowledge instead of type-level inference:

- Array equality is per-field wire semantics: rows gain `arrayEquality`,
  defaulting to SEQUENCE (api.schemas' first entry is PostgREST's default
  schema; api.extra_search_path is a literal search_path), with
  auth.additional_redirect_urls opting into set semantics. Local
  ["public","extensions"] vs remote "extensions,public" now registers as
  drift instead of exiting 0.
- remote_only suppression no longer infers "unconfigured" from JS zeros —
  canonicalization turns GoTrue's sessions_timebox: 0 into the string
  "0s", which escaped the zero check and flagged every untouched project.
  Rows now declare the platform's `unconfiguredValue` (sessions "0s", the
  13 provisioning-default mailer subjects pinned by the recorded
  config_auth fixtures, notification toggles false per supabase/auth's
  defaults); with no baseline at any tier the value is reported rather
  than guessed. A registry-driven test walks every comparable path whose
  config-side baseline is undefined and pins the choice.
- A declared path the local projection drops (auth.oauth_server, disabled
  storage.analytics/vector, sentinel-pruned siblings, …) surfaces in a new
  `unmanaged` bucket — rendered like the masked note — instead of printing
  a false "No config differences found" while the file disagrees with the
  remote.
- ConfigChange paths are segment arrays end-to-end (a test_otp phone key
  containing "." previously round-tripped to undefined and the drift was
  silently dropped); joining is display-only in diff.format.ts. The JSON
  payload emits paths as arrays for the same reason.

Structural cleanups riding along: remote_only entries keep the
materialized local default plus a `declared` flag (text mode renders
"1000 (schema default — not declared in config.toml)"); the dead
`defaults` option is deleted; DiffProjectConfigOptions takes the loaded
{config, document, valueOrigins} pair so the projection and declared set
cannot come from different loads (env references derive from the same
pair — CliConfigValueOrigin.envVariables is now a list, never a
comma-joined string); counts are computed once in the package and carry
`total`. The handler keeps ProjectConfigParseError in the typed channel
for both normalizer calls, preserving its suggestion and its
purpose-built actionability adapter instead of mislabeling response
problems as network errors.

Addresses PR #6295 review (Coly010): array-equality, zero-suppression,
oauth_server false-clean, remote_only local-nulling, dotted-path, counts,
paired-operands, and response-decode threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two flag-surface fixes from the PR #6295 review:

- `Flag.boolean("exit-code")` without `Flag.withDefault(false)` is a
  REQUIRED flag, so plain `supabase config diff` — the help's own first
  example — failed with `required flag(s) "exit-code" not set`. The
  integration suite hands the handler a pre-built flags object and never
  parses, so a new diff.e2e.test.ts pins the parser at the subprocess
  boundary.
- The global `-o/--output` flag was rejected outright, violating Legacy
  Shell Invariant #6 ("both --output and --output-format must be
  honored"). It is now honored with --output taking priority, following
  the backups/list pattern: `-o json|yaml|toml|env` encode the same
  structured payload the --output-format json envelope carries through
  the shared encoders, `pretty` falls through to the text renderer, and
  stdout stays payload-pure (root.ts already swaps in the quiet-progress
  layer for machine formats). This also retires the rejection error, its
  three papercuts (help advertising a flag the handler killed, the
  unactionable --debug suggestion, the missing suggestion field), and the
  entry the review flagged in the frozen go-cli-divergences.md record —
  that file is restored to develop's version, undoing the table reflow.

Addresses PR #6295 review (Coly010 blockers/threads; Codex P1s): the
required exit-code flag, -o handling, and the frozen-record row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…project-ref

`--target` re-invented vocabulary `link` already settled (CLI-2167): it
was a strict superset of `--project-ref`, the two were mutually
exclusive, and its description omitted the 20-lowercase-letters rule, so
a branch named like a ref silently resolved as a project. The command
now has one flag — `--project-ref` accepting a project ref or the name
(or UUID) of a branch of the linked project, with link's exact
description sentence — keeping diff flag-compatible with config push and
retiring the conflict error.

The resolution pipeline is restructured around it:

- The local config is loaded and validated BEFORE any network call: a
  fresh directory gets `supabase init` instead of the resolver's
  not-linked error, and a malformed TOML no longer burns a
  branch-resolution round trip. Configs declaring [remotes.*] reload
  once the target ref is known so the overlay stays keyed by the
  RESOLVED ref; remotes-free configs load exactly once.
- The parent project ref is passed to the branch resolver lazily and
  evaluated only for branch-NAME lookups, so a UUID --project-ref works
  in an unlinked directory (`GET /v1/branches/{id}` needs no parent).
- Branch resolution runs under an output.task, matching the config
  fetch's own progress treatment.
- A UUID target echoes as `branch <uuid> (project ref <ref>)` instead of
  being quoted as if it were a display name.
- Telemetry now flushes on EVERY invocation (Legacy Shell Invariant #1)
  — load failures and branch-resolution failures included — while the
  linked-project cache write fires exactly when a ref resolved.

Telemetry alignment rides along: diff logs `--project-ref` verbatim only
when ref-shaped (link's guard — a branch name must never reach PostHog),
config push gains the same-family safe logging its ref-only flag always
qualified for, and the documented safe list in apps/cli/CLAUDE.md now
names the config family and the branch-accepting guard rule.

Addresses PR #6295 review (Coly010: --target vocabulary, safeFlags
drift, TOML-before-network, telemetry-flush threads; Codex: UUID-without-
link, branch-resolution progress, pre-resolution telemetry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The remaining handler/formatter fixes from the PR #6295 review:

- --workdir is honored: config loading resolves against
  cliSettings.workdir (the same root the project-ref resolver and
  linked-project cache use) instead of process.cwd(), so
  `config diff --workdir ../other` compares ../other's config.toml
  against ../other's linked project. config push shared the bug and gets
  the same fix.
- --exit-code drift exits 2, with 1 reserved for errors (terraform
  plan -detailed-exitcode's convention) — `config diff --exit-code ||
  alert` no longer fires on an expired token.
- Text output is injection-safe: every non-constant string (path
  segments — [remotes.*] names and sms.test_otp keys are unconstrained
  TOML keys —, env-var names, branch/remote names, the project ref) goes
  through legacySanitizeInlineName, so a hostile name can no longer emit
  raw ANSI or forge a "No config differences found." line. Pinned by an
  integration test with an ESC-carrying remotes name.
- The machine payload is contract-clean: `schema_version` is now an
  integer version of the payload shape itself (1) with the user's
  `$schema` URL moved to `config_schema`; `scope` is `{present, missing}`
  with the block set owned by @supabase/config (exported
  projectConfigApiBlockKeys, derived from its response mirror) instead of
  hand-copied; an EMPTY block record counts as not-returned, so a
  permission-truncated `auth: {}` can't be claimed compared while 38 auth
  keys print local-only; and the json/stream-json message carries the
  masked/unmanaged caveats so echoing it never reports "in sync" on a
  project whose SMTP password may have drifted.
- A 404 from /v2/projects/{ref}/config classifies as invalid input (the
  ref names a user-selected resource), matching the branch-resolve error
  and the ref-addressed push.errors.ts convention.
- One spelling per concept: labels and summary both say remote-only /
  local-only (JSON keeps snake_case remote_only), and counts pluralize
  properly now that they're known at render time.

Addresses PR #6295 review (Coly010: workdir, exit-code conflation, ANSI
injection, schema_version, scope machinery, JSON message caveat, naming
threads).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The review traced every blocker it found to a hole in the test suite's
structure; this closes them:

- The shared v2 fixture's `auth: {}` meant the largest, most
  transform-heavy mapping surface (~200 GoTrue keys: durations,
  inversions, unconfigured sentinels, provisioning-default subjects)
  never ran end to end. It now carries a realistic fresh-project auth
  record at platform defaults, and the existing clean-config test proves
  the whole surface classifies cleanly against an empty config.toml. The
  empty-block case keeps its own test pinning the "(not returned: auth)"
  scope report.
- diff.live.test.ts asserted only exit 0 while its own comment named
  auth-record cleanliness as the one thing mocks can't prove — it now
  asserts no `auth.` change lines on a fresh project.
- The masking scenario seeded a secret and an HMAC-shaped remote value
  without asserting ABSENCE; both streams (and the JSON payload +
  message) now pin `not.toContain(<secret>)` against formatter changes.
- Failure paths assert telemetry flushed (branch 404, missing config,
  malformed TOML with a branch target — the last also pinning zero API
  requests) and that the linked-project cache stays untouched when no
  ref resolved.

Addresses PR #6295 review (Coly010): the test-suite-structure section
and the four inline test-addition asks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add the docs-site overlay (docs/supabase/config/diff.md, the published
  reference page config push already had) covering branch targeting, the
  update/remote-only/local-only classes, (unset) vs (not returned),
  masking and unpushable-declared notes, the convergence-projection
  rendering ("1m" renders as "1m0s"), the 0/1/2 exit-code contract, and
  the machine output modes.
- ADR 0022 moves to accepted and records the review-driven revisions its
  body previously contradicted or omitted: the unconfiguredValue baseline
  tier replacing type-level zero inference, registry-declared array
  equality (sequence default), the unmanaged bucket, segment-array paths
  with declared and materialized-local on change entries, the
  branch-accepting --project-ref surface, honoring -o per Legacy Shell
  Invariant #6, the 0/1/2 exit codes, the versioned payload contract, and
  the relaxed response contract behind the scope note.

Addresses PR #6295 review (Coly010): docs-overlay and ADR-status threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T20:30:46.996725Z a4dc035 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4dc035bac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +266 to +267
if (flags.exitCode && changeSet.counts.total > 0) {
yield* processControl.setExitCode(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve drift exit semantics in telemetry

When config diff --exit-code finds drift, this sets ProcessControl to 2, but withLegacyCommandAnalyticsImplementation currently collapses every nonzero process-controlled status to exit_code: 1 and attaches the generic ProcessControlledFailure classification. Consequently, an expected drift result is reported as an unknown CLI failure, corrupting command-success and error-category dashboards; teach the instrumentation about config diff's intentional status 2 instead of routing it through the generic failure fallback.

AGENTS.md reference: apps/cli/AGENTS.md:L308-L312

Useful? React with 👍 / 👎.

Comment on lines +238 to +240
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate values before comparing sets

When a field using arrayEquality: "set" contains duplicate entries on only one side, the early length check reports drift even though the memberships are identical. For example, local auth.additional_redirect_urls = ["https://a", "https://a"] and remote ["https://a"] compare unequal despite this field being explicitly modeled as membership-only; deduplicate the canonicalized arrays before checking their sizes and elements.

Useful? React with 👍 / 👎.

Comment on lines +104 to +107
// resolver and the linked-project cache use — so `--workdir ../other`
// pushes `../other`'s config.toml, never the invoking directory's file to
// another root's linked project.
const projectRoot = (yield* findCliProjectRoot(cliSettings.workdir)) ?? cliSettings.workdir;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent config push from climbing above an explicit workdir

When --workdir or SUPABASE_WORKDIR names a config-less directory beneath another project, cliSettings.workdir is already the authoritative directory, but findCliProjectRoot climbs to the ancestor project and the later loadCliConfig call searches upward again. config push can therefore read the ancestor's .env and config and send those settings to the explicitly resolved remote instead of failing for a missing config; use the resolved workdir directly and load with ancestor searching disabled.

AGENTS.md reference: apps/cli/AGENTS.md:L272-L279

Useful? React with 👍 / 👎.

Comment on lines +85 to +90
Effect.catchTag(
"CliConfigParseError",
(cause) =>
new LegacyConfigDiffLoadConfigError({
message: `failed to parse supabase/config.toml: ${String(cause.cause)}`,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report parse failures using the selected config filename

When a project contains supabase/config.json—or both files, in which case the loader selects JSON—a malformed JSON document is caught here but reported as a failure to parse supabase/config.toml. This directs the user to edit a file that was not read and obscures which supported config document failed; construct the message from the CliConfigParseError path or format instead of hard-coding TOML.

AGENTS.md reference: AGENTS.md:L50-L53

Useful? React with 👍 / 👎.

Comment on lines +35 to +39
// Unlike `config diff`'s branch-accepting flag, push's `--project-ref`
// is ref-only, so its value is always safe to log verbatim — keeping
// the config family's telemetry consistent (documented safe list in
// apps/cli/CLAUDE.md).
withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact invalid project-ref values from telemetry

When config push --project-ref <value> receives anything other than a valid 20-letter ref, the flag parser still accepts the string and LegacyProjectRefResolver rejects it only inside the wrapped handler. Because this unconditionally marks the flag safe, the post-run cli_command_executed event contains the arbitrary invalid value verbatim rather than "<redacted>", allowing a mistyped branch name, token, or other user data to be sent to PostHog; only add project-ref to safeFlags after validating it with PROJECT_REF_PATTERN, as config diff already does.

AGENTS.md reference: apps/cli/AGENTS.md:L313-L313

Useful? React with 👍 / 👎.

@kanadgupta

Copy link
Copy Markdown
Member Author

Thank you for the adversarial review — every blocker reproduced against running code made this round very direct to act on. All feedback is addressed in seven follow-up commits (no force pushes):

Commit What it carries
a32ada7 fix(api) Relaxes all 13 object-level required arrays under V2ProjectConfigResponse.data.attributes via the established openapi-overrides.json mechanism, so the staging shape (missing storage.database_pool_mode) and permission-truncated responses decode instead of hard-failing. Pinned by a client test with the exact staging shape.
3f23ca8 fix(config) The classifier cluster, expressed as registry row knowledge: arrayEquality: "set" | "sequence" (sequence default; additional_redirect_urls opts into set), unconfiguredValue replacing JS-zero inference (sessions "0s", the 13 provisioning-default subjects — values pinned from the recorded config_auth fixtures in this repo —, notification toggles false), the unmanaged bucket for declared-but-unpushable paths, segment-array paths end to end, remote_only keeping the materialized local + declared, counts computed once with total, the defaults option deleted, DiffProjectConfigOptions taking the loaded pair, envVariables as a list, and ProjectConfigParseError kept in the typed channel for both normalizer calls.
04a174a fix(cli) --exit-code gets Flag.withDefault(false) (plus a parser-level e2e pin), and -o/--output is now honored per Legacy Shell Invariant #6 instead of rejected — which also retires the rejection error, its three papercuts, and the frozen-record row (file restored to develop's version, undoing the reflow).
fba78b7 refactor(cli) --target folded into a branch-accepting --project-ref (link's vocabulary, with the 20-lowercase-letters sentence); config loaded and validated before any network call with the [remotes.*] overlay re-applied post-resolution; UUID targets resolve without a linked parent; branch resolution under an output.task; UUID echoed as an identifier, not a quoted name; telemetry flush outermost with the cache write gated on a resolved ref; safeFlags aligned across the config family (ref-shape-gated on diff, plain on push, documented list extended).
27b62ef fix(cli) --workdir honored (diff and push), drift exits 2 with 1 reserved for errors, legacySanitizeInlineName on every non-constant string in text output, schema_version: 1 (integer payload contract) + config_schema (the $schema URL), scope: {present, missing} with the block set exported from @supabase/config and empty records counting as not-returned, the masked/unmanaged caveats carried in the machine message, 404 on the config route classified as invalid input, and one spelling per concept with real pluralization.
977e300 test(cli) The structural blind spots: a realistic fresh-project auth record in the shared fixture (clean end to end), the live test asserting auth-record cleanliness rather than just exit 0, not.toContain(<secret>) on both streams and the machine payload, and telemetry-flush/no-cache assertions on the failure paths.
a4dc035 docs(cli) The docs-site overlay (docs/supabase/config/diff.md) and ADR 0022 → accepted, with the review-driven decisions recorded (including the three it was silent on — all three changed in this round).

Verification: pnpm check:all green (12/12 tasks), full CLI unit (5433) + integration (3423) suites green, packages/api/packages/config suites green, and the targeted diff.e2e.test.ts green against the built binary. I couldn't run the live suite from this environment (no staging credentials here) — the staging repro is covered by the new contract test with your exact database_pool_mode shape, and the live test now asserts the auth-record cleanliness your review flagged.

One deliberate non-change, called out inline as well: the linked-project cache write for an explicit --project-ref in an unlinked directory is pre-existing family-wide behavior (backups/list, config push both Effect.ensuring(cache(ref)) with whatever ref resolved; the cache itself is fire-and-forget and only fills an absent file). Diverging in one command felt worse than the status quo — happy to open a separate issue on the family semantics if you think it should change.

Also worth noting: the three storage.* remote-only entries from your staging run are, as far as I can tell, genuine plan-provisioned state (image_transformation.enabled: true etc. on the platform vs the schema's false default) — a config push of a default file would genuinely try to change them, so they report by design (ADR 0022's "platform defaults that diverge from schema defaults surface as drift"). The row-level unconfiguredValue mechanism is there if a plan-independent unconfigured value emerges for them.

@kanadgupta
kanadgupta requested a review from Coly010 August 31, 2026 23:30
@Coly010

Coly010 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

/ai-review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded by a newer AI review

🤖 AI Review

Both independent reviews completed. After code and convention verification, all 13 deduplicated findings are confirmed: one critical telemetry privacy issue, one major branch-resolution bug, six minor concerns, and five nits.

Findings

Severity Location Category Sources Claim
🔴 CRITICAL apps/cli/src/legacy/commands/config/push/push.command.ts:39 telemetry-privacy claude+codex config push sends any supplied --project-ref string to telemetry verbatim, including invalid values that may contain user data or secrets.
🟠 MAJOR apps/cli/src/legacy/commands/config/diff/diff.handler.ts:137 correctness claude Branch-name resolution uses the linked branch ref as the parent, causing config diff --project-ref <branch-name> to fail after linking directly to a branch.
🟡 MINOR apps/cli/src/legacy/commands/config/diff/diff.handler.ts:171 user-experience claude Configs containing [remotes.*] are loaded twice, duplicating load-time deprecation warnings.
🟡 MINOR apps/cli/src/legacy/commands/config/diff/diff.handler.ts:186 forward-compatibility codex The command passes the response through strict generated decoding before its lenient config projection, so new API enum values can fail before fromApiProjectConfig runs.
🟡 MINOR packages/config/src/config-diff.ts:239 correctness codex The set array mode actually implements multiset equality, producing drift when duplicate counts differ even though membership is identical.
🟡 MINOR apps/cli/src/legacy/commands/config/diff/diff.format.ts:236 api-contract codex The versioned payload always emits target.branch: null, although its documented contract says the field is optional.
🟡 MINOR packages/config/src/config-diff.unit.test.ts:438 code-quality codex The TypeScript test source contains a literal NUL byte.
🟡 MINOR apps/cli/src/legacy/commands/config/push/push.integration.test.ts:426 test-coverage claude The subdirectory config-push test no longer exercises subdirectory discovery because it changes only the mocked runtime cwd while the handler reads cliSettings.workdir.
⚪ NIT apps/cli/src/legacy/shared/legacy-db-target-flags.ts:149 dead-code claude target is a stale value-consuming flag registration even though no legacy command declares --target.
⚪ NIT apps/cli/src/legacy/commands/config/diff/diff.e2e.test.ts:18 test-cleanup claude+codex The e2e test leaks its temporary working directory.
⚪ NIT apps/cli/src/legacy/commands/config/diff/diff.format.ts:7 module-boundaries claude The pure formatter imports a regex from a service-bearing resolver module, unnecessarily pulling Effect and API-service code into its dependency graph.
⚪ NIT apps/cli/src/legacy/commands/config/diff/diff.format.ts:81 dead-code claude LEGACY_CONFIG_DIFF_PAYLOAD_VERSION is exported despite having no external consumer.
⚪ NIT packages/api/scripts/openapi-overrides.json:629 diff-noise claude Two unrelated pre-existing comments were changed from literal em dashes to JSON Unicode escapes.

Findings outside the diff

  • 🟡 MINOR apps/cli/src/legacy/commands/config/push/push.integration.test.ts:426 — The subdirectory config-push test no longer exercises subdirectory discovery because it changes only the mocked runtime cwd while the handler reads cliSettings.workdir.

Stats

Claude findings: 9 · Codex findings: 6 · Confirmed: 13 · Refuted: 0 · Uncertain: 0


Models: claude-opus-5 + gpt-5.6-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

branch = target;
const resolving =
output.format === "text" ? yield* output.task("Resolving branch...") : undefined;
ref = yield* legacyResolveBranchProjectRef(target, resolver.resolve(Option.none()), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR · correctness · source: claude

Branch-name resolution uses the linked branch ref as the parent, causing config diff --project-ref <branch-name> to fail after linking directly to a branch.

Evidence: diff.handler.ts:137 passes resolver.resolve(Option.none()); legacy-project-ref.layer.ts:87-93 resolves that from .temp/project-ref, while legacy-parent-project-ref.ts:155-181 documents that this file contains the branch ref after branch linking and parent-scoped endpoints reject it. legacy-branch-ref.resolver.ts:67-70 then sends it to the parent-scoped branch lookup.

Suggested fix: Pass legacyResolveParentScopedProjectRef(Option.none()) as the lazy parent-ref effect and add a test where .temp/project-ref contains a branch ref.

Comment on lines +171 to +172
if (isRecord(loaded.document?.["remotes"])) {
loaded = yield* loadLocalConfig(ref);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · user-experience · source: claude

Configs containing [remotes.*] are loaded twice, duplicating load-time deprecation warnings.

Evidence: diff.handler.ts:121 loads without a target and lines 171-172 reload after resolution. packages/config/src/io.ts:505-510 and 603-609 print warnings on every load without deduplication.

Suggested fix: Apply the remote overlay without rereading, or suppress/deduplicate diagnostics during the second load.

"status",
"sub",
"swift-access-control",
"target",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · dead-code · source: claude

target is a stale value-consuming flag registration even though no legacy command declares --target.

Evidence: legacy-db-target-flags.ts:149 contains target; the command surface instead declares --project-ref at diff.command.ts:14, and the repository has no Flag.*("target") declaration. The completeness test at legacy-db-target-flags.unit.test.ts:262-275 checks only for missing registrations.

Suggested fix: Remove target and optionally make the completeness test detect stale registrations.

// the subprocess boundary. The invocation is expected to fail LATER (no
// linked project in this hermetic cwd/HOME) — the assertion is only that
// it gets past the parser.
const cwd = await mkdtemp(join(tmpdir(), "supabase-config-diff-e2e-"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · test-cleanup · source: claude+codex

The e2e test leaks its temporary working directory.

Evidence: diff.e2e.test.ts:18 creates the directory with mkdtemp; the test ends at line 26 without any removal or cleanup hook.

Suggested fix: Remove the directory in a finally block or use a scoped temporary-directory helper.

projectConfigApiBlockKeys,
} from "@supabase/config";

import { LEGACY_BRANCH_UUID_PATTERN } from "../../../shared/legacy-branch-ref.resolver.ts";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · module-boundaries · source: claude

The pure formatter imports a regex from a service-bearing resolver module, unnecessarily pulling Effect and API-service code into its dependency graph.

Evidence: diff.format.ts:7 imports LEGACY_BRANCH_UUID_PATTERN from legacy-branch-ref.resolver.ts, whose lines 1-4 import Effect and LegacyPlatformApi, despite diff.format.ts:10-12 describing the formatter as containing no Effect or services.

Suggested fix: Move the shared project-ref and UUID patterns into a small service-free module.

Comment on lines +629 to +659
@@ -656,7 +656,7 @@
{
"op": "remove",
"path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints",
"$comment": "CLI-2157: the platform's v2 spec gives all 10 org-webhook operations the shared operationId \"allV2OrganizationsBySlugWebhooks\" (and all 10 project-webhook operations share \"allV2ProjectsByRefWebhooks\") duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist."
"$comment": "CLI-2157: the platform's v2 spec gives all 10 org-webhook operations the shared operationId \"allV2OrganizationsBySlugWebhooks\" (and all 10 project-webhook operations share \"allV2ProjectsByRefWebhooks\") \u2014 duplicated and not version-prefixed, which breaks codegen. Remove-if-present because staging's v2-json is currently served by two backend variants that disagree on whether these paths exist."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT · diff-noise · source: claude

Two unrelated pre-existing comments were changed from literal em dashes to JSON Unicode escapes.

Evidence: pr.diff changes only to \u2014 in the CLI-2157 comments at openapi-overrides.json:629 and 659; the substantive response-contract overrides are elsewhere.

Suggested fix: Restore the literal em dashes to keep the diff scoped to the intended overrides.

// 4. Fetch the effective remote config (single read-only call).
const fetching =
output.format === "text" ? yield* output.task("Fetching remote config...") : undefined;
const response = yield* api.v2.getProjectConfig({ ref }).pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · forward-compatibility · source: codex

The command passes the response through strict generated decoding before its lenient config projection, so new API enum values can fail before fromApiProjectConfig runs.

Evidence: diff.handler.ts:186 calls generated api.v2.getProjectConfig; effect-client.ts:2509-2521 uses decoded client.execute, and contracts.ts:11135 restricts pool_mode to three literals. Trusted ADR 0019 lines 51-67 and 120-128 explicitly require executeRaw before lenient decoding.

Suggested fix: Use executeRaw, handle non-2xx status explicitly, and pass the raw JSON to fromApiProjectConfig.

Comment on lines +239 to +248
if (a.length !== b.length) {
return false;
}
const left = a.map(canonicalArrayElement);
const right = b.map(canonicalArrayElement);
if (arrayEquality === "set") {
left.sort();
right.sort();
}
return left.every((element, index) => element === right[index]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · correctness · source: codex

The set array mode actually implements multiset equality, producing drift when duplicate counts differ even though membership is identical.

Evidence: config-diff.ts:239 rejects unequal lengths and lines 244-248 sort without deduplicating. registry-auth.ts:738-741 describes auth.additional_redirect_urls as membership-only, while config-diff.unit.test.ts:455-458 currently pins duplicate-sensitive behavior.

Suggested fix: Canonicalize set-mode arrays through a Set before comparing and add duplicate-membership coverage.

config_schema: context.configSchema,
target: {
project_ref: context.projectRef,
...valueEntry("branch", context.branch),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · api-contract · source: codex

The versioned payload always emits target.branch: null, although its documented contract says the field is optional.

Evidence: diff.format.ts:224-226 converts undefined to null and line 236 always spreads the resulting branch property. SIDE_EFFECTS.md:89-95 documents target.branch as optional.

Suggested fix: Omit branch when no branch was selected, or document it as always present and nullable before publishing schema version 1.

{ api: { max_rows: 5 }, auth: { site_url: "https://local.example.com" } },
{ api: { max_rows: 6 }, auth: {}, database: { postgres_settings: { work_mem: "64MB" } } },
);
const joined = result.changes.map((change) => change.path.join(""));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR · code-quality · source: codex

The TypeScript test source contains a literal NUL byte.

Evidence: config-diff.unit.test.ts:438 contains byte 0x00 between the quotes passed to join; file inspection classifies the source as data rather than text.

Suggested fix: Replace the literal byte with the escaped source spelling "\0" or "\u0000".

kanadgupta and others added 2 commits September 1, 2026 10:30
…iff-to-the-cli

Conflict resolution and adaptation for develop's CLI-2234 public-surface
trim + compiled build and the upstream Management API spec sync:

- packages/config/src/index.ts resolves to develop's trimmed public
  surface; the diff engine's exports (diffProjectConfig, the ConfigChange*
  types, projectConfigApiBlockKeys) land on the apps/cli-only ./internal
  subpath instead — the CLI is their only consumer today and the
  change-set shape will move again with config pull, so they stay out of
  the semver-covered contract. isEqualConfigValue leaves the barrels
  entirely (the package's own tests import the module file directly).
  entrypoint-purity snapshots regenerated accordingly.
- The generated API files (openapi.json, contracts.ts, effect-client.ts)
  and openapi-overrides.json are taken from develop WHOLESALE: upstream's
  spec sync renamed V2ProjectConfigResponse to V2ProjectConfigResponse_
  Output, so this branch's 26 contract-relaxation override entries now
  point at a schema that no longer exists, and the textual auto-merge of
  two large JSON rewrites is not trustworthy. The relaxation is not
  re-pointed but DROPPED (with its partial-decode client test and the
  drift-guard NonNullable adaptations): the immediate follow-up commit
  moves the config fetch to executeRaw per ADR 0019 rule 2, which makes
  the strict generated contract irrelevant to this command and restores
  the ADR's intended division — strict generated contract, leniency in
  @supabase/config's mirror.
- apps/cli's diff handler/formatter import the moved symbols from
  @supabase/config/internal, including the goViperCompat-typed
  loadCliConfig develop already routes config push through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The diff handler decoded the response through the generated typed client
before the lenient config projection ever saw it — ADR 0019 rule 2 calls
routing through executeRaw "required, not incidental": the generated
Schema.Struct decode drops excess properties and rejects unknown enum
members (pooler.pool_mode is three closed literals there), so a new
platform enum value failed the command before fromApiProjectConfig's
leniency could run, and a lenient decode layered on the strict output
would have nothing left to be lenient about.

The fetch now follows the established executeRaw pattern (projects/list):
the handler checks the status itself (non-200 maps to the existing
read-status error with a sanitized body; JSON-parse failures keep the
decode-flagged network error) and hands the raw envelope to
fromApiProjectConfig, which ADR 0019 built for exactly this input. A new
integration test pins the degradation: a novel pool_mode classifies
clean instead of failing the decode.

This supersedes the contract relaxation from the previous review round
(dropped in the merge commit): with the strict generated contract off
this command's code path, partial responses AND unknown enum values both
degrade through the one lenient mirror, matching ADR 0019's intended
division of labor.

Addresses PR #6295 AI review: the forward-compatibility finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kanadgupta and others added 2 commits September 1, 2026 10:42
Two findings from the PR #6295 AI review:

- CRITICAL telemetry-privacy: config push marked --project-ref safe
  unconditionally, but nothing validates the flag before instrumentation
  fires — an arbitrary string (a typo, a wrong-clipboard paste) reached
  PostHog verbatim. The wiring now gates the whitelist on
  PROJECT_REF_PATTERN, same as link and config diff, and is exported
  (legacyConfigPushHandler, link's precedent) so integration tests drive
  the exact Command.withHandler wiring: verbatim for a ref-shaped value,
  "<redacted>" otherwise. The context-merging analytics mock those tests
  need existed as three identical local copies (link, functions/download,
  the instrumentation unit test) — hoisted to tests/helpers/mocks.ts as
  mockContextualAnalytics per the hoist-before-you-duplicate rule.
- MAJOR correctness: config diff resolved the branch-name lookup's parent
  with resolver.resolve, which returns whatever .temp/project-ref holds —
  after `link <branch>` that is the BRANCH's own ref, which the
  parent-scoped branches endpoint rejects. The lazy parent now comes from
  legacyResolveParentScopedProjectRef (the branches family's resolver,
  which prefers the linked-project.json parent recovery), pinned by a
  test where project-ref holds a branch ref.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Configs declaring [remotes.*] only reload (for the resolved-ref
  overlay) when a remote's project_id actually MATCHES — checked on the
  already-loaded, env-interpolated document — so load-time deprecation
  warnings no longer repeat for every remotes-carrying config.
- Set-mode array equality is true membership equality (dedupe through a
  Set): a repeated redirect URL is the same allow list, not drift.
  Duplicate-membership coverage added.
- The machine payload omits target.branch when no branch was targeted,
  matching the documented optional contract, and the integration test now
  consumes LEGACY_CONFIG_DIFF_PAYLOAD_VERSION so the exported constant is
  the payload contract's single pinned source.
- The ref/UUID patterns move to a service-free legacy-ref-patterns.ts
  (re-exported by the resolver), so the pure formatter no longer pulls
  Effect and API-service code into its dependency graph.
- The stale `target` entry leaves the value-consuming flag registry (no
  legacy command declares --target anymore), the diff e2e test removes
  its temp directory in a finally, the push subdirectory test exercises
  workdir-based project-root discovery again (it varied the now-unused
  mocked cwd), and a literal NUL byte in the config-diff unit test source
  becomes the escaped backslash-u0000 spelling.

Addresses PR #6295 AI review: the six minor/nit findings not covered by
the preceding commits (the em-dash diff noise disappeared with the
override revert in the merge commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kanadgupta

Copy link
Copy Markdown
Member Author

Develop is merged in (merge commit 7c63ce697, no force pushes) and all 13 AI-review findings are addressed across three follow-up commits.

The merge, and one deliberate reversal. Develop's CLI-2234 surface trim moved the diff engine's exports (diffProjectConfig, the ConfigChange* types, projectConfigApiBlockKeys) onto the apps/cli-only ./internal subpath — they stay out of the semver-covered contract until config pull settles the shape (isEqualConfigValue left the barrels entirely; only the package's own tests consume it). The upstream spec sync also renamed V2ProjectConfigResponseV2ProjectConfigResponse_Output, stranding the 26 contract-relaxation override entries from the last review round — and rather than re-pointing them, the merge drops the relaxation entirely, because the AI review's forward-compatibility finding is right on the underlying architecture: ADR 0019 rule 2 says this fetch must go through executeRaw ("required, not incidental"), and 17254efdd now does exactly that, following the projects/list pattern. That supersedes the contract-loosening we settled on in the previous round — same goal, the ADR-designed mechanism: partial responses and unknown enum values (a new pool_mode) both degrade through @supabase/config's one lenient mirror, pinned by a new integration test, and the generated contract stays strict for typed-client consumers. The PR is ~800 lines smaller for it.

Finding → commit map:

Finding Commit
🔴 CRITICAL: push logs any --project-ref verbatim 44572340c — the whitelist is gated on PROJECT_REF_PATTERN (link/diff's guard); the wiring is exported (legacyConfigPushHandler, link's precedent) and both directions are pinned (verbatim ref / "<redacted>" arbitrary string). The context-merging analytics mock those tests need existed as three identical local copies — hoisted to tests/helpers/mocks.ts per the hoist rule.
🟠 MAJOR: branch-name resolution uses the linked branch ref as parent 44572340c — the lazy parent is now legacyResolveParentScopedProjectRef (the branches family's resolver), with a regression test where .temp/project-ref holds a branch ref and the lookup must hit the parent from linked-project.json.
🟡 strict decode before lenient projection 17254efddexecuteRaw per ADR 0019 (see above).
🟡 remotes configs loaded twice (duplicate warnings) b02b19487 — reload only when a remote's project_id actually matches the resolved ref, checked on the already-loaded document.
🟡 set mode is multiset b02b19487 — true membership equality via Set, duplicate-membership coverage added.
🟡 target.branch: null vs documented-optional b02b19487 — omitted when absent.
🟡 literal NUL byte in test source b02b19487 — escaped "\u0000" spelling.
🟡 push subdirectory test no longer exercises discovery b02b19487 — it varies cliSettings.workdir (what --workdir resolves to) instead of the now-unused mocked cwd.
⚪ stale target flag registration b02b19487 — removed.
⚪ e2e temp-dir leak b02b19487 — removed in a finally.
⚪ formatter imports a service-bearing module for a regex b02b19487 — patterns hoisted to a service-free legacy-ref-patterns.ts.
⚪ unused LEGACY_CONFIG_DIFF_PAYLOAD_VERSION export b02b19487 — consumed by the integration test asserting the payload's schema_version.
⚪ em-dash JSON escapes in the overrides resolved by the merge itself — the overrides file reverted to develop's version with the relaxation drop.

Verification: pnpm check:all green (13/13), full CLI unit (5532) + integration (3439) suites green, packages/config (1119) and packages/api suites green, targeted diff.e2e.test.ts green against the built binary.

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.

2 participants