Skip to content

Release: develop -> main - #4583

Merged
TaprootFreak merged 5 commits into
mainfrom
develop
Aug 1, 2026
Merged

Release: develop -> main#4583
TaprootFreak merged 5 commits into
mainfrom
develop

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

* chore(ci): pin the files that reached full coverage

The ratchet run reports files that reach 100 percent on all four metrics but are
not guarded yet, so a later change can erode them without turning the gate red.
Fourteen had accumulated: ten carrying logic, four declarative.

Only the fiat controller comes from recent work on the specification cache; the
other thirteen were completed by other pull requests and reported since.

* docs(ci): re-derive the pinned counts in the coverage gate document

The document quoted the pinned count in five places and split it into
logic-carrying and declarative files. Those numbers had drifted before this
change already: the document said 422 while the config held 425.

They are now counted from the config rather than incremented. The measured class
counts under "Current state" stay as they are — they name the commit they were
measured at, and deriving a new Partial figure by subtraction would invent a
number nobody measured. That table's Complete cell now says so explicitly.
…4582)

* Fall back to the collection account only on infrastructure failures

The collection-account fallback swallowed every issuance error with `.catch(() => null)`, including the authoritative KYC rejection that issuance raises under its own lock against the freshly loaded, merge-resolved owner. The fallback then re-checked only the request's own, possibly stale KYC-50 snapshot, so a customer the locked check had just rejected could still receive a usable collection account with a reference. Only degrade to the fallback on an infrastructure failure; re-throw business rejections (BadRequestException, above all KYC_REQUIRED) so they reach the caller. Applies to both the explicit and the implicit Frick issuance paths. A test covers the discrepancy case.

* Test the explicit path and reorder the helper

Cover the explicit personal-IBAN path with the same business-rejection test the implicit path already has: a KYC_REQUIRED raised by issuance for a level-50 request must reach the caller and never reach the collection-account fallback. Move the infrastructure-vs-business helper above the fallback method so each comment sits directly over the method it describes.
…4584)

* fix(asset): write decimals through the service that owns the cache

The hourly decimals job wrote through the repository instance built by
RepositoryFactory, while every reader goes through AssetService and its own
instance. invalidateCache() only clears the instance it is called on, so the
freshly written decimals stayed invisible to readers until the entry expired.

The write now goes through AssetService, which invalidates the cache the readers
are served from. updatePrices did exactly that already and was the only caller
of its kind, so it is renamed to updateAssets and shared instead of copied.

The job also collects its updates and writes once: it runs hourly and usually
finds nothing, so invalidating unconditionally would drop the whole asset cache
every hour for no reason.

* fix(asset): invalidate the cache even when an update fails

Batching the writes had given up the failure isolation the per-asset loop used
to have: if one update threw, invalidateCache() was never reached and the rows
written before it stayed out of the cache — the exact staleness this change
exists to remove.

The invalidation now sits in a finally block, so it also runs when an update
fails. The empty case is guarded in the service instead of at the call site,
which makes it hold for every caller: the price job passed an empty list every
five minutes and dropped the whole asset cache for nothing.

* fix(asset): attempt every update instead of stopping at the first failure

The finally block covered the cache but not the loop: the first failing update
still aborted the batch, so every asset queued behind it was skipped. Before
this change each asset was written on its own, and a row that keeps failing was
harmless — as a batch it would block all following assets on every run.

Every update is attempted now, failures are collected, the cache is invalidated
either way, and the collected ids are reported afterwards.

* fix(asset): keep the original errors when several updates fail

Collecting the failures had reduced them to text: the caller lost the type,
the stack and the code of the database error, and every cause after the first
was dropped. Whoever reads the log needs the error itself.

