Only a rule that can fail must cite a legislature host
When a lint fails a job posting on a legal rule, it is making a claim about the law, and whoever reads the finding cannot check that claim from the message text alone. In jobpostrules we tied the right to fail to the quality of the citation: every cited rule carries a URL, a section and a verification date, and only the one status that produces an error must also point at an https address on a host the registry lists by name.
Severity is derived, not chosen#
A rule record in src/rules/registry.ts does not carry a severity. It carries a status, and the severity follows from that. The mapping is small enough to read in a glance:
const expected: Record<RuleStatus, Severity> = {
"in-force": "error",
advisory: "warning",
unverified: "warning",
pending: "info",
};That record lives in test/registry.test.ts, where it pins severityForStatus for every member of RULE_STATUSES. One status maps to error. Everything else can only warn or inform.
That single mapping is what makes a citation policy possible at all. "May this rule fail a posting?" stops being a question about the rule's wording, or about how confident its author felt, and becomes a question about one field. So the registry can attach an extra obligation to exactly that field's value and leave the rest of the table alone.
Three fields for every cited rule, a fourth constraint for the one that fails#
ruleProblems is the per-record check, and it hands back a list of sentences rather than a boolean. It walks the record in a fixed order: an empty id first, then a short circuit for shape rules, then an empty jurisdiction, then the citation triple of url, section and verifiedAt, then the dates through isIsoDate from src/model.ts, then an effectiveTo that is not a date or lies before effectiveFrom, then a minHeadcount that is not a whole number of employees above zero. The allowlist check runs last:
if (rule.status === "in-force" && url.trim() !== "" && findAllowedSource(url) === undefined) {
problems.push(
`an in-force rule cites ${JSON.stringify(url)}, which is outside the primary-source allowlist`,
);
}It is guarded twice, and both guards earn their place. A rule with no URL at all has already been reported as "the citation carries no url", and reporting it a second time as off-allowlist would just be noise on top of a problem the reviewer is already fixing. An advisory or unverified rule is deliberately free to cite a secondary source (a summary, a government news page, somebody's explainer) because the worst it can do is warn.
The test states that permission outright:
test("a rule that cannot emit an error is not held to the allowlist", () => {
const unverified: CitedRule = {
...withCitation({ url: "https://example.com/secondary-summary" }),
status: "unverified",
};
assert.deepEqual(ruleProblems(unverified), []);
assert.equal(severityOf(unverified), "warning");
});The asymmetry is deliberate. A secondary source is usually how a rule first lands in the table: somebody reads a summary, writes the record, marks it unverified, and it warns. Promoting it to in-force is then a specific act with a specific cost (open the statute itself, record the section, record the date it was read), and the build refuses the promotion until the URL resolves to a primary source.
A shape rule never reaches the citation checks at all, because ruleProblems returns right after the id check when rule.kind === "shape". Those rules check the posting against itself: a range that is not two numbers, a currency that is not an ISO 4217 alpha-3 code, a posting marked remote that names no region the role is open to. They make no claim about any statute, so there is nothing to cite and no window to be inside.
The allowlist is hosts by name, with the path compared segment by segment#
src/rules/sources.ts holds the list as data, nineteen records of a name, a host and an optional path prefix:
export const ALLOWED_SOURCES: readonly AllowedSource[] = [
{ name: "EUR-Lex", host: "eur-lex.europa.eu" },
{ name: "Ontario e-Laws", host: "www.ontario.ca", pathPrefix: "/laws" },
{ name: "ISAP, the Sejm law database", host: "isap.sejm.gov.pl" },
{ name: "Google developer documentation", host: "developers.google.com" },
{ name: "California Legislative Information", host: "leginfo.legislature.ca.gov" },
// fourteen more legislature hosts
];Fifteen of the nineteen are US state legislature hosts. developers.google.com is on the list because the markup checks that follow will cite platform documentation rather than law, and we wanted the same discipline to apply to a platform rule as to a statute.
Matching is deliberately dull:
export function findAllowedSource(url: string): AllowedSource | undefined {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return undefined;
}
if (parsed.protocol !== "https:") {
return undefined;
}
const host = parsed.hostname.toLowerCase();
return ALLOWED_SOURCES.find(
(source) => source.host === host && matchesPathPrefix(parsed.pathname, source.pathPrefix),
);
}No regex, no substring search. new URL either parses the string or the source is refused. The protocol must be exactly https:. The hostname is lowercased and compared for equality, so a subdomain is not a source. The test enumerates the shapes that get refused, and they are the ones that would slip past a looser check: an unrelated host, a blog host with eur-lex.europa.eu sitting in the path, a real EUR-Lex URL over plain http, and a string that is not a URL at all.
The path prefix is the one place where a host is only partly trusted:
function matchesPathPrefix(pathname: string, prefix: string | undefined): boolean {
if (prefix === undefined) {
return true;
}
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}A source without a prefix accepts any path. With a prefix, /laws matches and /laws/statute/... matches, but /lawsuits/fixture does not, because the comparison lands on a path segment boundary rather than on a string prefix. That is the difference between the statute database and everything else a government site publishes under the same hostname. Trusting the host whole would have made any page on it a primary source.
What the allowlist cannot check is whether the right section was read. That is what verifiedAt is for, and it is a human claim. Two of the six Ontario rules in the table ship as unverified for exactly this reason: their URLs sit on an allowlisted host, but the section numbering inside the statute and the regulation is not confirmed, so those rules warn instead of failing. A trusted host is necessary and not sufficient.
Every lapse at once, named by rule id#
The registry-wide check collects rather than throws:
export function registryProblems(rules: readonly Rule[] = RULES): RuleProblem[] {
const problems: RuleProblem[] = [];
const seen = new Set<string>();
for (const rule of rules) {
if (seen.has(rule.id)) {
problems.push({ ruleId: rule.id, problem: "the id is used twice" });
}
seen.add(rule.id);
for (const problem of ruleProblems(rule)) {
problems.push({ ruleId: rule.id, problem });
}
}
return problems;
}A duplicate id is caught here rather than inside ruleProblems, because it is a property of the table and not of a record. Every entry is a RuleProblem pairing a rule id with a sentence, so a reviewer reads the whole table's state in one pass instead of fixing one record, rerunning, and discovering the next one. When a rule table is edited by hand, and a rule table is pretty much always edited by hand, that difference decides whether the check gets run before the commit or after the review.
The gate itself is one assertion:
test("the registry itself carries no broken rule", () => {
assert.deepEqual(registryProblems(), []);
});npm test is the entire gate. There is no build step and no runtime dependencies, so the registry check runs in the same place and the same command as the posting checks. A second test closes the loop from the other side: for every rule whose severity is error, either it is a shape rule, or its status is in-force and findAllowedSource resolves its citation. That one fails if a future status is ever mapped to error without the citation obligation following it.
What it costs to run#
Nineteen hostnames are a maintenance surface. A legislature that moves to a new host breaks our build, which is the behaviour we want and still work somebody has to do. Adding a jurisdiction now takes two edits in a fixed order, the source record first and then the rule, because a rule cannot ship in-force before its host is listed.
The demotion path costs something too. A rule we cannot fully verify ships as a warning, and warnings get skimmed. We would rather publish a warning that names its own uncertainty in the rule summary than an error we cannot defend, but it does mean part of the table blocks nothing.
And the whole thing is only as honest as verifiedAt. The checks enforce the host, the protocol, the path boundary, the date format and the ordering of the effective window. Whether the cited section actually says what the rule claims is still a person's claim, recorded in a field, visible in a diff.
The part worth copying#
The useful move was not the allowlist. It was deriving severity from status first, so that "can this fail a posting?" became a property of a single field, after which the obligation to cite a primary source could be attached to that field alone, checked last, and reported for the whole table in one pass, instead of living as a convention the table's authors were trusted to remember. The code is at https://github.com/shipmindlabs/jobpostrules. It is a lint, not legal advice.