Skip to content

fix(render): hold a render to its own environment's exclusions - #1223

Merged
antoinetoussaint-byte merged 6 commits into
mainfrom
render/hold-a-flow-to-its-own-environment
Oct 28, 2026
Merged

antoinetoussaint-byte merged 6 commits into
mainfrom
render/hold-a-flow-to-its-own-environment

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

A gitops render of one environment was refused over another environment's exclude-services. codefly deploy gitops render --all --env blue --app-project platform-obin --local-agents exited 1 before any module rendered, and without --debug the message named no environment at all — which is what made this misdiagnose twice as blue's own declaration being wrong. With --debug:

· render wiki
  ↳ Workspace::ValidateEnvironments: environment "staging": exclude-services withdraws
    "authority/local", but robin/sandbox declares a dependency on it: …

Blue's declaration was correct and read: the same run logs Skipped authority: environment blue withdraws every service of it, and blue's block carries withdrawn-from: [robin/chat, robin/sandbox].

Cause

environments.ValidateWorkspace called ValidateExcludedServices, which iterates FromWorkspace(workspace) and holds every environment to the workspace graph. That graph is built from the current composition (loadExclusionGraph: LoadServices, LoadModules, solutionrun.ConsumedEndpoints), so an environment imported before a module overlay added a service carries no withdrawal for it and is judged against a composition it never saw. robin/sandbox entered through the robin module overlay; staging predates it.

This is not theoretical: any two environments composing different module sets hit it as soon as one gains a service the other has not withdrawn.

The sibling validator already had the right contract and was used by run, build and deploy through NewFlow — ValidateEnvironmentExclusions, whose comment reads "a flow is held to its own environment's composition, never refused over another's". Only the gitops render path took the all-environments one.

Fix

ValidateWorkspace takes an optional selected environment. The four gitops callers all had one in scope and now pass it (orchestrate.go ×2, dev.go, solution.go via req.Environment), so a render is held to its own environment. A caller with no selection keeps holding every environment, which is correct for a whole-workspace check such as doctor workspace. Module namespaces and host composition remain whole-workspace invariants and are unchanged, as is deploy/runtime validation.

Nothing was weakened: the inconsistent environment still refuses when it is the one selected.

Why not fix it from the declaration side

Re-importing the offending environment requires the product intent to declare it. platform-obin's delivery.environments.yaml declares exactly dev, blue and green — no staging — so codefly environment import staging … correctly exits 1 with product intent does not declare environment "staging". Fabricating an unused staging entry was explicitly ruled out, which is why the fix belongs here.

Test

TestFlowIsNotRefusedOverAnotherEnvironmentsExclusion asserts both halves, so it cannot pass vacuously: the inconsistent environment still refuses when selected, and selecting the clean environment succeeds. pkg/environments, pkg/gitops (135s) and the non-engine pkg/orchestration set all pass; go vet ./pkg/... and golangci-lint clean.

Not claimed

I have not run a real render — Blue10901 has the reproducer and will report exactly what it prints. This change removes the cross-environment refusal; it does not assert that blue's render then succeeds end to end.

🤖 Generated with Claude Code

Antoine Toussaint and others added 6 commits October 28, 2026 17:51
A gitops render of one environment was refused over another environment's
exclude-services. `render --all --env blue` exited 1 over `staging`, and
without --debug the message named no environment at all, which made it read
like blue's own declaration was wrong when blue's was correct and read.

ValidateWorkspace called ValidateExcludedServices, which holds EVERY
environment to the workspace graph. That graph is the current composition
(loadExclusionGraph: LoadServices, LoadModules, ConsumedEndpoints), so an
environment imported before a module overlay added a service carries no
withdrawal for it and fails against a composition it never saw. Any two
environments composing different module sets hit this as soon as one gains a
service the other has not withdrawn. The operator could not fix it from the
declaration side either: re-importing the offending environment requires the
product intent to declare it, and platform-obin's declares only dev, blue and
green.

A caller that selected an environment now passes it and gets
ValidateEnvironmentExclusions' existing contract — "a flow is held to its own
environment's composition, never refused over another's" — which run, build
and deploy already used through NewFlow. A caller with no selection keeps
holding every environment, which is right for a whole-workspace check such as
the doctor. Module namespaces and host composition stay whole-workspace
invariants, unchanged.

