Direct reads of .tsqoba/tsqoba.toml and raw event-string matches look different, but both create side doors next to an interface that already owns the job.
A direct read of .tsqoba/tsqoba.toml and a raw string match on an event name look like two unrelated shortcuts. In practice they create the same maintenance problem: a caller rebuilds translation logic that already has an owner.
The shortcut rarely arrives as a grand design choice. It usually arrives as one local convenience. A file path is nearby, so the caller opens it directly. A string token is nearby, so the caller branches on it directly. Both feel harmless in one function. Both become expensive once other call sites copy them.
The same side door twice
When the shortcut becomes precedent, the repository now has two interfaces for one job:
- the owned boundary;
- the local copy.
After that, readers stop knowing which one is authoritative. Fixes land in one path but not the other. Tests and call sites drift. The code gets harder to reason about not because the logic is deep, but because translation no longer happens in one place.
Repo config should enter through one loader
Current repo-config access already has a dedicated adapter. It returns parsed and resolved values instead of asking each caller to rebuild path and parse behavior locally.
pub fn load_v1_from_repo_root( repo_root_physical: &Path, ) -> Result<TsqobaConfigV1, TsqobaConfigLoadError> { ... } pub fn load_resolved_v1_from_repo_root( repo_root_physical: &Path, ) -> Result<Option<ResolvedTsqobaConfigV1>, TsqobaConfigLoadError> { ... }
The write lane protects that boundary explicitly. Current rule RUST-SEMGREP-TSQOBA-TOML-DIRECT-ACCESS-001 rejects ad-hoc reads of .tsqoba/tsqoba.toml outside the repo-config adapter path.
That rule matters because path resolution and parsing are part of the interface. Once callers reopen the file on their own, the adapter stops being the thing that really owns repo-config behavior.
Event names should become a type at the edge
The same pattern shows up with event names. Matching directly on raw strings is a second, weaker enum living in call-site code.
The repository names that problem explicitly through RUST-ANTI-DYN-STRINGLY-MATCH-001: convert inputs to a typed enum at the edge and match on the enum inside core logic.
A preserved boundary failure makes the point concrete. The offending code matched directly on envelope.payload.event_type.as_str(). That is short in the moment. It also turns string tokens into the real registry of behavior while the typed projection becomes optional.
Parse once, then branch on the typed form
Current code already points in the cleaner direction. The event type still arrives as a string at the edge, but the branch happens only after a parse step into SystemProjectionKindV1.
enum SystemProjectionKindV1 { ... } impl SystemProjectionKindV1 { fn parse(event_type: &str) -> Option<Self> { ... } } pub(super) fn project_system_event( envelope: &AuditEventEnvelopeV1, ) -> Option<EventMsg> { let kind = SystemProjectionKindV1::parse( envelope.payload.event_type.as_str() )?; match kind { ... } }
This split keeps text-to-type translation in one owned place. The rest of the code can branch on meaning instead of branching on tokens.
Why this matters in practice
Neither shortcut looks dramatic. That is exactly why both are risky: they spread as precedent.
Once a repository has one owned loader and several local copies, or one typed enum and several raw string branches, later maintenance becomes archaeology.
The practical rule is simple:
- load repo config through the boundary that already resolves and parses it;
- parse event names once at the edge;
- branch on typed values after that;
- do not add local copies just because the file or string is nearby.
This is the same boundary mistake in two forms: moving translation out of the interface that already owns it.
