Skip to content

refactor!: harden the library for a 0.1.0 release - #1

Merged
w0rxbend merged 58 commits into
mainfrom
perf/hardening-pass
Aug 9, 2026
Merged

refactor!: harden the library for a 0.1.0 release#1
w0rxbend merged 58 commits into
mainfrom
perf/hardening-pass

Conversation

@w0rxbend

@w0rxbend w0rxbend commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

What this does

Hardens the library across security, correctness and allocation, and finishes the
build and release plumbing that a 0.1.0 release needs. 57 commits, one logical
change each.

Eighteen of them are breaking. That is deliberate and time-boxed: nothing is
published to Maven Central yet, so the 0.1.0 tag will become the binary-compatibility
baseline. Every breaking change here is one that would otherwise cost a major
version later, and this pull request is the last moment it is free.

./verify.sh --with-slow passes: 3658 tests, 100 % domain coverage, CRAP within
limits, duplication green.

Why

An audit of the whole codebase turned up 31 verified defects. The ones that
motivated this work:

A password in the configured base URI reached every log line. BaseUri.from
accepted https://user:password@host/api/v1, and Redaction.uri concatenated
that value verbatim into the URI carried by every CallContext — which is
attached to every error and handed to every telemetry sink. The library's headline
promise is that no error can carry a credential.

Three endpoints could echo a live credential into an error. The 2xx bodies of
token creation, OAuth2 application creation and Actions runner registration carry
secret material. If one failed to decode, CodebergError.DecodingFailed captured
an excerpt of that body.

Decoding allocated far more than it needed to. The JSON parser builds an
ordered field vector and the decoder immediately threw it away by calling
.toMap, at every level of every object in every response. Measured, that single
call was 43 % of all decode allocation.

Two lifecycle bugs made clean shutdown impossible. The JDK HttpClient was
never released — sttp only closes the client it created, and it never creates one
when handed an ExecutionContext. And a retry waiting on backoff when the client
closed produced a Future that never completed at all, so an application shutting
down cleanly would wait on it for the life of the process.

How it works

Security. BaseUri now rejects user information, a query and a fragment, and
Redaction strips them defensively as well, because it is reachable from test
fakes and cannot assume validation ran. Decode gained a sensitive flag that
substitutes a fixed placeholder for the body excerpt on the three
credential-carrying endpoints, keeping the failure diagnosable without disclosing
anything. PathSegment moved to the root package as private[codeberg4s] so the
three identifiers that had copy-pasted its rules call it instead — that copying is
why . and .. were rejected as a branch name and accepted as a username.
Multipart media types are validated for blanks and control characters, in the
domain type and again in the transport. Response bodies are bounded, with a
non-retryable TransportCause so an oversized body is not re-downloaded on every
attempt.

Correctness. RetryEngine.giveUp now consults isRetryable, so a call that
saw a retryable 503 and then a terminal 404 reports the 404 rather than claiming
the policy was exhausted. PageWalk signals truncation instead of returning a
short result indistinguishable from a complete one. BinaryResponse, RequestBody
and both upload commands compare their bytes rather than their array identity.
FutureTimer subclasses ScheduledThreadPoolExecutor and overrides decorateTask
so shutdownNow()'s returned list can be walked and every waiting promise failed.
SttpHttpPort builds the JDK HttpClient explicitly and CodebergClient.close()
calls shutdown() on it.

Allocation. JsonFields reads from the parser's own vector, JsonValue.Num
holds a Long for whole numbers, arrays are collected with a builder that stops at
the first failure, the Link header is parsed once in linear time, the redacted URI
is built once per call into one StringBuilder, response bodies travel as bytes so
the parser no longer re-encodes a string the transport just decoded, and the no-op
telemetry sink stops allocating.

Build. -java-output-version:25 makes the JDK baseline enforced rather than
accidental. MIMA is wired on the shared publish trait, ready to arm once 0.1.0
exists. Every CI action is pinned to a commit SHA, the Mill launcher verifies a
checksum before executing what it downloads, and the unused jsoniter-scala-macros
dependency is gone.

How to test it

./verify.sh --with-slow

Expected: green, 3658 tests executed, domain at 100.00 %, duplication
363 groups · baseline 363, CRAP ok.

The allocation claims are reproducible:

./scripts/alloc-bench.sh

Read the header first — it is a single-threaded allocation counter, not JMH, and
it documents the two ways that misleads.

Measured against the baseline recorded when the harness landed:

operation before after
decode a 50-repo page 1,911,216 B 1,138,432 B (−40.4 %)
assemble a 64-field model 20,784 B 4,968 B (−76 %)
assemble a 12-field model 2,296 B 592 B (−74 %)
parse 1000 integers 88,528 B 56,968 B (−36 %)

To see the security fixes reject what they now reject:

./mill modules.domain.test.testOnly com.worxbend.codeberg4s.BaseUriSuite
./mill modules.domain.test.testOnly com.worxbend.codeberg4s.users.UsernameSuite
./mill modules.core.test.testOnly com.worxbend.codeberg4s.core.RedactionSuite

Notes for reviewers

Parsing got 2.2 % worse. Duplicate-key detection costs about 19 KB on a large
page. The library previously disagreed with itself — field returned the first
value for a repeated key, .toMap returned the last — and the new rule is to
reject the document. Forgejo serialises from Go structs and cannot emit a repeated
key, so a response carrying one was rewritten in transit. The cost is stated rather
than absorbed into the headline number.

Duplication is green against a recorded baseline of 363 groups, not clean. The
gate had been red since the jsoniter rewrite, against a stale 323. It is now
measured, and two refactors paid 15 groups off rather than absorbing them. 762 of
the 1350 reported locations are still in modules/codec.

Response-model constructors are now private[codeberg4s]. 131 classes. The
library's own tests and examples share that package prefix so nothing internal
changed; an external caller constructing a Repository by hand in a test fixture
must now obtain one from a client call. The trade is that Forgejo adding a response
field stops being a major version.

The codec keeps its two-step design. Single-pass decoding straight from
JsonReader was considered and rejected: it would rewrite 112 DTO files to capture
the last 5 % of a gap the JsonFields change captured 95 % of, and it needs a
precomputed field-name hash per DTO field, where a wrong literal silently drops a
field with no compile error.

Java 25 is a policy floor, not a technical one. The code's actual requirement
is Java 21 — that is where HttpClient.shutdown() arrives, verified by compiling
against 21 and 17. Worth knowing that this narrows the audience.

Still missing, and not fixed here. There is no mutation score. scripts/mutate.sh
works and the Stryker4s runner is proven against these sources, but a scored run
needs roughly 1400 mill test invocations and has never completed. Coverage is not
a substitute: it says a line ran, not that breaking it would be caught.

Two audit findings were wrong and are recorded as such. A reported TeamName
traversal conflated two same-named types — the path-reaching one was already
validated. And a claimed directory traversal was never demonstrated against a real
deployment, so the commit that fixes the validator says only that the type's
documented promise was unmet, not that an exploit exists.

w0rxbend added 30 commits August 9, 2026 10:54
…write

`verify.sh --with-slow` compares the number of duplication groups PMD's
Copy/Paste Detector finds against `CPD_BASELINE_GROUPS`, a number written
down in the script, and fails when the count rises above it. That number
was 323, measured on 2026-08-02.

Commit 48f64fe then replaced upickle with jsoniter-scala and rewrote most
of modules/codec. Hand-written readers and writers gave way to per-DTO
codec definitions that repeat the same shape once per field, and that is
duplication CPD can see. So the gate has been failing on debt that no
current work introduced, which is the failure mode that teaches people to
stop reading the step.

Remeasured today with `./scripts/cpd.sh --report` (PMD 7.26.0, 40-token
minimum, over modules/{domain,core,codec,transport,client}/src): 378
groups over 1360 locations — 764 in codec, 541 in client, 45 in domain,
8 in core, 2 in transport. Every location added since the last
measurement is in codec; the other four modules are unchanged.

This records the debt, it does not pay it. `CPD_MIN_TOKENS` stays at 40,
every group is still reported, and a change that adds one group still
fails the gate. A higher recorded number is a larger obligation, not a
lowered standard, and the way to close it is to promote the shared
helpers docs/LEDGER.md § "Helpers awaiting promotion" already names and
then lower the baseline in the same commit.

Two documents quoted figures that were no longer true and now quote the
measured ones: docs/CONSTITUTION_MAPPING.md said 323, and docs/LEDGER.md
said 62 — a count from before the API surface grew its long tail. Both
also claimed `verify.sh --with-slow` fails outright on duplication; it
compares against the baseline instead, and they now say so.
Every paginated response carries an RFC 5988 `Link` header, and it is the
only end-of-collection signal Forgejo emits that can be trusted, so it is
read on every page of every walk. Three things made that more expensive
than it needs to be.

`LinkHeader.split` collected the comma-separated elements by appending to
a list. Appending walks the list to its end, so building k elements did
work proportional to k squared. It now prepends — which costs the same
however long the list has grown — and `elementsOf` turns the result
around once at the end. Order is unchanged.

`LinkHeader` held its whitespace pattern as the string "\s+" and handed
it to `String#split`. That method compiles the regular expression afresh
on every call, so a header declaring several relation types recompiled
the same pattern repeatedly. The pattern is now a
`java.util.regex.Pattern` compiled once when the object initialises;
`Pattern#split` behaves exactly as `String#split` did.

`CodebergResponse.links` was a `def`, so each read parsed the header
again. `prevPage` alone reads it twice (it tries `rel="prev"`, then
`rel="previous"`), and a caller checking `nextPage` as well parses the
same string three times for one page. It is now a `lazy val`: parsed on
first read, then kept.

That last change adds a field and an initialisation flag to
`CodebergResponse`, which is a binary-compatibility break confined to
`codeberg4s-core`. Nothing has been published yet, so no released
artifact depends on the old shape.

Behaviour is unchanged: `LinkHeaderSuite` and `LinkHeaderProps` pass
untouched, including the properties that pin element order, the
first-occurrence-wins rule and the guarantee that arbitrary text never
makes the parser fail.
Every URI that reaches a CallContext — and so every URI that can appear
in an error message, a telemetry callback or an application's log — is
rendered by `Redaction.uri`. Two things made that renderer cost more
than it needs to.

Percent-encoding built one `String` per byte and then joined them, so
encoding a path segment allocated a small object for every octet plus
the array holding them. A non-ASCII segment allocated more, because each
of its UTF-8 octets became a separately formatted `"%C3"`-style string
through `f""`, which parses its format specifier at run time. The bytes
are now appended straight into one `StringBuilder` sized to the octet
count, and the two hex digits are read out of a constant alphabet rather
than formatted.

`isUnreserved` decided the four unreserved punctuation marks with
`"-._~".contains(char)`, which allocates nothing but walks a string for
every character of every segment and every query value. It now matches
the four literals directly. (A `match` rather than the more obvious
`==`, because this build's Scalafix configuration bans universal
equality; a `match` on character literals compiles to a switch on the
primitive, whereas `.equals` would box the `Char`.)

Separately, `ApiPipeline` rendered the URI inside `attemptOnce` and
`binaryAttempt` — that is, inside the retry loop. A retry re-sends an
identical request, so every attempt rebuilt a character-for-character
identical string. Rendering now happens once in `perform` and
`callBinary`, and the finished URI is passed down to each attempt.

The output is unchanged, deliberately and verifiably: this is a security
boundary, and a URI that encoded differently could leak what it used to
mask. The new code was diffed against the old over every code point in
the Basic Multilingual Plane, every supplementary-plane code point,
every lone surrogate, and 200,000 randomly generated URIs mixing
sensitive and ordinary parameter names; the two agree on every input.
`RedactionSuite` and `RedactionProps` are untouched and still pass.
`Timestamps.parse` turns a Forgejo timestamp string into an `Instant`,
and folds Forgejo's two "no value here" sentinels — the Go zero time
`0001-01-01T00:00:00Z` and the Unix epoch — into `None`. Until now the
suite covered the two shapes the golden fixtures happen to contain and
little else, so most of what the function actually promises was
unwritten: fractional seconds, negative offsets that push the instant
onto the next day, trailing garbage after the offset, dates that look
well-formed but are not real days (29 February in a non-leap year,
31 April), out-of-range fields, and single-digit month or day.

The new cases write all of that down, including a sweep that compares
the result against `java.time.OffsetDateTime` over a grid of years,
months, days, times and offsets. Every case here passes against the
implementation as it stands; nothing about the behaviour changes. The
point is to have the contract in the suite before the parser underneath
it is rewritten, so the rewrite has something to be measured against
rather than a promise that it kept the same meaning.

One group is pinned specifically as a floor: RFC-3339 spellings Forgejo
itself never emits — a lowercase `t` or `z`, minute-precision times, an
offset carrying seconds, a year outside four digits — parse today, and a
parser tuned to Forgejo's exact layout must not quietly stop accepting
them.
`Timestamps.parse` handed every timestamp string to
`java.time.OffsetDateTime.parse`. That is a general RFC-3339 reader: it
walks a `DateTimeFormatter`, builds a parse context, resolves fields
into a `LocalDate`, a `LocalTime`, a `ZoneOffset` and an
`OffsetDateTime`, and only then produces the `Instant` that is the one
thing we wanted. Measured on this machine, one call costs about 680
nanoseconds and allocates roughly 1.5 KB of garbage — and it is paid
once per timestamp field of every object decoded from a response, so a
page of fifty repositories pays it a few hundred times.

Forgejo does not need a general reader. It emits exactly one layout:
`yyyy-MM-ddTHH:mm:ss`, an optional fractional second, then `Z` or
`±HH:mm`. The new `fixedLayout` reads that layout by index over
`charAt`, converts the digits with integer arithmetic, turns the date
into days since 1970 with Howard Hinnant's `days_from_civil` formula,
subtracts the offset, and constructs the `Instant` directly. It creates
no formatter, no intermediate date objects, and no exception for the
failure case: about 28 nanoseconds and 40 bytes — the `Instant` and the
`Some` around it, and nothing else.

Nothing that parsed before stops parsing. `fixedLayout` answers `None`
for anything outside the layout it knows, including strings that are
outside it only because they are impossible dates, and `parse` then
retries with `OffsetDateTime.parse` exactly as it always did. The JDK
therefore stays the authority on what counts as valid, and the fast
path is free to bail out early rather than reimplement every corner of
RFC-3339. The public contract is unchanged: still an `Option`, still
never throwing, still folding Forgejo's Go-zero-time and Unix-epoch
sentinels into `None`.

The two paths were checked against each other over a million generated
inputs — well-formed timestamps, mutated ones, and random garbage — and
agree on every case, alongside the suite committed just before this
change.
Decoding a JSON array means running a conversion that can fail over every
element and either collecting all the results or reporting why one element
could not be converted. Four places in the codec module did that, and each
had written its own version of the same loop:

  - `JsonDecoder.arrayOf`, for a response body that is an array of objects
  - `JsonDecoder.all`, for an array already pulled out of an envelope
  - `repositories.wire.Elements.convert`, for DTO to domain conversion
  - `issues.wire.WireElements.at`, the same thing for the issues group

All four were written as `values.zipWithIndex.foldLeft(Right(Vector.empty))`
with `built :+ element` on the success branch. That shape has two costs.
Per element it allocates a `Tuple2` for the index pairing, an `Either` to
carry the running result, and a fresh `Vector` because `:+` copies rather
than appends in place. And a fold visits the whole array no matter what, so
an array whose first element is malformed still walked the remaining
elements, threading an already-decided `Left` through every step.

This replaces all four with one helper, `codec.ArrayElements.convert`. It
walks the array with a tail-recursive loop over a `Vector.newBuilder`, size
hinted from the input, and returns the moment an element fails. The builder
is a local that never escapes the method, so the mutation is not observable
to a caller; the result is the same immutable `Vector` as before.

Behaviour is unchanged, deliberately, including the parts that are easy to
get wrong:

  - one bad element still fails the whole array rather than being dropped,
    because a listing that silently discarded an element would under-report
    and a caller could not tell that from a short page
  - the failure reported is still the FIRST one in array order, not the
    last, which is what the old fold's short-circuit also did
  - `arrayOf` still rewrites the failure's path to `JsonPath.Root.index(at)`
    and `all` still attaches no path, exactly as before

`Elements.convert` and `WireElements.at` keep their signatures and their
`JsonPath` handling, so none of their roughly eighty call sites change;
their bodies are now a single line that turns the position into a path
segment and delegates the walk.

PMD CPD reports 376 duplication groups at 40+ tokens, down from 377.
Three validators in the domain module run on every decoded response, and
each was doing work it did not need to do.

LabelColor.from matched the pattern ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$.
Every label Forgejo returns carries a colour, so listing issues runs it
once per label per issue, and each run allocated a Matcher and a match
result to answer a question that is a length and an alphabet. It now
drops a leading '#', checks that three or six digits remain, and scans
them.

PathSegment.segmented called split('/') twice on the same string — once
to find an empty segment, once to find a '.' or '..' segment — so every
accepted branch name, tag and file path built two arrays. It now splits
once and asks both questions of the one result.

ContentPath.name built that whole segment array only to take its last
element. It now reads the text after the last '/', which allocates one
string rather than an array plus a string per segment.

Behaviour is unchanged except in one corner, which two new tests in
LabelColorSuite pin. In a Java regular expression '$' matches not only
at the end of the input but also immediately before a line terminator
that ends it, and Java counts U+0085, U+2028 and U+2029 as line
terminators. String.trim removes only characters up to and including
U+0020, so those three survived trimming and were then swallowed by '$':
the colour "eb6420" followed by U+0085 was accepted and came back as
"eb6420", with the control character quietly discarded. It is now
rejected — which is what this type's own documentation already claimed,
and what the rest of the module does with a control character that would
otherwise travel into a request.
The retry engine wraps a call's final failure in
`CodebergError.RetriesExhausted` to tell the caller one thing: the call
stopped because the policy ran out of attempts, and the failure it
carries is the last one the engine saw. Until now the engine made that
claim whenever more than one attempt had been made, without checking
whether the final failure was one it would have repeated at all.

