feat: move routing into a project __routing__.py with a RoutingTable - #17
Merged
Conversation
Routing and credentials want opposite things, so they now live apart.
- ~/.clair/environments.yml stays YAML and holds credentials only. It is
inert: no imports, no clair version coupling, no logic.
- Routing moves into a project-level __routing__.py, keyed by environment
name. That name is the join key between the two files. The file is
checked in: routing is a team decision, not a secret.
A routing rule is now a RoutingConfig subclass or any callable
(database_name, schema_name, table_name) -> "database.schema.table".
A callable rule reads the environment, so one committed rule gives each
person a separate target:
"dev": lambda database_name, schema_name, table_name: (
f"{database_name}_{os.environ['CLAIR_USER'].upper()}"
f".{schema_name}.{table_name}"
)
Python cannot statically validate a callable, so route() validates its
output instead: 3 dot-separated parts, each a legal unquoted identifier
within 255 characters. This closes a pre-existing gap where
DatabaseOverrideRouting validated nothing at all.
Adds `clair validate`: applies the rules to every Trouve and reports all
problems plus collisions at once. It needs no Snowflake credentials, so
CI runs it on every change. compile and run validate too (fail-fast via
route()), and point at `clair validate` for the full list.
Two guards against silent writes to production:
- A leftover `routing:` block in environments.yml raises instead of being
dropped by pydantic.
- A __routing__.py that omits the active environment warns. An explicit
`"prod": None` is a decision and stays quiet.
Other fixes carried over from the closed #4 review:
- Collision messages describe a callable by its source, not "callable".
- The routing loader registers its module in sys.modules and caches by
(path, mtime), matching discovery._load_config_file. A rule that reads
a secret store runs one time, not once per load.
- Callable parameters are database_name/schema_name/table_name per
CLAUDE.md, since they are user-facing API.
Docs (README, site-docs, specs) still reference the old layout and are
the next step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…jects Routing moves from a dict of callables to three Pydantic types. The validation now happens when the code makes an address, not after a rule runs. * TrouveAddress holds a database_name, a schema_name, and a table_name. It validates each name on construction, so an address that exists is a valid Snowflake identifier. The model is frozen and hashable. * RoutingEntry is the abstract base class for one environment's rule. A user writes a subclass with an environment_name and a route method, which accepts one TrouveAddress and gives one TrouveAddress. * RoutingTable holds the entries. It rejects two entries for one environment name, because only one entry can win. The three types are public: `from clair import RoutingEntry, RoutingTable, TrouveAddress`. Behaviour changes: * A callable or a dict in __routing__.py is now an error. The file must give the name `routing` a RoutingTable. * An unknown key in an environments.yml block is now an error. A routing block from an older version of clair therefore stops the run, and the message tells the user to move the rule. * A directory name or a file name that Snowflake cannot use as an identifier now stops discovery. Before, clair found the fault at run time, in Snowflake. The class name and the field values replace inspect.getsource() in the CLI messages. This deletes the source text mangling, and a rule now has a stable description even with no source file. Docs: rewrite the routing guide, add a clair validate page, and correct every page that named a routing policy.
The validate command now keeps the (logical name, type) pair, not the Trouve. The type checker cannot narrow trouve.compiled across a list comprehension, and the name is the only part that the collision report needs. The tests that exercise a runtime error now say so in a way that the type checker accepts: model_validate for an absent field, setattr for a frozen model, and model_dump to read a field of a user subclass.
OmerBaddour
commented
Aug 2, 2026
Member
Author
There was a problem hiding this comment.
I don't think we should have this. I think instead we should look to site_docs as the source of truth, and make sure after each PR that it is completely consistent with the code changes made.
OmerBaddour
commented
Aug 2, 2026
Member
Author
There was a problem hiding this comment.
this feels like a mistake.. remove
macOS made a copy of each file with " 2" in the name. A git add -A command then put the copies in the branch. The original files stay.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Routing moves out of
~/.clair/environments.ymland into a project__routing__.py. Credentials stay global and inert. Routing becomes Python, and the team commits it.Supersedes #4.
The split
~/.clair/environments.yml<project>/__routing__.pyCredentials and routing want opposite things. Credentials want to sit outside the repo and stay boring: no imports, no logic, no coupling to the clair version that a project pins. Routing wants to be expressive, reviewable, and version-matched to the project. The environment name joins the two files.
The three types
TrouveAddressholds adatabase_name, aschema_name, and atable_name, and validates each one on construction. An address that exists is a valid Snowflake identifier. It is frozen and hashable.RoutingEntryis the abstract base class for one environment's rule. A subclass adds a field for each value that the rule needs, and Pydantic validates the fields.RoutingTableholds the entries and rejects two entries for one environment name, because only one can win.That
CLAIR_USERfield is the argument for the whole design: one committed rule that resolves to a different target for each person. No per-developer config, nothing gitignored. YAML could not express it.clair ships no concrete entry. The
clair inittemplate writes two, and the guide explains how to write one.Validation moved to construction
An earlier draft of this work made a rule a plain callable, which removed the static validation that Pydantic gave the typed configs. The replacement was to inspect the output after each rule ran.
Putting the validation in
TrouveAddress.__init__is better. A rule that gives an address gives a correct address, or it raises. The check happens once, in one place, for every rule kind and for the logical names that the file system gives.This also closes a pre-existing gap: the identifier and 255-character checks used to live only inside
SchemaIsolationRouting.apply, soDatabaseOverrideRoutingvalidated nothing.clair validateA credential-free command, for CI.
compileandrunstop at the first bad address, because they must not write to a wrong target.validateinstead reports every problem at once, then the collisions, and exits 1 on any problem.Two guards against silent production writes
The join-key design has one scary failure mode: a rule that does not apply means the writes land on the logical, that is production, names. Two guards:
environments.ymlblock is an error, throughextra="forbid". A leftoverrouting:block therefore stops the run. Pydantic would otherwise drop the key without a word.__routing__.pythat does not name the active environment warns. An entry for that environment stays quiet, because an explicit passthrough is a decision, not a typo.Behaviour changes
The major version is 0, so this PR keeps no backwards compatibility and adds no migration path.
__routing__.pyis an error. The file must give the nameroutingaRoutingTable.environments.ymlblock is an error.Why a class beats a callable
The class name and the field values replace
inspect.getsource()in the CLI messages. That deletes the source-text mangling that the callable design needed, and a rule keeps a stable description even when no source file is available.Also
CLAIR_prefix.CLAUDE.mdrecords the v0 no-backwards-compatibility stance.Verification
ruff check src/ tests/clean.validatepasses withCLAIR_USERset and exits 1 without it,compilewrites toanalytics_OBADDOUR/finance/revenue.sql,--env prodstays quiet,--env typowarns, amy-dbdirectory fails, and a stalerouting:block fails with the migration message.Docs
Rewrote
guides/routing.md, addedcli/validate.md, and corrected every page that named a routing policy.🤖 Generated with Claude Code