User entity and password storage (task 49) #26

Merged
rob merged 6 commits from feat/user-password-storage into main 2026-08-03 22:05:20 +00:00
Owner

User entity, IPasswordHasher/Pbkdf2PasswordHasher, and a read-only UserRepository.FindByEmailAsync. Recorded in ADR-0028.

The stored format is self-describing — $pbkdf2-sha256$i=600000$<b64 salt>$<b64 hash> — so verification reads the label, cost and salt from the stored value rather than from configuration. That is what allows the algorithm or its parameters to change later without invalidating existing passwords: Verify returns SucceededWithOutdatedHash and sign-in rewrites the hash.

Open to challenge:

  • No DI registration for either type, and no IUserRepository interface. Reasoning is in the ADR and in UserRepository's doc comment.
  • SeedData still writes NULL for password_hash, left to task 51.

Three things this hands to the tickets that follow, all recorded in the ADR:

  • Task 51 must act on SucceededWithOutdatedHash, or the upgrade path never runs and the format's whole purpose is unrealised.
  • Verify returns immediately when there is no stored hash, so sign-in must equalise the timing — verifying against a throwaway hash — or the endpoint enumerates users. An OIDC-only account has no hash, so this is reachable, not theoretical.
  • ADR-0007's IP rate limiting on auth endpoints is now load-bearing: one unauthenticated request costs 600,000 SHA-256 compressions, about 80 ms.

docs/adr/README.md will conflict with the PRs in flight — keep the rows in numeric order.

`User` entity, `IPasswordHasher`/`Pbkdf2PasswordHasher`, and a read-only `UserRepository.FindByEmailAsync`. Recorded in ADR-0028. The stored format is self-describing — `$pbkdf2-sha256$i=600000$<b64 salt>$<b64 hash>` — so verification reads the label, cost and salt from the stored value rather than from configuration. That is what allows the algorithm or its parameters to change later without invalidating existing passwords: `Verify` returns `SucceededWithOutdatedHash` and sign-in rewrites the hash. Open to challenge: - No DI registration for either type, and no `IUserRepository` interface. Reasoning is in the ADR and in `UserRepository`'s doc comment. - `SeedData` still writes `NULL` for `password_hash`, left to task 51. Three things this hands to the tickets that follow, all recorded in the ADR: - **Task 51 must act on `SucceededWithOutdatedHash`**, or the upgrade path never runs and the format's whole purpose is unrealised. - **`Verify` returns immediately when there is no stored hash**, so sign-in must equalise the timing — verifying against a throwaway hash — or the endpoint enumerates users. An OIDC-only account has no hash, so this is reachable, not theoretical. - **ADR-0007's IP rate limiting on auth endpoints is now load-bearing**: one unauthenticated request costs 600,000 SHA-256 compressions, about 80 ms. `docs/adr/README.md` will conflict with the PRs in flight — keep the rows in numeric order.
Add the User entity and PBKDF2 password storage
All checks were successful
CI / build (pull_request) Successful in 54s
0724dcaace
Ticket #49 asks for hashing that is salted and adaptive, and names three
candidates. ADR-0028 chooses the in-box `Rfc2898DeriveBytes.Pbkdf2` —
PBKDF2-HMAC-SHA256 at OWASP's 600,000 iterations, a 128-bit salt per
password, 256 bits out — so no NuGet package lands on the credential
path and `PlaceMark.Domain` goes on referencing nothing. Argon2id and
bcrypt lost on the third-party dependency, and bcrypt again on its silent
72-byte truncation, which a test now pins.

The part worth reviewing is the stored format, not the algorithm:

    $pbkdf2-sha256$i=600000$<base64 salt>$<base64 hash>

Verification reads the label, the cost and the salt out of the value it
was handed rather than from the constants in the class, so raising the
iteration count is a one-line change that locks nobody out — each hash
keeps verifying under the count it was written with, and `Verify` returns
`SucceededWithOutdatedHash` so that sign-in can replace it at the one
moment the plaintext exists. Changing algorithm is one more label. The
failure this avoids is `salt:hash` with the cost only in code, which
works until the constant moves and then locks out every account at once,
irrecoverably, with both halves of the code still agreeing with each
other.

`User` is a class rather than a record on purpose: a record's generated
`ToString` prints every property, so `{User}` in a log template would put
the hash in the console from a declaration keyword, with no call site
doing anything wrong. `UserTests` fails if that changes. Nothing added
here takes an `ILogger`, no argument reaches an exception message — the
two guards used name the parameter and never the value — and an
unreadable stored value is refused rather than thrown on, so it cannot
reach the ADR-0022 handler and be logged there.

`UserRepository` reads only. Registration writes the user, its personal
group and that group's Owner membership in one transaction (ADR-0004), so
a write here owning its own connection could not take part in it; that
belongs to #50 with the transaction. Nothing is registered for dependency
injection yet, for the same reason: no endpoint consumes either type.

The seed still writes a NULL `password_hash`. ADR-0026 pointed at this
ticket for a real one; it is deliberately left to #51, where signing in as
a seeded user is testable rather than merely assertable, and where
publishing a known development credential beside its plaintext can be
decided on its own.
Author
Owner

Pbkdf2PasswordHasher.TryParse bounds the iteration count below but not above — i=100000000 measured at 14 s of single-threaded CPU per Verify on this machine, i=2147483647 extrapolates to ~5.5 min; cap it at a small multiple of Iterations and fail closed above that.

Verify passes expectedHash.Length to Pbkdf2 as the derived-key length with no cap, so a 4 MiB hash segment allocates 3 MiB in TryDecodeBase64 and then derives 131,072 blocks × 600,000 iterations (had not returned after 8 s) — bound the decoded length.

Truncating a stored hash returns SucceededWithOutdatedHash all the way down to one byte (measured at 16/8/4/2/1), because the comparison width comes from the stored value: any password matching an 8-bit prefix authenticates. Reject a decoded hash below a floor (16 bytes) rather than merely reporting it outdated, and the same for the salt. All three reach the parser only through the column, so they need database write or a partial-write injection first — but the class documents itself as failing closed on an unreadable stored value, and these are the three fields where it does not.

ADR-0028 lines 91–94 claim the population converts itself when the algorithm changes; it will not — this class returns Succeeded for a current-cost PBKDF2 hash, so a future Argon2id default has to re-map any non-preferred label to SucceededWithOutdatedHash itself. Say that in the ADR, since it is the one part of the upgrade story no test can hold.

The two duties this hands to #51 — rewrite on SucceededWithOutdatedHash, and equalise the timing of the no-stored-hash branch — live only in this PR body and the ADR; Vikunja #51's description mentions neither, and a merged PR body is not where the next agent looks.

ADR-0028 justifies 600,000 (OWASP) and the 32-byte output (extra pass per block, no added strength) but only states the 128-bit salt — cite NIST SP 800-132 §5.1 so all three are justified rather than two.

Both open decisions are right: nothing consumes either type yet so the lifetime belongs with #50/#51, and UserRepository has no second implementation and is tested against PostgreSQL, so an interface would be a seam nothing pulls.

Verdict: changes required

`Pbkdf2PasswordHasher.TryParse` bounds the iteration count below but not above — `i=100000000` measured at 14 s of single-threaded CPU per `Verify` on this machine, `i=2147483647` extrapolates to ~5.5 min; cap it at a small multiple of `Iterations` and fail closed above that. `Verify` passes `expectedHash.Length` to `Pbkdf2` as the derived-key length with no cap, so a 4 MiB hash segment allocates 3 MiB in `TryDecodeBase64` and then derives 131,072 blocks × 600,000 iterations (had not returned after 8 s) — bound the decoded length. Truncating a stored hash returns `SucceededWithOutdatedHash` all the way down to one byte (measured at 16/8/4/2/1), because the comparison width comes from the stored value: any password matching an 8-bit prefix authenticates. Reject a decoded hash below a floor (16 bytes) rather than merely reporting it outdated, and the same for the salt. All three reach the parser only through the column, so they need database write or a partial-write injection first — but the class documents itself as failing closed on an unreadable stored value, and these are the three fields where it does not. ADR-0028 lines 91–94 claim the population converts itself when the algorithm changes; it will not — this class returns `Succeeded` for a current-cost PBKDF2 hash, so a future Argon2id default has to re-map any non-preferred label to `SucceededWithOutdatedHash` itself. Say that in the ADR, since it is the one part of the upgrade story no test can hold. The two duties this hands to #51 — rewrite on `SucceededWithOutdatedHash`, and equalise the timing of the no-stored-hash branch — live only in this PR body and the ADR; Vikunja #51's description mentions neither, and a merged PR body is not where the next agent looks. ADR-0028 justifies 600,000 (OWASP) and the 32-byte output (extra pass per block, no added strength) but only states the 128-bit salt — cite NIST SP 800-132 §5.1 so all three are justified rather than two. Both open decisions are right: nothing consumes either type yet so the lifetime belongs with #50/#51, and `UserRepository` has no second implementation and is tested against PostgreSQL, so an interface would be a seam nothing pulls. Verdict: changes required
Bound every field the hash parser reads, and accept only canonical values
All checks were successful
CI / build (pull_request) Successful in 48s
b4bbf80a47
Three fields came out of `users.password_hash` unbounded, and each let the
column decide something the class had no business delegating.

The iteration count was floored at 1 and capped nowhere, so a stored
`i=2147483647` asked for about five minutes of one core per `Verify`. It
is now capped at four times the current cost — a multiple, so raising the
cost raises the ceiling with it.

The stored hash's length is the derived-key length handed to PBKDF2, so an
enormous segment allocated megabytes and then derived a block per 32 bytes
of it. Salt and hash are now bounded at 64 bytes, and the whole value is
refused above 256 characters before it is split, decoded or allocated.

The sharpest was the floor. The comparison is exactly as wide as the
stored value, so a hash truncated to one byte returned
`SucceededWithOutdatedHash` for any password matching its first eight
bits — a partial write or a narrowed column was an authentication bypass
rather than a broken row. Both fields now have a 16-byte floor, refused
outright rather than reported outdated: NIST SP 800-132 §5.1 for the
salt, and eight bits of agreement not being a credential for the hash.

The general fix is the last check rather than any of those. The parser
re-renders what it parsed and requires it back character for character, so
it accepts only what this hasher would itself have written. That answers
what else it used to accept, which turned out to be a padded iteration
count, whitespace inside base64 — .NET's decoder skips it — and base64
whose unused trailing bits differ, all of which decode to the right bytes
and were therefore honoured. One credential with several spellings is a
column that cannot be reasoned about by comparing it.

Thirteen new cases, each of which fails against the parent commit.

ADR-0028 gains the bounds and their reasons, NIST SP 800-132 §5.1 for the
salt length that was previously the only unjustified number, and a
correction: it claimed the population converts itself when the algorithm
changes. It does not. This class answers `Succeeded` for a current-cost
PBKDF2 hash, so a successor preferring Argon2id has to decide for itself
that the old label is outdated — one condition, in code nobody has
written, whose absence looks like nothing at all.

The two duties this hands to #51 — rewrite on `SucceededWithOutdatedHash`,
and equalise the timing of the no-stored-hash branch — are now a comment
on that ticket rather than only on a pull request nobody will reread.
Author
Owner

All three bounds are in at b4bbf80, plus the general fix behind them: the parser re-renders what it parsed and requires it back character for character, so it accepts only what this hasher would have written — which also closes the padded iteration count, whitespace inside base64 and non-canonical trailing bits it used to honour. Thirteen new cases, each failing against 0724dca.

ADR-0028 carries the bounds and their reasons, NIST SP 800-132 §5.1 for the salt, and the correction that a successor preferring Argon2id must mark the old label outdated itself; the two duties for #51 are now a comment on that ticket.

All three bounds are in at `b4bbf80`, plus the general fix behind them: the parser re-renders what it parsed and requires it back character for character, so it accepts only what this hasher would have written — which also closes the padded iteration count, whitespace inside base64 and non-canonical trailing bits it used to honour. Thirteen new cases, each failing against `0724dca`. ADR-0028 carries the bounds and their reasons, NIST SP 800-132 §5.1 for the salt, and the correction that a successor preferring Argon2id must mark the old label outdated itself; the two duties for #51 are now a comment on that ticket.
Author
Owner

Paused. No verdict on b4bbf80 — the re-review was stopped before it reported. Resume by reviewing 0724dca..b4bbf80, checking in particular that the new canonical-form requirement cannot reject a value the hasher itself produces.

Paused. No verdict on `b4bbf80` — the re-review was stopped before it reported. Resume by reviewing `0724dca..b4bbf80`, checking in particular that the new canonical-form requirement cannot reject a value the hasher itself produces.
Author
Owner

ADR-0028's new DoS sentence understates the bound by 2×: the worst accepted stored value measured 8.02× a sign-in here (81 ms at i=600000/32 B, 651 ms at i=2400000/64 B), because a 64-byte hash is two PBKDF2 blocks. The ceiling is the product of MaximumIterations and MaximumKeyBytes, not the iteration multiple alone — say eight, or state it as the product.

Verify dropped salt.Length < SaltBytes from the outdated test, and MinimumSaltBytes's doc points the next editor at raising the floor with SaltBytes. Doing that refuses every stored row written with a 16-byte salt; not doing it means salts silently never upgrade, since a short-salt hash then reports Succeeded. Restore the clause — it is correct under either edit — and reword the comment to say the floor must not move.

docs/adr/README.md conflicts with main at 75cf44e; rebase before merging.

Verdict: changes required

ADR-0028's new DoS sentence understates the bound by 2×: the worst accepted stored value measured 8.02× a sign-in here (81 ms at `i=600000`/32 B, 651 ms at `i=2400000`/64 B), because a 64-byte hash is two PBKDF2 blocks. The ceiling is the product of `MaximumIterations` and `MaximumKeyBytes`, not the iteration multiple alone — say eight, or state it as the product. `Verify` dropped `salt.Length < SaltBytes` from the outdated test, and `MinimumSaltBytes`'s doc points the next editor at raising the floor with `SaltBytes`. Doing that refuses every stored row written with a 16-byte salt; not doing it means salts silently never upgrade, since a short-salt hash then reports `Succeeded`. Restore the clause — it is correct under either edit — and reword the comment to say the floor must not move. `docs/adr/README.md` conflicts with `main` at `75cf44e`; rebase before merging. Verdict: changes required
Only `docs/adr/README.md` conflicted, both sides having appended a row to
the index table: ADR-0028 from this branch and ADR-0029 from #27. Both
rows are kept, in numeric order.
Restore the salt clause, and state the work ceiling as the product
All checks were successful
CI / build (pull_request) Successful in 47s
58fb23f665
`Verify` had dropped `salt.Length < SaltBytes` from the outdated test on
the grounds that nothing shorter than the floor is accepted at all, which
is true and was the wrong conclusion. The clause is what upgrades stored
salts the day `SaltBytes` is raised, and without it a short-salt hash
would report `Succeeded` and never be rewritten. It is restored, inert
while the two constants are both 16, with a comment saying that deleting
it is how salts silently stop upgrading.

`MinimumSaltBytes` said the opposite of what it should: it pointed the
next editor at raising the floor alongside `SaltBytes`, and doing that
refuses every row already written with a 16-byte salt — every account
locked out at once. It now says the floor must not move, and why a floor
and a ceiling differ in that respect: a ceiling is policy and may be
raised freely, a floor is what the existing rows already satisfy.

ADR-0028's denial-of-service ceiling understated itself by 2×. The bound
is the product of `MaximumIterations` and `MaximumKeyBytes` — four times
the cost, and 64 bytes is two PBKDF2 blocks where 32 is one — so the worst
accepted stored value is about eight sign-ins' worth of work, 651 ms
against 81 ms, not four.
Author
Owner

All three actioned at 58fb23f: the salt clause is back in the outdated test (inert while both constants are 16, with a comment saying deleting it is how salts stop upgrading), MinimumSaltBytes now says the floor must not move and why a floor differs from a ceiling, and ADR-0028 states the work ceiling as the product of MaximumIterations and MaximumKeyBytes — about eight sign-ins, 651 ms against 81 ms.

main is merged in at e984294; the only conflict was the ADR index, and both rows are kept in numeric order.

All three actioned at `58fb23f`: the salt clause is back in the outdated test (inert while both constants are 16, with a comment saying deleting it is how salts stop upgrading), `MinimumSaltBytes` now says the floor must not move and why a floor differs from a ceiling, and ADR-0028 states the work ceiling as the product of `MaximumIterations` and `MaximumKeyBytes` — about eight sign-ins, 651 ms against 81 ms. `main` is merged in at `e984294`; the only conflict was the ADR index, and both rows are kept in numeric order.
Author
Owner

The floor/ceiling rule has a gap in the one direction that locks people out: MaximumIterations is not independently settable, so lowering Iterations lowers a ceiling below what existing rows already carry. Built with Iterations = 100_000, a row written at i=600000 verifies as Failed — every account locked out, permanently, from an edit the constant's own doc frames as free ("raising it is a one-line change"). Cutting the cost on a small box is the plausible edit, and it needs the same warning the salt floor now has: Iterations may be raised, but never lowered below a quarter of the highest count already written.

Verdict: changes required

The floor/ceiling rule has a gap in the one direction that locks people out: `MaximumIterations` is not independently settable, so lowering `Iterations` lowers a ceiling below what existing rows already carry. Built with `Iterations = 100_000`, a row written at `i=600000` verifies as `Failed` — every account locked out, permanently, from an edit the constant's own doc frames as free ("raising it is a one-line change"). Cutting the cost on a small box is the plausible edit, and it needs the same warning the salt floor now has: `Iterations` may be raised, but never lowered below a quarter of the highest count already written. Verdict: changes required
Say that a bound may only move in the widening direction
All checks were successful
CI / build (pull_request) Successful in 47s
408cf09b56
The floor-and-ceiling rule was wrong about `MaximumIterations`, which is
four times `Iterations` and therefore not policy at all: lowering the cost
lowers the ceiling with it and refuses every row written at the old one.
A cost reduction reads like a performance change and would arrive as a
total lockout.

So the rule is now about direction rather than about which end of the
range a number sits at: a bound may only be moved in the direction that
widens what verifies — a floor down, a ceiling up — and a bound derived
from another value is only as safe as the direction that value moves in.
`Iterations` says what to do if the cost ever has to come down (pin the
ceiling to a literal at or above the highest count ever written), and
`SaltBytes` and `HashBytes` say they must not fall below their floors,
which would make this hasher write values it then refuses.

The same class of defect was in `MaximumStoredHashLength`, which was a
round 256 justified by "the bounds above permit 205". A literal there is a
ceiling that does not track the ceilings it exists to accommodate:
raising `MaximumKeyBytes` would be a widening everywhere except that line,
and the refusal would come from the one bound nobody edited. It is now
computed from `MaximumKeyBytes` — 205, exactly the longest value the other
bounds permit — and a test verifies a stored hash sitting on every ceiling
at once, which fails if the cap ever stops keeping up.
Author
Owner

Actioned at 408cf09: the rule is now "a bound may only move in the direction that widens what verifies", with the derived case spelled out on Iterations/MaximumIterations; the same defect in MaximumStoredHashLength (a literal 256 that would not have tracked a raised MaximumKeyBytes) is fixed by deriving it — 205 — and a new test verifies a stored hash sitting on every ceiling at once, which fails if the cap stops keeping up.

Actioned at `408cf09`: the rule is now "a bound may only move in the direction that widens what verifies", with the derived case spelled out on `Iterations`/`MaximumIterations`; the same defect in `MaximumStoredHashLength` (a literal 256 that would not have tracked a raised `MaximumKeyBytes`) is fixed by deriving it — 205 — and a new test verifies a stored hash sitting on every ceiling at once, which fails if the cap stops keeping up.
Author
Owner

SaltBytes "may be raised freely" is false, and it is the interaction the directional rule still misses: both written lengths are bounded above by MaximumKeyBytes as well as below by their floors. Built with SaltBytes = 128, and separately HashBytes = 128, the hasher's own output verifies as Failed — 64 is the last value that works. Say "up to MaximumKeyBytes" in both doc comments.

205 is not "exactly the longest value the bounds above permit" — that is 202, since MaximumIterations allows seven digits, not int's ten. The slack is right and load-bearing (it survives a raised Iterations), which is why calling it exact matters: it invites the tightening to 202 that would then refuse an eight-digit count. Drop "exactly" rather than the slack.

Verify_AStoredHashAtEveryCeilingAtOnce_Succeeds does not catch a cap that has stopped keeping up, contrary to its comment: its 2_400_000/64/64 are literals, not the constants. With MaximumKeyBytes = 128 and the cap pinned back to a literal 205, that value still Succeeded while a value at the actual raised ceiling Failed. The derivation is what closes this; the test guards today's numbers only, so say that.

Verdict: changes required

`SaltBytes` "may be raised freely" is false, and it is the interaction the directional rule still misses: both written lengths are bounded above by `MaximumKeyBytes` as well as below by their floors. Built with `SaltBytes = 128`, and separately `HashBytes = 128`, the hasher's own output verifies as `Failed` — 64 is the last value that works. Say "up to `MaximumKeyBytes`" in both doc comments. 205 is not "exactly the longest value the bounds above permit" — that is 202, since `MaximumIterations` allows seven digits, not `int`'s ten. The slack is right and load-bearing (it survives a raised `Iterations`), which is why calling it exact matters: it invites the tightening to 202 that would then refuse an eight-digit count. Drop "exactly" rather than the slack. `Verify_AStoredHashAtEveryCeilingAtOnce_Succeeds` does not catch a cap that has stopped keeping up, contrary to its comment: its `2_400_000/64/64` are literals, not the constants. With `MaximumKeyBytes = 128` and the cap pinned back to a literal 205, that value still `Succeeded` while a value at the actual raised ceiling `Failed`. The derivation is what closes this; the test guards today's numbers only, so say that. Verdict: changes required
Cut every claim about this hasher back to what it does
All checks were successful
CI / build (pull_request) Successful in 45s
71296d4e02
Prose only; no behaviour changes.

The three reported. `SaltBytes` and `HashBytes` said they may be raised
freely, which is false in the direction that matters: both are bounded
above by `MaximumKeyBytes`, and at 128 the hasher's own output verifies as
Failed. `MaximumStoredHashLength` called 205 exactly the longest value the
other bounds permit; that is 202, because `MaximumIterations` is seven
digits and not `int`'s ten. The slack is deliberate — it survives a raised
cost — so the doc now says why tightening it to 202 would be the mistake.
And `Verify_AStoredHashAtEveryCeilingAtOnce_Succeeds` claimed to catch a
cap that had stopped keeping up; its numbers are literals, so it guards
today's ceilings only, and what closes the general case is the cap being
computed. It says so.

Then the sweep, which found four more. The parser was twice described as
accepting "only what this hasher would itself have written" — it accepts
any parameters within the bounds, and it is the spelling that is fixed;
that distinction is the whole reason an old hash still verifies. "A hash
written under a higher cost is left alone rather than rewritten downwards"
held only below `MaximumIterations`; above it the value is refused. "What
the format cannot absorb is a change to the delimiter or the segment
count" understated the canonical-form requirement, which fixes the whole
spelling of a `pbkdf2-sha256` value. And the test class claimed a
self-consistent format change is "every change that breaks every password
already in the database", which round four disproved: lowering a parameter
a bound derives from does it too, and no test holds that.

Four smaller corrections: the written value is 93 characters, not "around
95"; sixteen bytes is accepted and reported outdated, where the text read
as though it were refused; a sign-in is 600,000 HMAC iterations, twice
that many compressions; and "raising the iteration count" offers an
upgrade rather than performing one, since nothing rewrites the column
until #51 does.
Author
Owner

All three corrected at 71296d4, plus eight more the sweep found: the parser accepts a fixed spelling rather than "only what this hasher would have written" (twice), a higher-cost hash is only left alone below MaximumIterations, the canonical form fixes more than the delimiter and segment count, the test class overclaimed that a self-consistent format change is the only way to break stored passwords, the written value is 93 characters not ~95, sixteen bytes is accepted-and-outdated rather than refused, a sign-in is 600,000 HMAC iterations rather than that many compressions, and raising the cost offers an upgrade rather than performing one. Prose only; no behaviour changed.

All three corrected at `71296d4`, plus eight more the sweep found: the parser accepts a fixed *spelling* rather than "only what this hasher would have written" (twice), a higher-cost hash is only left alone below `MaximumIterations`, the canonical form fixes more than the delimiter and segment count, the test class overclaimed that a self-consistent format change is the only way to break stored passwords, the written value is 93 characters not ~95, sixteen bytes is accepted-and-outdated rather than refused, a sign-in is 600,000 HMAC iterations rather than that many compressions, and raising the cost offers an upgrade rather than performing one. Prose only; no behaviour changed.
Author
Owner

Nothing to act on at 71296d4: every claim checks out, and the diff is comment-only — non-comment lines are byte-identical in all three source files.

Verdict: mergeable

Nothing to act on at `71296d4`: every claim checks out, and the diff is comment-only — non-comment lines are byte-identical in all three source files. Verdict: mergeable
rob merged commit cba603ca32 into main 2026-08-03 22:05:20 +00:00
rob referenced this pull request from a commit 2026-08-03 22:05:21 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!26
No description provided.