Skip to content

Feat/unified configuration - #3789

Open
marvin-hansen wants to merge 54 commits into
stagingfrom
feat/unified-configuration
Open

Feat/unified configuration#3789
marvin-hansen wants to merge 54 commits into
stagingfrom
feat/unified-configuration

Conversation

@marvin-hansen

@marvin-hansen marvin-hansen commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Describe your changes

Feat/unified configuration

Issue ticket number and link

Code checklist before requesting a review

  • I have signed the DCO?
  • The build completes without errors?
  • All tests are passing when running make test?

Adds the add-unified-config-and-secret-managers change proposal and the
environment variable audit it is built on. Specs only -- no implementation.

The audit (openspec/notes/env-var-inventory.md) measured the current state
from source: .bazelrc forwards 45 environment variable names while the
integration-test code reads 58. Twenty-six are read but never forwarded and
silently take a default, including the TLS-affecting PGSSLTARGETNAME and
SRQL_TEST_DATABASE_SSLMODE. Thirteen are forwarded but unread. One setting
has three spellings that can never meet: .bazelrc forwards NATS_CA_FILE, Go
reads NATS_CACERTFILE, the Elixir e2e test uses NATS_TEST_CERT_DIR. And
config/test.exs reads fourteen alias pairs on adjacent lines.

The failure mode is silence: nearly every reader has a default, so a missing
value produces a working connection with the wrong settings rather than an
error.

The proposal moves the contract into git and makes each layer mechanically
checkable:

- a protobuf schema plus one text-format instance per environment as the
  committed ground truth, using explicit presence and *_UNSPECIFIED enum
  sentinels so a missing value cannot decode to a type default
- validation as a committed rule set over a closed predicate vocabulary,
  each rule carrying an evaluation phase and a stable violation code
- native managers in Rust, Go and Elixir -- no FFI or NIF, since protobuf
  codegen already provides the cross-language contract
- conformance vectors asserting violation identity, plus property-based
  tests over the predicate laws
- least privilege by construction: config files are Bazel targets declared
  as data, and components declare the logical secret names they may request
- SERVICERADAR_ENV as the single boundary variable, selecting both the
  config file and the secret provider

Three claims in the audit were refuted by adversarial review before landing,
and the corrections are recorded in the note: NATS_KEY_B64 is pinned by
//:buildbuddy_cache_proxy_config_test, SERVICERADAR_TEST_ADMIN_URL is live
under bazel run, and TEST_CNPG_PASSWORD is likewise pinned. Nothing in the
inventory is safe to delete on the strength of a read-scan alone.

openspec validate --strict passes: 10 requirements, 27 scenarios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Records the last phase-1 decisions on the unified-configuration proposal.
Phase 1 is now complete; nothing blocks phase 2.

Committed .textproto is the authored ground truth; a protoc build action
compiles each instance to a binary message and every manager loads the
binary. This matters because no implementation can parse text format: only
Go reads textproto natively, prost does not, and Elixir's :protobuf has no
parser at any version (0.17.0 added Protobuf.Text, encode-only). Moving the
parse into a build action removes the requirement from all three at once. A
round-trip test pins each generated binary to its committed source.

Also records, with reasoning, the two rejected alternatives: declaring
configuration programmatically in Rust (turns config review into code
review, blocks non-Rust authors of onprem instances, and lets a builder read
the environment), and Starlark-generated instances (held in reserve as the
escape hatch if overlays later justify computation).

Verified against a real Mix release: runtime.exs can call loaded modules but
no application is started; Application.ensure_all_started there is a trap
because Config.Provider restarts the VM, so anything started runs twice
against pre-runtime config; Application.app_dir/2 works at boot and __DIR__
does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…ings

Implements phases 2-3 of add-unified-config-and-secret-managers, and adds the
rules_elixir/rules_erlang consolidation specification that the work produced.

SCHEMA (config/proto/)

config.proto defines EnvironmentConfig over database, NATS and core settings,
including the CORE_* family that Decision 11 requires in scope. Two rules govern
every field, and both exist to prevent the silent defaulting this replaces:
every field uses proto3 explicit presence, and every enum reserves 0 as an
*_UNSPECIFIED sentinel that validation rejects. Without them a missing port
decodes to 0 and a missing TLS mode to whatever is = 0 -- the same disease
relocated into the decoder.

The connecting role and the owning role are separate config fields, so nothing
needs to recover an identity by parsing a credential-bearing DSN.

RULE SET (config/rules/)

rules.proto expresses validation as data over a closed predicate vocabulary.
The predicate is a oneof rather than an enum plus a separate params message, so
a predicate and its parameters cannot disagree. ruleset.textproto carries 37
rules over every schema field; fixtures.textproto carries 10 conformance cases
that double as negative fixtures, covering each predicate kind plus cascading,
violation ordering, and both sides of scope.

config/SEMANTICS.md is normative: the verdict type, per-predicate totality,
cascading (an absent required field yields exactly one violation), exhaustive
evaluation, a total order on (field_path, code), and phase/scope gating. It also
states plainly what a verified engine cannot do -- detect that the rule set
forgot a rule.

BUILD (config/defs.bzl)

environment_config, rule_set and fixture_set compile each committed .textproto
to binary via protoc. No implementation parses text format: only Go reads it
natively, prost does not, and Elixir's :protobuf has no parser at any version
(0.17.0 added Protobuf.Text, encode-only). Doing the parse once in a declared,
cached action removes the requirement from all three languages.

Verified on RBE, positive and negative: ci.textproto compiles to a 314-byte
binary; an unknown field, a wrong type for port, and an invalid enum value each
fail the build, naming the offending token. The rule set likewise rejects an
unknown predicate and an unknown enum in scope.

BINDINGS (config/proto_bindings/)

Rust: crate serviceradar-config-schema (prost), registered in the workspace and
Cargo.lock. Three tests pass on RBE, including one asserting that unset fields
are absent rather than defaulted -- explicit presence surviving codegen is a
tested property, not an assumption about a generator.

Go and Elixir: checked-in bindings. 26 Elixir fields carry proto3_optional, so
presence survives there too.

Note: the generators for the checked-in Go and Elixir bindings are deliberately
not included. Makefile targets would violate the "everything is a Bazel target"
rule, and the Bazel build already generates Go from source via
//config/proto:configpb rather than reading the checked-in copy. The Elixir
.pb.ex therefore have no drift guard until elixir_proto_library exists; see the
consolidation note.

CONSOLIDATION SPEC (openspec/notes/rules-elixir-consolidation.md)

Writing the Elixir bindings hit a gap -- rules_elixir has no proto rule -- and
the investigation into why produced a specification for consolidating the ~4034
lines of de facto Elixir rules in build/ and the ~250-file hex dependency model
in third_party/hex into rules_elixir, measured against both rulesets at HEAD.

Four findings drive it: elixir_app hardcodes priv = [] and hdrs = [], which
drops NIFs and generated output at the provider boundary; rules_erlang's hex
resolver is dead code that has never run, while third_party/hex demonstrably
works across 269 packages; build/ contains three stacked generations whose
newest already produces ErlangAppInfo per package; and rules_erlang fetches OTP
with curl inside a build action and extracts to an absolute /tmp path outside
the sandbox -- the same bug class this consolidation exists to remove.

The note also records the invariants that look like junk and are not, chiefly
the Application.compile_env/Config.Provider boot check: drop it and you get a
ruleset that builds perfectly and produces releases that refuse to start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…lidator

The 37 rules in //config/rules were a document. //config/validator is a Rust
implementation of //config/SEMANTICS.md that makes them executable, and a Bazel
test that runs them over every committed instance.

Engine notes. Only Required treats absence as a violation, so one missing value
yields one violation rather than a cascade. Scope gates on kinds/except_kinds.
Violations are totally ordered by (field_path, code), which is what will let the
Go and Elixir engines be compared against this one rather than merely all
rejecting. Field access is an explicit match rather than reflection: the schema
is closed and small, and the payoff is that a rule naming a field the schema
lacks is a hard error instead of a silently skipped rule.

It validates the compiled binaries. That is what a runtime loads; validating the
.textproto sources would be checking a different artifact.

localhost is its own kind because it is the one the rule set exempts from
verified TLS and a secure core transport -- a dev machine has no cluster DNS, no
fixture certificate and no SPIFFE agent. saas carries no `instance` field;
only on-prem is multi-tenant.

Also settles the last open phase-2 question. EnvironmentConfig describes the
deployed system; anything describing the build or the test harness stays a Bazel
target attribute or a runfiles lookup. SRQL_FIXTURE_ROOT is a fixture-directory
override that the harness already resolves through runfiles and that nothing
sets, so it gets no schema field.

Verified: three tests, all confirmed to run. The committed instances pass; a
deliberately broken one is rejected with the expected codes; an absent
database.port reports DATABASE_PORT_REQUIRED alone. End-to-end, setting
saas.textproto to TLS_MODE_DISABLE turns the test red and restoring it turns it
green -- a validator that only ever passes cannot be distinguished from one that
never runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
relocating it. A value comes from the configuration system or from a declared
build input; an ad-hoc environment override in a test can repoint an input to
something the build graph does not know about, which is what makes a green
result meaningless. Fixture data resolves through runfiles.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…-4 file checks

ENVIRONMENT_KIND_DEMO (5) with config/environments/demo.textproto. A kind rather
than a saas instance: demo has its own topology and its own admission policy, and
every rule scoped `except_kinds: LOCALHOST` must reach it. It carries saas's
posture -- VERIFY_FULL, mTLS, pool_size 25 -- because it is a real deployment
reachable over the internet. Hostnames are demo's own; copying saas's namespace
verbatim would have committed a known-wrong value as ground truth.

DgraphConfig (host, port, tls_mode) for rust/dgraph-client, with a SEPARATE
DgraphTlsMode enum. Not a reuse of TlsMode: the Dgraph client has no verify-full,
so a shared enum would let an instance state TLS_MODE_VERIFY_FULL for Dgraph -- a
setting that reads as the strongest option and that no client can honour. One host
rather than a repeated endpoint list, because the Kubernetes alpha Service already
load-balances. ACL username, password, api key, bearer token and namespace are
omitted: the last four are SecretManager's, and the schema does not carry fields no
instance sets. Seven rules mirror the database TLS posture; the outside-localhost
rule admits VERIFY_CA alone.

credential_shape_test rejects credential-shaped values in committed instances. Two
choices are load-bearing. It scans the canonical text of the COMPILED BINARY, not
the source: that covers every field that is present, including ones no checker knows
by name, and it does not flag the word "password" in the comment warning against
them. And it matches on SHAPE -- URL userinfo, `password=`-style connection
parameters, PEM material, whole-value base64 runs -- rather than on a list of bad
field names, which would only catch what someone already thought of. A
false-positive control asserts that SPIFFE IDs, service DNS names and
`platform, ag_catalog` survive it, because a shape check that blocks legitimate
values gets disabled by whoever it blocks. Verified end to end on RBE: a DSN with an
embedded password planted in demo.textproto fails the test naming
`demo: database.host`.

round_trip_test compares each committed .textproto against the canonical text of the
binary built from it, flattened to path=value pairs. Structural rather than
byte-for-byte because the committed file carries comments and blank lines no encoder
emits, and neither prost nor Elixir's :protobuf can parse text format. Its weight
comes from the two sides being read from different places -- source tree vs. decoded
artifact -- so it becomes load-bearing once the binary is copied into a release
artifact's priv/. Within one build the two cannot diverge, so an in-test negative
control is what proves the comparison works.

Two defects found while doing the above.

//config/proto_bindings/go/BUILD.bazel claimed `make verify-proto-go-config` was the
drift guard for the checked-in Go bindings. That target was never written, so the Go
bindings had no drift check at all while the Elixir ones did. Adds
//config/proto:config_go and binding_drift_go_{config,rules}_test.

config/validator did not compile under standalone cargo, pre-existing: it used
regex_automata::meta while the workspace declares default-features = false. It built
under Bazel only because feature unification let another crate supply the feature --
the trap AGENTS.md documents under "cargo check -p <crate> must pass standalone".
Fixed by naming ["meta", "syntax", "unicode-case"] at the consuming crate. No
Cargo.lock or crate-mirror churn.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 8/8 pass.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Decision 6: a target declares the section it needs, so a database test's runfiles
PHYSICALLY DO NOT CONTAIN the NATS configuration. Enforced by the sandbox rather
than by discipline, and visible at the target definition instead of inferred from a
global union of forwarded environment variables. Under remote execution the property
is absolute -- only declared inputs reach the executor.

Four sections per instance (database, nats, core, dgraph), e.g.
//config/environments:ci_database, cut by //config/tools:extract_section.

A tool rather than a protoc invocation because text format cannot be sliced: the
section boundary is structure, and recovering it from the source text would be a
parser pretending to be a grep. An absent section is an error rather than an empty
file -- zero bytes decode to a message whose every field is absent, which validation
would then report as a dozen missing values instead of as the one thing that is
actually wrong.

section_privilege_test declares exactly one section and asserts both halves of the
claim, because Decision 6 is a statement about runfiles and is only true if something
checks the runfiles. Negative control verified on RBE: granting the target ci_binpb
and ci_nats makes both absence assertions fail, each naming the reachable file, while
the positive assertion still passes -- so the absence checks are detecting absence
rather than a broken lookup.

Note: the _SECTIONS loop in config/defs.bzl and the config/tools workspace member in
Cargo.toml were reverted during the commit of 16baa78 and are re-applied here.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 9/9 pass.
cargo check -p serviceradar-config-tools passes standalone.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Decision 6: a target declares the section it needs, so a database test's runfiles
PHYSICALLY DO NOT CONTAIN the NATS configuration. Enforced by the sandbox rather
than by discipline, and visible at the target definition instead of inferred from a
global union of forwarded environment variables. Under remote execution the property
is absolute -- only declared inputs reach the executor.

Four sections per instance (database, nats, core, dgraph), e.g.
//config/environments:ci_database, cut by //config/tools:extract_section.

A tool rather than a protoc invocation because text format cannot be sliced: the
section boundary is structure, and recovering it from the source text would be a
parser pretending to be a grep. An absent section is an error rather than an empty
file -- zero bytes decode to a message whose every field is absent, which validation
would then report as a dozen missing values instead of as the one thing that is
actually wrong.

section_privilege_test declares exactly one section and asserts both halves of the
claim, because Decision 6 is a statement about runfiles and is only true if something
checks the runfiles. Negative control verified on RBE: granting the target ci_binpb
and ci_nats makes both absence assertions fail, each naming the reachable file, while
the positive assertion still passes -- so the absence checks are detecting absence
rather than a broken lookup.

Note: the _SECTIONS loop in config/defs.bzl and the config/tools workspace member in
Cargo.toml were reverted during the commit of 16baa78 and are re-applied here.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 9/9 pass.
cargo check -p serviceradar-config-tools passes standalone.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
config/environments/onprem/untd.textproto, in its own Bazel package so each
deployment's targets mirror its file path and the instance set grows without touching
a shared BUILD file.

Its coordinates are the ServiceRadar chart's DEFAULTS rather than invented topology:
an on-prem install runs the same chart, so `cnpg-rw` in namespace `serviceradar` is
what a default install produces. Anything a given deployment overrides in its values
file must be overridden here too.

This is Decision 10 in effect -- a customer instance is validated by this
repository's build, not by a pipeline this repo does not control. Negative control
verified on RBE: weakening its database.tls_mode to DISABLE fails file_phase_test
with `onprem/untd.textproto has 1 violation(s): database.tls_mode
DATABASE_TLS_MODE_VERIFIED_OUTSIDE_LOCALHOST`, so a customer file is held to the same
cross-environment invariants as ci and saas.

INSTANCE_FORMAT is unchanged: the identifier is lowercase, matching the committed
rule and the file path it appears in.

INSTANCES now lives once in src/utils_tests.rs and is shared by file_phase_test,
round_trip_test and credential_shape_test. It was duplicated in file_phase_test.rs,
which is how an instance gets added to one check and not the others.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 9/9 pass.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…tegrity requirement

A dedicated configuration server is out of scope and a follow-up specification. This
records the constraints that keep it reachable, because each is free now and expensive
later.

The one that would have blocked it is a bootstrap cycle: a server authenticates its
clients, authenticating needs a credential, credentials come from SecretManager, and
SecretManager needs configuration to know its provider. Broken by requiring the config
source to be reachable with platform-provided workload identity alone -- a Kubernetes
service account token, a SPIFFE SVID, or a platform-mounted client certificate.
ServiceRadar already deploys with SPIFFE and mTLS, so that identity exists before any
ServiceRadar configuration is read.

That also corrects the open call from the previous commit. Requiring a #sha256= digest
pin on https: would foreclose a server that renders per deployment, since no
client-known digest can exist for it. The requirement is now the PROPERTY -- a remotely
fetched artifact's integrity must be verifiable against something the client knew
before fetching -- satisfied by a digest pin for a static artifact or a signature
against a pinned public key for anything dynamic.

Three further constraints: the selector stays a URI rather than a path so a new source
is a new scheme; schemes are a closed set with an unknown one a startup error, but the
loader is shaped as a resolver keyed by scheme so adding one is adding an entry;
and the loader returns the instance together with its provenance from the first
version, because explain has nowhere to report endpoint, fetch time or verified
identity if the result is a bare EnvironmentConfig.

Hot reload is explicitly not promised. Decision 9 requires the first resolution to be
synchronous and complete before the application tree starts, so any server must support
a fetch at boot; push or lease is an addition to that, never a replacement.

Client-side validation needed nothing extra -- a server is not trusted to have
validated what it serves, which is what makes it safe to plug in rather than a new
thing to trust.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…least one rule

Without this the rule set is complete only by memory. A field added to the schema and
not to the rule set is reported by nothing: validate evaluates the rules it has, so an
unconstrained field passes silently, which reads exactly like being valid. This session
supplied the case -- four dgraph.* fields were added by hand, and nothing would have
caught a missing rule for any of them.

