Skip to content

Layer a local config beneath the remote one, per environment - #42

Merged
andyyhope merged 3 commits into
mainfrom
feature/environment-config
Aug 26, 2026
Merged

Layer a local config beneath the remote one, per environment#42
andyyhope merged 3 commits into
mainfrom
feature/environment-config

Conversation

@andyyhope

Copy link
Copy Markdown
Owner

An app that talks to more than one backend usually wants two config layers for the
environment it is in: one bundled with the build, one fetched. EnvironmentConfiguration
holds both and keeps them in step with the environment you switch to.

let config = EnvironmentConfiguration(
    AppFlags.self,
    local:  { env in Bundle.main.data(named: "\(env).json") },   // ships with the app
    remote: { env in try await api.fetchConfig(for: env) }       // fetched, async
)
let pole = FlagPole(AppFlags.self, sources: [byHand] + config.sources)

pole.flags.$environment.publisher
    .removeDuplicates()
    .sink { env in Task { await config.load(env) } }   // staging → local, then remote
    .store(in: &cancellables)

Precedence

Highest wins:

by-hand override   (companion / SnapshotSource)
  remote  {env}    (fetched)
    local {env}    (bundled)
      @Flag(default:)   (compiled base)

Remote wins over local for the same flag; config.sources returns them in that order.

Clear-first, so a failure falls back rather than lies

Loading an environment clears each layer before it loads it. A fetch that fails or
returns nothing leaves that layer empty and the one beneath showing through — the
bundled config, then the defaults — rather than leaving an app labelled staging
running the previous environment's values. load(_:) returns a LoadOutcome saying
what happened to each layer, since one can succeed while the other does not.

The framework does no networking and reads no files: the two closures hand it the
bytes, it decodes, validates against the schema, and layers them. Each layer is an
ordinary RemoteOverrideSource, so either can be audited with a FlagMappingAudit.

Adversarial review

One defect, and it was the clear-first failure reached from the other side. Two
overlapping load calls — switch a→b with a slow a fetch — each cleared then applied,
and whichever apply landed last won regardless of which environment was current, so b
could end up showing a's values. Each load now carries a monotonic epoch and re-checks
it after the fetch; a stale result is dropped and reported as LayerOutcome.superseded
rather than written over the newer load.

Clean under review: empty Data() reads as a failure (not valid JSON), not absent;
both-absent is complete.

Demo

Wired through it: a bundled local config per environment beneath the fetched one, driven
by the environment flag. Clearing the remote layer falls back to the local config, not
raw defaults, and the on-device provenance panel shows the sources flip Remote → Local.

Verified

771 tests. DocC clean with --warnings-as-errors, both example apps and the iOS package
scheme build, and the layering was walked through on a simulator: switching environments,
remote-over-local, and the clear-to-local fallback.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD

andyyhope and others added 3 commits August 26, 2026 10:32
An app that talks to more than one backend usually wants two config layers for the
environment it is in: one bundled with the build, one fetched. EnvironmentConfiguration
holds both and keeps them in step with the environment you switch to.

    let config = EnvironmentConfiguration(
        AppFlags.self,
        local:  { env in Bundle.main.data(named: "\(env).json") },
        remote: { env in try await api.fetchConfig(for: env) }
    )
    let pole = FlagPole(AppFlags.self, sources: [byHand] + config.sources)
    await config.load(.staging)   // local staging.json, then remote staging.json

Precedence within the pair is remote over local, so the full stack highest-first is a
by-hand override, the fetched config, the bundled config, then the compiled defaults.
Loading an environment clears each layer before loading it, so a fetch that fails or
returns nothing falls back to the layer beneath rather than leaving an app labelled
staging running the previous environment's values. load(_:) returns a LoadOutcome
saying what happened to each layer, since one can succeed while the other does not.

The framework does no networking and reads no files — the two closures hand it the
bytes, it decodes, validates, and layers them. Each layer is an ordinary
RemoteOverrideSource, so either can be audited with a FlagMappingAudit.

The demo is wired through it: a bundled local config per environment beneath the
fetched one, and clearing the remote layer now falls back to the local config rather
than to raw defaults — the local layer earning its place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
The demo's on-screen copy still described a single fetched payload. It now says the
environment loads two layers — a bundled local config, then a fetched remote one,
remote winning — and the provenance footer names the whole stack, highest first.
Clearing the remote layer points at the local fallback the sources panel then shows.

The docs section gains the missing half of the story: binding the load to the
environment flag's publisher, so setting the environment to staging loads staging's
layers, and the note on why that flag must carry no remoteKey.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
Switching environments quickly, with a slow fetch, could leave the new
environment showing the old one's values: two overlapping load calls each clear
then apply, and whichever apply landed last won regardless of which environment
was current. That is the exact failure clearing-first exists to prevent, reached
from the other side.

Each load now takes a monotonic epoch and re-checks it after the fetch: a result
whose epoch is no longer current is dropped, reported as a new LayerOutcome.superseded
rather than written over the newer load. Found reviewing the coordinator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnwCqKNm8rZ63Au6bpYdaD
@andyyhope
andyyhope merged commit 2a49dbd into main Aug 26, 2026
6 checks passed
@andyyhope
andyyhope deleted the feature/environment-config branch August 26, 2026 04:25
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