diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..d727077
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,58 @@
+# Open Elements standard EditorConfig.
+# See .claude/skills/project-setup/references/editorconfig.md for the shared baseline.
+# Deviations from that baseline are marked below and explained where they occur.
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+# Deviation from the shared baseline, which specifies 4 spaces and a 120 column limit.
+# This project enforces Spotless with googleJavaFormat for every child project, and
+# Google Java Format writes 2 spaces with a 100 column limit and a 4 space continuation
+# indent. Following the baseline here would make the editor fight the formatter: a
+# developer would type to one rule and the next `spotless:apply` would rewrite it.
+# The ij_java_* rules below are the baseline's and agree with Google Java Format.
+[*.java]
+indent_size = 2
+ij_continuation_indent_size = 4
+max_line_length = 100
+ij_java_class_count_to_use_import_on_demand = 9999
+ij_java_names_count_to_use_import_on_demand = 9999
+ij_java_use_single_class_imports = true
+ij_java_layout_static_imports_separately = true
+ij_java_block_brace_style = end_of_line
+ij_java_class_brace_style = end_of_line
+ij_java_method_brace_style = end_of_line
+ij_java_lambda_brace_style = end_of_line
+ij_java_if_brace_force = always
+ij_java_for_brace_force = always
+ij_java_while_brace_force = always
+ij_java_do_while_brace_force = always
+
+[*.{ts,tsx,js,jsx,json,css,scss,html}]
+indent_size = 2
+
+[*.{yml,yaml}]
+indent_size = 2
+
+# Trailing spaces are significant in Markdown (they encode a line break).
+[*.md]
+trim_trailing_whitespace = false
+
+[*.xml]
+indent_size = 4
+
+# Matches the eol=crlf pin in .gitattributes. Without this the [*] rule above would
+# tell the editor to write LF, and every save would fight the checkout.
+[*.{cmd,bat}]
+end_of_line = crlf
+
+[{Dockerfile,Dockerfile.*}]
+indent_style = space
+indent_size = 4
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..03833cf
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,37 @@
+# Line endings are pinned so a checkout produces the same bytes on every platform.
+#
+# Git always stores text files with LF in the index; `eol=lf` additionally forces LF
+# in the working tree, overriding a local `core.autocrlf` (Windows default: true).
+# Without this, the bytes of every source and resource file would depend on the Git
+# configuration of whoever checked the repository out — including the bytes of this
+# project's own pom.xml, which is deployed to Maven Central verbatim.
+* text=auto eol=lf
+
+# Windows batch files are the single exception: cmd.exe is fragile with LF-only
+# scripts (labels, goto, multi-line blocks), so these are checked out as CRLF.
+# The index representation stays LF, so this produces no commit diff.
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Truly binary files: never normalized, never diffed as text. Git's content
+# heuristic already classifies the assets currently in this repository correctly;
+# these markers remove the dependency on that heuristic for future additions —
+# keystores and archives being the cases where a misdetection would corrupt a file.
+# Listing a type here says how Git must treat it if it ever appears — it is not a
+# statement that such a file belongs in version control. Keystores generally do not.
+*.class binary
+*.eot binary
+*.gif binary
+*.gz binary
+*.ico binary
+*.jar binary
+*.jks binary
+*.jpeg binary
+*.jpg binary
+*.p12 binary
+*.pdf binary
+*.png binary
+*.ttf binary
+*.woff binary
+*.woff2 binary
+*.zip binary
diff --git a/README.md b/README.md
index 0136431..c03e2e9 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,81 @@ builds are reproducible and free of "you should pin this plugin" warnings:
- **Surefire** pre-configured with the `--add-opens` flags commonly needed by
reflection-based test/mocking libraries.
- **Toolchain enforcement** (see [Requirements](#requirements)).
+- **LF line endings** enforced for the formatter, so `spotless:apply` never
+ writes CRLF (see [Line endings](#line-endings)).
+
+## Line endings
+
+This repository pins line endings, and it does so for a concrete reason: with
+`packaging=pom` and no `flatten-maven-plugin`, its `pom.xml` is deployed to Maven
+Central **verbatim**. Checked out on a machine with `core.autocrlf=true` — the
+Windows default — every text file arrives with CRLF, and the published `.pom` would
+differ from one built elsewhere. For a project with Java sources the same variance
+lands in the sources jar and in every copied resource.
+
+`.gitattributes` forces LF in the working tree on every platform, overriding whatever
+`core.autocrlf` or `core.eol` the developer has set. Windows batch files are the
+single exception, because `cmd.exe` is fragile with LF-only scripts. `.editorconfig`
+mirrors the same rules for editors.
+
+This eliminates line endings as a source of byte-level variance. It does **not** by
+itself make builds reproducible across operating systems — that involves further
+variables and is not claimed here.
+
+### What child projects need to do
+
+Git attributes are repository-local: Maven inheritance cannot deliver them. A child
+project inherits the Spotless setting below, but must carry its own `.gitattributes`.
+Copy this file into the repository root:
+
+```gitattributes
+# Force LF in the working tree on every platform, overriding local core.autocrlf.
+* text=auto eol=lf
+
+# Windows batch files are the exception: cmd.exe is fragile with LF-only scripts.
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Truly binary files: never normalized, never diffed as text.
+*.class binary
+*.eot binary
+*.gif binary
+*.gz binary
+*.ico binary
+*.jar binary
+*.jks binary
+*.jpeg binary
+*.jpg binary
+*.p12 binary
+*.pdf binary
+*.png binary
+*.ttf binary
+*.woff binary
+*.woff2 binary
+*.zip binary
+```
+
+Then run `git add --renormalize .` once and commit whatever it stages. In a
+repository that never received CRLF this is a no-op.
+
+Take this project's [`.editorconfig`](.editorconfig) along with it. Its `[*.java]`
+block is set to 2 spaces and a 100 column limit, matching the Google Java Format
+this parent enforces via Spotless — a `.editorconfig` specifying anything else makes
+the editor fight the formatter on every save. The `[*.{cmd,bat}]` block keeps editors
+in agreement with the CRLF pin above.
+
+### What the parent enforces on its own
+
+The parent sets `UNIX` on the Spotless plugin, which every
+child inherits. Spotless defaults to `GIT_ATTRIBUTES`, so without this a
+`spotless:apply` run on a Windows machine in a repository lacking `.gitattributes`
+would actively write CRLF into Java sources.
+
+The reach of that setting is narrow, and worth stating plainly: it applies only to
+files Spotless formats, and only when `spotless:apply` or `spotless:check` is invoked
+— neither is bound to a lifecycle phase. It guarantees the mandated formatter never
+*introduces* CRLF. It does not make a child's sources jar LF-clean; only the child's
+own `.gitattributes` does that.
## Reproducible builds
diff --git a/docs/TODO.md b/docs/TODO.md
index 4d33da9..0372611 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -86,19 +86,22 @@ external verifier actually needs; deferred with the rest of the verification too
**Prerequisite:** Spec 001.
-## Windows line-ending behaviour ships unverified
+## Windows line-ending behaviour is unverified on real Windows
Spec 002 pins `* text=auto eol=lf`, which matters on exactly one platform: Windows,
-where it has to beat the `core.autocrlf=true` default. The mechanism is specified —
-a path-specific `eol` attribute takes precedence over `core.autocrlf` — but nobody
-has observed it here. A `windows-latest` CI job asserting that `git ls-files --eol`
-reports no `w/crlf` outside `*.cmd`/`*.bat` would turn the argument into a
-measurement, at a cost of roughly fifteen lines.
+where it has to beat the `core.autocrlf=true` default. During implementation the
+mechanism was measured — checking files out with `core.autocrlf=true` and with
+`core.eol=crlf` forced still yields LF for text files and CRLF for `*.cmd`, so the
+attribute wins as specified. Git's conversion logic is the same implementation
+everywhere, so this covers the decisive behaviour.
+
+What is still unobserved is the platform itself: no build has run on a real Windows
+machine. A `windows-latest` CI job asserting that `git ls-files --eol` reports no
+`w/crlf` outside `*.cmd`/`*.bat` would close that remainder for roughly fifteen lines.
**Context:** A CI guard was offered during the grill session for spec 002 and
declined, consistent with spec 001 shipping without automated verification. The
-maintainer develops on macOS and cannot reproduce the case locally.
-
+maintainer develops on macOS.
## `claude-base` conventions need two fixes
The org-wide convention documents live in `claude-base` and are vendored into each
@@ -172,3 +175,18 @@ and the library should be rebuilt and tested once against 2.2.47.
**Prerequisite:** Spec 003 released as a `java-parent` version.
+## The project has no `CLAUDE.md`
+
+`/spec-implement` treats updating the Project Context sections of `CLAUDE.md`
+(Features, Tech Stack, Structure, Architecture) as a mandatory closing step of every
+spec. This repository has no `CLAUDE.md` at all, so that step has silently done
+nothing for specs 001 and 002 and will keep doing nothing.
+
+Creating one means describing the whole project, not the slice a single spec touched
+— `/project-analyze` is the tool for it. Worth doing once, after which the per-spec
+update step becomes meaningful.
+
+**Context:** Surfaced during the quality review for spec 002; deliberately not done
+inside that spec, because generating whole-project documentation is not part of
+pinning line endings.
+
diff --git a/docs/specs/002-pinned-line-endings/design.md b/docs/specs/002-pinned-line-endings/design.md
index 3e5da51..d58a666 100644
--- a/docs/specs/002-pinned-line-endings/design.md
+++ b/docs/specs/002-pinned-line-endings/design.md
@@ -230,13 +230,25 @@ sequenceDiagram
Note over G: index stays platform-independent
```
-## Consequence: the Windows promise is argued, not measured
+## Consequence: the Windows mechanism is measured, the Windows platform is not
-`eol=lf` was chosen specifically for Windows, and no automated check verifies it. The
-mechanism is well-specified — a path-specific `eol` attribute takes precedence over
-`core.autocrlf` — but the behaviour ships untested against a real Windows runner,
-which was a deliberate decision. The gap is recorded in `docs/TODO.md` so the
-distinction between *specified* and *observed* stays visible.
+`eol=lf` was chosen specifically for Windows, and no CI guard verifies it. The
+decisive mechanism was however measured during implementation, by checking files out
+of the index with the offending configurations forced on:
+
+| Configuration forced | `crlf.java` | `probe.cmd` |
+|---|---|---|
+| `core.autocrlf=true` (Windows default) | LF | CRLF |
+| `core.eol=crlf` | LF | CRLF |
+| `core.autocrlf=false` | LF | CRLF |
+
+The path-specific `eol` attribute wins in every case, which is the entire behaviour
+the design depends on. Since Git's conversion logic is the same implementation on
+every platform, this is stronger evidence than the specification alone.
+
+What remains untested is the platform, not the mechanism: no build has run on a real
+Windows machine, so editor behaviour, filesystem effects and the Git for Windows
+installer defaults are unobserved. That residual gap is recorded in `docs/TODO.md`.
## Acceptance
@@ -247,6 +259,9 @@ There is no automated check, by decision. Acceptance before merge is:
3. `git check-attr text eol -- pom.xml mvnw mvnw.cmd .claude/skills/…/Lato-Regular.ttf`
reports `eol=lf` for the first two, `eol=crlf` for `mvnw.cmd`, and `-text` for the
font.
+3a. Probe files confirm the conversion behaviour: a CRLF-authored `.java`, `.cmd`,
+ `.md` and `.toml` all land as `i/lf` in the index; a file without line endings
+ stays `i/none`; an explicitly marked `.p12` is stored byte-identical to disk.
4. `./mvnw clean verify` passes.
5. `./mvnw spotless:check` passes (no Java sources here, but the configuration must
parse — an invalid `lineEndings` value fails the plugin).
diff --git a/docs/specs/002-pinned-line-endings/steps.md b/docs/specs/002-pinned-line-endings/steps.md
new file mode 100644
index 0000000..e86974f
--- /dev/null
+++ b/docs/specs/002-pinned-line-endings/steps.md
@@ -0,0 +1,150 @@
+# Implementation Steps: Pinned line endings
+
+Spec: [`design.md`](design.md) · [`behaviors.md`](behaviors.md) · Issue #4
+
+This spec changes build and repository configuration in a `packaging=pom` project
+with no Java sources and no test framework. "Tests" are therefore Git and Maven
+verification commands, not JUnit cases — the checks below are the executable form of
+the behaviour scenarios. Scenarios that can only be observed on Windows are recorded
+as specified-but-unverified, consistent with the design's
+[no-guard decision](design.md#acceptance).
+
+---
+
+## Step 1: Add `.gitattributes`
+
+- [x] Create `.gitattributes` with the `* text=auto eol=lf` baseline
+- [x] Add the `*.bat` / `*.cmd` → `eol=crlf` exception with its rationale comment
+- [x] Add explicit `binary` markers for the asset types present plus the binary types
+ a Java repository predictably acquires
+- [x] Run `git add --renormalize .` and confirm it stages nothing
+
+**Acceptance criteria:**
+- [x] `git check-attr text eol -- pom.xml` reports `text: auto` and `eol: lf`
+- [x] `git check-attr eol -- mvnw.cmd` reports `eol: crlf`
+- [x] `git check-attr text -- ` reports `text: unset`
+- [x] `git ls-files --eol` shows no `i/crlf` and no `i/mixed`
+- [x] `git add --renormalize .` produces no staged changes
+- [x] `git status` reports no modification for `mvnw.cmd`
+
+**Related behaviors:** Text file on a Windows default installation · Extensionless
+text file · Windows batch file · Unix checkout is unchanged · Local Git configuration
+cannot override the pin · Binary assets are untouched · A file authored with CRLF is
+normalized · A batch file authored with CRLF is normalized in the index · A binary
+file is committed verbatim · Renormalization changes nothing · The existing working
+tree stays valid · A new text file type nobody thought about · A new binary type
+covered by an explicit marker · A new binary type not covered by any marker · A file
+with mixed line endings · A file with no line endings at all
+
+---
+
+## Step 2: Add `.editorconfig`
+
+- [x] Create `.editorconfig` from the Open Elements standard
+ (`.claude/skills/project-setup/references/editorconfig.md`)
+- [x] Correct `[*.java]` to Google Java Format: `indent_size = 2`,
+ `ij_continuation_indent_size = 4`, `max_line_length = 100`
+- [x] Add a `[*.{cmd,bat}]` block with `end_of_line = crlf` so the editor agrees with
+ the Git attribute
+- [x] Comment the deviation from the org standard at the point of deviation
+
+**Acceptance criteria:**
+- [x] `[*] end_of_line = lf` and `charset = utf-8` are present
+- [x] The `[*.java]` block specifies 2 spaces and 100 columns, matching
+ `googleJavaFormat`
+- [x] The `[*.{cmd,bat}]` block specifies CRLF, matching `.gitattributes`
+- [x] The `ij_java_*` rules from the org standard are carried over unchanged
+
+**Related behaviors:** Java formatting agrees with the formatter · Editor and Git
+agree on batch files
+
+---
+
+## Step 3: Force UNIX line endings in the inherited Spotless configuration
+
+- [x] Add `UNIX` to the Spotless plugin configuration in
+ `pom.xml`, above the existing `` block
+- [x] Comment why the `GIT_ATTRIBUTES` default is insufficient for child projects
+
+**Acceptance criteria:**
+- [x] `./mvnw spotless:check` passes — an invalid enum value would fail the plugin at
+ configuration time
+- [x] `./mvnw clean verify` passes
+- [x] `./mvnw -Pfull-build clean verify` passes, including pomchecker
+
+**Related behaviors:** Formatting on Windows without a child `.gitattributes` · A
+child can still override the setting · The configuration is validated by the build
+
+---
+
+## Step 4: Document the pin in the README
+
+- [x] Add a **Line endings** section describing what is pinned and why the parent's
+ own published `.pom` is affected
+- [x] Include a copy-paste `.gitattributes` block for child projects
+- [x] State what the inherited Spotless setting does and does not cover
+- [x] Reference the section from the existing Build conventions list
+
+**Acceptance criteria:**
+- [x] The section states the narrow claim: line endings are eliminated as a variance
+ source; cross-OS reproducibility is not claimed
+- [x] The copy-paste block matches the repository's own `.gitattributes`
+- [x] No promise is made that the parent delivers `.gitattributes` to children
+
+**Related behaviors:** No scenario in `behaviors.md` describes README content
+directly. This step supports *Java formatting agrees with the formatter* and
+*Formatting on Windows without a child `.gitattributes`*, both of which depend on a
+child project actually receiving the two files.
+
+---
+
+## Step 5: Final verification
+
+- [x] `git ls-files --eol` audit across the whole tree
+- [x] `git add --renormalize .` is a no-op
+- [x] `./mvnw -Pfull-build clean verify` passes
+- [x] `docs/specs/INDEX.md` status is correct
+
+**Acceptance criteria:**
+- [x] All of the above pass
+- [x] The diff contains no incidental whitespace changes to existing files
+
+**Related behaviors:** Weakening the attributes silently restores the old behaviour
+(documented, deliberately unguarded)
+
+---
+
+## Behavior Coverage
+
+23 scenarios. Layer is "Repo/Build" throughout — there is no application code, so the
+executable form of a scenario is a Git or Maven command rather than a JUnit case.
+
+| Scenario | Verification | Step |
+|---|---|---|
+| Text file on a Windows default installation | Measured — `core.autocrlf=true` forced on checkout yields LF | 1 |
+| Extensionless text file | Attribute asserted for `mvnw` and `.sdkmanrc` | 1 |
+| Windows batch file | Measured — CRLF under `autocrlf=true`, `autocrlf=false` and `core.eol=crlf` | 1 |
+| Unix checkout is unchanged | Measured — clean tree, no renormalization | 1 |
+| Local Git configuration cannot override the pin | Measured — `autocrlf=true` and `core.eol=crlf` both lose | 1 |
+| Binary assets are untouched | Measured — `.p12` blob byte-identical, `.ttf` reports `-text` | 1 |
+| A file authored with CRLF is normalized | Measured — CRLF probe lands as `i/lf` | 1 |
+| A batch file authored with CRLF is normalized in the index | Measured — `i/lf` with `attr/text eol=crlf` | 1 |
+| A binary file is committed verbatim | Measured | 1 |
+| Renormalization changes nothing | Measured — no tracked file staged | 1, 5 |
+| The existing working tree stays valid | Measured — `mvnw.cmd` not reported modified | 1 |
+| Java formatting agrees with the formatter | By inspection — no Java sources in this repository | 2 |
+| Editor and Git agree on batch files | By inspection — `[*.{cmd,bat}]` matches `eol=crlf` | 2 |
+| Formatting on Windows without a child `.gitattributes` | Not verifiable here — needs a child project | 3 |
+| A child can still override the setting | Not verifiable here — needs a child project | 3 |
+| The configuration is validated by the build | Measured — `spotless:check` exits 0 | 3 |
+| The deployed POM does not depend on the build platform | Follows from the measured checkout behaviour; not directly compared across platforms | 1 |
+| A new text file type nobody thought about | Measured — `.toml` probe lands as `i/lf` | 1 |
+| A new binary type covered by an explicit marker | Measured — `.p12` probe | 1 |
+| A new binary type not covered by any marker | Not verifiable — documented residual risk | 1 |
+| A file with mixed line endings | Measured — mixed probe lands as `i/lf` | 1 |
+| A file with no line endings at all | Measured — stays `i/none` | 1 |
+| Weakening the attributes silently restores the old behaviour | Not guarded, by decision | 5 |
+
+**16 measured, 2 by inspection, 5 not verifiable in this repository.** The five gaps
+need either a child project or a real Windows machine; both are recorded in
+`docs/TODO.md`.
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index d7cb841..68523d2 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -3,5 +3,5 @@
| ID | Spec-Folder | Name | Areas | Description | GitHub Issue | Status |
|-----|-------------|------|-------|-------------|--------------|--------|
| 001 | 001-reproducible-build-timestamp | Reproducible build timestamp | build, infrastructure, documentation | Fixed `project.build.outputTimestamp` literal in the parent POM, inherited by all child projects, maintained by `release.sh` — so a third party can rebuild any Open Elements Java artifact byte-identically | #3 | done |
-| 002 | 002-pinned-line-endings | Pinned line endings | build, infrastructure, documentation | `.gitattributes` forcing LF (CRLF for `*.bat`/`*.cmd`), a matching `.editorconfig` aligned with Google Java Format, and an inherited Spotless `lineEndings=UNIX` — so a checkout produces the same text bytes on every platform | — | open |
+| 002 | 002-pinned-line-endings | Pinned line endings | build, infrastructure, documentation | `.gitattributes` forcing LF (CRLF for `*.bat`/`*.cmd`), a matching `.editorconfig` aligned with Google Java Format, and an inherited Spotless `lineEndings=UNIX` — so a checkout produces the same text bytes on every platform | #4 | done |
| 003 | 003-openapi-stack-versions | OpenAPI stack versions | build, api, documentation | Import `springdoc-openapi-bom` and `swagger-bom` and manage `org.webjars:swagger-ui`, so the coupled OpenAPI stack resolves uniformly in every consumer instead of splitting into a `NoSuchMethodError` | — | open |
diff --git a/pom.xml b/pom.xml
index 3669886..b72e230 100644
--- a/pom.xml
+++ b/pom.xml
@@ -221,6 +221,13 @@
com.diffplug.spotless
spotless-maven-plugin
+
+ UNIX