Skip to content

feat: operator notifications, dependency editing on the edit page, and a measured memory baseline - #318

Merged
fclairamb merged 80 commits into
mainfrom
batch/2026-09-03
Sep 4, 2026
Merged

fclairamb merged 80 commits into
mainfrom
batch/2026-09-03

Conversation

@fclairamb

Copy link
Copy Markdown
Owner

Five specs from specs/todos/, implemented sequentially on a batch branch, each gated by an independent completeness audit before being archived. Three of the five needed at least one fix round, and the audits caught two real defects the implementers had reported as done.

No database migration — the fix below is a reader change, and existing rows are readable as-is.

What changed for the user

Check credentials can be decrypted by every process, not just the one that generated the key. An organization's data encryption key is stored wrapped as {"value": …} but was read back raw, so the process that generated the key kept working from its in-memory cache while every other process — each checks worker, and the API itself after its next restart — failed with unknown envelope version. The user-facing advice was "re-save the check's credentials", which cannot help and, after an API restart, stops working entirely because the API can no longer decrypt to merge the update. The reader now accepts the wrapped shape as well as the two legacy ones, a freshly generated key is read back and verified before it is cached so a storage-shape regression fails the first encrypt loudly, and an organization-key failure is now reported as its own cause instead of blaming the check's credentials. Nothing needs rotating or re-entering; the wrapped key was intact all along.

Two corrections to the original diagnosis came out of the work, both verified against git history: the wrapping has been there since the first commit rather than since v0.22.0, so cold key loads have been broken since the key store shipped in May — and the remediation SQL the spec proposed would have made things worse, because rewriting the row to a bare string breaks the parameter reader outright. Both are now flagged in the archived spec and documented.

Operators can be paged when a support message arrives or someone signs up. Neither event could reach a human in real time: an inbound support message produced one email to the instance mailbox, and a signup produced nothing beyond an optional analytics event. A new Notifications tab under Server lets a super admin choose which super admins hear about which of those events, delivered over the notification routes each recipient already set up — email, Telegram, a Slack DM, web push or SMS. The page is built around how this feature fails quietly: a recipient with no route is flagged in amber next to their name, a subscriber who has lost super-admin status keeps a visible row explaining why they are skipped, and "Send me a test" reports how many routes actually carried the notice rather than a bare success. Bursts fold the way the support mailbox mirror already folds them, and the email mirror is unchanged.

Check dependencies are edited where every other attribute is edited. The detail page — the view surface for a check — was the only place you could add, retype or delete a dependency, while the edit page offered a weaker editor that could only add and remove parents and hard-coded every new edge to "hard" with no description. The detail page is now read-only with an Edit link, and the edit form carries the whole editor: kind, description, inline staging until Save, and cycle prevention both in the picker and on submit. Both lists also got a real row design, registered in the design reference as a reusable pattern.

The "Getting started" checklist reads like something to finish. It gained depth and a gradient accent, a real progress bar, a single highlighted "next up" step, numbered badges that turn into filled ticks, and a celebratory all-set state. The strikethrough is gone — five struck-through rows looked like a wall of deletions rather than a finished list. Every animation respects prefers-reduced-motion, which is a new convention for the dashboard and is documented as one.

Under the hood

Memory can now be measured instead of eyeballed. /api/mgmt/memory reports a real Linux breakdown (process, smaps, cgroup v2, Go runtime classes, and the off-heap figure the runbook asked for), and a new membench harness runs the shipped container shape with a warm-up, a sampling window and repeated runs, so a change is judged against its own noise floor rather than a single snapshot. Two of the spec's own premises did not survive contact with the measurements, and the runbook says so: the SQLite driver in the shipped binary is modernc, not mattn, so the driver the build reported was wrong (now fixed) and the off-heap rule rested on it; and idle memory reads ~149 MiB at 45 seconds but ~23–31 MiB at five minutes, so the 60-second warm-up the spec proposed would have measured the startup burst as steady state.

