npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nodii/doctrine-lint

v0.8.0

Published

Nodii doctrine CI gates as one dependency: the D417 naming-conventions rules (gRPC FQN / RBAC key / outbox topic), the CROSS-TENANT RLS rule that blocks the membership-union policy shape and the untenanted user-session DB client, the LOGG-25 rule that ban

Readme

@nodii/doctrine-lint

Nodii doctrine CI gates as one dependency. Six bins ship here:

| Bin | Gate | |-----|------| | naming-conventions-lint | D417 naming conventions — gRPC FQN / RBAC key / outbox topic | | s3-write-lint | DOC-OV-1.6 — no S3 write outside Document Cloud, in TypeScript, Python or Go | | cross-tenant-lint | Cross-tenant RLS — the membership-union policy shape and the untenanted user-session DB client | | logging-lint | LOGG-25 logging — no pino and no console in production source | | openapi-contract-lint | S-09 contract — operationId, path grammar and response envelopes in a dumped OpenAPI document | | response-envelope-lint | S09-3 — a success or error envelope built by hand in src/ instead of through @nodii/http | | schema-lint | STD-19 schema conventions — names, vocabularies, migrations, ids, time and money, over Drizzle schemas and SQL migrations |

Each exits 1 on any violation. None has an advisory mode.

The S-09 gates are in the sections at the end of this file.


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-lint

Add @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-out

