Checks in C#¶
This page is for whoever adds a check to kac that the schema cannot declare on its own. It says when a rule needs C#,
how to write one, how to add a fact the expression grammar can ask for, and why the numbers in the calibrated rules are
what they are.
A corpus is one repository of knowledge records kept in git. A type is one kind of record, declared by one file under
.schema/. Checks is the reference for what a schema owner declares.
When a rule needs C#¶
A rule needs C# when one fixed message cannot tell the author what the code could. An expr: rule reports the one
message: its schema declares, whichever part of the document failed. A rule class can name the part.
- The grammar cannot ask the question. A loop over records, a join between documents or a quantifier over a document's parts is beyond the expression grammar, so only code can answer it.
- The grammar can ask it, and its answer is only "something here is wrong". Where code could name the missing piece, write the code. A rule written as an expression to save a class is cheaper and worse, and no check fails it.
Cost comes second. It argues for turning a class into an expr: only where one fixed message already says everything
the class would.
| Rule | What its message names |
|---|---|
y-statement-present |
the moves missing from a Y-statement, where an expression could only say it is not one |
alternatives-have-verdicts |
the Alternatives Considered bullet with no verdict |
terms-are-alphabetical |
the glossary term out of order, and the term it belongs before |
no-dependency-cycles |
the route of a dependency loop, reported once against the lowest id on it |
dependency-criticality |
the record cited, and the grade of each end, reading the ranking from ordered: true |
mirrors-repo-links |
a repository in repos: with no link, or a link with no entry, built from repos-base |
departs-only-from-what-binds |
the clause a deviation cites and the modals it states, read from the export for an import |
shared-ids-signposted |
the clause sharing an id, and which of the two clauses lacks the signpost |
Writing a rule class¶
A rule class is one file under tooling/kac.core/Rules/, its own unit tests, and a line in DocumentRules.All,
CorpusRules.All or HistoryRules.All. Which interface it takes follows from what it reads:
| Interface | Given | Runs from | Reports |
|---|---|---|---|
IDocumentRule |
one document, through a RuleContext |
Validator.CheckRules |
against that document |
ICorpusRule |
every record and the byId index, through a CorpusRuleContext |
Validator.CheckCorpusRules |
against the document it names, rarely its own |
IHistoryRule |
one record at two revisions, paired by id, through a HistoryRuleContext |
Validator.CheckHistory |
against the record where it is now |
Take the narrower one wherever it will do. A rule handed every record to judge one document reads as though it needs them all.
A rule that compares two revisions¶
An IHistoryRule runs only under validate --since. Baseline.Read reads each file that differs between that
revision and the working tree, through the same Corpus.Load the working tree takes, so both copies of a record are
parsed alike. Any other file is the same at both, and pairs with itself. Validator.CheckHistory pairs the two
copies by id, and hands a rule only a record whose text differs. Substance.Changes lists the parts of a record that
changed in substance, which is the question both immutability rules ask. A rule on a type's parts compares
Before.Parts with After.Parts, as PartIdKept does. It reads the noun and the in-force: value from the type it is
handed, and HistoryRules.All constructs it once per type under an id of that type's own: clause-id-kept and
rule-id-kept. The golden suite's coverage gate counts check ids as one set, so an id two types shared would stay
covered after either type's fixture case was deleted.
retire-not-delete is no rule class. It reads only a type's in-force: value, so Validator.CheckHistory reports it
for every type.
Rule ids and check ids¶
A dispatcher finds a rule class by the id the schema's rules: block declares, which is a RuleId. The class reports
under a CheckId, which is usually different: no-dependency-cycles reports as dependency-cycle. The two types keep
that difference deliberate.
- The class lists the check ids it reports in
Emits. - Each check id needs an entry in
.schema/_checks.yaml.schema-dispatchreports one that has none. - A finding goes to the
Reportthe pass hands down, asRuleContext.Reportin a rule class. Write the id as aCheckIdat the call, so the compiler stops a message landing in the id's place.
A rule id that no class implements and no expr: states is an intention. It declares no severity:, and the type
page renders it as declared but not yet enforced.
A rule's config¶
A rule class declares the config keys it reads, such as max-words, in Reads. The loader reads a rule's entry for
those keys alone. schema-dispatch reports a config key on a rule whose class does not read it. An expr: rule reads
none, so any config key on one is reported.
A rule class also checks its own config in Misconfigured, and returns one sentence for each fault.
y-statement-present checks that max-words is a whole number above zero. alignment-rollup checks each of its
postures against the level-two headings on the register pages the records cite. A corpus rule's Misconfigured
therefore takes the corpus, and a document rule's takes the Schema.
Each fault is reported once under schema-dispatch, against the rule's line in its .schema/ file. The rule then
checks no record. Write Misconfigured for a value that would make every finding the rule reports wrong.
The three places that must agree¶
Each one fails a meta-test, not the test you were looking at.
- An entry in
.schema/_checks.yaml. Itsdescription:is whatkac checksprints. A check id a rule class reports with no entry failsschema-dispatchwhen the schema loads. - A row in
ChecksTable.DocRows, oron-type-page: falseon the entry.ChecksTable.Problemsfails a check with neither. The flag is for a check that reads the schema, the template or the type page, which an author of a record cannot act on. - A fixture that trips it. The coverage gate fails a reachable check no fixture exercises. It counts ids, not branches, so a rule reporting three faults under one id needs a fixture for each, and unit tests for the rest.
Core checks¶
A core check runs on every document, in the order Validator.CheckDocument reads one, and is never looked up in a
registry. Several return early, so a later check does not report on a document already known to be broken.
A self-contained group with logic worth testing becomes a static class under tooling/kac.core/Checks/:
IdChecksstates the shape of an id once. Three passes read it, and a second copy would let them disagree.ValueChecksjudges one frontmatter value against itsFieldSpecalone. It reads a template's unfilled marks as absent before a field's own check sees them, so a placeholder is never reported as a malformed date.IsAbsentis public because the required-field pass asks the same question.SchemaChecksreads no document. It runs once, before the corpus, and asks whether the schema declares anything the tool cannot act on.
SchemaChecks reads each vocabulary it tests from the code that dispatches it: IdChecks.IdStyles,
Generator.IndexOrders, the two ByRuleId maps and each rule class's Reads. A copy would list what is spelled
correctly, and not what runs.
Adding a fact¶
Reach for a fact, not for the grammar. Adding one means one method on the tool's Facts class, one row in its function
table, and one row in the fact table on Rule expressions. A test checks that table
against the function table, so a fact missing from the page fails the build. The grammar never changes.
A new fact usually serves one rule at first, and answers a question the next corpus will ask again. Extending the grammar for one rule is almost always the wrong move.
Facts.cs holds the fact functions, and nothing else an expression can reach. RuleExpr.cs parses, type-checks and
evaluates an expression. A Facts is built for one document and discarded after that document's rules run, which is
what makes words() safe to memoise.
A question that needs loops, joins or quantifiers is a rule class, as above.
The calibrated windows¶
Most rules that count words, links or days are uncalibrated, because no corpus has yet had enough records of those types to fix a number. Checks lists the three that are.
FedRAMP sets high-risk-review-window from the risk
level and gives a higher rating a shorter one: 30 days for high, 90 for moderate, 180 for low. Those windows time the
remediation of a vulnerability, where this one times the re-reading of an accepted risk, so the shape carries over and
the numbers do not. Six months is this repository's own figure, written as 184 days because six calendar months runs
from 181 days to 184.
staleness reads its windows off the calendar, so a rehearsal held to the cadence never trips. A date check has no
window to compare an event against, which is why on-change and per-release are reported only at never.
staleness-loud takes 92 days as a floor that every rehearsal-frequency meets. A runbook is read during an incident,
and the tools, permissions and commands it assumes go out of date whether or not the system it covers changed. The
price is a walk a quarter for every runbook a corpus keeps. A shorter window for a tabletop walk would fall hardest on
the runbooks whose fault nobody can cause, which are the ones no drill reaches.
A fixture pins each of high-risk-review-window's numbers, so moving one is visible. A fixture cannot pin staleness
or staleness-loud, because their windows are measured against the day of the run and a fixture date drifts further
from today every day it sits there. A unit test names the day instead, and pins every window against it.
Decisions¶
A rule is data wherever it can be. Wiring a rule as C# means a class, a registry line, unit tests, an entry in
_checks.yaml, a row in ChecksTable.DocRows, and a fixture. Wiring it as an expression means a line of YAML and a
fixture. A corpus downstream of this one may add a whole type file of its own. Without this layer every rule in that
file would be inert, and enforcing one would need an upstream code change and a release.
OPA/Rego was the obvious alternative and is the wrong shape. It would replace only the evaluation tail of the
pipeline, leaving all the markdown and frontmatter extraction untouched. It would add a language to learn and a runtime
dependency to the tool. The one property worth having is new rules as data, and a small hand-rolled evaluator buys it at
a fraction of that cost. RuleExpr.cs says when that judgement expires.
ChecksTable.DocRows is written by hand. Several catalogue ids fold into one row a reader of a type page can act
on, so the rows are grouped and worded for that reader. An expr: rule's row comes from its description: instead.
A text pattern lives in .schema/. A rule that matches text is a heuristic, and a heuristic gets tuned. Tuning a
regex in the schema costs a schema edit. Moving it in the tool would cost a release every corpus has to take.
Where to go next¶
Adding a report is the other way kac grows a question it can answer about a corpus.