@nodii/doctrine-lint
v0.2.0
Published
Nodii doctrine CI gates as one dependency: the D417 naming-conventions rules (gRPC FQN / RBAC key / outbox topic) plus the CROSS-TENANT RLS rule that blocks the membership-union policy shape and the untenanted user-session DB client — as named exports and
Readme
@nodii/doctrine-lint
Nodii doctrine CI gates as one dependency. Two bins ship here:
| Bin | Gate |
|-----|------|
| naming-conventions-lint | D417 naming conventions — gRPC FQN / RBAC key / outbox topic |
| cross-tenant-lint | Cross-tenant RLS — the membership-union policy shape and the untenanted user-session DB client |
Both exit 1 on any violation. Neither has an advisory mode.
Gate 1 — D417 naming conventions
Replaces the hand-vendored scripts/review/naming-conventions-lint.ts that had been copied into
14 service repos and had drifted into four incompatible variants — so the "single source of
truth" enforced a different rule depending on which repo you happened to be in.
Surfaces enforced
| # | Surface | Rule |
|---|-----------|------|
| 1 | gRPC FQN | served protos: package === nodii.<this-svc>.v1; vendored/client protos: nodii.<svc>.v1 (any svc) |
| 2 | RBAC keys | <module>.<resource>.<verb>[.<qualifier>] — 3-4 dot segments, lowercase + underscores, no colons, no hyphens |
| 3 | Outbox | <emitting-svc>.<aggregate>.<event>.vN — the .vN is MANDATORY |
CI adoption (one line)
// package.json
"gate:naming": "bunx naming-conventions-lint"# .github/workflows/ci.yml
- run: bunx naming-conventions-lintAdd @nodii/doctrine-lint as a devDependency and delete
scripts/review/naming-conventions-lint.ts.
CLI
bunx naming-conventions-lint # slug from ./package.json
NAMING_LINT_SVC=tenant bunx naming-conventions-lint
bunx naming-conventions-lint svc=tenant # explicit arg (wins over env)
bunx naming-conventions-lint exclude=docs/ # extra path prefixes
bunx naming-conventions-lint --lint-colon-keys # disable the D430 colon carve-outEnv equivalents: NAMING_LINT_SVC, NAMING_LINT_EXCLUDE (comma-separated),
NAMING_LINT_COLON_KEYS=1. Exit 1 on any violation, 0 otherwise.
The output is a frozen byte contract — services grep it in CI, so it is byte-identical to
what the vendored copies printed. tests/cli.test.ts pins it.
Bun only. The scanner uses
Bun.Glob, exactly as all 14 vendored copies did, and the bin ships#!/usr/bin/env bun. Running the built bin under plainnodefails withCannot find package 'bun'. Every consuming repo already runs Bun in CI;engines.bundeclares the requirement.
Library
import { RX_OUTBOX, isCanonicalOutboxTopic, lintNamingConventions } from "@nodii/doctrine-lint";
expect(isCanonicalOutboxTopic("tenant.tenant.created.v1")).toBe(true);
expect(isCanonicalOutboxTopic("tenant.usage.flush")).toBe(false); // missing .vN
const { violations, ok } = lintNamingConventions({ root: process.cwd() });Variant provenance
Four distinct copies existed across 14 repos (hashes taken at consolidation time):
| Variant | Lines | Repos |
|---------|-------|-------|
| V-A | 208 | nodii-planning-hub (canonical upstream) |
| V-B | 207 | nodii-crm-service |
| V-C | 148 | nodii-auth, nodii-customer-service, nodii-hr-service, nodii-kyc-service, nodii-notification-service, nodii-task-tracking, nodii-tenant-service, nodii-ticketing-service |
| V-D | 140 | nodii-billing-service, nodii-edge, nodii-geo-service, nodii-product-service |
Most of the line-count delta is formatting (prettier multi-line vs compact) and null-safety guards. Only two axes were semantic, and both are now explicit configuration rather than a silent choice:
C1 — the D430 colon carve-out (d430ColonCarveOut, default true)
V-B/V-C skip COLON-delimited literals in the RBAC scan; V-A/V-D judge them against the dot grammar. Introduced downstream by the "systemic-gap sweep" that reached only 9 of 14 repos and was never back-ported to the hub.
Resolved in favour of the carve-out (the LOOSER option), against a naive
strictest-superset read. Enforcing the dot grammar on colon literals produces 14 false
positives fleet-wide and zero real defects — every hit is a legitimate D430 S2S transport
scope (tenant:role_catalog:write), a Node builtin specifier (node:crypto), or a doc fixture
(a:b). Colon strings are a different namespace, not malformed RBAC keys. RX_S2S_SCOPE is
exported for services that want to assert their scopes, but is deliberately not wired into the
scan — the literal harvest cannot distinguish a scope from node:crypto.
Pass --lint-colon-keys to restore the pre-D430 behaviour.
C2 — the docs/ exclusion (exclude, default none)
V-A only. The hub's docs/ tree is the INGESTED planning corpus and vendors OTHER services'
locked protos, which are registry DATA, not surfaces the hub serves — 24 false "served proto"
violations without it. Repo-specific, so it is a caller option: the hub passes
exclude=docs/; the other 13 repos pass nothing.
Known coverage boundary (inherited)
A bare unversioned literal in an outbox-named file — export const T = ["a.b.c"] — is not
flagged. RX_TOPICISH_LITERAL (the broad 3a scan) itself requires .vN to harvest, so it
structurally cannot flag a missing-.vN value; only the 3b emit-FIELD scan
(topic: / eventType: / event_type:) catches that case. An unbound array literal is not
proof the value is ever emitted, and harvesting every 2-dot string would false-positive broadly.
This matches the vendored V-A and V-C copies exactly and is pinned by the two BOUNDARY: tests
in tests/scan.test.ts. Widening it is a decision, not a refactor — it would newly red every
repo that keeps unversioned constants near outbox code.
Gate 2 — cross-tenant RLS (cross-tenant-lint)
The hole
Services define RLS for two runtime roles. The service pool is correctly fenced to one tenant:
CREATE POLICY tasks_svc_select_policy ON tasks FOR SELECT
TO nodii_services USING (tenant_id = auth.get_tenant_id());The user-session pool is fenced to a MEMBERSHIP UNION:
CREATE POLICY tasks_auth_select_policy ON tasks FOR SELECT
TO authenticated
USING (tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id())));auth.user_tenant_ids returns every tenant the principal is active in, and the session client
sets only app.user_id. A user with memberships in tenants A and B, logged into A, reads and
writes B. Reproduced live in nodii-task-tracking: a tenant-A session approved a tenant-B refund
of ₹250,000 and got HTTP 200. Nothing was forged.
Operator ruling (2026-07-27): "enforce that even if this shape regresses we DON'T ALLOW ANYTHING CROSS-TENANT EVER."
This gate is the static half. The runtime half — a tenant-scoped session client and the
replacement policy predicate — is @nodii/db-rls. They are complementary: a static rule catches
the SQL being written; a runtime check catches behaviour a grep cannot see. Neither substitutes
for the other.
What it flags
| Rule | Shape |
|------|-------|
| union-predicate | a policy reachable by the user-JWT role whose USING / WITH CHECK resolves tenancy through auth.user_tenant_ids(...) with no accompanying tenant_id = auth.get_tenant_id() conjunct |
| unfenced-session-client | a DB context that writes app.user_id and never pins app.tenant_id |
| invalid-waiver | a malformed escape-hatch marker (it suppresses nothing, and says so) |
"Reachable by the user-JWT role" means TO authenticated or no TO clause at all — an absent
TO defaults to PUBLIC, which the user-JWT role is a member of. That is the same hole with less
text, and a regex keyed on the literal TO authenticated misses it entirely.
USING and WITH CHECK are judged independently: a policy can fence reads and not writes.
A tenant pin that is a top-level OR branch is still flagged — a disjunctive pin constrains
nothing. x = auth.get_tenant_id() AND (a OR b) is fine; the OR is inside parens.
What it does NOT flag
nodii_services/nodii_ownerpolicies. Different roles; out of scope by construction.- The platform-staff cross-tenant path (D185). Staff access runs on a separate role,
nodii_platform_ops, on its own pool, predicated on a per-grantplatform_ops_grants_replicaEXISTS check with ascope_kind, an expiry and a revocation column — auditable per row. It needs no waiver: it is a different role with a different predicate, so this rule never sees it. That is the point. Anything reaching for cross-tenant reach through theauthenticatedrole is by construction not that path, and must not be able to borrow its legitimacy. withPlatformOpsContext-shaped code. It writesapp.user_idtoo, but it also writesapp.platform_ops_service_id/app.platform_ops_permission, which identifies it structurally.- The target fix.
USING (tenant_id = auth.get_tenant_id() AND tenant_id IN (SELECT auth.user_tenant_ids(auth.get_user_id())))is clean, in either clause order, with or without a table qualifier, and with the rawcurrent_setting('app.tenant_id')form. Flagging the shape every repo is migrating to would make the rule unadoptable. - Single-tenant
authPoolMode.USING (tenant_id = auth.get_tenant_id())onauthenticated(live in nodii-finance-service) is clean.
Parsing, not pattern-matching
A policy is a multi-line statement whose clause order and whitespace vary by generator. The reader
in src/sql-parse.ts tokenizes:
--and nested/* */comments are blanked to spaces — length- and newline-preserving, so offsets stay 1:1 and reported line numbers are exact. A commented-out policy is invisible; a trailing-- generateddoes not hide a real one.'…'(with''),"…"idents (with"") and$tag$…$tag$bodies are skipped, so a;or aUSINGinside a string neither splits nor fakes a statement.USING/WITH CHECKbodies are extracted by balanced parens, quote-aware.TOis parsed as a list, soTO nodii_services, authenticatedis caught.
Policy DDL living inside code is read too — every service authors its policies in a
scripts/db/gen-rls.ts that emits the DDL as concatenated string literals. Literal bodies are
harvested in source order, \n escapes decoded, ${…} replaced with __EXPR__, and the same SQL
reader runs over the result. Joining adjacent literals can only add text to a statement, never
split one, so it cannot manufacture a false positive for a rule that fires on a MISSING clause.
The escape hatch
Deliberately awkward, and never silent:
-- doctrine-lint: allow-cross-tenant(D185) reviewed 2026-07-27: read-only catalog mirror, no tenant column
CREATE POLICY … ;- It must cite a locked hub decision in parentheses, and carry ≥ 12 characters of
justification. A marker missing either is an
invalid-waiverfinding — it suppresses nothing and reports itself. A hatch you can trip over by accident is worse than no rule. - It reaches 3 lines, on or above the statement. It is not a file-level or directory-level switch; you cannot waive a tree.
- Every honoured waiver is printed on stdout on every run, with the decision and the justification, and counted in the summary line — including on a clean (exit 0) run.
// comments work identically, and the same marker waives the session-client rule.
CI adoption
- run: bunx cross-tenant-lintbunx cross-tenant-lint
bunx cross-tenant-lint exclude=drizzle/,legacy/ # path prefixes, as in the naming gate
bunx cross-tenant-lint --include-tests # scan test files too (off by default)Env equivalents: CROSS_TENANT_LINT_EXCLUDE, CROSS_TENANT_LINT_INCLUDE_TESTS=1.
Known blind spots
Documented on purpose. An undiscovered blind spot is the problem; a recorded one is a backlog item.
USING (true)onauthenticatedis not flagged. A user-facing policy with no tenant fence at all is a bigger hole than the union — but the fleet legitimately uses that shape for platform-global catalog tables (17 innodii-tenant-service/infra, 4 innodii-hr-service:currencies,module_bundles,bundle_pricing_tiers,task_action_button_registry, …). Telling a tenant-less catalog from an unfenced tenant table needs the table's schema, which a static SQL rule does not have. Closing this needs a per-table allowlist or a column check against the Drizzle schema — a decision, not a tweak. This is why the real0010_rls_policies.sqlreports 194 findings from 195 user-facing clauses: the one skipped istask_action_button_registry_read_policy … USING (true), a genuinely tenant-less registry.- A predicate that reaches tenancy through a helper the rule cannot see. If a service replaces
auth.user_tenant_ids(...)with its ownauth.my_tenants(...), the rule does not know. It keys on the one function name the doctrine defines. Renaming the helper defeats it. - The session-client rule searches two enclosing FUNCTION bodies (control blocks in between
are free). A tenant pinned three function levels above the
app.user_idwrite reads as unfenced. Widening it would let a broken client hide behind a correct sibling in the same module — which is exactly the live nodii-task-tracking shape. - The session-client rule is a text search, so prose fires it. A comment that merely mentions
auth.set_user_id()inside a function with no tenant write is reported. Pinned by a test rather than pretended away. - Python session clients are not checked for rule 2 (no braces to scope). Python policy DDL in string literals is read.
- Test files are skipped by default (
--include-teststo look). A test that stands up the union policy against a throwaway database is a test double, and reporting it drowns the real hits. - Runtime construction is invisible. A predicate assembled at runtime from variables — rather
than written as a literal — is not seen. That is the seam
@nodii/db-rlscovers. - The Drizzle
withRls(col)builder is not judged at the call site. A schema file that writes...withRls(t.tenantId)carries no policy text — the predicate is generated inside db-rls. It is caught only when the repo commits the generated migration SQL, which nodii-crm-service (41 files) and nodii-geo-service (8) do, so today the coverage holds. A repo that adopted the builder and randrizzle-kit pushwithout committing the SQL would be invisible to this gate. Judging the builder needs db-rls'sauthPoolModedefault to change — which is the runtime half's job, not a regex's.
Blast radius (measured 2026-07-27, read-only sweep of the 15 service repos)
1,378 findings across 9 of 15 repos — 1,377 union-predicate + 1 unfenced-session-client —
from 3,687 policies parsed. 1,371 are distinct policy+clause pairs (so this is not re-counting the
same policy), and 734 sit outside append-only Drizzle journal directories.
| repo | findings | outside journal dirs | |------|---------:|---------------------:| | nodii-crm-service | 570 | 5 | | nodii-hr-service | 376 | 376 | | nodii-task-tracking | 196 | 196 | | nodii-tenant-service | 106 | 106 | | nodii-auth | 64 | 12 | | nodii-geo-service | 27 | 0 | | nodii-customer-service | 21 | 21 | | nodii-vendor-service | 12 | 12 | | nodii-product-service | 6 | 6 | | nodii-billing-service | 0 (158 policies) | | | nodii-finance-service | 0 (506 policies) | | | nodii-kyc-service | 0 (76 policies) | | | nodii-notification-service | 0 (42 policies) | | | nodii-edge, nodii-ticketing-service | 0 (no policies) | |
The six clean repos are the evidence that this is not "flags everything with authenticated":
billing and notification already pin tenant_id = auth.get_tenant_id() on the user role, finance
runs single-tenant authPoolMode, and kyc deliberately grants the authenticated pool no policy
on its PII tables.
The single unfenced-session-client hit is
nodii-task-tracking/src/db/setup/setup-db.ts → createSecureClient — the exact client behind the
live incident.
nodii-libs itself reports 27 — and that is the important number, because it says the hole is upstream, not nine separate service mistakes:
| file | findings | what it is |
|------|---------:|------------|
| ts/audit-chain/src/migrations/001-audit-chain.sql | 6 | lib-emitted policies |
| ts/saga/src/migrations/{index.ts,001-saga-state.sql} | 10 | lib-emitted policies |
| go/saga/postgres_store.go | 5 | the Go port of the same |
| ts/db-rls/src/rls-policy.ts | 2 | generateRlsPolicy — the canonical generator |
| ts/db-rls/src/{context,drizzle-client,postgres-js-client}.ts, go/db-rls/pgx_session.go | 4 | withUserMembershipContext + its three ports |
withUserMembershipContext sets ONLY app.user_id on purpose — it is documented as the
doctrine 08-rls § 4 multi-membership API. So the untenanted session is a first-class library
contract, not an accident in one service. That is why the runtime fix belongs in @nodii/db-rls
and why this static gate cannot substitute for it.
