fix(query): enforce ClickHouse resource caps server-side#335
Conversation
…-query Settings
The native ClickHouse connection sent no per-query Settings, so a public
structured read's policy caps only ever bound it client-side: a Go context
deadline (which cancels while the client reads result blocks) plus a SQL
LIMIT. Memory and rows scanned were never bounded server-side, so a heavy
aggregation could allocate GBs of state or scan an entire table well within
the time budget — and a sub-second max_execution_time_ms cap reached
ClickHouse with no server-side bound at all (clickhouse-go only derives
max_execution_time from context deadlines > 1s).
Attach per-query Settings derived from the role's resolved permissions on the
structured-query path so a query can't outrun its budget during a server-side
scan / merge / aggregation phase:
- max_execution_time <- max_execution_time_ms (fractional seconds; closes
the sub-second gap the driver heuristic misses)
- max_result_rows+throw <- max_rows (defense-in-depth behind the SQL LIMIT)
- max_rows_to_read+throw<- new max_rows_to_read policy field (rows scanned)
- max_memory_usage <- new max_memory_usage_bytes policy field (peak mem)
A 0 cap means "no limit" and is omitted. The pipe path carries no per-role
ResolvedPermissions and is left unchanged (separately tracked).
Tests:
- internal/api: unit test of the cap->setting mapping (chReadSettings)
- internal/policy: Evaluate carry-through + negative validation
- tests/integration: handler-level proof vs real ClickHouse — a viewer with
max_rows_to_read=1 / max_memory_usage_bytes=1 is rejected (CH code 158 /
241); the uncapped control returns all rows. Verified RED before the fix.
- tests/e2e: same enforcement through the full SDK -> WaveHouse -> ClickHouse path
- docs: access-control.mdx resource-limits section + field table
Closes #316
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📜 Recent review details⏰ Context from checks skipped due to timeout of 300000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
🧰 Additional context used📓 Path-based instructions (3)docs/src/content/docs/**/*.{md,mdx}📄 CodeRabbit inference engine (AGENTS.md)
Files:
**/*.go📄 CodeRabbit inference engine (AGENTS.md)
Files:
internal/policy/**/*.go📄 CodeRabbit inference engine (AGENTS.md)
Files:
🪛 LanguageTooldocs/src/content/docs/access-control.mdx[typographical] ~307-~307: Insert a space between the numerical value and the unit symbol. (UNIT_SPACE) [typographical] ~307-~307: In American English, use a period after an abbreviation. (MISSING_PERIOD_AFTER_ABBREVIATION) [typographical] ~307-~307: Insert a space between the numerical value and the unit symbol. (UNIT_SPACE) [typographical] ~307-~307: Insert a space between the numerical value and the unit symbol. (UNIT_SPACE) 🔇 Additional comments (3)
📝 WalkthroughSummary by CodeRabbit
WalkthroughDerive per-query ClickHouse Settings from resolved role permissions (time, result rows, rows-to-read, memory), apply them to structured-query execution, add typed policy scalars and configurable query.default_max_rows, and update config, tests, and docs accordingly. ChangesServer-side Query Resource Caps
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
📚 Docs preview is live → https://47c50767-wavehouse-docs.wave-rf.workers.dev
|
Code Coverage OverviewLanguages: Go GoThe overall coverage in the branch remains at 89%, unchanged from the branch. Show a code coverage summary of the most impacted files.
Updated |
… defaults Refines the #316 resource-cap work (per review/design discussion) before the schema ships: Human-readable, config-consistent policy fields (pre-launch rename): - max_execution_time_ms (int) -> max_execution_time (Go duration string "5s") - max_memory_usage_bytes (int) -> max_memory_usage (size string "4GiB") - max_rows_to_read stays an int Backed by a new internal/units package: Duration + ByteSize, each with a single TextMarshaler/TextUnmarshaler pair that serves JSON, YAML, and env (encoding/json and yaml.v3 both route string scalars through the Text methods; cleanenv uses TextUnmarshaler). ByteSize parses binary RAM semantics via docker/go-units (already an indirect dep; now direct): "2G" == "2GB" == "2GiB". Server-wide defaults — new query_limits config block: - default_max_rows (10000) makes the formerly hard-coded query.DefaultMaxRows a documented, tunable knob (Build now takes it as a parameter) - default_max_rows_to_read (0 = off) and default_max_memory_usage (4GiB) Applied to every NON-ADMIN read on BOTH the structured-query and named-pipe paths (closing the pipe-path gap), enforced server-side. Precedence is most-specific-wins: a per-table-per-role policy cap overrides the default; admin bypasses these DoS backstops entirely. Tests: internal/units round-trip (JSON/YAML/env); config parse + validation; configurable builder default; chReadSettings + resolveReadBudget precedence; integration cases for the global default + admin bypass (structured) and the pipe path. Docs (access-control.mdx, configuration.mdx), config.yaml, SDK RolePermissions type, and CHANGELOG updated for the new schema. Refs #316 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # go.mod
Reworks the resource-cap design (per discussion) so the WaveHouse/ClickHouse
boundary is principled: WaveHouse owns the DYNAMIC, per-role/per-claim caps
(sent as per-query settings on its single connection — what ClickHouse RBAC
can't express without a credential per role); the STATIC, server-wide backstop
lives in ClickHouse's own settings profiles and quotas, where it enforces
uniformly across every query (named pipes and raw admin SQL included) and
composes with the per-role caps.
Changes vs the prior iteration:
- Drop the WaveHouse global query_limits DoS defaults (default_max_rows_to_read,
default_max_memory_usage) and the admin-bypass/resolveReadBudget machinery.
Config keeps only query.default_max_rows (the result-LIMIT pagination knob).
- Revert the pipe-path per-query settings — pipes are governed by ClickHouse's
server-wide config + query_timeout, not per-role policy.
- Delete internal/units. The two value types move into the policy package
(internal/policy/scalars.go: Millis, ByteSize) and switch to "human-in,
number-out": UnmarshalJSON/YAML accept a string ("5s"/"4GiB") OR a bare
number (ms/bytes); no Marshaler, so reads return the canonical number and
SDKs never reimplement humanization. ByteSize parses via dustin/go-humanize,
so 4GB (10^9) and 4GiB (2^30) differ correctly (go-units, which conflated
them, is dropped back to indirect).
- Docs: new "Server-side resource limits" section in configuration.mdx with
ClickHouse settings-profile / quota XML examples and the readonly/constraints
composition rule; access-control.mdx points global governance at ClickHouse
and documents the human-in/number-out fields.
Tests: policy scalar round-trip (string|number in, number out, SI/IEC),
config query.default_max_rows, integration per-role enforcement (codes
158/241). SDK type, config.yaml, CHANGELOG updated.
Refs #316
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 179baf7f-b359-4a16-a526-adf4a8c2ffa2
📒 Files selected for processing (22)
CHANGELOG.mdclients/ts/src/types.tscmd/wavehouse/main.goconfig.yamldocs/src/content/docs/access-control.mdxdocs/src/content/docs/configuration.mdxgo.modinternal/api/ch_settings.gointernal/api/ch_settings_test.gointernal/api/structured_query.gointernal/api/structured_query_test.gointernal/config/config.gointernal/config/config_test.gointernal/policy/policy.gointernal/policy/policy_test.gointernal/policy/scalars.gointernal/policy/scalars_test.gointernal/query/builder.gointernal/query/builder_test.gotests/e2e/sdk/query.test.tstests/integration/identifier_roundtrip_test.gotests/integration/query_limits_test.go
📜 Review details
⏰ Context from checks skipped due to timeout of 300000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Coverage
🧰 Additional context used
📓 Path-based instructions (10)
**/*.go
📄 CodeRabbit inference engine (AGENTS.md)
**/*.go: Use Go 1.26 with strict formatting enforced by gofumpt
Use structured logging with log/slog (JSON handler)
Use Chi v5 for HTTP routing
Return errors, don't panic. Wrap with fmt.Errorf("context: %w", err)
Use package naming: lowercase, single word (or abbreviated). internal/ enforces module privacy
No global state: Dependencies are passed explicitly (constructor injection)
Comment the why, not the what. Add a comment only when the reason isn't obvious from the code; a line that matches the surrounding pattern needs none. Keep comments to 1–2 lines
DRY — one source of truth. Before adding logic, look for an existing helper, type, or constant to reuse; before duplicating a rule, factor it into one place every caller reads
Leave it neater than you found it — within reason. Fix small, safe things in passing: a stale comment, an obvious typo, a misnamed local, dead code on your path
Files:
internal/api/ch_settings.gointernal/policy/scalars.gointernal/api/ch_settings_test.gointernal/api/structured_query_test.gotests/integration/query_limits_test.gocmd/wavehouse/main.gointernal/policy/policy_test.gointernal/policy/policy.gointernal/config/config_test.gotests/integration/identifier_roundtrip_test.gointernal/config/config.gointernal/policy/scalars_test.gointernal/api/structured_query.gointernal/query/builder.gointernal/query/builder_test.go
internal/api/**/*.go
📄 CodeRabbit inference engine (AGENTS.md)
Chi HTTP router, JWT/JWKS middleware (from auth/), ingest/query/structured-query/SSE/schema/DLQ/policy/pipes handlers, Hub
Files:
internal/api/ch_settings.gointernal/api/ch_settings_test.gointernal/api/structured_query_test.gointernal/api/structured_query.go
internal/policy/**/*.go
📄 CodeRabbit inference engine (AGENTS.md)
Hasura-style access control: Policy/TablePolicy/RolePermissions types, Evaluate() engine with JWT claim templating, NATS KV store (WAVEHOUSE_POLICY). policy.IsAdmin (role == admin_role, exact case-sensitive, default "admin") is the single admin check
Files:
internal/policy/scalars.gointernal/policy/policy_test.gointernal/policy/policy.gointernal/policy/scalars_test.go
**/*_test.go
📄 CodeRabbit inference engine (AGENTS.md)
**/*_test.go: Use table-driven tests with tests := []struct{ name string; ... } and t.Run(tt.name, ...)
Use shared mocks from internal/testutil/ (MockPublisher, MockCache, MockDeduplicator, MockSubscriber) instead of creating ad-hoc mocks
Use testutil.MakeJWT(t, claims) and testutil.MakeExpiredJWT(t, claims) for auth tests
Use testutil.NewTestSchemaRegistry(tables) or discovery.NewSchemaRegistryFromMap(tables) for schema-aware tests
Use policy.NewMemoryStore(p) for in-memory policy testing without NATS
Use pipes.NewMemoryStore(queries...) for in-memory pipes testing without NATS
Use testutil.AssertJSONResponse(t, rec, status, expected) and testutil.AssertJSONContains(t, rec, status, substring) for response assertions
Files:
internal/api/ch_settings_test.gointernal/api/structured_query_test.gotests/integration/query_limits_test.gointernal/policy/policy_test.gointernal/config/config_test.gotests/integration/identifier_roundtrip_test.gointernal/policy/scalars_test.gointernal/query/builder_test.go
docs/src/content/docs/**/*.{md,mdx}
📄 CodeRabbit inference engine (AGENTS.md)
docs/src/content/docs/**/*.{md,mdx}: Author Mermaid diagrams vertically (flowchart TB/TD) to fit page column width (~46–58rem); reserve LR for genuinely short chains (≤3–4 nodes)
Keep Mermaid node labels short; use
for a second line rather than one long line; lean on semantic node classes (wh, win, pain, fail, infra, neutral, store, client)
Never sit two large diagrams side-by-side; wrap comparisons in…to stack them vertically
Files:
docs/src/content/docs/configuration.mdxdocs/src/content/docs/access-control.mdx
tests/integration/**/*_test.go
📄 CodeRabbit inference engine (AGENTS.md)
Go integration tests (//go:build integration; ClickHouse testcontainer); run via make test-integration
Files:
tests/integration/query_limits_test.gotests/integration/identifier_roundtrip_test.go
**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
TypeScript SDK (
@wavehouse/sdk): zero-dep client, typed query builder, real-time SSE, live queries (incrementable/decomposable/poll aggregation), codegen CLI
Files:
clients/ts/src/types.tstests/e2e/sdk/query.test.ts
internal/config/**/*.go
📄 CodeRabbit inference engine (AGENTS.md)
YAML + env var config loading (cleanenv); add field with yaml, env, and env-default tags
Files:
internal/config/config_test.gointernal/config/config.go
tests/e2e/sdk/**/*.test.ts
📄 CodeRabbit inference engine (AGENTS.md)
tests/e2e/sdk/**/*.test.ts: E2E integration tests via TypeScript SDK (Vitest); each test file owns its own ClickHouse tables (clicks_/events_/users_); add suite name to SUITES in tables.ts and get names via const T = suiteTables("")
Test files run sequentially (maxWorkers: 1 in vitest.config.ts) due to shared global policy state; policy-mutating tests snapshot the full policy and restore it
Files:
tests/e2e/sdk/query.test.ts
internal/query/**/*.go
📄 CodeRabbit inference engine (AGENTS.md)
Structured query AST types + SQL builder with schema validation, permission injection, timestamp bucketing. Every column reference — projection, aggregation args, filters, group_by, order_by, time_range — is authorized inside query.Build (the single chokepoint)
Files:
internal/query/builder.gointernal/query/builder_test.go
🧠 Learnings (3)
📚 Learning: 2026-05-20T01:02:00.784Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 164
File: internal/api/router_test.go:289-350
Timestamp: 2026-05-20T01:02:00.784Z
Learning: In WaveHouse’s internal API tests (files matching internal/api/**/*_test.go), follow the existing separation-of-concerns convention for testing the RequireRole middleware: inject `ContextKeyRole` directly into the request `context.Context` instead of using `testutil.MakeJWT`/JWT-driven flows. Do not refactor role-gate tests to use JWT tokens—JWT parsing and token handling are covered separately in `middleware_test.go` (the dedicated JWT parsing tests), and mixing those concerns would expand the failure surface and reduce isolation.
Applied to files:
internal/api/ch_settings_test.gointernal/api/structured_query_test.go
📚 Learning: 2026-05-23T01:23:59.268Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 174
File: internal/api/ingest_test.go:111-111
Timestamp: 2026-05-23T01:23:59.268Z
Learning: In WaveHouse Go tests in internal/api/**/*_test.go, use internal/testutil.AssertJSONErrorResponse(t, w) for HTTP error-path JSON assertions. Do not use (or reintroduce) package-local assertJSONErrorResponse helpers. AssertJSONErrorResponse verifies the response Content-Type is application/json, includes the X-Content-Type-Options: nosniff header, and that the JSON body contains an "error" field.
Applied to files:
internal/api/ch_settings_test.gointernal/api/structured_query_test.go
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.
Applied to files:
CHANGELOG.md
🪛 LanguageTool
docs/src/content/docs/access-control.mdx
[typographical] ~307-~307: Insert a space between the numerical value and the unit symbol.
Context: ...et max_memory_usage as a size string ("4GiB", "512MiB" — IEC vs SI is respected,...
(UNIT_SPACE)
[typographical] ~307-~307: In American English, use a period after an abbreviation.
Context: ...size string ("4GiB", "512MiB" — IEC vs SI is respected, so "4GB" is 4×10⁹ an...
(MISSING_PERIOD_AFTER_ABBREVIATION)
[typographical] ~307-~307: Insert a space between the numerical value and the unit symbol.
Context: ..."512MiB" — IEC vs SI is respected, so "4GB" is 4×10⁹ and "4GiB" is 4×2³⁰) or a ...
(UNIT_SPACE)
[typographical] ~307-~307: Insert a space between the numerical value and the unit symbol.
Context: ...I is respected, so "4GB" is 4×10⁹ and "4GiB" is 4×2³⁰) or a bare number of **bytes...
(UNIT_SPACE)
[typographical] ~506-~506: Insert a space between the numerical value and the unit symbol.
Context: ...(ClickHouse max_memory_usage). Set as "4GiB"/"512MiB" or a number of bytes; retu...
(UNIT_SPACE)
[typographical] ~506-~506: Insert a space between the numerical value and the unit symbol.
Context: ...se max_memory_usage). Set as "4GiB"/"512MiB" or a number of bytes; returned as byt...
(UNIT_SPACE)
[style] ~506-~506: Consider shortening this phrase to avoid wordiness.
Context: ..._usage). Set as "4GiB"/"512MiB"or a number of bytes; returned as bytes.0` = no role...
(A_NUMBER_OF)
🔇 Additional comments (23)
clients/ts/src/types.ts (1)
215-231: LGTM!docs/src/content/docs/access-control.mdx (6)
296-308: LGTM!
309-334: LGTM!
338-341: LGTM!
348-348: LGTM!Also applies to: 352-352, 355-356
432-432: LGTM!Also applies to: 461-461
503-506: LGTM!docs/src/content/docs/configuration.mdx (4)
52-52: LGTM!
54-60: LGTM!
62-100: LGTM!
223-224: LGTM!Also applies to: 292-292
CHANGELOG.md (1)
26-26: LGTM!internal/policy/policy.go (1)
41-51: LGTM!Also applies to: 74-76, 168-170, 436-443
internal/api/ch_settings_test.go (1)
1-117: LGTM!internal/query/builder.go (1)
15-20: LGTM!Also applies to: 48-48, 126-137
internal/query/builder_test.go (1)
31-875: LGTM!tests/integration/identifier_roundtrip_test.go (1)
151-151: LGTM!Also applies to: 185-185, 210-210, 235-235, 265-265, 271-271
internal/config/config.go (1)
32-46: LGTM!Also applies to: 204-210
internal/config/config_test.go (1)
70-106: LGTM!internal/api/structured_query.go (1)
11-12: LGTM!Also applies to: 30-30, 48-48, 58-58, 117-117, 173-175, 180-197
internal/api/structured_query_test.go (1)
43-43: LGTM!Also applies to: 295-295
cmd/wavehouse/main.go (1)
384-384: LGTM!tests/integration/query_limits_test.go (1)
1-118: LGTM!
…durations Address CodeRabbit review on #335: - scalars.go: reject non-scalar YAML nodes (mapping/sequence) for the Millis/ByteSize cap fields — their empty Value read as 0 and silently disabled the cap instead of failing validation. - scalars.go: widen the sub-millisecond guard from `d > 0` to `d != 0`. Milliseconds() truncates toward zero, so "-500us" collapsed to 0 and slipped past negative-cap validation downstream. - scalars_test.go: convert TestScalars_YAML to table-driven and pin regressions for both edge cases above. - config.yaml: correct the default_max_rows comment — any non-positive value (not just 0) falls back to the built-in 10000. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address docs-reviewer findings on #335: this PR turned the result-LIMIT cap into the tunable `query.default_max_rows` knob, so docs that still described it as a hard-coded 10,000 `DefaultMaxRows` constant were stale. - api.md: `limit` is capped at the configured `query.default_max_rows`. - sdk/queries.md: same — server enforces the configured maximum. - configuration.mdx: note a negative `default_max_rows` is rejected at startup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs-reviewer [MAY] on #335: the §Resource limits intro said `0`/unset means only server-wide ClickHouse limits apply, but for `max_rows` the `query.default_max_rows` result default (10,000) still clamps the LIMIT. The field-reference row already stated this; make the intro agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o comments The IEC/SI byte-size note used Unicode superscript characters (4×10⁹, 4×2³⁰) that render inconsistently. Use proper <sup> tags in the rendered MDX, and plain ASCII ^ notation in the Go doc comments (which are read as source text, not HTML). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Closes #316. The native ClickHouse connection opened with no per-query
Settings, so a public read's policy resource caps were only ever enforced client-side — a Gocontextdeadline (which cancels only while the client is reading result blocks) plus a SQLLIMIT. Memory and rows scanned were never bounded server-side, so a heavy aggregation could allocate gigabytes of state or scan an entire table within the time budget; andclickhouse-goonly derivesmax_execution_timefrom a context deadline> 1s, so a sub-second time cap reached ClickHouse with no server-side bound at all.This attaches per-query
clickhouse.Settingsderived from the role's caps so a query can't outrun its budget during a server-side scan / merge / aggregation phase.Where the line sits (WaveHouse vs ClickHouse)
The design draws a deliberate boundary:
users.xmlexamples) and how the two layers compose (areadonly/<constraints>lock on WaveHouse's CH user will reject its per-query overrides).So there's no WaveHouse global resource knob — just the per-role policy caps plus one query-shaping default (
query.default_max_rows, the formerly hard-codedDefaultMaxRows = 10000, now tunable).Enforcement
internal/api/ch_settings.gobuilds the per-query settings; wired into the structured-query handler:max_execution_timemax_execution_time(fractional seconds — emitted explicitly so the sub-second case is enforced)max_rowsmax_result_rows+result_overflow_mode=throw(defense-in-depth behind the SQLLIMIT)max_rows_to_readmax_rows_to_read+read_overflow_mode=throwmax_memory_usagemax_memory_usageA query that exceeds its budget is rejected by the server (ClickHouse codes 158 / 241). Admin resolves to no caps → no settings sent (bounded only by ClickHouse's own config).
Policy field ergonomics (human-in / number-out)
The two human-scale fields accept a friendly string or a number on input and always return the canonical number on read-back, so SDKs never reimplement humanization:
max_execution_time— set as"5s"/"500ms"or a bare number of milliseconds; returned as ms.max_memory_usage— set as"4GiB"/"512MiB"(IEC vs SI respected viadustin/go-humanize, so4GB= 4×10⁹ ≠4GiB= 4×2³⁰) or a bare number of bytes; returned as bytes.This is backed by two small
Millis/ByteSizetypes in thepolicypackage — no new package.Tests
configparse + validation; the configurable builder default;policyEvaluatecarry-through + negative validation.viewerwithmax_rows_to_read=1/max_memory_usage=1is rejected server-side (CH codes 158 / 241); the same-shape uncapped control returns all rows. Verified RED before the fix.Docs
access-control.mdx(per-role caps, human-in/number-out, "server-wide limits live in ClickHouse") andconfiguration.mdx(new "Server-side resource limits" section with ClickHouse profile/quota XML + the composition caution);config.yaml, the SDKRolePermissionstype, and the CHANGELOG updated.make cigreen locally (unit + integration + e2e + coverage); pre-push code + docs reviewers: ship_it.🤖 Generated with Claude Code