The field list comes from the schema's own descriptor
(//config/proto:config_descriptor_set), recursed to leaves, rather than from a list in
the test. A hand-kept list is one more thing to forget to update, and forgetting is the
failure being caught.

Two further assertions cover the blind spots of the obvious one. A rule naming a field
the schema lacks is dead -- it can never fire, so the constraint it expresses is absent
-- and the descriptor walk is checked to reach nested sections and to stop at leaves,
because a walk returning only top-level fields would pass the coverage check while
constraining nothing nested.

Negative control verified on RBE: adding dgraph.forgotten_field to the schema without a
rule fails with `schema fields with no rule -- they are unvalidated, which reads exactly
like valid: ["dgraph.forgotten_field"]`.

prost-types is named as a dependency of config/validator rather than inherited through
Bazel's feature unification, so cargo check -p serviceradar-config-validator still
passes standalone.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 10/10 pass.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…s integrity

ForbiddenIf: a field must be ABSENT when another field holds one of the given values.
It closes the half of `instance` that had no predicate. The schema said "required if
and only if the kind is ONPREM", but only the required half was expressible, so
nothing stopped ci.textproto naming an instance -- a file describing something it is
not.

Its trigger is a SET where RequiredIf takes a single value, and that asymmetry is the
point. The forbidding side enumerates the COMPLEMENT of what is permitted, so a
per-value rule is exactly how a newly added enum value becomes permitted by omission --
and the coverage meta-rule cannot catch that, because the field still has rules.

So the set comes with a guard: a RequiredIf/ForbiddenIf pair keyed on the same enum
must cover every value of it. Both negative controls verified on RBE. Naming an
instance on ci yields INSTANCE_FORBIDDEN_OUTSIDE_ONPREM. Adding a sixth kind without
extending the trigger set fails naming the uncovered value.

Two fixtures, not one. The forbidding case, and a permitting case on onprem: a trigger
set that accidentally included ONPREM would reject every on-prem instance, and only a
positive case catches that.

Vocabulary is now nine predicates. SEMANTICS.md carries the verdict row, Elixir and Go
bindings are regenerated and their drift guards pass.

Artifact integrity over https: is deferred to the configuration-server specification
rather than dropped, with the exposure recorded: without verification against something
the client knew beforehand, whoever controls the endpoint or its DNS controls the
deployment's database host and TLS mode. It cannot be settled here because the
mechanism depends on what serves the artifact -- a digest pin cannot exist for anything
rendered per client, and a signature presumes a signing key the server design has not
introduced. file: carries no such exposure and is the recommended source until then.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 10/10 pass.
cargo check -p serviceradar-config-validator passes standalone.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…a fixture

The fixture file was inert data. Nothing evaluated it, so it was neither the
negative fixture set nor the conformance vector set it claimed to be -- just a
document. //config/validator:vector_test runs it, and found two real defects on
its first run.

Every pre-existing fixture was stale: the dgraph section was added to the schema
and the rule set without updating them, so all ten silently emitted three
phantom violations. And 35 of 45 rules had no fixture at all -- the invariant the
rule set file states in its own header and that nothing enforced. A rule no case
exercises is indistinguishable from a rule that does nothing.

47 fixtures now, covering all 45 rules. Generated SHAPE, authored INTENT: each
case is one mutation of a valid baseline, with expected violations written by
hand from what the rule is for. Deriving them by running the validator would
have been faster and worthless, because a fixture whose expectations come from
the implementation cannot test that implementation. All 35 hand-written
expectations matched the engine on the first run.

Comparison is the ORDERED SEQUENCE of (code, field_path), not accept/reject and
not a set. Three implementations can reject the same input for three different
reasons and a bare rejection assertion stays green. Two cases deliberately name
two violations, because one field carries several rules and the full sequence is
what pins the reason.

Two further checks: every rule must be violated by some fixture, and every
expected code must name a rule that exists -- a fixture naming an undefined code
is dead weight that reads like coverage.

Negative control verified on RBE, testing the SEMANTICS.md section 8 property
rather than assuming it: silently widening DATABASE_POOL_SIZE_RANGE from min 1 to
min 0 reds database_pool_size_zero with `expected [...] actual []`.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 11/11.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Eight proptest properties plus eight hand-written cases. Both layers earn their
place: the properties state each law as a universally quantified claim and shrink
to a minimal counterexample on failure, while the hand-written cases pin the
specific points three implementations most easily diverge on.

Running the negative control found a real defect in the first generator. Breaking
the engine to make IntRange exclusive at its upper bound was caught by the
hand-written edge case but NOT by prop_int_range_is_inclusive_containment, which
drew min, span and v independently over 400k values and so reached v == max only
by luck. The property was passing vacuously with respect to the boundary it
claims to prove. Fixed by drawing the value relative to the bounds; the same
control now fails and shrinks to min = 0, span = 0, pick = 0 -- the degenerate
range [0,0] probed at its own edge. Sampling a boundary is not testing it.

proptest is added to [workspace.dependencies] and as a dev-dependency of
config/validator, with normal_dev = True on the Bazel target. Six archives
vendored via //third_party/crate_mirror:sync; Cargo.lock and MODULE.bazel.lock
updated. bazel build //rust/... is unaffected.

make test selection confirmed by query: no target under //config/... carries
manual, integration_test or acceptance_test, so all twelve are selected.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 12/12.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Eight proptest properties plus eight hand-written cases. Both layers earn their
place: the properties state each law as a universally quantified claim and shrink
to a minimal counterexample on failure, while the hand-written cases pin the
specific points three implementations most easily diverge on.

Running the negative control found a real defect in the first generator. Breaking
the engine to make IntRange exclusive at its upper bound was caught by the
hand-written edge case but NOT by prop_int_range_is_inclusive_containment, which
drew min, span and v independently over 400k values and so reached v == max only
by luck. The property was passing vacuously with respect to the boundary it
claims to prove. Fixed by drawing the value relative to the bounds; the same
control now fails and shrinks to min = 0, span = 0, pick = 0 -- the degenerate
range [0,0] probed at its own edge. Sampling a boundary is not testing it.

proptest is added to [workspace.dependencies] and as a dev-dependency of
config/validator, with normal_dev = True on the Bazel target. Six archives
vendored via //third_party/crate_mirror:sync; Cargo.lock and MODULE.bazel.lock
updated. bazel build //rust/... is unaffected.

make test selection confirmed by query: no target under //config/... carries
manual, integration_test or acceptance_test, so all twelve are selected.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 12/12.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Three implementations now, verified against one another by the committed vectors
rather than by three hand-maintained suites. This is the precondition for
Decision 12, which requires every implementation to validate at load.

Go (//config/go/validator) reproduces all 47 fixtures as an ordered
(code, field_path) sequence and revalidates all five committed instances, plus
seven rapid properties. Two negative controls on RBE, catching different things:
making IntRange exclusive at its upper bound was caught by the property and shrank
to `range [0,0] at 0`, the identical minimal counterexample proptest produced in
Rust, but did NOT red the vectors because no committed fixture uses a
max-boundary value; disabling NonEmpty instead failed ten named fixture subtests.
That asymmetry is why both layers exist.

Elixir (config/elixir) carries the engine and seven StreamData properties. All
three trees draw range values relative to the bounds rather than independently,
carrying forward the defect the Rust control exposed: an independent draw reaches
v == max only by luck, so the property passes against an off-by-one engine.

The Elixir vector harness is written but BLOCKED, and excluded from the glob with
the diagnosis inline rather than left red or tagged manual. rules_elixir
private/ex_unit_test.bzl:51 stages every test input with `src = s.path`, which for
a generated file is bazel-out/<cfg>/bin/... and does not resolve from the test's
working directory; source files work only because path == short_path for them.
Fix is `src = s.short_path`, against pinned commit 832a95b4.

Both engines depend on the checked-in bindings rather than the generated ones,
because a Go source file can only name one import path and `go test` must resolve
it too. binding_drift_go_{config,rules}_test is what makes that safe.

pgregory.net/rapid added to go.mod and registered by `bazel mod tidy` as
net_pgregory_rapid.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 14/14.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
The tree had four conventions at once: config/validator (Rust, no language dir),
config/go/validator (language first), config/elixir (language, no component) and
config/manager_config/rust (component, then language). Only the last is coherent,
so it now applies everywhere.

  proto/, environments/, rules/     data and schema -- no language
  proto_bindings/{rust,go,elixir}   generated bindings
  validator/{rust,go,elixir}        the rule engine
  manager_config/{rust,go,elixir}   ConfigManager
  manager_secret/{rust,go,elixir}   SecretManager
  tools/{rust}                      build tooling

Component first, language second, because the implementations of ONE component
change together -- a shape change lands in all three or the shared conformance
vectors go red -- while two different components in the same language do not.
Grouping by language would scatter the things that must agree across three places,
which is the arrangement Decision 10 rejected for the tree as a whole. tools/ has
one language today and still carries the directory: a rule with an exception is a
rule nobody applies.

Mechanical fallout, all of it load-bearing: crate manifests re-point one level
deeper; the Rust test runfiles fallback gains a path segment; the Go importpath
becomes .../config/validator/go and its imports are now explicitly aliased,
because a path ending in /go no longer matches the package name and inference
should not carry that; defs.bzl's Label() for the section extractor moves; and the
labels in tasks.md and design.md follow.

One real trap on the way through. //config/validator was valid shorthand only
because the package basename matched the target name; one level down it resolves
to :rust, which does not exist. Bazel dropped both manager tests from the build
and still reported "Executed 15 out of 15 tests: 15 tests pass" -- green over a
shrunken target set. Confirmed the fix by enumerating targets with
`bazel query 'kind(".*test", //config/...)'` (18) rather than trusting the summary.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 17/17.
cargo check passes for all three config crates; go build and go vet clean.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…y by mutation

cargo test ran ZERO tests in every config crate. The files under src/*_test.rs were
never declared in lib.rs, so cargo never compiled them; they existed only through
Bazel's crate_root. `cargo check --lib` passing said nothing about them, and the
signal -- "running 0 tests" -- was in the output twice and went unread.

Both crates now follow the repository's Rust layout: src/{types,errors,traits}/ with
one type per file and each hand-written trait impl in its own file, shared fixtures
at src/utils_tests/ where Bazel can reach them, and tests/ exercising the public API
as a consumer sees it with every module registered from one entry point. Dispatch is
static: ReadSource is a generic bound, not dyn.

autotests = false with one declared [[test]] per crate. Cargo otherwise discovers
each tests/*.rs as its own binary, double-compiling what lib_tests.rs registers and
letting a file run under cargo that Bazel's single crate_root never sees.

Bazel is authoritative. The validator's suite reads artifacts protoc compiles during
the build, and this repository removes Bazel's convenience symlinks, so cargo has no
path to them. Those carry #[ignore] naming the reason, so cargo lists them as skipped
rather than quietly covering less, and the Bazel target passes --include-ignored. The
manager needed no such gap: its rule set is built in code, because those tests are
about what the manager does with a rule set rather than the committed one's contents.

section_privilege_test stays a separate target in both builds. Folded into the
omnibus target it received every fixture and failed correctly -- it asserts that
declaring one config section does not grant the others, which only holds if its
inputs are declared alone.

Adds the Go ConfigManager mirroring the Rust one, with the same 26-test surface.

Every safety property is verified by MUTATION, not asserted. Removing the identity
cross-check, skipping validation, accepting an instance on a single-instance kind,
and defaulting an unset variable each fail exactly the tests that name them, in both
languages, with no mutation uncaught.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 12/12.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…N assembly

ConfigManager lands in Go and Elixir mirroring Rust: one variable, source derived
from the kind, identity cross-check, loading that validates. Where the three
disagree one of them is a bug, since the same SERVICERADAR_ENV must behave
identically whichever service reads it.

SecretManager enforces two properties with types rather than discipline. A Secret
cannot be printed: Debug and Display redact, the value is reachable only through
expose(), and redaction is proven to survive nesting in Option, Vec, tuples and
Result -- {:?} reaches secrets through tracing spans, unwrap panics and error
chains, none of which look like printing a password at the call site. And refusal
precedes resolution: an undeclared name is refused identically whether or not the
store holds it, because answering even to say "not found" reveals whether a secret
the component may not have exists.

Empty is not a secret. A provider returning an empty string has failed to resolve
one, and treating it as a value is how a component connects with a blank password.

DSN assembly returns a redacting type. The DSN is not a schema field precisely
because it embeds a password, and that reasoning does not stop at the schema:
a bare String would undo the redaction SecretManager provides. sslmode comes from
the typed TLS mode rather than string concatenation, which is how sslmode went
missing and tokio-postgres fell back to Prefer; userinfo is percent-encoded,
because a password containing @ or : would truncate the host or the role and
produce a DSN that parses into something else rather than failing.

Ten mutations, none uncaught: removing the identity check, skipping validation,
accepting an instance on a single-instance kind, defaulting an unset variable,
printing a Secret from Debug or Display, accepting an empty secret, removing the
manifest check, printing a Dsn, dropping sslmode, and skipping percent-encoding
each fail exactly the tests that name them.

One test was rewritten mid-flight: asserting database_url returns None for an
unspecified TLS mode was testing dead code, because validation rejects that
instance before a manager can exist. It now asserts the load fails.

Still open in this phase: SecretManager in Go and Elixir, and the explain command.

Verified: bazel test --config=remote --nocache_test_results //config/... -> 16/16.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…n, and rename

config/validator becomes config/manager_validator, so every component in the tree
reads component/language. The crate's relative dependency path (../../validator/rust)
contains no "config/" and was missed by the path rewrite; cargo caught it.

SecretManager lands in Go and Elixir. Elixir needed a different mechanism, and a test
found out why: a custom Inspect implementation is not enough on the BEAM, because a
struct IS a map and inspect(term, structs: false) renders it as one, printing every
field and bypassing the protocol -- as does anything that walks the term, including a
crash report. The value is therefore held in a closure rather than a field, verified
against structs: false, Map.from_struct/1 and term_to_binary/1. Go needed GoString as
well as String, since %#v prints the struct literal. Three languages, three different
leak vectors.

explain reports where every value came from, including values compiled into the
release: "it was built in" is an answer, and omitting it leaves open the question this
system exists to close. Enums report by name rather than number. Secrets are absent
rather than redacted -- Explanation has no way to reach a secret value at all, which is
stronger than remembering to mask one, and a masked value beside a name is one
formatting change away from an unmasked one.

Seventeen mutations across the phase, none uncaught. The harness itself was wrong twice
and is fixed: backups keyed by full path rather than basename, which had destroyed a
file when two modules were both named mod.rs; and a missing test log now reports a
failed build rather than being read as "not caught".

Verified: bazel test --config=remote --nocache_test_results //config/... -> 19/19.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Every Rust target was unbuildable on macOS. Three faults stacked, each hidden
behind the one before it.

The sysroot archive could not be extracted. osx.bzl builds its include list as
both System/Library/Frameworks/<F>.framework/* and
System/Library/PrivateFrameworks/<F>.framework/* for EVERY requested framework,
unconditionally, and all six of its defaults are public frameworks. The
joseluisq/macosx-sdks repackage this repository pinned carries no PrivateFrameworks
tree, so six include patterns matched nothing and bsdtar exits 1 on an unmatched
include pattern. The pin is now the genuine Apple CLT SDK package hermetic-llvm
itself consumes, where archive and include list are known to agree.

That exposed the second: `ld: framework 'System' not found`. System is absent from
_DEFAULT_FRAMEWORKS and the driver emits -framework System for every executable, so
libraries compiled and only binaries failed.

Which exposed the third: osx.frameworks REPLACES the default list rather than
extending it, so naming System alone dropped the other six and the link failed on
Foundation instead. All seven are now listed.

`Failed to open property list SDKSettings.plist` is emitted throughout and is NOT
fatal -- verified both ways: supplying a plist did not fix the link, and the final
build succeeds with it absent. It reads like the headline error and is a dead end.

The comment on the pin claimed it overrode a default for faster download. There is
no default: @llvm's extension fails with "Missing osx.from_archive(...)" without it.
It was load-bearing while reading as an optimisation, and now says so. The cost is a
slower first fetch, since a full Apple SDK package replaces a trimmed tarball.

llvm stays at 0.8.17: v0.8.18's osx.bzl is identical in this area, so the bump buys
nothing here and carries a rules_rust update.

Verified: bazel build --config=cache_only //rust/... -> 11,272 actions, clean,
binaries linking under darwin-sandbox.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…uild input

The integration lifecycle is six separate Bazel invocations that share no
process, so each must independently derive the same disposable database name
while two concurrent runs must not. That identity came from GITHUB_RUN_ID and
GITHUB_RUN_ATTEMPT, read separately by Rust and Elixir, each formatting the name
itself and each falling back to the constant "sr_core_test_local" when the
variables were absent.

Three things were wrong with that. The names belonged to a CI vendor that was
never the source: BuildBuddy does not set them, so buildbuddy.yaml synthesised
entropy locally and laundered it through cksum into a fake numeric id purely to
satisfy a digits-only validator. The format was implemented twice, with a
comment admitting the two "must agree exactly or the suite runs against a
database nothing provisioned". And the constant fallback meant two runs against
one fixture silently shared a database, with each teardown dropping the other's
data.

Replace all of it with --//build:run_id, materialised by //build:run_id_file and
read from runfiles by both languages. The caller mints one id and repeats the
flag on every invocation, so the value arrives on the command line -- the build's
declared input -- rather than as ambient process environment. There is no
default: a missing id now stops the run with the whole six-step sequence in the
error, instead of substituting a name two runs can share.

Bazel cannot mint this itself. A genrule running uuidgen has no inputs, so its
key never changes: measured across three invocations it returned one value, and
with the remote cache that constant would be shared org-wide. Forcing
re-execution gives a different value per invocation, so the six steps disagree.
Neither is "stable within a run, different between runs", because a run spanning
six invocations is not a concept Bazel models -- it belongs to the caller.

Cache impact is confined by construction: the rule reading the flag is a leaf
with no dependencies, and consumers take its output as data, which changes an
action's inputs but not its configuration. Flipping the id re-executes 2 of 4318
actions and recompiles nothing; only the analysis cache is discarded, and the
flag is constant across a run.

Also fixes an unrelated live failure in the same path. database_url_named
appended libpq's &sslsni=1&host=<name> whenever tls_server_name was set, which
every verify-full environment sets. tokio-postgres has no sslsni arm and
rejected the whole connection string; it also reads a query-string host as an
additional host to dial, so the verification name would have become a silent
fallback endpoint. The name is a typed field the TLS connector already receives.

Fix drift found while hoisting the fixture inputs: provision_db declared the rule
set without the committed instances, and the per-shard variants declared neither,
so both would have failed the same way the sweep did. One FIXTURE_DATA list now.

- //build:run_id + //build:run_id_file (build/run_id.bzl)
- database_name() reads runfiles; validation split into a pure, tested function
  (7 new tests, all four guards mutation-verified)
- integration_env.exs reads the same file with the same guards, failing closed
- drop the GITHUB_RUN_ID/ATTEMPT --test_env passthrough from .bazelrc
- thread the flag through all 6 buildbuddy, 7 forgejo and 8 skill invocations
- add build/run_id.bzl to the integration workflow's paths filter

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…test.py was deleted

env-var-inventory.md §4.1 presented that test as the repository's existing
.bazelrc drift guard, and three verdicts in §9 rested on it pinning NATS_KEY_B64
and TEST_CNPG_PASSWORD. It is deleted, so those pins are gone: both names now
have no reader and no pin, which moves two "refuted" verdicts to partly
restored. §8's first recommendation asked to extend that guard, which is no
longer possible.

Two facts about it are worth carrying forward rather than losing with the file.
It had no py_test target, so make test never ran it despite §4.1 claiming it did;
and its setUp read .github/workflows/elixir-integration-sr-core.yml, deleted in
8ce61b5, so every test in it errored rather than asserted. A guard that is not
a build target is not a guard -- which is why its replacement is now specified as
a Bazel test target asserting both directions.

The same correction applies to this change's own proposal.md and tasks.md, which
told the reader the test "must be updated, not deleted" and that removing the
--test_env lines without it would turn make test red. Removing them is now
silent, which is worse.

Makefile: the comment explaining why BAZEL_CACHE_PROXY_CONFIG is left empty cited
that test as the thing enforcing every named --config exists in .bazelrc. Nothing
enforces it now; a stale value fails at invocation instead.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…ookup

Two implementations of one security decision drift, and these two already
had. srql's build_tls_connector read its CA from a FILE PATH and supported
client certificates; integration-db's tls_connector_for took PEM content and
refused client auth outright. Both are now rust/srql/src/tls.rs, which takes
content only -- a path is meaningful just on the host that resolves it, and
SecretManager yields content because a secret that must be a file on disk
cannot be a Kubernetes secret, a Docker secret and a developer's directory at
once. Two guards neither had: an empty CA bundle now fails loudly rather than
producing a root store that verifies nothing, and half a client identity is an
error rather than a silent fall back to anonymous against an mTLS server.

Six hand-rolled runfiles lookups are replaced by the Bazel ecosystem's
reference implementations. They were not merely untidy. The Rust ones read
only TEST_SRCDIR/RUNFILES_DIR, so they found nothing under `bazel run` --
prepare_template failed on exactly that -- and located files with Path::exists,
which cannot work in manifest mode. They also guessed the repository directory:
integration-db and manager_validator tried "_main" then "serviceradar", and the
srql harness tried the runfiles root, $TEST_WORKSPACE, "__main" and "__main__",
none of which is the canonical Bzlmod name. @rules_rust//rust/runfiles consults
the repo mapping the build emitted; it is published to crates.io, so cargo and
Bazel share one source.

Elixir needed no library. rules_elixir's ex_unit_test copies srcs and data to
${TEST_TMPDIR}/<workspace-relative path>, which is the rule's contract and
needs no repository name at all. Reaching into TEST_SRCDIR worked only because
the runfiles tree happens to carry the same files.

prepare_template no longer writes $GITHUB_OUTPUT -- a GitHub Actions concept
BuildBuddy does not set, faked in the workflow with a temp file that was then
grepped back. The caller branches on the line the binary prints, which keeps
the skip that saves 28.8-45.6s of BEAM startup on a branch adding no migrations.

Well-known database secret names move out of the fixture crate into
manager_config, with client_cert and client_key added. Two components
resolving "the database password" must ask for the same name, and while the
constants were private to integration-db they could not.

- rust/srql/src/tls.rs: postgres_connector + PgRustlsConnect, both callers on it
- deletes build_tls_connector, build_client_config, load_client_certs,
  load_client_key and a duplicate crypto-provider install
- runfiles crate added to [workspace.dependencies]
- provision_db_test needs no change: SERVICERADAR_TEST_DB_SHARDS is declared in
  the target's env, not ambient

7a remains open on rust/srql/src/config.rs, which is blocked on how a SERVICE
obtains the RuleSet (every consumer reads it from runfiles; a container has
none), and on the DSN/TLS half of the srql harness. Both are recorded in
tasks.md with the options.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
`load` took a `&RuleSet`, which put the rule set in the dependency graph of
every caller. That shape was wrong twice over. It claimed the rules vary, when
they vary by nothing -- not by environment, not by component, not by deployment.
And it leaked an implementation concern of the config layer into the API of
every consumer: a service had to obtain the rules before it could obtain its
configuration, and the only mechanism available was runfiles, which a container
does not have. That is precisely what kept rust/srql from using ConfigManager
at all.

Validation is unchanged -- there is still no entry point that returns an
unvalidated value. The rules are now an internal dependency of the config layer,
embedded at build time, in all three implementations: Rust `include_bytes!`, Go
`go:embed`, Elixir a compile-time module attribute with `@external_resource`.

Embedded, NOT shipped beside the instance in the mount. The instance is read
from a mount at runtime -- untrusted, and the thing being verified. Reading the
rules from that same mount would let whatever supplied a bad instance supply the
rules that bless it, which proves nothing. The asymmetry is the whole value of
the check: trusted rules, untrusted instance.

The .binpb is committed because `include_bytes!` and `go:embed` need a file
`cargo` and `go test` can reach, and `go:embed` cannot reach outside its own
package -- so there is one copy per language, exactly like the generated
protobuf bindings, and guarded the same way. Three diff_tests compare each copy
against protoc's output for config/rules/ruleset.textproto.

The BuiltIn instances (localhost, ci) are embedded for the same reason.
`Source::BuiltIn` means "compiled into the release", so a binary that cannot
produce them cannot run in those environments. The mounted kinds are
deliberately absent: those genuinely vary per deployment, which is why they are
mounted, and embedding them would let a release disagree with the platform about
what `demo` means.

No test may inject a rule set. A manager test that validates against invented
rules is not testing the system -- it tests against rules no deployment has, and
it lets the valid fixture drift from what the committed rules require. The
synthetic rule sets in all three languages are deleted; the fixtures now satisfy
the real 45 committed rules. The Rust fixture had only ever been checked against
2 invented ones.

`rust/srql/src/config.rs` loses all five PGSSL* reads. `DatabaseTls::resolve`
takes the posture and verification name from the committed instance and the
three PEMs from SecretManager, so no combination of variables can describe a
server the process is not talking to. AppConfig carries PEM CONTENT, not paths.

`integration_tests/srql/tests/support/harness.rs` goes 1117 -> 691 lines with no
schema-covered env reads left. The DSNs are assembled from typed fields instead
of mined for the owner and database name, which retires parse_fixture_pg_config,
normalize_sslmode_for_tokio_postgres, normalize_fixture_pg_connection_string,
normalize_postgres_url, parse_host_port, quote_pg_keyword_value, percent_decode,
decode_hex_digit, read_env_value, FixtureRootCert and TemporaryCaCert. Every one
of those existed to recover something the schema states, or to turn PEM content
into a file because a consumer wanted a path. TLS resolves through srql's own
DatabaseTls, so the harness verifies the fixture by the code path the service
uses rather than a second implementation.

That surfaced a live bug: the harness parses its DSN with tokio-postgres, and
the assembled DSN carries sslmode=verify-full, which tokio-postgres rejects
outright. Both parses now go through integration-db's shared parse_pg_config,
which strips the mode rather than rewriting it. Same defect class as the sslsni
fix, one layer over.

//config/rules:ruleset_binpb is gone from integration-db's FIXTURE_DATA -- the
fixture targets no longer declare the rule set at all.

Verified: bazel build and test exit 0 (18 pass, 7 skipped as macOS-incompatible
Elixir); cargo srql 467, manager_config 38, secret 21, validator 16,
integration-db 20; go ok. Each drift guard mutation-tested -- one appended byte
fails it, restoring passes.

Note: cargo passed while Bazel failed partway through. srql's BUILD names
first-party deps explicitly and `cargo_only = True` excludes them, so the three
manager deps had to be added by hand, exactly as rust/README_RUST.md warns.

The Elixir manager change is parse-verified only: those tests are SKIPPED on
darwin (Linux-only OpenSSL sysroot), so it needs a CI run before it is trusted.
Also restores the `### 7b` header, which an earlier tasks.md edit had eaten,
filing eleven Elixir items under 7a.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
… it does when unset

Phase 7 was scoped from `openspec/notes/env-var-inventory.md`, which measured the
schema-covered readers. Two passes over the Elixir tree produced two different
counts, so this measures both trees properly and writes down a per-name
destination -- the stated goal is zero environment reads, and that cannot be
planned against a count.

Adds `elixir-inventory.md` (676 names, 1173 read sites) and `go-inventory.md`
(173 names, 246 read sites), each with the full inventory, a substitution plan,
and a migration plan. Notes the measured scope in tasks.md at phase 7 and 7d.

Why the earlier counts were wrong: a scan for `get_env("` misses two read forms.
`"NAME" |> System.get_env("default")` puts the name BEFORE the call, so the
DEFAULT is read as the name; and thirteen project helpers take the name as an
argument. Four secrets were reachable only through a helper, `SECRET_KEY_BASE`
among them -- a grep-based exit criterion would have reported them as migrated
while they were not.

The finding that matters is section 3.1 of the Elixir document. 350 names are
set by nothing in this repository. When absent, six raise; the other 340 change
behaviour and say nothing. `nil` is not one behaviour but three, all silent: a
feature switches itself off (CLUSTER_GOSSIP_SECRET, so gossip clustering is
simply not configured), a credential goes absent rather than wrong
(AGENT_GATEWAY_NATS_USER passes `user: nil` into the NATS connection), or an
empty value substitutes for a real one (SERVICERADAR_HOSTED_CLUSTER_CONTRACT
decodes to `%{}`). Whole subsystems boot this way: the cold tier reads 31 names
of which 25 are nil, and 33 of the never-set names are credentials, none of
which raises. Explicit presence plus a validator that rejects a missing field
turns 301 of those into a boot-time error naming the field.

Similarity is checked two ways. A value read as `get_env(A) || get_env(B)` proves
A and B are one value: 63 such pairs in Elixir, so 676 names carry 622 distinct
values. Lexical similarity misses most of them -- GH_TOKEN = GITHUB_TOKEN,
SPIFFE_ENDPOINT_SOCKET = SPIFFE_WORKLOAD_API_SOCKET, and a three-way secret
alias across the edge crypto/recording integrity/onboarding encryption keys.

Go is roughly a quarter the size and better guarded: 20 of 107 never-set names
fail loudly, and only two reads are unresolvable against Elixir's 91. Three Go
findings change 7d. `go/pkg/config/env_loader.go` derives variable names at
runtime from JSON struct tags and accepts a whole config document through
SERVICERADAR_CONFIG_JSON, so no scan can enumerate it -- it is dead in
deployment (every chart sets CONFIG_SOURCE=file) and live in code, and should be
deleted rather than migrated. `NATS_CREDSFILE`, read by two shipped services, is
a spelling nothing sets; Helm and Elixir use `NATS_CREDS_FILE`. And zero of the
169 literal-name reads use `os.LookupEnv`, so no Go code can distinguish an
unset variable from an empty one -- the sole exception is the
`SERVICERADAR_ENV` read this change introduced.

Both documents propose the same partitions, because a single embedded config
message is a global rebuild trigger: the managers read their artifact at compile
time, so one message means one config change invalidates every consumer. The
measurement says the split is natural -- 88 of 108 Elixir consumer files and 45
of 62 Go ones read exactly one partition, with the four `runtime.exs` files the
only hubs.

No code changes; measurement and plan only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
@marvin-hansen marvin-hansen self-assigned this Aug 18, 2026
@gitguardian

gitguardian Bot commented Aug 18, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 15 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36296917 Triggered Generic Password 3239e92 config/manager_secret/elixir/test/manager_test.exs View secret
36296917 Triggered Generic Password 7e62ade config/manager_secret/rust/tests/types/manifest_tests.rs View secret
36296917 Triggered Generic Password 3239e92 config/manager_secret/elixir/test/manager_test.exs View secret
36296917 Triggered Generic Password 3239e92 config/manager_secret/go/manager_test.go View secret
36296916 Triggered Generic Password 3239e92 config/manager_secret/go/manager_test.go View secret
36296917 Triggered Generic Password 7e62ade config/manager_secret/rust/tests/types/manifest_tests.rs View secret
36296916 Triggered Generic Password 3239e92 config/manager_secret/elixir/test/manager_test.exs View secret
36296917 Triggered Generic Password 3239e92 config/manager_secret/elixir/test/manager_test.exs View secret
36296918 Triggered PostgreSQL Credentials 0511311 rust/integration-db/src/lib.rs View secret
36296917 Triggered Generic Password 3239e92 config/manager_secret/go/manager_test.go View secret
36296917 Triggered Generic Password 7e62ade config/manager_secret/rust/tests/types/secret_manager_tests.rs View secret
36296917 Triggered Generic Password 3239e92 config/manager_config/rust/tests/types/explain_tests.rs View secret
36296917 Triggered Generic Password 7e62ade config/manager_secret/rust/tests/types/secret_manager_tests.rs View secret
36296917 Triggered Generic Password 7e62ade config/manager_secret/rust/tests/types/secret_manager_tests.rs View secret
36296917 Triggered Generic Password 3239e92 config/manager_secret/elixir/test/manager_test.exs View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

mfreeman451 and others added 25 commits August 18, 2026 17:20
…es Hex

mix hex.audit started failing today because Hex published
EEF-CVE-2026-43971 (cowlib Link-header smuggling) against every cowlib
release from 2.9.0 through 2.19.0. The existing .deps_audit_ignore files
already covered 43966/43969 and the gun GHSA alias, so the quality gate
failed only on the new id.

There is still no Hex release that closes the range. Upstream has a git
fix at ninenines/cowlib@89da27ee. This is not a missing-dotfile issue
from the Forgejo-to-GitHub move -- the ignore files were already on the
branch.

Signed-off-by: Michael Freeman <mfreeman451@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
… from the alpine CDN.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
on BB executor and workflow.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…the CA

Four failures in a row on the fixture lifecycle, each hiding the next. The last
one was the design: SecretManager resolved `database.password` through
FileProvider(/etc/serviceradar/secrets), which nothing mounts on a BuildBuddy
runner -- or anywhere else.

Nothing mounts it in production either. //helm/serviceradar supplies every
credential through `valueFrom.secretKeyRef` -- 38 of them, CNPG_PASSWORD among
them -- which is a Kubernetes Secret projected as an ENVIRONMENT VARIABLE, not as
a volume. `grep -rn "mountPath: /etc/serviceradar/secrets" helm/ gitops/ docker/`
returns nothing. So the only provider all three languages shipped with matched no
deployment that exists, and CI was not a special case: it was the first
environment to actually ask.

EnvProvider resolves a logical name from SERVICERADAR_SECRET_<NAME>, a mechanical
transform of the manifest rather than a mapping table -- a caller that knows the
name can compute the variable, so a --test_env list is derived instead of
curated. The bridge it replaces forwarded 43 names, 38 of which nothing set.
EnvironmentProvider::for_kind selects it for every kind except `localhost`, which
keeps the file store because a developer machine has nothing injecting variables
into a test action. Selection now sits behind one constructor: three call sites
wrote `FileProvider::for_kind` themselves, which made the docstring claiming "a
component never names a provider" false.

An empty variable is absent, not an empty credential. A set-but-blank secret
authenticates as nobody and produces a confusing error from the server instead of
a clear one from here.

Two schema fields, because the fixture needs two things the schema could not say.

`database.admin_role`: the suite connects as the application role, which
deliberately lacks CREATEDB, so provisioning cannot use `connecting_role`.
Deriving it produced an admin DSN naming a role with no rights, which PostgreSQL
reports as a permission error and therefore reads like a missing GRANT. Its
password is a separate logical secret, `database.admin_password`.

`database.ca_bundle_url`: configuration, not a secret. A CA bundle is what a
client needs BEFORE it can authenticate anything, so it cannot itself be
authenticated material. A URL rather than the PEM, because a cert-manager issuer
rotates: any copy -- a CI secret, an instance file -- is correct until the next
rotation and then silently is not, which is what a stored
SRQL_TEST_DATABASE_CA_CERT did, expiring on a date nobody was watching. The PEM
now travels nowhere; every client reads the current bundle and no human rotates
anything.

While verifying the roles against the fixture rather than the instance file:
ci.textproto named `srql_test` for both connecting_role and owning_role, and no
such role exists. `pg_roles` has `srql` (the application role) and `srql_hydra`
(superuser). This would have been the next failure after the secret one, reported
as `role "srql_test" does not exist`. The same class of error is already
documented in integration-db's `database_owner()`, where an earlier port
hardcoded `serviceradar` -- a name the fixture has never had.

Four of this repository's own guards caught mistakes in this change, and are
worth naming because each one paid for itself here:

  * every_schema_field_carries_at_least_one_rule -- two new fields, no validation
  * every_rule_is_violated_by_at_least_one_fixture -- two new rules nothing would
    notice breaking
  * the per-language validator field registry -- unknown field path
  * instance_drift_ci_test -- a stale embedded ci.binpb

The new transform is covered by tests that were mutation-checked: inverting the
separator mapping in EnvProvider::variable_for fails both name tests.

Also in this commit, from the same CI run:

  * buildbuddy.yaml forwards the two secrets by name. BuildBuddy injects them into
    the runner shell and --test_env passes the value through without it reaching a
    log or a disk.
  * `bazel run //third_party/crate_mirror:sync` after adding ureq to
    integration-db. ureq was already vendored; the sync closed an unrelated gap,
    runfiles-0.3.0, which was in the lock with no archive -- the mirror is a
    fallback, so that had been degrading rather than failing.
  * tasks.md records what 7b now depends on: EnvProvider exists only in Rust, and
    Go and Elixir need it before either can resolve a secret in CI or in cluster.

Verified: `bazel test -c opt --config=ci //... --test_tag_filters=-integration_test,
-acceptance_test` -> 168/168 pass. The integration path still needs the fixture,
so provisioning is exercised by the next CI run, not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…I through the internet

Two CI failures, one cause, and it was never DNS.

BuildBuddy REJECTs every RFC1918 destination from an action's network namespace.
server/util/networking/networking.go builds, per container network:

  for allow in task_allowed_private_ips: -I FORWARD/INPUT -d <allow> -j ACCEPT
  for r in {10/8, 172.16/12, 192.168/16, 169.254/16}: -I FORWARD/INPUT -d <r> -j REJECT

with the allowlist inserted above the rejects. It is SSRF protection. REJECT
rather than DROP is why every symptom was an instant "Connection refused" instead
of a timeout -- it reads like a dead service rather than a firewall.

That single rule explains the whole sequence. 10.43.x ClusterIPs, 10.42.x pods,
10.0.2.x nodes and the 192.168.6.x LAN pool were all rejected; 23.138.124.18 was
not, because it is a public address that merely happens to live on our own L2.
So every green run before this reached the fixture and the CA over publicly
routable addresses, and the failures began the moment the endpoint moved into
config as srql-fixture-rw.srql-fixtures.svc.cluster.local.

The fix is the supported knob, on BOTH fleets:

  task_allowed_private_ips: [10.42.0.0/16, 10.43.0.0/16]
  oci.dns: "10.43.0.10"

The pod CIDR is the non-obvious half. kube-proxy DNATs a ClusterIP to a pod IP in
nat PREROUTING, which runs BEFORE the filter FORWARD chain, so by the time these
rules match, the destination is already 10.42.x.x -- allowing only the service
CIDR leaves every ClusterIP connection rejected at its rewritten address.
Deliberately not 10.0.0.0/8, which would also expose the node subnet; 169.254/16
stays blocked, that is cloud metadata.

executor.oci.dns defaults to "8.8.8.8" and the runtime WRITES a resolv.conf
holding that one nameserver into every action container, then bind-mounts it over
/etc/resolv.conf (ociruntime.go:642 and :1340) -- which is why cluster.local came
back NXDOMAIN from a resolver that answered perfectly well. One CoreDNS address
serves both worlds: the Corefile ends in `forward . /etc/resolv.conf`, so it is
authoritative for cluster.local and forwards everything else.

WHAT I TRIED FIRST AND WAS WRONG ABOUT, recorded because the next person will
reach for both:

  * `oci.dns: ""` alone. It bind-mounts the pod's resolv.conf, whose nameservers
    the namespace could not reach while RFC1918 was blocked -- so it replaced one
    failing lookup with EVERY lookup failing, public names included. Deployed and
    reverted within minutes. With the allowlist in place it would work; the
    explicit address is kept because that file also lists an IPv6 upstream this
    namespace has no route to.
  * `dockerNetwork: "host"`. Accepted by GetEffectiveDockerNetwork, ignored by the
    OCI runtime, which reduces the property to `networkEnabled: mode != "off"` and
    then always calls CreateContainerNetwork. Under `oci`, "", "bridge" and "host"
    are the same thing. Host networking exists only for podman/docker isolation.

With the cluster reachable, ci.textproto names in-cluster endpoints for the
database, its TLS server name, and the CA bundle. Nothing on the CI path leaves
the cluster now -- which also matters because the published
srql-fixture-ca.serviceradar.cloud endpoint has been switched off and answers
404. buildbuddy_setup_fixture_env.sh follows, and drops the public fallback
rather than keeping a dead URL that would only add a misleading failure line.

Also here, from the same investigation:

  * The build fleet's filecache goes 150 -> 100 GB, with the node arithmetic in
    both files updated to match (worst case ~380 GB of 589 GB when a node also
    carries the workflow fleet's 50 GB).
  * The kubectl branch of the CA resolver now reports WHAT kubectl said. "no" and
    "cannot reach the API server" have different fixes -- a RoleBinding versus a
    route -- and `>/dev/null 2>&1` collapsed them into one indistinguishable line.

Verified: //config/... and //rust/integration-db/... 27/27 pass. The in-cluster
path was proven end to end against the live fixture from an action container, with
a deliberately wrong password, so the only remaining error is
`FATAL: password authentication failed for user "srql_hydra"` -- everything before
it (cluster DNS, ClusterIP routing, CA fetch, verify-full) succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…pile the integration targets

Two changes that the same CI step was hiding from each other.

MIGRATE_TEMPLATE

test/db/template_env.exs read SRQL_TEST_DATABASE_URL -- a whole DSN carrying
host, port, database, user, password and sslmode in one string -- and rewrote its
path to point at the template. That made a BuildBuddy secret the source of an
ENDPOINT: the fixture's address lived outside this repository, so it could not be
reviewed, could not differ per environment without a second secret, and had to be
parsed back apart to change any one component of it.

Now SERVICERADAR_ENV names an environment, //config/environments/<kind>.textproto
declares the coordinates as typed fields, and SecretManager resolves the one thing
that is actually secret. The database name is SUBSTITUTED into an assembled DSN
rather than rewritten into a parsed one.

The resolution lives in a new test/db/fixture_config.exs rather than in
template_env.exs, because test/db/integration_env.exs needs exactly the same thing
when the suite converts, and a second copy of "how to reach the fixture" is how the
two drift. It is loaded with `-r` for the same reason template_env.exs is: the
values must be in the environment before config/test.exs builds the Repo settings,
which is earlier than any module in lib/ exists.

System.put_env stays, and is not an oversight. It is a handoff INSIDE one OS
process to a Config script that runs before our code can hand it a value any other
way. What changed is where the values come from.

The instance is a DECLARED BUILD INPUT (//config/environments:{ci,localhost}_binpb
in `data`), so the configuration under test is the artifact the build just produced
and validated -- the same contract //rust/integration-db has, rather than a file
found on the ambient filesystem.

Supporting pieces: ServiceradarSecret.Names mirrors //config/manager_config/rust
secrets.rs, so both languages ask for the same logical name -- which matters
because that name is what EnvProvider.variable_for/1 turns into a variable, and a
component that spells it differently silently reads one nothing sets.

Verified against the live fixture from an action container with a deliberately
wrong password:

  FATAL 28P01 (invalid_password) password authentication failed for user "srql"

Everything before authentication therefore succeeded: SERVICERADAR_ENV parsed, the
compiled instance located in runfiles and loaded, the DSN assembled with
connecting_role and the template database, the CA fetched from the in-cluster
bundle over cluster DNS, and TLS negotiated. Only the password was wrong, on
purpose.

THE COMPILE ERROR IT WAS HIDING

//integration_tests/srql failed the database sweep with

  error[E0432]: unresolved import `serviceradar_integration_db`
                no external crate `serviceradar_integration_db`

which reads like a missing Cargo dependency and is a missing BUILD dep:
tests/support/harness.rs resolves the fixture through //rust/integration-db, and
`all_crate_deps(cargo_only = True)` reports only third-party crates -- deliberately
-- so a first-party edge has to be named.

It reached CI because NOTHING COMPILED IT. These targets are `integration_test`-
tagged plus requires_shared_fixture(), and the earlier `bazel build //...` runs
without --//build:enable_integration_tests, so they are incompatible and a wildcard
SKIPS rather than builds them. The error could only appear in the last step, after
a fixture had been provisioned.

So //buildbuddy.yaml now passes that flag to the build step. It starts no database
and runs no test -- it only makes those targets compatible, so they are actually
compiled, and a missing dep fails in minute two instead of minute twenty. Checked
across the whole repo: `bazel build --//build:enable_integration_tests //...` is
exit 0 with no other target in this state.

Verified: `bazel test -c opt --config=ci //... --test_tag_filters=-integration_test,
-acceptance_test` -> 170/170 pass.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
The workflow changed configuration-affecting options at nearly every step, and
Bazel discards the analysis cache for the whole server whenever they change. With
~80k targets that is the dominant fixed cost of a run, and it was being paid four
times.

THE EXPENSIVE ONE was a missing `-c opt`. The database lifecycle's $FLAGS had none,
so its final `bazel test $FLAGS //...` did not merely re-analyse the repo -- it
RECOMPILED it, in fastbuild, sharing no action with the opt build two steps
earlier. A second full build of everything, to run the integration tests.

The rest is flag hygiene. Every invocation that touches //... now carries the same
`--//build:enable_integration_tests`. It runs nothing new anywhere: the unit sweep
still excludes integration_test by tag, and the lifecycle targets are `manual`, so
no wildcard reaches them. It is there so the value never toggles, because a
Starlark build setting is part of the configuration and flipping it between two
otherwise identical invocations is a full discard. The same reasoning adds both
settings to the `buildbuddy_setup_fixture_env` run, which reads neither.

Ordering carries the remainder. The go race step sets
`--@io_bazel_rules_go//go/config:{pure,race}` -- that IS the point of the step, so
it cannot share a configuration with anything else and its discard is unavoidable.
It used to sit between the unit sweep and the database lifecycle, where it cost two
discards instead of one; it now runs last. `//:images` moves ahead of the lifecycle
so the once-per-run `--//build:run_id` change is the final switch rather than one
in the middle.

Remaining, and deliberately not addressed here:

  * `--//build:run_id` changes every run by construction, so it discards once per
    run. Removing that would mean sourcing the id from workspace status
    (`ctx.version_file`), which is designed for exactly this -- values that change
    per build without invalidating analysis. That is a redesign of //build:run_id,
    not a workflow edit, and //build/run_id.bzl's reasoning about it being a leaf
    covers ACTION keys, not the analysis cache.
  * The go race configuration could get its own `--output_base` so the main
    server's analysis survives it entirely, trading disk and a first-run fetch.

Verified with CI's exact flags: build //... incl. integration targets exit 0; unit
sweep 170/170; //:images exit 0; go race 52/52.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
config/ had SEMANTICS.md, which specifies the validation engine formally, and
nothing that answered "what is this and how do I use it". The tree holds a schema,
five committed instances, a rule set, conformance fixtures and three
implementations each of three components; the entry point for someone meeting it
was a BUILD file comment.

This is the missing half: the one-variable contract, the layout and why it is one
tree, the compile pipeline from .textproto to the per-section artifacts, what
`load` always does, usage in all three languages for both managers, the
secret-name-to-variable transform, an inventory of what each guard prevents, and
recipes for adding a field, an environment or a secret.

Written against the source rather than from memory, which caught four things I
would otherwise have documented wrongly:

  * file_phase_tests asserts every committed INSTANCE satisfies every file-phase
    rule -- "the gate that makes the rule set real" -- not predicate semantics.
  * Only Elixir and Go have committed bindings to refresh. Rust's come from
    proto_bindings/rust/build.rs at build time, which is why there are four
    binding_drift_* tests and none for Rust.
  * All three languages resolve a field path through a hand-written match, so
    adding a schema field means teaching three of them; an unhandled path is
    `unknown field path`.
  * predicate_law_tests asserts each predicate obeys its algebraic law and is
    total.

Section 10 records the gaps rather than describing the intended end state as
though it were current: Go has no EnvProvider, so a Go service cannot resolve a
secret from the environment; nothing mounts /etc/serviceradar/environment.binpb,
so the deployed kinds have no instance to load; and adoption is partial. A README
that overstates readiness is worse than none, because the next person builds on
it.

Every Bazel target named here was checked to resolve, and the file is ASCII only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…guration

# Conflicts:
#	buildbuddy_setup_fixture_env.sh
#	elixir/datasvc/.deps_audit_ignore
#	elixir/serviceradar_agent_gateway/.deps_audit_ignore
#	elixir/serviceradar_core/.deps_audit_ignore
#	elixir/serviceradar_core_elx/.deps_audit_ignore
#	elixir/web-ng/.deps_audit_ignore
#	elixir/web-ng/mix.exs
#	k8s/srql-fixtures/ca-bundle.yaml
--disk_cache went out when the caches were moved off the source tree and was never
added back at a correct path. It returns on build:remote_base, machine-local under
~/.cache and capped, so a locally executed action that already ran is not re-run.

Disabled on CI. `local_cache_size_bytes` in //k8s/buildbuddy is the executor's
filecache; --disk_cache is the client's, and on CI the client runs inside the
runner container on that same node disk, under root_directory. It would duplicate
the executor cache outside the disk budget, and hold almost nothing anyway since
--remote_download_minimal leaves remotely executed outputs remote.

Also restores the rule about not putting caches under the source tree, which the
distdir line still cited after it was deleted.

Verified from a clean state: --config=ci creates no cache directory,
--config=remote does, and a build succeeds under each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
--disk_cache, --repository_cache and --repo_contents_cache move to
build:remote_base under ~/.cache/serviceradar-bazel. HOME is /root in a
BuildBuddy action container (measured on an RBE executor), so the same path is
valid on the runner and on a workstation, and --config=ci can no longer name a
directory only the pods have -- it previously failed with "could not acquire lock
on repo contents cache" anywhere else. Only the GC cap stays per profile.

CI persistence comes from a new hostPath, /mnt/buildbuddy/bazel-caches, bind-
mounted into action containers at /root/.cache/serviceradar-bazel, so the caches
outlive pod replacement. Deliberately not under cache-volume: BuildBuddy's
filecache assumes it owns local_cache_directory and evicts against its own
accounting.

Without the mount the paths still resolve inside the runner, so this is safe to
land ahead of the helm upgrade -- it just does not persist yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
--disk_cache, --repository_cache and --repo_contents_cache sit on
build:remote_base under ~/.cache/serviceradar-bazel. HOME is /root in an action
container, so the same paths resolve on a runner and a workstation; ci and remote
differ only in the GC cap, so interchanging them cannot name a missing path.

Both fleets mount a dedicated hostPath at /root in action containers, separate
from cache-volume: BuildBuddy's filecache assumes it owns local_cache_directory
and evicts against its own accounting. Paths are distinct per fleet, matching the
existing cache / cache-workflows split, and hostPath is per node so each executor
keeps its own.

Sharing one directory between replicas on a node is fine: Bazel documents the repo
contents cache as holding fetched repo directories shareable across workspaces.
Keying it per pod was rejected -- the only identity the chart exposes is
K8S_POD_UID, which changes on restart and would discard the cache on the event it
exists to survive.

Deployed: build fleet rev 54, workflow fleet rev 14, both rolled out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
--disk_cache, --repository_cache and --repo_contents_cache sit on
build:remote_base under ~/.cache/serviceradar-bazel. HOME is /root in an action
container, so the same paths resolve on a runner and a workstation; ci and remote
differ only in the GC cap, so interchanging them cannot name a missing path.

Both fleets mount a dedicated hostPath at /root in action containers, separate
from cache-volume: BuildBuddy's filecache assumes it owns local_cache_directory
and evicts against its own accounting. Paths are distinct per fleet, matching the
existing cache / cache-workflows split, and hostPath is per node so each executor
keeps its own.

Sharing one directory between replicas on a node is fine: Bazel documents the repo
contents cache as holding fetched repo directories shareable across workspaces.
Keying it per pod was rejected -- the only identity the chart exposes is
K8S_POD_UID, which changes on restart and would discard the cache on the event it
exists to survive.

Deployed: build fleet rev 54, workflow fleet rev 14, both rolled out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
…general build.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Run two still re-fetched every external repo, and /bazel-caches on the workflow
node was 0 bytes despite the mount and the cache paths both being live.

The runner image sets no ENV HOME, so ci_runner.go ensureHomeDir redirects HOME to
{workdir}/.home, which is destroyed with the runner. Bazel wrote --disk_cache,
--repository_cache and --repo_contents_cache there every run and threw them away.
ensureHomeDir returns early when HOME is already set, so setting it in the action
env is enough to make ~ resolve to the mounted volume.

Ruled out first: the config was live (330e207 landed 06:08 UTC, run at 06:20),
and oci.mounts is not gated -- the authentication check is in
initPersistentVolumes, a different feature.

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
Two independent causes.

The Elixir shards failed at random because bootstrap_app_role! did check-then-
create on a CLUSTER-WIDE role: concurrent bootstraps both saw it absent, both ran
CREATE ROLE, and one raised 42710. It now converges on ALTER via the module's own
duplicate_ddl_error?/1. Production code, not test-only.

The three //integration_tests/srql targets were green while asserting nothing.
SRQL_IT_DATA carried neither the run id nor the compiled instance, so
Fixture::from_env() failed and the harness took its "no fixture, skip" path. With
the data declared they run, which surfaced the rest:

  * all three reset database.database -- the shared fixture. Each now derives a
    disposable per-target database and calls assert_disposable, the guard
    //rust/integration-db already owned and this bypassed.
  * the "admin" DSN resolved connecting_role, which ci.textproto denies CREATEDB.
  * install_required_extensions used a bare parse, so sslmode=verify-full was
    rejected rather than stripped.
  * four tests share one binary and reset one database on parallel libtest
    threads; RUST_TEST_THREADS=1 serializes them.

Removed what turned those into "flake": the 3x seed retry (it only ever helped
when another target was resetting underneath it) and unwrap_or(false) on the AGE
probe (it downgraded every AGE-gated assertion when the check itself broke).

The bootstrap scratch database was named outside the sr_core_test_ prefix that
sweep_stale collects, so the leak backstop its comment promised did not exist.

Verified: full unit gate 170/170; //rust/integration-db + //config 27/27 including
a new case pinning assert_disposable("srql_fixture"); all integration targets
compile under --//build:enable_integration_tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Marvin Hansen <marvin.hansen@gmail.com>
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