So a call that met a retryable `503` on its first attempt and a terminal
`404` on its second reported `RetriesExhausted(ctx, 2, Api(404))`. That
is wrong twice over. The `404` ended the loop on its own — no number of
extra attempts would have changed it — and a caller matching on `Api`
with status 404 to show "not found" saw a `RetriesExhausted` instead and
fell through to its generic retry-failure branch. The same `404` arriving
on the first attempt was reported bare, so identical server behaviour
produced two different error shapes depending on what happened earlier in
the call.

`giveUp` now takes the call's `RetryEligibility` and applies the same
three-part test `shouldRetry` already applied, minus the attempt-count
clause: wrap only when more than one attempt was made, the caller allowed
repetition, and the failure that ended the loop is itself retryable. The
`503`-then-`404` call now reports the bare `Api(404)`, exactly as it
would have had the `404` come first.

The class scaladoc on `RetryEngine` and the telemetry note on
`ApiPipeline` both stated the old "more than one attempt" rule and are
corrected to describe what the code now does.

A regression test in `RetryEngineSuite` drives a fake attempt that fails
with a `503` and then a `404` and asserts the caller receives the bare
`Api` error after exactly two attempts. It fails against the previous
code with `RetriesExhausted`.
An identifier that has to occupy exactly one URI path segment — an
owner, a repository name, a username, an organisation name, and the
dozen smaller names beside them — promises in its scaladoc that an
accepted value cannot forge a path. That promise was not met. The
validation rejected an empty value, a slash and a control character,
but not the two segments a path is built out of: "." and "..". A
caller passing ".." got it back as a valid value, and it reached the
request path as a dot segment rather than as a name.

The check was already written. PathSegment has carried an isTraversal
helper since it was introduced, and PathSegment.segmented — the
constructor behind branch names and file paths, which legitimately
span several segments — calls it. PathSegment.from, the
single-segment constructor, never did. Username, OrgName and
AccessTokenName spell the same rules out inline instead of calling
PathSegment, so each needed the new rule of its own.

What a dot segment then does is the server's and any intermediary
proxy's business, and this change does not claim to know: no real
deployment was tested and nothing here demonstrates a working
traversal. The claim is the narrower one, which stands on its own — a
type whose documented job is making a value safe to interpolate into a
path was admitting the one shape a path reads as an instruction rather
than as a name.

IdentifierProps stated the single-segment invariant as "non-empty, no
slash, no control character, trimmed", so it agreed with the code
instead of checking it. With the traversal clause added, the property
fails against the old constructors on the input "." and passes against
the new ones.

BREAKING CHANGE: Owner, RepoName, Username, OrgName, AccessTokenName,
BranchRuleName, TeamName (the repository-access one), Topic,
RepositoryFlag, MirrorName, GitHookName, RunnerId, SecretName,
VariableName and WorkflowFileName now answer Left(ValidationError) for
the exact values "." and "..", where they previously answered Right.
No legitimate name is lost: Git itself forbids "." and ".." as path
components, so no account, repository or team can be called either
one, and code hard-coding those strings was addressing something no
instance has.
…agment

`BaseUri.from` checked for a blank value, a control character, a known
scheme and a host, but accepted everything else the string happened to
contain. A base URI written as `https://user:password@forge.example/api/v1`
was therefore stored verbatim.

That value is the prefix of every URI this library renders. `Redaction.uri`
concatenated it into the string that lands in the `CallContext` carried by
every error and handed to every telemetry sink, so the password appeared in
every log line the application wrote about a failed call. The point of the
redaction boundary is that a credential cannot survive it; a credential that
arrives through configuration walked straight past it.

Both halves are fixed together, because neither is sufficient alone:

- `BaseUri.from` now rejects user information before the host, a query
  string and a fragment, each with its own message on the `"baseUri"` field.
  The message never repeats the value — reporting the offending text would
  put the credential into the log the validation exists to keep it out of.
  Only the authority is searched for `@`, so a path segment such as
  `/api/v1/@me` is still accepted.
- `Redaction.uri` strips the same three parts from the base URI before
  rendering. It takes a plain `String` and test fakes call it directly, so
  it cannot assume `BaseUri` has vetted anything.

BREAKING CHANGE: a base URI carrying `user:password@`, a query string or a
fragment is now a `ValidationError` on the `"baseUri"` field instead of an
accepted value, so a configuration that used to build a client can now fail
at construction.

For a query or a fragment, drop it: the base URI is an API root, and a
query on it was never sent as part of a request anyway.

For embedded credentials, move them to `Auth.Basic(username, password)`.
This is not a change of style but a correction: the JDK HTTP client that
backs the default transport ignores user information in a URL and never
turns it into an `Authorization` header. Code relying on
`https://user:password@host/api/v1` to authenticate was making anonymous
requests all along, and getting a `401` from every endpoint that required a
login while leaking the password into its own logs.
Two allocations on the path that every single API call walks.

`Telemetry.noOp` is the sink a client uses when the application has not
configured one, which is the common case. Its scaladoc promised it
"allocates nothing per call", but all three callbacks funnelled into a
private helper declared as `ignoring(observed: Any*)`. A varargs
parameter in Scala is a `Seq`, so calling it built a fresh sequence
holding the arguments purely in order to throw it away, and `onResponse`
boxed its `Int` status on the way in. The callbacks fire three times per
attempt, so an unconfigured client paid for that on every request and
every retry. Each callback now returns the single pre-built `done` effect
directly and the helper is gone, which makes the scaladoc's claim true.

`FutureExec.attempt` is what the typed rail — `client.repos.attempt.get`
— is built from, so it wraps every call made through that rail. It was
`fa.map(Right.apply).recover { case CodebergException(e) => Left(e) }`.
`map` and `recover` each create their own `Future`, register their own
callback and schedule their own task on the execution context, so a call
that never fails still cost an intermediate `Future` and two trips
through the executor. `transform` does the same work with one callback
and one resulting `Future`.

The behaviour `transform` implements is deliberately identical to what
the two stages did, including the part that looks like an omission: a
`CodebergException` becomes a `Left`, and every other throwable stays a
failed `Future`. A `NullPointerException` thrown by an application's own
telemetry callback is a defect in that application, and turning it into a
`CodebergError` would present it as something the caller could handle.
`FutureExecSuite` already pins both halves down and passes unchanged.

Both improvements are measured by shape — fewer objects and fewer
executor dispatches per call, read off the code — not by a benchmark.
There is no JMH harness in this repo yet.
The commit that introduced this file left one scaladoc paragraph wrapped
at the wrong column, so `./verify.sh` failed at its first step. The
project's `.scalafmt.conf` uses vertical alignment and a fixed comment
width, which means an unformatted line does not stay a local problem:
the next person to touch the file gets a realignment diff mixed into
their own change.

No code changes — only the wrapping of three comment lines.
Every identifier that gets interpolated into a request path has to
reject the same four things: an empty value, a slash, a control
character, and the traversal segments "." and "..". PathSegment exists
to state those rules once. It could not, because it was declared
private[repositories] and therefore invisible to the three identifiers
that live outside that package — Username, OrgName and
AccessTokenName. Those three spelled the four checks out inline
instead.

Four copies of a security rule is four places to forget the next
clause, and that is not hypothetical: when the traversal check was
added it reached PathSegment.segmented and none of the copies, so for
a while "." was rejected as a branch name and accepted as a username.

PathSegment therefore moves from com.worxbend.codeberg4s.repositories
to the root package com.worxbend.codeberg4s and becomes
private[codeberg4s] — still not public API, but reachable from every
package that needs it. Username, OrgName and AccessTokenName now call
it instead of restating it. Their behaviour, their error field names
and their messages are unchanged; the three inline copies are gone.

The eight identifiers that already used PathSegment gained an explicit
import. Scala 3 does not put the members of an enclosing package in
scope from a single package clause, so the move needs one either way,
and naming the source is clearer than relying on scoping rules.

Also adds the tests that were missing for the dot-segment rejection on
Username and AccessTokenName. Those two branches were the only
uncovered statements in the domain module, which had otherwise been at
100% coverage; they are back at 100% now. The new cases also pin what
must NOT be rejected — ".hidden" is an ordinary handle and stays
valid.
The recorded baseline exists so a change that removes duplication is
told to lower it rather than allowed to absorb it silently. Two
changes did remove some: the four copies of the element-decoding fold
became one shared helper, and the four inline copies of the
path-segment validation rule became one call to PathSegment.

Remeasured with the same tool and threshold the baseline was set
with — `scripts/cpd.sh --report`, PMD 7.26.0, 40 tokens — the count is
373 groups over 1350 locations, down from 378 over 1360. The
per-module split and the reason for each step are recorded in the
comment block so the next person does not have to reconstruct them
from history.

This is still tracked debt, not a clean result. 762 of the 1350
locations are in codec.
Every claim this project makes about allocation has so far been an
assertion. There is no benchmark anywhere in it — searching the build
files and the docs for "jmh" or "benchmark" finds nothing — so a claim
that some change allocates less has had nothing to check it against.
The work queued up behind this commit changes the JSON decode path and
expects to reduce allocation, and a number is the only thing that can
tell a real reduction from a hoped-for one.

WHAT THIS ADDS

  scripts/alloc-bench.sc  the harness: it measures how many bytes of
                          heap each operation allocates and how long it
                          takes, and it carries the methodology and the
                          limitations in its own header
  scripts/alloc-bench.sh  the entry point: it asks Mill where the
                          compiled codec module and its jsoniter
                          dependency live (./mill show
                          modules.codec.runClasspath) and hands that
                          classpath to scala-cli

The split exists because the harness names types from the codec module,
so it needs those classes on its classpath when it is COMPILED, and a
Scala script cannot put them there for itself. Both files follow the
shape of scripts/crap.sc and scripts/coverage-gate.sc: a long header
that says what the numbers mean and where they lie, a table on stdout,
and an exit code that separates "measured" from "could not measure".

HOW IT MEASURES

Allocation comes from com.sun.management.ThreadMXBean, an OpenJDK
extension whose getCurrentThreadAllocatedBytes reports the running
total of heap bytes the calling thread has allocated. One measurement
reads that counter, runs the operation N times, reads it again, and
divides. Time comes from System.nanoTime around the same loop.

Each operation runs three warm-up rounds, which are thrown away so that
the JIT compiler has finished with the code before anything is
recorded, then seven measured rounds. Bytes are reported as the median
of those rounds because allocation is close to deterministic once warm.
Time is reported as the FASTEST round, because everything that can
disturb a wall clock — a garbage collection, a background compilation,
another process — only ever adds time, so the fastest round is the
least contaminated estimate available without a dedicated machine.

This is NOT JMH and the header says so at length: one JVM for all the
operations, no forking, no blackholes beyond a checksum, no error bars.
It is deliberately NOT wired into verify.sh. A timing assertion in CI
passes on a quiet runner and fails on a busy one, which teaches
everybody to re-run the build until it goes green, and that habit is
what makes a real regression invisible. It is a tool to run by hand
either side of a change.

WHAT IT MEASURES, AND WHY THOSE THINGS

The page is built by repeating the captured
golden/repository/repo-single.json (3,404 bytes) fifty times inside one
array, which gives a 170,251-byte page with the field spellings, the
null parent and the Go zero-time sentinels a real listing has. The
narrow object is the captured golden/organization/org-single.json (418
bytes). Both are verbatim captures, not invented JSON, so the shapes
being measured are the shapes the API sends.

A wide DTO (RepositoryDto, 63 modelled fields of a 64-key object) and a
narrow one (OrganizationDto, 12 of 12) are measured apart, and each is
measured both end to end and from an already-parsed document. A change
to how a field is looked up behaves differently at those two widths,
and one average over a mixed payload would hide which way each moved.

Two rows are worth knowing about before reading the table:

  baseline.noop is an operation that does nothing, driven through the
  same loop. It must read 0.0 B/op or nothing above it can be believed.

  timestamps.parse appears twice. Measured with its answer thrown away,
  Timestamps.parse allocates nothing at all, because HotSpot's escape
  analysis takes the Instant apart when it cannot leave the method.
  That is a real optimisation and a misleading benchmark, since the
  library stores the Instant in a Repository, where it does allocate.
  So the main row stores the answer in a one-element array that was
  allocated up front, and the paired -ea row does not. The gap between
  them is the size of the effect rather than a claim about the code.

THE BEFORE MEASUREMENT