The regression asserts both halves: the inconsistent environment still refuses
when it is the selected one, so the test cannot pass vacuously, and selecting
the clean environment succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without --debug the refusal printed no environment at all — just
`exclude-services withdraws "authority/local"…` — so its reader could not tell
which environment's declaration was at fault and re-declared something already
declared, twice.

The name was there, as an `environment %q:` prefix on the wrapped error, but a
prefix is a separate link in the error chain and the plain renderer shows only
the leaf; the name appeared solely in the --debug chain. So each message in
validateExclusionsAgainst now names the environment itself, and the redundant
prefix is gone from both validators rather than duplicating it.

Every message in that function is covered, not only the dependency one that
surfaced: removing the prefix while leaving the siblings unnamed would have
taken the name away from refusals that previously carried it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd document it

The earlier regression went through NewFlow, which already used
ValidateEnvironmentExclusions at flow.go:484, so it never reached the
ValidateWorkspace path this PR changes — it passed against unfixed main, which
I verified rather than assumed. The claim in the PR body that it could not pass
vacuously was wrong about which path it covered.

TestValidateWorkspaceHoldsOnlyTheSelectedEnvironment calls ValidateWorkspace
directly and is behaviourally red at the parent: giving the parent the new
signature while keeping ValidateExcludedServices fails on "a selected
environment must not be refused over another environment's exclusion". It also
pins the two halves that must not move — the inconsistent environment still
refuses when it is the one selected, and a caller selecting nothing, nil
included, still holds every environment.

Docs corrected rather than added to. An earlier draft offered `doctor
workspace` as the whole-workspace example; doctor already validates the
selected environment through its own scope check
(cmd/doctor_workspace.go:794), so that was inaccurate. Every CLI path that
validates exclusions selects an environment; the whole-workspace form is what
remains for a caller that selected none.
docs/configuration-contract.md now states that these refusals are judged for
the selected environment and name it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e claim

Three bounded corrections from the focused closure review.

A render-caller regression. The direct ValidateWorkspace test proves the
validator narrows, but nothing proved the render entries still pass what they
selected: dropping the environment from all four production callers
(orchestrate.go twice, dev.go, solution.go) left the whole gitops package
green. TestRenderModuleIsNotRefusedOverAnotherEnvironmentsExclusion goes
through RenderModule with `stale` carrying a graph-inconsistent exclusion and
`staging` clean, asserts the inconsistent environment is still refused when it
IS the selection (so the second assertion is not vacuous), and fails when
orchestrate.go drops its argument. Verified both ways.

Accurate scope. The prose said an operation is "never refused over a different
environment's exclude-services", which is broader than the code. The selection
narrows the graph-consistency check and nothing else: loading a workspace
decodes and validates every environment, so a malformed exclusion anywhere
still refuses, and module namespaces, host composition, service-scoped names
and managed-service keys are all still held across every environment — the
same cross-environment shape, left deliberately whole-workspace. Both pages
now say so, and TestSelectionNarrowsTheGraphCheckAndNothingElse pins it: a
clean environment selects fine and is still refused over an unqualified name
declared somewhere else.

Doctor. Three comments said doctor relies on the no-selection fallback. It
does not — it selects and calls ValidateEnvironmentExclusions directly
(cmd/doctor_workspace.go). Corrected without changing doctor's behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prose said every environment is validated "whenever a workspace is loaded"
and that a malformed exclusion "refuses any operation". The test added
alongside it shows otherwise: LoadWorkspaceFromDir succeeds and Select("local")
succeeds, and only then does ValidateWorkspace refuse.

Corrected to what actually holds — ValidateWorkspace decodes and validates
every environment's declarations before comparing the selected one against the
graph, so a malformed exclusion refuses whatever was selected — with the
load/select distinction stated rather than implied. Both pages and the test's
own comment. No behaviour or test change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment said the test fails if "any render entry" drops its selection. It
covers RenderModule. The other three callers that pass a selection —
orchestrate.go's second call, dev.go and solution.go — are reviewed source,
not covered here, and the comment now says so. No test or behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit ad0463d Oct 28, 2026
19 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