Skip to content

Decision Log: Kotlin Standards Plugin

The design decisions behind the Kotlin Standards plugin — the apparatus that enforces the Kotlin Coding Standards rather than describing them. The design record describes what was built; this page records why it takes that shape.

A Design Topic (DT-XXX) is one question about how the apparatus should work, with the options weighed against each other and the resolution. The plugin’s source comments cite these identifiers directly — DT-009 appears in twenty-three of them — so this page is what those citations resolve to. The identifiers are kept as DT-XXX rather than renumbered to the template’s DQ-XXX precisely because they are already published in a shipped artifact.

#QuestionStatusDecision
DT-001The rule engine — ktlint or detektDecideddetekt; :ktlint-rules retired
DT-002Carrying rules with no subject in the consuming repositoryDecidedCarry them — the exemplar carries what its siblings will need
DT-003Where the plugin livesDecidedIts own repository and its own ticket, separate from PDEV-1304
DT-004The word for what a repository is made ofDecidedFacet; capability is reserved for what a Module offers
DT-005The shared / repository-local seamDecidedOne scope dial including none; the plugin reports the configuration
DT-006Gate defaults for a repository with an empty baselineDecidedtree for every rule
DT-007Liveness for rules with no subjectDecidedThe liveness fixture ships with the plugin and is mandatory
DT-008The CI surface — what the GitHub Actions must doOpen—
DT-009Extension points for a repository adding its own rulesDecidedThe rule set is an open collection; invariants are plugin-enforced
DT-010Sequencing — where each step landsDecidedMeasure, then close, then extract; five sub-steps for the extraction
DT-011Packaging — one artifact or twoDecidedOne
DT-012Who owns the Spotless/ktlint formatting gateDecidedThis plugin, not PDEV-1304
DT-013When the documentation is writtenDecidedA draft PR held open for the life of the project
DT-014Where an outbound port livesResolvedA capability package under servers; the standard’s api row had an omission
DT-015Retiring the ktlint engineDecidedDeleted; make lintArda runs the detekt gate
DT-016Who owns the rule listDecidedThe plugin ships it; none is the only way to disable a catalogued rule

DT-001 — The rule engine: ktlint or detekt

Section titled “DT-001 — The rule engine: ktlint or detekt”

The question. print-module ran five custom rules on the ktlint engine; operations ran fourteen on detekt, plus three detekt built-ins. A shared plugin has to pick one, or carry both.

What the overlap was. Of print-module’s five ktlint rules, one was redundant with a detekt built-in (arda:single-exit ≡ ReturnCount at max: 1), three were duplicates of detekt rules in operations, and one — arda:no-fold-to-null — was unique.

The constraint that had chosen ktlint, and why it did not bind. print-module’s build recorded the reason plainly: Spotless resolves custom rule sets from Maven coordinates, so wiring the ruleset into Spotless would require publishing it before the build that uses it can run — a cycle on a clean checkout. That is correct, and it is why make lintArda ran the engine directly. It does not apply to detekt, where detektPlugins(project(":detekt-rules")) is an ordinary project dependency. The constraint was never an argument for ktlint over detekt, only for a standalone runner over Spotless.

Decided: detekt. The deciding reason is not that detekt is the better engine. It is that everything worth extracting — the catalog, the citations, the scope classification, the reporter, and the ten rules print-module did not have — is detekt-shaped, and two custom rule engines behind one shared plugin means authoring every future Arda rule twice and watching the pair drift.

What it cost, stated plainly. Four rules and their tests rewritten from ktlint’s visitor to detekt’s. Every consumer inherits the dev.detekt pin — unavoidable if the resolving rules are wanted, and owning that pin in one place is an argument for the plugin. And a green test suite does not prove a port: operations had already had a META-INF/services file naming a stale interface take every rule offline while all forty rule tests passed. What proves a port is a differential run — same tree, both engines, diffed finding by finding.

DT-002 — Carrying rules that have no subject

Section titled “DT-002 — Carrying rules that have no subject”

The question. Three rules judge persistence code. print-module has no persistence at all. Under an ordinary port they would be dropped.

Decided: carry them. The keystone ruling, MP, 2026-08-27:

print-module is acting as a guinea pig for how to build modules and components in the future, so we should not drop rules or other mechanisms if there is no current use case in the repository, as it is likely that this repository, or a sibling one will have that use case.

This reverses the obvious reading of a port. A rule a future Module repository will need is exactly what the exemplar should carry, and carrying it before the subject exists is how the mechanism gets proven while the stakes are low.