Taken at commit d5ec0c5 ("build: bank the duplication the last two
commits removed"), on OpenJDK 64-Bit Server VM 25.0.4+7-LTS. This
commit adds no library code, so these numbers describe it too. The
commits that follow are to be judged against them.

  operation                     B/op     ns/op best
  -------------------------------------------------
  parse.page-50              870,528        203,441
  decode.page-50           1,911,216        519,662
  decode.repo-wide            38,216          9,912
  assemble.repo-wide          20,784          5,461
  decode.org-narrow            4,768          1,277
  assemble.org-narrow          2,296          650.6
  timestamps.parse              40.0           25.5
  timestamps.parse-ea           40.0           29.1
  parse.ints-1000             88,528         16,403
  parse.bools-1000            26,576          6,835
  baseline.noop                  0.0            2.0

Read that as: decoding one full page of fifty repositories allocates
1.9 MB, which is eleven times the 170 KB of text it was given, and
parsing alone accounts for 870 KB of it. One wide repository object
costs 38,216 bytes end to end, of which 20,784 is assembling the DTO
from an already-parsed document rather than parsing. The narrow object
costs 4,768 and 2,296 for the same two things.

HOW STABLE THESE ARE

Three consecutive full runs were compared. Within a run, every byte
figure but one agreed to the byte across all seven rounds. Between
runs, every byte figure was identical except decode.org-narrow
(4,768 / 4,784 / 4,768) and assemble.org-narrow (2,296 / 2,280 / 2,296)
— one object of difference, 16 bytes. Treat a 16-byte move at those
sizes as noise rather than a result. Best-case times moved by a few
percent on the large operations and by up to a third on the small ones,
which is why the header asks for ns/op to be read as a direction of
travel and B/op as the number to argue about.

One more caveat was measured rather than assumed, and it is recorded in
the header: run on its own, timestamps.parse-ea reports 0.0 B/op,
because Timestamps.parse then has a single caller, is inlined, and its
Instant is optimised away. Run in the full table, where the other
timestamp row calls it too, both rows report 40.0 B/op. Forking a JVM
per benchmark is what JMH does about that and what this script does
not, so compare a full table against a full table and never a filtered
run against an unfiltered one.

verify.sh --with-slow passes unchanged, duplication included: a script
under scripts/ is not a module, so the "every source tree is inside the
gate" step does not see it, and the recorded duplication baseline is
still 373 groups.
JSON allows one object to name the same field twice — {"id":1,"id":2}
is legal text — and says nothing about what that means. This library
had three answers at once, and each was an accident of how some piece
of code happened to be written rather than a decision. Run against the
code as it stood, with body = {"id":1,"id":2}:

  Json.parse(body).map(_.field("id"))
    -> Right(Some(Num(1)))                 the first field wins

  Json.decode[Map[String, JsonValue]](body)
    -> Right(Map("id" -> Num(2)))          the last field wins

  Json.parse(body).map(Json.render)
    -> Right({"id":1,"id":2})              both fields are kept

JsonValue.Obj holds an object as an ordered Vector of name-value pairs.
`field` scans that vector and stops at the first match. JsonDecoder
turns the same vector into a Map with `toMap`, and building a Map from
pairs keeps the last of two entries sharing a name. The renderer writes
the vector out as it stands. Nobody chose any of that.

It has to be settled now rather than later because the next change
makes that vector the single representation an object has: `toMap` goes
away and every DTO starts reading through the scan instead. That would
have flipped the effective answer for every DTO in the library from
"last" to "first", silently, on a payload no golden fixture contains.

THE DECISION: REFUSE THE DOCUMENT

Json.parse now fails a body in which any one object names a field
twice, with the message `duplicated field "id"`, at the root path like
every other structural failure. No value this library parses can carry
a repeated key any more, so the three readings above have nothing left
to disagree about.

The alternative was to pick a winner, first or last. Both were rejected
for one reason: either silently drops a value the sender wrote, and the
caller has no way to learn that it happened. That is the same trade
JsonDecoder.arrayOf already refuses when it fails a whole page rather
than skip the one element it cannot read.

docs/HAZARDS.md §1 is where this project's tolerance for wire weirdness
is written down, and it was weighed. That tolerance is for shapes
measured coming out of a real Forgejo — JSON null where the spec
promises an array, "" where a field is unset — and it exists because a
strict decoder fails on the first issue of the first page. A repeated
key is not in that category. Forgejo serialises responses from Go
structs and maps, which cannot produce one, and no golden fixture has
one. A response carrying a repeated key was rewritten somewhere between
the server and the caller, which is worth a failure rather than a
guess; it is also how a rewritten body smuggles a second value past a
reader that only ever looked at one of them.

The cost is real and worth stating plainly: one such object fails the
response it arrives in, and for a listing that is the whole page. The
failure names the offending field, so it is diagnosable rather than
mysterious, which neither silent alternative is.

HOW THE CHECK WORKS, AND WHAT IT COSTS

It runs on every object of every response, so it is written twice. Up
to eight fields it compares the names against each other and allocates
nothing, which covers the small objects nested inside a response — a
repository's `permissions` and `internal_tracker` have three fields
each. Above eight it fills one open-addressed table of int positions
and probes that, because comparing every pair grows with the square of
the width and a remote party chooses the width: a 500-key object would
otherwise cost a quarter of a million comparisons. Names are still
compared in full on a hash hit, so a collision cannot reject a valid
document.

Measured with scripts/alloc-bench.sh on this machine, a full table
before against a full table after:

  operation            B/op before   B/op after   change
  parse.page-50            870,528      904,128    +3.9%
  decode.page-50         1,914,416    1,944,816    +1.6%
  decode.repo-wide          38,280       38,888    +1.6%
  decode.org-narrow          4,768        4,864    +2.0%

Finding the repeat is what costs that, not refusing it: first-wins and
last-wins would have had to find it too, in order to drop the loser.
For scale, the first version of this check put a
scala.collection.mutable.HashSet on every object and measured
parse.page-50 at 1,090,528 B/op, +25% — which is why the narrow and
wide cases are handled apart.

jsoniter's own JsonReader.duplicatedKeyError(len) is deliberately not
called. It formats the offending name out of the reader's character
buffer, which by the time an object is complete holds the last string
the reader saw rather than the key. The message here is worded the same
way and names the right field.

WHAT THIS DOES NOT DO

An object built in code is not policed. JsonValue.Obj takes the vector
it is given and Json.render writes it out faithfully, so handing it a
repeated key produces a request body this library would then refuse to
read back. That is documented on Obj as a "do not" rather than
enforced: the constructor is where request bodies are assembled, and a
silent de-duplication there would be the same invisible data loss this
commit refuses at the other end. A test pins both halves of it.

The rule is documented on JsonValue.Obj, on JsonValue.field and on
JsonFields, whose Map was the other half of the disagreement. Tests
pin it for a flat object, a nested one, a wide one that takes the
hashed path, sibling objects that repeat a name between them (allowed,
since that is what a page looks like), both decoding doors, and the
render side. verify.sh --with-slow passes; duplication is unchanged at
373 groups.
JsonFields is the view every DTO in this library reads its fields
through. It used to hold a Map[String, JsonValue], and the only way to
get one was to copy the Vector[(String, JsonValue)] the JSON parser had
just built -- once per object, at every level of every response, and
then throw the copy away as soon as the DTO was assembled.

scripts/alloc-bench.sh measured what that cost. Decoding a page of
fifty repositories allocated 1,944,816 bytes, of which the toMap calls
were 819,600: 42% of the whole decode, spent duplicating a list the
parser already had.

So the view now holds that vector. Every accessor keeps the signature
it had -- text, rawText, number, boolean, nested, values, texts,
nestedAll -- and the two that used to rebuild a map per nested object,
nested and nestedAll, no longer do. A repository inside a pull request
inside a page was paying for that at every level.

HOW A FIELD IS FOUND

It depends on how wide the object is, and the split is the one
JsonValue already makes for its repeated-key check, at the same width
of eight fields:

  - a narrow object has its field names compared one after another.
    That allocates nothing and beats a hash lookup at this width, and
    most of the objects a Forgejo response nests are narrow: a
    repository's `permissions` has three fields and its
    `internal_tracker` three.

  - a wide object has its field positions indexed by name hash once, at
    construction, and a lookup probes that index instead.

The index is not decoration. A plain scan was written first and
measured: it made assemble.repo-wide 5,433 -> 6,727 ns/op, 24% SLOWER
than the map it replaced, because RepositoryDto reads 63 fields out of
a 64-key object and scanning for each of them compares about two
thousand names to assemble one repository. Adding the index turned that
into 1,977 ns/op. It is the only reason this commit is a speed-up
rather than a regression on the library's widest and most common
payload.

WHAT WAS MEASURED

scripts/alloc-bench.sh --rounds=15, before and after, full table
against full table, on OpenJDK 25.0.4. Bytes are the median of the
measured rounds; times are the fastest round of each.

  operation                   bytes/op                ns/op
  -------------------- ----------------------  --------------------
  parse.page-50          904,128 ->   904,128   262,298 ->  262,121
  decode.page-50       1,944,816 -> 1,159,616   541,281 ->  372,111
  decode.repo-wide        38,888 ->    23,200    10,198 ->    6,667
  assemble.repo-wide      20,784 ->     5,096     5,433 ->    1,977
  decode.org-narrow        4,848 ->     3,144     1,330 ->      820
  assemble.org-narrow      2,296 ->       608       659 ->      195

Read the bytes and treat the times as a direction of travel. That is
the harness's own advice and it applies here: this ran on a working
laptop, and decode.page-50's rounds disagreed by more than 100% while
assemble.repo-wide's disagreed by 37%. Repeated full runs put
decode.page-50 anywhere from 372,111 to 547,661 ns/op, so the honest
claim for that row is "no slower, usually faster", while the two
assemble rows, which exclude the parse and are the steadiest numbers
here, are 2.7x and 3.4x faster and did not vary.

The page saving is 785,200 bytes rather than the full 819,600 because
the index costs one small Array[Int] per wide object, which adds 35,200
bytes back over the fifty repositories and their nested owners. That
trade is why the numbers above are quoted net.

WHAT CALLERS SEE

Of the 120 files that mention JsonFields, 113 only call accessors and
are untouched. The rest:

  - JsonDecoder.objectOf and objectOfEither hand the parser's vector
    straight to the view instead of calling toMap on it.
  - the `fields` given, JsonDecoder[Map[String, JsonValue]], is for
    payloads whose keys are data rather than schema and still answers a
    Map. It is now the one place in the library that builds one.
  - JsonFields.toMap is new, for the three DTOs that genuinely need a
    map: EditorConfigDto, HookWire and LanguageStatisticsDto.
  - RepositoryContentDto passed an already-parsed object through a Map
    on its way into the view; it now passes the vector.

New tests cover the index path, which the old code had no equivalent
of: that a 40-field object reads the same as an 8-field one on either
side of the threshold, that two names with the same String hash ("Aa"
and "BB", both 2112) are still told apart, that a view built by hand
from a repeated name answers with the first of them at either width,
and that toMap round-trips the fields.

BREAKING CHANGE: JsonFields' constructor parameter changed from
`underlying: Map[String, JsonValue]` to `entries: Vector[(String,
JsonValue)]`, which also renames the field of this case class.

Constructing a view: `JsonFields(someMap)` becomes
`JsonFields(someMap.toVector)`, and a view built from a parsed document
-- `JsonFields(fields.toMap)` after matching JsonValue.Obj(fields) --
becomes `JsonFields(fields)`, which is what it should always have been.

Reading a view's whole contents: `fields.underlying` becomes
`fields.toMap` for the same Map as before, or `fields.entries` for the
vector without the copy. Prefer `entries` unless a Map is what the code
downstream actually consumes.

No accessor changed, so a DTO that only calls text, rawText, number,
boolean, nested, values, texts or nestedAll needs no edit at all.
Every number in a parsed document used to be a BigDecimal. That is
exact, which was the point — the document model before it parsed
numbers as Double and silently rounded anything above 2^53 — but it
is also two objects and about sixty bytes, spent on a value that is
nearly always a row id, a count, or a timestamp offset. A Forgejo
response is mostly numbers, so the whole decode path paid it.

WHAT CHANGES

JsonValue has two number cases now instead of one:

  JsonValue.Int64(value: Long)          a number written without a
                                        fractional part or an
                                        exponent whose digits fit in
                                        64 bits
  JsonValue.Decimal(value: BigDecimal)  everything else: fractional,
                                        exponent form, or a whole
                                        number past Long.MaxValue

Which case a parsed number lands in follows the text that was on the
wire and nothing else, so Json.render still writes back exactly what
it read — 102 stays 102 and 102.0 stays 102.0. Forgejo's integer
fields reject 102.0, so that was never negotiable; the existing
round-trip tests pinning it are untouched and green.

The parse calls jsoniter's readNumber rather than readBigDecimal.
readNumber returns a java.lang.Long for a whole number that fits, a
java.math.BigInteger for one that does not, and a
java.math.BigDecimal for everything else — checked against the 2.39.1
jar rather than recalled — which is exactly the distinction the two
cases want to draw.

PRECISION IS UNCHANGED

Nothing here goes near a Double. A Long holds every integer up to
2^63 exactly and anything larger stays a BigDecimal, so
9007199254740993 (2^53 + 1, the first integer a Double cannot
represent) and 9223372036854775808 (one past Long.MaxValue) both
round-trip byte for byte. There is a test for each.

MEASURED, NOT ASSUMED

scripts/alloc-bench.sh, full table, before and after, on OpenJDK
64-Bit Server VM 25.0.4+7-LTS. B/op is heap bytes allocated per
operation.

  operation              before       after    change
  ------------------------------------------------------
  parse.page-50         904,128     889,328     -1.6%
  decode.page-50      1,159,616   1,134,416     -2.2%
  decode.repo-wide       23,200      22,680     -2.2%
  assemble.repo-wide      5,096       4,888     -4.1%
  decode.org-narrow       3,160       3,096     -2.0%
  assemble.org-narrow       608         592     -2.6%
  parse.ints-1000        88,528      56,952    -35.7%
  parse.bools-1000       26,576      26,560      noise
  baseline.noop             0.0         0.0         —

parse.bools-1000 is the control: this change cannot touch an array of
booleans, and it did not move beyond the 16 bytes the harness header
calls noise.

parse.ints-1000 parses [0,1,...,999], whose numbers are far smaller
than a real row id, so a row-id-shaped array was added to the harness
temporarily, measured on both sides of the change with the same
filter, and then removed — a thousand nine-digit integers, the shape
Codeberg's identifiers actually have:

  parse.ids-1000         95,080      63,080    -33.7%

That is 32.0 bytes saved per number. It is also why the page rows
move so little: repo-single.json holds 13 numbers against 44 strings
and 35 booleans, so numbers were never most of a repository's weight.
They are most of a page of identifiers or a quota response.

WHAT IS LEFT ON THE TABLE

63.1 bytes an element is still not the 26.6 the same-length array of
booleans costs. About 24 of the difference is a java.lang.Long that
jsoniter boxes in order to return it from readNumber, and that dies
one line later. Removing it needs a look-ahead the reader interface
does not offer; the only route to it is to try readLong behind a mark
and catch the failure, which hands a remote party a document that
throws an exception once per fractional number. Twenty-four bytes is
the cheaper of the two, so the box stays, documented where it
happens.

SOURCE COMPATIBILITY

JsonValue.Num survives as an object with the same four constructors
and a new unapply, so JsonValue.Num(7) still builds a number and
`case JsonValue.Num(value)` still binds a BigDecimal. What it can no
longer be is a type.

The readers on the hot path were moved off it rather than left on it,
because the extractor has to build a BigDecimal for the Int64 case
and that is the cost this commit exists to remove. JsonDecoder.long
and JsonFields.number name the two cases instead, through the new
JsonValue.longOpt; RepositoryContentDto asked numOpt.isDefined and
now asks the new JsonValue.isNum, which allocates nothing; and
EditorConfigDto's renderer matches the cases directly.

TWO CORRECTNESS FIXES ALONG THE WAY

BranchProtectionOptionDto and MigrateRepoOptionsDto built their
numbers as JsonValue.Num(x.toDouble), which rounds above 2^53 and so
could have corrupted a team id — an int64 on the wire. They pass the
value itself now. EditorConfigDto rendered a whole number through
BigDecimal.toLong, which wraps rather than fails for a value too
large for a Long, and uses toBigInt now.

VERIFICATION

./verify.sh --with-slow passes. Duplication is unchanged at 373
groups, so the recorded baseline is left where it is.

BREAKING CHANGE: JsonValue.Num is no longer a case class. A number in
a parsed document is now JsonValue.Int64(Long) or
JsonValue.Decimal(BigDecimal), and JsonValue.Num is an object holding
the constructors and an extractor for both.

Constructing is unchanged: JsonValue.Num(7), Num(7L), Num(7.0) and
Num(BigDecimal(7)) all still compile. They return JsonValue rather
than JsonValue.Num, which no longer names a type, and Num is the
constructor to keep using — it is what decides which of the two cases
a value belongs in, and building a JsonValue.Decimal directly from a
whole number that would fit a Long produces a document that does not
equal the one parsing its own rendering gives back.

Matching with `case JsonValue.Num(value)` is unchanged and still
binds a BigDecimal, but it now builds one for a whole number. Prefer
`case JsonValue.Int64(value)` where a Long was what was wanted, or
the new JsonValue.longOpt.

A match that enumerates every JsonValue case must replace its
`case JsonValue.Num(_)` with the two new cases. The compiler says so:
Num is no longer one of the cases, so such a match stops being
exhaustive.
Every response used to be copied twice on its way to the parser, and
neither copy did anything.

The transport read the body with sttp's `asStringAlways`, so the bytes
that arrived on the socket were decoded into a `String` — copy one.
`Json.parse` then called jsoniter's `readFromString`, which encodes that
`String` straight back into a `byte[]` in order to read it, because
jsoniter (like every JSON parser worth using) reads bytes — copy two. On
a 170 KB listing page that is a third of a megabyte of garbage produced
before a single field is looked at, on a payload that was already in
exactly the right shape when it arrived.

So the body is now bytes from the transport all the way to the parser,
and is decoded to text only where text is genuinely wanted.

WHAT CHANGED

`ResponseBody` is a new type in `modules/core`. It holds the bytes and
the charset the response declared for them, and it is the one place that
knows how to turn one into the other:

  * `bytes` — the array, not copied; copying it would reintroduce the
    copy the type exists to remove, so the contract is that whoever
    builds a body gives up the array and whoever reads it must not write
    to it. `BinaryResponse` has always worked this way.
  * `text` — the whole body decoded, once, and kept.
  * `utf8Bytes` — the bytes for a reader that requires UTF-8 rather than
    merely expecting it. See the charset note below.
  * `isBlank` — answered on the bytes, so asking it of a 40 MB payload
    does not decode 40 MB of text.
  * `excerpt(n)` — a bounded excerpt, in characters; see below.

`CodebergResponse.body` is a `ResponseBody` rather than a `String`, and
`Decode[A]` takes one. `Decode` is core's port and `modules/core` still
imports nothing but the standard library — a byte array and a
`java.nio.charset.Charset` are as library-agnostic as a `String` was.
The architecture-boundary check in `verify.sh` confirms it.

`SttpHttpPort` reads every response with `asByteArrayAlways`. That also
made its textual and byte-carrying paths identical apart from which
response type they assemble, so the two `build` methods and the two
`dispatch` methods collapsed into one of each.

`Json` gained `decode`/`parse` overloads taking an `Array[Byte]`, which
read through jsoniter's `readFromArray`. The `String` overloads remain
for callers that genuinely start from text — the error-payload parser
and tests written against a literal.

CHARSET HANDLING

Decoding bytes yourself means choosing a charset, so the choice is
written down rather than assumed. `ResponseBody.charsetOf` reads the
`charset` parameter of `Content-Type` and falls back to UTF-8 when the
header is absent, carries no charset, or names one that is not a legal
or available charset name. That is exactly what sttp's `asStringAlways`
did on this library's behalf, so nothing about text decoding changed
when the body stopped being text.

Forgejo answers `charset=utf-8` everywhere, but that is an observation
about one server, not a licence to ignore the header. The one place
UTF-8 is genuinely *required* rather than expected is JSON — RFC 8259
§8.1 — and that requirement is stated in `utf8Bytes`, which hands back
the socket's own array when the response declared UTF-8 and transcodes
when it declared anything else. A non-conforming server is therefore
read correctly and merely slowly, instead of as mojibake.

THE SNIPPET, WHICH IS THE TRAP

`ApiPipeline.snippetOf` bounds the excerpt a decoding failure carries.
It bounded *characters*, and it still does. Slicing the byte array at
512 bytes and decoding the slice is not the same thing: it cuts a
multi-byte character in half and ends the excerpt in a replacement
character the server never sent, and for a payload of three-byte
characters it would return 170 characters where 512 were asked for.

`ResponseBody.excerpt` slices generously instead — enough bytes that the
requested number of characters is certainly inside, computed from the
widest encoding of one character in that charset — and then applies the
bound to the decoded text, where a character is a character. Whatever
the generous slice damaged at its own tail sits beyond the bound and is
discarded. `ResponseBodySuite` pins that with three-byte characters and
with four-byte ones, which are two characters each.

MEASURED, NOT RECALLED

`scripts/alloc-bench.sh` on this machine (OpenJDK 25.0.4+7-LTS), 11
measured rounds, decoding one 170,251-byte page of 50 repositories into
`Vector[RepositoryDto]`:

  before, end to end   1,308,728 B/op   440,695 ns/op
  after,  end to end     968,160 B/op   360,753 ns/op

That is 340,568 bytes per page saved, 26%. The page is 170,251 bytes and
two copies of it are 340,502 bytes, so the saving is the two copies and
essentially nothing else — which is what a change that removes two
copies and touches nothing else should look like.

Allocation was identical to the byte across two full runs and every
round within them. The times are from the fastest round of each and are
worth reading as a direction, not a figure: the spread between rounds on
this machine reached 35%.

DELIBERATELY NOT DONE

`BinaryResponse` and `BinaryHttpPort` are now near-duplicates of
`CodebergResponse` and `HttpPort` — the reason they existed, that a
response body was text and a ZIP is not, has gone. Merging them changes
the published signature of `ActionDownloadApi` and of `CodebergClient`'s
constructor, so it belongs in its own commit rather than riding on this
one. Both types say so in their own documentation now.

`RepositoryGitApi.getRawFile`, `getMediaFile` and `downloadArchive`
still return `String`, and are still lossy for a binary file. That was a
limitation of core; it is now a choice of that group's signatures, and
changing them is likewise a separate change. Their Scaladoc, and the
Scaladoc on `IssueAttachmentApi`, `RepositoryActionApi`,
`RepositoryAdminApi`, `IssueAttachment` and `ActionArtifact`, has been
corrected — all of it justified a decision by a constraint that no
longer exists, and two paragraphs also claimed the ZIP endpoints were
unimplemented when `ActionDownloadApi` has implemented them for a while.

BREAKING CHANGE: `CodebergResponse.body` is a `ResponseBody` rather than
a `String`, and `Decode[A].apply` takes a `ResponseBody`.

Anyone implementing `Decode` by hand — `body => parse(body)` — takes a
`ResponseBody` now. Ask it for what you need: `body.bytes` for a parser
that reads bytes, `body.text` for one that reads text, `body.isBlank` to
recognise an empty payload.

Anyone constructing a `CodebergResponse`, which in practice means a test
fake standing in for the transport, wraps the body:
`CodebergResponse(200, headers, ResponseBody.utf8("[]"))`, or
`ResponseBody.Empty` for a `204`. `ResponseBody.of(bytes, charset)` is
the form a real transport uses.

Nothing about the endpoints changes. Every method on every API group
returns exactly what it returned before.
The previous commit stopped copying a response body twice on its way to
the parser. The harness could not show that, because every operation in
it started from a `String` — the shape a response no longer has.

Two rows are added, both decoding the same 170 KB page of 50
repositories into `Vector[RepositoryDto]`:

  decode.page-50-bytes      from the bytes the socket produced, which is
                            the path a response takes today
  decode.page-50-viastring  the path a response took before: decode the
                            socket's bytes into a `String`, which is what
                            sttp's `asStringAlways` did, then hand that
                            `String` to the parser, which encodes it back
                            into a `byte[]` to read it

The gap between the two is the whole of what the change removed, and
measuring both in one JVM run is the only way to get an honest number:
the harness's own header explains why a filtered run cannot be compared
against an unfiltered one, and the same applies to comparing a run of
today's code against a recorded number from a checkout of yesterday's.

`decode.page-50` stays exactly as it was, so numbers recorded before the
change are still comparable with numbers recorded after it.

On this machine (OpenJDK 25.0.4+7-LTS, 11 measured rounds):

  decode.page-50-viastring   1,308,728 B/op   440,695 ns/op
  decode.page-50-bytes         968,160 B/op   360,753 ns/op

340,568 bytes per page, which is two copies of a 170,251-byte payload
and essentially nothing else.

This is still not a gate and must not become one, for the reasons the
script's header gives at length.
A response model in this library is decoded from a Forgejo payload and
never built by a caller: a client call hands one back, and that is the
only way one comes into existence. Their constructors were public
anyway, which quietly makes every field Forgejo adds our problem.

Forgejo grows response fields routinely. Adding one to a public
`final case class` changes the generated `<init>`, `apply`, `copy` and
every `copy$default$N`, so it is a breaking change. Once the 0.1.0 tag
becomes the binary-compatibility baseline, a breaking change costs a
major version — which would mean owing one every time the server grows
a field. Nothing has been published yet, so closing these constructors
today is free; doing it after 0.1.0 would cost the very major version
it exists to avoid. That deadline is why this lands now.

`private[codeberg4s]` costs almost nothing inside the repository,
because every source tree here — the library, its unit tests, the
examples and the integration suite — lives under
`com.worxbend.codeberg4s`. Each wire DTO's `toDomain` still builds
these models and every existing test fixture still compiles. Reading
fields and pattern matching stay open to everyone; only `apply` and
`copy` are narrowed, which was verified against a probe source in an
unrelated package.

One honest caveat: a qualified-private constructor still erases to
public in the bytecode, so a binary-compatibility checker will keep
seeing the synthetic members change shape. What changes is that no
code outside the library can have compiled against them, so such a
report becomes a filter to write rather than a release to renumber.

Left public on purpose: the command models a caller must be able to
build in order to make a request — `CreateIssue`, `EditIssue`,
`CreatePullRequest`, `MergePullRequest`, `SubmitReview`, every `*Query`
— plus the opaque types, the enums and the error ADT.

BREAKING CHANGE: `Comment`, `Issue`, `IssueAttachment`, `IssueDeadline`,
`IssueSubscription`, `Label`, `Milestone`, `Reaction`, `TimelineEvent`,
`TrackedTime`, `ChangedFile`, `PullRequest`, `PullRequestBranch`,
`Review` and `ReviewComment` can no longer be constructed or copied
from outside the library. Code that built one directly — most
plausibly a test fixture standing in for a server response — must
obtain it from a client call instead, or decode a recorded payload
through the client. That is a real cost, and it is the deliberate
trade for not owing a major version every time Forgejo adds a field to
a response.
Continues closing response-model constructors, on the same reasoning
as the issues and pulls commit: these models are decoded from a Forgejo
payload and never built by a caller, so leaving their constructors
public means every field the server grows is a breaking change to this
library's API. With 0.1.0 about to become the binary-compatibility
baseline, that is a bill payable in major versions; closing them before
anything is published costs nothing.

Which models these are was decided from the codec, not from their
names: a class a wire DTO's `toDomain` constructs, and which appears
nowhere as a parameter of a public client method, only ever travels
from the server to the caller. Every class touched here meets both
halves of that test.

The quota models are the clearest case for the change. `QuotaInfo`,
`QuotaGroup`, `QuotaRule`, `QuotaSizes`, `QuotaUsage` and the
`QuotaUsed*` family are a deep tree of small records mirroring
Forgejo's quota report, which is a young and moving part of its API.
Each one was a field addition away from being a breaking change.

Left public: `CreateOrganization`, `EditOrganization`, `CreateTeam`,
`EditTeam`, `CreateAccessToken`, `CreateSshKey`, `CreateGpgKey`,
`VerifyGpgKey`, `UpdateUserSettings`, `OAuth2ApplicationDefinition`,
`CreateRepository`, `ActivityFeedQuery`, `TrackedTimeWindow` and the
opaque identifiers — a caller has to be able to build all of those to
make a request, and `RepoSlug`, which a caller supplies as part of
`CreateAccessToken.repositories` as well as receiving inside a
`Repository`.

BREAKING CHANGE: `User`, `UserSettings`, `PublicKey`, `Email`,
`AccessToken`, `CreatedAccessToken`, `GpgKey`, `GpgKeyEmail`,
`HeatmapEntry`, `StopWatch`, `OAuth2Application`, `Organization`,
`OrganizationPermissions`, `Team`, both `BlockedUser` models, both
`QuotaInfo` models and the whole quota tree beneath them can no longer
be constructed or copied from outside the library. A caller that built
one directly — realistically a test fixture standing in for a server
response — must obtain it from a client call instead. Reading fields
and pattern matching are unaffected.
The largest group, and the one the whole exercise was aimed at.
`Repository` alone carries 40 fields, every one of them decoded from a
Forgejo payload and none of them supplied by a caller. Forgejo's own
`Repository` definition has 66 keys and gains more with each release,
so with a public constructor this library owed an API break every time
upstream added one — and, after 0.1.0 becomes the binary-compatibility
baseline, a major version with it. Nothing is published yet, so the
change is free today and expensive tomorrow.

The same argument applies to everything decoded alongside it, which is
why this commit reaches into `access`, `actions`, `admin`, `gitdata`,
`hooks` and `publishing`: `Release`, `ReleaseAsset`, `Commit` and its
`CommitDetails`/`CommitSummary`/`CommitStats`/`CommitVerification`
pieces, `Branch`, `Tag`, `Webhook`, `WikiPage`, `DeployKey`,
`BranchProtection`, `TagProtection`, the `Action*` runner and workflow
models, and the git-data models `GitBlob`, `GitTreeEntry`,
`GitReference`, `GitObjectRef`, `AnnotatedTag`, `GitNote`,
`CommitComparison`, `FileChange`, `FileCommit` and
`EditorConfigDefinitions`.

Two neighbouring pairs are deliberately split down the middle, and the
split is the evidence that this was decided per class rather than per
package. `GitIdentity` is closed and `GitAuthor` stays open: its own
Scaladoc already says one is what a response carries and the other is
what a request sets. `BranchProtection` is closed while
`BranchProtectionSettings` stays open, because the latter is the
builder a caller fills in for `CreateBranchProtection` and
`EditBranchProtection`.

Also left public: `CreateRepository`, `EditRepository` and its
`*TrackerSettings`/`ExternalWikiSettings` parts, `MigrateRepository`,
`TransferRepository`, `CreateFork`, `GenerateRepository`,
`CreateRelease`, `EditRelease`, `UploadAsset`, `EditAsset`,
`CreateTag`, `CreateBranch`, `RenameBranch`, `CreateFile`,
`UpdateFile`, `DeleteFile`, `ChangeFiles` and their `CommitOptions`,
`CommitIdentity` and `CommitDates` parts, `CreateHook`, `EditHook`,
`CreateWikiPage`, `EditWikiPage`, `ApplyDiffPatch`, `DispatchWorkflow`,
`CreateVariable`, `UpdateVariable`, `RegisterRunner`, `CreatePushMirror`
and every `*Query`. A caller has to be able to build all of those.

BREAKING CHANGE: `Repository` and 58 other repository-side response
models can no longer be constructed or copied from outside the library;
`git show --stat` on this commit lists them. Code that built one
directly — most plausibly a test fixture standing in for a server
response — must obtain it from a client call instead, or decode a
recorded payload through the client. Reading fields and pattern
matching are unaffected. The trade is deliberate: the alternative is a
major version of this library for every field Forgejo adds to a
response.
The last of the response models: notifications, the instance-wide
`miscellaneous` models, and the two that sit in the root package. Same
reasoning as the three commits before it — each of these is decoded
from a Forgejo payload and never built by a caller, so a public
constructor makes every field the server adds a break in this
library's API, and after 0.1.0 becomes the binary-compatibility
baseline that break costs a major version.

`ServerApiSettings`, `ServerUiSettings`, `ServerAttachmentSettings`
and `ServerRepositorySettings` are the strongest case in the whole
change. They are a straight mirror of an instance's configuration, so
they grow whenever a Forgejo release adds a setting — which is more
often than any other model here changes.

Four of these are reached through a companion rather than a DTO, and
they are included on the same grounds. `SigningKey.from` and
`SshSigningKey.from` read a plain-text response body,
`RenderedMarkdown.apply` is handed to the markdown decoder as a
function value, and `ServerVersion.apply` is used the same way by
`ServerVersionDto`. All four still work, because those companions live
inside `com.worxbend.codeberg4s`.

`ApiErrorBody` is the payload inside `CodebergError.Api`. The error ADT
itself stays exactly as it is — closing a case of it would break every
exhaustive match — but the body it carries is a decoded response like
any other, and matching on it still works.

Left public here: `MarkdownRenderRequest`, `MarkupRenderRequest` and
`NotificationQuery`, which a caller builds to make a request, plus
`Page` and `CallContext`. Those last two are worth naming because they
are not Forgejo models at all: `Page` is this library's own pagination
container, and `CallContext` is the call description attached to a
failure. Neither grows a field because upstream did, and a caller may
reasonably build either one when testing its own code, so the argument
for closing them does not apply.

BREAKING CHANGE: `NotificationThread`, `NotificationSubject`,
`NodeInfo` and its `NodeInfoSoftware`/`NodeInfoServices`/
`NodeInfoUsage`/`NodeInfoUsers` parts, `ServerApiSettings`,
`ServerUiSettings`, `ServerAttachmentSettings`,
`ServerRepositorySettings`, `GitignoreTemplate`, `LicenseTemplate`,
`LicenseTemplateSummary`, `TemplateLabel`, `RenderedMarkdown`,
`SigningKey`, `SshSigningKey`, `ServerVersion` and `ApiErrorBody` can
no longer be constructed or copied from outside the library. A caller
that built one directly — realistically a test fixture standing in for
a server response — must obtain it from a client call instead. Reading
fields and pattern matching are unaffected.
The compatibility rules said, of every model in the library, that
adding a field to a public `final case class` is breaking and never a
patch. That is no longer true of response models: their constructors
are now `private[codeberg4s]`, so no caller outside the library can be
calling the generated `apply` or `copy`, and a new field cannot break
anyone's source compatibility. A rule that overstates the cost of a
release is as unhelpful as one that understates it — the whole point
of closing those constructors was to make tracking Forgejo cheap, and
the document that decides what a release costs has to say so.

What changed here:

- The "minor release may" list gains adding a field to a response
  model, with the definition of what a response model is and examples.
- The "these are breaking" bullet is narrowed to command models, and
  now says why they are different: a caller has to build one to make a
  request, so their shape really is part of the API.
- A new section states the two limits of the exemption honestly. It
  covers growth only — removing, renaming or retyping a field is still
  breaking either way. And a qualified-private constructor is still
  public in the bytecode, so MIMA will keep reporting those synthetic
  members once it is wired; the note says those reports are filters to
  write rather than releases to renumber, and that the reasoning
  belongs in the commit that adds the filter, so nobody later reads a
  green build into a rule that was never checked.
`UploadAsset` and `UploadAttachment` describe the file half of a
`multipart/form-data` upload — a release asset, an issue attachment.
Both carried a scaladoc promise the code did not keep, and both had a
builder that wrote a caller's string into an HTTP header without
looking at it.

The promise first. `UploadAsset.of` says its file-name check lives "in
a smart constructor a caller cannot bypass", and `UploadAttachment`
says the same. Neither was true: both types were public `final case
class`es, so Scala generated a public `apply` and a public `copy`, and
either one built an upload with any file name at all — a quotation
mark to close the `filename="…"` parameter, a CRLF to end the
`Content-Disposition` line and open a header of the caller's choosing.
Both constructors are now `private`, which makes the generated `apply`
and `copy` private with them, so `of` really is the only door. Reading
the fields and pattern matching are unchanged.

The second defect is `as`, the builder that states the part's own
`Content-Type`. It performed no validation at all, and its argument
reaches the wire as a header value. An HTTP header value ends at the
first carriage return or newline, so `as("text/plain\r\nX-Bad: 1")`
wrote a header this API never offered, and inside a multipart body it
can open a part nobody asked for. `as` now returns
`Either[ValidationError, …]` and refuses a blank media type or one
carrying any control character, reporting on the `"mediaType"` field.

Both types reach that check through a new `ContentType` in the root
package, placed next to `PathSegment` and for the same reason: a
validation rule only one package can reach gets copied into the
packages that cannot, and copies drift. `ContentType.isSafe` is the
rule itself; `ContentType.from` wraps it in a named `ValidationError`.

`SttpHttpPort.withBody` applies `isSafe` a second time to the media
type of a `RequestBody.Multipart` and answers a `TransportFailure` —
cause `Unknown(SttpHttpPort.UnsafeMultipartMediaType)` — instead of
throwing or sending. The domain type is the primary guard; this is the
last place the value is still ours. `Multipart` is an ordinary enum
case that any code in this library can build from a bare `String`
without going near an upload command, and `withBody` is the final point
at which that string is a Scala value rather than wire bytes.

Threading that refusal outward made `build` fallible, so `send` and
`sendBinary` now share one `dispatch`, which resolves the base URI and
the body up front and short-circuits to an already-completed `Future`
when either is refused. The backend never sees a request it should not
send, which the new transport tests assert directly.

Why now: nothing is published to Maven Central yet, and the 0.1.0 tag
will become the MIMA binary-compatibility baseline. Closing a
constructor and widening a return type are free today and cost a major
version the day after that tag exists.

BREAKING CHANGE: `UploadAsset` and `UploadAttachment` can no longer be
constructed or copied from outside the library. Replace
`UploadAsset(fileName, bytes, mediaType, name)` with
`UploadAsset.of(fileName, bytes)` followed by `.named(…)` and `.as(…)`,
and likewise for `UploadAttachment`. Reading the fields and pattern
matching still work.

`as` now answers `Either[ValidationError, UploadAsset]` and
`Either[ValidationError, UploadAttachment]` rather than the upload
itself, so a chained call goes through `map` or a `for` comprehension:

    for
      upload <- UploadAsset.of("notes.txt", bytes)
      typed  <- upload.as("text/plain")
    yield typed.named("release-notes")

A caller that never calls `as` is unaffected: the default media type is
still `application/octet-stream`.
This library reads a whole HTTP response into memory as an array of
bytes; it never streams. Until now nothing said how many bytes that was
allowed to be. The request builder in SttpHttpPort never called sttp's
.maxResponseBodyLength, so a compromised, misconfigured or merely
misbehaving instance could answer a request with a body limited by
nothing but the client's heap: the only thing standing in the way was
the read timeout multiplied by the peer's bandwidth, which is not a
limit.

CodebergConfig grows two settings, both counted in bytes.

maxResponseBodyBytes, 16 MiB, applies to every textual response. The
number is derived rather than picked. The largest legitimate JSON body
Forgejo produces is a file's contents, which carries a repository blob
capped by the instance's default_max_blob_size (10 MiB on codeberg.org,
captured in docs/HAZARDS.md section 5) and base64-encoded at four bytes
per three, so roughly 13.4 MiB on the wire. That number is per-instance
configuration, so the scaladoc points at ServerApiSettings for reading
back what your own Forgejo reports.

