Skip to content

feat: move routing into a project __routing__.py with a RoutingTable - #17

Merged
OmerBaddour merged 5 commits into
mainfrom
worktree-ob-project-routing-file
Aug 2, 2026
Merged

feat: move routing into a project __routing__.py with a RoutingTable#17
OmerBaddour merged 5 commits into
mainfrom
worktree-ob-project-routing-file

Conversation

@OmerBaddour

@OmerBaddour OmerBaddour commented Aug 1, 2026

Copy link
Copy Markdown
Member

Routing moves out of ~/.clair/environments.yml and into a project __routing__.py. Credentials stay global and inert. Routing becomes Python, and the team commits it.

Supersedes #4.

The split

File Holds Commit it?
~/.clair/environments.yml Connection settings and credentials No
<project>/__routing__.py The routing rules Yes

Credentials 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

from clair import RoutingEntry, RoutingTable, TrouveAddress


class DeveloperRouting(RoutingEntry):
    environment_name: str = "dev"
    user_variable: str = "CLAIR_USER"

    def route(self, trouve_address: TrouveAddress) -> TrouveAddress:
        user_name = os.environ[self.user_variable].upper()
        return trouve_address.model_copy(
            update={"database_name": f"{trouve_address.database_name}_{user_name}"}
        )


routing = RoutingTable(entries=[DeveloperRouting()])
  • TrouveAddress holds a database_name, a schema_name, and a table_name, and validates each one on construction. An address that exists is a valid Snowflake identifier. It is frozen and hashable.
  • RoutingEntry is 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.
  • RoutingTable holds the entries and rejects two entries for one environment name, because only one can win.

That CLAIR_USER field 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 init template 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, so DatabaseOverrideRouting validated nothing.

clair validate

A credential-free command, for CI. compile and run stop at the first bad address, because they must not write to a wrong target. validate instead reports every problem at once, then the collisions, and exits 1 on any problem.

  environment: dev
  routing file: /home/alice/project/__routing__.py
  entry: DeveloperRouting(environment_name='dev', user_variable='CLAIR_USER')
  Trouves to route: 12

  ✓ Every routed name is valid. No collisions.

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:

  1. An unknown key in an environments.yml block is an error, through extra="forbid". A leftover routing: block therefore stops the run. Pydantic would otherwise drop the key without a word.
  2. A __routing__.py that 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.

  • A callable or a dict in __routing__.py is an error. The file must give the name routing a RoutingTable.
  • An unknown key in an environments.yml block is an error.
  • A directory name or a file name that Snowflake cannot use as an identifier stops discovery. Before, clair found the fault at run time, in Snowflake.

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

  • Every environment variable that clair reads takes the CLAIR_ prefix.
  • CLAUDE.md records the v0 no-backwards-compatibility stance.

Verification

  • 579 tests pass, up from 513 on main.
  • ruff check src/ tests/ clean.
  • End-to-end CLI run on a scaffolded project: validate passes with CLAIR_USER set and exits 1 without it, compile writes to analytics_OBADDOUR/finance/revenue.sql, --env prod stays quiet, --env typo warns, a my-db directory fails, and a stale routing: block fails with the migration message.

Docs

Rewrote guides/routing.md, added cli/validate.md, and corrected every page that named a routing policy.

🤖 Generated with Claude Code

OmerBaddour and others added 3 commits August 1, 2026 18:52
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.
@OmerBaddour OmerBaddour changed the title feat: move routing into a project __routing__.py with callable rules feat: move routing into a project __routing__.py with a RoutingTable Aug 2, 2026
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@OmerBaddour
OmerBaddour marked this pull request as ready for review August 2, 2026 23:27
@OmerBaddour
OmerBaddour merged commit 09edbb4 into main Aug 2, 2026
3 checks passed
@OmerBaddour
OmerBaddour deleted the worktree-ob-project-routing-file branch August 2, 2026 23:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant