Commit Graph
4485 Commits
Author SHA1 Message Date
Marc-André Bombeck 3bb08c3c8a fix(settings): hold the channel test until changes are saved
The webhook, ntfy, email and Telegram test routes send through the
saved channel config. After a field changed, the card kept showing the
Saved note from the last save and Test still ran, so it reported on the
old settings: a webhook switched to the Gotify format was tested with
the generic body and Gotify refused it. The note now disappears on a
change, and the test button stays off with a save-first hint while the
form differs from what is saved. Refs #947
2026-09-13 18:28:47 +02:00
Marc-André Bombeck 669058d37c Merge the review fixes for channel tests 2026-09-13 15:17:36 +02:00
Marc-André Bombeck 3dece61cc8 Merge the review fix for record settings the grant cannot manage 2026-09-13 15:17:36 +02:00
Marc-André Bombeck d4fa7d5913 Merge the review fixes for the service worker and the Loki note 2026-09-13 15:17:36 +02:00
Marc-André Bombeck 60dfffc7ad docs(api): name each channel's own credentials in test errors
The ntfy and webhook 502 descriptions shared one list of refused credentials that neither channel fully has. Each now names its own, including the userinfo and long path segments of a webhook URL. The email description names the TLS, protocol and envelope cases.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck 32eaf7a29e fix(admin): show each channel's cause in the notification test toast
The admin test response carried a code and status per channel that nothing rendered. The toast now lists each failing channel with the translated cause and the HTTP status or SMTP code. The relay reply is no longer part of that response, and the ntfy error no longer echoes the topic, which the sender treats as a secret.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck 2480c9fa55 fix(notifications): refuse encoded and path-borne secrets in replies
Userinfo and each query value are decoded on their own, so one malformed escape no longer discards every other secret in the URL. A reply is compared with each secret in literal, JSON-escaped and percent-encoded form, and URL path segments of 16 or more characters count as secrets, which covers the token in a Discord or Slack webhook URL and an n8n webhook id.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck f81cdf8768 fix(notifications): name TLS, protocol and envelope faults in email tests
A STARTTLS failure (port 465 set up as 587) and a protocol error now read as connection_failed, and an envelope or message refused without a reply code as upstream_rejected. They used to fall through to the 500 for an unnamed failure.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck a11e5991cf test(settings): read a real failure response through the test button
The fetch and envelope reading move into runConnectionTest, which the button calls. A test stubs the fetch with the 502 envelopes the notification test routes send and asserts the rendered code, HTTP status, SMTP code and relay reply, so dropping the meta extraction fails it.

Refs #947
2026-09-13 14:50:32 +02:00
Marc-André Bombeck 6a663dfc27 fix(notifications): record a relay refusal at warn, not error
A 502 from a channel test reports the user's relay or mail setup, not a fault on this server, yet the status alone raised every press to an error-level event. The event builder gains a ceiling for the status-derived level, and the test routes cap it at warn on the 502 path. The 500 for an unnamed failure stays at error, and explicit elevations are unaffected.

The notifications guide notes that a reverse proxy which replaces 5xx pages hides the detail, so the card falls back to Test failed.

Refs #947
2026-09-13 14:50:32 +02:00
Marc-André Bombeck 45ee70acb7 fix(settings): list record content only where the grant manages it
The anamnesis page writes through requireRecordAuth("manage",
"profile"), which refuses a grant whose sections do not reach profile.
The Settings list and the navigation entry checked only the level and
the record kind, so a MANAGE grant scoped away from profile would be
offered a page whose saves are refused.

Record-content destinations now name the section they write, and are
listed only when the active record's manageable sections include it.
A destination without a named section is never listed. The shell and
the navigation read the same predicate. MANAGE grants are whole-record
today, so managed profiles and adult MANAGE shares list what they did
before.

Refs #939
2026-09-13 14:49:56 +02:00
Marc-André Bombeck 873e9cb4fb fix(pwa): keep a subscribed worker registered behind a gateway
A same-origin auth gateway that answers an expired session with a 200
sign-in page returns HTML for both the navigation and the /api/version
probe, which read as another app. The worker then unregistered and
destroyed the Web Push subscription, which nothing re-creates, so
reminders stopped without notice.

The worker no longer unregisters while the registration holds a push
subscription, or when the lookup is unavailable. It deletes its caches
and passes requests to the network for the rest of its life instead.
The probe now sends same-origin credentials, so a valid gateway cookie
reaches HealthLog.

Refs #847
2026-09-13 14:45:31 +02:00
Marc-André Bombeck 4306bf1aec Merge channel tests that report the other end's answer, and the Gotify format 2026-09-13 14:30:14 +02:00
Marc-André Bombeck f89dda81a6 test: point the prefix drift guard at the shared matcher
The guard read src/lib/idempotency.ts for the hle_ prefix, which moved to src/lib/secret-shape.ts with the matcher. It now asserts that the shared matcher refuses an hle_ token and that the idempotency cache imports and calls it, so a private copy of the pattern would fail it too.
2026-09-13 14:24:37 +02:00
Marc-André Bombeck fafb5cdc08 feat(notifications): add a Gotify payload format to the webhook
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras.

Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does.

The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter.

Refs #947
2026-09-13 14:19:48 +02:00
Marc-André Bombeck f1aebcc3bf Merge private Loki shipping and the service worker that leaves a foreign origin 2026-09-13 14:17:25 +02:00
Marc-André Bombeck e34c5db45e fix(pwa): confirm a foreign origin before the worker retires
A reverse proxy with a response-header allowlist strips
X-HealthLog-Page from every real HealthLog page, and the worker then
unregistered on each load and dropped Web Push subscriptions.

A successful page without the marker now only sends requests to the
network while the worker asks /api/version with no-store and a manual
redirect. It deletes its caches and unregisters only when the answer
positively is not HealthLog: a 404, a non-JSON 2xx, or JSON without
the version shape. A failed probe, any other status or the HealthLog
shape resumes normal service. buildSha may be null on a self-built
image, so only its presence is checked.

Refs #847
2026-09-13 14:16:52 +02:00
Marc-André Bombeck 92606f6409 feat(settings): show the status and reply of a failed channel test
The test-connection callout now appends the HTTP status or SMTP reply code the route reports, and shows what the relay answered on a line beneath it. The reply is rendered as a React text child, so markup in it is shown as characters.

Adds the upstream_rejected message in all seven locales.

Refs #947
2026-09-13 14:13:55 +02:00
Marc-André Bombeck dd5dd1e45e docs(nav): correct two comments about utilities and manage invites
The More hub carries feature destinations only; Settings and
Notifications sit in the top-bar menu. The browser-only refusal for a
manage invitation comes from the transport check, which runs before and
independently of the second-factor step-up.
2026-09-13 14:12:54 +02:00
Marc-André Bombeck f6a890df8a fix(notifications): answer a failed channel test with its cause
The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500.

The guard that pinned the bare 500 now asserts the opposite, with the reason in its name.

The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type.

Refs #947
2026-09-13 14:10:35 +02:00
Marc-André Bombeck 8c345f600e feat(notifications): keep what the relay answered on a failed send
The webhook and ntfy senders now carry up to 200 characters of a non-2xx response body on the send outcome, with control and formatting characters removed. The body is dropped when it looks like a credential or echoes the header value, auth token, topic or a URL token. Nothing stores it; the ledger row and channel state keep the reason code as before.

Transport faults without a status now say whether they were a timeout or a refused connection, and the email sender maps SMTP failures into the same vocabulary with the reply code. Both exist so the test routes can answer with the cause instead of a bare 500.

Refs #947
2026-09-13 14:04:40 +02:00
Marc-André Bombeck 9dcef3b0e2 fix(nav): offer Settings inside a record that has Settings pages
A self-hoster looking after a managed profile could not find Settings
after switching into it: the utility entries vanished for every shared
record, while the Settings shell lists the profile's own configuration
and the section gate admits it.

The utility tail now answers for the record on screen. Settings is
offered when the shell lists at least one section for it and links to
the first one: the profile card for a managed profile, the anamnesis
page for an adult share at manage. Read and write shares, and a
refused or pending switch, get no utility. Notifications stays hidden
under every switch. The desktop sidebar footer and the mobile user
menu read the same list; the footer identity block stays a plain
block.

Refs #939
2026-09-13 14:03:16 +02:00
Marc-André Bombeck f5fc713783 refactor(settings): share the record section predicate
Move the rule that decides which Settings sections a shared record
lists out of the settings shell and into the record-settings
classification module, next to the inventory it reads. The shell
filters through it unchanged; the navigation reads the same answer
in the next change, so the two cannot disagree about whether a record
has Settings pages and where they start.

Refs #939
2026-09-13 14:02:56 +02:00
Marc-André Bombeck cd44a85537 fix(pwa): let the service worker leave an origin HealthLog left
The worker is registered with scope / and serves /_next/static/*
cache-first. A self-hoster who stopped HealthLog and started another
app on the same port kept getting HealthLog's cached chunks until they
unregistered the worker by hand.

The proxy now marks every page response with a presence-only
X-HealthLog-Page header. When a successful navigation arrives without
it, the worker stops handling requests for the rest of its life,
deletes its own caches and unregisters, and hands the page through
untouched. A network failure keeps the offline fallback, and a
redirect or error status is never read as another app, so a gateway
in front of a stopped HealthLog does not drop the Web Push
subscription.

Refs #847
2026-09-13 14:00:11 +02:00
Marc-André Bombeck afb26251e5 fix(logging): ship to a private Loki and report failed pushes
LOKI_ENDPOINT is read only from the server environment, so the push now
dials its exact origin through the pinned operator-approved resolver
instead of the public-host pin that refused a Loki on the operator's
own network. Link-local, metadata and unspecified addresses stay
refused.

A thrown push or a non-2xx answer used to vanish in an empty catch. It
now writes one line to stderr per reason at most every five minutes,
naming the Loki origin, the reason and the dropped event count, without
credentials. The endpoint accepts both the base URL and the full push
URL; the second form used to double the path into a silent 404.

Refs #947
2026-09-13 13:57:33 +02:00
Marc-André Bombeck 15f0e499f9 refactor: share the secret-shape matcher from one leaf
The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged.
2026-09-13 13:56:58 +02:00
Marc-André Bombeck 9bf78e32d6 Merge remote-tracking branch 'origin/main' into fix/close-account-enumeration 2026-09-11 16:01:25 +02:00
Marc-André Bombeck 93874af99b fix(api): record the retirement against the release that carries it
v1.38.19 was cut before this change and still answers on the path, so
naming it as the version that removed the route would be wrong in the
published contract from the day it appears.
2026-09-11 14:39:30 +02:00
Marc-André Bombeck 625763982b Narrow a spent email budget to the field, and record both refusals
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.

The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.

The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
2026-09-11 14:36:58 +02:00
Marc-André Bombeck 3ace3aad35 Answer 410 for the removed account-discovery path
The path stood in the published contract, so a caller outside this
repository can hold a reference to it whether or not we can name one. A
bare 404 reads as a broken deploy or a misrouted proxy, which is exactly
the reading that kept a client retrying a removed path for twelve days
once before. Registering the retirement makes the edge answer 410 with
the removing version and no replacement, and republishes the path as
gone rather than erasing it from the document.
2026-09-11 14:36:47 +02:00
Marc-André Bombeck 10d6dc8099 Guard the dummy mint, not only the dummy verification
The dummy hash was awaited as an argument to the verifier, so a rejected
mint threw before the verifier was reached and before the catch on it
could apply. The promise was created once at import, and a rejected
promise stays rejected, so one failed mint under memory pressure would
have made every unknown identifier answer 500 while a real account
answered 401 — a cleaner oracle than the timing gap this closes, and
passkey-only accounts locked out of the password form until restart.

The mint is now awaited inside the guarded region, is lazy rather than
an import-time cost in every process that touches this module, and is
dropped on failure so the next refusal mints again instead of inheriting
a cached rejection. A failure leaves a log line; the answer is false
either way. The tests cover a rejecting mint, not only a rejecting
verify.
2026-09-11 14:36:27 +02:00
Marc-André Bombeck 80426697db Record why registration checks duplicates before hashing
With the discovery endpoint gone, this is the remaining anonymous path
that answers whether an address is registered. The 409 already gives
that away, so the work asymmetry ahead of it costs nothing today. It
would become the whole answer the moment somebody unified the status
code without moving the check after the hash, so the ordering now says
it is load-bearing.
2026-09-11 14:36:19 +02:00
Marc-André Bombeck 4460047910 Merge the coach cap counting only the tokens the operator paid for
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
2026-09-11 14:18:21 +02:00
Marc-André Bombeck fe420f76d8 Cap the profile email-address conflict answer
Changing the profile address asks whether another account already holds
it, and the answer is 409 versus 200 — an existence check over every
address on the instance, open to any signed-in caller. Neither route
that reaches it had a ceiling of any kind and the API wrapper supplies
no default, so the only bound on a sweep was the network.

Both routes now share one per-account bucket, ten an hour, charged
before the question is asked so a refused caller learns nothing. Only a
request whose address actually differs from the one on file is counted,
so re-saving a settings form that carries the unchanged address stays
free. The refusal is the standard 429 with Retry-After and the
X-RateLimit triple, carries `profile.update.emailRateLimited`, and is
published on both operations.
2026-09-11 14:05:25 +02:00
Marc-André Bombeck 6a5fb338b1 Make every login refusal cost the same verification
Sign-in returned the moment it found no account, or an account carrying
no password hash, before it reached the verifier. An account that did
have a password paid a full Argon2id verification at 19 MiB and t=2. The
three refusals answered with the identical 401 body and three very
different durations, so the clock told an anonymous caller whether an
address is registered and whether it carries a password.

`verifyPasswordOrDummy` verifies against a stand-in hash minted once per
process at the same cost parameters real hashes use, and returns false
whenever there is nothing to verify, so the passkey-only account takes
the same path as one with a password. The dummy arm discards its own
verdict and swallows a throw, so no outcome there can be read as a
success or answer with a different status.
2026-09-11 13:59:42 +02:00
Marc-André Bombeck 6ef139c7b2 Remove the anonymous account-discovery endpoint
`POST /api/auth/check-user` answered, for any typed identifier and with
no credential at all, whether an account exists and whether it carries a
passkey or a password. Its header called that the intended contract with
the native client; it is not one any more. The native client never calls
it, and no web surface in this repository does either.

Removed without a replacement: the route, its two test files, the
OpenAPI operation and its two schemas, the surface's mention in the
rate-limiter doc comment, the bucket expectation in the auth-surface
rate-limit test, and the login route's comment pointing at it. No stub,
no deprecation shim, no 410 arm — there is no caller to keep.
2026-09-11 13:56:49 +02:00
Marc-André Bombeck 7436a0fcba Merge main — the test-infrastructure fixes from the shared suite
Brings in the truncate retry, the socket fix for the record-fence spec that
failed this branch's second e2e shard, and the corrected absence assertions.
2026-09-11 13:22:43 +02:00
Marc-André Bombeck 82d4a32726 fix(onboarding): the path CTA breaks onto two lines at phone width
The connect step's new attention and credentials tiles link to the
integrations page with a label that names a path — "Open Settings →
Integrations". The button base is `whitespace-nowrap` with a fixed height and
`shrink-0`, so at 390 px the German and French renderings could neither shrink
nor wrap: the control ran 27 px past the viewport. The pre-existing connect
card was unaffected, its label being a short verb phrase; the path label and
the tiles that carry it are new, which is why the sweep passed before.

The CTA is bounded to its card, allowed to break between the words of the
path, and allowed to grow for the second line, with the 44 px tap target kept.
No copy moved, so the seven catalogues stay as they are.
2026-09-11 12:57:10 +02:00
Marc-André Bombeck bcef5aeeee fix(ai): hold a reserve so the chat is never locked out by jobs
The background ceiling applied only when the operator funded the chain, so on a
chain the operator does not pay for the automatic generators and the chat shared
one undivided ceiling and the first could still take all of it. That is the
configuration the recommended remedy for the original lockout produces — put the
personal Codex account first — so the guarantee was missing on exactly the setup
it was written for.

The ceiling is now expressed as what it is meant to protect. Background work may
run until the day's spend would leave less than a fixed reserve for whoever is
waiting on an answer, on both cost owners. A reserve and a quota look alike at
the boundary and mean opposite things in the middle: a quota stops useful
generation on a day nobody was ever going to interrupt, while a reserve costs it
nothing until the moment it would take the last of the day. An account
generating 1.45 million tokens of insights on its own plan keeps every one of
them and the chat still holds 400 000.

INTERACTIVE_RESERVE_SHARE replaces JOB_SURFACE_SHARE. The old name described a
quota over the jobs rather than a floor under the person waiting, and a name
that states the opposite of the intent is what put the wrong comparison in this
ledger twice already.

The fraction is a default an operator will want to change. The setting and its
documentation are separate work.
2026-09-11 12:55:52 +02:00
Marc-André Bombeck 42b9aefdfd test(integration): retry a truncate that lost a deadlock
The suite wipes the database between files with one TRUNCATE, which needs
the strictest lock on every table at the same time. The server writes in
thirty-nine places without awaiting the result, so one of those can land
after the test that caused it has returned, and Postgres then picks the
truncate as the deadlock victim. It failed an unrelated file twice in two
days and passed on the rerun both times.

The truncate is retried up to four times, on that one error and no other,
with a short back-off. Three cases hold it there: a deadlock that clears,
an error that is not a deadlock and must not be retried at all, and a
deadlock that never clears and must still fail. All three go red without
the retry.
2026-09-11 12:38:31 +02:00
Marc-André Bombeck b565edc48d docs(ai): drop planning vocabulary from the ledger comments
CLAUDE.md forbids planning shorthand in committed artefacts, and this branch
introduced 74 lines of it across source, tests, a schema field and two
migrations. Someone reading the migration SQL in a year has no way to resolve a
wave letter or a round number, and a comment that points at something outside
the repository names no reason at all.

Three passes. A version followed by a parenthetical keeps the version and loses
the parenthetical, because the version says since when something holds. A
sentence that leaned on the vocabulary to carry its meaning is rewritten to
state the reason directly: the arrival reservation now says it was refused over
money the operator never spent, rather than naming the defect a wave existed to
remove. Three test titles carried the marker in the name itself and are renamed
to what they pin.

Comments only. No behaviour, no renames beyond those three titles.
2026-09-11 12:37:54 +02:00
Marc-André Bombeck 45599c527e fix(demo): the profile route writes only the baseline fields in the demo
Allowlisting `PUT /api/auth/profile` so the setup flow's baseline step could
finish also handed every visitor the shared demo account's contact email, its
display name, its full name and its insurer fields: the allowlist admits a
path and a method, and every field the handler behind it writes comes with
them. The justification beside the entry named three fields; the entry
admitted eleven.

The write is narrowed on the server, in `applyProfileUpdate`, to height, date
of birth and sex while `DEMO_MODE` is set — not in the client, which is the
party the allowlist exists to distrust. It happens ahead of the email conflict
check, which would otherwise answer "is this address registered here?" to
anybody who can open the demo. The display-name box goes read-only there, as
the anamnesis card already is, so nothing accepts input it will discard.

The entry's comment now lists what the handler writes and what is admitted.
2026-09-11 12:22:47 +02:00
Marc-André Bombeck ceefbc5571 fix(onboarding): a receipt on file is not evidence the provider works
The consent branch of the done screen's AI panel painted "insights work for
you right now" on the strength of the receipt alone, reading no health at all.
A person who had consented — including one who had just tapped the offer — was
told a dead provider was serving them. That is the claim-nobody-measured
defect closed at the offer end of the same panel, still open at the other.

The positive sentence now needs a healthy verdict as well. A consented account
on a provider that failed reads the same "not answering" note as anybody else,
and on an unmeasured one the panel claims nothing. The panel's four variants
are pinned across all three health states in every arm.
2026-09-11 11:38:31 +02:00
Marc-André Bombeck 87cf0dc215 docs(api): drop the internal markers from six published descriptions
Six operation descriptions in the API document opened with a version and
a bracketed letter-number code from the notes the work was planned in.
A client author reading the document has no way to know what B1 or B6
mean, and they mean nothing outside those notes. The version stays,
since it says when the operation appeared.
2026-09-11 11:23:28 +02:00
Marc-André Bombeck 34caeeea3c test(onboarding): the AI panel names the variant it settled on
The panel paints before its status query answers, and every variant inside it
is conditional, so an assertion that a variant is absent could pass on a
question that had simply not been answered yet — which is how the journey for
the unmeasured-provider case passed on a retry against the very defect it was
written to catch.

The panel now carries the variant it settled on, absent until it has settled,
and the three journeys wait on that instead of racing it. The third journey
covers the state a freshly deployed instance is in before its first AI call:
no offer, and no claim of an outage either.
2026-09-11 11:15:39 +02:00
Marc-André Bombeck c66bcbc295 feat(admin): the AI card shows the day's spend split by who paid
The provider-health route answered a spendToday payload that nothing rendered,
which is the pattern the two-ended rule exists to stop. It is also the screen
the operator opens when a surface refuses on budget, and the one mixed number
could not answer the question that raises: a day at 1.24 million tokens is
alarming until the card says that 151 200 of them were the instance's own key
and the rest ran on the users' own plans. The operator ceiling is enforced
against the second figure. Copy lands in all seven locales.
2026-09-11 11:11:17 +02:00
Marc-André Bombeck 7b21840630 fix(ai): the arrival reaction reserves through the shared ledger gate
The arrival-reaction claim carried its own copy of the reservation upsert so
the write could ride the claim transaction. The copy drifted: it learned to
write operator_tokens and kept gating on the day's mixed total, at the halved
background ceiling. On the row the wave was written for, 1.2 million tokens
served by the user's own plan, the reaction was refused all day at half the old
ceiling, reproducing the defect the wave exists to remove on the one surface it
forgot to convert. reserveBudget now takes the transaction client, so both
writes still roll back together and there is no second copy left to drift.

The row also remembers who paid for its reservation. The supersede refund in
data-arrival has no chain to ask and could only reverse the total, so the
operator share of every superseded reservation stayed on the row forever,
walking the operator's ceiling upward against spend that never happened. A
supersede is routine, not an edge case: any second measurement of the same kind
on the same local date before the worker runs. The resume path re-derived the
owner from a chain the health ledger may have reordered since, which leaks the
same way or drains another request's balance.

A structural guard makes the invariant executable. Every INSERT column list,
SET clause and RETURNING list that names one counter must name the other, and
the set of files holding raw ledger SQL is pinned. Both defects above were a
grep away and both would have failed this test.
2026-09-11 11:11:12 +02:00
Marc-André Bombeck 18ed57b867 fix(ai): every surface keeps a ceiling on the day's mixed total
The owner split left an operator-funded chain with no ceiling at all. Its arm
compared operator_tokens, and that counter returns to roughly zero on every
reconcile the user's own plan settled: on the 2026-09-11 evidence the shared
key answered 500 and codex served, so the counter never grew, every job
reservation was admitted forever, and a runaway client loop could write an
unbounded row. A pre-existing concurrency contract caught it from the other
side, two reservations that must not both pass one cap both passing.

reserveBudget now refuses when the owner's counter is over its cap OR the day's
mixed total is over the abuse ceiling, and a background surface gets its share
of that ceiling too, so background spend is bounded on the total and not only
under a counter the operator's key never fills. The surface is a required
argument, so the type checker enumerated every reservation site.

The hop guard learns the same lesson: an operator-funded fallback hop is now
rationed at the calling surface's ceiling rather than the full operator cap. A
job admitted under the job share could previously spend the whole 200 000 once
its chain fell back onto the shared key, which is the original defect reached
by the fallback path.

The job share applies only where the operator pays for the chain. Multiplying
whichever ceiling the chain resolved halved background generation for a
self-hoster on a local model, who costs the operator nothing.

Refusals now name the ceiling they hit and whose it was. A refusal used to emit
the day's mixed total and nothing else, which on an operator refusal is not the
counter that tripped.
2026-09-11 11:11:01 +02:00
Marc-André Bombeck 41596d1cb8 docs: comments name what the code does, not how it was built
Thirty-one files carried internal build vocabulary and anchors pointing at
notes that are not in the repository: a reader of `checklist.ts` in a year
would find its rationale keyed to a research id they cannot open, inside a
comment that also names an internal build stage. Two test titles carried the
same anchors.

The version tags and the whole of the reasoning stay; only the vocabulary and
the dangling references go, with the few sentences that were built around an
anchor rewritten to stand on their own.
2026-09-11 11:03:04 +02:00
Marc-André Bombeck ba29a5e471 fix(onboarding): no shared-provider offer where the grant cannot be made
The done screen's one-tap button posts `POST /api/consent/ai/web`, a path the
demo instance's edge allowlist refuses — correctly, since the demo is a single
shared account and a receipt one visitor minted would turn the operator's
provider on for everyone after them. The button was offered anyway, so a
visitor who took it got the generic "give it another try in a moment" toast on
the one screen whose whole purpose is not to say things it cannot back.

The route withholds the offer under `DEMO_MODE`, which leaves the panel in its
neutral form, claiming nothing either way.
2026-09-11 10:58:25 +02:00