AggregateError carries all of them and names the affected ids in its message.
No dedicated error class with a failedIds field: neither caller inspects the
error, both hand it to the cron, so that would be structure without a consumer.
…ting each other (#4571)

* Bound the masking work, and stop the passes from eating each other

Four findings against what was just merged, all in the same log line.

The reason was masked whole before it was capped, twice over since the masking
became two passes. An exception message can be as large as the body it
interpolated a value from: 1 MiB held the thread for 663 ms, 20 MiB for 14.5 s.
Only the front is masked now, wide enough past the cap that a pattern starting
inside what stays visible is still seen whole - and a pattern length is dropped
from the end of what came back, because a pattern crossing the point where the
masking stopped arrives halved and its head would otherwise be what the shortening
pulls into view. The tail goes after the masking, not before it; before it, the
head moves into view with everything else.

The first of the two passes wrote the placeholders the second one reads. `***` is
a valid local part as far as the address pattern is concerned, so
`192.0.2.123<sep>@error.code` collapsed to `***` and took the diagnostic text with
it. The first pass writes markers the patterns cannot match instead, and they are
turned into the placeholders once both passes are done.

`format` collapsed the serialized section but never masked it, and `redact` walks
the values rather than the keys - so a body key that is an address reached the log
as one. The section goes through the same rendering as any other value now.

The status was read twice and could answer differently, which put a body under a
status it does not name; it is read once. A status in the accepted range does not
have to have a name, so the message falls back past it.

* Cover the plain key too, not only the one with a character in it

A body key reached the trace whether or not anything was placed inside it - the
serialization was never masked at all. The test said so only for the harder case.

* Close what a request could still forge, decode, or lose

Six findings across both reviews of this branch, four of them in what it added.

A request could send the markers the first masking pass writes. They survived
both passes untouched and were turned into `***` and `0x…` on the far side, a
redaction that never redacted anything. Each marker is now grown until it does
not occur in the value it is used on.

The body an exception carries names a status of its own, and that was never
compared to the one being sent: `new HttpException({ statusCode: 418 }, 400)`
shipped `{"statusCode":418}` under HTTP 400. The body is kept only when it either
names the status being sent or names none.

Masking the whole serialized section masked the keys but masked the values twice,
and the second time took their surroundings with them - a wallet address in front
of a domain became the whole string. The keys are rendered where the values
already are, in `redact`, and the section itself only needs collapsing again.

The tail dropped from a shortened reason was dropped even where it could not have
reached the visible part. It only goes where the shortening could bring it into
view.

Two more are older than this branch and unchanged by it, but they are in the same
lines and cheap to close. An address stayed readable whenever a letter or an
underscore stood next to it, because the boundary was against word characters
rather than digits - `ip192.0.2.123` is how one usually arrives. And a request
target carries its values percent-encoded, so `victim%40example.com` was an
address to everyone reading the line and to no pattern matching it; the segments
are decoded before they are masked.

* Take the second masking pass back out

The pass was added because removing what breaks a line can join two halves and
hide a pattern from a masking that runs after it. Closing that needed markers the
patterns could not match, and the markers needed to be chosen per value so a
request could not send them - which grew quadratically on a value full of them,
and could still be forged with a character that only disappears later.

The threat it defends against is a request hiding what it sent about itself. That
is not what this masking is for: it is here so a value nobody meant to log does
not end up in one, and it is best effort by construction. What a request must not
be able to do is put a second line in the log, and the removal alone does that.

So the machinery goes and the limit is written down instead: a wallet address
with a character inside it comes out as the longer hex run it becomes, which is
left alone on purpose because that is what a transaction hash looks like.

Two keys can render the same once they are masked. The second one kept the
entry and the first one disappeared; it gets its own now.

* Give an object key the guard a value already had

Masking the keys reopened the thing this branch is about. A value longer than
 is reported by length instead of masked, because the masking is
regex work over whatever it is handed; a key had no such guard and a request
chooses one as freely and as long as the body allows. A 20 MB key held the thread
for 20 seconds - the same shape as the reason, in the one place that had just
been given more to do.

It goes through the guard now, and is capped like the name it is.

The comment about the serialized section still said the keys were not reached.

* Count collisions instead of searching for them, and keep the path as it came

Renaming a key that rendered the same as an earlier one searched the object it
was building, once per candidate suffix: a thousand keys that all mask to the
same thing cost a hundred milliseconds and wrote past the section budget. They
are counted now, and what the rename adds is charged to the budget like anything
else. The object has no prototype either, so `constructor` and `toString` are
keys like any other rather than collisions with something it never held.

Decoding a path segment is how a pattern is recognized in it, not how the path is
rendered. `%2F` became a separator and `%3F` the start of a query, and the line
named a route that was never called. The decoded form is only what gets rendered
when something in it was masked.

A body carrying an accessor for `statusCode` is read again when it is serialized
and can answer differently then; only a plain value counts as naming a status.

The failure to send a response is reported inside the same guard as the rest of
the line, since it is as much a description of what happened and as unable to
travel back to the caller.

The previous commit's message lost the name `MAX_STRING` to a shell expansion:
the sentence should read that a value longer than `MAX_STRING` is reported by
length instead of masked.

* Render the section instead of its keys, and settle what is sent

Masking the keys where they are walked needed a length guard, then a collision
counter, then budget accounting for what the counter added - three rounds, and
the last one still let a real key named like a generated one take another entry.
The section is cut to its budget and rendered whole instead, which reaches the
keys in two lines and in an affordable size. A value is masked a second time that
way and the second time can take its surroundings with it; that is the direction
to be wrong in.

A path segment is read decoded and rendered as it arrived, unless something in it
was masked - and if the decoding would have put a separator or a query into the
path, nothing of it is rendered at all, because the line would otherwise name a
route with a segment more than the one that was called. Each escape is decoded on
its own, so one that is not a valid sequence no longer costs the whole segment.
The target itself is capped before any of that: reading it is regex work like
everything else here.

The body an exception carries is settled once before it is checked, so what is
sent is the thing that was checked - a body that answers differently the second
time it is asked cannot put a status in the response that the status line does
not have.

The error from a failed send is kept as what it is rather than asserted to be an
`Error`, and one that is falsy is still an error.

* Put back what develop already had, and stop widening the masking

Two of these are corrections of this branch rather than of what it set out to fix.

The masking on develop runs before and after the removal. This branch had taken
the second pass out on the grounds that it defended against a request hiding its
own data - which is true of one direction and not of the other: removing a
character also joins what stood on either side, and a wallet address followed by
one more hex character stops being one. Against develop that was a weakening, not
a simplification. It is back as develop has it, and what it costs - the second
pass folding `***` into an address of its own - is written down and tested.

Masking the object keys is out too, in every form it was tried: in `redact` with
a length guard, then a collision counter, then budget accounting for the counter;
and over the serialized section, which masks each value a second time. Each round
of review found the next thing wrong with it. A body key that is an address still
reaches the trace, exactly as it does today, and what to do about that is a
question about what this trace is for - the same question the encoded values in a
path raise, and the same one raised by tracing bodies that carry free customer
text at all. It does not belong in a change about bounding work.

The response body is checked as it is rather than settled first: making a copy of
it before checking added a full serialization of a body that has no bound, which
is the thing this branch is about.

What is left is what it set out to be: the reason is masked only as far as it is
shown, the request target is capped after being read rather than before, the
status is read once and the body has to name it, a failure to log cannot reach
the caller, and an address stays an address when a letter stands next to it.

* Take the replacement message out of the body, not out of the exception

Dropping a body that names another status put `exception.message` on the wire in
its place. That message is what an exception says to us; the body is what its
thrower wrote for the caller, and the two can differ - a thrower can set an
internal one on the exception and a public one in the body. Against develop and
against the framework's own filter, this branch was the only thing that would
have sent the first. The replacement message comes out of the body now, as a
plain string or a plain array of them, and out of the status when the body has
none.

Six comments and tests were behind the code. The two masking docstrings had the
passes the wrong way round or missing; the guard around the logging said the
response was already out, which is not true of the path where sending it failed;
and four properties had no test that would fail without them - the status being
read once, the accessor checks on the body, the cap on the request target coming
after the masking, and a failure to report a failed send staying inside the guard.

* Send only what the body would have sent itself

A property that is not enumerable is left out when the response is serialized, so
reading one to build the replacement message put something on the wire that would
never otherwise have been on it. Both the message and the status are now read the
way the serialization would read them: the body's own property, enumerable, and a
value rather than something asked again.

The timing bound in the reason test is looser. What it shows is the bound - the
same message costs seconds without it - and a limit that tight only measured how
busy the machine was.

* Give a plain error the same footing as a body

Taking the message helper out left the branch for everything that is not an
HttpException reading the exception directly and unguarded. An error that says
nothing about itself put an empty message on the wire in place of the name of
what is being sent, and one whose message cannot be read at all took the response
with it - the thing this branch exists to prevent. It goes through a reading that
can fail and falls back to the status, like every other message here.

* Read a body only where reading it says anything

A body that answers toJSON is serialized from what that returns, not from what it
holds. Judging it by its own properties therefore judged something that was never
going to be sent - and worse, a body holding an internal message and serializing
a public one was dropped for naming the wrong status and then rebuilt out of the
internal one. That is more than develop sends, which is the one thing this branch
must not be.

Such a body is not read at all now: neither for the status it names nor for the
message it carries, and what goes out in its place is the name of the status
being sent.

An exception message that is not text is not a message either. The other path
already checked; this one now does the same.

* Judge a body only where judging it means anything

Five ways the replacement body read something the wire would never have carried,
or replaced one the wire would have carried unchanged.

A status that had to be replaced now takes the body with it. What a thrower wrote
belongs to the status it wrote it for; reading its message for a different status
reads it for something it was not, and the message it wrote for the caller and the
one it kept for itself are not always the same. The generic body goes out instead.

A body that serializes itself is passed on rather than replaced. What it holds
says nothing about what it sends, so there is nothing here to judge - and building
a replacement out of what it holds was how the one thing this branch must not do
happened again.

`JSON.stringify` leaves out more than an accessor: a function, a symbol, and any
name put on an array alongside its elements. None of those name a status, so a
body carrying one is no longer replaced for contradicting a status it never
carried.

A sparse array is sent with its holes as `null`, which is not an array of strings.
`every` skips the holes; the check no longer does.

And asking what an exception is can throw - a proxy is enough - which left the
caller without a response at all. That question is inside the guard now.

* Read a body once, and send what was read

A status carried by an accessor is serialized, so the body does carry one - and it
answers again when it is, so what it would carry cannot be read here. That is not
agreement, and such a body is replaced rather than passed on. What the
serialization leaves out entirely - a name that is not enumerable, a function, a
symbol, a name put on an array alongside its elements - carries nothing and is
left alone, which is what the round before this one asked for.

`toJSON` is asked for as a descriptor rather than read. Reading it would be one
read and the serialization another, and something that answers differently
between them would be sent as whatever it holds.

A message array is copied out rather than passed on. Checking it with `Array.from`
ran whatever iterator it brought along, and what that runs can leave different
elements behind than the ones that were checked; `JSON.stringify` reads an array
by index, so that is how it is read here.

Deliberately not done: confirming the status a second time before the body is
passed on. It would close the case of a `getStatus` that answers differently on
the second call - but reading the status twice was itself a finding two rounds
ago, and what it defends against is an exception this code base builds against
itself.

* Take nothing out of a body that could not be passed on

Four rounds of review have all found the same shape: a field read off a body was
not the field the serialization would have sent. A message array with a `toJSON`
of its own, a `statusCode` that answers when asked, a hole in an array, a name a
prototype supplies. Each one was closed and the next one appeared, because the
premise was wrong - a body that cannot be passed on is a body whose contents say
nothing about what it would have sent, and reading it at all is reading something
else.

So it is not read. What goes out in its place is the status being sent and the
name of that status, and the reading of messages, arrays and descriptors that
served no other purpose goes with it - thirty-nine lines of it.

A body agrees with the status it is sent under unless it carries one of its own
that differs, and a value of `undefined` is not carried at all: the serialization
leaves it out like a function, a symbol, or a name put on an array.

What was thrown while sending a response is only turned into text if it already
is text. Anything else could hold what the body held, and the line that reports a
failed send is not the place to find that out.

* Name the question the way it is answered

The check returns true when the body agrees with the status being sent and also
when it says nothing about one, which is not what `names` reads as at the call
site.

* Say what the code does with what was thrown

Only a thrown `Error` is reported as what it is; a thrown string is replaced by a
fixed one like anything else. The comment said the opposite, and the fixed one
started lower case.

* Trust a toJSON that is simply there, not one that might answer

An accessor might answer with a function and might not. Taking it for one let a
body past the check that the status it names is the status being sent, and the
serialization then ignored what the accessor returned and sent what the body
holds - a status in the body that the status line does not have. Only a function
that is simply there serializes a body; a body that only might is judged like any
other.

Names: the effective status is called that rather than `sent`, which read like
the response had already gone out and collided with the flag that says whether it
had. The methods are verbs.

* Drop two tests that describe a mechanism there no longer is

Both said they were about what is read out of a message, and both used a body
whose status already differed from the one being sent - so the status decided and
the message never came into it. There is no path left that reads a message out of
a body at all: what cannot be passed on is replaced whole. What they were meant to
show is covered by the test that says exactly that.

* Use the nullish default the repository asks for

Five fallbacks fell back on falsiness where the name of a status is either there
or not there at all.

* Give the two questions the prefix the repository asks of them

Both answer yes or no, and the rule for that is the same for both.
* fix: omit Content-Type from Frick vIBAN GET requests

* fix: keep Frick transport comment parseable
@TaprootFreak
TaprootFreak merged commit facd08b into main Aug 1, 2026
15 checks passed
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