Of the seven reduction candidates, two shipped, two were measured and rejected, one was closed by inspection, and the rest are recorded as not attempted with what it would take. The candidate that streams embedded assets instead of copying each one onto the heap is honest about not clearing an RSS gate — the harness cannot resolve it — and ships on an allocation measurement instead.

Verification

The full dash0 Playwright suite was run as a batch-level gate rather than per-spec, which caught two cross-spec selector collisions introduced by the new Edit link. Four failures remained; all four were A/B'd against origin/main in isolated worktrees and reproduce there, so none is introduced by this branch. Two are not product failures at all — they only appear when the E2E server is started without the environment variables CI sets. The other two are pre-existing defects now written up as their own specs: an ipVersion clear-to-Auto bug, and an unbounded connection pool against the embedded Postgres server.

Added since this branch was first opened

The unbounded embedded-Postgres pool mentioned above is no longer just a filed spec — it is fixed here.

NewEmbedded opened its sql.DB with database/sql's unbounded default against a server it
itself pins at max_connections=10 (7 usable after superuser reservations), so a 50-goroutine
test asked for ~50 backends and everything past the 7th got a hard 53300 sorry, too many clients already. It is now bounded in the constructor via the same applyPoolLimits the
non-embedded path uses, and the three suites that each hand-rolled their own
SetMaxOpenConns workaround no longer need to.

One correction worth calling out, because the spec got it wrong: the spec concluded this was
purely a test-infrastructure bug on the grounds that NewEmbedded is test-only. It is not.
postgres-embedded is a real, user-selectable SP_DATABASE_TYPE reached from main.go's
openDB and runMigrations and from internal/app/server.go, so before this change an
operator running embedded Postgres would hit those same hard 53300 errors under modest
concurrency. The fix is unchanged — bounding the client turns the errors into queueing — but it
is a product fix as well as a test fix, and the archived spec now carries an amendment saying
so rather than leaving the false premise on the record.

The guard is proven by a positive control, not just a green run: with the bound removed both
concurrency tests fail again with the original 53300, and the Postgres log shows dozens of
simultaneous rejections, so the contention is still genuinely concurrent.

🤖 Generated with Claude Code

fclairamb and others added 30 commits September 3, 2026 19:21
…ated — give it a modern, flashy treatment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…at and dated — give it a modern, flashy treatment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-up focal row

The onboarding checklist was five identical flat rectangles on a flat tint,
with progress as a line of text and done rows struck through like deletions.
Rework the presentation only — the container derivation, dismissal writes and
test-alert flow are untouched:

- card chrome: a primary/violet top accent bar, two blurred low-alpha blobs
  and shadow-primary on a normal bg-card surface. Every decorative layer is
  aria-hidden and pointer-events-none so it cannot intercept a row's
  stretched click target. No AuroraPanel (marketing-only, always dark).
- header: a Rocket tile and a real Progress bar. destructiveWhenFull={false},
  because the primitive defaults to TRUE and would paint a finished checklist
  red — the colour that means "down" everywhere else on this dashboard.
- rows: three tones instead of two. The first still-open step is the focal
  "next up" row (primary ring, Next pill, the card's only default CTA),
  later pending rows are quiet, done rows recede into an emerald wash with a
  filled tick. The strikethrough is gone, and the pending marker is now a
  numbered badge that visibly becomes the tick.
- all-set: an emerald→primary strip with a Sparkles glyph, not a grey-green box.
- motion: staggered reveal, tick zoom-in and hover lift, all behind Tailwind's
  motion-safe: variant — the first surface in dash0 to set that convention.

Every test id, data-done attribute, the stretched after:absolute link and the
relative z-10 test-alert button survive unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd lack of strikethrough

Two new e2e cases against the real dashboard. The first reads the rows' new
data-next attribute (carried by rows only, never by the status wells) and
asserts the progress bar agrees with them: aria-valuemax = row count,
aria-valuenow = done count, exactly one row focal, and that row is the FIRST
still-open one. A self-created org opens at 3/5, so the test also bounds the
done count strictly between 0 and total — without that, "one focal row" would
pass on a degenerate list.

The second measures computed text-decoration on a genuinely done row (check
is ticked the moment the card appears) with a pending row as the positive
control, so the removal of the strikethrough cannot silently regress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd dated — give it a modern, flashy treatment

make build-dash0: green.
cd web/dash0 && bun run lint: 42 errors / 440 warnings — byte-for-byte the
pre-existing base count (the react-hooks debt), zero new findings in any
touched file.
cd web/dash0 && bun run test:unit: 75 files, 1274 tests green (includes the
onboarding locale-parity suite, now covering the new onboarding.nextUp key in
de/en/es/fr).
Playwright onboarding-checklist.spec.ts + magic-wand.spec.ts +
empty-state-onboarding.spec.ts against a SP_RUNMODE=test side-car on :4010:
23 passed, 1 failed. The failure is magic-wand "attaches every check even
when the org has more than 100", which 429s bulk-creating 105 checks against
the side-car's default rate limit — it passes on the same build with the
limits raised, and it never touches the checklist card.

Both themes verified by screenshot in the running app: the card at 3/5, the
whole dashboard with the card sitting above OverallStatusBanner, and the
design-reference section with both fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d dated — give it a modern, flashy treatment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… and signups

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…messages and signups

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ransport

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…its admin API

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd the notice job

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ications tab

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rives or a new user signs up

Two events that matter most for running an instance used to reach nobody in
real time. An inbound support message produced a single email to the instance
support mailbox, so an operator who lives in Telegram or Slack only learned
about a customer's question by happening to open the unlinked support inbox. A
new signup produced nothing at all beyond an optional analytics event, so
nobody could welcome a new user or notice one whose signup got stuck with no
organization and no checks.

A new "Notifications" tab under Server lets a super admin pick which super
admins hear about which of those events. Delivery reuses the notification
routes each recipient already set up for themselves — email, Telegram, a Slack
DM, web push or SMS — so there is no new medium to configure and no webhook to
paste. WhatsApp routes are skipped, because Meta will not carry free-form text
outside a session, and the skip is logged rather than silent. A recipient who
belongs to several organizations with the same address gets one notice, not
one per organization.

The page is built around the way this feature fails quietly: a recipient with
no enabled notification route is flagged in amber next to their name rather
than only in the logs, a subscriber who has lost super-admin status keeps a
visible row explaining why they are being skipped, and a "Send me a test"
button delivers a real notice over the real transport and reports how many
routes actually carried it — never a bare success when nothing was delivered.
Recipients must be super admins at delivery time, not merely when the
configuration was saved, because a notice can quote a customer's support
message and support threads are super-admin-only.

Bursts are folded the way the support mailbox mirror already folds them: one
notice per thread per ten minutes, carrying the count of what it swallowed,
under an instance-wide hourly ceiling. Every attempt is counted in
solidping_operator_notice_total, so a silent drop stays visible. The email
mirror to the support mailbox is unchanged — this is additive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ages and signups

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ding org

The Notice -> job payload conversion was an inline field-by-field copy in the
dispatcher wiring, and it omitted AboutUserUID. Every queued notice therefore
reached the delivering job with an empty subject uid, enrichWithOrganization
short-circuited, and no operator was ever told which organization a signup
landed in — the one thing the docs page already promises.

Nothing caught it because the tests sat on either side of that seam: the job
tests hand-built the config, the auth tests asserted at the Notify boundary.
The conversion now lives once in jobtypes with a reflection round-trip guard
that fails when any Notice field stops travelling, and a new app-level test
drives a real password signup through the real dispatcher and asserts both the
uid on the queued job and the Org line in the delivered email.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s log prefix

Build assigns all five medium closures unconditionally, so opsnotify's
nil-closure branch could never fire in a real process: an instance with no
Telegram bot token recorded outcome="failed" for every Telegram route, which
made "never configured" indistinguishable from "the provider is broken" in
solidping_operator_notice_total — the metric added specifically so a silent
drop stays visible. Wiring failures now carry an ErrMediumUnavailable sentinel
that delivery classifies as skipped, and the new opsnotifywire tests exercise
the real Build output rather than a hand-built Deps, with a positive control
proving a configured medium still delivers.

The delivery log lines also name the notice kind again, so a watchdog digest
keeps logging as "Platform watchdog digest ..." instead of disappearing under
a generic operator-notice prefix that would break log-based alerting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fold window only collapses a burst inside ONE thread; a hundred different
numbers texting once each are a hundred threads, each admitted by its own empty
fold state. The hourly ceiling is what stops that becoming a hundred pushes,
and nothing exercised it — the existing ceiling test covers the mail mirror's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n settings

The amber "no notification routes" warning linked every row to
/orgs/$org/account/notifications — the VIEWER'S page. Clicking it on Bob's row
took Alice to Alice's settings: it looks actionable, changes nothing about Bob,
and is worse than no link at all.

Notification routes are per-user and dash0 has no admin route onto somebody
else's, so only the viewer's own row links anywhere now; another admin's row
states the problem and names who has to fix it. The e2e case asserts the
absence of the link, not just the presence of the warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n the spec

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ages and signups

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ges and signups

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…precise, repeatable RSS measurement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…napshot

The RSS a memory question needs is not one number: /proc RSS is anon+file+shmem,
kubectl top is the cgroup working set, the Go runtime sees neither the cgo arena
nor the binary's rodata, and the OOM killer acts on cgroup anon+kernel. This
package reads all four sources and computes the two numbers nobody had:
offHeapBytes = RssAnon - (goTotal - heapReleased), and the cgroup unreclaimable
total that the bench harness uses as its primary metric.

Every reader is total: an absent file (macOS, cgroup v1, no cgroup) yields a
zero value with present=false, never an error. Parsers are pure over bytes, so
the Linux layouts are fixture-tested from a Mac.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ething

/api/mgmt/memory reported one rssBytes and left the reader to guess which RSS it
was. It now carries the /proc anon/file/shmem split, smaps_rollup Pss, the
container's own cgroup accounting (including anon+kernel, the total an OOM kill
is actually decided on), the runtime/metrics memory classes, and the derived
off-heap gap the runbook's section 4 asks for but nobody computed.

?gc=1 adds a floor mode (runtime.GC + FreeOSMemory before sampling) so 'live
floor' and 'steady state' are two named measurements instead of one ambiguous
one; every payload says which mode produced it.

Every existing field keeps its name and place. Linux-only sections report
present=false off Linux rather than failing the request, and the matching
Prometheus gauges emit no series at all where the source is absent - a zero
RssAnon would read as 'no anonymous memory', which is worse than silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gement

A delta smaller than the baseline's own inter-run spread is not a result -
repeating a run moves these numbers by a few percent all on its own. This is
the pure core that makes that call: per-run median/p95/max, the spread across K
repetitions, and a comparison that flags anything inside the noise floor as not
significant and refuses any verdict at all when fewer than two repetitions make
the spread unknown.

The tests are the negative controls the spec demands: a no-op rerun of the
baseline reports nothing significant anywhere, a real reduction is flagged only
on the scenario it targets and not on the untouched one, and a delta exactly at
the noise floor stays unflagged.

Reports carry their own caveats - a --local run prints that it is not
authoritative, and comparing a local run against a containerized one is
labelled not comparable rather than quietly tabulated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts itself

make bench-memory. Boots a FRESH server per repetition (a reused process carries
its page cache, heap and GC phase into the next run - exactly the noise this
exists to measure), warms up, samples /api/mgmt/memory on a fixed protocol, and
repeats K times so the inter-run spread is a measured number rather than an
assumption.

Three modes, and every report says which one produced it: docker (the shipped
image under --memory/--cpus, real cgroup v2 - authoritative), local (host
binary, explicitly labelled non-authoritative), and attach (sample a server
somebody else is running - a dev loop, a port-forwarded pod).

Fixed scenario set so two runs diff: idle at three role splits, checks at
200/500/1000, login burst, docs crawl, dash0 reload. A scenario that cannot run
in the chosen mode is recorded as SKIPPED with the reason, never dropped.

Dockerfile.bench compiles the current tree into the production base image using
the embedded assets already in it, so a MODIFIED build can be measured and not
just a released tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fclairamb and others added 17 commits September 4, 2026 06:58
…e run

The spec is being archived with its original Problem and Proposal sections
intact, but two of their claims were disproven during implementation and both
read as authoritative to someone opening this file top-down during an incident.

The wrapping predates v0.22.0 by a year, and the suggested repair SQL would
break GetOrgParameter rather than fix it. Mark both in place so the correction
cannot be missed by a reader who never scrolls to the implementation plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ecrypt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he detail page

The check detail page's Dependencies card was the only editor for a
dependency's kind and description, while the edit form — the page you go
to when you want to change a check — could only add and remove parents,
hard-coding every new edge to kind "hard" and dropping the kind and
description of the ones it loaded.

Move all mutation into the form's Dependencies section (inline kind
select, description input and a destructive remove per row, plus an add
row), make the card read-only with an Edit link that deep-links back to
that section, and give both lists a proper row surface: one bordered
container with divide-y rows, fixed identity/kind/description/actions
columns, and a dot-pill kind badge.

The edit-page sync gains a third bucket: parents present on both sides
whose kind or description changed are PATCHed, via a diffDependencies
helper extracted out of the route and unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a "Dependency row" section next to List surface: the bordered
divide-y container, the fixed identity/kind/description/actions columns,
the in-container empty row, the dashed add-row footer and the dot-pill
kind badge. Also adds a locale-parity test for the dependency editor
copy, which is now the only place a dependency can be created or
retuned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrites check-dependencies.spec.ts around the view/edit split: the
dependency is added from the edit page and the detail page is asserted
read-only (link + badge, no pencil, no trash, no picker), plus two new
cases for the paths that were broken — retuning an existing edge hard ->
soft with a description (the toUpdate PATCH bucket) and adding an edge
as soft (the create path no longer hard-codes "hard"). Both were
verified to fail against a deliberately reverted implementation.

The Dependencies card's new Edit link is a second anchor whose href
contains "/edit" on the check detail page, so the suite-wide
a[href*="/edit"] and getByRole("link", {name: /Edit/i}) selectors are
scoped to the page header. For the same reason the card's link carries a
title rather than an aria-label, which would have made getByLabel("Edit")
ambiguous in check-detail.spec.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rameter

The gc=1 floor mode added a query parameter to /api/mgmt/memory, which changes
GetMemoryWithResponse to take a params argument. The committed client was never
regenerated, so the tree built locally against the old signature while CI - which
regenerates from openapi.yaml before building - failed on all three call sites.

Both callers pass nil: the CLI and the integration tests want the steady-state
sampling, not the forced-collection floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SPA-shell and docs-404 tests added with the asset streaming change read the
real embedded FS, which only carries a built frontend on a developer machine.
CI never builds the frontends for the backend job - it writes a one-line
placeholder into each embed dir - so the shell test looked for a doctype in
"placeholder" and the docs test got a plain http.Error because no 404.html was
stubbed.

The shell test now compares against whatever the embedded FS holds, which pins
the regression more tightly than a doctype substring (the bug served a 404, not
a different body), and CI stubs 404.html alongside the other files it already
creates for exactly this reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NewEmbedded opened its sql.DB with database/sql's default (unbounded)
MaxOpenConns while the embedded server runs with max_connections=10 (3
reserved for superusers). A 50-goroutine concurrency test therefore asks for
~50 backends and everything past the 7th is refused with `53300 sorry, too
many clients already` — a test-infrastructure failure that reads exactly
like a product bug.

Bound the pool inside NewEmbedded itself (MaxOpenConns: 5, MaxIdleConns: 2),
reusing applyPoolLimits so there is exactly one place that bounds a pool.
Remove the three now-redundant per-test SetMaxOpenConns(4/6) workarounds in
tls_storage_postgres_test.go, incident_number_test.go and slug_race_test.go.
Document, at New's embedded branch, that Config's pool fields are dropped
there on purpose rather than silently.

Also stop TestReserveMonthlyUsage_ConcurrentNeverOverruns_Postgres from
swallowing reserveErr — it now requires every error to be nil (an
over-limit refusal is (false, nil), never an error), so a connection
failure fails the test instead of being absorbed into "goroutine lost the
race."

Add TestApplyPoolLimits_QueuesRatherThanErrors, a driver-agnostic
database/sql-level test pinning the production claim: a bounded pool makes
callers past MaxOpenConns queue for a connection rather than get refused
one, which is what makes bounding a production pool safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec 2026-09-04-04 analysis claimed NewEmbedded is test-only and
production is not affected. That is false: postgres-embedded is a real,
user-selectable SP_DATABASE_TYPE reached from main.go's openDB/runMigrations
and internal/app/server.go, and NewEmbedded pins max_connections=10 on every
path including production. Rewrite the comment at New's embedded branch to
say so plainly instead of reasoning about "a real deployment" as if it were
hypothetical, and add a brief, accurate note to
web/docs/docs/configuration/database.md about the fixed connection ceiling.

The bound added in 106757c is unaffected and still correct — it now also
fixes a real (if niche) production failure mode, not only a test one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add TestNewEmbedded_PoolBoundedBelowNonSuperuserConnectionLimit: it starts a
real embedded-Postgres instance and asserts Service.DB().Stats().MaxOpenConns
is positive and below 7 (the server's max_connections=10 minus 3
superuser-reserved slots). This is a cheap, permanent stand-in for the
50-goroutine concurrency tests — if a future change ever widens or drops the
applyPoolLimits call in NewEmbedded, this fails immediately instead of
waiting for a flaky 53300 to resurface elsewhere.

Also fix a misspelling (honouring -> honoring) flagged by golangci-lint's
misspell check in the comment added by the previous commit.

Positive-control evidence (manual, not committed): with NewEmbedded's
applyPoolLimits call commented out, both
TestIncrementUsageCounter_Concurrent_Postgres and
TestReserveMonthlyUsage_ConcurrentNeverOverruns_Postgres fail with
"FATAL: sorry, too many clients already (SQLSTATE=53300)", confirming the
50-goroutine contention is real (not quietly serialized) and that these
tests still catch the regression the bound fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ounded pool against a max_connections=10 server, so concurrent tests fail with 53300

Final gate: make build-backend lint-back test all green (0 lint issues, no
test failures). Verification from spec 2026-09-04-04, all run for real:
- go test ./internal/db/postgres/ -run '...Concurrent...' -count=3: green
  three times, no 53300.
- go test ./internal/db/postgres/ -count=1 (~9.5 min): ok.
- go test ./internal/db/ ./internal/db/postgres/ ./internal/handlers/checks/:
  ok (a one-off internal/db failure from concurrent embedded-postgres binary
  extraction under -p default was confirmed as the known
  shared-testcontainer-resource flake, not a regression, by a clean rerun
  with -p 1).
- go test ./internal/notifier/ ./internal/realtime/ ./internal/jobs/jobsvc/:
  ok with -p 1 (same flake reproduced and cleared the same way under default
  parallelism); no suite is hurt by the newly bounded pool.

No source changes were needed at this step — the two prior commits
(106757c and this branch's docs/test follow-ups) already cover the fix,
the corrected spec analysis, and the permanent regression guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fclairamb added a commit that referenced this pull request Sep 4, 2026
## Why

`wiki/` holds internal engineering notes and is an input to **no
build**. The published docs site is `web/docs/`, which is separate;
every other reference to `wiki/` in the tree is a comment pointing at
it. So a wiki-only pull request currently spends a ~28-minute E2E run, a
Go build, four frontend builds and a full backend test suite proving
that prose did not break the compiler.

That is not hypothetical — #319 (five files, all under `wiki/`) did
exactly that.

## What changed

A `changes` job asks the API which files the PR touches and sets one
`code` output. The seven build jobs are gated on it. **Nothing else
moves** — the jobs, their steps and their ordering are untouched.

This is safe *specifically* because of how the existing `ci` gate is
written: `always()`, failing only on `failure` or `cancelled`, so a
skipped job already satisfies it. That is the same property the
release-please skip has relied on all along, and `main` has no required
status checks, so there is no check that can hang unreported. If that
gate ever starts treating `skipped` as a failure, this must change with
it — there is a comment saying so.

The gate now also depends on `changes` itself. Without that, a detector
that *failed* would skip every build job and leave the gate looking at
an all-skipped graph — turning a broken detector into a way to merge
untested code.

## Fail-safes

The failure mode here is silently not testing things, so the decision
fails toward building:

- any non-`pull_request` event (pushes to `main`, in particular) always
builds;
- an empty file list from the API means "the API told us nothing", not
"nothing changed" — it always builds;
- the wiki-only decision is a POSIX `case` glob, not `grep -qv
'^wiki/'`.

That last point is not theoretical. `ugrep`, which is `grep` on some
developer machines, returns false for inverted-quiet matching even when
non-wiki files are present: it classified a **138-file code change as
"wiki-only"**, while `grep -cv` on the same input correctly counted 126
non-wiki files. GNU grep on the runner would have behaved correctly, but
a check whose meaning depends on which `grep` is installed is not one
worth shipping.

## Verification

Both live PRs, evaluated with the exact decision logic from the
workflow:

| PR | Files | Result |
|---|---|---|
| #319 | 5, all under `wiki/` | `code=false` → skips |
| #318 | 138 | `code=true` → builds |

YAML parses, and the resulting job graph was checked: all seven build
jobs gain `changes` in `needs`, and the `ci` gate depends on it too.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@fclairamb
fclairamb merged commit 7990c78 into main Sep 4, 2026
13 checks passed
@fclairamb
fclairamb deleted the batch/2026-09-03 branch September 4, 2026 22:07
@fclairamb fclairamb mentioned this pull request Sep 4, 2026
fclairamb added a commit that referenced this pull request Sep 4, 2026
release-please can only put the squash-merge subject in the changelog, and
#318 carried six specs behind one title — "operator notifications, dependency
editing on the edit page, and a measured memory baseline" told a reader
nothing about the two bugs it fixed. Expand it into user-facing prose, per
wiki/conventions/changelog.md.

Three features and two fixes, split out of that single bullet. The internal
work in the batch — the memory-profiling runbook and harness, the CI
wiki-only skip, and the embedded-Postgres test plumbing — is deliberately
left out rather than scoped under deps: none of it changes anything for
someone running SolidPing.

The embedded-PostgreSQL fix is scoped `database`, not `deps`, on purpose. The
docs transform drops every deps-scoped bullet, so filing it there would have
erased a real user-facing fix from the published changelog while leaving it
in this file. `database` is not in SCOPE_LABELS and so renders as its raw
slug, the way `storage` already does in v0.21.2 — worth a follow-up to the
lookup table, but not worth holding a release for.

Version heading, date and compare link are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fclairamb added a commit that referenced this pull request Sep 4, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.23.0](v0.22.1...v0.23.0)
(2026-09-04)


### Features

* operator notifications, dependency editing on the edit page, and a
measured memory baseline
([#318](#318))
([7990c78](7990c78))


### Bug Fixes

* **deps:** update github.com/dop251/goja digest to f87b40a
([#312](#312))
([43bac80](43bac80))
* **deps:** update module github.com/go-jose/go-jose/v4 to v4.1.5
([#313](#313))
([48344aa](48344aa))
* **deps:** update module golang.org/x/crypto to v0.56.0
([#310](#310))
([e9de249](e9de249))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant