Commit Graph
33 Commits
Author SHA1 Message Date
jcoffey-dev b7f99b49f2 Take grpc to 1.83.2 across the nine modules that carry it
GHSA-2v4p-qf9q-27wj is a panic in gRPC-Go's xDS routing interceptor: a
request arriving with neither `:authority` nor `Host` indexes an empty
slice, the per-RPC goroutine does not recover, and the process dies.
High, and nine alerts, because nine go.mod files pin the same version --
eight directly, terraform indirectly.

Nothing here was reachable. The interceptor is installed by
`xds.NewGRPCServer`, which this repo never calls: the one production
server is `grpc.NewServer(grpc.Creds(...))` in ingest/internal/grpcserver
and the only other is a plain one in a searchclient test. That is also
why security-scan has been green throughout -- govulncheck reports on
reachability and found nothing on 1.83.1, while Dependabot reports on
version ranges and found nine. Both were right.

Taken anyway: it is a patch release, and the next advisory in this
dependency may well land somewhere we do reach.

`go mod tidy` carried the indirect requirements grpc 1.83.2 asks for --
x/net, x/text, x/sys and friends. No CI job builds or tests Go here, so
all nine modules were built locally and api, ingest and enterprise
tested with -count=1, since a cached pass would not have exercised the
new version.

The dependabot.yml is the other half. There was no config, so nothing
opened a PR against any of this. Go majors stay out of the group, being
import path changes rather than bumps.
2026-09-10 09:01:27 -07:00
jcoffey-dev 7becb7344d Stop an owner deleting the account they are signed in as
Deleting your own user succeeded, and logged you out doing it:
local_sessions.user_id is ON DELETE CASCADE, so the delete took the
caller's own live session with it. Nothing refused this. The
last-owner guard is the only thing in the path, and it passes cleanly
as soon as a second owner exists -- which is exactly the state you are
in just after creating one.

The way back in was then whatever other account happened to exist, and
-seed-admin could not help: it skipped whenever *any* local user was
present, so the command documented as the way to create an
administrator refused precisely when there was no usable one, because
some other account still existed. It now asks whether the admin
account itself is missing, which is what its own help text always
claimed, and what makes it useful as recovery rather than only as
first-run bootstrap.

TestCanDeleteAnOwnerWhenAnotherRemains signed in as admin1 and deleted
admin1, asserting 204 -- it encoded the lockout as intended behaviour.
It now deletes the other owner, which is what it meant to cover, and a
new test holds the refusal in place.

runSeedAdmin takes a small interface so the bootstrap path is tested
without a Postgres pool; it had no tests before.

Signed-off-by: John Coffey <[email protected]>
2026-09-04 17:39:22 -07:00
jcoffey-dev 200f801e2c Clear the Dependabot findings
Dependabot alerts were switched on for this repo today and reported 12 open
findings. Ten are fixed here; the other two are addressed below.

gRPC 1.83.0 -> 1.83.1, in all nine modules that require it. This is
GHSA-vp52-pcj8-j9qc / CVE-2026-84304, heap memory exhaustion via HTTP/2 DATA
frame fragmentation, affecting <= 1.83.0. It matters more than the version
delta suggests: ingest/ is a gRPC listener deliberately exposed to the internet
on :4317, so a remote OOM is reachable. mTLS narrows that to holders of a
client certificate, which is why this was not an emergency, but the fix is one
patch release away and there is no reason to carry it.

golang.org/x/oauth2 0.21.0 -> 0.27.0 in deploy/operator, an indirect
dependency (GHSA-6v2p-p543-phr9). enterprise/ was already past it at 0.36.0.

npm cookie 0.6.0 -> 0.7.2, via an overrides entry rather than a dependency
bump. @sveltejs/kit requires ^0.6.0 and still does at 2.70.3, the latest
release, so there is no version of kit that resolves this on its own -- an
override is the only route that does not involve waiting on upstream.

Three incidental changes came out of `go mod tidy` and are not mine:
genproto/googleapis/rpc moved forward as a transitive of the new grpc; pgx/v5
was reclassified from indirect to direct in enterprise/, which is simply
correct, since audit.go and cmd/enterprise-auth import it; and the proto
replace directive shuffled between require blocks at the same version.

The twelfth finding, lru (GHSA-rhfx-m35p-ff5j), is not fixed and is not
fixable here -- see the note in the pull request. It is CVSS 0, a Stacked
Borrows soundness issue in IterMut, and reaching a patched version means
tantivy 0.22 -> 0.26, which is a search engine migration rather than a
dependency bump.

Verified: all ten Go modules build, 40 test packages pass, the web app builds
and svelte-check reports 0 errors across 288 files.
2026-09-03 11:12:35 -07:00
jcoffey-dev f756a9d4f6 Rename the project spec and update every reference to it
The charter file carried a tool-specific name while being the repository's own
document: mission, non-negotiable constraints, the pinned stack, repo
conventions and phase status, cited as authority by thirty files across the
agent, api, deploy, docs, search and terraform trees.

PROJECT-SPEC.md says what it is. All 42 references are updated in the same
commit, including the relative link in docs/status.md, so nothing points at a
filename that no longer exists.
2026-08-28 15:57:12 -07:00
jcoffey-dev 7a86008062 Complete the low-risk half of the Sentry -> Cairn OBS rebrand
Sweeps the references that carry no runtime coupling, and fixes one that
turned out to be a real bug rather than stale branding.

Docker network: sentry_default -> cairnobs_default across 23 runbook and
test-header `docker run` commands. Compose derives the network from the
directory name, so this lands together with renaming the working copy to
cairnobs/ -- the two are only correct as one change.

Stale references corrected: four Dockerfile "repo root (sentry/)"
headers; .env pointing at the long-renamed deploy/helm/sentry/ chart;
five Helm comments describing the topic as sentry.logs.raw when all four
code paths have defaulted to cairnobs.logs.raw for some time; an
absolute /home/john/Projects/sentry/ path in the operator's package doc,
now repo-relative; the hand-written Tenant CRD description in both of
its identical copies, whose Go source already said Cairn OBS.

Migration 0043 repoints the default tenant's data source. 0026 seeded it
with ('sentry', '/var/lib/sentry-search') to match what
api/internal/config then defaulted to; the rebrand later moved those
defaults to "cairnobs" and /var/lib/cairnobs-search without moving the
already-applied row, leaving the default tenant naming a ClickHouse
database nothing writes to. Scoped to the exact stale values so it is a
no-op on any deployment that set them deliberately. 0026's comment is
annotated as superseded; its applied SQL is untouched.

Deliberately not included: the gRPC wire packages (sentry.logs.v1,
sentry.agent.v1) and proto/sentry/ import paths, which cannot change
without a lockstep agent/server upgrade; the Helm chart's
sentry_metadata database and sentry role, which need a real Postgres
migration on existing deployments; and the compliance audit records in
docs/compliance/, which are a dated historical record.

go build, go vet, and go test pass for ingest and deploy/operator.
2026-08-22 18:39:25 -07:00
jcoffey-dev 6ee918d15f Let each user pick the timezone timestamps are displayed in
Everything stays UTC: ingest still records Unix nanoseconds, ClickHouse
still stores UTC, every API response is still RFC3339 with a Z, and
queries are evaluated exactly as before. This changes only how those
instants are written on screen, so two people in two timezones looking
at one log line see the same instant written two ways -- never two
different lines, and never a different sort order.

Where the preference lives differs by deployment, and the three cases
are genuinely different products rather than one with fallbacks:

  - Local login: server-side per named user (display_timezone on users,
    PUT /auth/timezone), so it follows the person across browsers and
    survives logout. Self-service at the RoleViewer floor, same as the
    password change -- a viewer is the role most likely to be *only*
    reading logs, so gating it higher would make it useless.
  - Public demo: sessionStorage, so every new session starts at UTC. A
    shared account's visitors have nothing to do with each other.
  - Neither: localStorage, since there's no per-user record to write to.

api/cmd/api/main.go now imports time/tzdata. The image is
distroless/static with no /usr/share/zoneinfo, so LoadLocation would
otherwise reject every real zone name and the validation would refuse
every valid input.

Two details worth knowing when reading $lib/time.ts. Sub-second digits
are copied verbatim from the source string rather than round-tripped
through a JS Date, which is millisecond-precision and would silently
drop six digits of a ClickHouse nanosecond timestamp; expanding a result
row shows the localized value and the full-precision UTC original
together. And chart axes format their own labels, because ECharts'
type: 'time' axis renders in the browser's zone with no override --
which today puts a chart's clock out of step with the table beside it.

Timestamps are detected by value, not by column name: query output is
arbitrary, so a column called "timestamp" holding something else must
not be mangled, and `stats max(timestamp) as newest` must still be
formatted.

Verified against real zones including both sides of a DST boundary
(America/New_York at -05:00 in January, -04:00 in July), a half-hour
offset, and date rollover.
2026-08-22 16:15:16 -07:00
jcoffey-dev 754996dbfd Parse a query that starts with a pipe-stage keyword
`stats count by host` with no leading filter is documented as valid in
/docs/query-language-reference.md, but every pipe-stage keyword is also
a valid bare identifier, so the parser read it as a base_search of four
ANDed free-text terms ("stats", "count", "by", "host") -- matching
nothing, and returning an empty result rather than an error, which is
the worst of both outcomes for anyone typing it.

Recognizing a leading stage keyword up front and skipping straight to
stage-parsing fixes it with no planner or executor change: q.Base stays
its zero value, and compileBoolExpr already treats zero terms as
match-everything. The comparator lookahead keeps a genuine field named
`where`/`stats` (`where=foo`) parsing as a filter, as before.
2026-08-22 16:12:17 -07:00
jcoffey-dev c920e0f2c4 Finish the Cairn OBS rename through services, docs, and assets
The rename commit before this one covered module paths and the obvious
user-facing strings; this is the rest of it -- the places where "sentry"
was a default value, a filename, or a picture rather than a word in a
sentence.

Defaults that changed: CLICKHOUSE_DATABASE (sentry -> cairnobs),
POSTGRES_DATABASE (sentry_metadata -> cairnobs_metadata), and
POSTGRES_USERNAME (sentry -> cairnobs), across api/alerting/ingest and
the enterprise binaries, plus the compose files and migrate scripts that
create those objects. These are *defaults*, so a deployment that sets
them explicitly is unaffected -- but any deployment relying on the old
defaults must have its environment updated before it picks this up, or
it will come up pointing at a database that doesn't exist.

Also: the light-mode logo variants (the dark ones existed alone, so the
landing page and sidebar rendered a dark mark on a light background),
regenerated favicons, and the docs/README/threat-model prose that still
said Sentry.
2026-08-22 16:12:08 -07:00
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00
jcoffey-dev 9e21ea17bb Fix log retention Settings section getting stuck on "Loading hosts…"
Root cause: handleHosts and partitionTargets both declared their result
slices with `var`, so an empty result (no logs old enough yet, or every
requested target blocked by a floor) marshaled to JSON `null` instead of
`[]` on fields without `omitempty`. The frontend's `.length` access on
that `null` threw mid-render, which is why this shipped with the spinner
stuck forever instead of the empty state ever painting -- production is
freshly deployed with nothing yet older than the default 30-day cutoff,
so every user hit this on first load.

Also replaces the static "Loading hosts…" text with the existing
shimmer Skeleton component for real visual feedback, and adds `?? []`
fallbacks in api.ts as a second line of defense.
2026-08-21 17:08:50 -07:00
jcoffey-dev 653e4efa76 Enforce the full user-management RBAC matrix, add self-service password change
api/localauth now enforces every rule of the requested matrix, each
checked inside the handler beyond RegisterRoutes' floor:
  - At least one owner must always exist -- handleDeleteUser and
    handleSetRole both refuse an operation that would leave zero
    owners (wouldRemoveLastOwner, backed by new store method
    CountUsersWithRole), whether the caller is admin or owner.
  - Owner can create/delete any role, including another owner (subject
    to the above). Admin can only create/delete viewer or editor --
    GET/POST /auth/users and DELETE .../{id} moved from RoleOwner to
    RoleAdmin floor, with an inner check narrowing what an admin
    caller specifically may target.
  - Only a user can change their own password -- new POST
    /auth/password (RoleViewer floor, i.e. every role) requires the
    caller's current password (verified via new store method
    GetPasswordHashByID) and is now the only path to changing your
    own, including for an owner. The existing admin-reset endpoint
    (POST /auth/users/{id}/reset-password, also moved to RoleAdmin
    floor) now refuses id == the caller's own ID, and refuses an
    owner target unless the caller is themselves an owner -- "admin
    can change any password except an owner's; owner can change any
    password, even another owner's."
  - Role reassignment (PUT .../{id}/role) stays owner-only, unchanged
    beyond the last-owner guard above.

New web/src/routes/account page (linked from NavSidebar next to "Log
out", visible to every local-auth role) is the self-service password
change UI. /users now mirrors the server's per-row restrictions
client-side (disabled role selects/delete/reset buttons with an
explanatory title, a restricted role list on the create form) so an
admin never sees an action that would just 403 -- the server remains
the actual authority.

Verified live against real Postgres and in the browser: the full
matrix via curl (owner creating a second owner, admin blocked from
creating/deleting/resetting admin or owner accounts, last-owner delete
and demote both blocked, admin resetting non-owner passwords,
self-target reset rejected, self-service change with wrong/right
current password), plus the actual /users page rendering correctly
restricted for an admin session and a full change-password round trip
through the real UI ending in a forced re-login with the new password.
2026-08-21 16:18:29 -07:00
jcoffey-dev 5ff2e5bb60 Scope log retention deletion and floors to (host, service), not host alone
logs rows carry a real per-record `service` (nginx, smtp, ufw, ...) --
already true of the schema (storage/migrations/0001) and wire protocol,
not something this feature invents. Both the deletion picker and the
retention floor now operate on (host, service) pairs instead of whole
hosts, so an operator can delete just one noisy log type from an agent
without touching everything else it ships, and can protect one service
(e.g. keep smtp a year) longer than the rest of that host's default.

api/agents.ConfigOverride gains ServiceLogRetentionDays (map[string]int),
owner-only to change like LogRetentionDays -- a service listed there
overrides the host's LogRetentionDays default for that service only.
Agent config page gets a matching "Per-service log retention overrides"
add/remove list next to the existing host-level field.

api/logretention: Store's count/delete now take []HostService and build
a ClickHouse tuple IN ((?,?),...) over (host, service); AgentRetentionStore.
FloorsByHost returns each host's default plus its per-service map, with
HostFloor.Effective(service) resolving which one applies. preview/delete
moved from GET/DELETE-with-query-params to POST-with-JSON-body (a list of
targets needs a real body, not a repeated compound query param), and
partitionTargets checks the floor per target so one protected service
never blocks deleting a different, unprotected one in the same request.

Settings' Log retention section is a two-level picker now: each host
row (with a "select all services" checkbox and its default floor badge)
expands to its services, each with its own count and effective
protected-days badge.

Verified live against real ClickHouse/Postgres and in-browser: a host
with a 7-day default plus a 365-day smtp override -- deleting nginx+
smtp+ufw together correctly removed nginx and ufw, left smtp's 10
records untouched, and confirmed via a follow-up owner delete that
bypassing the floor works. Also verified the full click-through (add a
service override on the agent page, see it reflected in Settings'
picker, select/preview/cancel) and confirmed no regression from the
prior host-only version's tests.
2026-08-21 15:54:58 -07:00
jcoffey-dev 087c52a64f Scope log retention deletion to selected hosts, not the whole table
api/logretention no longer deletes wholesale by age alone: a new
GET /logs/retention/hosts lists every host with matching records (plus
any configured retention floor), and preview/delete now require an
explicit, non-empty host list -- there is no "omitted host means every
host" shortcut server-side. Store's count/delete statements are
host-scoped (host IN (...)); Handler.partitionHosts checks the floor
per host instead of one global max, so a floor on one host never
blocks acting on other hosts requested in the same call. A request
that ends up fully or partially blocked still returns 200 with
blocked_hosts explaining why, rather than rejecting the whole call.

Settings' Log retention section is a host picker now: checkboxes with
per-host counts and a "protected Nd" badge where a floor applies,
"select all/none", and a confirm panel that names exactly which hosts
will be affected and which were skipped and why.

Verified live against real ClickHouse/Postgres and in-browser: three
hosts seeded, one protected by a 90-day floor -- a scoped delete
correctly removed the two open hosts' records, left the protected
host's untouched, and the response/UI both named it as skipped. Also
fixed a real spacing bug in the result message caught during that
browser pass (an adjacent {expr}{#if} with no source whitespace
between them rendered with no space either).
2026-08-21 15:32:05 -07:00
jcoffey-dev a20bb5d1c7 Add per-agent log retention floor, owner-only to set or override
api/agents.ConfigOverride gains LogRetentionDays: a per-agent setting
edited on the same remote-config page as extra_file_paths, but unlike
every other field there it's central-policy metadata api/logretention
reads, never something the agent process itself sees. Any change to
it -- setting, raising, lowering, or clearing -- requires RoleOwner,
not just RoleAdmin: the whole point of the field is a floor an admin
can't move, so an admin able to freely edit it would defeat that.

api/logretention now checks the largest LogRetentionDays configured
across any agent (AgentRetentionStore, new) before every preview/delete:
a non-owner's request is rejected with a clear 403 if it would reach
into that protected window. An owner always bypasses it, matching "make
the log retention override any attempts to delete logs by anyone other
than owner role."

Verified live end-to-end: owner sets a 90-day floor on an agent, admin
is blocked deleting anything newer than that (both preview and delete),
allowed beyond it, and owner bypasses it entirely -- confirmed against
real ClickHouse data, not just the fake-backed unit tests. Also caught
and fixed a real pre-existing latent bug while verifying in-browser: a
type="number" Input's bind:value becomes an actual JS number once a
user types into it (only the initial value is a string), which broke a
bare .trim() call on the new field.
2026-08-21 15:11:41 -07:00
jcoffey-dev 787def06fd Add owner/admin-only log retention deletion to Settings
New api/logretention package: GET /logs/retention/preview and
DELETE /logs/retention, both gated to RoleAdmin (Owner satisfies it
too), issue purpose-built parameterized statements against ClickHouse's
logs table (a count and a synchronous ALTER TABLE ... DELETE mutation)
rather than routing through querylang/executor's SELECT-only SQLRunner.

Settings gets a new "Log retention" section, visible only to an owner
or admin, that previews how many records a chosen age cutoff would
remove before showing an explicit confirm/cancel panel -- no delete
happens without that second step.

Scoped to core's single-tenant ClickHouse table; enterprise/'s
per-tenant routing and Tantivy's lack of a bulk-delete primitive are
disclosed gaps in api/logretention/store.go's doc comment, not silently
assumed to already work.
2026-08-21 14:49:18 -07:00
jcoffey-dev 864e68253a Give local users their own manager: custom passwords and role reassignment
Move user management out of Settings into its own /users page (nav-gated
to owners), let an owner type a specific password on reset instead of
always generating a random one, and add role reassignment via a new
PUT /auth/users/{id}/role endpoint. Role changes revoke the target's
existing sessions, same as a password reset, so a demoted user can't
keep acting under a stale, higher-privileged session.
2026-08-21 14:23:52 -07:00
jcoffey-dev 4b5dae5879 Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings
This is a large squashed commit covering two batches of prior uncommitted
work plus a full security-audit remediation pass, kept together because
go.mod/go.sum and several shared files (main.go, handler.go) were touched
by both and splitting risked non-building intermediate commits.

Features (built earlier, previously uncommitted):
- Local username/password login for single-tenant deployments with no
  SSO configured (api/localauth, alerting/internal/sessioncheck,
  sentryctl users, web/src/routes/login, metadata migrations 0040/0041).
- Remotely-editable additional log file paths for agents, on top of
  their existing primary source (api/agents, agent/sentry-agent
  extra-file-path diffing, web agent config UI).
- IPv4/IPv6 addresses reported alongside other host system metrics.

Security audit remediation (this pass, all live-verified in production):
- Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...)
  in the raw-SQL query escape hatch.
- High: deny sensitive paths and require Admin to add agent
  extra_file_paths (Editor could previously point an agent at /etc/shadow
  or an SSH key); alerting webhook targets now validate against
  internal/metadata/loopback addresses, both at creation and send time;
  alerting's session middleware now enforces an Editor+ floor on
  mutating requests instead of "any authenticated session"; bumped
  goxmldsig to close a SAML signature-verification bypass (GO-2026-4753).
- Medium: per-IP login rate limiting; security response headers
  (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy)
  on web/nginx.conf; a DevCredentialWarnings check in every Go service's
  config loader, logging loudly at startup if a deployment is still on
  docker-compose.yml's literal dev-only credentials; dependency bumps
  (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected
  Go module and both Rust crates, including a previously-uncovered x/net
  vulnerability in deploy/operator; a new security-scan.yml CI workflow
  running cargo-deny/govulncheck/npm-audit, mirroring the existing
  license-compliance.yml matrix shape.
- Low: removed sentryctl's plaintext --password flag (shell
  history/`ps` exposure) in favor of stdin and a --password-stdin flag
  for reset-password's optional specific-password path; a dummy bcrypt
  comparison closes a login response-time username-enumeration
  side-channel.
2026-08-18 23:53:20 -07:00
jcoffey-dev 93c160ec51 Add agent restart lifecycle command
Extends the existing CheckIn RPC with a one-shot AgentCommand
(restart only -- stop/uninstall need real per-platform OS
service-manager integration and stay deliberately out of scope),
delivered at-most-once: cleared the instant it's handed to the agent
in a response, since a restarting agent's process is gone before it
could ever confirm receipt. On restart, the agent flushes whatever's
buffered, aborts its source task, and exits cleanly, relying entirely
on the host's own service manager to bring it back up.

Issuing a command is gated at RoleAdmin (stricter than config
editing's RoleEditor) and logged into the same audit_log table Phase
7's AI interactions use, via a new agent_command event type.

A real bug was found and fixed during live verification: the first
implementation tried to atomically read-and-clear pending_command in
a single INSERT...ON CONFLICT statement using a sibling CTE
referenced only from RETURNING, on the assumption that Postgres
evaluates every part of a WITH query against one pre-statement
snapshot. That's wrong specifically for FOR UPDATE, which always
reads the latest row version including one written earlier in the
same statement -- confirmed empirically (a restart command was
always coming back empty even when genuinely pending, so the agent
never received it). Fixed by splitting into two real, ordered
statements inside one explicit transaction.

See /docs/agent-management-design.md's "Lifecycle commands" section.
2026-08-16 20:30:07 -07:00
jcoffey-dev 4f0da1ae5e Add agent inventory, management, and remote config
Extends the heartbeat mechanism with a second gRPC service on the same
mTLS channel (AgentControl.CheckIn, agent-initiated on the existing
heartbeat ticker -- still push-only, no inbound port on any agent) so
an agent reports its running config and can pick up an operator-set
override. A new web UI section (/agents) lists every agent that's
checked in, shows its reported config, and lets an operator edit a
narrow, deliberately-scoped subset remotely: batch/heartbeat tuning,
and (journald sources only) the unit filter.

TLS material and the ingest endpoint are never reportable or remotely
editable, by proto shape rather than a validation rule -- a bad or
malicious edit there could permanently strand an agent or redirect
where its logs go, unlike every other editable field, which only
degrades behavior.

An override lives only in the agent's memory (agent.toml is never
rewritten) and re-syncs on the agent's own schedule; changing the
journald filter aborts and respawns the source task since there's no
other way to change what's being tailed. Building the hot-reload path
surfaced a real, independent, pre-existing bug: shutdown was using
poll_timeout(), which only drains once flush_interval has elapsed,
silently dropping anything buffered more recently on every graceful
shutdown that landed between flushes -- fixed with a new unconditional
Batcher::flush_all(), now used at both shutdown and hot-reload.

Verified live end-to-end against a real stack: an edited heartbeat
interval changed a running agent's actual send cadence within one
check-in cycle (confirmed by the real timestamps landing in
ClickHouse), and an edited journald filter triggered a real source
restart, both reflected back in the next reported-config snapshot.

See /docs/agent-management-design.md.
2026-08-16 18:08:51 -07:00
jcoffey-dev 4df6931869 Add agent heartbeat monitoring and fix a query-language lexer bug
Agents now send an independent "still alive" record on a configurable
schedule (seconds/minutes/hours, [heartbeat] in agent.toml), separate
from real log traffic and tagged with a sentry.heartbeat attribute.
No new wire protocol -- it's an ordinary record through the same
PushBatch RPC/mTLS identity every log line already uses. Unavailability
alerting reuses the existing absence-condition alert rule type
unchanged; no new alerting code was needed. See
/docs/agent-heartbeat-monitoring.md for the design and how to build the
alert rule.

While verifying the alert rule live, found that the query language's
lexer never treated '-' as part of an identifier, so any unquoted
hyphenated filter value -- including the reference doc's own canonical
example, `host!=host-03` -- failed to parse at all. Fixed in
api/internal/querylang/lexer/lexer.go with regression tests; a leading
'-' still lexes as its own token so earliest=-1h/sort -count are
unaffected.
2026-08-16 18:08:05 -07:00
jcoffey-dev 7d316f92db Phase 7: AI-assisted query authoring (autocomplete, explain, fix, optimize, NL translation)
Adds a self-hosted (Ollama, qwen2.5-coder) model provider abstraction
with a pluggable opt-in cloud adapter, schema grounding, and a shared
cost/safety guard every AI-suggested query is assessed against --
compiling to and executing through the same unchanged Phase 2 IR/
compiler and Phase 4 tenant scoping as a hand-written query, no
parallel execution path.

Track A (built into the query bar): inline ghost-text autocomplete,
"Explain this query", "Fix this query" with a diff view, and a
rule-based "Optimize" suggestion. Track B: natural-language-to-query
translation, always a separate review step from execution, with
`sentryctl query --nl` requiring explicit confirmation to run.
Every accepted/dismissed translate-fix-optimize interaction is logged
into the same append-only audit_log table Phase 4 built.

Two real product bugs were found and fixed via live browser
verification (a Svelte effect re-running on every keystroke that
silently cancelled the ghost-text debounce; a ghost-text widget
positioned at document offset 0 instead of the cursor), and a real
costguard logic bug (unbounded-aggregation vs. raw-row) was caught by
its own test suite. New integration tests wire a real Ollama client
through the real HTTP handler against a mock server matching Ollama's
wire contract (hack/mock-ollama), keeping model-quality verification
out of CI as a disclosed, periodic human-run check instead.

See /docs/phase-7-ai-design.md and /docs/phase-7-runbook.md.
2026-08-16 18:06:27 -07:00
jcoffey-dev 5e8b3d8edd Add a real charting layer and a heatmap panel type
Five chart types on ECharts (modular imports, not the full bundle):
TimeSeriesChart (multi-series, legend toggle), BarChart (incl.
stacked), SingleStat (big number + sparkline + trend), Heatmap, TopN.
Shared interactions: tooltips, dataZoom feeding the global time-range
picker, click-to-drill-into-query (drilldown.ts strips a panel's query
to its pre-stats filter and appends the clicked series/x-value as a
new filter term -- no backend change needed).

pivot.ts reshapes the query language's existing {columns, rows} tabular
output into per-series chart data client-side -- `stats count by
service, timestamp` already returns "long" rows, so multi-series
support needed zero query-language changes. theme.ts reads real
computed CSS custom properties so charts render in the active theme's
actual colors, with an SSR_FALLBACK for adapter-static's prerender pass
where `document` doesn't exist.

heatmap is the one narrow, justified backend change: a new VizType
needed to feed a new visualization, not a new query capability. Three
places had to change together, not two -- api/dashboards/types.go's
validator, web/src/lib/api.ts's union (previous commit), and the
dashboard_panels table's viz_type CHECK constraint
(migrations/0035_add_heatmap_viz_type.sql), which mirrors the Go
validator and doesn't update itself.

/dev/charts (unlisted, dev-only) is a synthetic fixture/perf-test route:
confirmed 50ms first-two-frames render time on a production build
against a 30,006-row/6-series stress case, and a 211,975-byte gzipped
chart chunk -- both real measurements behind the ECharts-over-
Observable-Plot-or-D3 choice, not estimates.
2026-08-16 12:35:40 -07:00
jcoffey-dev 5365c92ffa Validate panels in Store.AddPanel/UpdatePanel, not just at the handler
CreateDashboard's inline panel-creation path already called
validatePanel before insert; AddPanel and UpdatePanel relied on the
HTTP handler to validate first instead of enforcing it themselves.
Found via a live Postgres integration test: calling store.AddPanel
directly (bypassing the handler) hit a viz_config NOT NULL constraint
violation instead of getting the same default-empty-JSON treatment
every other panel-creation path gets.
2026-08-15 17:17:16 -07:00
jcoffey-dev abeee0076b Build and browser-verify the tenant-picker frontend page
web/src/routes/select-tenant now calls enterprise-auth's existing
GET /auth/memberships / POST /auth/select-tenant protocol (built earlier
this phase, previously called only from Go tests) via
fetch(..., {credentials: 'include'}) -- new listMemberships/selectTenant
functions in $lib/api.ts, using a dedicated request helper that reads
plain-text error bodies (loginhandler's http.Error responses), unlike
every other request helper in that file which expects JSON.

Credentialed cross-origin fetch needed CORS enterprise-auth didn't have:
api/httpserver.WithCORS's wildcard-friendly default can't be combined
with a credentialed request at all (browsers refuse to honor
Access-Control-Allow-Origin: "*" on one) -- added WithCredentialedCORS
(literal origin, Access-Control-Allow-Credentials: true) alongside it,
wired into enterprise-auth via a new CORS_ALLOWED_ORIGIN config var
defaulting to POST_LOGIN_REDIRECT_URL (web's own origin, the same
default pattern SELECT_TENANT_REDIRECT_URL already used).

adapter-static's route crawler doesn't discover a page nothing links to
(this one is only ever reached via enterprise-auth's redirect) -- fixed
with select-tenant/+page.ts's `export const prerender = true`, the same
declaration every other route already has.

Genuinely verified in a real browser in this environment, not just
type-checked: a throwaway Node server standing in for enterprise-auth's
exact wire contract (including its plain-text error bodies), driven
through the full flow via mcp__claude-in-chrome -- cross-origin
pending-login cookie set, credentialed preflight + GET/POST round trip,
a real click choosing a tenant, the post-selection redirect, and the
missing/expired-cookie error path rendering the backend's actual
message. No Docker or live Postgres/IdP needed, since the point was
exercising web's own fetch/CORS/cookie wiring, not enterprise-auth's
internals (already covered by loginhandler's own tests).

This closes the tenant-picker as the last named gap in Phase 4. What's
left is the already-disclosed live-verification caveat shared by every
Postgres/ClickHouse-backed piece and both SSO protocols: none of this
has run against a real database, external IdP, or multi-container
deployment in this environment.
2026-08-14 23:02:09 -07:00
jcoffey-dev 2e698f5623 Close the last tenant-isolation adversarial probe (mid-provisioning tenants)
Phase 4 task 8's verification plan named four adversarial probes;
three were closed earlier this phase, the fourth (an evaluator tick,
or any other caller, hitting a tenant that exists but hasn't reached
the active+credentialed gate yet -- must be refused, not served) was
still an explicitly-skipped stub in
api/queryapi/tenant_isolation_gap_test.go.

Investigating it found the two storage engines needed genuinely
different treatment:

- ClickHouse (enterprise/internal/chrunner) already had this property
  structurally, for free: Registry is built once at startup from
  rbacstore.ListProvisionedDataSources, which already filters to
  active+credentialed tenants only, so a mid-provisioning tenant is
  simply absent from the connection map. New test
  TestRegistryRefusesMidProvisioningTenant proves this without Docker
  -- an empty DataSource list never dials ClickHouse, so this genuinely
  runs in this environment, unlike every other test in that file.

- Tantivy (search/src/registry.rs's IndexRegistry) was a real, different
  gap, not just an unverified assumption: it opens-or-creates an index
  for any syntactically-valid tenant_id on first request, because it's
  a separate process with no Postgres access and structurally can't
  know which tenants are actually provisioned. A query against a
  mid-provisioning tenant would have silently succeeded with zero
  results from a freshly-created empty index -- "ambient success"
  indistinguishable from "no matching logs," exactly the failure mode
  this item was worried about.

Fixed the Tantivy gap with a new enterprise/internal/searchclient.
TenantChecker interface (backed by a new rbacstore.TenantIsActive,
implemented structurally, no new import edge needed), consulted before
every gRPC call: Client.Search now refuses a non-active tenant before
it ever reaches `search`. Dial's signature gained a required
TenantChecker parameter; enterprise-api's main.go passes its existing
rbacstore.Store (already satisfies the interface). Verified Docker-free
via searchclient's existing real-in-process-gRPC-server test harness
(TestSearchRefusesMidProvisioningTenant, plus
TestSearchPropagatesTenantCheckerError for the fail-closed-on-error
case) -- both genuinely run in this environment, same bar as the rest
of the Tantivy isolation work.

rbacstore.TenantIsActive itself has two new skip-gated live-Postgres
tests (TestTenantIsActive, TestTenantIsActiveNonexistentTenant) --
disclosed as not run against a live database here, same gap as the
rest of this phase's Postgres-backed pieces.

api/queryapi/tenant_isolation_gap_test.go rewritten from a checklist
with one skipped stub to a full accounting of all four now-closed
probes. Docs updated in lockstep: CLAUDE.md, threat-model.md,
phase-4-isolation-design.md (implementation note added after its
original sign-off), phase-4-runbook.md (§9), enterprise/README.md.
2026-08-14 08:07:52 -07:00
jcoffey-dev 243f4dc2ab Enforce per-resource dashboard grants (RBAC matrix's own/granted qualifier)
api/dashboards' handler previously enforced only tenant-baseline role
(RoleEditor+), so any Editor could edit/delete any dashboard in their
tenant -- the matrix's "(own/granted)" qualifier was explicitly named
as unbuilt in this handler's own doc comment. This closes that gap.

New core interface api/dashboards.PermissionStore (nil-safe, same "not
wired == no-op" shape as authz.Authorizer) resolves a per-resource
dashboard_permissions grant. canEditDashboard now requires the
identity be Admin/Owner, the dashboard's creator, or hold a grant of at
least Editor; canManageGrants is deliberately stricter (creator or
Admin/Owner only, never grant-derived access) so a user who can edit a
dashboard only because of a grant can't extend or re-grant that access
to themselves or others. Wired handlers: PUT/DELETE
/dashboards/{id}/permissions/{userId}, GET .../permissions.

Two real bugs found and fixed while wiring this up, before any of it
touched a live database:
- handleCreate/handleImport never stamped created_by from the
  authenticated identity, so every dashboard was owned by "anonymous"
  regardless of who made it -- the ownership check would have been
  meaningless. Also fixed: ImportDashboard trusted the exported JSON's
  created_by verbatim, so re-importing someone else's export would
  leave the actual importer unable to edit their own copy.
- metadata/migrations/0024_create_dashboard_permissions.sql's CHECK
  constraint diverged from /docs/phase-4-rbac-design.md's schema
  (allowed role='admin', nullable granted_by). Reconciled via
  0033_restrict_dashboard_permissions_role.sql: Admin/Owner already
  have tenant-wide access so a resource-level "admin" grant is
  meaningless, and every real grant now always has an attributable
  granter.

enterprise/internal/rbacstore gets the storage side: raw CRUD
(dashboard_permissions.go) plus DashboardPermissions
(dashboards_adapter.go), an adapter implementing
api/dashboards.PermissionStore -- same pattern as audit.QueryAPILogger
over queryapi.AuditLogger. Wired into enterprise/cmd/enterprise-api
only; plain api/cmd/api passes nil (ownership/Admin checks still work
via the nil-permissions fallback, just without the "granted" bonus).

Verified: the full own/granted/admin/creator matrix, including the
granted-editor-cannot-manage-grants regression, passes against a fake
PermissionStore (api/dashboards/handler_test.go, all existing tests
also still pass unmodified in behavior). Real integration tests exist
in enterprise/internal/rbacstore/rbacstore_test.go (skip-gated on
RBACSTORE_TEST_POSTGRES_ADDR, same convention as every other
Postgres-backed piece this phase) but have not run against a live
database in this environment -- disclosed in threat-model.md,
phase-4-runbook.md, and enterprise/README.md alongside every other
piece carrying the same gap. Also fixed a stale path in
phase-4-runbook.md's dashboards-tenant-scoping section
(./internal/dashboards/... -> ./dashboards/..., stale since that
package moved out of api/internal/ earlier in this phase).
2026-08-14 07:11:18 -07:00
jcoffey-dev ba2276aa1a Phase 4: real Tantivy per-tenant isolation (search/src/registry.rs, enterprise/internal/searchclient)
Closes the last named "isolation mechanism" gap: search.proto gains a
tenant_id field on SearchRequest; search/src/registry.rs's IndexRegistry
resolves it to an on-demand-opened, per-tenant Tantivy index (empty
tenant_id keeps today's single default index, so this is purely
additive); enterprise/internal/searchclient sets that field from the
authenticated request identity in ctx, mirroring chrunner's exact
fail-closed "never a parameter" shape. Wired into enterprise-api in
place of the shared api/searchclient.

Unlike the ClickHouse pieces from the previous two commits, this one is
genuinely verified end to end in this environment: Tantivy is an
embedded library, not a networked service, so both the Rust index
registry (cargo test, cargo clippy --all-targets -- -D warnings, both
clean) and the Go client (a real in-process gRPC server) could actually
run. registry.rs's tenant_index_is_isolated_from_default_and_other_tenants
seeds three real indices with the same term and confirms a tenant-scoped
search returns only that tenant's document -- item 3 of the isolation
design doc's verification plan, closed for real, not just written.

With both ClickHouse and Tantivy isolation now built, the single largest
remaining gap is no longer a missing mechanism: it's that nothing forces
or flags whether a deployment actually runs enterprise-api instead of
plain api, and that ingest itself has no tenant concept for either
storage engine (every record still lands in the one shared database/
index no matter what -- undesigned, not just unbuilt). Updated the
threat model, architecture doc, CLAUDE.md, and both READMEs accordingly.
2026-08-13 23:16:22 -07:00
jcoffey-dev 1d57e697b1 Phase 4: real per-tenant ClickHouse isolation via a new enterprise-api binary
Closes the threat model's headline finding for the SQL query path:
enterprise/internal/tenantprovision does real CREATE DATABASE/USER/GRANT
against ClickHouse, and enterprise/internal/chrunner is a per-tenant
connection registry implementing api's SQLRunner interface, resolving
the tenant from the authenticated request identity -- never a
caller-suppliable parameter. Both are wired into a new binary,
enterprise/cmd/enterprise-api, alongside the unchanged single-tenant
api/cmd/api, since AGPL core can never import enterprise/ and Go's own
internal/ package visibility rules meant enterprise/ couldn't implement
core's SQLRunner interface without importing the package that defines
it. That required moving api/internal/{authz,queryapi,dashboards,
querylang/executor,searchclient,httpserver} out of internal/ -- the
minimal set enterprise-api needs to import; querylang's compiler
internals (planner/lexer/parser/ast/ir) and api's own config stay
internal, since nothing outside api needs them directly.

Also finally wires enterprise/internal/audit into queryapi.AuditLogger
(nil since Phase 4 task 4) via a new adapter, and adds live-ClickHouse
integration tests for two of the four adversarial probes named in
docs/phase-4-isolation-design.md's verification plan.

Corrected several overclaims in the docs while writing this up: an
earlier claim that rbacstore's CRUD was "verified against a live
Postgres" was never actually true in this environment (only
internal/audit was, earlier in this phase, before Docker access was
lost) -- threat-model.md, phase-4-runbook.md, CLAUDE.md, and
enterprise/README.md all now distinguish "a real integration test
exists" from "this was confirmed against a live database."

Still not built: Tantivy/free-text tenant isolation
(enterprise/internal/searchclient), and any deployment-topology
mechanism that actually routes traffic to enterprise-api instead of
plain api -- both binaries exist side by side today with nothing
enforcing or flagging which one a deployment runs.
2026-08-13 22:48:38 -07:00
jcoffey-dev 3eb0f4c589 Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment
RBAC (api/internal/authz) is live on /query and /dashboards, backed by a
new enterprise/ module (session issuance, audit logging, RBAC storage,
OIDC/SAML protocol wiring) that core never imports -- only calls over
HTTP. Found and fixed a real cross-tenant vulnerability in dashboards
(no tenant_id filtering at all) while writing the threat model doc.

Two things are explicitly NOT done, documented rather than hidden:
tenant isolation for log data itself (/query still shares one ClickHouse
connection and Tantivy index across every tenant -- RBAC controls who
can query, not what a query can see), and human SSO login (protocol
wiring exists, no HTTP handler calls it yet). See
docs/security/threat-model.md and docs/phase-4-runbook.md.

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
2026-08-13 22:16:59 -07:00
jcoffey-dev 9435115ab7 Phase 3: dashboards and alerting
Saved, shareable multi-panel dashboards (table/line/bar/single-stat
panels via gridstack + uPlot, global + per-panel time range, JSON
export/import) and threshold/absence alert rules with an
ok/pending/firing evaluator and webhook/Slack/PagerDuty delivery.

- New /metadata component: Postgres control-plane store for dashboards,
  panels, notification targets, alert rules/state, and delivery log --
  see docs/phase-3-dashboard-design.md for why ClickHouse's MergeTree
  family isn't a fit for this access pattern (needs real row-level
  locking and read-your-writes consistency).
- api/internal/dashboards: dashboard/panel CRUD, pure -- panel query
  execution stays client-side, reusing the existing /query endpoint.
- New /alerting service: rule/target CRUD, a ticker-driven evaluator
  (claim-then-evaluate concurrency control, transactional-outbox
  delivery, query errors and threshold zero-rows never coerced into a
  false transition) and webhook/Slack/PagerDuty delivery with
  retry/backoff. See docs/phase-3-alerting-design.md for the full
  state-machine design and the four correctness properties it
  implements.
- web: /dashboards and /alerts UIs; cli: sentryctl dashboards/alerts
  list/get/apply, seeding a future Terraform provider's JSON contract.
- hack/alert-load-test: 500 rules against real ClickHouse data, real
  measured results in docs/phase-3-runbook.md.

Five real bugs found by actually running this against a live stack
(documented in the runbook, not just fixed silently): a latent Phase 2
bug where ClickHouse rejected the timestamp format used for
earliest=/latest= queries; a "now" literal token injected into query
text; a GridStack/uPlot layout-timing race; JS's Date.parse being too
lenient to use as a timestamp-detection heuristic; a rule's "enabled"
field silently defaulting to false when omitted; and the evaluator's
claim-batch-size and worker-pool-concurrency defaulting to the same
value, causing 500 concurrently-due rules to take 125s to cycle through
instead of the configured 60s.
2026-08-13 17:29:38 -07:00
jcoffey-dev fb5049a747 Phase 2: unified query language spanning ClickHouse and Tantivy
Replaces the separate SQL-only /query and text-only /search endpoints
with one pipe-syntax query language (plus raw SQL escape hatch) that
compiles to a single IR and execution plan across both backends, so a
query like `message:"connection refused" | stats count by host` runs
as one request instead of two disjoint tools.

- api/internal/querylang: lexer -> ast -> parser -> ir -> planner ->
  executor, each layer independently tested.
- Execution generalizes Phase 1's proven Tantivy-prefilter pattern
  into a 4-way routing table (pure ClickHouse / text-only / text +
  aggregation / raw SQL passthrough).
- Unified web query page and `sentryctl query`, both hitting the same
  POST /query endpoint.
- Benchmarked against a real 1,022,000-row dataset
  (hack/benchmark-fixture); caught and fixed a real bug where the
  Tantivy prefilter cap (10,000) produced an IN-clause exceeding
  ClickHouse's default max_query_size -- lowered to 5,000, documented
  in docs/query-language-design.md and docs/phase-2-runbook.md.
- docs/query-language-reference.md: customer-facing syntax reference.
2026-08-13 12:21:42 -07:00
jcoffey-dev cd8aa290ca Phase 1: Windows log collection + full-text search
Extends the agent, ingest, storage, api, and web with Windows Event
Log/ETW sourcing and Tantivy-backed free-text search, per the approved
Phase 1 plan.

- CLAUDE.md: materialized on disk (never existed as a file before) with
  a new Phase 1 "done looks like" section.
- agent: Windows Event Log (EvtSubscribe) and ETW sources, Windows
  service wrapper (install/uninstall/run-service), both feature- and
  target_os-gated so Linux builds/tests/clippy stay unaffected. Also
  fixed two pre-existing Phase 0 clippy gaps (dead-code on
  default-features-only builds, a type-inference edge case) found while
  testing every feature combination properly for the first time.
  UNVERIFIED on real Windows -- no Windows toolchain existed anywhere in
  the build environment; flagged prominently in three places.
- proto/ingest: new record_id field, assigned once server-side in
  ingest's gRPC front end so ClickHouse and Tantivy agree on the same ID
  for the same record.
- storage: record_id column + bloom filter index, verified against a
  live ClickHouse.
- search: new service, Tantivy index, rskafka consumer as an independent
  second consumer group on the same Redpanda topic ingest already reads.
- api/web: new /search endpoint and page, sharing the query page's
  result-table shape and component.
- hack/windows-fixture: sends realistic Windows-shaped data straight to
  ingest, so the pipeline's handling of it is verifiable without a
  Windows host.

Verified end-to-end on the live docker-compose stack: the same record_id
comes back from both /query and /search for the same log line, including
for windows-fixture's synthetic Windows Event Log data. Real bugs found
and fixed along the way: api/Dockerfile missing proto/ in its build
context, search's logs being completely silent (RUST_LOG gap), and
search/target/ missing from .gitignore/.dockerignore.
2026-08-13 11:27:35 -07:00
jcoffey-dev b6b092c912 Scaffold Phase 0: agent -> Redpanda -> ingest -> ClickHouse -> api -> web
End-to-end log pipeline for Linux hosts, per /docs/architecture.md:

- proto: shared gRPC contract (agent <-> ingest), Go bindings checked in
- agent: Rust, musl-targeted, journald/file sourcing, RFC5424 parser,
  mTLS gRPC client, no required config for the common case
- ingest: Go, single binary with --mode server|consumer|all; gRPC front
  end forwards to Redpanda unchanged, consumer normalizes and
  batch-writes to ClickHouse with at-least-once delivery
- storage: ClickHouse schema + a plain SQL-file migration runner
- api: minimal SELECT-only query endpoint, plain REST (not gRPC+gateway
  yet -- see api/README.md)
- web: SvelteKit static SPA, one query page
- transport: Redpanda compose + topic provisioning
- cli: sentryctl ping stub
- hack/dev-certs: throwaway CA + cert generation for local mTLS
- root docker-compose.yml + docs/phase-0-runbook.md tie it together

Not yet run end-to-end against real Docker/ClickHouse/Redpanda -- see the
runbook's caveats section before relying on this working as-is.
2026-08-13 08:25:19 -07:00