@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-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.
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-testsLOGG-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 andscripts/do not count. - For a library with
governedSymbolsinsrc/adoption-registry.ts, only those symbols count. A service that importsgrpcErrorfrom@nodii/grpc-interceptorsbut never builds the standard stack is not on the stack. - For a tool (
doctrine-lint,review-gates), running its command frompackage.json,lefthook.ymlor 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
governedSymbolsnarrows it. Most libraries have no such list yet, sousedmeans "imported in shipped source". @nodii/grpc-interceptorsreadslocal-copyfor 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 gateThe 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.