maxDownloadBodyBytes, 50 MiB, applies only to the two ZIP-fetching
operations under client.repos.actions.downloads. A CI artifact is
whatever a workflow uploaded, so default_max_blob_size says nothing
about it, and a single shared number would have had to be either too
small for ordinary artifacts or too large to bound JSON usefully.
50 MiB is where this project had already drawn the line in prose:
SECURITY.md and docs/ROADMAP.md both record "attachment streaming above
50 MB" as out of scope for v1. The default now enforces what those
documents describe instead of leaving it to the heap, and SECURITY.md
no longer offers "a large artifact exhausting the heap is documented
behaviour" as an answer to a report.

Exceeding either bound is reported as a new TransportCause case,
ResponseTooLarge. Giving it a case of its own is about behaviour, not
about vocabulary: RetryEngine.isRetryable treats TransportCause.Unknown
as retryable, so folding this failure into Unknown would have made the
default policy of three attempts download the oversized body three
times over. A bound meant to cap one call at N bytes would have let
through 3N. The new case is non-retryable, and the test added to
RetryEngineSuite asserts the attempt count rather than asserting the
classification, so an edit that made it retryable again fails there.

The transport also has to recognise the failure. sttp throws
sttp.capabilities.StreamMaxLengthExceededException and hands it on
wrapped in an SttpClientException.ReadException, so classification stays
a walk down the cause chain; matching only the outermost type would have
reported every oversized body as Unknown and quietly made it retryable
again. BackendStub does not apply the limit itself, so the new transport
tests assert the two things a stub can prove: that each request carries
the configured bound in its sttp options, and that the exception a real
backend raises is classified as ResponseTooLarge both bare and wrapped.

BREAKING CHANGE: CodebergConfig gains two parameters,
maxResponseBodyBytes and maxDownloadBodyBytes, and TransportCause gains
a case, ResponseTooLarge. Code that calls the full CodebergConfig
constructor must pass the two new arguments; the constants
CodebergConfig.DefaultMaxResponseBodyBytes and
CodebergConfig.DefaultMaxDownloadBodyBytes reproduce the values the
one-argument CodebergConfig(auth) uses. Any exhaustive match on
TransportCause must handle the new case. Nothing is published to Maven
Central yet and the 0.1.0 tag becomes the MIMA binary-compatibility
baseline, so making this change now costs nothing, whereas making it
after 0.1.0 would cost a major version.
…rt result

`PageWalk.fold` visits at most `MaxPages` (10 000) pages so that an
instance which offers a next page forever cannot hang the caller's
process. Until now, reaching that cap returned the state folded so far —
exactly the same shape a walk that reached the real end of the listing
returns. A caller walking a repository with more pages than the cap got a
short answer with no way to tell it was short.

That is the failure mode the whole pagination design exists to prevent.
README's pagination section already warns at length that an incomplete
result which looks complete is the worst kind of bug in a client library,
and then the library shipped one.

The walk now fails instead. Reaching the cap while the server is still
offering another page fails the `Future` with `CodebergException`
wrapping a new error case:

    case WalkTruncated(pagesVisited: Int, resumeFrom: PageParams)

`resumeFrom` is the window the walk was about to request, page size
included, so a caller who genuinely wants more than half a million items
continues with `PageWalk.all(resumeFrom)` rather than starting over. The
case carries no `CallContext` because nothing went wrong on the wire —
every one of those pages arrived; what failed is the walk's promise to
cover the whole collection.

The cap is now tested against a page the server actually offered, so a
listing whose last page happens to be the ten-thousandth and offers
nothing further has reached its natural end and still succeeds. The old
code could not tell those two situations apart at all.

Adding a case to a sealed family is a source-breaking change for anyone
matching exhaustively. Nothing is published to Maven Central yet and the
0.1.0 tag will become the MIMA binary-compatibility baseline, so making
it now is free and making it later costs a major version. That deadline
is the reason this is a new case rather than, say, an `Option` returned
alongside the result.

Everything that matches the family exhaustively was updated:
`CodebergError.describe`, `RetryEngine.isRetryable` and
`RetryEngine.contextOf` (a truncated walk never reaches the retry engine,
and repeating the walk would stop at the same cap, so it is not
retryable), the test telemetry recorder, and the `HandlingErrors`
example — whose compiler error on the missing case is the proof that the
break is visible to a reader rather than silent. The redaction property
in `SecretProps` lists the new case too, so its claim to cover every case
of the ADT stays true.

BREAKING CHANGE: `CodebergError` gains a sixth case,
`WalkTruncated(pagesVisited, resumeFrom)`. An exhaustive `match` on
`CodebergError` no longer compiles until the case is handled; add a
clause for it, or add a `case _` if the distinction does not matter to
you. Separately, `PageWalk.all`, `PageWalk.fold` and `PageWalk.foreach`
now fail the returned `Future` when they hit the page cap, where they
previously returned the pages gathered so far. Code that relied on the
short result should either handle `WalkTruncated` and resume from
`resumeFrom`, or narrow the query so the walk fits inside the cap.
`core.Pagination` was a public class with two methods, `foldPages` and
`listAll`, that walked a paginated collection one page at a time. It has
zero call sites: a grep across every `modules/*/src` tree finds the class
named nowhere but its own definition, and the only files that mention it
at all are its two test suites.

What actually walks pages in this library is
`com.worxbend.codeberg4s.paging.PageWalk`, which is what the README and
the roadmap point readers at. `PageWalk` supersedes `Pagination` on every
axis that mattered: it takes the listing operation as an argument so one
implementation of the termination rule serves every endpoint, it works on
the `Future` API a caller actually holds rather than on the internal
`Exec[F]` abstraction, and it bounds the walk. Keeping a second walker
alongside it means two places where the termination rule can drift apart
and a reader having to work out which one is current.

Deleting rather than deprecating is the right move because nothing is
published to Maven Central yet. A deprecation exists to give existing
users a migration window, and there are no existing users — the first
released artifact will simply never have contained this class. Waiting
would invert that: the 0.1.0 tag becomes the MIMA binary-compatibility
baseline, after which removing a public class costs a major version for
the benefit of nobody.

The two test suites go with it. They tested only this class, so keeping
them would mean keeping the class to compile them against. `PageWalk` has
its own suite covering the same termination behaviour.

Documentation that named the old class is corrected in the same commit:
CHANGELOG's pagination bullet and its known-limitations entry now name
`paging.PageWalk` and its `all` / `fold` / `foreach`, and the roadmap no
longer lists `Pagination` among what `core` provides. The roadmap's
property-suite entry claimed the pagination driver, codec laws and retry
bounds all lacked property suites; the pagination half of that stops
being true in the way it described the moment `PaginationProps` is
deleted, so the whole sentence is restated against what the tree actually
contains — `JsonProps`, `ApiErrorBodyCodecProps` and `RetryEngineProps`
cover two of the three areas, and the walk is the one left open.

BREAKING CHANGE: `com.worxbend.codeberg4s.core.Pagination` is removed,
along with its `foldPages` and `listAll` methods. Replace
`new Pagination[F].listAll(start)(fetch)` with
`PageWalk.all(start)(fetch)` and `foldPages(start, zero)(fetch)(step)`
with `PageWalk.fold(start, zero)(fetch)(step)`, both from
`com.worxbend.codeberg4s.paging.PageWalk`. The replacements return
`Future` directly rather than an abstract `F`, and they fail with
`CodebergError.WalkTruncated` on hitting the page cap instead of walking
until the fetch stops offering pages.
BinaryResponse, RequestBody.Binary and RequestBody.Multipart are case
classes holding an Array[Byte]. In Scala an array's own equals is
identity, so the equality a case class generates compared those bodies
by reference: two responses carrying byte-identical archives answered
false, their hash codes differed, and a Set kept both copies. Nothing
warns about that, so the wrong answer turns up in an assertion or in a
lookup rather than at compile time.

All three now write equals and hashCode over java.util.Arrays.equals
and java.util.Arrays.hashCode, keeping every other field in the
comparison and testing those first because they are the cheap half —
the archive may be megabytes. Each class is final, so the type test in
equals is exactly what the compiler generates for canEqual and the two
cannot disagree; the Scaladoc records that, and records that removing
final would change it.

RequestBody stopped being an enum to make room for this. A Scala 3 enum
case cannot have a body, so a case that has to write its own equals
cannot stay an enum case. The cases are now final case classes under a
sealed trait, which construction, pattern matching and exhaustivity
checking all see exactly as they saw the enum's cases; `ordinal` and
the scala.reflect.Enum supertype are what is lost, and nothing in the
library or its examples used either.

Nothing is published to Maven Central yet, and the 0.1.0 tag becomes
the MIMA binary-compatibility baseline, so reshaping RequestBody is
free today and would cost a major version after that tag. That deadline
is why the change happens now rather than being deferred.

The new core suite ByteEqualitySuite states the rule for all three
types: equal-but-distinct arrays are equal and hash alike, differing
bytes are not equal, and every non-byte field still counts.

BREAKING CHANGE: RequestBody is a sealed trait whose cases are case
classes rather than a Scala 3 enum. Building a body
(RequestBody.Json("…"), RequestBody.Empty) and pattern matching on one
compile unchanged; code that called `ordinal` on a RequestBody or
treated it as a scala.reflect.Enum has to match on the case instead.
Equality also changes meaning: BinaryResponse, RequestBody.Binary and
RequestBody.Multipart now compare their bytes, so anything that relied
on two byte-identical values staying distinct — using one as a map key
and expecting reference identity — has to say `eq` explicitly.
w0rxbend added 26 commits August 9, 2026 15:32
CodebergClient promises that "a Telemetry failure never fails the call
it was observing". It did not keep that promise.

Telemetry is the only way to see what this library is doing: it has no
logging dependency, so an application implements the trait and the
client calls it three times per attempt. Those callbacks are the
application's own code, running on the library's request path, and they
can break in two ways — throw where they stand, or hand back a Future
that fails a moment later. Either one used to reach the caller of, say,
repos.get as the outcome of a request the server had already answered
with a 200.

The path was two correct pieces meeting badly. ApiPipeline.observe runs
every callback through Exec.attempt and discards the result, and for
F = Either that hides the failure completely. FutureExec.attempt,
though, materialises only CodebergException and deliberately leaves
every other throwable as a failed Future, so that a caller's
NullPointerException is never laundered into a CodebergError nobody can
act on. Widening attempt would have destroyed that property, which is
load-bearing everywhere else, so the fix is not there.

It is at the boundary instead. A caller-supplied sink is now wrapped in
GuardedTelemetry before it ever reaches the pipeline. The wrapper calls
the sink inside a try, so a synchronous throw is caught, and maps the
returned Future's outcome to success, so a later failure is caught too.
The observation is lost in both cases; that is the intended trade.

Fatal throwables still get through on both rails: an OutOfMemoryError, a
LinkageError, a ControlThrowable or an InterruptedException says the
process is unsound or that someone asked for cancellation, and hiding
one to protect a single API call trades a visible crash for a silent
corruption, or a cancellation for a hang. Recognising them on the async
rail needs one extra step, because completing a promise with such a
throwable replaces it with an ordinary ExecutionException wrapping the
original — asking scala.util.control.NonFatal alone would hide exactly
the failures that must never be hidden.

Telemetry.noOp is not wrapped. It is this library's own code, it cannot
fail, and it is on the path of every request an unconfigured client
makes, so it does not pay for a guard it does not need.

TelemetryFailureSuite is the proof. Its four end-to-end tests fail
against the previous commit — both failure modes, on both the exception
rail and the .attempt rail — and pass now; two further tests pin down
that a fatal error and an interrupt are still propagated. ApiPipeline's
class Scaladoc overstated what it could promise about a failing callback
and now says which failures it swallows and which are the caller's
business to wrap.
When a 2xx response body does not match the model, ApiPipeline builds a
CodebergError.DecodingFailed carrying an excerpt of that body. For almost
every endpoint that is right: an excerpt of what arrived is the only thing
that makes a decoding failure diagnosable, and the excerpt is bounded so a
large listing cannot flood a log.

Four responses in this API are the exception, because their success body is
itself a live secret:

  - POST /users/{username}/tokens answers a usable personal access token;
  - POST /user/applications/oauth2 answers a client secret, and a PATCH of
    the same application may re-issue one — the spec does not say, so this
    treats it as though it does;
  - POST .../actions/runners answers the runner's registration token, and
    the registration-token endpoint answers the same credential on its own.
    Both exist three times over, for repositories, organisations and the
    authenticated account.

On those, a payload that carried the credential and failed to decode for
some unrelated reason put the credential into an error value the
application is about to log. The domain types mask themselves in toString,
but a snippet is the raw body taken before any conversion, so no mask
reaches it.

The Decode port now answers `sensitive`, a concrete `def` defaulting to
false so every existing instance keeps compiling, and `Decode.sensitive`
wraps an instance to set it. ApiPipeline substitutes a fixed placeholder —
`*** (N bytes withheld)` — for the excerpt when it is set. The placeholder
still reports that a body arrived and how large it was, so a truncated
response is still distinguishable from a complete one that did not match.

Deciding this inside the pipeline is what closes the hole. UserTokenApi
already rewrote the failure it received, but Telemetry.onError fires while
the attempt is being settled, before any endpoint sees the result: a
deployment logging raw telemetry errors recorded the token regardless.
That bespoke rewriting is gone, and with it the public
UserTokenApi.RedactedBody.

The OAuth2 decoder is split in two rather than marked wholesale. A read of
an application cannot carry a secret — Forgejo stores it hashed — so
UserAccountDecoders.application keeps its excerpt and only the creation and
the update read through the sensitive `issuedApplication`. Withholding a
body that has nothing to withhold costs diagnosability for nothing.

Tests: ApiPipelineSuite asserts the substitution, that an ordinary decoder
is untouched, and that a telemetry sink observes the redacted failure;
UserTokenApiSuite, UserApplicationApiSuite and RepositoryActionApiSuite
assert that the rendered failure of a malformed credential-bearing response
contains no part of the material. The UserApplicationApiSuite test that
previously pinned the leak as intended behaviour is inverted, and the
Scaladoc on ClientSecret, CreatedAccessToken and RunnerRegistrationToken
that documented the leak is corrected.

Measured duplication fell from 373 groups to 363 (PMD 7.26.0 at 40 tokens),
so CPD_BASELINE_GROUPS in verify.sh is lowered to 363 in this commit rather
than left as headroom.

BREAKING CHANGE: `UserTokenApi.RedactedBody` is removed. A caller comparing
a snippet against it should compare against
`ApiPipeline.redactedSnippet(bytes)` instead, or simply stop special-casing
the token endpoint — every credential-bearing response is now redacted the
same way. Nothing is published to Maven Central yet and the 0.1.0 tag
becomes the MIMA baseline, so removing it now is free and removing it later
would cost a major version.
`CodebergClient.close()` promised to release the HTTP backend when the
client had created it. It called `backend.close()`, and that call did
nothing: the JDK `java.net.http.HttpClient` underneath survived, taking
its connection pool and its selector thread with it. An application that
built a client per instance and closed each one at shutdown was leaking a
pool per client.

Why it did nothing, read off the sttp 4.0.26 sources. When you ask for a
backend with `HttpClientFutureBackend(options)`, sttp decides whether it
is allowed to release the client it just built by testing whether the
`ExecutionContext` you handed it is also a `java.util.concurrent.Executor`:

    val executor = Some(ec).collect { case executor: Executor => executor }
    HttpClientFutureBackend(
      HttpClientBackend.defaultClient(options, executor),
      closeClient = executor.isEmpty, ...)

The reasoning is that an executor you supplied is yours to shut down, so
sttp must not. But every ordinary `ExecutionContext` — the global one, one
from `ExecutionContext.fromExecutor`, the one a test framework hands you —
is an `ExecutionContextExecutor`, so `executor.isEmpty` is always false,
`closeClient` is always false, and `HttpClientBackend.close()` takes its
`if (closeClient)` branch straight to `monad.unit(())`. There is no
combination of arguments that makes the old call release anything.

The fix builds the `java.net.http.HttpClient` here and hands it to
`HttpClientFutureBackend.usingClient`, so the decision to end it belongs
to the code that created it. `SttpHttpPort.defaultBackend` now returns a
small wrapper backend whose `close()` calls the client's `shutdown()`
before delegating.

`shutdown()` and not `close()`. Both exist on `HttpClient` (it has been
`AutoCloseable` since Java 21, and this project targets Java 25), but the
JDK's `close()` "waits until all operations have completed execution",
while `shutdown()` "does not wait for previously submitted request to
complete execution". `CodebergClient.close()` is documented as returning
promptly so that an ordinary `finally` block stays cheap, so it must not
be the blocking one. Requests already in flight still run to completion;
new ones are refused.

The client is configured exactly as sttp configured its own, so nothing
else changes: the same connect timeout, no redirect following (sttp's
`FollowRedirectsBackend` wrapper applies the policy itself, and a client
that also followed redirects would apply it twice), the system proxy that
`BackendOptions.Default` reads out of the `http.proxyHost` family of
properties, and the caller's `ExecutionContext` as the client's executor
when it is one. The executor is never shut down — it belongs to the
caller, which was sttp's original concern and remains correct.

Tests use a real JDK client rather than the counting stub, because a stub
cannot tell a `close()` that released a pool from one that returned an
already-completed `Future`. `DefaultBackendSuite` asserts that the client
reports `isTerminated` after the backend is closed and that the caller's
execution context is still running; `CodebergClientSuite` asserts the same
termination through the public `close()`. Both fail against the previous
behaviour, waiting out the full limit and reporting "close() left the JDK
HTTP client running". No test sends a request, so none opens a socket.

Scaladoc corrected where it described the old behaviour:
`CodebergClient.close`, `CodebergClient.apply`, and the ownership notes on
`SttpHttpPort` and `SttpHttpPort.defaultBackend`.

BREAKING CHANGE: a backend from `SttpHttpPort.defaultBackend` now really
does end when it is closed, and code that closed one and kept sending on
it used to keep working by accident. Such code must now close the backend
only once it is finished with it, or build a second one. Nothing is
published to Maven Central yet and the 0.1.0 tag will become the MIMA
baseline, so making this change now costs nothing; making it after 0.1.0
would cost a major version.
The shared-backend example exists to answer "who closes what", and its
own answer leaked. It built the pool with sttp's
`HttpClientFutureBackend(BackendOptions.Default.connectionTimeout(...))`,
and the `backend.close()` in its `finally` block released nothing: that
constructor declines to end the JDK HTTP client it created whenever the
`ExecutionContext` it was handed is also an `Executor`, which every
ordinary execution context is. An example whose closing comment reads
"then the thing this program owns" should not be the one that demonstrates
a leak.

It now calls `SttpHttpPort.defaultBackend(ConnectTimeout, executionContext)`,
which is this library's own factory and, since the previous commit, hands
back a backend that really does shut its client down. The example teaches
the same lesson — clients first, then the backend they were built on — and
the teardown at the end now does what the comment beside it claims.

The connect timeout still travels with ownership, so the class comment's
paragraph about that now points at the `defaultBackend` call rather than
at the `BackendOptions` value that is gone. The comment on `close()` also
loses its claim that sttp's shutdown is asynchronous, which was a
description of the Future being returned rather than of anything the
program waits for.
Closing a CodebergClient while a call sat in retry backoff left the
Future for that call with no outcome at all: not fulfilled, not failed,
nothing. An application shutting down cleanly would wait on that Future
for as long as the process lived. Two scaladocs disagreed about this,
and the one describing the hang was the accurate one.

FutureTimer.close now completes those promises, failing each with a
java.util.concurrent.CancellationException. That matches how sleep
already reports a scheduler that has been shut down: closing a client
while it is still in use is a defect in the calling program, not a
remote failure, so it must not be laundered into a CodebergError that
the caller would then retry.

Reaching the waiting promises took a change of scheduler.
ScheduledThreadPoolExecutor does not put the Runnable it was handed
onto its queue; it wraps that Runnable in an internal task object, and
the wrapper is what shutdownNow() returns. Walking the returned list
therefore found nothing recognisable. The timer now runs on a small
subclass that overrides decorateTask, which is the supported hook for
choosing what goes onto the queue, so every queue entry names the
Completing runnable it would have run and close() can fail that
runnable's promise. The completion stopped being an anonymous lambda
for the same reason: decorateTask has to be able to recognise it.

Both scaladocs are reconciled with the new behaviour. A call started
after close fails with RejectedExecutionException, a call already
waiting fails with CancellationException, and no Future this client
handed out is left without an outcome.

The test awaits with a bound rather than mapping over the Future,
because a continuation on a Future that never completes never runs — a
regression would have hung the suite instead of failing it. Making that
bound real needed one more fix: an existing test deliberately continues
on the scheduler's own thread, close() interrupts that thread, and
munit may start the next test on it. An interrupted thread cannot wait,
so Await threw at once instead of honouring its timeout; beforeEach now
clears the leaked interrupt.

This changes the documented contract of a published method. Nothing is
on Maven Central yet and the 0.1.0 tag will become the MIMA
binary-compatibility baseline, so the change is free today and would
cost a major version once that tag exists.
SttpHttpPort built each request by applying the credential from
CodebergConfig first and the request's own headers second. sttp's
`header` defaults to DuplicateHeaderBehavior.Replace, so a name written
twice keeps the value written last, which meant the caller's headers
overwrote the credential — the exact opposite of what the scaladoc on
`withAuth` claimed ("applied after the caller's own headers so
configuration always wins").

No endpoint in this library sets an Authorization header today, so this
was not a live vulnerability. It was a latent one: every per-request
header map is an ordinary List[(String, String)] built somewhere in this
repo, and a new endpoint is one line away from putting a credential
there and silently authenticating as something other than what Auth was
configured with.

Two changes make that outcome unreachable rather than unlikely. The
credential now goes on last, so nothing can replace it. Authorization
and Proxy-Authorization are dropped from the caller's headers before
those are applied, matched case-insensitively because header names are
case-insensitive on the wire — so a request can no longer carry two
credentials under two spellings of one name.

User-Agent joins them in the dropped set. That is what keeps the
existing "the configured user agent wins over one a caller supplied"
contract true once the user agent stops being applied last, and the
observable result is unchanged: one User-Agent header carrying the
configured value.

Every other header still passes through, and still lands after the
body's own Content-Type. POST /markdown/raw depends on that ordering —
it sends markdown as text/plain over a body core models as JSON — and
MiscellaneousApiSuite's case "renderMarkdownRaw sends the markdown
itself as a plain-text body" stays green.

Separately, the user agent and the read timeout move into a `template`
val. An sttp request is an immutable value, so every builder call
allocates a new one, and both of these are read off a config that
cannot change once the port exists. They were rebuilding the same two
values on each call; they are built once per port now.

The three new SttpHttpPortSuite cases fail against the previous code —
the credential one put "token someone-elses-token" on the wire in place
of the configured token — so the fix is pinned by tests that could not
have passed before it.
Both upload commands gained a hand-written equals so that two uploads
carrying byte-identical content compare equal — an array compares by
identity, so the generated equals answered false for values that are
the same in every way that matters.

A hand-written equals owns one case the generated one handled for
free: being asked to compare against a value of an unrelated type. It
must answer false rather than assume the cast succeeds. That branch
shipped with no test, and it was the only uncovered statement left in
the domain module, which is otherwise at 100%.

Getting this branch wrong does not fail loudly at the point of the
mistake. It throws a ClassCastException later, out of whatever
collection lookup happened to compare the two values, which is a long
way from the code that caused it.
Until now nothing in this repository chose a JDK baseline. `javap -v` on a
compiled class reported major version 61 — Java 17 — not because anyone
decided that, but because Java 17 is Scala 3.8.4's built-in default when
no target is given. The number moved whenever the compiler's default
moved, and no file recorded it.

Worse, that accidental 17 was already a lie. `SttpHttpPort.close` calls
`java.net.http.HttpClient.shutdown()`, which the JDK added in Java 21. A
Java 17 JVM would happily load a class file stamped 17 and then die with
a `NoSuchMethodError` the first time somebody closed a client — a failure
at shutdown, far from the dependency that caused it. Proof: setting the
flag introduced here to 21 compiles the whole build, and setting it to 17
fails on exactly that line. So the compiler is now checking the API
surface, not only stamping a number.

What changed:

- `build.mill` gains `-java-output-version:25`. This is Scala 3's name
  for `javac --release`: it stamps the class-file version *and* removes
  every JDK API newer than the named release from the compiler's view.
- `.mill-jvm-version` pins `temurin:25`. The flag is rejected outright by
  a compiler running on an older JDK ("25 is not a valid choice for
  -java-output-version"), and Mill was running on a Coursier-provisioned
  JDK 21 regardless of the JDK on `PATH`. Mill now downloads and runs on
  Temurin 25, which also covers the scoverage sub-modules — those do not
  inherit a per-module `jvmId`, so a build-wide pin is the only setting
  that reaches them.
- Both CI toolchains move from 21 to 25: the `java-version` default in
  `.github/actions/scala-toolchain` (all four GitHub workflows take it)
  and the container image in `.forgejo/workflows/ci.yml`. Both Coursier
  cache keys now hash `.mill-jvm-version`, because the provisioned JDK is
  unpacked into that same cache and a stale key would restore the wrong
  one.
- `README.md` states the requirement next to the install coordinates, and
  `CONTRIBUTING.md` next to the setup instructions.

This narrows the audience, and that is the point of writing it down. A
consumer on Java 21 could link against the jars this build produced
yesterday; against these jars they get an `UnsupportedClassVersionError`
at class-load time. The trade is deliberate: Java 25 is the current LTS,
a stated floor that fails loudly at load time is kinder than an unstated
one that fails later with a `NoSuchMethodError`, and the flag now makes
the compiler enforce whatever floor we pick instead of leaving it to a
compiler default.

Note for anyone tempted to blame the client-lifecycle work: `shutdown()`
is a Java 21 method, so 25 is a policy floor rather than a technical
requirement. The README says so and invites an issue from anyone who
needs 21 — lowering it is a one-line change.

BREAKING CHANGE: the published jars now require a Java 25 runtime
(class-file major version 69). Java 21 and Java 17 JVMs, which could load
the previously emitted Java 17 bytecode, fail with
`UnsupportedClassVersionError`. Building the project also requires JDK
25; `.mill-jvm-version` provisions it automatically, so no manual install
is needed to compile.
The recent run of breaking changes was timed to land before 0.1.0
freezes the public surface. That freeze means nothing without a tool
that enforces it, and until now there was none: no plugin, no baseline,
and a section in RELEASING.md saying so.

MIMA — the Migration Manager — compares the class files a build
produces against the class files of an already-released version and
reports every difference that would stop a program compiled against the
old jar from linking against the new one. It works on bytecode, so it
catches what the compiler cannot see from this side: a copy overload
that changed arity, an opaque type whose representation moved.

It is now wired through the mill-mima plugin, declared in build.mill's
header as com.github.lolgab::mill-mima::0.2.2. The two colons before
the version are Mill's "add the platform suffix" spelling; Mill
resolves that to mill-mima_mill1_3, the build for the Mill 1.x line
that .mill-version pins at 1.1.7. 0.2.2 was the latest stable version
in that artifact's maven-metadata.xml on repo1.maven.org. The Mima
trait is mixed into Codeberg4sPublishModule rather than into each
module, so one declaration covers all five published artifacts: the
plugin derives every coordinate from pomSettings().organization,
artifactId() and mimaPreviousVersions.

Nothing has been published, so there is nothing to compare against.
Publish.binaryCompatibleWith is therefore Seq.empty, and
./mill modules.__.mimaReportBinaryIssues stops with the plugin's own
"No previous artifacts configured" message. That costs nothing: no
other task depends on that command, so compile, test and verify.sh
never reach it. It is deliberately absent from verify.sh, which has to
run offline and in seconds while this check downloads previous
artifacts from Maven Central — the release checklist runs it instead.
Arming it once 0.1.0 is on Central is a one-line edit.

Two traps are handled here rather than left to be discovered.

First, mimaPreviousVersions on a shared trait means every future module
inherits the claim that each listed version of *it* exists on Central.
A sixth artifact first published in, say, 0.3.0 would send MIMA looking
for a 0.1.0 of itself that was never uploaded, and the run would fail
on a download error that says nothing about compatibility. The trait's
scaladoc carries the override such a module needs.

Second, the response models' constructors are private[codeberg4s], and
Scala erases qualified private to public bytecode, so the expectation
recorded earlier in RELEASING.md was that MIMA would report <init>,
apply and copy on all 131 of those classes the moment a field was
added. Measured against mill-mima 0.2.2 and Scala 3.8.4 — by
publishing modules.domain locally as a throwaway 0.1.0 and re-running
the check — that is not what happens. MIMA reads Scala 3's own
signature and honours the qualified private: the constructor, copy,
every copy$default$N and the companion object's apply are never
reported. Exactly one member leaks, the static forwarder Scala 3 emits
on the class so Java callers can reach the companion's apply, because a
forwarder carries no Scala-side access information. Appending a field
to a 2-field model and to a 21-field model each produced one report;
inserting the same field at the front produced six, because the Product
accessors _1, _2 … are genuinely public and genuinely change result
type. The same insertion into a command model with a public
constructor produced fourteen, which is the check doing its job.

So the filter is one line per model that gains a field, written in the
release that adds it, and no standing blanket is added now: a wildcard
broad enough to cover 131 classes would also silence a real break in a
command model in the same package. RELEASING.md now carries the
measured table, the validated filter snippet — ProblemFilter.exclude,
singular, where sbt-mima spells it ProblemFilters.exclude — the advice
to append new fields rather than insert them, and the procedure for
re-taking the measurement when Scala or the plugin moves. CHANGELOG.md
loses the claim that MIMA is unwired.

The throwaway local Ivy publication used for the measurement was
deleted afterwards. Leaving a fake 0.1.0 in ~/.ivy2/local would make a
later run silently check against a jar nobody released.
The `./mill` launcher downloaded a ~60 MB executable over the network,
made it executable and ran it, with no check that the bytes were the
ones Mill published. Everything this project does — compile, test, sign,
publish — runs through that executable, so whatever it turned out to be
would have been trusted completely. `scripts/cpd.sh` already pins PMD by
digest and `.github/actions/scala-toolchain/action.yml` already pins
scala-cli by digest; the launcher was the one fetch left unpinned, and it
is the one that matters most.

A new `.mill-checksums` file records the SHA-256 of each Mill
distribution the launcher may run. There is one line per platform
because the launcher picks a different native binary per operating
system and architecture, plus the portable JVM launcher: five entries
for Mill 1.1.7. Every digest was produced by downloading the artifact
from Maven Central and checking it against the `.sha1` sidecar published
alongside it before recording the SHA-256. The file's header carries the
exact shell loop for regenerating it when Mill is bumped.

The launcher now checks the file it is about to execute against that
digest, and refuses to run anything that does not match or is not
listed. The check runs on the already-cached path too, not only after a
download: the cache lives outside the repository, is shared with every
other project on the machine, and on any machine that ran Mill before
this commit it holds a binary that was never verified at all. Hashing
60 MB takes about 35 ms, which is not a reason to trust a file forever
because it was fetched once. A version with no recorded digest fails
before the download starts rather than after it.

`DEFAULT_MILL_VERSION` in the launcher said `1.1.6-104-5bbe1e` — an
untagged development snapshot, 104 commits past the `1.1.6` tag, which
Maven Central confirms is a nightly-style publication rather than a
release. `.mill-version` pins `1.1.7`, and that pin wins in this
checkout, so the snapshot only applied to a checkout with no
`.mill-version` at all. Even as a fallback, landing on an unreleased
snapshot is a build nobody can reproduce, so the default is now `1.1.7`
and the two agree.

The launcher is vendored from upstream Mill, so both edits are marked
`LOCAL CHANGE` and a note at the top of the file says they must be
re-applied if the script is ever regenerated.

Verified on Linux x86-64: a warm cache runs, a removed cache
re-downloads and runs, a byte appended to the cached binary is refused
with both digests printed, an unlisted version is refused without
downloading, and upstream's `MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT` hook
still prints the URL as before. `shellcheck -s sh mill` is clean.
`uses: actions/checkout@v7` names a tag, and a tag is a mutable pointer.
Whoever controls that repository can move `v7` to different code at any
moment, and every workflow here would fetch and run whatever it now
points at. In `release.yml` that job holds `MILL_PGP_SECRET_BASE64`, the
key the published artifacts are signed with, so moving one tag would be
enough to read the key out of the runner. This is not hypothetical: it
is how the `tj-actions/changed-files` compromise reached tens of
thousands of repositories in 2025.

Every third-party `uses:` in the repository now names a full
40-character commit SHA, which cannot be repointed, with the release it
corresponds to in a trailing comment so the line is still readable at a
glance.

Four GitHub workflows and the composite action, resolved on github.com:

  actions/checkout              v7 -> 3d3c42e5  # v7.0.1
  actions/upload-artifact       v7 -> 043fb46d  # v7.0.1
  actions/setup-java            v5 -> b6effb05  # v5.7.0
  actions/cache                 v6 -> 55cc8345  # v6.1.0
  actions/upload-pages-artifact v5 -> fc324d35  # v5.0.0
  actions/deploy-pages          v5 -> cd2ce8fc  # v5.0.0

`.forgejo/workflows/ci.yml` runs on Codeberg and had the same problem.
Its two SHAs come from **code.forgejo.org**, not github.com, because a
Forgejo runner resolves a bare `actions/<name>` against its
DEFAULT_ACTIONS_URL and that is code.forgejo.org on Codeberg — the same
tag on the two forges is not the same code:

  actions/checkout              v4 -> 11d5960a  # v4.4.0
  actions/cache                 v4 -> 0057852b  # v4.3.0

A comment in that file records which forge the SHAs belong to, so an
instance pointed at a different actions registry knows to re-resolve
them rather than assume they will work.

No SHA was written from memory. The GitHub ones were resolved with
`gh api repos/<owner>/<repo>/commits/<tag>` and confirmed independently
with `git ls-remote --tags`, which showed the same SHA carrying both the
major tag and the exact release tag named in the comment. The Forgejo
ones were resolved with `git ls-remote --tags` against code.forgejo.org
and confirmed against that instance's `/api/v1/repos/.../tags/v4`.
Nothing was left unresolved.

`uses: ./.github/actions/scala-toolchain` is deliberately left as-is. A
`uses:` starting with `./` is not fetched from anywhere — it runs the
copy of that action in this repository at the commit being built, so the
checkout above already pins it. The header of `release.yml` says so, in
case a future reader reads the unpinned line as an oversight.

Behaviour is unchanged: the same action versions run as before, they are
now identified by content rather than by a name someone else can
reassign.
The previous commit replaced every action tag with a commit SHA, which
is what stops someone repointing the code CI runs. It also freezes those
pins: a SHA never moves, so a bug fixed upstream never arrives, and the
pins age until nobody remembers whether they are deliberate or forgotten.
Pinning without an update path trades one problem for another.

`.github/dependabot.yml` adds the update path. Once a week it checks the
pinned actions, and when one has a newer release it opens a pull request
that rewrites both the SHA and the trailing `# v7.0.1` comment. A human
reviews and merges it, so the pins move forward on purpose.

Checked first that nothing else already does this: there is no
`dependabot.yml`, no `renovate.json`, no `.renovaterc`, and no
Scala Steward workflow anywhere in the tree. `docs/READINESS.md` listed
dependency automation as a gap rather than as something configured, so
this is the first such config, not a second one competing with an
existing one.

Two `updates` entries, both for the `github-actions` ecosystem:

  * `directory: "/"` — for this ecosystem that means `.github/workflows`,
    which is the four workflow files.
  * `directory: "/.github/actions/scala-toolchain"` — the composite
    action. A root-level entry does not reach into `.github/actions/`,
    and its two pins are the ones the release job's toolchain is built
    from, so leaving them out would leave the pins that matter most
    unattended.

Both entries group all matching updates into a single pull request.
Six separate pull requests to advance six `actions/*` pins is six
reviews of the same decision, and the predictable outcome is that none
of them are read. Commit messages come out as
`ci(deps): bump actions/checkout from 7.0.1 to 7.0.2`, matching the
Conventional Commits format the rest of the history uses.

Deliberately out of scope, and said so in the file so the next reader
does not think it was missed:

  * Scala and Mill versions. Dependabot has no Mill support at all;
    `mill mill.scalalib.Dependency/showUpdates` reports those today and
    `docs/READINESS.md` still names Renovate as the eventual automation,
    partly because Renovate also runs on Codeberg.
  * `.forgejo/workflows/ci.yml`. Dependabot cannot see it, and should
    not touch it: its SHAs come from code.forgejo.org while Dependabot
    resolves against github.com, where the same tag is different code.
    That file stays hand-maintained, as its header now states.

`docs/READINESS.md` is updated in the same commit so the gap list stops
claiming there is no automation for the part that now has some.
`modules/codec` declared two jsoniter-scala artifacts, `-core` and
`-macros`, and imported only the first. The macros artifact exists for
one thing: `JsonCodecMaker.make`, the compile-time macro that *derives*
a `JsonValueCodec[A]` from a case class. This library derives no codecs
at all. `JsonValue.scala` hand-writes a single `JsonValueCodec` over a
small document model, and every DTO assembles itself from that document
through `JsonFields`, because the Forgejo API needs "every field
optional, `null` and absent identical, unknown kinds tolerated" — a
shape derivation cannot express (docs/HAZARDS.md §1).

The evidence is a grep: every `com.github.plokhotnyuk` import under
`modules/` resolves to `jsoniter_scala.core`, across seven import lines
in two files, and none to `jsoniter_scala.macros`.

Why it matters to someone who has never seen this repository. Mill's
`mvnDeps` is compile *and* runtime scope, so a declared dependency is
written into the published POM and every consumer resolves it
transitively. jsoniter-scala-macros is roughly a megabyte of derivation
machinery, so anyone depending on `codeberg4s-client` was downloading
and shipping a jar that no line of code on either side of the boundary
touches. README.md advertises "sttp client4 and jsoniter-scala, that is
the list"; that claim is now literally true of the generated POM rather
than approximately true.

Nothing at all changes for callers: no source file is edited, and
`./mill modules.codec.pom` now emits three dependencies
(jsoniter-scala-core, scala-library, and the two sibling modules)
instead of four.

docs/VERSIONS.md loses the macros row per its own §7.5, and ADR-0003
gains a correction: it had argued the macros module was "compile-time
only", which is true of the macro expansion but not of the dependency
scope Mill assigns it.
Resolved from the canonical resolver — `maven-metadata.xml` for
`com/github/plokhotnyuk/jsoniter-scala/jsoniter-scala-core_3` lists
2.40.1 as the newest stable, two releases past the pinned 2.39.1.

What is in those two releases, from the project's own notes: 2.40.0 adds
a key-sorting option to the circe booster and reduces allocations when
`JsonReader.readNumber` parses small integers; 2.40.1 makes encoding of
sorted-key JSON objects faster in that same booster. No API is removed
or renamed, and the circe booster is a separate artifact this build does
not depend on, so the only change that reaches this code is the
`readNumber` allocation work.

That one needed checking rather than assuming, because `JsonValue`
matches on the *concrete* class `readNumber` hands back and its Scaladoc
claims to know the complete set. Reading the 2.40.1 sources
(`jsoniter-scala-core_3-2.40.1-sources.jar`, `JsonReader.readNumber`),
the return paths are still exactly three: `java.lang.Long` for a whole
number that fits in 64 bits, `java.math.BigInteger` for a whole number
that does not, and `java.math.BigDecimal` for anything with a fraction
or an exponent. The Scaladoc's version reference moves to 2.40.1 so it
keeps naming the version somebody actually looked at.

`./verify.sh --with-slow` passes: 3658 unit tests, coverage gate green,
duplication unchanged at the recorded 363 groups.
Resolved from the canonical resolver: `maven-metadata.xml` for
`org/scoverage/scalac-scoverage-reporter_3` (and its `-serializer` and
`-domain` siblings, which move in lockstep) lists 2.5.2 as the newest
stable. The build was pinned at 2.3.0, six releases behind.

What this artifact does, for anyone who has not met scoverage. Since
Scala 3.4 the coverage *instrumentation* is part of the compiler, not a
plugin, so `scoverageVersion` no longer selects how code is measured. It
selects the libraries that read the compiler's raw measurement files and
turn them into the XML and HTML reports — which is what `verify.sh`
step 7 and `scripts/crap.sc` consume. A bump here therefore changes
reporting, not the numbers being reported.

The one breaking change in the range is 2.4.0, which drops support for
Scala 2.13.15-and-earlier and 2.12.16. This project is Scala 3 only
(3.8.4), so nothing in the build can be affected by it. The rest are
2.4.1 (fixes instrumentation of pattern-matching assignments), 2.4.2 and
2.5.1 (add newer Scala 2 versions), 2.5.0 (dependency updates) and 2.5.2
(incremental coverage support).

Evidence that reporting did not shift: `./verify.sh --with-slow` reports
the same per-module coverage as the run before the bump, to the
statement — domain 3968/3968, core 794/822, codec 6053/6358 — with the
same CRAP table over 2217 methods and duplication unchanged at 363
groups.
Resolved from the canonical resolver: `maven-metadata.xml` for
`org/scalameta/munit_3` lists 1.3.5 as the newest stable, one patch past
the pinned 1.3.4.

munit is the test framework the five `*.test` modules run under, and it
is a test-only dependency — it appears in no published POM, so nothing
here reaches a consumer.

The 1.3.5 release notes are dominated by the project migrating its own
build from sbt 1 to sbt 2 and dropping JDK 8 from its build matrix. That
reads alarming and is not: those are changes to how munit is built, not
to the API a test suite compiles against. Nothing in the notes removes,
renames or redefines an assertion, a fixture or a tag, which is the
surface this repository uses. The remaining entries are compilation
error reporting that no longer depends on terminal capabilities, and a
Java source formatter.

The same 3658 unit tests run and pass, with the same coverage,
duplication and CRAP results as before the bump. `munit-scalacheck`
stays at 1.3.0, which is its own newest stable — the two artifacts
version independently and this is not a mismatch.
Resolved from the canonical resolver: `maven-metadata.xml` for
`org/scalameta/scalafmt-core_3` lists 3.11.5 as the newest stable, one
patch past the 3.11.4 pinned in `.scalafmt.conf`.

This is a `style:` commit on its own, and deliberately so. `.scalafmt.conf`
sets `align.preset = most`, which lines up tokens vertically across
neighbouring lines; a formatter release that changes how that alignment
is computed can rewrite every file in the repository. Mixing that churn
into a dependency or logic commit would bury the real change in
thousands of whitespace lines, so docs/VERSIONS.md §7.4 requires the
bump to travel alone with the whole tree reformatted in the same commit.

Reformatting the whole tree under 3.11.5 changed nothing:
`./mill mill.scalalib.scalafmt/` reports 850 of 850 files formatted and
`git status` lists no modified Scala source. That is why this diff is
four text files and no code. The 3.11.5 release is a documentation
website migration plus four fixes — inverted offsets on empty trees, a
tolerated CLI error that could mask a real one, the runner naming the
failure it exited on, and a brace/colon oscillation in
`RemoveScala3OptionalBraces` — none of which this configuration hits.

The zero-file result doubles as re-verification of the note in
`.scalafmt.conf` above `rewrite.imports.groups`, which explains that the
catch-all `.*` pattern can safely precede the `scala\..*` group because
scalafmt assigns an import to its longest matching pattern rather than
its first. Had 3.11.5 changed that, `scala.*` imports across the tree
would have moved groups and the reformat would not have been a no-op. So
the comment's "verified against" version moves to 3.11.5, as do the
formatter rows in SCALA_CODE_STYLE.md and docs/CONSTITUTION_MAPPING.md.
CHANGELOG.md is what the 0.1.0 tag will carry, and a first-time reader
would have acted on several things in it that were not true.

The counts were wrong. It described "seven endpoint groups ... plus
version"; CodebergClient exposes nine accessors, and `downloads` — the
two ZIP-fetching Actions operations — was missing from the list
entirely. It said "61 REST operations" and, under Known limitations,
"Endpoint coverage is 61 of 439 in-scope operations"; the real figure is
439 of 439, which docs/API_INVENTORY.md section 0 and docs/ROADMAP.md
have both said for a while. It claimed 1,072 unit tests; ./verify.sh
reports 3,658. It listed `GET /repos/issues/search` as deferred when
IssueApi implements it and the inventory ticks it.

The architecture-boundary sentence named the wrong things. verify.sh
bans sttp, upickle, ujson and the Future family below `client`, and
sttp inside `codec`. It does not ban jsoniter-scala below `client`, and
could not: `codec` is below `client` and jsoniter is what it is built
on.

The remaining figures — 54 golden fixtures, the coverage rates, the
CRAP result, the duplication count — are now the ones this commit
measured rather than ones carried forward. Where something is still
unproven it says so: there is no mutation score for this repository,
and the ScalaCheck suite still has no property test for the page
walker.

The larger gap was that this branch changed a great deal that a release
note has to describe, and none of it was described. Three new sections
cover it.

"Changed" lists the breaking changes, with a note at the top of the
entry explaining why a first release lists any at all: nothing is on
Maven Central, so these are not a migration path from an earlier
release, they are the decisions taken before the tag freezes the
surface. Closed response constructors, the sixth CodebergError case,
the removal of core.Pagination, byte-carrying response bodies, the two
JSON number cases, JsonFields over a vector, the upload builders, the
two new config bounds and the Java 25 floor.

"Security" covers the four fixes: a base URI carrying credentials is
rejected rather than concatenated into every logged URI, dot segments
are rejected in single-segment identifiers, a credential-bearing
response body is withheld from a decode-failure snippet, and a response
body is bounded rather than limited only by the heap. The configured
credential is also applied after caller headers now, which was latent
rather than live and is recorded as such.

"Fixed" covers the lifecycle bugs: the JDK HTTP client the library
created was never actually shut down, so an application building a
client per instance leaked a connection pool per instance; and a retry
sitting in backoff when the client closed was left with no outcome at
all, so a clean shutdown waited on that Future forever.

"Performance" carries the measured numbers and nothing rounder than
they are. Decoding a 170,251-byte page of fifty repositories allocates
1,133,632 B/op against 1,944,816 before, which is 41.7 %; the
end-to-end path allocates 963,360 B/op against 1,303,928 for the same
decode via a String, which is 26.1 % and works out as exactly the two
copies of the page that were removed. Every figure names
scripts/alloc-bench.sh and the JVM it ran on, and repeats that harness's
own warning that its wall-clock times are a direction of travel rather
than a figure.
The top of this file has said "439 of 439 in-scope operations, 100 %"
for a while. The "Distance to 0.1.0" table at the bottom still said
"61 / 439 ... the surface is 14 % of the way there", so the same
document answered its own headline question two different ways, and the
pessimistic answer was the one a reader looking for release readiness
would land on.

Every figure in that table is now one measured on this commit rather
than one carried forward:

  - endpoints: 439 / 439, from docs/API_INVENTORY.md section 0
  - unit tests: 3,658, from the count ./verify.sh prints, not 1072
  - coverage: domain 100.00 % statement / 100.00 % branch, core
    96.59 % / 92.48 %, codec 95.20 % / 91.47 %, so the row saying the
    floors "have not yet been asserted against a fresh report" is
    replaced by the report they were asserted against
  - CRAP: 2,217 methods measured, worst 28.0 against a limit of 30
  - duplication: 363 groups, not 323

Two clauses are deliberately left saying "no". The mutation score does
not exist for this repository and is called unproven rather than
quietly dropped. And CPD is not clean: 363 groups is what the tool
reports, and ./verify.sh --with-slow passes only because it compares
that count against a recorded baseline and fails on an increase. The
table says so in those words so that a green gate is not read as an
absence of duplication.

The Phase 4 checkboxes this branch completed are ticked, with what is
still missing spelled out beside each:

  - PMD CPD: ticked, because the tool runs and the gate holds. The
    threshold is still 40 tokens and every group is still reported.
  - CRAP and the coverage gate: ticked, both now run against a freshly
    generated scoverage report rather than only implemented.
  - Nightly spec-drift: ticked. It moved to .github/workflows/nightly.yml
    behind a real schedule: trigger, where the previous copy was gated
    on a schedule event its workflow never emitted and so had never run.
    It still only warns on a sha mismatch instead of opening an issue,
    which is recorded as the remaining piece.
  - Maven Central and MIMA: still unticked, but "no MIMA setup at all —
    no plugin, no baseline" is no longer true. MIMA is wired through
    mill-mima 0.2.2 on the shared publish trait. What remains is the
    publication itself.
  - Stryker4s: still unticked. The note that scalameta parsed all 196
    production files now says those modules hold 503 today, so the
    reader can see that even the parse half of that proof predates the
    current sources.

Three older figures elsewhere in the file were stale for the same
reason and are corrected: the build declares seven modules rather than
six (examples joined it), CI is no longer only .forgejo, and the 50 MB
attachment line under "Out of scope" is now enforced by
CodebergConfig.maxDownloadBodyBytes rather than only written down.
The two Actions endpoints that answer with a ZIP archive live on
`ActionDownloadApi`, and `CodebergClient` exposes that class as
`client.downloads` — a top-level accessor, alongside `client.repos` and
`client.issues` rather than underneath one of them. Ten places across
the scaladoc, the README and the self-hosting guide told readers to
reach it as `client.repos.actions.downloads` instead, which is a path
that does not exist: `client.repos.actions` is `RepositoryActionApi`,
and that class has no `downloads` member. Anyone who typed what the
documentation said got a compile error.

The reference page `site/src/reference/api-groups.md`, the FAQ, SECURITY.md
and the CHANGELOG already said `client.downloads`, so the wrong form was
the minority spelling, not a rename that half-landed.

Documentation only — no signature, no behaviour and no test changed.
`docs/VERSIONS.md` still explained the codec layer in upickle's
vocabulary, two commits after upickle was removed from the build.

Two passages were wrong. The "JSON library" note ended by saying the
style guide's rule applies "translated to jsoniter-scala `ReadWriter`s".
`ReadWriter` is upickle's codec type; it does not exist in this
repository, and it has no jsoniter-scala counterpart by that name — the
jsoniter type is `JsonValueCodec[A]` from `jsoniter_scala.core`. The
same paragraph also claimed the style guide's jsoniter examples "do not
apply to this repository", which was true when the code was upickle and
is now only half true: the library is the right one, but the examples
derive a codec per data-transfer object with `JsonCodecMaker`, and this
build derives none. The rewrite says which half applies and why the
other half does not, pointing at `docs/HAZARDS.md` §1 for the reason
(the pinned spec marks no field required, never says `nullable`, and
live payloads send `null` where an array is promised — a derived codec
answers all three by failing).

The "Not adopted" table listed "circe / jsoniter-scala | rejected", so
the document rejected the library the project runs on. It is now four
rows that match reality: circe rejected, upickle removed with a pointer
to the ADR that reversed it, and `jsoniter-scala-macros` rejected on its
own terms, since that is the artifact this build declines rather than
jsoniter itself. The cats-effect row's "four artifacts" was left over
from when the macros jar was declared; §3 of the same file lists three.
ADR-0003 records a decision to move the JSON boundary off upickle and
onto jsoniter-scala. A blanket rename of the library name had replaced
every occurrence of "upickle" in the file, including the ones describing
what was being moved away from, which left the document arguing with
itself: it said it superseded an earlier ADR "which chose
jsoniter-scala", in order to choose jsoniter-scala; it credited sttp
with a "first-party jsoniter-scala integration" that "was never used"
when the unused integration was the upickle one; and it listed "Keep
jsoniter-scala" as a rejected alternative. A reader had no way to
recover what the decision actually was.

Six occurrences are restored to "upickle" or "ujson" — the two that name
the superseded choice, the two grounds it rested on, the removed sttp
integration, the leniency `JsonDecoder[String]` no longer inherits, the
tracing visitor that produced per-field parse paths, and the rejected
alternative. Occurrences that genuinely mean jsoniter are untouched.

ADR-0005's closing note said the style guide's "Ox and jsoniter examples
no longer match the code". Half of that expired when ADR-0003 landed:
the guide's jsoniter examples now name the library in use. What still
diverges is narrower and worth stating precisely — those examples derive
a codec per data-transfer object with `JsonCodecMaker`, and this build
derives none.

No decision is changed or reopened; this restores the record of one
already made.
The artifact step was labelled "Coverage HTML" and promised that "the
gate reports a percentage, and the report says which lines". No step in
CI produces HTML. `verify.sh`'s coverage step runs
`mill <module>.scoverage.xmlReport` and stops there, because XML is what
`scripts/coverage-gate.sc` and `scripts/crap.sc` parse. Anyone who
downloaded the artifact looking for a browsable report found none.

Rather than add a `mill` call here — the header of this file rules that
out, and it is the rule that keeps `./verify.sh` locally and CI in step —
or charge every local `./verify.sh` run for rendering a page nothing
reads, the step now says what it uploads. The XML still answers the
"which lines" question, statement by statement; the comment points at
`./mill modules.__.scoverage.htmlReport` for a reader who wants it as a
web page.

The upload path narrows from `out/modules/*/scoverage/` to
`out/modules/*/scoverage/xmlReport.dest/scoverage.xml`. The old glob
swept up the whole task-cache directory — roughly forty JSON files of
Mill bookkeeping per module, alongside the one file worth keeping. The
artifact is renamed `scoverage-xml-<run id>` so a downloaded zip is not
mistaken for the HTML the old name implied.
The architecture-boundary step forbade a set of imports below the client
module. Its JSON half read `upickle|ujson` and had done since before
commit 48f64fe replaced upickle with jsoniter-scala, so it was checking
for two packages that no longer appear anywhere in the repository while
the package that does appear, `com.github.plokhotnyuk.jsoniter_scala`,
went unmentioned. Adding that import to `modules/domain` or
`modules/core` — the two modules whose whole definition is that they
know neither the transport nor a JSON library (PLAN.md §3.1) — would
have printed "boundaries clean".

Verified before and after. With
`import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec`
temporarily added to `modules/core/.../Exec.scala`, the old pattern
matched nothing and the step passed; the new one fails with

  core must not know about sttp, a JSON library or Future
    modules/core/.../Exec.scala:3:import com.github.plokhotnyuk...

The violation was reverted before committing and the full
`./verify.sh --with-slow` is green.

Three changes:

- the vendor prefix `com.github.plokhotnyuk` is matched rather than the
  exact package, so a `jsoniter_scala.macros` import — the artifact
  docs/adr/0003 deliberately does not declare — trips the same rule;
- the JSON alternatives move into their own `FORBIDDEN_JSON` variable
  and gain circe, play-json, zio-json, Jackson, json4s and Gson.
  upickle and ujson stay. None of these is a dependency, which is the
  point: a pattern for a library that is not there costs one alternation
  and earns its keep on the day somebody adds one;
- `modules/transport` gets the mirror-image check. docs/adr/0003 gives
  "the JSON library stays out of the transport" as the reason the unused
  sttp-upickle integration was dropped — the transport reads every body
  as a `String` and hands it to `modules/codec` — and nothing was
  holding that true.

`modules/codec` is exempt, as it must be: it is the module that owns the
JSON library. Its own boundary, that it must not import sttp, is
unchanged.
Twenty-four tracked files cite `PLAN.md` as normative — `docs/ROADMAP.md`
seven times, `verify.sh` in its header, `docs/VERSIONS.md`,
`CONTRIBUTING.md`, `README.md`, five of the six ADRs, and every script
under `scripts/` — and `.gitignore` listed `PLAN.md` among the
AI-generated scratch documents. So the file existed only on the machine
where it was written. Anyone who cloned this repository read "PLAN.md
§3.1", "PLAN.md ADR-3", "PLAN.md §6.6" and had no way to open any of
them. A document that other documents treat as the source of truth
cannot be untracked.

It is tracked rather than dissolved into the docs it is cited from,
because the citations are by section number: rewriting them all into
prose would touch two dozen files, lose the section anchors, and cost
the record of what was decided before any code existed.

Read in full before committing. It holds no credentials, no tokens, no
personal data and nothing about anyone's infrastructure — it is a design
document. The one environment variable it mentions, `CODEBERG_TOKEN`, is
an illustrative `sys.env` call in an API sketch, and the same name is
already documented in README.md and CONTRIBUTING.md.

The file is a snapshot of the plan as written before implementation, and
parts of it were overruled while the code was built. Committing it
unannotated would install a stale document as a second, contradictory
source of truth, so a "Status of this document" header is added at the
top: it says the document is historical, that an ADR wins wherever the
two disagree, and it tables the six divergences that matter (upickle,
the `codeberg4s.*` package prefix, the `sttp-transport` module name,
softwaremill/retry, the Gherkin acceptance pipeline, quicklens), each
with a link to the ADR or doc that settled it. Nothing in the body is
edited; the plan reads as it was written.

`.gitignore` keeps the whole scratch-document list — PLANS.md,
PLANNING.md, SPEC.md and the rest are still ignored, and the section's
`git add -f` note is generalised rather than dropped. Only `PLAN.md`
comes off, with a comment saying why it is the exception.
This document listed as blocking several things that have since been
built, which makes it worse than no document: a contributor reading it
would conclude the project has no contribution path, no CI and no
binary-compatibility policy, and all three exist.

Rechecked every claim against the repository rather than against
memory. Moved to a "Resolved" section, each verified by opening the
file: the release process, the community health files and issue
templates, the GitHub workflows with SHA-pinned actions, the MIMA
wiring, the examples module inside the gate, and the ten guides with
mdoc compiling their snippets. The duplication gate is green now, so
the entry saying it is red is gone too.

What remains blocking is shorter and sharper. Nothing is published —
the version is still a SNAPSHOT and no tag exists. And no mutation
score exists, which matters more than it looks: coverage says a line
ran, not that breaking it would be noticed, and that is the one number
PLAN.md asks for that this repository still cannot show.

Two entries are deliberately hedged rather than claimed. The site
workflow depends on a repository setting that cannot be seen from a
clone, so hosted documentation is recorded as unproven rather than
done. And the duplication gate passing is recorded as passing against
a baseline of 363 tracked groups, not as clean code, because those are
different statements and the second one is not true.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 251 files, which is 151 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e52f35dc-3367-4c08-aaa3-fcfbeae207ef

📥 Commits

Reviewing files that changed from the base of the PR and between 48f64fe and 48b3c81.

📒 Files selected for processing (251)
  • .forgejo/workflows/ci.yml
  • .github/actions/scala-toolchain/action.yml
  • .github/dependabot.yml
  • .github/workflows/ci.yml
  • .github/workflows/nightly.yml
  • .github/workflows/release.yml
  • .github/workflows/site.yml
  • .gitignore
  • .mill-checksums
  • .mill-jvm-version
  • .scalafmt.conf
  • CHANGELOG.md
  • CONTRIBUTING.md
  • PLAN.md
  • README.md
  • RELEASING.md
  • SCALA_CODE_STYLE.md
  • SECURITY.md
  • build.mill
  • docs/CONSTITUTION_MAPPING.md
  • docs/LEDGER.md
  • docs/READINESS.md
  • docs/ROADMAP.md
  • docs/VERSIONS.md
  • docs/adr/0003-jsoniter-for-json.md
  • docs/adr/0005-future-public-api.md
  • mill
  • modules/client/src/com/worxbend/codeberg4s/CodebergClient.scala
  • modules/client/src/com/worxbend/codeberg4s/client/FutureExec.scala
  • modules/client/src/com/worxbend/codeberg4s/client/FutureTimer.scala
  • modules/client/src/com/worxbend/codeberg4s/client/GuardedTelemetry.scala
  • modules/client/src/com/worxbend/codeberg4s/client/WireDecode.scala
  • modules/client/src/com/worxbend/codeberg4s/issues/IssueAttachmentApi.scala
  • modules/client/src/com/worxbend/codeberg4s/issues/IssueDecoders.scala
  • modules/client/src/com/worxbend/codeberg4s/miscellaneous/PlainText.scala
  • modules/client/src/com/worxbend/codeberg4s/organizations/actions/OrganizationActionDecoders.scala
  • modules/client/src/com/worxbend/codeberg4s/paging/PageWalk.scala
  • modules/client/src/com/worxbend/codeberg4s/repositories/actions/ActionDownloadApi.scala
  • modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApi.scala
  • modules/client/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionDecoders.scala
  • modules/client/src/com/worxbend/codeberg4s/repositories/admin/RepositoryAdminApi.scala
  • modules/client/src/com/worxbend/codeberg4s/repositories/gitdata/RepositoryGitApi.scala
  • modules/client/src/com/worxbend/codeberg4s/users/account/UserAccountDecoders.scala
  • modules/client/src/com/worxbend/codeberg4s/users/account/UserApplicationApi.scala
  • modules/client/src/com/worxbend/codeberg4s/users/social/SocialDecoders.scala
  • modules/client/src/com/worxbend/codeberg4s/users/social/UserTokenApi.scala
  • modules/client/test/src/com/worxbend/codeberg4s/CodebergClientSuite.scala
  • modules/client/test/src/com/worxbend/codeberg4s/TelemetryFailureSuite.scala
  • modules/client/test/src/com/worxbend/codeberg4s/client/FutureTimerSuite.scala
  • modules/client/test/src/com/worxbend/codeberg4s/paging/PageWalkSuite.scala
  • modules/client/test/src/com/worxbend/codeberg4s/repositories/actions/RepositoryActionApiSuite.scala
  • modules/client/test/src/com/worxbend/codeberg4s/users/account/UserApplicationApiSuite.scala
  • modules/client/test/src/com/worxbend/codeberg4s/users/social/UserTokenApiSuite.scala
  • modules/codec/src/com/worxbend/codeberg4s/codec/ArrayElements.scala
  • modules/codec/src/com/worxbend/codeberg4s/codec/Json.scala
  • modules/codec/src/com/worxbend/codeberg4s/codec/JsonDecoder.scala
  • modules/codec/src/com/worxbend/codeberg4s/codec/JsonFields.scala
  • modules/codec/src/com/worxbend/codeberg4s/codec/JsonValue.scala
  • modules/codec/src/com/worxbend/codeberg4s/codec/Timestamps.scala
  • modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireElements.scala
  • modules/codec/src/com/worxbend/codeberg4s/issues/wire/WireNumbers.scala
  • modules/codec/src/com/worxbend/codeberg4s/repositories/access/wire/BranchProtectionOptionDto.scala
  • modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/LanguageStatisticsDto.scala
  • modules/codec/src/com/worxbend/codeberg4s/repositories/admin/wire/MigrateRepoOptionsDto.scala
  • modules/codec/src/com/worxbend/codeberg4s/repositories/gitdata/wire/EditorConfigDto.scala
  • modules/codec/src/com/worxbend/codeberg4s/repositories/hooks/wire/HookWire.scala
  • modules/codec/src/com/worxbend/codeberg4s/repositories/wire/Elements.scala
  • modules/codec/src/com/worxbend/codeberg4s/repositories/wire/RepositoryContentDto.scala
  • modules/codec/test/src/com/worxbend/codeberg4s/codec/JsonFieldsSuite.scala
  • modules/codec/test/src/com/worxbend/codeberg4s/codec/JsonSuite.scala
  • modules/codec/test/src/com/worxbend/codeberg4s/codec/PropertyBaseProps.scala
  • modules/codec/test/src/com/worxbend/codeberg4s/codec/TimestampsSuite.scala
  • modules/core/src/com/worxbend/codeberg4s/core/ApiPipeline.scala
  • modules/core/src/com/worxbend/codeberg4s/core/BinaryHttpPort.scala
  • modules/core/src/com/worxbend/codeberg4s/core/BinaryResponse.scala
  • modules/core/src/com/worxbend/codeberg4s/core/CodebergRequest.scala
  • modules/core/src/com/worxbend/codeberg4s/core/CodebergResponse.scala
  • modules/core/src/com/worxbend/codeberg4s/core/Decode.scala
  • modules/core/src/com/worxbend/codeberg4s/core/HttpPort.scala
  • modules/core/src/com/worxbend/codeberg4s/core/LinkHeader.scala
  • modules/core/src/com/worxbend/codeberg4s/core/Pagination.scala
  • modules/core/src/com/worxbend/codeberg4s/core/Redaction.scala
  • modules/core/src/com/worxbend/codeberg4s/core/RequestBody.scala
  • modules/core/src/com/worxbend/codeberg4s/core/ResponseBody.scala
  • modules/core/src/com/worxbend/codeberg4s/core/RetryEngine.scala
  • modules/core/src/com/worxbend/codeberg4s/core/Telemetry.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/ApiPipelineSuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/BinaryPipelineSuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/ByteEqualitySuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/CodebergResponsePagingSuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/CodebergResponseSuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/PagesSuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/PaginationProps.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/PaginationSuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/RecordingTelemetry.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/RedactionSuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/ResponseBodySuite.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/RetryEngineProps.scala
  • modules/core/test/src/com/worxbend/codeberg4s/core/RetryEngineSuite.scala
  • modules/domain/src/com/worxbend/codeberg4s/ApiErrorBody.scala
  • modules/domain/src/com/worxbend/codeberg4s/BaseUri.scala
  • modules/domain/src/com/worxbend/codeberg4s/CodebergConfig.scala
  • modules/domain/src/com/worxbend/codeberg4s/CodebergError.scala
  • modules/domain/src/com/worxbend/codeberg4s/ContentType.scala
  • modules/domain/src/com/worxbend/codeberg4s/PathSegment.scala
  • modules/domain/src/com/worxbend/codeberg4s/ServerVersion.scala
  • modules/domain/src/com/worxbend/codeberg4s/TransportCause.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/Comment.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/Issue.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/IssueAttachment.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/IssueDeadline.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/IssueSubscription.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/Label.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/LabelColor.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/Milestone.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/Reaction.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/TimelineEvent.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/TrackedTime.scala
  • modules/domain/src/com/worxbend/codeberg4s/issues/UploadAttachment.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/GitignoreTemplate.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/LicenseTemplate.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/LicenseTemplateSummary.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/NodeInfo.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/RenderedMarkdown.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/ServerApiSettings.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/ServerAttachmentSettings.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/ServerRepositorySettings.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/ServerUiSettings.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/SigningKey.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/SshSigningKey.scala
  • modules/domain/src/com/worxbend/codeberg4s/miscellaneous/TemplateLabel.scala
  • modules/domain/src/com/worxbend/codeberg4s/notifications/NotificationSubject.scala
  • modules/domain/src/com/worxbend/codeberg4s/notifications/NotificationThread.scala
  • modules/domain/src/com/worxbend/codeberg4s/organizations/BlockedUser.scala
  • modules/domain/src/com/worxbend/codeberg4s/organizations/OrgName.scala
  • modules/domain/src/com/worxbend/codeberg4s/organizations/Organization.scala
  • modules/domain/src/com/worxbend/codeberg4s/organizations/OrganizationPermissions.scala
  • modules/domain/src/com/worxbend/codeberg4s/organizations/QuotaInfo.scala
  • modules/domain/src/com/worxbend/codeberg4s/organizations/QuotaUsedItems.scala
  • modules/domain/src/com/worxbend/codeberg4s/organizations/Team.scala
  • modules/domain/src/com/worxbend/codeberg4s/pulls/ChangedFile.scala
  • modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequest.scala
  • modules/domain/src/com/worxbend/codeberg4s/pulls/PullRequestBranch.scala
  • modules/domain/src/com/worxbend/codeberg4s/pulls/Review.scala
  • modules/domain/src/com/worxbend/codeberg4s/pulls/ReviewComment.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/ArchiveDownloadCount.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/Branch.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/BranchName.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/Commit.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/CommitDetails.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/CommitFile.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/CommitRef.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/CommitStats.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/CommitSummary.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/CommitVerification.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/ContentMeta.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/ContentPath.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/GitIdentity.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/Owner.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/PathSegment.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/Release.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/ReleaseAsset.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/RepoName.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/Repository.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/RepositoryPermissions.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/Tag.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/TagName.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/access/AccessNames.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/access/BranchProtection.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/access/CollaboratorAccess.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/access/DeployKey.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/access/TagProtection.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionArtifact.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionNames.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionRun.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionRunJob.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionRunner.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionSecret.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionTask.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/ActionVariable.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/DispatchedWorkflowRun.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/RegisteredRunner.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/actions/RunnerRegistrationToken.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/admin/AdminIds.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/admin/FileCommands.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/admin/LanguageBreakdown.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/admin/PushMirror.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/admin/RepositoryActivity.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/admin/WatchStatus.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/AnnotatedTag.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/CombinedCommitStatus.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/CommitComparison.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/CommitStatus.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/CompareRange.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/EditorConfigDefinitions.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/FileChange.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/FileCommit.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/GitBlob.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/GitNote.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/GitObjectRef.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/GitReference.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/GitTreeEntry.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/gitdata/RefName.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/GitHook.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/HookIds.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/IssueConfig.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/IssueTemplate.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/RepositoryFlag.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/Webhook.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/WikiCommit.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/WikiPage.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/hooks/WikiPageName.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/Topic.scala
  • modules/domain/src/com/worxbend/codeberg4s/repositories/publishing/UploadAsset.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/PublicKey.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/User.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/Username.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/account/ClientSecret.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/account/Email.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/account/OAuth2Application.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/account/QuotaInfo.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/account/QuotaUsage.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/account/UserSettings.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/social/AccessToken.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/social/BlockedUser.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/social/GpgKey.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/social/HeatmapEntry.scala
  • modules/domain/src/com/worxbend/codeberg4s/users/social/StopWatch.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/BaseUriSuite.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/CodebergConfigSuite.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/IdentifierProps.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/TransportCauseSuite.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/auth/SecretProps.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/issues/IssueTailCommandSuite.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/issues/LabelColorSuite.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/repositories/publishing/UploadAssetSuite.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/users/UsernameSuite.scala
  • modules/domain/test/src/com/worxbend/codeberg4s/users/social/SocialDomainSuite.scala
  • modules/examples/src/com/worxbend/codeberg4s/examples/HandlingErrors.scala
  • modules/examples/src/com/worxbend/codeberg4s/examples/SharingABackend.scala
  • modules/it/src/com/worxbend/codeberg4s/it/IntegrationConfig.scala
  • modules/transport/src/com/worxbend/codeberg4s/transport/SttpHttpPort.scala
  • modules/transport/test/src/com/worxbend/codeberg4s/transport/DefaultBackendSuite.scala
  • modules/transport/test/src/com/worxbend/codeberg4s/transport/RequestBodySuite.scala
  • modules/transport/test/src/com/worxbend/codeberg4s/transport/SttpHttpPortSuite.scala
  • scripts/alloc-bench.sc
  • scripts/alloc-bench.sh
  • site/src/guides/03-errors.md
  • site/src/guides/07-testing-your-code.md
  • site/src/guides/09-self-hosted.md
  • verify.sh

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

CI has never passed on this repository. The first run against this
branch failed twice, and both causes predate it — the same failures
are on main.

The verify job died at the coverage step. `scripts/coverage-gate.sc`
and `scripts/crap.sc` are scala-cli scripts, and scala-cli compiles
through a Bloop server by default. Starting one means downloading an
entire Scala 2.12 toolchain — bloop-frontend, monix, scalaz and the
rest — from repositories that answer slowly or not at all, and the
handshake times out before it finishes. The error surfaces as
"FAILED at step 7: coverage thresholds", which points at the gate
rather than at the compiler that never started.

`scripts/site.sh` already knew about this and passes `--server=false`,
which makes scala-cli compile in-process. The two calls in verify.sh
did not, and neither did the allocation harness. They do now. A script
of a few hundred lines does not need a build server, and the flag
costs nothing locally: the full gate still runs in well under two
minutes.

The site job died sooner and more plainly — `'cs' is not on PATH, and
this script needs it`. The shared toolchain action installs scala-cli
and warms a Coursier *cache*, which is not the same thing as putting
the Coursier *CLI* on PATH, and site.sh calls `cs fetch` twice to
resolve the mdoc and scaladoc classpaths. The action now installs it,
pinned by version and by sha256 exactly as scala-cli already is, since
this action also runs in the job that holds the signing key.

The digest was taken by downloading the v2.1.24 x86_64-linux asset and
running the binary, not copied from anywhere.
@w0rxbend
w0rxbend merged commit 6cb2ba8 into main Aug 9, 2026
5 checks passed
@w0rxbend
w0rxbend deleted the perf/hardening-pass branch August 9, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant