Skip to content

feat(flags): feature flags Phase 1 — backend, CLI, SDK and console UI - #526

Merged
dviejokfs merged 14 commits into
mainfrom
feat/feature-flags
Aug 4, 2026
Merged

feat(flags): feature flags Phase 1 — backend, CLI, SDK and console UI#526
dviejokfs merged 14 commits into
mainfrom
feat/feature-flags

Conversation

@dviejokfs

@dviejokfs dviejokfs commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Phase 1 of ADR-034: set a flag value per environment and flip it without a redeploy. Backend, CLI, SDK and console UI.

No targeting rules or percentage rollouts yet — but the parts that are expensive to change once callers depend on them are fixed now.

Why

The only runtime knob a Temps user has today is an environment variable, and changing one triggers a redeploy. That's correct for secrets and wrong for a kill switch: by the time the container restarts, the incident has been running for two minutes.

What's in it

  • crates/temps-flags — new plugin crate: pure evaluator, service, 7 REST endpoints, OpenAPI, RBAC, audit logging
  • Two tables mirroring the env_vars / env_var_environments split
  • temps flags … in @temps-sdk/cli (full API parity)
  • FlagsClient inside the existing @temps-sdk/node-sdk — no new package
  • Console UI under the project, after Environment Variables
  • User docs + ADR

The design decision that matters

Resolution order is pinned now, with a reserved step for targeting:

1. flag missing/archived -> caller's fallback   FLAG_NOT_FOUND
2. enabled == false      -> default_value       DISABLED
3. -- reserved for rules --
4. environment value set -> that value          ENVIRONMENT_VALUE
5. otherwise             -> default_value       DEFAULT

Deciding later whether a rule outranks a blanket per-environment value would silently change what existing flags serve. And because the kill switch short-circuits ahead of targeting, enabled = false keeps meaning exactly "ignore everything, serve the default" once rules land.

bucket() ships tested but uncalled for the same reason: once one subject is inside a percentage rollout the algorithm can never change, so it's pinned with locked vectors before anything can depend on it.

Security

A security-auditor pass found a HIGH cross-project IDOR that I had introduced, and it is fixed in this branch.

project_access_guard! deliberately skips deployment tokens (they carry no user identity for a team-membership check) and delegates their confinement to project_scope_guard!. I had only called the former. A deployment token for project A could read project B's flags by changing the path id.

Reproduced against a live instance before the fix:

$ curl -H "Authorization: Bearer $PROJECT_1_TOKEN" $API/projects/2/flags
{"flags":[{"key":"victim.secret_experiment","default_value":"confidential-cohort", ...}]}
  http=200

After adding project_scope_guard! to all six project-scoped handlers:

--- project-1 token -> project 2 flags list ---
{"title":"Cross-Project Access Denied", ...}                       http=403
--- project-1 token -> project 2 single flag ---
{"title":"Cross-Project Access Denied", ...}                       http=403
--- project-1 token -> its OWN project (must still work) ---
  own project OK: total=25 returned=2                              http=200

Four regression tests cover it. Verified non-vacuous by removing the guard and confirming they fail:

test full_access_deployment_token_is_still_project_confined ... FAILED
test deployment_token_cannot_read_another_projects_flags ... FAILED

Also addressed from the audit:

  • feature_flags.salt is now #[serde(skip_serializing)] — a published salt lets a client pre-compute its own bucket and self-select into a rollout
  • The snapshot endpoint returns one error for both "no such environment" and "another project's environment", so it can't be used as an environment-id existence oracle
  • An unrecognised value type renders as text in the console, not as a switch

Other security properties, verified:

  • Deployment tokens are read-only for flags. FlagsRead bridges; FlagsWrite/FlagsDelete deliberately don't. Auto-minted tokens carry ["*"] and still can't write.
  • Snapshot scope comes from the token, never a path parameter.
  • Audit entries record old and new values on every mutation — a flag change is a production change with no deployment behind it.

Evidence

Validation

duplicate key                     -> 409 Feature Flag Already Exists
key "Checkout/V2"                 -> 400 first character 'C' must be a-z or 0-9
default_value "yes" on bool flag  -> 400 Value "yes" is not valid ... (field: default_value)

Kill switch preserves the override

set checkout.v2 = true        -> enabled=True  env_value=true
disable                       -> enabled=False env_value=true   (SDK resolves false, DISABLED)
re-enable                     -> enabled=True  env_value=true   (restores, not lost)

Pagination (25 flags)

no params        total=25 page=1 page_size=20  total_pages=2  returned=20
?page=2          total=25 page=2 page_size=20  total_pages=2  returned=5
?page_size=5&page=3                page_size=5 total_pages=5  returned=5
?page_size=10000 total=25 page=1 page_size=100 total_pages=1  returned=25   <- capped

total_pages uses the same clamp the query used, so it can never describe a page size the server didn't serve. Overrides still attach to the right flag across pages; the snapshot endpoint stays unpaginated because the SDK needs the whole set.

ETag

If-None-Match (unchanged)     -> 304
If-None-Match (after change)  -> 200

SDK against a live server

checkout.v2     -> false  { kind: "DISABLED" }        (kill switch: default served)
api.rate_limit  -> 5000   { kind: "ENVIRONMENT_VALUE" }
does.not.exist  -> my-fallback { kind: "FLAG_NOT_FOUND" }

Degraded paths never throw — no credentials / 401 / unreachable host all serve fallbacks, and a failed refresh keeps the last good snapshot (5000, not the fallback).

Migrationup on a clean database produces the expected schema; a dedicated test asserts up → down → up (the tables are FK-linked, so drop order matters and a half-applied rollback would strand an operator).

Console UI — created a JSON flag through the dialog and confirmed it server-side; toggled a flag and confirmed the write; verified desktop, mobile, light and dark.

Checks

cargo check --lib (workspace)                    clean
cargo test -p temps-flags                        51 passed
cargo test -p temps-auth -p temps-entities       339 passed
cargo clippy --all-targets -D warnings           clean
web: tsc --noEmit                                clean
web: eslint src/components/project/flags         0 errors

Notes for review

  • last_evaluated_at is SDK-reported per-flag exposure, not a snapshot-fetch stamp. Stamping on fetch would mark every flag freshly used the moment an instance boots, which is worse than having no column. POST /flags/exposure takes the keys an app actually evaluated; the SDK batches them on the refresh interval, only records keys present in the snapshot (so get('user-' + id) can't grow the set), ignores unknown keys silently (no existence oracle), and writes only the timestamp. Verified: reporting feature.f01 stamps only feature.f01, and a snapshot fetch stamps nothing. UI says "Last evaluated by an app" and renders Never distinctly.
  • resolveEffectiveValue() in the console is a third mirror of the evaluator, alongside Rust and the SDK. Commented as such; should collapse when the same-origin evaluation endpoint lands in Phase 2.
  • The @temps-sdk/node-sdk generated client was not regenerated — its checked-in spec is 242 paths stale and doing so would bury this change in unrelated churn. FlagsClient is hand-written and doesn't depend on it.
  • CLI docs regeneration picked up pre-existing drift for other commands, because the generator's hardcoded import list had never included several of them.

Review round 2 — findings addressed

A security pass and a Rust pass raised two blocking issues, two majors and a set of minors. All are fixed in ef0c222e / 1819d705.

Blocking — cross-environment read. An environment-scoped deployment token could read every environment's flag values via GET /projects/{id}/flags, because project_access_guard! is a documented no-op for tokens and the response carries all override rows. A preview container could therefore read production values. Fixed with deny_deployment_token! on list_flags/get_flag — a token's documented machine surface is /flags/snapshot + /flags/exposure, both of which honour its pinning. Verified live:

GET /projects/1/flags      -> 403  Deployment Token Not Allowed
GET /projects/1/flags/KEY  -> 403
GET /flags/snapshot        -> 200  (25 flags — legitimate route intact)
session GET flags          -> 200  (humans unaffected)

Blocking — unthrottled exposure writes. The "once a minute per instance" claim was a client-side promise the server never enforced. Now only stamps rows that are actually stale:

timestamp before repeat report: 2026-08-03 21:28:46.323963+00
timestamp after  repeat report: 2026-08-03 21:28:46.323963+00   (unchanged — no row rewritten)
never-seen flag:  NULL -> 2026-08-03 21:29:07.388093+00          (still stamps)
501 keys       -> 400 "reported 501 keys, maximum is 500 per batch"
unknown key    -> 200 recorded=1  (accepted count, not a match count)

That last line closes the existence oracle: recorded no longer echoes rows_affected.

Majors. From<DbErr> maps unique violations to DuplicateKey, so the create() race returns 409 rather than 500. Guard tests added for create_flag/update_flag/set_flag_environment — previously removing a guard from the endpoint that flips production broke nothing in CI.

One honest note: the cross-project case on the write handlers is unreachable defence-in-depth — permission_guard!(FlagsWrite) rejects deployment tokens before project_scope_guard! is consulted. A sentinel run confirmed removing the scope guard does not fail that test, so it is named and commented accordingly rather than implying it proves more than it does. The permission guard itself is covered (removing it fails two tests).

Minors. Snapshot filters environment inside the join instead of fetching all and discarding; update_flag 3 fetches → 1; archive() idempotent (it was overwriting the original archived_at) plus a restore path so an accidental archive is recoverable; migration indexes/FKs and the add-column idempotent, down() tolerates a partial up(); migration test asserts last_evaluated_at is present on feature_flags; tri-state PATCH deserializers tested; ETag handles weak validators and candidate lists; SDK chunks exposure, re-queues rejected batches, flushes on close; tokio/async-trait moved to dev-deps.

A follow-up self-check then caught a bug in the fix itself: is_unique_violation matched a bare "23505" anywhere in the driver error, and flag keys permit digits — a flag named 23505 could turn an unrelated failure into a 409 hiding a 500. Tightened in 1819d705 with a regression test.

temps-flags is now 76 tests (was 56). CI green on 1819d705.

Still open

  • R4.5 — production-environment writes are not separately gateable. Anyone with FlagsWrite can flip a prod flag. Decided and documented rather than fixed here: the fix belongs on the credential, not the resource. Deployment tokens already carry project_id/environment_id and are confined by is_scoped_to_project; API keys carry neither and are unconfined by construction. All six flag handlers already call project_scope_guard!, so flags are confined for free once API keys gain project scoping — environment scoping needs one new guard with a single flag call site. See "R4.5, decided" in the ADR.
  • ADR open questions 1 (crate boundary once evaluation runs in the proxy) and 2 (OSS vs plugin split) are unanswered.

Two tables mirroring the env_vars/env_var_environments split: the definition
is project-scoped, the value is overridden per environment.

Three columns ship without a Phase 1 reader because each is expensive or
impossible to introduce correctly later:

- salt: percentage bucketing must be stable from the first rollout, so a salt
  added after subjects are assigned would reshuffle every live experiment
- client_visible: defaults to false; flipping a security default later would
  expose server-only flags that predate the change
- rules: the evaluator reserves an ordered targeting step between the kill
  switch and the environment value, so writing rules later cannot change what
  an existing flag serves

salt is #[serde(skip_serializing)] so it cannot leak even if the entity is
ever returned directly - a published salt lets a client pre-compute its own
bucket and self-select into a rollout.

Includes an up/down/up reversibility test: the tables are FK-linked, so
dropping them in the wrong order fails, and a half-applied rollback would
leave an operator unable to migrate forward.
Phase 1 of ADR-034: set a flag value per environment and flip it without a
redeploy. No targeting rules yet, but the parts that are expensive to change
once callers depend on them are fixed now.

Resolution order is pinned, with a reserved step for targeting:

  1. flag missing/archived -> caller's fallback   FLAG_NOT_FOUND
  2. enabled == false      -> default_value       DISABLED
  3. -- reserved for rules --
  4. environment value set -> that value          ENVIRONMENT_VALUE
  5. otherwise             -> default_value       DEFAULT

Deciding later whether a rule outranks a blanket per-environment value would
silently change what existing flags serve, so it is decided now. The kill
switch short-circuits ahead of targeting, so `enabled = false` keeps meaning
exactly "ignore everything, serve the default" once rules land.

evaluate() is pure, synchronous and total: it returns a usable value for every
input and never panics. A stored value that no longer matches the flag's type
degrades to the default rather than failing the caller's request.

bucket() ships tested but uncalled. Once one subject is inside a percentage
rollout the algorithm can never change, so it is pinned with locked vectors
before anything can depend on it.

Security:
- Every project-scoped handler carries project_scope_guard! and
  project_access_guard!. The latter deliberately skips deployment tokens and
  delegates their confinement to the former; shipping only one left a
  cross-project IDOR where a token for project A could read project B's flags.
  Four regression tests fail if the guard is removed.
- Deployment tokens are read-only: Permission::FlagsRead bridges to
  DeploymentTokenPermission::FlagsRead, FlagsWrite/FlagsDelete deliberately do
  not. A credential baked into a container must not flip a production flag.
- The snapshot endpoint takes its scope from the token, never a path
  parameter, and returns one error for both "no such environment" and
  "another project's environment" so it cannot be used as an existence oracle.
- Audit entries record old and new values on every mutation. A flag change is
  a production change with no deployment behind it, so the audit log is the
  only record it happened.

List is paginated (default 20, max 100). The clamp is shared with the handler
so total_pages cannot describe a page size the server did not serve. The
snapshot endpoint is deliberately unpaginated: the SDK needs the whole set.
Lives in the existing package rather than a new one: a separate library would
duplicate the same deployment-token bootstrap, and this is a few hundred lines
of caching, not a product.

Reads are synchronous and cost no network I/O. The whole environment's flag
set lives in memory and refreshes in the background, because a per-check HTTP
round trip (~200-500ms) is unusable inside a request handler.

Zero configuration on Temps: TEMPS_API_URL and TEMPS_API_TOKEN are already
injected into every deployment and the token pins the project and environment,
so `new FlagsClient()` knows what to fetch.

evaluate() is a deliberate line-for-line mirror of the Rust evaluator. If the
two diverge the same flag resolves one way in the app and another on the
server, so the tests mirror the Rust tests case for case.

Failure is never fatal: init() does not throw, a failed refresh keeps the last
good snapshot rather than reverting every flag to its fallback, and lastError
is exposed so a self-hosted operator can see that flags are stale instead of
guessing why a rollout did nothing.
Full parity with the flags API, in the TypeScript client rather than the Rust
binary: apps/temps-cli is the one scripting surface for the API, and the Rust
binary's subcommands are server lifecycle commands.

  temps flags list|get|create|update|set|clear|disable|enable|archive

`flags set` reads the flag first so the raw CLI string is parsed into the
flag's own declared type - a shell has no types to offer, and the server
rejects a mismatch. `flags disable` is the kill switch as a single command.

Also registers flags with the docs generator, which uses a hardcoded import
list, so the commands actually appear in CLI.md/CLI.mdx. Regenerating those
picked up pre-existing drift for other commands as well.
Lives under the project rather than settings, in the sidebar directly after
Environment Variables - the sibling concept it exists to contrast with (env
var means redeploy, flag means seconds).

The table shows one environment at a time, because the question people arrive
with is "what is this flag doing in production?". The detail sheet then shows
every environment at once, which is where the project/environment relationship
becomes explicit.

Kill switch and value are kept visually distinct: the row switch sets the
value, the kill switch lives in the row menu and the sheet, and a
kill-switched row renders its switch disabled with a Disabled badge. Letting
someone toggle a value the kill switch is overriding would be a lie.

resolveEffectiveValue() is a third mirror of the evaluator, alongside Rust and
the SDK, and is commented as such - if it drifts the console shows one value
while the app receives another.

Type selection uses radio cards, not a dropdown (four options), and the value
control adapts to the flag's type. An unrecognised type renders as text rather
than a switch, which would silently mean the wrong thing.
ADR-034 records the design and, importantly, what was deliberately deferred
and why. The organising principle is "fix what is expensive to change later,
defer what is cheap": resolution order, the reason wire contract, typed
values, the client_visible security default, the bucketing salt and key
immutability are settled now; variants, rule contents, push propagation and
exposure events are not.

Also records three things the security audit surfaced: that project-scoped
handlers need both guards, that the bucketing salt must never be serialized,
and that the snapshot endpoint must not distinguish a missing environment from
someone else's.

The user-facing page leads with the distinction that matters - a flag is not
an environment variable, and is explicitly not a place to put secrets, since
flag values are stored unencrypted and can be marked browser-visible.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [Unreleased]

### Added

- **flags:** Add feature flag entities and migration
- **flags:** Add temps-flags crate with evaluation, API and RBAC
- **sdk:** Add feature flag client to @temps-sdk/node-sdk
- **cli:** Add temps flags commands
- **web:** Add feature flags console UI
- **flags:** Add last_evaluated_at backed by SDK-reported exposure

### Documentation

- **flags:** Add ADR-034 and feature documentation
- **flags:** Record the R4.5 decision on per-environment gating

### Fixed

- **flags:** Update deployment-token permission tests for FlagsRead
- **flags:** Address review findings from the security and Rust passes
- **flags:** Require SQLSTATE and "unique" together when detecting a conflict
- **flags:** Stop snapshot dropping flags overridden in other environments

Phase 1 ships with a known gap: FlagsWrite lets a caller flip a flag in any
environment of a project, production included. Accepted for now, but the shape
of the fix is worth writing down so it isn't re-derived later.

Resource-side elevation (a column on environments plus a stronger permission)
was rejected: it invents a flag-specific authorization axis nothing else has,
and puts the rule on the resource when the thing that varies is the caller.

The direction is capability scoping on the credential. Deployment tokens
already carry project_id and environment_id and are confined by
is_scoped_to_project; API keys carry neither and fall into that function's
"unconfined" branch by construction.

Two consequences worth recording: all six flag handlers already call
project_scope_guard!, so flags are confined for free the moment API keys gain
project scoping; and environment scoping needs one new guard with exactly one
flag call site (set_flag_environment).

Also notes project_permission_guard! as the option that exists today - it
would make flag writes respect team-based project roles via the ADR-028 seam,
inert in plain OSS - in case that is wanted before credential scoping lands.
Stale-flag cleanup is core to a flag product, and it needs a real answer to
"is any running code still reading this?".

The obvious implementation is wrong. The snapshot endpoint hands the SDK every
flag in the environment and evaluation happens locally, so stamping on snapshot
fetch would mark every flag as freshly used the moment an instance boots -
including flags nothing references. The column would then actively mislead:
a recent timestamp on a dead flag, or a trusted one on a flag about to be
deleted. That is why it was left out of the first cut.

So the flag set is not the signal, the reads are. POST /flags/exposure takes
the keys an app actually evaluated; the SDK accumulates them in memory and
flushes on the refresh interval, never per call - a network round trip per
evaluation is exactly what this client exists to avoid.

Bounds that make it safe:
- The SDK only records keys present in the snapshot, so get('user-' + id) can
  never grow the set; the server caps a batch regardless.
- Unknown keys are ignored silently and counted out of `recorded`, so the
  endpoint cannot be used to discover which flag keys exist.
- It writes last_evaluated_at and nothing else, so "a deployment token cannot
  change what a flag serves" still holds despite being a write path.

Lives on feature_flags, not feature_flag_environments: the row always exists
(a flag with no override anywhere has no environment row to stamp), and "is it
safe to delete this?" is answered across all environments at once.

The UI says "Last evaluated by an app", not "Last evaluated", and renders
Never distinctly - the whole risk is someone reading it as "last time anything
touched this flag".
Adding DeploymentTokenPermission::FlagsRead grew all() from 7 to 8 variants,
which broke test_permission_all's length assertion in temps-deployments - a
crate outside the ones I had been testing locally.

Kept the length assertion rather than loosening it: it is what forces a new
variant to be listed here, and all() is what the UI offers when scoping a
token, so a variant missing from it is invisible to operators.

Also adds FlagsRead to the as_str/from_str roundtrip list.
Two real conflicts, both from parallel additions:

crates/temps-migrations/src/migration/mod.rs — main added
m20260803_000001_add_template_slug_to_projects while this branch added
m20260802_000002_create_feature_flags and
m20260803_000001_add_flag_last_evaluated_at. Kept all three. The relative
order of the two m20260803 migrations is irrelevant (one alters `projects`,
the other `feature_flags`), but add_flag_last_evaluated_at must stay after
create_feature_flags since it alters that table. Verified by booting against
a live database: all four m202608* migrations apply cleanly in sequence.

web/src/api/client/{sdk,index,@tanstack/react-query}.gen.ts — generated
files, so hand-merging them would produce a client matching neither branch.
Took main's copies to clear the markers, then regenerated the whole client
from the merged server's live OpenAPI spec. The result carries both surfaces:
all five flags endpoints and main's template_slug schema.

Verified on the merged tree: cargo check --lib clean, 921 tests passing
across temps-flags/migrations/auth/entities/deployments, clippy clean,
web tsc clean, CLI tsc clean.
Two blocking issues, two majors, and the minors from both review passes.

BLOCKING - deployment tokens could read every environment's flag values.
The management reads return all overrides for a flag, but a token is pinned
to one environment. project_access_guard! is a documented no-op for tokens,
so nothing confined them: a preview container could read production values
for its project. Added deny_deployment_token! to list_flags/get_flag - their
whole documented machine surface is /flags/snapshot and /flags/exposure,
both of which honour the pinning. The test that asserted the permissive
behaviour is replaced with one asserting confinement, plus coverage that the
snapshot route still works.

BLOCKING - /flags/exposure was an unthrottled row-update path. The "once a
minute per instance" claim was a client-side promise the server did not
enforce; a loop could rewrite up to 500 rows per request on a table shared by
every tenant. Now only stamps rows whose timestamp is actually stale, so
steady state is zero writes. Over-cap batches are rejected rather than
truncated (silent truncation left evaluated flags reading as "never"), keys
longer than the column are dropped before the query, and `recorded` is the
accepted count rather than rows_affected - echoing the match count let a
caller probe for which keys exist.

MAJOR - no semantic From<DbErr>, so the create() check-then-insert race
surfaced as a 500 while losing the race a millisecond earlier gave a clean
409. Unique violations now map to DuplicateKey.

MAJOR - create/update/set_flag_environment had no handler tests; removing a
guard from the endpoint that flips production broke nothing in CI. Added
guard coverage for all three. Note the cross-project case on writes is
documented as unreachable defence-in-depth: permission_guard!(FlagsWrite)
rejects tokens before the scope guard is consulted, confirmed by a sentinel
run, so the comment says so rather than implying the test proves more than
it does.

Minors: snapshot filtered the environment inside the join instead of
fetching every environment and discarding (this is the endpoint every
container polls); update_flag went from three fetches to one; archive() is
idempotent and no longer overwrites the original archived_at, with a restore
path so an accidental archive is recoverable; migration indexes/FKs and the
add-column are idempotent and down() tolerates a partial up(); the migration
test asserts last_evaluated_at is present on feature_flags, not just absent
from the environments table, so the follow-up migration cannot be silently
dropped; tri-state PATCH deserializers are tested (the clear-to-null
mechanism that has regressed here before); ETag handles weak validators and
candidate lists; SDK chunks exposure to the server cap, re-queues rejected
batches and flushes on close; tokio/async-trait moved to dev-dependencies.
…onflict

Found while re-verifying the review fixes. is_unique_violation matched a bare
"23505" anywhere in the rendered driver error, but flag keys permit digits —
a flag named `23505` could make an unrelated query failure report itself as a
409 duplicate, hiding a real 500.

Now requires either the full "duplicate key value violates unique constraint"
phrase or the SQLSTATE alongside the word "unique". Regression test covers the
look-alike key.
…ents

Found by the security re-review, and it was a regression I introduced in the
previous pass while "optimising" the snapshot query.

`find_with_related` emits a LEFT JOIN, and the `.filter()` I added landed in
the WHERE clause, which is evaluated after the join. A flag whose only
override belongs to a different environment produced a joined row satisfying
neither `environment_id = $env` nor `id IS NULL`, so the flag was dropped from
the result entirely rather than appearing with its default value.

Confirmed against Postgres before fixing — querying environment 1 with three
flags (override in env 2, override in env 1, no overrides) returned only two:

      key            | environment_id
  -------------------+----------------
   has_override_here |              1
   no_overrides      |

`has_override_elsewhere` vanished. The SDK reads a missing flag as
FLAG_NOT_FOUND and serves the caller's own compiled-in fallback instead of the
operator's configured default — for a kill switch that is precisely the wrong
value, and it fails silently.

Replaced with the two-query shape `list()` already uses: fetch the flags, then
fetch their overrides filtered to the environment. Structurally immune, since
the flag set no longer depends on the join. Same verification now returns all
three flags with only the env-1 override attached.

No cross-environment leak either before or after — the failure was flags going
missing, never another environment's values being served.
Same three generated files as the previous merge — hand-merging generated
output would produce a client matching neither branch, so they were taken from
main to clear the markers and then regenerated from the merged server's live
OpenAPI spec (658 paths, all six flag endpoints present).

Note for anyone regenerating after this point: main now requires step-up MFA
for sensitive actions, so `POST /api/api-keys` returns STEP_UP_REQUIRED and the
usual "mint an API key for codegen" route no longer works unattended. The
openapi-ts config already supports TEMPS_API_COOKIE — pass the session cookie
from a normal login instead.

Verified on the merged tree: cargo check --lib clean, temps-flags 76 tests
passing, clippy clean, web and CLI typecheck clean.
@dviejokfs
dviejokfs merged commit 312f710 into main Aug 4, 2026
27 checks passed
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.

1 participant