The consequence, which is not the obvious one. A rule with no subject reports zero — and so does a rule that has silently stopped running. For a rule with a subject, going dark is at least visible as a count dropping from N to 0. For a subjectless rule, dead and clean are the same number, permanently. That is why DT-007 exists and why its answer is mandatory rather than advisory.

A second consequence — calibration. Each of operations’ last three rules had a false positive that only the whole corpus exposed. A persistence rule carried into print-module cannot be calibrated there, because there is nothing for it to judge. Calibration for those rules can only come from operations — which is an argument for validating the plugin against both consumers rather than shipping against one.

The question. PDEV-1304 — “Extract the Gradle build conventions into a shared, published plugin” — already exists, covering Helm packaging, image conventions, manifest generation and OpenAPI jar packaging. Is standards enforcement part of it?

Decided: its own repository and its own ticket. MP, 2026-08-27:

We should create a sibling ticket and repository for this “Kotlin Standards” plugin, separate from the one PDEV-1304 requests. A project can then import each plugin as needed.

The two are separately adoptable concerns: a repository may want build conventions without standards enforcement, or the reverse, and sharing a version train would force every consumer to take both. PDEV-1304’s acceptance criteria already require the extension pattern this project also needs — “repository-specific configuration is expressed through an extension; no component names, partitions or paths are hardcoded in the plugin” — which is the shape to imitate, not the repository to move into.

Tracked as PDEV-1790. The repository is kotlin-standards-gradle-plugin; the plugin id is cards.arda.standards.kotlin.

DT-004 — Facets: the word for what a repository is made of

Section titled “DT-004 — Facets: the word for what a repository is made of”

The question. The plugin must let a repository declare the layers, technologies and patterns it uses, because that is what decides whether a rule has a subject at all. The first draft called these capabilities. MP reserved that word, 2026-08-27:

I was using “capabilities” in a loose way. I would actually like to reserve that term to describe features, use cases, scenarios, API’s that a module supports rather than the layers, technologies or patterns it uses.

So capability = what a Module offers. A different word was needed for what it is made of.

CandidateForAgainst
facetIntelliJ’s own project-configuration vocabulary — a facet is a characteristic of a module that brings its own tooling with itSlightly less common in prose
traitShortEstablished Scala meaning — an alternative to interface
containsPlain English, verifies naturallyA verb where the others are nouns
compositionSays what it meansCollides — Module.kt is already “a composition root”

Decided: facet. The IntelliJ analogy is exact rather than decorative: declaring a facet is what brings that facet’s rules into scope.

What the word has to carry. Three properties keep a facet declaration from degenerating into a silent off switch: it describes the repository rather than the rules, so the act says something checkable; the declaration is exhaustive, so a repository that later grows persistence does not inherit rules that quietly never ran; and the plugin verifies it — “you declared no persistence and I found four Universe types” is nearly free, and turns a false declaration into a build failure rather than a comment.

DT-005 — The shared / repository-local seam

Section titled “DT-005 — The shared / repository-local seam”

The principle, settled early. The plugin ships what the standard says. The repository declares what it is and how hard it is held to it today.

Scope is repository-local, and that is load-bearing: operations ratchets ResultUnwrapping because it has a standing backlog; print-module holds the same rule at tree because its baseline is empty. Same rule, same citation, opposite posture, both correct. A rule’s claim, guidance and doc are plugin-owned, because they cite the Kotlin Coding Standards, which are one document for the organization.

The proposal that was wrong. The original position was that a rule’s exemptions must be plugin base + repository additions, never subtractions, on the ground that subtraction lets a consumer quietly weaken the standard. MP reframed it, 2026-08-31:

The Plugin declares default modes for each rule and the consuming repository can provide its own scope of application. Taking this approach we could have an additional scope for a rule (none) that disables the rule for a particular repository. The plugin would then just need to report the configuration so that it can be audited.

The reframe exposed an inconsistency rather than a preference: arguing that exemptions must be add-only because subtraction weakens the standard, while defending a dial where advisory already weakens it completely, is not a coherent line to hold.

What survives the reframe, and why it matters. There is a real difference between the two, and it is not about who decides — it is about what survives the decision. A scope acts on findings: the rule runs, and the scope decides whether what it found blocks. An advisory rule still prints every finding; the knowledge is preserved and deferred. An exemption acts on whether the rule looks: nothing is deferred, and nobody can count what an exemption hid, because there is nothing left to count. MP’s last sentence is what closes it — auditing the configuration restores exactly the visibility that narrowing destroys.

Decided: one dial. Scopes are tree, ratchet, advisory and none, with two conditions that make the audit real rather than nominal:

  1. none must never render as clean. It reports SKIPPED — disabled by configuration. A disabled rule that renders as clean is indistinguishable from a rule that ran and found nothing, which is the failure this entire apparatus exists to prevent.
  2. The audit states what the configuration excluded, not merely what it was. ResultUnwrapping: tree, excludes **/Main.kt is auditable; ResultUnwrapping: tree is not.

What it simplifies. Once none exists, most of the pressure on exemption lists disappears: a repository that genuinely cannot live with a rule sets none and says so in a file anyone can read, rather than quietly widening an exemption list until the rule stops firing. The loud option being available is what makes the quiet one unnecessary.

DT-006 — Gate defaults for an empty baseline

Section titled “DT-006 — Gate defaults for an empty baseline”

The question. operations splits its catalogued rules across tree, ratchet and advisory, following gate the rules already at zero; ratchet the ones with a backlog. print-module has no backlog at all, so tree is available everywhere — but was it wise for rules carried ahead of their subject (DT-002), which no corpus has ever calibrated?

Decided: tree for every rule.

Why the uncalibrated-rule worry does not bite. The two conditions cannot coexist. A rule whose subject does not exist produces no findings at all — false or true — so it cannot fail a tree gate in a repository that gives it nothing to judge. The moment the repository grows persistence is the moment the rule acquires both its first real finding and its first chance at a false one, and that is a change a pull request is looking at anyway.

The residual risk is smaller and different: a false positive arriving with the first legitimate use of a facet. A @Suppress with its reason in the diff is the documented escape, and that escape hatch is what makes a strict gate workable at all.

DT-007 — Liveness for rules with no subject

Section titled “DT-007 — Liveness for rules with no subject”

The question. DT-002 decided to carry rules whose subject does not exist. How do we know such a rule still works?

Why this is not the ordinary liveness problem. For a subjectless rule there is no baseline to compare against, no trend to break, and no reader who would notice. operations has already had the whole ruleset go dark at once — during a major-version migration a META-INF/services file still naming the old interface took every rule offline while all forty rule tests passed, and the survey printed CLEAN — ran, found nothing for each of them. Its knowledge base catalogues six independent forms of silent non-execution, and every one produces a plausible-looking clean report.

Decided: the liveness fixture ships with the plugin and is mandatory. It lints a fixture violating every rule in the set and fails if any one reports nothing. Three properties are load-bearing:

  • It reads the rule list from the provider rather than repeating it, so a rule added without a fixture fails the test rather than going silently unwatched.
  • It is deliberately weaker than the per-rule tests — it asks only whether the rule still speaks, not whether it says the right thing. That is the failure per-rule tests structurally cannot see, because they run in the test JVM and cannot inherit a stale daemon classloader.
  • It lives with the rules, not with the consumer. A repository must not be able to adopt the plugin without adopting its liveness check; that is what makes a subjectless rule trustworthy in a repository that can never exercise it.

Open. MP, 2026-08-27: “we will also need as part of this project other GH actions for wiring the whole thing together and produce the reports in CI/CD pipelines.”

The split to design: the plugin produces the report and the gate verdict; an action posts it as a sticky pull-request comment and carries the check name. Constraints already known from the survey: fetch-depth: 0 for the merge base; the ratchet base must be the pull request’s base ref rather than main, or a stacked PR is held answerable for its parent’s files; and the report comment posts unconditionally, because a red gate is exactly when the full survey is worth reading.

This is the only design topic the extraction did not need answered before it started.

DT-009 — Extension points for a repository adding its own rules

Section titled “DT-009 — Extension points for a repository adding its own rules”

The question. MP, 2026-08-31: “How does this approach support a repository that wants to add new rules, extending the checks that are performed?”

Deferred on purpose, and the deferral paid. MP held this until the survey and closure steps were complete, because an extension surface designed against zero consumers is shaped by imagination. The answer below is drawn from four closed lists that were actually built rather than four that were imagined.

The three axes carry over unchanged. A repository-authored rule declares its own scope — it is the only party who can, since the plugin has never heard of the rule. Facets work identically. Site departures are @Suppress as before. That DT-005 needs no extension for this case is a good sign about the shape of that seam.

What had to change: the apparatus’s idea of “the rule set.” It was a closed list in four places, and each would make a consumer-authored rule a fork rather than a contribution.

MechanismAs builtWhat a consumer hit
Tier A rulesdetekt RuleSetProviderNothing — detekt already loads rule sets side by side
Tier B rulesa literal listOf(...) roster in the runner’s sourceA list only the plugin’s author can edit
Catalogone properties fileNowhere to declare a local rule’s claim, guidance, doc and scope
Liveness fixturesone map in one testSame

Tier B was the sharp one. It was not a detekt rule set at all — it was a JVM main with a literal roster, and the roster was verified by parsing its own source file’s text. That is not extensible by anyone but the plugin’s author, and it is not a mechanism a second contributor can reason about.

The design point. The plugin is not a bag of rules. It is the apparatus, and the rules are its payload. Catalog, roster check, liveness check and report all operate over plugin rules ∪ repository rules. Tier B discovers its rules through a ServiceLoader rather than listing them; catalogs merge; liveness fixtures merge the same way.

The invariants are plugin-enforced, not copied. If consumers author rules, they would otherwise copy the tests carrying the guarantees the whole report rests on — and a copied invariant drifts. That is not hypothetical: two rules once held two exclusion lists that a comment asked to agree, in two repositories, for as long as both had existed, and they did not. So those become a task the plugin runs over the union: a consumer that adds a rule without a catalog entry, or without a liveness fixture, fails its own build without having had to know the requirement existed.

The reverse direction needs no extension point. A rule going up is a pull request against the plugin. Both of this project’s promotions went that way. The asymmetry is correct: a rule going up is a claim about the platform and deserves review; a rule staying local is a claim about one repository and does not.

Decided: measure, then close, then extract. Step 1 runs the survey in the consuming repository and never fails, producing a measurement rather than a verdict. Step 2 closes what the measurement found. Only then does step 3 extract the machinery into its own repository, in five sub-steps, with operations as the step that validates the seam — because a seam proved against one consumer is not proved at all (DT-002).

Why the order matters is visible in DT-014. Had gating come first, a real structural divergence would have been red on the branch’s first day, and the pressure would have been to suppress three imports or weaken the gate. Measuring before gating bought the room to find that neither was necessary. What the sequencing protected was not the code from a false positive, but the design from a decision made under a red build.

The question. The machinery has three separable parts: the rule implementations that detekt loads onto its own classpath, the reporting and gate machinery, and the Gradle plugin that wires them together. One published artifact or several?

Two conditions had to resolve first, which is why this stayed open: whether detekt’s classpath model tolerates one artifact carrying both its API and Gradle’s, and what DT-009 decided.

On DT-009 alone the recommendation was two artifacts, on the ground that one drags Gradle’s API into a rule-authoring subproject that has no use for it. MP then narrowed the use case, 2026-08-31:

The use case that we are contemplating for external rule authoring is for a repository that authors and uses new rules. We are not considering “pure rule authoring” repositories that will publish rules to be used by other repositories in combination with the plugin.

That reverses it. A repository that authors and uses its rules keeps that subproject local and unpublished, depending compileOnly — so the split’s main benefit reduces to compile-classpath tidiness, while the integrity argument for keeping plugin and rule set on one version survives intact.

Decided: one artifact.

The question. Every Arda Kotlin repository runs Spotless with ktlint’s standard rules, duplicated everywhere. PDEV-1304 names the Kotlin style gate among its own later candidates; this project has an equally good claim.

Decided: it comes here. The deciding property is not which claim is better but that two owners for one gate is worse than either owner alone — a repository configuring source roots twice, in two extensions, from two plugins with two version trains, is exactly the drift PDEV-1304 exists to stop, reproduced one level up. Formatting and standards also share machinery that formatting and Helm packaging do not: the source-root list, the ratchet base, the three-dot diff, and the rule that an unresolvable ratchet base must fail loudly rather than report an empty change set.

Kover thresholds and the JUnit platform pin stay with PDEV-1304. Kover could have gone either way. Leaving it puts the boundary on a line that states itself: PDEV-1790 owns what enforces the Kotlin Coding Standards; PDEV-1304 owns build configuration. A boundary a reader can re-derive is worth more than one drawn item by item.

DT-013 — Documentation follows the implementation

Section titled “DT-013 — Documentation follows the implementation”

The finding that raised it. The rule catalog’s entire purpose is to make a finding cite the section of the standards that defines it. Checked against the published site, three rules this project intended to ship had nothing to cite — the fold-to-null rule, the getOrElse { throw it } rule, and the Endpoint protocol boundary. Two of the three had been settled by ruling in conversation and implemented the same day; neither ever reached the documentation. The gap was invisible until something tried to link to the standard, which is an argument for the citation mechanism independent of its value to a reader.

Decided: a documentation pull request accompanies the work throughout, as a draft. MP, 2026-08-27:

A Documentation PR must follow this work. We will create the documentation worktree, branch and open a Draft PR as soon as a change is needed and we’ll keep the PR until the project completes to make sure we get the latest reality, then un-draft it at the same time as the new plugin is published.

Four properties, each doing work: opened when the first change is needed rather than up front; draft for the life of the project, so it describes what was built rather than what was planned; un-drafted when the plugin publishes, so the documentation and the artifact that enforces it become true at the same moment; and it is what the tooling points at — the catalog’s citations, the plugin README, and the text a developer reads when a rule fires.

Consequences. A rule may ship before its page exists: the catalog requires only claim, so a rule with no citation still reports and still gates. That is acceptable because the PR is open and tracked; it would not be acceptable as a permanent state. And anchors are verified, never guessed — the site’s slugger leaves trailing hyphens where a heading ends in punctuation, so every citation is checked against the rendered page before it is trusted.

The finding. The survey reported three layering violations, all the same shape: an outbound adapter in servers importing the port it implements, which lived in service.

A correction, recorded because the reasoning matters. The first reading — that the rule was wrong and the repository right — was mistaken. The evidence offered for it was the repository’s own test comment, asserting that an outbound adapter depending upward onto its port is correct hexagonal structure. That is a statement the codebase wrote about itself. It is not the standard. Using a codebase’s own comment to invalidate a rule derived from the platform standard inverts the direction of authority the whole exercise depends on. A divergence found is the finding working, not the finding failing — and it is the failure mode this project is most exposed to, because the tool measures a corpus that has opinions about itself, and the corpus is loud.

Resolved by nesting the vendor adapter under a capability package inside servers, so the consumer and the implementor both depend downward onto a port that sits below them. That resolution is now published as part of DAG Package Discipline, together with the second omission it surfaced in the standard’s own layering table.

Decided. All five ktlint rules have detekt counterparts — four already did, the fold-to-null rule was ported, and single-exit is covered by detekt’s own ReturnCount. The subproject is deleted and the lint entry point runs the detekt gate, which has been seen to fail rather than assumed to work.

What removing it uncovered. The retired engine’s @Suppress("ktlint:arda:…") annotations had been silently disabling ktlint’s standard rules file-wide, masking a dangling KDoc and a misordered import block that nobody saw until the annotations were removed. Found by removing the mechanism rather than by reading it.

The question, named by DT-005 as the piece it could not settle: does the plugin ship the rule list, or does each repository restate it? “The rule list” is two things, and bundling them is what made the question feel binary — the roster (which rules exist and are on) and the per-rule configuration (excludes, exemption lists, thresholds).

The mechanism, verified rather than assumed. detekt merges several config files, later overriding earlier. Confirmed by experiment: detekt was pointed at the repository’s config plus a one-line overlay disabling a rule, and a violation was added to a source file. The rule did not fire. So shipping the list restricts nothing — an overlay overrides anything the plugin ships.

What the same experiment exposed. With the rule switched off, the survey listed it under “Clean — ran over the whole tree and found nothing. Listed so that a rule which silently stops working is visible.” It did not run. It was disabled, and the report called it clean, under the heading that exists to prevent exactly that. That reframes the topic: the real question is not ship or don’t, but how many unaudited ways exist to disable a rule.

Decided. The plugin ships the roster and the standard’s per-rule configuration. A catalogued rule is governed by the scope dial only. Overlays remain available for anything outside the catalogue, because that rule is not catalogued and nothing here governs it. The legibility argument recorded against shipping — a repository that cannot see its own rule list cannot reason about its gate — does not survive contact with the audit: the plugin printing the effective configuration is strictly better than a local file, because it shows the merged reality rather than one input to it.

Say so at execution time. MP, 2026-08-31:

Given that the detekt.yml allows for active: false and we are going to disable this effect for the standards controlled rules, the plugin should output that issue, as an error or at least a warning, at execution time.

An error, not a warning. MP allowed either, and this project has its own evidence for the stricter reading: three separate things that did not fail the build stayed invisible for as long as they existed — a ratcheted rule whose own bootstrap findings never blocked, seven suppressions that disabled a whole standard ruleset file-wide, and a disabled rule reporting itself as clean. A warning in a build log would be the fourth instance of the same pattern. The dial exists, using it is one word, and the message can name the exact replacement.

Wider than active: false. A check for that alone is easy to route around — excludes: ['**'] neutralises a rule just as completely. The check is therefore divergence from the shipped configuration for catalogued rules, which makes it the same mechanism as the audit: one comparison, reported one way.

Thresholds. No overrides are supported. A repository that disagrees with the file-size signal names the departure where it applies, rather than moving the line for the whole tree — which keeps the threshold meaning the same thing in every repository, and is what makes it a standard rather than a default.


Copyright: (c) Arda Systems 2025-2026, All rights reserved