Env 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 plain node fails with Cannot find package 'bun'. Every consuming repo already runs Bun in CI; engines.bun declares 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_owner policies. 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-grant platform_ops_grants_replica EXISTS check with a scope_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 the authenticated role is by construction not that path, and must not be able to borrow its legitimacy.
  • withPlatformOpsContext-shaped code. It writes app.user_id too, but it also writes app.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 raw current_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()) on authenticated (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 -- generated does not hide a real one.
  • '…' (with ''), "…" idents (with "") and $tag$…$tag$ bodies are skipped, so a ; or a USING inside a string neither splits nor fakes a statement.
  • USING / WITH CHECK bodies are extracted by balanced parens, quote-aware.
  • TO is parsed as a list, so TO nodii_services, authenticated is 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-waiver finding — 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-lint
bunx 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.

  1. USING (true) on authenticated is 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 in nodii-tenant-service/infra, 4 in nodii-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 real 0010_rls_policies.sql reports 194 findings from 195 user-facing clauses: the one skipped is task_action_button_registry_read_policy … USING (true), a genuinely tenant-less registry.
  2. A predicate that reaches tenancy through a helper the rule cannot see. If a service replaces auth.user_tenant_ids(...) with its own auth.my_tenants(...), the rule does not know. It keys on the one function name the doctrine defines. Renaming the helper defeats it.
  3. 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_id write 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.
  4. 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.
  5. Python session clients are not checked for rule 2 (no braces to scope). Python policy DDL in string literals is read.
  6. Test files are skipped by default (--include-tests to look). A test that stands up the union policy against a throwaway database is a test double, and reporting it drowns the real hits.
  7. 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-rls covers.
  8. 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 ran drizzle-kit push without committing the SQL would be invisible to this gate. Judging the builder needs db-rls's authPoolMode default 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.


Gate 3 — LOGG-25 logging

Every production service logs only through the shared logger in @nodii/telemetry.

| Rule | Finds | |------|-------| | LOG-1 | an import, require or dynamic import of pino or a pino-* package | | LOG-2 | a console.<method>(...) call | | LOG-3 | pino or a pino-* package in package.json |

// package.json
"gate:logging": "logging-lint"
bunx logging-lint                                 # scans src/ and package.json
bunx logging-lint paths=src,scripts exclude=src/generated/
bunx logging-lint --include-tests

LOGG-25 allows the console for bounded output before telemetry starts and as the fallback when the exporter fails. Each such line carries a waiver with a reason, on the line or the one above:

// logging-lint-allow: before telemetry starts
console.error("missing NODII_SERVICE_NAME");

Report — substrate adoption (adoption-report)

The report measures whether a repo really uses each shared library (S07-53 to S07-55, STD-24). It fails nothing and reads one checkout. Dev runs scanAdoption against each repo's main and shows the result; nobody edits a state by hand.

bunx adoption-report repo=nodii-crm-service commit=<sha>

States

| State | Meaning | |-------|---------| | used | Shipped source calls the library on its governed path. | | declared-only | A manifest names it; the governed path does not call it. | | local-copy | The repo carries its own version of the concern. This wins over used. | | absent | None of the above. S07-54 names three states; this marks a repo with none of them. | | not-measured | STD rows only: no import-site rule can measure this STD, and reason says why. |

What counts as a call

  • A value import in TypeScript, Python or Go. import type, comments, tests and scripts/ do not count.
  • For a library with governedSymbols in src/adoption-registry.ts, only those symbols count. A service that imports grpcError from @nodii/grpc-interceptors but never builds the standard stack is not on the stack.
  • For a tool (doctrine-lint, review-gates), running its command from package.json, lefthook.yml or a workflow.

A STD row reads used only when every library it names is used. Where importing the library proves too little, the STD names the symbol that implements it: STD-17 needs verifyWebhook, not any @nodii/grpc-auth import.

Known limits

  • An import proves the governed path reaches the library only as far as governedSymbols narrows it. Most libraries have no such list yet, so used means "imported in shipped source".
  • @nodii/grpc-interceptors reads local-copy for any gRPC server without the standard stack, including a service that composes library interceptors by hand.

Gate 4 — openapi-contract-lint

Run it on the JSON a producer's openapi-dump writes:

bunx openapi-contract-lint openapi.json exempt=/oauth/,/api/v1/webhooks/

| Rule | What it refuses | |---|---| | S09-21 | A missing, null or blank operationId, one that is not lower camelCase, or a duplicate across the given files. | | S09-22 | An operationId that is a route index or contains a version. | | S09-24 | A path outside /api/v<major>/, or a segment that is not lowercase kebab-case. | | S09-25 | A path variable that is not {snake_case}. | | S09-5, S09-6, S09-7 | A 2xx JSON body without a required data, with another top-level member or another meta member; a 2xx with no content that is not a 204; a 204 with a body. | | S09-8, S09-9, S09-10 | A 4xx, 5xx or default JSON body without error, code and message, with another top-level member, or whose code does not list lowercase snake_case values as enum or const. |

Probe and metrics paths are skipped. exempt= lists path prefixes whose shape an outside standard sets, such as provider webhooks (S-12) and OAuth endpoints; only their operation ids are checked. A non-JSON body, such as a file download, is not an envelope.

It does not judge whether a segment is a plural noun or whether an id describes the action. Uniqueness across every Nodii API belongs to the devops catalogue (S09-23). Exit 2 means a usage or read error.

Gate 5 — response-envelope-lint

Run it from the service root. It scans src/ for c.json({ data …, c.json({ error …, c.json({ code … and the same on ctx, context and Response, and skips test files.

bunx response-envelope-lint exclude=src/routes/provider-webhooks/

exclude= lists path prefixes, for the responses S-12 sets. It reads source text, so a body built in a variable first is not found. Exit 2 means a usage or read error.


Gate 6 — STD-19 schema conventions (schema-lint)

schema-lint reads Drizzle table declarations with the TypeScript parser, and replays SQL migrations in order into the tables they leave behind. A table a Drizzle schema declares is judged from that schema, which carries the waivers, and takes from SQL only what the schema hides behind a helper or a spread. SQL alone judges the tables no schema declares. Tables match by schema-qualified name, so audit.events is not the events in public.

A table is a call to a drizzle builder: pgTable (or its alias imported from drizzle-orm/pg-core), a builder made by pgTableCreator, or .table on a pgSchema. Other *Table("x", {...}) calls are not read. A pgTableCreator table takes the name its callback makes ((n) => n, (n) => `hr_${n}` or (n) => "hr_" + n); a table from a callback in any other form is skipped, and SQL alone judges it.

The gate's API is the @nodii/doctrine-lint/schema subpath. It needs typescript, an optional peer dependency; the package root does not load it. The schema-lint bin needs it too: add typescript (^5) as a devDependency, or the bin stops with that message.

| Rule | Clause | Refuses | |------|--------|---------| | table-name-case, column-name-case | S01-23 | an identifier that is not lowercase snake_case | | table-service-prefix | S01-24 | a table that repeats the owning service, except the fixed names in S01-25 | | state-holds-lifecycle, status-holds-progress | S01-35, S01-36 | active or revoked in state; dispatched or retrying in status | | duplicate-lifecycle-boolean | S01-37 | is_suspended beside a status or state column | | id-default-random, id-column-type, id-generator-local | S01-38 | a v4 default on an id (a primary key, id or *_id; an opaque token may stay v4), a text or integer id primary key, a UUIDv7 not from @nodii/telemetry | | native-enum, vocabulary-check-missing | S04-54 | an unwaived native enum; a TEXT status or state with no named CHECK | | migration-name, migration-sequence | S04-53, S04-2 | a name that is not <sequence>_<semantic_action>, a generated drizzle-kit name, a reused sequence | | migration-journal | S04-3, S04-4 | a journal tag with no file, a file with no journal entry, an idx that does not increase | | migration-immutable | S04-2 | with since=<ref>: a migration edited, renamed or deleted since the branch forked from <ref> | | timestamp-naive, instant-suffix | S04-29 | a TIMESTAMP without a time zone; an instant whose name does not end in _at | | civil-date-suffix, duration-unit | S04-30, S04-31 | a DATE named _at; a duration or epoch column with no unit in its name | | money-decimal, money-column-name, minor-units-type | S04-25, S04-27 | money in NUMERIC or FLOAT; an integer money column not named <concept>_minor_units; such a column that is not BIGINT | | minor-units-currency, currency-column-name | S04-26 | a money row with no currency column; a column named currency_code |

Adoption

bunx schema-lint --write-baseline         # once: records today's findings in schema-lint-baseline.json
bunx schema-lint since=origin/main        # the CI gate

The conventions apply prospectively, so an existing finding goes in the baseline and only a new one fails. --write-baseline refuses to replace a baseline, and a baseline entry that no longer matches fails the gate until --prune-baseline removes it. The CI gate MUST pass since=<ref>: only then does an entry absent from the baseline at the fork point fail, so a deleted-and-rewritten or hand-edited baseline cannot hide a new finding. Without since= the gate cannot see either. The change that adds the first baseline passes. since= needs history back to the fork point, so a CI checkout must not be shallow (fetch-depth: 0). The gate does not detect that a change touched a baselined table; S04's "changes when next touched" stays a review duty.

An empty scan fails, so a wrong root= cannot pass. Migration directories are found from Drizzle journals, migrations/ and infra/postgres/*, skipping seed, manual and fixtures. Add others with migrations=<a,b>. Migrations replay in sequence order, so 10_x.sql follows 2_x.sql.

A Drizzle column declared without a name takes its key as written, as drizzle does by default. When drizzle.config.* at the root sets casing: "snake_case", the key is snake_cased instead. casing=preserve|snake_case overrides the detection. --prune-baseline with no baseline file does nothing, so it cannot block the first --write-baseline.

Waivers

// schema-lint-allow: native-enum -- closed transport state only the library changes
export const dispatchState = pgEnum("dispatch_state", ["pending", "sent"]);

A waiver sits on the line of the finding or up to three lines above it, and needs a reason of at least 12 characters. S04-54 requires this waiver on every native enum. minor-units-currency takes one when a row inherits its currency from a parent, and the money rules take one on a false match. migration-immutable cannot be waived or baselined: either would hide an edit to the migration.

Limits

The money, duration and status value rules judge by name and stated values, not meaning: a NUMERIC money column with no money word in its name passes, and leave days named amount need a waiver. A CHECK or enum value set built outside a literal is not read. Region-derived tenant time zones (S05-24) are not checked here.